Author SHA1 Message Date
lucast b483ad84e4 Add album table search and pagination 2026-10-06 03:03:42 +02:00
lucast 900b2b8abf Add artist table search and pagination 2026-10-06 03:03:41 +02:00
lucast 12031cb481 Add genre table search and pagination 2026-10-06 03:03:39 +02:00
lucast 00b482e62a Fade between table pages 2026-10-06 03:02:55 +02:00
lucast 70ef737f19 Paginate songs admin table 2026-10-06 03:02:49 +02:00
lucast 1ffe483e70 Use themed search clear button 2026-10-06 03:00:22 +02:00
lucast 53aa702758 Enable songs table search 2026-10-06 03:00:20 +02:00
lucast 148c5b267d Handle loading table data 2026-10-06 03:00:17 +02:00
lucast 24d4f2e577 Added table search 2026-10-06 02:52:56 +02:00
lucast d9e1cb1c6f Added table pagination 2026-10-06 02:52:26 +02:00
lucast 440b6ef817 Align H3 handler helper imports 2026-10-06 02:50:04 +02:00
lucast c70b840520 Guard player frequency samples 2026-10-06 02:47:37 +02:00
lucast 4d4c0f0e3a Handle empty search query arrays 2026-10-06 02:47:23 +02:00
lucast 7d42c6edb1 Handle unset synced lyrics height 2026-10-06 02:47:04 +02:00
lucast 95f6199cea Render admin field keys as labels 2026-10-06 02:46:42 +02:00
lucast d76ecf6063 Type text input events 2026-10-06 02:46:25 +02:00
lucast 379acf400c Handle optional API data in pages 2026-10-06 02:46:01 +02:00
lucast a6485e5c73 Guard palette array indexing 2026-10-06 02:45:08 +02:00
lucast 8f1cc8d4a7 Avoid typed route expansion in API fetch 2026-10-06 02:44:31 +02:00
lucast dec7f46365 Imported file helpers into Vue templates 2026-10-06 02:43:27 +02:00
lucast 58338d0e8d Merge dynamic background 2026-10-06 02:34:50 +02:00
lucast b190d1671c Optimize fullscreen rendering 2026-10-06 02:33:07 +02:00
lucast b696da1854 Move fullscreen wash to WebGPU 2026-10-06 02:11:56 +02:00
lucast ebd6fedd89 Cap fullscreen pulse brightness 2026-10-06 01:56:46 +02:00
lucast d07221f600 Adapt fullscreen beat response 2026-10-05 22:05:08 +02:00
lucast 27c289483b Tune fullscreen beat response 2026-10-05 21:48:48 +02:00
lucast 1d6422f422 Reduce banding in fullscreen gradient 2026-10-05 21:19:13 +02:00
lucast ffdb159d57 Randomize fullscreen gradient motion 2026-10-05 21:14:46 +02:00
lucast 3001267393 Use isolation style fullscreen gradient 2026-10-05 21:11:05 +02:00
lucast 29b6418e2c Refine fullscreen album color wash 2026-10-05 20:59:41 +02:00
lucast a1e993facb Add album color fullscreen background 2026-10-05 20:44:47 +02:00
lucast 7a80ac8f3f First try 2026-10-05 19:01:45 +02:00
lucast 7ae68592a2 Bundle Vue in server build 2026-10-05 18:38:02 +02:00
lucast 84658e4499 Added saved artists page 2026-10-05 18:28:47 +02:00
lucast 8df84bce86 Added saved albums page 2026-10-05 18:22:01 +02:00
lucast af07bf2249 Added library track menus 2026-10-05 18:20:34 +02:00
lucast 0526e83a9b Added saved library API 2026-10-05 17:54:29 +02:00
lucast 36d05ab04f Set up Golar typechecking 2026-10-05 17:52:52 +02:00
lucast 1eef02ccfa Upgraded Nuxt 2026-10-05 17:35:43 +02:00
lucast e908ae27ad Added song loading state 2026-10-05 03:46:47 +02:00
lucast 869ca7d728 Added track navigation menu items 2026-10-05 02:49:24 +02:00
lucast 51114d65c8 Handle unsynced lyrics marked synced 2026-10-05 02:22:06 +02:00
lucast fb99058492 Allow bearer auth for admin API 2026-10-05 02:01:40 +02:00
lucast 142e34babe Return session token 2026-10-05 01:54:37 +02:00
lucast 45ae19149f Refresh lyrics position on open 2026-10-05 01:10:53 +02:00
lucast bdfa76f993 Reset title when queue ends 2026-10-05 00:26:34 +02:00
lucast 30541eab8d Cache album slider data 2026-10-04 22:01:34 +02:00
lucast 3393c133fd Update readme 2026-10-04 21:52:07 +02:00
lucast e89463591c Use musicSession cookie for auth 2026-10-02 00:20:38 +02:00
lucast e2c47e6d0f Gate debug guide and disclaimer by cookie 2026-09-30 05:18:25 +02:00
lucast bfaa4acb25 Align fullscreen lyrics across line wraps 2026-09-30 05:18:08 +02:00
lucast 4caa9135ee Fixed fullscreen lyrics alignment 2026-09-30 04:32:38 +02:00
lucast cc0623ef4a Add page titles 2026-09-30 01:50:01 +02:00
lucast e2e2fc7755 Enable progressive audio playback 2026-09-30 01:30:30 +02:00
lucast e02cb83235 Fixed model registration 2026-09-30 01:22:49 +02:00
lucast 492a54dd0d Upload processed audio tracks to S3 2026-09-29 17:03:19 +02:00
lucast 0d09198e57 Store artist and album artwork in S3 2026-09-29 17:03:08 +02:00
lucast c2dcec2c31 Add S3 storage configuration and client 2026-09-29 17:02:57 +02:00
lucast 39cb48a656 Remove legacy static media route 2026-09-29 16:22:53 +02:00
lucast 069ef37825 Build media URLs from public bucket config 2026-09-29 16:22:22 +02:00
lucast 19e84d774c Add bucket backed media reads 2026-09-29 16:22:22 +02:00
lucast 8cd0d5ca89 Update API schema and deployment guidance 2026-09-29 15:57:39 +02:00
lucast 0cf29e0e06 Harden Nitro runtime and health handling 2026-09-29 15:57:31 +02:00
lucast f377424d3a Normalize protected read API responses 2026-09-29 15:57:25 +02:00
lucast a2b8dec813 Normalize admin API auth responses 2026-09-29 15:57:14 +02:00
lucast 31e6c0c5d2 Harden cookie authentication 2026-09-29 15:57:07 +02:00
lucast a0a62c1c06 Removed obsolete backend dependencies 2026-09-29 15:32:08 +02:00
lucast 47db7abd6a Switched frontend API calls to Nuxt 2026-09-29 13:33:48 +02:00
lucast 4621a81384 Added API schema and docs routes 2026-09-29 01:54:51 +02:00
lucast bca41b5e6f Migrated admin and media APIs to Nitro 2026-09-29 01:22:29 +02:00
lucast ee5809a489 Migrated read APIs to Nitro 2026-09-29 00:34:50 +02:00
lucast eb4e9f0700 Migrated auth to Nitro 2026-09-29 00:23:30 +02:00
lucast 27feb1ce95 Smooth lyrics fade 2026-09-26 22:27:15 +02:00
lucast e50648e35f Moved lyrics a bit down 2026-09-26 22:19:22 +02:00
lucast b5f7aa1372 Add media session controls 2026-09-26 22:18:33 +02:00
lucast 5e3f3ef6a2 Fix single track artist fields 2026-09-26 00:21:59 +02:00
lucast 661b9341d7 Add backend dependencies 2026-09-26 00:14:00 +02:00
115 changed files with 12826 additions and 3271 deletions

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
# Copy this file to .env for local development. Use real, private values locally.
NUXT_MONGODB_URI=mongodb://127.0.0.1:27017/music-v2
NUXT_PUBLIC_MEDIA_BASE_URL=https://s3-seaweedfs.lucasskt.dk/music
NUXT_S3_ENDPOINT=https://s3-seaweedfs.lucasskt.dk
NUXT_S3_BUCKET=music
NUXT_S3_REGION=us-east-1
NUXT_S3_ACCESS_KEY_ID=
NUXT_S3_SECRET_ACCESS_KEY=
NUXT_TOKEN_EXPIRE=1h
+1 -1
View File
@@ -53,4 +53,4 @@ Pull requests should include:
## Security & Configuration Tips
The app reads `VITE_PUBLIC_BACKEND` from `.env` and expects remote assets from `api.music.lucasskt.dk`. You can get the API schema from `https://api.music.lucasskt.dk/schema`. Do not commit secrets or machine-specific values.
The frontend and backend are served by this Nuxt application; API requests are same-origin and `VITE_PUBLIC_BACKEND` is no longer used. Private runtime settings are `NUXT_MONGODB_URI`, `NUXT_UPLOAD_DIR`, and optional `NUXT_TOKEN_EXPIRE`; see `.env.example` and `README.md`. Production requires the Node Nitro server, persistent writable upload storage, and `ffmpeg`/`ffprobe` on `PATH`. Do not commit secrets or machine-specific values.
+16 -62
View File
@@ -1,75 +1,29 @@
# Nuxt Minimal Starter
# Music v2
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
Yet another music platform.
## Setup
This is a rewrite of my [first version](https://github.com/Sniperhund/Music), everything is contained in this repo instead of v1 (v1 had a separate frontend + backend)<br />
I got tired and irritated by trying to add new features to v1, so here we are nearly 5 months in development.
Make sure to install dependencies:
It's a Nuxt 4 full stack application, hook up a S3 bucket and MongoDB service and you're basically good to go (check .env.example)
```bash
# npm
npm install
This project is mostly a inspiration of Apple Music (my go-to platform) with my personal design choices and the "I can do it" mindset.
# pnpm
pnpm install
### Debug view
# yarn
yarn install
There's a debug view (now only for lyric alignemt), just set `DEBUG_VIEW` to `1`. It will likely be expanded.
# bun
## Local setup
Install dependencies and create a local environment file:
```sh
bun install
cp -n .env.example .env
```
## Development Server
Run the app at <http://localhost:3000>:
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
```sh
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
+31
View File
@@ -1,11 +1,42 @@
<script setup lang="ts">
const { currentSong, isPlaying } = usePlayer()
const debugView = useCookie("DEBUG_VIEW")
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
useHead(
computed(() => ({
titleTemplate:
currentSong.value && isPlaying.value
? `${currentSong.value.name} - Now Playing - Music`
: (titleChunk) => `${titleChunk || "Home"} - Music`,
})),
)
</script>
<template>
<ToastContainer />
<NuxtLayout>
<NuxtPage />
<Player />
</NuxtLayout>
<div v-if="debugViewEnabled" class="debug-view-disclaimer">
Debug view: everything may not align correctly on different screens.
It's made for 1920x1080
</div>
</template>
<style lang="scss">
@use "styles/global.scss";
.debug-view-disclaimer {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 200;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
background: rgba(0, 0, 0, 0.7);
color: white;
font-size: 0.875rem;
}
</style>
+24 -4
View File
@@ -47,6 +47,8 @@ interface ModalFormProps<T> {
fields: Field<T>[]
title?: string
onSave: (value: any) => Promise<boolean>
loading?: boolean
loadingText?: string
}
const props = defineProps<ModalFormProps<any>>()
@@ -128,13 +130,13 @@ watch(
"
v-model:value="localItem[field.key]"
:fetch-options="field.fetchOptions"
:label="field.label || field.key"
:label="field.label || String(field.key)"
:predefined="props.item?.[field.key]"
:multiple="field.type == 'search-select-array'"
/>
<Checkbox
v-else-if="field.type == 'checkbox'"
:label="field.label || field.key"
:label="field.label || String(field.key)"
:model-value="localItem[field.key]"
@change="(v) => (localItem[field.key] = v)"
/>
@@ -142,7 +144,7 @@ watch(
v-else
:type="field.type"
:key="field.key"
:label="field.label || field.key"
:label="field.label || String(field.key)"
v-model:value="localItem[field.key]"
:textarea="field.type == 'textarea'"
:rows="field.type == 'textarea' ? field.rows : 0"
@@ -150,7 +152,10 @@ watch(
/>
</template>
<Button type="submit" full center-text>Save</Button>
<Button type="submit" full center-text :disabled="props.loading">
<span v-if="props.loading" class="spinner" aria-hidden="true" />
{{ props.loading ? props.loadingText || "Saving…" : "Save" }}
</Button>
</form>
</Modal>
</template>
@@ -169,4 +174,19 @@ watch(
flex-direction: column;
gap: 0.8rem;
}
.spinner {
width: 1rem;
height: 1rem;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
+32 -18
View File
@@ -1,27 +1,41 @@
<script setup lang="ts">
const { data: genresData } = await useApiFetch<Genre[]>("/genres/random")
import { GET_FILE } from "@/utils/file"
const data = await Promise.allSettled(
(genresData.value ?? []).map(async (genre: Genre) => {
const res = await cfetch(`/genres/albums/${genre._id}`)
const cacheKey = "album-slider-data"
const data = ref<{ genre: Genre; albums: Album[] }[]>([])
return {
genre,
albums: res.data,
onMounted(async () => {
const cached = sessionStorage.getItem(cacheKey)
if (cached) {
try {
data.value = JSON.parse(cached)
return
} catch {
sessionStorage.removeItem(cacheKey)
}
}),
).then((results) =>
results
}
const genres = await cfetch<Genre[]>("/genres/random")
const results = await Promise.allSettled(
(genres ?? []).map(async (genre) => ({
genre,
albums: await cfetch<Album[]>(`/genres/albums/${genre._id}`),
})),
)
data.value = results
.filter(
(
r,
): r is PromiseFulfilledResult<{
genre: any
albums: any[]
}> => r.status === "fulfilled",
)
.map((r) => r.value),
result,
): result is PromiseFulfilledResult<{
genre: Genre
albums: Album[]
}> => result.status === "fulfilled",
)
.map((result) => result.value)
sessionStorage.setItem(cacheKey, JSON.stringify(data.value))
})
</script>
<template>
@@ -32,7 +46,7 @@ const data = await Promise.allSettled(
v-for="album in item.albums"
:key="album._id"
:name="album.name"
:file="`${BACKEND_SERVE}${album.file}`"
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
/>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { Artist } from "@/utils/types"
const props = defineProps<{ artist: Artist }>()
</script>
<template>
<article class="card">
<NuxtLink :to="`/artist/${props.artist._id}`">
<NuxtImg
:src="GET_FILE(props.artist.file)"
:alt="props.artist.name"
placeholder
/>
<p>{{ props.artist.name }}</p>
</NuxtLink>
</article>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
@use "@/styles/variables" as *;
.card {
@include fontSize(14px);
display: flex;
flex-direction: column;
justify-content: start;
& > a {
& img {
border-radius: $border-radius-standard;
}
& p {
margin-top: 0.3rem;
}
}
}
</style>
+22 -23
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const { fullscreen, close: closeFullscreen } = useFullscreen()
const song = useSong()
const mouseMovedRecently = ref(true)
onMounted(() => {
@@ -15,11 +16,18 @@ onMounted(() => {
mouseMovedRecently.value = false
}, 2000)
}
const handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Escape" && fullscreen.value) {
closeFullscreen()
}
}
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("keydown", handleKeydown)
onUnmounted(() => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("keydown", handleKeydown)
})
})
@@ -37,12 +45,9 @@ nuxtApp.hook("page:finish", () => {
v-show="fullscreen"
>
<template v-if="song">
<NuxtImg
<PlayerFullscreenBackgroundWash
:src="GET_FILE(song.album.file)"
:alt="song.name"
width="500"
height="500"
class="background"
:active="fullscreen"
/>
</template>
@@ -54,11 +59,16 @@ nuxtApp.hook("page:finish", () => {
@click="closeFullscreen()"
/>
<div class="content-container" :class="{ lyrics: song.lyrics?.text }">
<PlayerFullscreenTrackDisplay :show-buttons="mouseMovedRecently" />
<div
class="content-container"
:class="{ lyrics: song.lyrics?.text }"
>
<PlayerFullscreenTrackDisplay
:show-buttons="mouseMovedRecently"
/>
<PlayerFullscreenLyricsDisplay
:animation-duration="0.2"
:offset="150"
:offset="250"
:show-scroll-bar="mouseMovedRecently"
/>
</div>
@@ -72,8 +82,8 @@ nuxtApp.hook("page:finish", () => {
top: 0;
left: 0;
width: 200vw;
height: 200vh;
width: 100vw;
height: 100vh;
z-index: 150;
cursor: none;
@@ -83,17 +93,6 @@ nuxtApp.hook("page:finish", () => {
}
}
.background {
position: absolute;
top: -25%;
left: -25%;
width: 100%;
height: 100%;
object-fit: cover;
filter: blur(40px);
}
.screen-container {
position: fixed;
top: 0;
@@ -102,7 +101,7 @@ nuxtApp.hook("page:finish", () => {
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.45);
background-color: rgba(0, 0, 0, 0.34);
}
.close-btn {
@@ -0,0 +1,218 @@
<script setup lang="ts">
import {
extractPalette,
type AlbumPalette,
} from "./palette"
import { createCpuRenderer } from "./renderers/cpu"
import { createWebGpuRenderer } from "./renderers/webgpu"
import type { BackgroundFrame, BackgroundRenderer } from "./renderers/types"
import { shapeBeatPulse } from "~/utils/beat"
const props = defineProps<{ src: string; active: boolean }>()
const ANIMATION_SPEED = 0.3
const FRAME_RATE = 12
const FRAME_INTERVAL = 1000 / FRAME_RATE
const { beatEnergy } = usePlayer()
const cpuCanvas = ref<HTMLCanvasElement | null>(null)
const webGpuCanvas = ref<HTMLCanvasElement | null>(null)
const palette = ref<AlbumPalette | null>(null)
const backend = ref<"cpu" | "webgpu">("cpu")
let frameTimer: number | null = null
let startedAt = 0
let random: [number, number] = [0, 0]
let angleJitter = 0
let flowParams: [number, number, number, number] = [5, 25, 0.75, -0.08]
let cpuRenderer: BackgroundRenderer | null = null
let webGpuRenderer: BackgroundRenderer | null = null
let activeRenderer: BackgroundRenderer | null = null
let disposed = false
const fallback: AlbumPalette = { dominant: [30, 35, 55], accent: [105, 65, 45] }
function loadArtwork() {
const image = new Image()
image.crossOrigin = "anonymous"
image.onload = () => {
try {
palette.value = extractPalette(image)
} catch {
palette.value = fallback
}
}
image.onerror = () => {
palette.value = fallback
}
image.src = props.src
}
function randomizeMotion() {
random = [Math.random() * Math.PI * 2, Math.random() * Math.PI * 2]
angleJitter = (Math.random() - 0.5) * 0.3
flowParams = [
4.5 + Math.random(),
22 + Math.random() * 7,
(0.65 + Math.random() * 0.2) * (Math.random() < 0.5 ? -1 : 1),
((-5 + (Math.random() - 0.5) * 12) * Math.PI) / 180,
]
}
function useCpuFallback() {
if (!cpuRenderer) return
activeRenderer = cpuRenderer
backend.value = "cpu"
}
async function initializeRenderers() {
const cpuElement = cpuCanvas.value
const gpuElement = webGpuCanvas.value
if (!cpuElement || !gpuElement) return
cpuRenderer = createCpuRenderer(cpuElement)
activeRenderer = cpuRenderer
if (!cpuRenderer) {
console.warn("[background renderer] 2D canvas is unavailable")
return
}
let rendererLost = false
const renderer = await createWebGpuRenderer(gpuElement, () => {
rendererLost = true
webGpuRenderer = null
console.warn("[background renderer] WebGPU device lost; using CPU")
useCpuFallback()
})
if (!renderer) return
if (disposed || rendererLost) {
renderer.dispose()
return
}
webGpuRenderer = renderer
activeRenderer = renderer
backend.value = "webgpu"
console.info("[background renderer] using WebGPU")
}
function makeFrame(now: number, canvas: HTMLCanvasElement): BackgroundFrame {
if (!startedAt) startedAt = now
const width = 224
const height = Math.max(
72,
Math.round(
(width * canvas.clientHeight) / Math.max(1, canvas.clientWidth),
),
)
return {
width,
height,
time: ((now - startedAt) / 1000) * ANIMATION_SPEED,
beat: shapeBeatPulse(beatEnergy.value),
random,
angleJitter,
flowParams,
palette: palette.value ?? fallback,
}
}
function updateCanvasScale(canvas: HTMLCanvasElement, beat: number) {
const transform = `scale(${1.08 + beat * 0.003})`
if (canvas.style.transform !== transform) canvas.style.transform = transform
}
function draw(now: number) {
if (!props.active || disposed) return
const canvas = backend.value === "webgpu" ? webGpuCanvas.value : cpuCanvas.value
if (canvas && activeRenderer) {
const renderFrame = makeFrame(now, canvas)
updateCanvasScale(canvas, renderFrame.beat)
try {
activeRenderer.render(renderFrame)
} catch (error) {
if (backend.value === "webgpu") {
console.warn(
"[background renderer] WebGPU render failed; using CPU",
error,
)
webGpuRenderer?.dispose()
webGpuRenderer = null
useCpuFallback()
if (cpuCanvas.value)
updateCanvasScale(cpuCanvas.value, renderFrame.beat)
try {
cpuRenderer?.render(renderFrame)
} catch (fallbackError) {
console.warn("[background renderer] CPU fallback failed", fallbackError)
}
} else {
console.warn("[background renderer] CPU render failed", error)
}
}
}
frameTimer = window.setTimeout(() => {
frameTimer = null
draw(performance.now())
}, FRAME_INTERVAL)
}
function startAnimation() {
if (frameTimer !== null) window.clearTimeout(frameTimer)
frameTimer = null
startedAt = 0
randomizeMotion()
draw(performance.now())
}
watch(() => props.src, loadArtwork)
watch(
() => props.active,
(active) => {
if (active) startAnimation()
else if (frameTimer !== null) {
window.clearTimeout(frameTimer)
frameTimer = null
}
},
)
onMounted(() => {
loadArtwork()
void initializeRenderers()
if (props.active) startAnimation()
})
onBeforeUnmount(() => {
disposed = true
if (frameTimer !== null) window.clearTimeout(frameTimer)
webGpuRenderer?.dispose()
cpuRenderer?.dispose()
})
</script>
<template>
<canvas
ref="webGpuCanvas"
class="background-wash"
:class="{ 'background-wash--active': backend === 'webgpu' }"
aria-hidden="true"
/>
<canvas
ref="cpuCanvas"
class="background-wash"
:class="{ 'background-wash--active': backend === 'cpu' }"
aria-hidden="true"
/>
</template>
<style scoped>
.background-wash {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
filter: blur(6px) saturate(1.08);
transform: scale(1.08);
visibility: hidden;
}
.background-wash--active {
visibility: visible;
}
</style>
@@ -0,0 +1,161 @@
type RGB = [number, number, number]
type Lab = [number, number, number]
export interface AlbumPalette {
dominant: RGB
accent: RGB
}
export function toOkLab([red, green, blue]: RGB): Lab {
const linear = (value: number) => {
const channel = value / 255
return channel <= 0.04045
? channel / 12.92
: ((channel + 0.055) / 1.055) ** 2.4
}
const r = linear(red)
const g = linear(green)
const b = linear(blue)
const l = Math.cbrt(
0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b,
)
const m = Math.cbrt(
0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b,
)
const s = Math.cbrt(
0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b,
)
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
]
}
export function fromOkLab([lightness, a, b]: Lab): RGB {
const lRoot = lightness + 0.3963377774 * a + 0.2158037573 * b
const mRoot = lightness - 0.1055613458 * a - 0.0638541728 * b
const sRoot = lightness - 0.0894841775 * a - 1.291485548 * b
const l = lRoot ** 3
const m = mRoot ** 3
const s = sRoot ** 3
const encode = (value: number) => {
const channel =
value <= 0.0031308
? 12.92 * value
: 1.055 * Math.max(value, 0) ** (1 / 2.4) - 0.055
return Math.max(0, Math.min(1, channel)) * 255
}
return [
encode(
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
),
encode(
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
),
encode(
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
),
]
}
/** Sample a small CPU histogram and return the most common album colors. */
export function extractPalette(image: HTMLImageElement, size = 48): AlbumPalette {
const canvas = document.createElement("canvas")
canvas.width = size
canvas.height = size
const context = canvas.getContext("2d", { willReadFrequently: true })
if (!context) {
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
}
context.drawImage(image, 0, 0, size, size)
const pixels = context.getImageData(0, 0, size, size).data
const bins = new Map<number, { count: number; r: number; g: number; b: number }>()
for (let i = 0; i < pixels.length; i += 4) {
const red = pixels[i]!
const green = pixels[i + 1]!
const blue = pixels[i + 2]!
if (pixels[i + 3]! < 96) continue
const r = red >> 3
const g = green >> 3
const b = blue >> 3
const key = (r << 10) | (g << 5) | b
const bin = bins.get(key) ?? { count: 0, r: 0, g: 0, b: 0 }
bin.count++
bin.r += red
bin.g += green
bin.b += blue
bins.set(key, bin)
}
const entries = [...bins.values()].map((bin) => ({
count: bin.count,
color: [bin.r / bin.count, bin.g / bin.count, bin.b / bin.count] as RGB,
}))
if (!entries.length) {
return { dominant: [30, 35, 55], accent: [105, 65, 45] }
}
const distance = (a: RGB, b: RGB) => {
const first = toOkLab(a)
const second = toOkLab(b)
return (first[0] - second[0]) ** 2 + (first[1] - second[1]) ** 2 + (first[2] - second[2]) ** 2
}
const centers: RGB[] = [entries.reduce((best, entry) => entry.count > best.count ? entry : best).color]
while (centers.length < 4) {
let candidate = entries[0]!
let bestScore = -1
for (const entry of entries) {
const nearestDistance = Math.min(...centers.map((center) => distance(entry.color, center)))
const score = nearestDistance * Math.sqrt(entry.count)
if (score > bestScore) {
bestScore = score
candidate = entry
}
}
centers.push(candidate.color)
}
let clusterSupport = centers.map(() => 0)
for (let iteration = 0; iteration < 8; iteration++) {
const clusters = centers.map(() => ({ count: 0, red: 0, green: 0, blue: 0 }))
for (const entry of entries) {
let nearest = 0
let nearestDistance = Infinity
for (let i = 0; i < centers.length; i++) {
const nextDistance = distance(entry.color, centers[i]!)
if (nextDistance < nearestDistance) {
nearest = i
nearestDistance = nextDistance
}
}
const cluster = clusters[nearest]!
cluster.count += entry.count
cluster.red += entry.color[0] * entry.count
cluster.green += entry.color[1] * entry.count
cluster.blue += entry.color[2] * entry.count
}
for (let i = 0; i < clusters.length; i++) {
const cluster = clusters[i]!
if (cluster.count) centers[i] = [cluster.red / cluster.count, cluster.green / cluster.count, cluster.blue / cluster.count]
}
clusterSupport = clusters.map((cluster) => cluster.count)
}
const dominantIndex = clusterSupport.reduce(
(best, count, index) => count > clusterSupport[best]! ? index : best,
0,
)
const dominant = centers[dominantIndex]!
const distinctCenters = centers.filter((center) => distance(center, dominant) > 0.002)
const candidates = distinctCenters.length ? distinctCenters : centers
const accent = candidates.reduce((best, color) => {
const index = centers.indexOf(color)
const [_, a, b] = toOkLab(color)
const chroma = Math.hypot(a, b)
const score = chroma * Math.sqrt(clusterSupport[index] || 1)
const [__, bestA, bestB] = toOkLab(best)
const bestScore = Math.hypot(bestA, bestB) * Math.sqrt(clusterSupport[centers.indexOf(best)] || 1)
return score > bestScore ? color : best
})
return { dominant, accent }
}
@@ -0,0 +1,116 @@
import { fromOkLab, toOkLab } from "../palette"
import type { BackgroundFrame, BackgroundRenderer } from "./types"
function gradientHash(x: number, y: number): [number, number] {
const hash = (a: number, b: number, xScale: number, yScale: number) => {
const value = Math.sin(a * xScale + b * yScale) * 43758.5453
return (value - Math.floor(value)) * 2 - 1
}
return [hash(x, y, 127.1, 311.7), hash(x, y, 269.5, 183.3)]
}
function gradientNoise(x: number, y: number) {
const cellX = Math.floor(x)
const cellY = Math.floor(y)
const offsetX = x - cellX
const offsetY = y - cellY
const easeX = offsetX * offsetX * (3 - 2 * offsetX)
const easeY = offsetY * offsetY * (3 - 2 * offsetY)
const dot = (cx: number, cy: number, ox: number, oy: number) => {
const [gx, gy] = gradientHash(cx, cy)
return gx * ox + gy * oy
}
const lower =
dot(cellX, cellY, offsetX, offsetY) * (1 - easeX) +
dot(cellX + 1, cellY, offsetX - 1, offsetY) * easeX
const upper =
dot(cellX, cellY + 1, offsetX, offsetY - 1) * (1 - easeX) +
dot(cellX + 1, cellY + 1, offsetX - 1, offsetY - 1) * easeX
return 0.5 + 0.5 * (lower * (1 - easeY) + upper * easeY)
}
function smoothstep(edge0: number, edge1: number, value: number) {
const amount = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)))
return amount * amount * (3 - 2 * amount)
}
function ditherNoise(x: number, y: number, channel: number) {
const value = Math.sin(
(x + channel * 17) * 127.1 + (y + channel * 59) * 311.7,
) * 43758.5453
return value - Math.floor(value) - 0.5
}
function flowPoint(
x: number,
y: number,
frame: BackgroundFrame,
): [number, number] {
const { random, angleJitter, flowParams, time } = frame
const degree = gradientNoise(
time * 0.1 + random[0] * 0.07,
x * y + random[1] * 0.07,
)
const angle = (((degree - 0.5) * 720 + 180) * Math.PI) / 180 + angleJitter
const sine = Math.sin(angle)
const cosine = Math.cos(angle)
let flowX = x * cosine - y * sine
let flowY = x * sine + y * cosine
const speed = time * flowParams[2]
flowX += Math.sin(flowY * flowParams[0] + speed) / flowParams[1]
flowY +=
Math.sin(flowX * flowParams[0] * 1.5 + speed) / (flowParams[1] * 0.5)
const tiltSine = Math.sin(flowParams[3])
const tiltCosine = Math.cos(flowParams[3])
return [
flowX * tiltCosine - flowY * tiltSine,
flowX * tiltSine + flowY * tiltCosine,
]
}
export function createCpuRenderer(
canvas: HTMLCanvasElement,
): BackgroundRenderer | null {
const context = canvas.getContext("2d")
if (!context) return null
return {
render(frame) {
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width
canvas.height = frame.height
}
const image = context.createImageData(frame.width, frame.height)
const dominant = toOkLab(frame.palette.dominant)
const accent = toOkLab(frame.palette.accent)
const maxPaletteLightness = Math.max(dominant[0], accent[0])
for (let y = 0; y < frame.height; y++) {
for (let x = 0; x < frame.width; x++) {
const px = x / (frame.width - 1)
const py = y / (frame.height - 1)
const point = flowPoint(px - 0.5, py - 0.5, frame)
const blend = smoothstep(-0.34, 0.16, point[0])
const transition = 1 - Math.abs(blend * 2 - 1)
const lab: [number, number, number] = [
dominant[0] * (1 - blend) + accent[0] * blend,
dominant[1] * (1 - blend) + accent[1] * blend,
dominant[2] * (1 - blend) + accent[2] * blend,
]
lab[0] = Math.min(maxPaletteLightness, lab[0] + frame.beat * 0.01)
lab[1] *= 1 + frame.beat * 0.02
lab[2] *= 1 + frame.beat * 0.02
const color = fromOkLab(lab)
const offset = (y * frame.width + x) * 4
image.data[offset] = color[0] + ditherNoise(x, y, 0) * transition * 3
image.data[offset + 1] =
color[1] + ditherNoise(x, y, 1) * transition * 3
image.data[offset + 2] =
color[2] + ditherNoise(x, y, 2) * transition * 3
image.data[offset + 3] = 255
}
}
context.putImageData(image, 0, 0)
},
dispose() {},
}
}
@@ -0,0 +1,133 @@
struct Params {
resolutionTimeBeat: vec4f,
randomAndJitter: vec4f,
flowParams: vec4f,
dominant: vec4f,
accent: vec4f,
}
@group(0) @binding(0) var<uniform> params: Params;
fn hash(a: f32, b: f32, xScale: f32, yScale: f32) -> f32 {
let value = sin(a * xScale + b * yScale) * 43758.5453;
return fract(value) * 2.0 - 1.0;
}
fn gradientHash(cell: vec2f) -> vec2f {
return vec2f(
hash(cell.x, cell.y, 127.1, 311.7),
hash(cell.x, cell.y, 269.5, 183.3),
);
}
fn gradientDot(cell: vec2f, offset: vec2f) -> f32 {
return dot(gradientHash(cell), offset);
}
fn gradientNoise(point: vec2f) -> f32 {
let cell = floor(point);
let offset = fract(point);
let ease = offset * offset * (3.0 - 2.0 * offset);
let lower = mix(
gradientDot(cell, offset),
gradientDot(cell + vec2f(1.0, 0.0), offset - vec2f(1.0, 0.0)),
ease.x,
);
let upper = mix(
gradientDot(cell + vec2f(0.0, 1.0), offset - vec2f(0.0, 1.0)),
gradientDot(cell + vec2f(1.0, 1.0), offset - vec2f(1.0, 1.0)),
ease.x,
);
return 0.5 + 0.5 * mix(lower, upper, ease.y);
}
fn flowPoint(point: vec2f, time: f32) -> vec2f {
let degree = gradientNoise(vec2f(
time * 0.1 + params.randomAndJitter.x * 0.07,
point.x * point.y + params.randomAndJitter.y * 0.07,
));
let angle = ((degree - 0.5) * 720.0 + 180.0) * 0.017453292519943295
+ params.randomAndJitter.z;
let sine = sin(angle);
let cosine = cos(angle);
var flow = vec2f(
point.x * cosine - point.y * sine,
point.x * sine + point.y * cosine,
);
let speed = time * params.flowParams.z;
flow.x += sin(flow.y * params.flowParams.x + speed) / params.flowParams.y;
flow.y += sin(flow.x * params.flowParams.x * 1.5 + speed)
/ (params.flowParams.y * 0.5);
let tiltSine = sin(params.flowParams.w);
let tiltCosine = cos(params.flowParams.w);
return vec2f(
flow.x * tiltCosine - flow.y * tiltSine,
flow.x * tiltSine + flow.y * tiltCosine,
);
}
fn linearToSrgb(value: f32) -> f32 {
if (value <= 0.0031308) {
return 12.92 * value;
}
return 1.055 * pow(max(value, 0.0), 1.0 / 2.4) - 0.055;
}
fn fromOkLab(lab: vec3f) -> vec3f {
let lRoot = lab.x + 0.3963377774 * lab.y + 0.2158037573 * lab.z;
let mRoot = lab.x - 0.1055613458 * lab.y - 0.0638541728 * lab.z;
let sRoot = lab.x - 0.0894841775 * lab.y - 1.291485548 * lab.z;
let l = lRoot * lRoot * lRoot;
let m = mRoot * mRoot * mRoot;
let s = sRoot * sRoot * sRoot;
let red = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
let green = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
let blue = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
return clamp(vec3f(
linearToSrgb(red),
linearToSrgb(green),
linearToSrgb(blue),
), vec3f(0.0), vec3f(1.0));
}
fn ditherNoise(x: f32, y: f32, channel: f32) -> f32 {
let value = sin(
(x + channel * 17.0) * 127.1 + (y + channel * 59.0) * 311.7,
) * 43758.5453;
return fract(value) - 0.5;
}
@vertex
fn vertexMain(@builtin(vertex_index) index: u32) -> @builtin(position) vec4f {
var positions = array<vec2f, 3>(
vec2f(-1.0, -1.0),
vec2f(3.0, -1.0),
vec2f(-1.0, 3.0),
);
return vec4f(positions[index], 0.0, 1.0);
}
@fragment
fn fragmentMain(@builtin(position) position: vec4f) -> @location(0) vec4f {
let dimensions = params.resolutionTimeBeat.xy;
let time = params.resolutionTimeBeat.z;
let beat = params.resolutionTimeBeat.w;
let uv = position.xy / dimensions;
let point = flowPoint(uv - vec2f(0.5), time);
let blend = smoothstep(-0.34, 0.16, point.x);
let transition = 1.0 - abs(blend * 2.0 - 1.0);
var lab = mix(params.dominant.xyz, params.accent.xyz, blend);
lab.x = min(params.dominant.w, lab.x + beat * 0.01);
lab.y = lab.y * (1.0 + beat * 0.02);
lab.z = lab.z * (1.0 + beat * 0.02);
let color = fromOkLab(lab);
let pixel = floor(position.xy);
let noise = vec3f(
ditherNoise(pixel.x, pixel.y, 0.0),
ditherNoise(pixel.x, pixel.y, 1.0),
ditherNoise(pixel.x, pixel.y, 2.0),
);
let dithered = clamp(color + noise * transition * (3.0 / 255.0),
vec3f(0.0), vec3f(1.0));
return vec4f(dithered, 1.0);
}
@@ -0,0 +1,17 @@
import type { AlbumPalette } from "../palette"
export interface BackgroundFrame {
width: number
height: number
time: number
beat: number
random: [number, number]
angleJitter: number
flowParams: [number, number, number, number]
palette: AlbumPalette
}
export interface BackgroundRenderer {
render(frame: BackgroundFrame): void
dispose(): void
}
@@ -0,0 +1,188 @@
import { toOkLab } from "../palette"
import shader from "./shader.wgsl?raw"
import type { BackgroundFrame, BackgroundRenderer } from "./types"
const uniformUsage = 0x0040 | 0x0008
interface GpuBuffer {
destroy(): void
}
interface GpuPass {
setPipeline(pipeline: unknown): void
setBindGroup(index: number, bindGroup: unknown): void
draw(vertexCount: number, instanceCount?: number): void
end(): void
}
interface GpuDevice {
queue: {
writeBuffer(buffer: GpuBuffer, offset: number, data: Float32Array): void
submit(commands: unknown[]): void
}
lost: Promise<{ message: string }>
createBuffer(descriptor: { size: number; usage: number }): GpuBuffer
createShaderModule(descriptor: { code: string }): {
getCompilationInfo(): Promise<{
messages: Array<{ type: string; message: string }>
}>
}
createRenderPipeline(descriptor: unknown): {
getBindGroupLayout(index: number): unknown
}
createBindGroup(descriptor: unknown): unknown
createCommandEncoder(): {
beginRenderPass(descriptor: unknown): GpuPass
finish(): unknown
}
destroy(): void
}
interface GpuAdapter {
requestDevice(): Promise<GpuDevice>
}
interface GpuApi {
requestAdapter(options?: {
powerPreference?: "low-power" | "high-performance"
}): Promise<GpuAdapter | null>
getPreferredCanvasFormat(): string
}
interface GpuCanvasContext {
configure(options: {
device: GpuDevice
format: string
alphaMode: "opaque"
}): void
getCurrentTexture(): { createView(): unknown }
unconfigure(): void
}
export async function createWebGpuRenderer(
canvas: HTMLCanvasElement,
onDeviceLost: () => void,
): Promise<BackgroundRenderer | null> {
let device: GpuDevice | null = null
let context: GpuCanvasContext | null = null
let uniformBuffer: GpuBuffer | null = null
try {
const gpu = (navigator as unknown as { gpu?: GpuApi }).gpu
if (!gpu) {
console.info("[background renderer] WebGPU API unavailable; using CPU")
return null
}
const adapter = await gpu.requestAdapter({
powerPreference: "high-performance",
})
if (!adapter) {
console.info("[background renderer] no WebGPU adapter; using CPU")
return null
}
device = await adapter.requestDevice()
const rawContext = (canvas.getContext as (kind: string) => unknown).call(
canvas,
"webgpu",
)
if (!rawContext) {
device.destroy()
return null
}
context = rawContext as GpuCanvasContext
const format = gpu.getPreferredCanvasFormat()
context.configure({ device, format, alphaMode: "opaque" })
const shaderModule = device.createShaderModule({ code: shader })
const compilation = await shaderModule.getCompilationInfo()
const shaderErrors = compilation.messages.filter(
(message) => message.type === "error",
)
if (shaderErrors.length) {
throw new Error(shaderErrors.map((message) => message.message).join("\n"))
}
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: { module: shaderModule, entryPoint: "vertexMain" },
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [{ format }],
},
primitive: { topology: "triangle-list" },
})
uniformBuffer = device.createBuffer({ size: 80, usage: uniformUsage })
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{ binding: 0, resource: { buffer: uniformBuffer } }],
})
const activeDevice = device
const activeContext = context
const activeBuffer = uniformBuffer
const uniforms = new Float32Array(20)
let lastPalette: BackgroundFrame["palette"] | null = null
let dominant: [number, number, number] = [0, 0, 0]
let accent: [number, number, number] = [0, 0, 0]
let maxPaletteLightness = 0
let disposed = false
void device.lost.then(() => {
if (!disposed) onDeviceLost()
})
return {
render(frame: BackgroundFrame) {
if (canvas.width !== frame.width || canvas.height !== frame.height) {
canvas.width = frame.width
canvas.height = frame.height
}
if (lastPalette !== frame.palette) {
dominant = toOkLab(frame.palette.dominant)
accent = toOkLab(frame.palette.accent)
maxPaletteLightness = Math.max(dominant[0], accent[0])
lastPalette = frame.palette
}
uniforms[0] = frame.width
uniforms[1] = frame.height
uniforms[2] = frame.time
uniforms[3] = frame.beat
uniforms[4] = frame.random[0]
uniforms[5] = frame.random[1]
uniforms[6] = frame.angleJitter
uniforms[7] = 0
uniforms.set(frame.flowParams, 8)
uniforms.set(dominant, 12)
uniforms[15] = maxPaletteLightness
uniforms.set(accent, 16)
uniforms[19] = 0
activeDevice.queue.writeBuffer(activeBuffer, 0, uniforms)
const encoder = activeDevice.createCommandEncoder()
const pass = encoder.beginRenderPass({
colorAttachments: [
{
view: activeContext.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
},
],
})
pass.setPipeline(pipeline)
pass.setBindGroup(0, bindGroup)
pass.draw(3, 1)
pass.end()
activeDevice.queue.submit([encoder.finish()])
},
dispose() {
disposed = true
activeContext.unconfigure()
activeBuffer.destroy()
activeDevice.destroy()
},
}
} catch (error) {
console.warn("[background renderer] WebGPU unavailable; using CPU", error)
uniformBuffer?.destroy()
context?.unconfigure()
device?.destroy()
return null
}
}
@@ -7,39 +7,40 @@ const { animationDuration, offset, showScrollBar } = defineProps<{
const song = useSong()
const { fullscreen } = useFullscreen()
const { secondsPlayed } = usePlayer()
const { secondsPlayed, refreshPosition } = usePlayer()
const debugView = useCookie("DEBUG_VIEW")
const debugViewEnabled = computed(() => String(debugView.value ?? "") === "1")
const parsedLyrics = computed(() => {
if (!song.value?.lyrics?.synced) return
if (!song.value?.lyrics?.synced) return []
if (song.value.lyrics.synced) return parseLyrics(song.value.lyrics.text)
return parseLyrics(song.value.lyrics.text)
})
const hasParsedLyrics = computed(() => parsedLyrics.value.length > 0)
const lyricsRefs = useTemplateRef("lyrics")
const findLyricIndex = (time: number) => {
if (!hasParsedLyrics.value) return -1
const activeIndex = computed(() => {
if (!parsedLyrics.value) return -1
const time = secondsPlayed.value
return parsedLyrics.value.findIndex(
const index = parsedLyrics.value.findIndex(
(lyric, i) =>
lyric.time <= time &&
(!parsedLyrics.value![i + 1] ||
parsedLyrics.value![i + 1]!.time > time),
)
return index
}
const activeIndex = computed(() => {
return findLyricIndex(secondsPlayed.value)
})
const scrollActiveIndex = computed(() => {
if (!parsedLyrics.value) return -1
if (!hasParsedLyrics.value) return -1
const time = secondsPlayed.value + animationDuration
let index = parsedLyrics.value.findIndex(
(lyric, i) =>
lyric.time <= time &&
(!parsedLyrics.value![i + 1] ||
parsedLyrics.value![i + 1]!.time > time),
)
let index = findLyricIndex(time)
if (index === -1 && time < parsedLyrics.value[0]!.time) index = 0
if (index >= parsedLyrics.value.length - 1)
index = parsedLyrics.value.length - 2
@@ -49,22 +50,45 @@ const scrollActiveIndex = computed(() => {
const height = ref<number>()
const top = ref(0)
const syncedHeight = ref<number>()
const layoutVersion = ref(0)
const lyricOffsets = ref<number[]>([])
const topFadeExtension = 75
const updateLyricsLayout = () => {
const trackDisplay = document.querySelector(".track-display")
if (!trackDisplay) return
if (trackDisplay) {
height.value = trackDisplay.clientHeight
top.value = trackDisplay.getBoundingClientRect().top
syncedHeight.value = window.innerHeight - top.value - 100
}
const lines = containerRef.value?.children
if (!lines) {
lyricOffsets.value = []
return
}
let accumulatedHeight = 0
lyricOffsets.value = Array.from(lines, (line) => {
const offset = accumulatedHeight
accumulatedHeight += line.getBoundingClientRect().height
return offset
})
}
watch(
fullscreen,
async () => {
if (fullscreen.value) refreshPosition()
await nextTick()
updateLyricsLayout()
if (fullscreen.value) {
requestAnimationFrame(() => {
updateLyricsLayout()
layoutVersion.value++
})
}
},
{ immediate: true },
)
@@ -72,24 +96,25 @@ watch(
onMounted(() => window.addEventListener("resize", updateLyricsLayout))
onUnmounted(() => window.removeEventListener("resize", updateLyricsLayout))
const transformY = computed(() => {
if (activeIndex.value < 0) return 0
let accumulatedHeight = 0
if (!lyricsRefs.value) return 0
for (let i = 0; i < scrollActiveIndex.value; i++) {
const el = lyricsRefs.value[i]
if (el) accumulatedHeight += el.getBoundingClientRect().height
}
return -accumulatedHeight
})
const containerRef = useTemplateRef("container-ref")
const outerRef = useTemplateRef("outer-ref")
const transformY = computed(() => {
layoutVersion.value
const offset = lyricOffsets.value[scrollActiveIndex.value]
return offset === undefined ? 0 : -offset
})
watch(
parsedLyrics,
async () => {
await nextTick()
updateLyricsLayout()
layoutVersion.value++
},
{ flush: "post" },
)
const manualOffset = ref(0)
const isScrolling = ref(false)
@@ -99,7 +124,7 @@ const onWheel = (event: WheelEvent) => {
if (!containerRef.value || !outerRef.value) return
if (!isScrolling.value) {
manualOffset.value = transformY.value + offset
manualOffset.value = finalTransform.value
}
isScrolling.value = true
@@ -109,13 +134,12 @@ const onWheel = (event: WheelEvent) => {
manualOffset.value -= delta
const maxTranslate = offset
const maxTranslate = finalTransform.value + offset
const lowerViewportLimit = Math.max(
outerRef.value.offsetHeight,
window.innerHeight - 300,
)
const minTranslate =
-containerRef.value.scrollHeight + lowerViewportLimit
const minTranslate = -containerRef.value.scrollHeight + lowerViewportLimit
manualOffset.value = Math.min(
maxTranslate,
@@ -134,18 +158,34 @@ const onLyricClick = (time: number) => {
const finalTransform = computed(() => {
if (isScrolling.value) return manualOffset.value
return transformY.value + offset
return transformY.value + offset + topFadeExtension
})
</script>
<template>
<div
v-if="debugViewEnabled"
:style="[
{
position: 'absolute',
top: '455px',
width: '100px',
height: '1px',
backgroundColor: 'red',
right: '900px',
},
{
transform: 'translateY(20px)',
},
]"
/>
<div
ref="outer-ref"
v-if="song && song.lyrics && song.lyrics.synced"
v-if="song && song.lyrics && song.lyrics.synced && hasParsedLyrics"
class="synced-lyrics-container"
:style="{
height: `${syncedHeight}px`,
marginTop: `${top}px`,
height: `${(syncedHeight ?? 0) + topFadeExtension}px`,
marginTop: `${top - topFadeExtension}px`,
}"
@wheel.prevent="onWheel"
>
@@ -160,8 +200,10 @@ const finalTransform = computed(() => {
<p
v-for="(lyric, i) in parsedLyrics"
:key="lyric.time"
:class="{ active: i == scrollActiveIndex }"
ref="lyrics"
:class="{
active: i == activeIndex,
past: i < activeIndex && !isScrolling,
}"
@click="onLyricClick(lyric.time)"
>
{{ lyric.text }}
@@ -210,6 +252,7 @@ const finalTransform = computed(() => {
font-weight: 600;
color: white;
opacity: 0.4;
margin: 0;
padding: 10px 0;
transition:
opacity 0.1s ease,
@@ -226,6 +269,18 @@ const finalTransform = computed(() => {
opacity: 1;
filter: initial;
}
&.past {
opacity: 0;
filter: blur(1.5px);
transition: opacity 0.4s ease;
&:hover {
cursor: default;
opacity: 0;
filter: blur(1.5px);
}
}
}
}
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const { showButtons } = defineProps<{ showButtons: boolean }>()
const song = useSong()
+2
View File
@@ -1,4 +1,6 @@
<script setup lang="tsx">
import { GET_FILE } from "@/utils/file"
const widthFixerRef = useTemplateRef("width-fixer-ref")
const playerRef = useTemplateRef("player-ref")
+53 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { DropdownMenuItem } from "~/ui/DropdownMenu.vue"
interface TrackRowProps {
@@ -7,12 +8,14 @@ interface TrackRowProps {
const { track } = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
const route = useRoute()
const hovering = ref(false)
const toast = useToast()
const { isSaved, toggleSaved } = useTrackLibrary(() => track._id)
const dropdownMenuItems: DropdownMenuItem[][] = [
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
[
{
label: "Play only this",
@@ -34,7 +37,54 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
},
},
],
]
[
{
label:
isSaved.value === null
? "Checking Library…"
: isSaved.value
? "Remove from Library"
: "Save to Library",
icon: isSaved.value ? "lucide:bookmark-minus" : "lucide:bookmark-plus",
async onSelect() {
await toggleSaved()
},
},
{
label: "Add to playlist",
icon: "lucide:list-plus",
onSelect() {
toast.show("Playlists are not implemented")
},
},
],
[
{
label: "Go to artist",
icon: "lucide:user-round",
onSelect() {
const artist = track.artists[0]
if (artist) navigateTo(`/artist/${artist._id}`)
},
},
{
label: "Go to album",
icon: "lucide:disc-3",
onSelect() {
navigateTo(`/album/${track.album._id}`)
},
},
],
])
const visibleDropdownMenuItems = computed(() =>
dropdownMenuItems.value.map((group) =>
group.filter(
(item) =>
!(item.label === "Go to album" && route.path.startsWith("/album/")),
),
),
)
</script>
<template>
@@ -62,7 +112,7 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
<ArtistName :artists="track.artists" class="artists" />
</div>
<DropdownMenu :items="dropdownMenuItems">
<DropdownMenu :items="visibleDropdownMenuItems">
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
+58 -4
View File
@@ -18,13 +18,20 @@ interface TrackRowProps {
}
const props = defineProps<TrackRowProps>()
const emit = defineEmits<{ (e: "playAlbumAtIndex"): void }>()
const emit = defineEmits<{
(e: "playAlbumAtIndex"): void
(e: "removeFromLibrary", track: Track): void
}>()
const route = useRoute()
const hovering = ref(false)
const { playAlbum, addToFrontOfQueue, addToQueue } = usePlayer()
const toast = useToast()
const { isSaved, toggleSaved } = useTrackLibrary(() =>
props.queueMode ? undefined : props.track?._id,
)
const dropdownMenuItems: DropdownMenuItem[][] = [
const dropdownMenuItems = computed<DropdownMenuItem[][]>(() => [
[
{
label: "Play only this",
@@ -52,7 +59,54 @@ const dropdownMenuItems: DropdownMenuItem[][] = [
},
},
],
]
[
{
label:
isSaved.value === null
? "Checking Library…"
: isSaved.value
? "Remove from Library"
: "Save to Library",
icon: isSaved.value ? "lucide:bookmark-minus" : "lucide:bookmark-plus",
async onSelect() {
const removed = await toggleSaved()
if (removed && props.track) emit("removeFromLibrary", props.track)
},
},
{
label: "Add to playlist",
icon: "lucide:list-plus",
onSelect() {
toast.show("Playlists are not implemented")
},
},
],
[
{
label: "Go to artist",
icon: "lucide:user-round",
onSelect() {
const artist = props.track?.artists[0]
if (artist) navigateTo(`/artist/${artist._id}`)
},
},
{
label: "Go to album",
icon: "lucide:disc-3",
onSelect() {
if (props.track) navigateTo(`/album/${props.track.album._id}`)
},
},
],
])
const visibleDropdownMenuItems = computed(() =>
dropdownMenuItems.value.map((group) =>
group.filter(
(item) => !(item.label === "Go to album" && route.path.startsWith("/album/")),
),
),
)
const durationFormatted = computed(() => {
if (!props.track) return ""
@@ -140,7 +194,7 @@ const artworkSrc = computed(() => {
class="queue-handle"
aria-hidden="true"
/>
<DropdownMenu v-else-if="props.track" :items="dropdownMenuItems">
<DropdownMenu v-else-if="props.track" :items="visibleDropdownMenuItems">
<Icon name="lucide:ellipsis" class="dropdown-icon" />
</DropdownMenu>
</article>
+15 -16
View File
@@ -6,33 +6,32 @@ export function useApiFetch<DataT = any, ErrorT = any>(
options?: UseFetchOptions<DataT, ErrorT>,
authorize: boolean = true,
): ReturnType<typeof useFetch<DataT, ErrorT>> {
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
if (!baseUrl) throw new Error("BACKEND URL not set")
const { sessionToken, refreshSessionToken } = useAuth()
const { refreshSession } = useAuth()
const defaults: UseFetchOptions<DataT, ErrorT> = {
baseURL: baseUrl,
// The migrated API lives in this Nuxt app under server/api.
baseURL: "/api",
retry: 1,
retryDelay: 0,
retryStatusCodes: [401],
headers:
authorize && sessionToken.value
? { Authorization: `Bearer ${sessionToken.value}` }
: {},
headers: {},
onResponseError: async ({ request, response, options }) => {
if (response.status == 401 && authorize) {
const newToken = await refreshSessionToken()
const refreshed = await refreshSession()
if (!newToken) return
if (!refreshed.ok) return
options.headers = {
...(options.headers as unknown as Record<string, string>),
// @ts-ignore
Authorization: `Bearer ${newToken}`,
if (import.meta.server && refreshed.sessionCookie) {
const headers = new Headers(options.headers as HeadersInit)
const cookies = (headers.get("cookie") ?? "")
.split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => cookie && !cookie.startsWith("musicSession="))
cookies.push(refreshed.sessionCookie)
headers.set("cookie", cookies.join("; "))
options.headers = headers
}
}
},
+74 -123
View File
@@ -1,151 +1,102 @@
import axios, { AxiosError } from "axios"
import { appendResponseHeader } from "h3"
let refreshPromise: Promise<any> | null = null
let waitForPromise: boolean = false
const setCookie = (
name: string,
value: string,
options: {
maxAge?: number
path?: string
sameSite?: "lax" | "strict" | "none"
secure?: boolean
} = {},
) => {
let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
if (options.maxAge !== undefined) {
cookie += `; Max-Age=${options.maxAge}`
}
cookie += `; Path=${options.path ?? "/"}`
cookie += `; SameSite=${options.sameSite ?? "Lax"}`
if (options.secure) cookie += "; Secure"
document.cookie = cookie
}
type SessionRefresh = { ok: boolean; sessionCookie?: string }
const refreshPromises = new WeakMap<object, Promise<SessionRefresh>>()
export const useAuth = () => {
const refreshToken = useCookie<string | null>("refreshToken", {
sameSite: "lax",
})
const nuxtApp = useNuxtApp()
const authenticated = useState("auth:authenticated", () => false)
const sessionToken = useCookie<string | null>("sessionToken", {
sameSite: "lax",
maxAge: 60 * 60, // 60 minutes
})
const signin = async (
email: string,
password: string,
remember: boolean = false,
) => {
const signin = async (email: string, password: string, remember = false) => {
try {
const res = await cfetch(
"/auth/signin",
{
await $fetch("/api/auth/signin", {
method: "POST",
data: {
email,
password,
},
},
false,
)
// NOTE: Quite hacky... this should be changed.
// TODO: Add httpOnly and update the backend to support that.
if (remember) {
setCookie("refreshToken", res.data.refreshToken, {
maxAge: 60 * 60 * 24 * 30, // 30 days
body: { email, password, remember: Boolean(remember) },
})
} else {
setCookie("refreshToken", res.data.refreshToken)
}
//refreshToken.value = res.data.refreshToken
await refreshSessionToken()
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
await navigateTo("/")
} catch (e) {
if (axios.isAxiosError(e) && e.response?.data?.message) {
throw new Error(e.response.data.message)
}
throw new Error("Failed to sign in")
} catch (error: any) {
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign in")
}
}
const signup = async (name: string, email: string, password: string) => {}
const signout = () => {
refreshToken.value = null
sessionToken.value = null
}
const refreshSessionToken = async () => {
if (waitForPromise) {
// Force it to wait up to 50 ms for it to set refreshPromise, hopefully less
for (let i = 0; i < 10; i++) {
if (refreshPromise != null) {
break
}
await new Promise((r) => setTimeout(r, 5))
}
}
waitForPromise = true
if (refreshPromise != null) {
waitForPromise = false
return refreshPromise
}
refreshPromise = cfetch(
"/auth/session",
{
data: {
refreshToken: refreshToken.value,
},
const signup = async (name: string, email: string, password: string) => {
try {
await $fetch("/api/auth/register", {
method: "POST",
},
false,
)
.then((res) => {
const token = res.data.sessionToken
sessionToken.value = token
refreshPromise = null
return token
body: { name, email, password },
})
.catch((e) => {
refreshPromise = null
sessionToken.value = null
return null
if (!(await refreshSession()).ok) throw new Error("Unable to create session")
await navigateTo("/")
} catch (error: any) {
throw new Error(error?.data?.message || error?.statusMessage || "Failed to sign up")
}
}
const signout = async () => {
try {
await $fetch("/api/auth/signout", { method: "POST" })
} finally {
authenticated.value = false
}
}
const refreshSession = async (): Promise<SessionRefresh> => {
const inFlight = refreshPromises.get(nuxtApp)
if (inFlight) return inFlight
const refreshPromise = (async () => {
const response = await $fetch.raw<{ expireAt: string }>(
"/api/auth/session",
{
method: "POST",
headers: import.meta.server
? useRequestHeaders(["cookie"])
: undefined,
},
)
let sessionCookie: string | undefined
const setCookies = import.meta.server ? response.headers.getSetCookie() : []
for (const cookie of setCookies) {
if (!cookie.startsWith("musicSession=")) continue
sessionCookie = cookie.split(";", 1)[0]
}
if (import.meta.server) {
const event = useRequestEvent()
if (event) {
for (const cookie of setCookies) {
appendResponseHeader(event, "set-cookie", cookie)
}
}
}
return { ok: true, sessionCookie }
})()
.then((result) => {
authenticated.value = true
return result
})
.catch(() => {
authenticated.value = false
return { ok: false }
})
.finally(() => {
waitForPromise = false
refreshPromises.delete(nuxtApp)
})
refreshPromises.set(nuxtApp, refreshPromise)
return refreshPromise
}
if (getCurrentInstance()) {
onMounted(() => {
if (refreshToken.value && !sessionToken.value) refreshSessionToken()
})
}
return {
refreshToken,
sessionToken,
authenticated,
signin,
signup,
signout,
refreshSessionToken,
refreshSession,
}
}
+64
View File
@@ -0,0 +1,64 @@
import { cfetch } from "@/utils/cfetch"
import { toValue, type MaybeRefOrGetter } from "vue"
interface TrackMembership {
contains: boolean
}
export const useTrackLibrary = (trackId: MaybeRefOrGetter<string | undefined>) => {
const toast = useToast()
const isSaved = ref<boolean | null>(null)
const saving = ref(false)
const checkMembership = async () => {
const id = toValue(trackId)
if (!id) return
try {
const result = await cfetch<TrackMembership>("/user/tracks/contains", {
params: { id },
})
isSaved.value = result.contains
} catch (error: any) {
toast.show(
error?.data?.message || "Could not check Library status",
"error",
)
}
}
onMounted(checkMembership)
const toggleSaved = async () => {
const id = toValue(trackId)
if (!id || isSaved.value === null || saving.value) return false
const wasSaved = isSaved.value
saving.value = true
try {
await cfetch("/user/tracks", {
method: wasSaved ? "DELETE" : "PATCH",
params: { id },
})
isSaved.value = !wasSaved
toast.show(
wasSaved ? "Removed from Library" : "Saved to Library",
"success",
)
return wasSaved
} catch (error: any) {
toast.show(
error?.data?.message ||
(wasSaved
? "Could not remove song from Library"
: "Could not save song to Library"),
"error",
)
return false
} finally {
saving.value = false
}
}
return { isSaved, saving, toggleSaved }
}
+36 -2
View File
@@ -1,6 +1,40 @@
export default defineNuxtRouteMiddleware((to, from) => {
import { appendResponseHeader } from "h3"
export default defineNuxtRouteMiddleware(async (to) => {
if (to.path.startsWith("/auth")) return
const auth = useAuth()
if (import.meta.client && auth.authenticated.value) return
try {
// Forward the incoming SSR credentials to this endpoint; the browser sends
// same-origin cookies automatically during client navigation.
const requestHeaders = import.meta.server
? useRequestHeaders(["cookie", "authorization"])
: undefined
const response = await $fetch.raw<{
authenticated: boolean
}>("/api/auth/session", { headers: requestHeaders })
const session = response._data
// Nuxt forwards incoming request cookies automatically, but internal SSR
// fetch responses do not forward Set-Cookie to the browser automatically.
if (import.meta.server) {
const event = useRequestEvent()
if (event) {
for (const cookie of response.headers.getSetCookie()) {
appendResponseHeader(event, "set-cookie", cookie)
}
}
}
if (session?.authenticated) {
auth.authenticated.value = true
return
}
} catch {
// Treat unavailable auth state as unauthenticated.
}
if (!auth.refreshToken.value && !to.fullPath.includes("/auth"))
return navigateTo("/auth/signin")
})
+1
View File
@@ -2,6 +2,7 @@
definePageMeta({
layout: "admin",
})
useHead({ title: "Admin" })
</script>
<template>
+5 -5
View File
@@ -14,7 +14,6 @@ const tableRows: Row[] = [
name: "file",
displayName: "Cover",
type: "image",
prefix: `${BACKEND_SERVE}`,
},
{
name: "name",
@@ -33,7 +32,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
const artistFetchOptions = async (q?: string): Promise<Option[]> => {
return artistData.value.map<Option>((g) => ({
return (artistData.value ?? []).map<Option>((g) => ({
label: g.name,
value: g._id,
}))
@@ -41,7 +40,7 @@ const artistFetchOptions = async (q?: string): Promise<Option[]> => {
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
const genreFetchOptions = async (q?: string): Promise<Option[]> => {
return genreData.value.map<Option>((g) => ({
return (genreData.value ?? []).map<Option>((g) => ({
label: g.name,
value: g._id,
}))
@@ -114,7 +113,7 @@ const show = (id?: string) => {
return
}
const curItem: any = data.value.filter((i) => i._id == id)[0]
const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return
@@ -138,12 +137,13 @@ const show = (id?: string) => {
showModal.value = true
}
useHead({ title: "Admin - Albums" })
</script>
<template>
<h1 class="title">Manage albums</h1>
<Table :rows="tableRows" :data="data" class="table">
<Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }">
<div class="action">
<Button @click="show(item._id)">Edit</Button>
+3 -3
View File
@@ -14,7 +14,6 @@ const tableRows: Row[] = [
name: "file",
displayName: "Cover",
type: "image",
prefix: `${BACKEND_SERVE}`,
},
{
name: "name",
@@ -80,7 +79,7 @@ const show = (id?: string) => {
return
}
const curItem: any = data.value.filter((i) => i._id == id)[0]
const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return
@@ -88,12 +87,13 @@ const show = (id?: string) => {
showModal.value = true
}
useHead({ title: "Admin - Artists" })
</script>
<template>
<h1 class="title">Manage artists</h1>
<Table :rows="tableRows" :data="data" class="table">
<Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }">
<div class="action">
<Button @click="show(item._id)">Edit</Button>
+3 -2
View File
@@ -71,7 +71,7 @@ const show = (id?: string) => {
return
}
const curItem: any = data.value.filter((i) => i._id == id)[0]
const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return
@@ -79,12 +79,13 @@ const show = (id?: string) => {
showModal.value = true
}
useHead({ title: "Admin - Genres" })
</script>
<template>
<h1 class="title">Manage genres</h1>
<Table :rows="tableRows" :data="data" class="table">
<Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }">
<div class="action">
<Button @click="show(item._id)">Edit</Button>
+2 -1
View File
@@ -1,3 +1,4 @@
<script setup lang="ts"></script>
<script setup lang="ts">useHead({ title: "Admin" })
</script>
<template></template>
+32 -21
View File
@@ -14,8 +14,6 @@ const tableRows: Row[] = [
name: "fileDir",
displayName: "Audio File",
type: "audio",
prefix: `${BACKEND_SERVE}`,
suffix: "/high.m4a",
},
{
name: "name",
@@ -34,7 +32,7 @@ import { type Option } from "~/ui/SearchSelect.vue"
const { data: artistData } = useApiFetch<Artist[]>("/all/artists")
const artistFetchOptions = async (q?: string): Promise<Option[]> => {
return artistData.value.map<Option>((g) => ({
return (artistData.value ?? []).map<Option>((g) => ({
label: g.name,
value: g._id,
}))
@@ -42,7 +40,7 @@ const artistFetchOptions = async (q?: string): Promise<Option[]> => {
const { data: albumData } = useApiFetch<Album[]>("/all/albums")
const albumFetchOptions = async (q?: string): Promise<Option[]> => {
return albumData.value.map<Option>((g) => ({
return (albumData.value ?? []).map<Option>((g) => ({
label: g.name,
value: g._id,
}))
@@ -50,7 +48,7 @@ const albumFetchOptions = async (q?: string): Promise<Option[]> => {
const { data: genreData } = useApiFetch<Genre[]>("/all/genres")
const genreFetchOptions = async (): Promise<Option[]> => {
return genreData.value.map<Option>((genre) => ({
return (genreData.value ?? []).map<Option>((genre) => ({
label: genre.name,
value: genre._id,
}))
@@ -100,8 +98,15 @@ const serializeLyrics = (lyrics: Track["lyrics"] | undefined) => {
return JSON.stringify(lyrics)
}
const getArtistIds = (artists: Array<string | Option> = []) =>
artists.map((artist) => (typeof artist === "string" ? artist : artist.value))
const getOptionId = (option: string | Option) =>
typeof option === "string" ? option : option.value
const showModal = ref<boolean>(false)
const item = ref<Track | undefined>(undefined)
const addingSong = ref(false)
const save = async (value: any) => {
if (value._id) {
@@ -123,7 +128,6 @@ const save = async (value: any) => {
return false
}
} else {
try {
if (!value.name || !value.file || !value.artists?.length) {
toast.show(
"Name, audio file, and at least one artist are required",
@@ -132,8 +136,7 @@ const save = async (value: any) => {
return false
}
if (value.createSingle) {
if (!value.cover || !value.genre) {
if (value.createSingle && (!value.cover || !value.genre)) {
toast.show(
"A cover image and genre are required for a single",
"error",
@@ -141,22 +144,25 @@ const save = async (value: any) => {
return false
}
const albumResponse = await cfetch("/admin/album", {
if (!value.createSingle && !value.album) {
toast.show("Choose an album or create a single album", "error")
return false
}
addingSong.value = true
await nextTick()
try {
if (value.createSingle) {
const albumResponse = await cfetch<{ _id: string }>("/admin/album", {
method: "POST",
data: {
name: `${value.name} - Single`,
artists: value.artists.map((artist: Option) => artist.value),
genres: value.genre.value,
artists: getArtistIds(value.artists),
genre: getOptionId(value.genre),
file: value.cover,
},
})
value.album = albumResponse.data._id
} else if (!value.album) {
toast.show(
"Choose an album or create a single album",
"error",
)
return false
value.album = albumResponse._id
}
await cfetch("/admin/track", {
@@ -164,7 +170,7 @@ const save = async (value: any) => {
data: {
...value,
album: value.album?.value ?? value.album,
artists: value.artists.map((artist: Option) => artist.value),
artists: getArtistIds(value.artists),
lyrics: serializeLyrics(value.lyrics),
},
})
@@ -174,6 +180,8 @@ const save = async (value: any) => {
toast.show("Failed to add song", "error")
return false
} finally {
addingSong.value = false
}
}
@@ -193,7 +201,7 @@ const show = (id?: string) => {
return
}
const curItem: any = data.value.filter((i) => i._id == id)[0]
const curItem: any = (data.value ?? []).filter((i) => i._id == id)[0]
if (!curItem) return
@@ -215,12 +223,13 @@ const show = (id?: string) => {
showModal.value = true
}
useHead({ title: "Admin - Songs" })
</script>
<template>
<h1 class="title">Manage songs</h1>
<Table :rows="tableRows" :data="data" class="table">
<Table :rows="tableRows" :data="data" search pagination class="table">
<template #action="{ item, index }">
<div class="action">
<Button @click="show(item._id)">Edit</Button>
@@ -236,6 +245,8 @@ const show = (id?: string) => {
:fields="modalFields"
:item="item"
:title="item ? 'Edit song' : 'Add new song'"
:loading="addingSong"
:loading-text="'Adding song…'"
/>
</template>
+8 -4
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const route = useRoute()
const id = computed(() => route.params.id)
@@ -7,8 +9,10 @@ const { data: albumData } = await useApiFetch<Album>(`/albums/${id.value}`)
const { data: tracksData } = await useApiFetch<Track[]>(
`/albums/${id.value}/tracks`,
)
const tracks = computed(() => tracksData.value ?? [])
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
useHead({ title: computed(() => albumData.value ? `${albumData.value.name} - Album` : "Album") })
</script>
<template>
@@ -27,12 +31,12 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<div class="buttons">
<Button
icon-name="lucide:play"
@click="() => playAlbum(tracksData)"
@click="() => playAlbum(tracks)"
>Play</Button
>
<Button
icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)"
@click="() => playShuffledAlbum(tracks)"
>Shuffle</Button
>
</div>
@@ -41,11 +45,11 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<section class="tracks">
<TrackRow
v-for="(track, i) in tracksData"
v-for="(track, i) in tracks"
:key="track._id"
:track="track"
:index="i"
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)"
@play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/>
</section>
</template>
+10 -6
View File
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
const route = useRoute()
const id = computed(() => route.params.id)
@@ -11,13 +13,14 @@ const { data: albumsData } = await useApiFetch<Album[]>(
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const tracks = computed(() => tracksData.value ?? [])
const chunkedTracks = computed(() => {
const SIZE = 3
const chunks = []
for (let i = 0; i < tracksData.value.length; i += SIZE) {
chunks.push(tracksData.value.slice(i, i + SIZE))
for (let i = 0; i < tracks.value.length; i += SIZE) {
chunks.push(tracks.value.slice(i, i + SIZE))
}
return chunks
@@ -36,6 +39,7 @@ const scrollWidth = () => {
}
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
useHead({ title: computed(() => artistData.value ? `${artistData.value.name} - Artist` : "Artist") })
</script>
<template>
@@ -53,12 +57,12 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
<div class="buttons">
<Button
icon-name="lucide:play"
@click="() => playAlbum(tracksData)"
@click="() => playAlbum(tracks)"
>Play</Button
>
<Button
icon-name="lucide:shuffle"
@click="() => playShuffledAlbum(tracksData)"
@click="() => playShuffledAlbum(tracks)"
>Shuffle</Button
>
</div>
@@ -79,7 +83,7 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
:track="track"
:index="i + pI * 3"
@play-album-at-index="
() => playAlbumAtIndex(tracksData, i + pI * 3)
() => playAlbumAtIndex(tracks, i + pI * 3)
"
/>
</section>
@@ -90,7 +94,7 @@ const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
v-for="album in albumsData"
:key="album._id"
:name="album.name"
:file="`${BACKEND_SERVE}${album.file}`"
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
/>
+4 -2
View File
@@ -5,8 +5,10 @@ const id = computed(() => route.params.id)
const { data: tracksData } = await useApiFetch<Track[]>(
`/artists/${id.value}/tracks`,
)
const tracks = computed(() => tracksData.value ?? [])
const { playAlbumAtIndex } = usePlayer()
useHead({ title: "Artist Songs" })
</script>
<template>
@@ -14,12 +16,12 @@ const { playAlbumAtIndex } = usePlayer()
<h1 class="title">Songs</h1>
<TrackRow
v-for="(track, i) in tracksData"
v-for="(track, i) in tracks"
:key="track._id"
:track="track"
:index="i"
show-image
@play-album-at-index="() => playAlbumAtIndex(tracksData, i)"
@play-album-at-index="() => playAlbumAtIndex(tracks, i)"
/>
</section>
</template>
+1
View File
@@ -2,6 +2,7 @@
definePageMeta({
layout: false,
})
useHead({ title: "Sign In" })
</script>
<template>
+2 -1
View File
@@ -10,11 +10,12 @@ const submit = async (e: any) => {
} catch {}
try {
await auth.signin(data.email, data.password, data.remember)
await auth.signin(data.email, data.password, data.remember === "on")
} catch (e: string | any) {
toast.show(e, "error")
}
}
useHead({ title: "Sign In" })
</script>
<template>
+3 -1
View File
@@ -1,4 +1,6 @@
<script setup lang="ts"></script>
<script setup lang="ts">
useHead({ title: "Home" })
</script>
<template>
<h1>Home</h1>
+67 -1
View File
@@ -1 +1,67 @@
<template></template>
<script setup lang="ts">
import { GET_FILE } from "@/utils/file"
import type { Album } from "@/utils/types"
const {
data: albumsData,
status,
error,
refresh,
} = await useApiFetch<Album[]>("/user/albums")
const albums = computed(() => albumsData.value ?? [])
useHead({ title: "Albums" })
</script>
<template>
<section class="albums-page">
<h1>Albums</h1>
<p v-if="status === 'pending'" class="message" role="status">
Loading your albums…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your albums could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="albums.length === 0" class="message">
No saved albums yet. Save a song to add its album here.
</p>
<div v-else class="album-list">
<AlbumCard
v-for="album in albums"
:key="album._id"
:name="album.name"
:file="GET_FILE(album.file)"
:artists="album.artists"
:_id="album._id"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.albums-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
}
.album-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
</style>
+63 -1
View File
@@ -1 +1,63 @@
<template></template>
<script setup lang="ts">
import type { Artist } from "@/utils/types"
const {
data: artistsData,
status,
error,
refresh,
} = await useApiFetch<Artist[]>("/user/artists")
const artists = computed(() => artistsData.value ?? [])
useHead({ title: "Artists" })
</script>
<template>
<section class="artists-page">
<h1>Artists</h1>
<p v-if="status === 'pending'" class="message" role="status">
Loading your artists…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your artists could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="artists.length === 0" class="message">
No artists in your saved songs yet.
</p>
<div v-else class="artist-list">
<ArtistCard
v-for="artist in artists"
:key="artist._id"
:artist="artist"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.artists-page {
padding-bottom: 2rem;
h1 {
@include fontSize(32px);
font-weight: 700;
margin-bottom: 1rem;
}
}
.artist-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
</style>
+100 -1
View File
@@ -1 +1,100 @@
<template></template>
<script setup lang="ts">
import type { Track } from "@/utils/types"
const toast = useToast()
const { playAlbum, playShuffledAlbum, playAlbumAtIndex } = usePlayer()
const {
data: tracksData,
status,
error,
refresh,
} = await useApiFetch<Track[]>("/user/tracks")
const tracks = computed(() => tracksData.value ?? [])
const playTracks = () => {
if (tracks.value.length) void playAlbum(tracks.value)
}
const shuffleTracks = () => {
if (tracks.value.length) void playShuffledAlbum(tracks.value)
}
const removeFromLibrary = (track: Track) => {
tracksData.value = tracks.value.filter(
(savedTrack) => savedTrack._id !== track._id,
)
}
useHead({ title: "Songs" })
</script>
<template>
<section class="songs-page">
<header class="page-header">
<h1>Songs</h1>
<div v-if="tracks.length" class="page-actions">
<Button icon-name="lucide:play" @click="playTracks">Play</Button>
<Button icon-name="lucide:shuffle" @click="shuffleTracks">Shuffle</Button>
</div>
</header>
<p v-if="status === 'pending'" class="message" role="status">
Loading your songs…
</p>
<div v-else-if="error" class="message" role="alert">
<p>Your songs could not be loaded.</p>
<Button variant="ghost" @click="refresh">Try again</Button>
</div>
<p v-else-if="tracks.length === 0" class="message">
You haven’t saved any songs yet.
</p>
<div v-else class="track-list">
<TrackRow
v-for="(track, index) in tracks"
:key="track._id"
:track="track"
:index="index"
:extended-info="true"
@play-album-at-index="() => playAlbumAtIndex(tracks, index)"
@remove-from-library="removeFromLibrary"
/>
</div>
</section>
</template>
<style lang="scss" scoped>
@use "@/styles/util" as *;
.songs-page {
padding-bottom: 2rem;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
h1 {
@include fontSize(32px);
font-weight: 700;
}
}
.page-actions {
display: flex;
gap: 0.75rem;
}
.track-list {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.message {
@include fontSize(14px);
opacity: 0.7;
margin: 0.75rem 0;
}
</style>
+3 -1
View File
@@ -15,7 +15,8 @@ const route = useRoute()
const { currentSong, playAlbum } = usePlayer()
const query = computed(() => {
const value = route.query.q
return (Array.isArray(value) ? value[0] : value ?? "").trim()
const firstValue = Array.isArray(value) ? value[0] : value
return (firstValue ?? "").trim()
})
const debouncedQuery = ref(query.value)
let debounceTimeout: ReturnType<typeof setTimeout> | undefined
@@ -80,6 +81,7 @@ watch(currentSong, (track) => {
const playResult = (result: SearchResult | Track) => {
if ("album" in result && result.album) playAlbum([result as Track])
}
useHead({ title: computed(() => query.value ? `Search: ${query.value}` : "Search") })
</script>
<template>
+312 -17
View File
@@ -1,9 +1,27 @@
import { Howl } from "howler"
import { Howl, Howler } from "howler"
import { shapeBeatPulse } from "~/utils/beat"
export default defineNuxtPlugin((nuxtApp) => {
const mediaBaseUrl = useRuntimeConfig().public.mediaBaseUrl
const { close: closeFullscreen } = useFullscreen()
if (import.meta.client) {
const howlerInternal = Howler as unknown as {
_obtainHtml5Audio: () => HTMLAudioElement
_musicV2CorsEnabled?: boolean
}
if (!howlerInternal._musicV2CorsEnabled) {
const obtainHtml5Audio = howlerInternal._obtainHtml5Audio
howlerInternal._obtainHtml5Audio = function () {
const audio = obtainHtml5Audio.call(Howler)
audio.crossOrigin = "anonymous"
return audio
}
howlerInternal._musicV2CorsEnabled = true
}
}
const VOLUME_STORAGE_KEY = "music-v2-player-volume"
const DEFAULT_VOLUME = 0.5
const DEBUG_BEAT_ANALYSIS = false
const sound = ref<Howl | null>(null)
const queue = ref<Track[]>([])
@@ -12,7 +30,16 @@ export default defineNuxtPlugin((nuxtApp) => {
const currentSong = ref<Track | null>(null)
const repeat = ref(false)
const isPlaying = ref(false)
const beatEnergy = ref(0)
const storedVolume = ref(DEFAULT_VOLUME)
type HowlWithMediaNode = Howl & {
_sounds?: Array<{ _node?: HTMLMediaElement }>
}
let analysisContext: AudioContext | null = null
let analysisSource: MediaStreamAudioSourceNode | null = null
let analysisNode: AnalyserNode | null = null
let analysisFrame = 0
let isAnalyzing = false
const clampVolume = (value: number) => Math.min(1, Math.max(0, value))
@@ -47,6 +74,9 @@ export default defineNuxtPlugin((nuxtApp) => {
sound.value?.seek(value)
},
})
const refreshPosition = () => {
tick.value++
}
const volume = computed<number>({
get() {
tick.value
@@ -59,49 +89,267 @@ export default defineNuxtPlugin((nuxtApp) => {
},
})
const duration = ref(0)
let lastPositionUpdate = 0
let trackingTimer: number | null = null
let mediaSessionSongId: string | null = null
const aniFrame = ref<number | null>(null)
const stopBeatAnalysis = (reason = "stopped") => {
if (isAnalyzing && DEBUG_BEAT_ANALYSIS)
console.info(`[beat analyzer] stopped: ${reason}`)
isAnalyzing = false
if (analysisFrame) cancelAnimationFrame(analysisFrame)
analysisFrame = 0
analysisSource?.disconnect()
analysisSource = null
analysisNode = null
beatEnergy.value = 0
}
// Helpers
const updateMediaSession = async () => {
if (!import.meta.client) return
if (!navigator.mediaSession) {
console.warn("Media Session API is not supported")
const startBeatAnalysis = (howl: Howl) => {
stopBeatAnalysis("restarted")
const mediaElement = (howl as HowlWithMediaNode)._sounds?.[0]?._node
if (!mediaElement) {
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed: Howler media element unavailable",
)
return
}
if (!currentSong.value) return
const elementWithCapture = mediaElement as HTMLMediaElement & {
captureStream?: () => MediaStream
mozCaptureStream?: () => MediaStream
webkitCaptureStream?: () => MediaStream
}
const capture =
elementWithCapture.captureStream ??
elementWithCapture.mozCaptureStream ??
elementWithCapture.webkitCaptureStream
if (!capture) {
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed: media capture unsupported",
)
return
}
navigator.mediaSession.metadata = new MediaMetadata({
try {
const stream = capture.call(mediaElement)
if (stream.getAudioTracks().length === 0) {
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed: capture has no audio tracks",
)
return
}
analysisContext ??= new AudioContext()
if (analysisContext.state === "suspended")
void analysisContext.resume().catch(() => {})
analysisNode = analysisContext.createAnalyser()
analysisNode.fftSize = 2048
analysisNode.smoothingTimeConstant = 0.05
analysisSource = analysisContext.createMediaStreamSource(stream)
analysisSource.connect(analysisNode)
const samples = new Uint8Array(analysisNode.frequencyBinCount)
let previousBass: number | null = null
let riseAverage = 0.01
let riseVariance = 0.0004
let pulse = 0
let lastBeatLogAt = 0
let lastLevelLogAt = 0
const sample = () => {
if (!analysisNode) return
analysisNode.getByteFrequencyData(samples)
const sampleRate = analysisContext!.sampleRate
const firstBin = Math.max(
1,
Math.floor((45 * 2048) / sampleRate),
)
const lastBin = Math.min(
samples.length - 1,
Math.ceil((130 * 2048) / sampleRate),
)
let bass = 0
for (let bin = firstBin; bin <= lastBin; bin++)
bass += samples[bin] ?? 0
bass /= Math.max(1, lastBin - firstBin + 1) * 255
if (previousBass === null) previousBass = bass
const rise = Math.max(0, bass - previousBass)
previousBass = previousBass * 0.985 + bass * 0.015
const riseDeviation = Math.sqrt(riseVariance)
const strongThumpThreshold = Math.min(
0.1,
Math.max(0.045, riseAverage + riseDeviation * 2),
)
const attack = Math.max(0, rise - strongThumpThreshold)
const thumpCooldown = Math.min(
500,
Math.max(300, 300 + riseDeviation * 4000),
)
const now = performance.now()
const isStrongThump =
rise >= strongThumpThreshold &&
now - lastBeatLogAt > thumpCooldown
const riseDelta = rise - riseAverage
riseAverage += riseDelta * 0.01
riseVariance += (riseDelta * riseDelta - riseVariance) * 0.01
if (isStrongThump) {
const thumpStrength = Math.min(0.38, 0.1 + attack * 2.5)
pulse = Math.max(pulse, thumpStrength)
if (DEBUG_BEAT_ANALYSIS) {
console.log(
"[fullscreen beat]",
`detected=${thumpStrength.toFixed(2)}`,
`visual=${shapeBeatPulse(thumpStrength).toFixed(2)}`,
)
}
} else pulse *= 0.94
beatEnergy.value = Math.min(1, pulse)
if (DEBUG_BEAT_ANALYSIS && now - lastLevelLogAt > 1000) {
lastLevelLogAt = now
console.info(
"[beat level]",
`bass=${bass.toFixed(3)}`,
`attack=${attack.toFixed(3)}`,
`gate=${strongThumpThreshold.toFixed(3)}`,
`cooldown=${Math.round(thumpCooldown)}`,
`pulse=${beatEnergy.value.toFixed(2)}`,
)
}
if (isStrongThump) lastBeatLogAt = now
analysisFrame = requestAnimationFrame(sample)
}
analysisFrame = requestAnimationFrame(sample)
isAnalyzing = true
if (DEBUG_BEAT_ANALYSIS)
console.info("[beat analyzer] analysis started")
} catch (error) {
stopBeatAnalysis()
if (DEBUG_BEAT_ANALYSIS)
console.warn(
"[beat analyzer] failed while setting up audio capture",
error,
)
}
}
// Helpers
const updateMediaSession = () => {
if (!import.meta.client) return
const mediaSession = navigator.mediaSession
if (!mediaSession) return
if (!currentSong.value) {
mediaSession.metadata = null
mediaSession.playbackState = "none"
mediaSessionSongId = null
return
}
if (mediaSessionSongId !== currentSong.value._id) {
mediaSession.metadata = new MediaMetadata({
title: currentSong.value.name,
artist: currentSong.value.artists[0]?.name,
album: currentSong.value.album.name,
artwork: [
{
src: GET_FILE(currentSong.value.album.file),
src: GET_FILE(
currentSong.value.album.file,
mediaBaseUrl,
),
sizes: "512x512",
},
],
})
mediaSessionSongId = currentSong.value._id
}
navigator.mediaSession.setPositionState({
if (duration.value > 0) {
try {
mediaSession.setPositionState({
duration: duration.value,
position: secondsPlayed.value,
position: Math.min(secondsPlayed.value, duration.value),
})
} catch {
// Position state can be unavailable while media metadata is loading.
}
}
}
const registerMediaSessionActions = () => {
if (!import.meta.client || !navigator.mediaSession) return
const actions: MediaSessionAction[] = [
"play",
"pause",
"nexttrack",
"previoustrack",
"seekto",
"seekbackward",
"seekforward",
]
for (const action of actions) {
try {
navigator.mediaSession.setActionHandler(action, (event) => {
switch (action) {
case "play":
void play()
break
case "pause":
pause()
break
case "nexttrack":
void next()
break
case "previoustrack":
void prev()
break
case "seekto":
if (event.seekTime !== undefined)
secondsPlayed.value = event.seekTime
updateMediaSession()
break
case "seekbackward":
secondsPlayed.value = Math.max(
0,
secondsPlayed.value - (event.seekOffset ?? 10),
)
updateMediaSession()
break
case "seekforward":
secondsPlayed.value = Math.min(
duration.value,
secondsPlayed.value + (event.seekOffset ?? 10),
)
updateMediaSession()
}
})
} catch {
// Browsers may support Media Session without supporting every action.
}
}
}
const startTracking = () => {
if (trackingTimer !== null) window.clearTimeout(trackingTimer)
const update = () => {
tick.value++
aniFrame.value = requestAnimationFrame(update)
const now = Date.now()
if (now - lastPositionUpdate >= 1000) {
lastPositionUpdate = now
updateMediaSession()
}
trackingTimer = window.setTimeout(update, 100)
}
update()
}
const stopTracking = () => {
if (aniFrame.value) cancelAnimationFrame(aniFrame.value)
aniFrame.value = null
if (trackingTimer !== null) window.clearTimeout(trackingTimer)
trackingTimer = null
}
const createHowl = async (song: Track) => {
@@ -109,20 +357,23 @@ export default defineNuxtPlugin((nuxtApp) => {
if (!song) throw new Error("Song not provided")
const newSound = new Howl({
src: [GET_AUDIO_FILE(song.fileDir)],
html5: false,
src: [GET_AUDIO_FILE(song.fileDir, mediaBaseUrl)],
html5: true,
autoplay: false,
volume: storedVolume.value,
onload: () => {
duration.value = sound.value?.duration() || 0
sound.value?.volume(storedVolume.value)
updateMediaSession()
},
onend: () => {
stopBeatAnalysis("track ended")
void closeFullscreen()
next()
stopTracking()
},
onstop: () => {
stopBeatAnalysis("playback stopped")
isPlaying.value = false
stopTracking()
if (navigator.mediaSession)
@@ -130,11 +381,13 @@ export default defineNuxtPlugin((nuxtApp) => {
},
onplay: () => {
isPlaying.value = true
startBeatAnalysis(newSound)
startTracking()
if (navigator.mediaSession)
navigator.mediaSession.playbackState = "playing"
},
onpause: () => {
stopBeatAnalysis("paused")
isPlaying.value = false
stopTracking()
if (navigator.mediaSession)
@@ -225,6 +478,46 @@ export default defineNuxtPlugin((nuxtApp) => {
currentSong.value = null
sound.value?.stop()
sound.value = null
updateMediaSession()
}
if (import.meta.client) {
registerMediaSessionActions()
window.addEventListener("keydown", (event) => {
if (
event.defaultPrevented ||
event.repeat ||
event.ctrlKey ||
event.metaKey ||
event.altKey
)
return
const target = event.target
if (
target instanceof HTMLElement &&
target.closest(
"input, textarea, select, button, [contenteditable='true'], [role='slider']",
)
)
return
switch (event.code) {
case "Space":
event.preventDefault()
if (isPlaying.value) pause()
else void play()
break
case "ArrowRight":
event.preventDefault()
void next()
break
case "ArrowLeft":
event.preventDefault()
void prev()
}
})
}
const shuffle = () => {
@@ -275,6 +568,7 @@ export default defineNuxtPlugin((nuxtApp) => {
provide: {
player: {
isPlaying,
beatEnergy,
currentSong,
repeat,
play,
@@ -290,6 +584,7 @@ export default defineNuxtPlugin((nuxtApp) => {
playAlbumAtIndex,
getDuration,
secondsPlayed,
refreshPosition,
duration,
volume,
queue,
+2
View File
@@ -8,6 +8,7 @@ interface ButtonProps {
full?: boolean
iconName?: string
centerText?: boolean
disabled?: boolean
}
const props = withDefaults(defineProps<ButtonProps>(), {
@@ -22,6 +23,7 @@ const props = withDefaults(defineProps<ButtonProps>(), {
props.type == 'link' && props.to != undefined ? NuxtLink : 'button'
"
:to="props.to"
:disabled="props.disabled"
:class="[
`button type-${props.variant}`,
{
+7 -1
View File
@@ -12,6 +12,7 @@ interface DropdownMenuProps {
const props = defineProps<DropdownMenuProps>()
const open = ref(false)
const mounted = ref(false)
const placement = ref<"bottom" | "top">("bottom")
const root = useTemplateRef("root")
const menuPosition = ref({ top: 0, left: 0 })
@@ -64,6 +65,7 @@ onMounted(() => {
document.addEventListener("keydown", handleKey)
window.addEventListener("resize", handleReposition)
window.addEventListener("scroll", handleReposition, true)
mounted.value = true
})
onBeforeMount(() => {
@@ -81,7 +83,7 @@ onBeforeMount(() => {
</div>
<Transition name="dropdown">
<Teleport to="body">
<Teleport v-if="mounted" to="body">
<div
v-show="open"
class="dropdown-menu"
@@ -184,6 +186,10 @@ onBeforeMount(() => {
cursor: pointer;
transition: background-color 0.2s ease;
span {
white-space: nowrap;
}
}
.item:hover {
+45 -3
View File
@@ -41,6 +41,15 @@ const onFileChange = (e: Event) => {
value.value = result
emit("file", result)
}
const onTextInput = (e: Event) => {
emit("input", (e.currentTarget as HTMLInputElement | HTMLTextAreaElement).value)
}
const clearSearch = () => {
value.value = ""
emit("input", "")
}
</script>
<template>
@@ -68,7 +77,7 @@ const onFileChange = (e: Event) => {
v-if="props.type != 'file' && props.type != 'textarea'"
:placeholder="props.placeholder"
v-model="value"
@input="(e) => emit('input', e.target?.value)"
@input="onTextInput"
@focus="emit('focus')"
@blur="emit('blur')"
:type="props.type"
@@ -77,12 +86,11 @@ const onFileChange = (e: Event) => {
:required="props.required"
:autocomplete="props.autocomplete"
/>
<textarea
v-else-if="props.textarea"
:placeholder="props.placeholder"
v-model="value"
@input="(e) => emit('input', e.target?.value)"
@input="onTextInput"
@focus="emit('focus')"
@blur="emit('blur')"
:name="props.name"
@@ -102,6 +110,16 @@ const onFileChange = (e: Event) => {
:id="props.name"
:required="props.required"
/>
<button
v-if="props.type == 'search' && typeof value == 'string' && value"
type="button"
class="clear-search"
aria-label="Clear search"
@mousedown.prevent
@click="clearSearch"
>
<Icon name="lucide:x" />
</button>
</div>
</div>
</template>
@@ -160,6 +178,30 @@ const onFileChange = (e: Event) => {
& input::placeholder {
color: $color-placeholder-text;
}
& input[type="search"] {
color-scheme: dark;
&::-webkit-search-cancel-button {
display: none;
}
}
.clear-search {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: $border-radius-standard;
line-height: 1;
color: $color-placeholder-text;
cursor: pointer;
transition: color 0.2s ease;
&:hover {
color: $color-text;
}
}
}
.input,
+150 -12
View File
@@ -1,28 +1,84 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { GET_AUDIO_FILE, GET_FILE } from "@/utils/file"
defineOptions({ inheritAttrs: false })
export type Row = {
name: string
class?: string
width?: string
type?: "image" | "audio"
prefix?: string
suffix?: string
displayName?: string
}
type TableProps = {
rows: Row[]
data: any
pagination?: boolean
pageSize?: number
search?: boolean
}
const { rows, data } = defineProps<TableProps>()
const props = withDefaults(defineProps<TableProps>(), {
pagination: false,
pageSize: 10,
search: false,
})
const currentPage = ref(1)
const query = ref("")
const data = computed(() => (Array.isArray(props.data) ? props.data : []))
const pageSize = computed(() => Math.max(1, Math.floor(props.pageSize)))
const filteredData = computed(() => {
const normalizedQuery = query.value.trim().toLocaleLowerCase()
const indexedData = data.value.map((item: any, index: number) => ({
item,
index,
}))
if (!props.search || !normalizedQuery) return indexedData
return indexedData.filter(({ item }: { item: any }) =>
props.rows.some((row) =>
String(item[row.name] ?? "")
.toLocaleLowerCase()
.includes(normalizedQuery),
),
)
})
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredData.value.length / pageSize.value)),
)
const pagedData = computed(() => {
if (!props.pagination) return filteredData.value
const start = (currentPage.value - 1) * pageSize.value
return filteredData.value.slice(start, start + pageSize.value)
})
watch(query, () => {
currentPage.value = 1
})
watch(pageCount, (count) => {
if (currentPage.value > count) currentPage.value = count
})
</script>
<template>
<table class="table">
<Input
v-if="props.search"
v-model:value="query"
type="search"
placeholder="Search table"
class="table-search"
/>
<table v-bind="$attrs" class="table">
<thead>
<tr>
<th
v-for="row in rows"
v-for="row in props.rows"
:key="row.name"
:class="row.class"
:style="{ width: row.width }"
@@ -31,25 +87,65 @@ const { rows, data } = defineProps<TableProps>()
</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, i) in data" :key="i">
<td v-for="(row, j) in rows" :key="j">
<slot :name="row.name" :item="item" :index="i">
<tbody :key="currentPage">
<tr
v-for="entry in pagedData"
:key="entry.index"
>
<td v-for="(row, j) in props.rows" :key="j">
<slot
:name="row.name"
:item="entry.item"
:index="entry.index"
>
<nuxt-img
v-if="row.type == 'image'"
:src="`${row.prefix}${item[row.name]}`"
:src="GET_FILE(entry.item[row.name])"
/>
<audio
v-else-if="row.type == 'audio'"
controls
:src="`${row.prefix}${item[row.name]}${row.suffix}`"
:src="GET_AUDIO_FILE(entry.item[row.name])"
/>
<p v-else>{{ item[row.name] }}</p>
<p v-else>{{ entry.item[row.name] }}</p>
</slot>
</td>
</tr>
</tbody>
</table>
<nav
v-if="props.pagination && pageCount > 1"
class="pagination"
aria-label="Table pagination"
>
<button
type="button"
:disabled="currentPage === 1"
aria-label="Previous page"
@click="currentPage--"
>
Previous
</button>
<button
v-for="page in pageCount"
:key="page"
type="button"
:aria-label="`Page ${page}`"
:aria-current="currentPage === page ? 'page' : undefined"
:class="{ active: currentPage === page }"
@click="currentPage = page"
>
{{ page }}
</button>
<button
type="button"
:disabled="currentPage === pageCount"
aria-label="Next page"
@click="currentPage++"
>
Next
</button>
</nav>
</template>
<style lang="scss" scoped>
@@ -85,4 +181,46 @@ const { rows, data } = defineProps<TableProps>()
padding: 5px 0;
}
}
.table-search {
margin-bottom: 0.75rem;
}
.table tbody {
animation: table-page-fade 0.18s ease;
}
@keyframes table-page-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 0.4rem;
margin-top: 0.75rem;
& button {
padding: 0.4rem 0.65rem;
border-radius: $border-radius-standard;
background-color: $color-button;
cursor: pointer;
}
& button:hover:not(:disabled),
& button.active {
background-color: $color-button-hover;
}
& button:disabled {
cursor: default;
opacity: 0.5;
}
}
</style>
+9
View File
@@ -0,0 +1,9 @@
export function shapeBeatPulse(value: number) {
const strength = Math.max(0, Math.min(0.4, value))
if (strength <= 0.2) return strength * 0.4
if (strength <= 0.35) {
const progress = (strength - 0.2) / 0.15
return 0.08 + (1 - (1 - progress) ** 3) * 0.42
}
return 0.5 + ((strength - 0.35) / 0.05) * 0.05
}
+57 -52
View File
@@ -1,78 +1,83 @@
import axios, { type AxiosRequestConfig, type AxiosResponse } from "axios"
import defu from "defu"
import { $fetch } from "ofetch"
import type { FetchOptions } from "ofetch"
const hasFile = (value: any): boolean => {
type RequestConfig = Omit<FetchOptions, "body" | "query"> & {
data?: unknown
params?: Record<string, unknown>
forceFormData?: boolean
}
const hasFile = (value: unknown): boolean => {
if (!value || typeof value !== "object") return false
const isFile = (item: unknown) =>
typeof File !== "undefined" && item instanceof File
const isBlob = (item: unknown) =>
typeof Blob !== "undefined" && item instanceof Blob
return Object.values(value).some(
(v) =>
v instanceof File ||
v instanceof Blob ||
(Array.isArray(v) && v.some((i) => i instanceof File)),
(item) =>
isFile(item) ||
isBlob(item) ||
(Array.isArray(item) && item.some((entry) => isFile(entry) || isBlob(entry))),
)
}
type RequestConfig = {
forceFormData?: boolean
} & AxiosRequestConfig
const toApiUrl = (url: string) => {
if (url === "/api" || url.startsWith("/api/")) return url
return `/api${url.startsWith("/") ? url : `/${url}`}`
}
export const cfetch = async (
export const cfetch = async <DataT = any>(
url: string,
options: RequestConfig = {},
authorize: boolean = true,
): Promise<AxiosResponse> => {
const baseUrl = import.meta.env.VITE_PUBLIC_BACKEND
if (!baseUrl) throw new Error("BACKEND URL not set")
const defaults: AxiosRequestConfig = {
baseURL: baseUrl,
authorize = true,
): Promise<DataT> => {
const { data, params, forceFormData, ...fetchOptions } = options
const headers = new Headers(fetchOptions.headers as HeadersInit | undefined)
const requestOptions: FetchOptions = {
...fetchOptions,
query: params ?? (fetchOptions as FetchOptions).query,
body: data ?? (fetchOptions as FetchOptions).body,
headers,
}
if (authorize) {
const { sessionToken, refreshSessionToken } = useAuth()
let token = sessionToken.value
if (token) defaults.headers = { Authorization: `Bearer ${token}` }
else
defaults.headers = {
Authorization: `Bearer ${await refreshSessionToken()}`,
}
}
const config: RequestConfig = defu(options, defaults)
if (config.data && (hasFile(config.data) || config.forceFormData)) {
if (
requestOptions.body &&
(typeof requestOptions.body === "object" || forceFormData) &&
(hasFile(requestOptions.body) || forceFormData)
) {
const form = new FormData()
for (const [key, val] of Object.entries(config.data)) {
if (Array.isArray(val)) {
val.forEach((v) => form.append(key, v)) // key as-is, no []
} else if (val !== undefined && val !== null) {
form.append(key, val as any)
for (const [key, value] of Object.entries(requestOptions.body as Record<string, unknown>)) {
if (Array.isArray(value)) {
value.forEach((entry) => form.append(key, entry as string | Blob))
} else if (value !== undefined && value !== null) {
form.append(key, value as string | Blob)
}
}
config.data = form
if (!config.headers) config.headers = {}
delete config.headers["Content-Type"]
requestOptions.body = form
headers.delete("Content-Type")
}
let response
try {
response = await axios(url, config)
return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT
} catch (error: any) {
if (error.response?.status == 401) {
const { sessionToken, refreshSessionToken } = useAuth()
if (!authorize || error?.response?.status !== 401) throw error
if (!config.headers) config.headers = {}
config.headers.Authorization = `Bearer ${await refreshSessionToken()}`
const refreshed = await useAuth().refreshSession()
if (!refreshed.ok) throw error
response = await axios(url, config)
} else throw error
if (import.meta.server && refreshed.sessionCookie) {
const cookies = (headers.get("cookie") ?? "")
.split(";")
.map((cookie) => cookie.trim())
.filter((cookie) => cookie && !cookie.startsWith("musicSession="))
cookies.push(refreshed.sessionCookie)
headers.set("cookie", cookies.join("; "))
}
if (!response) throw new Error("Something went wrong. Please try again")
return response
return (await $fetch(toApiUrl(url), requestOptions as any)) as DataT
}
}
+5 -4
View File
@@ -1,6 +1,7 @@
export const BACKEND_SERVE = `${import.meta.env.VITE_PUBLIC_BACKEND}/static/`
export const GET_FILE = (file: string, baseUrl = useRuntimeConfig().public.mediaBaseUrl) => {
return `${String(baseUrl).replace(/\/+$/, "")}/${file.replace(/^\/+/, "")}`
}
export const GET_FILE = (file: string) => `${BACKEND_SERVE}${file}`
export const GET_AUDIO_FILE = (file: string) => {
return `${GET_FILE(file)}/high.m4a`
export const GET_AUDIO_FILE = (file: string, baseUrl?: string) => {
return GET_FILE(`${file}/high.m4a`, baseUrl)
}
+1347 -431
View File
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from "golar/unstable"
import "@golar/vue"
export default defineConfig({
typecheck: {
include: ["app/**/*.{ts,tsx,vue}", "server/**/*.ts", "nuxt.config.ts"],
},
})
+21 -3
View File
@@ -3,7 +3,28 @@ import { fileURLToPath } from "node:url"
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: "2025-07-15",
nitro: {
preset: "node-server",
externals: {
inline: ["vue"],
},
},
devtools: { enabled: true },
runtimeConfig: {
// Private server configuration. Matching NUXT_* variables override these
// defaults at runtime in the built Nitro server. Legacy variable names
// are resolved by server utilities at runtime, never during this build.
mongodbUri: "",
tokenExpire: "",
s3Endpoint: "",
s3Bucket: "",
s3Region: "us-east-1",
s3AccessKeyId: "",
s3SecretAccessKey: "",
public: {
mediaBaseUrl: "",
},
},
modules: ["@nuxt/fonts", "@nuxt/icon", "@nuxt/image"],
components: [
{
@@ -15,7 +36,4 @@ export default defineNuxtConfig({
pathPrefix: true,
},
],
image: {
domains: ["api.music.lucasskt.dk"],
},
})
+6885 -2395
View File
File diff suppressed because it is too large. Load diff
+11 -3
View File
@@ -7,23 +7,31 @@
"dev": "nuxt dev",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
"postinstall": "nuxt prepare",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1142.0",
"@nuxt/fonts": "0.11.4",
"@nuxt/icon": "^2.1.0",
"@nuxt/image": "^2.0.0",
"@types/howler": "^2.2.12",
"axios": "^1.13.2",
"argon2": "^0.45.1",
"defu": "^6.1.4",
"fluent-ffmpeg": "^2.1.3",
"fuse.js": "^7.5.0",
"howler": "^2.2.4",
"nuxt": "^4.2.0",
"mongoose": "^9.10.2",
"nuxt": "^4.5.2",
"parse-duration": "^2.1.9",
"typescript": "^5.9.3",
"vue": "^3.5.22",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@golar/vue": "^0.1.10",
"@types/node": "^24.9.1",
"golar": "^0.1.10",
"sass-embedded": "^1.93.2"
}
}
+34
View File
@@ -0,0 +1,34 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObject, tryDeleteObjectPrefix } from "../../utils/object-storage"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const force = query.force === "true" || query.force === true
const album = await Album.findById(parsedId.value)
if (!album) {
setResponseStatus(event, 404)
return { message: "Album not found" }
}
const dependents = await Track.find({ album: album._id }).select("_id name").exec()
if (force) {
const tracks = await Track.find({ album: album._id }).select("_id fileDir").exec()
await Promise.all(tracks.map((track) => Track.findByIdAndDelete(track._id)))
await Promise.all(tracks.map((track) => tryDeleteObjectPrefix(event, `${track.fileDir}/`)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Album has one or more dependents", dependentType: "Track", dependents }
}
await Album.findByIdAndDelete(parsedId.value)
await tryDeleteObject(event, album.file)
return {}
})
+56
View File
@@ -0,0 +1,56 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Album } from "../../models/album"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const genre = adminFormText(parts, "genre")
const file = adminFormFile(parts, "file")
if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
if (genre && !mongoose.isValidObjectId(genre)) return validationResponse(event, "Invalid id")
if (file && !file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const album = await Album.findById(parsedId.value)
if (!album) {
setResponseStatus(event, 404)
return {}
}
if (file) {
const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
const previousPath = album.file
album.file = filePath
if (name) album.name = name
if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (genre) album.genre = new mongoose.Types.ObjectId(genre)
try {
await album.save()
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
await tryDeleteObject(event, previousPath)
return album
}
if (name) album.name = name
if (artists) album.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (genre) album.genre = new mongoose.Types.ObjectId(genre)
await album.save()
return album
})
+44
View File
@@ -0,0 +1,44 @@
import { setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, splitIds } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Album } from "../../models/album"
import mongoose from "mongoose"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const genre = adminFormText(parts, "genre")
const file = adminFormFile(parts, "file")
if (name === undefined || !artists || !genre || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!mongoose.isValidObjectId(genre)) {
setResponseStatus(event, 400)
return { message: "Invalid id" }
}
if (!artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
if (!file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const filePath = `album/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
let album
try {
album = await Album.create({ name, artists, file: filePath, genre })
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
setResponseStatus(event, 201)
return album
})
+50
View File
@@ -0,0 +1,50 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Artist } from "../../models/artist"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObject, tryDeleteObjectPrefix } from "../../utils/object-storage"
async function findDependents(model: typeof Track | typeof Album, field: string, value: unknown) {
return model.find({ [field]: value }).select("_id name").exec()
}
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const force = query.force === "true" || query.force === true
const artist = await Artist.findById(parsedId.value)
if (!artist) {
setResponseStatus(event, 404)
return { message: "Artist not found" }
}
let dependents = await findDependents(Track, "artists", artist._id)
if (force) {
const tracks = await Track.find({ artists: artist._id }).select("_id fileDir").exec()
await Promise.all(tracks.map((track) => Track.findByIdAndDelete(track._id)))
await Promise.all(tracks.map((track) => tryDeleteObjectPrefix(event, `${track.fileDir}/`)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Artist has one or more dependents", dependentType: "Track", dependents }
}
dependents = await findDependents(Album, "artists", artist._id)
if (force) {
const albums = await Album.find({ artists: artist._id }).select("_id file").exec()
await Promise.all(albums.map((album) => Album.findByIdAndDelete(album._id)))
await Promise.all(albums.map((album) => tryDeleteObject(event, album.file)))
}
else if (dependents.length) {
setResponseStatus(event, 409)
return { message: "Artist has one or more dependents", dependentType: "Artist", dependents }
}
await Artist.findByIdAndDelete(parsedId.value)
await tryDeleteObject(event, artist.file)
return {}
})
+44
View File
@@ -0,0 +1,44 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Artist } from "../../models/artist"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const file = adminFormFile(parts, "file")
if (file && !file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const artist = await Artist.findById(parsedId.value)
if (!artist) {
setResponseStatus(event, 404)
return {}
}
if (file) {
const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
const previousPath = artist.file
artist.file = filePath
if (name) artist.name = name
try {
await artist.save()
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
await tryDeleteObject(event, previousPath)
return artist
}
if (name) artist.name = name
await artist.save()
return artist
})
+32
View File
@@ -0,0 +1,32 @@
import { setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormFile, safeFileExtension } from "../../utils/admin-form"
import { putObjects, tryDeleteObject } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Artist } from "../../models/artist"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const file = adminFormFile(parts, "file")
if (name === undefined || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!file.type.includes("image/")) {
setResponseStatus(event, 400)
return { message: "Only image files are accepted" }
}
const filePath = `artists/${crypto.randomUUID()}.${safeFileExtension(file.name)}`
await putObjects(event, [{ key: filePath, body: file.data, contentType: file.type }])
let artist
try {
artist = await Artist.create({ name, file: filePath })
} catch (error) {
await tryDeleteObject(event, filePath)
throw error
}
setResponseStatus(event, 201)
return artist
})
+42
View File
@@ -0,0 +1,42 @@
import { getQuery, setResponseStatus } from "h3"
import { Album } from "../../models/album"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const query = getQuery(event)
const parsedId = parseMongoId(query.id)
if ("error" in parsedId) {
setResponseStatus(event, 400)
return { message: parsedId.error }
}
if (query.force !== "true" && query.force !== "false") {
setResponseStatus(event, 400)
return { message: query.force === undefined ? "Required" : "Expected boolean, received string" }
}
const force = query.force === "true"
const genre = await Genre.findById(parsedId.value)
if (!genre) {
setResponseStatus(event, 404)
return { message: "Genre not found" }
}
const dependents = await Album.find({ genre: genre._id }).select("_id name")
if (force) {
await Promise.all(dependents.map((dependent) => Album.findByIdAndDelete(dependent._id)))
} else if (dependents.length) {
setResponseStatus(event, 409)
return {
message: "Genre has one or more dependents",
dependentType: "Album",
dependents,
}
}
await Genre.findByIdAndDelete(parsedId.value)
return {}
})
+34
View File
@@ -0,0 +1,34 @@
import { getQuery, readBody, setResponseStatus } from "h3"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) {
setResponseStatus(event, 400)
return { message: parsedId.error }
}
const body = await readBody<unknown>(event)
if (!body || typeof body !== "object" || Array.isArray(body)) {
setResponseStatus(event, 400)
const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body
return { message: `Expected object, received ${received}` }
}
const record = body as Record<string, unknown>
if (record.name !== undefined && typeof record.name !== "string") {
setResponseStatus(event, 400)
return { message: `Expected string, received ${record.name === null ? "null" : typeof record.name}` }
}
const genre = await Genre.findByIdAndUpdate(parsedId.value, record.name === undefined ? {} : { name: record.name }, { new: true })
if (!genre) {
setResponseStatus(event, 404)
return { message: "Genre not found" }
}
return genre
})
+31
View File
@@ -0,0 +1,31 @@
import { readBody, setResponseStatus } from "h3"
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const body = await readBody<unknown>(event)
if (!body || typeof body !== "object" || Array.isArray(body)) {
setResponseStatus(event, 400)
const received = body === null ? "null" : Array.isArray(body) ? "array" : typeof body
return { message: `Expected object, received ${received}` }
}
const name = (body as { name?: unknown }).name
if (typeof name !== "string") {
setResponseStatus(event, 400)
return { message: name === undefined ? "Required" : `Expected string, received ${name === null ? "null" : typeof name}` }
}
try {
const genre = await Genre.create({ name })
setResponseStatus(event, 201)
return genre
} catch (error) {
setResponseStatus(event, 400)
if (typeof error === "object" && error !== null && "code" in error && error.code === 11000) {
return { message: "Genre already exists" }
}
return { message: "Unknown error" }
}
})
+19
View File
@@ -0,0 +1,19 @@
import { getQuery, setResponseStatus } from "h3"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { tryDeleteObjectPrefix } from "../../utils/object-storage"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const track = await Track.findByIdAndDelete(parsedId.value)
if (!track) {
setResponseStatus(event, 404)
return {}
}
await tryDeleteObjectPrefix(event, `${track.fileDir}/`)
return {}
})
+68
View File
@@ -0,0 +1,68 @@
import mongoose from "mongoose"
import { getQuery, setResponseStatus } from "h3"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, safeFileExtension, parseOptionalJson, splitIds } from "../../utils/admin-form"
import { prepareAudioUpload } from "../../utils/audio-files"
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
import { Track } from "../../models/track"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const album = adminFormText(parts, "album")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const file = adminFormFile(parts, "file")
const lyrics = adminFormText(parts, "lyrics")
if (file && !file.type.includes("audio/")) {
setResponseStatus(event, 400)
return { message: "Only audio files are accepted" }
}
if (album && !mongoose.isValidObjectId(album)) return validationResponse(event, "Invalid id")
if (artists && !artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
const parsedLyrics = lyrics ? parseOptionalJson(lyrics) : undefined
if (parsedLyrics && !parsedLyrics.ok) throw new Error("Invalid lyrics JSON")
const track = await Track.findById(parsedId.value)
if (!track) {
setResponseStatus(event, 404)
return {}
}
let replacementDirectory: string | undefined
let previousDirectory: string | undefined
if (file) {
replacementDirectory = `tracks/${crypto.randomUUID()}`
const extension = safeFileExtension(file.name)
const processed = await prepareAudioUpload(file.data, extension)
await putObjects(event, [
{ key: `${replacementDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
...processed.renditions.map((rendition) => ({
key: `${replacementDirectory}/${rendition.name}`,
body: rendition.body,
contentType: rendition.contentType,
})),
])
previousDirectory = track.fileDir
track.fileDir = replacementDirectory
track.durationInSeconds = Math.round(processed.duration)
}
if (name) track.name = name
if (album) track.album = new mongoose.Types.ObjectId(album)
if (artists) track.artists = artists.map((id) => new mongoose.Types.ObjectId(id))
if (parsedLyrics?.ok && parsedLyrics.value !== undefined) track.lyrics = parsedLyrics.value as any
try {
await track.save()
} catch (error) {
if (replacementDirectory) await tryDeleteObjectPrefix(event, `${replacementDirectory}/`)
throw error
}
if (previousDirectory) await tryDeleteObjectPrefix(event, `${previousDirectory}/`)
return track
})
+73
View File
@@ -0,0 +1,73 @@
import { setResponseStatus } from "h3"
import mongoose from "mongoose"
import { readAdminForm, adminFormText, adminFormTexts, adminFormFile, parseOptionalJson, splitIds } from "../../utils/admin-form"
import { prepareAudioUpload } from "../../utils/audio-files"
import { putObjects, tryDeleteObjectPrefix } from "../../utils/object-storage"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { Track } from "../../models/track"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event, true, { allowBearer: true, skipOriginCheck: true })
const parts = await readAdminForm(event)
const name = adminFormText(parts, "name")
const album = adminFormText(parts, "album")
const artistInput = adminFormTexts(parts, "artists")
const artists = artistInput.length ? splitIds(artistInput.join(",")) : undefined
const file = adminFormFile(parts, "file")
const lyricsInput = adminFormText(parts, "lyrics")
if (name === undefined || album === undefined || !artists || !file) {
setResponseStatus(event, 400)
return { message: "Required" }
}
if (!mongoose.isValidObjectId(album)) {
setResponseStatus(event, 400)
return { message: "Invalid id" }
}
if (!artists.every((id) => mongoose.isValidObjectId(id))) {
setResponseStatus(event, 400)
return { message: "One or more ID(s) are invalid" }
}
const parsedLyrics = parseOptionalJson(lyricsInput)
if (!parsedLyrics.ok) {
setResponseStatus(event, 400)
return { message: "Lyrics must be a JSON object" }
}
if (!file.type.includes("audio/")) {
setResponseStatus(event, 400)
return { message: "Only audio files are accepted" }
}
const fileDirectory = `tracks/${crypto.randomUUID()}`
try {
const extension = safeFileExtension(file.name)
const processed = await prepareAudioUpload(file.data, extension)
await putObjects(event, [
{ key: `${fileDirectory}/original${extension ? `.${extension}` : ""}`, body: processed.original, contentType: file.type },
...processed.renditions.map((rendition) => ({
key: `${fileDirectory}/${rendition.name}`,
body: rendition.body,
contentType: rendition.contentType,
})),
])
const track = new Track({
name,
album: new mongoose.Types.ObjectId(album),
artists: artists.map((id) => new mongoose.Types.ObjectId(id)),
fileDir: fileDirectory,
durationInSeconds: Math.round(processed.duration),
lyrics: parsedLyrics.value || undefined,
})
try {
await track.save()
} catch (error) {
await tryDeleteObjectPrefix(event, `${fileDirectory}/`)
throw error
}
setResponseStatus(event, 201)
return track
} catch (error) {
console.error(error)
setResponseStatus(event, 500)
return undefined
}
})
+16
View File
@@ -0,0 +1,16 @@
import { getRouterParam } from "h3"
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Album.findOne({ _id: parsedId.value })
.populate("artists")
.populate("genre")
.exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Track.find({ album: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate("artists")
.populate({ path: "album", select: "-artists -genre" })
.exec()
})
+16
View File
@@ -0,0 +1,16 @@
import { getQuery } from "h3"
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoIds, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Album.find({ _id: { $in: parsedIds.value } })
.populate("artists")
.populate("genre")
.exec()
})
+8
View File
@@ -0,0 +1,8 @@
import { Album } from "../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Album.find({}).populate("artists").populate("genre")
})
+8
View File
@@ -0,0 +1,8 @@
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Artist.find({})
})
+8
View File
@@ -0,0 +1,8 @@
import { Genre } from "../../models/genre"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Genre.find({})
})
+14
View File
@@ -0,0 +1,14 @@
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
return Track.find({})
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
})
+13
View File
@@ -0,0 +1,13 @@
import { getRouterParam } from "h3"
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Artist.findOne({ _id: parsedId.value }).exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Album } from "../../../models/album"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Album.find({ artists: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate("genre")
.populate("artists")
.exec()
})
+24
View File
@@ -0,0 +1,24 @@
import { getQuery, getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const query = getQuery(event)
const limit = parseNumber(query.limit, 9, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const skip = parseNumber(query.skip, 0, 0)
if ("error" in skip) return validationResponse(event, skip.error)
return Track.find({ artists: parsedId.value })
.limit(limit.value || 9)
.skip(skip.value || 0)
.populate({ path: "album", select: "-artists" })
.populate("artists")
.exec()
})
+13
View File
@@ -0,0 +1,13 @@
import { getQuery } from "h3"
import { Artist } from "../../models/artist"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoIds, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Artist.find({ _id: { $in: parsedIds.value } }).exec()
})
+56
View File
@@ -0,0 +1,56 @@
import { readBody, setCookie, setResponseStatus } from "h3"
import argon2 from "argon2"
import { User } from "../../models/user"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineAuthenticatedEventHandler(async (event) => {
assertCookieRequestOrigin(event)
const body = await readBody(event)
const validationMessage = getAuthValidationMessage(body, [
{ name: "name" },
{ name: "email", email: true },
{ name: "password", minimumLength: 8 },
])
if (validationMessage) {
setResponseStatus(event, 400)
return { message: validationMessage }
}
const email = body.email.toLowerCase()
if (await User.findOne({ email })) {
setResponseStatus(event, 400)
return { message: "The email is already in use" }
}
try {
const user = await User.create({
name: body.name,
email,
passwordHash: await argon2.hash(body.password),
})
const refreshToken = await User.findById(user._id).select("+refreshToken").then((doc) => doc?.refreshToken)
if (!refreshToken) throw new Error("Unable to create refresh token")
setCookie(event, REFRESH_COOKIE, refreshToken, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !import.meta.dev,
})
setResponseStatus(event, 201)
// Refresh credentials are now delivered only through the HttpOnly cookie.
return {}
} catch (error) {
if ((error as { code?: number }).code === 11000) {
setResponseStatus(event, 400)
return { message: "The email is already in use" }
}
throw error
}
})
+33
View File
@@ -0,0 +1,33 @@
import { defineEventHandler, getCookie, setCookie } from "h3"
import { User } from "../../models/user"
import { getSessionModel } from "../../models/session"
import {
findAuthenticatedSession,
getSessionTtl,
REFRESH_COOKIE,
SESSION_COOKIE,
} from "../../utils/auth"
export default defineEventHandler(async (event) => {
const activeSession = await findAuthenticatedSession(event)
if (activeSession) return { authenticated: true }
const refreshToken = getCookie(event, REFRESH_COOKIE)
if (!refreshToken) return { authenticated: false }
const user = await User.findOne({ refreshToken }).select("+refreshToken +verified")
if (!user?.verified) return { authenticated: false }
const ttl = getSessionTtl(event)
const Session = getSessionModel(ttl)
const session = await Session.create({ userId: user._id })
setCookie(event, SESSION_COOKIE, session.token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !import.meta.dev,
maxAge: Math.floor(ttl / 1000),
})
return { authenticated: true }
})
+53
View File
@@ -0,0 +1,53 @@
import { getCookie, readBody, setCookie, setResponseStatus } from "h3"
import { User } from "../../models/user"
import { getSessionModel } from "../../models/session"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
getSessionTtl,
REFRESH_COOKIE,
SESSION_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const body = await readBody(event).catch(() => undefined)
const validationMessage =
body === undefined ? null : getAuthValidationMessage(body, [{ name: "refreshToken" }])
if (validationMessage) {
setResponseStatus(event, 400)
return { message: validationMessage }
}
const bodyRefreshToken = typeof body?.refreshToken === "string" && body.refreshToken
const cookieRefreshToken = getCookie(event, REFRESH_COOKIE)
if (!bodyRefreshToken && cookieRefreshToken) assertCookieRequestOrigin(event)
const refreshToken = bodyRefreshToken || cookieRefreshToken
if (!refreshToken) {
setResponseStatus(event, 401)
return { message: "Invalid refresh token" }
}
const user = await User.findOne({ refreshToken }).select("+refreshToken")
if (!user) {
setResponseStatus(event, 401)
return { message: "Invalid refresh token" }
}
const ttl = getSessionTtl(event)
const Session = getSessionModel(ttl)
const session = await Session.create({ userId: user._id })
const expireAt = new Date(session.createdAt.getTime() + ttl)
setCookie(event, SESSION_COOKIE, session.token, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !import.meta.dev,
maxAge: Math.floor(ttl / 1000),
})
setResponseStatus(event, 201)
return { expireAt, sessionToken: session.token }
})
+52
View File
@@ -0,0 +1,52 @@
import { readBody, setCookie, setResponseStatus } from "h3"
import argon2 from "argon2"
import { User } from "../../models/user"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
} from "../../utils/auth"
import { getAuthValidationMessage } from "../../utils/auth-validation"
export default defineAuthenticatedEventHandler(async (event) => {
assertCookieRequestOrigin(event)
const body = await readBody(event)
const validationMessage = getAuthValidationMessage(body, [
{ name: "email", email: true },
{ name: "password", minimumLength: 8 },
])
if (validationMessage) {
setResponseStatus(event, 400)
return { message: validationMessage }
}
const user = await User.findOne({ email: body.email.toLowerCase() }).select(
"+refreshToken +passwordHash",
)
let passwordMatches = false
if (user) {
try {
// Bun.password's default output is PHC Argon2id and argon2 verifies
// its encoded algorithm and parameters directly.
passwordMatches = await argon2.verify(user.passwordHash, body.password)
} catch {
// Malformed or unsupported stored hashes must not reveal account state.
}
}
if (!user || !passwordMatches) {
setResponseStatus(event, 400)
return { message: "The email or password is wrong" }
}
setCookie(event, REFRESH_COOKIE, user.refreshToken, {
path: "/",
httpOnly: true,
sameSite: "lax",
secure: !import.meta.dev,
...(body.remember ? { maxAge: 60 * 60 * 24 * 30 } : {}),
})
setResponseStatus(event, 200)
// The long-lived refresh token is deliberately omitted from the JSON body.
return {}
})
+14
View File
@@ -0,0 +1,14 @@
import { deleteCookie } from "h3"
import {
assertCookieRequestOrigin,
defineAuthenticatedEventHandler,
REFRESH_COOKIE,
SESSION_COOKIE,
} from "../../utils/auth"
export default defineAuthenticatedEventHandler((event) => {
assertCookieRequestOrigin(event)
deleteCookie(event, REFRESH_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
deleteCookie(event, SESSION_COOKIE, { path: "/", sameSite: "lax", secure: !import.meta.dev })
return { success: true }
})
+32
View File
@@ -0,0 +1,32 @@
import { getQuery, getRouterParam } from "h3"
import mongoose, { type PipelineStage } from "mongoose"
import { Album } from "../../../models/album"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const id = parseMongoId(getRouterParam(event, "id"))
if ("error" in id) return validationResponse(event, id.error)
const { limit: rawLimit } = getQuery(event)
const limit = parseNumber(rawLimit, 10, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const pipeline: PipelineStage[] = [
{ $match: { genre: new mongoose.Types.ObjectId(id.value) } },
{ $sample: { size: limit.value || 10 } },
{
$lookup: {
from: "artists",
localField: "artists",
foreignField: "_id",
as: "artists",
},
},
{ $unset: "genre" },
]
return Album.aggregate(pipeline)
})
+15
View File
@@ -0,0 +1,15 @@
import { getQuery } from "h3"
import { Genre } from "../../models/genre"
import { parseNumber, validationResponse } from "../../utils/api-validation"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const { limit: rawLimit } = getQuery(event)
const limit = parseNumber(rawLimit, 10, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const genres = await Genre.aggregate([{ $sample: { size: limit.value || 10 } }])
return genres
})
+41
View File
@@ -0,0 +1,41 @@
import { getQuery, getRouterParam } from "h3"
import mongoose, { type PipelineStage } from "mongoose"
import { Track } from "../../../models/track"
import { parseMongoId, parseNumber, validationResponse } from "../../../utils/api-validation"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const id = parseMongoId(getRouterParam(event, "id"))
if ("error" in id) return validationResponse(event, id.error)
const { limit: rawLimit } = getQuery(event)
const limit = parseNumber(rawLimit, 10, 1)
if ("error" in limit) return validationResponse(event, limit.error)
const pipeline: PipelineStage[] = [
{
$lookup: {
from: "albums",
localField: "album",
foreignField: "_id",
as: "album",
},
},
{ $unwind: "$album" },
{
$lookup: {
from: "artists",
localField: "artists",
foreignField: "_id",
as: "artists",
},
},
{ $match: { "album.genre": new mongoose.Types.ObjectId(id.value) } },
{ $sample: { size: limit.value || 10 } },
{ $unset: "album.artists" },
]
return Track.aggregate(pipeline)
})
+1
View File
@@ -0,0 +1 @@
export default defineEventHandler(() => ({ status: "ok" }))
+96
View File
@@ -0,0 +1,96 @@
import { getQuery } from "h3"
import Fuse from "fuse.js"
import { Album } from "../models/album"
import { Artist } from "../models/artist"
import { Track } from "../models/track"
import {
parseNumber,
parseQueryString,
validationResponse,
} from "../utils/api-validation"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../utils/auth"
const searchTypes = ["track", "album", "artist", "default"] as const
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const query = getQuery(event)
const q = parseQueryString(query.q)
if ("error" in q) return validationResponse(event, q.error)
let type = "default"
if (query.type !== undefined) {
const parsedType = parseQueryString(query.type)
if ("error" in parsedType) return validationResponse(event, parsedType.error)
if (!searchTypes.includes(parsedType.value as (typeof searchTypes)[number])) {
return validationResponse(
event,
`Invalid enum value. Expected 'track' | 'album' | 'artist' | 'default', received '${parsedType.value}'`,
)
}
type = parsedType.value
}
const parsedLimit = parseNumber(query.limit, 9, 1)
if ("error" in parsedLimit) return validationResponse(event, parsedLimit.error)
const limit = parsedLimit.value
let results: { tracks: any[]; albums: any[]; artists: any[] } = {
tracks: [],
albums: [],
artists: [],
}
if (type === "track" || type === "default") {
const rawTracks = await Track.find({})
.populate(["album", "artists"])
.lean()
const fuse = new Fuse(rawTracks, {
keys: ["name", "lyrics.text"],
threshold: 0.3,
distance: 100,
ignoreLocation: true,
})
results.tracks = fuse.search(q.value).slice(0, limit).map((result) => result.item)
}
if (type === "album" || type === "default") {
const rawAlbums = await Album.find({})
.populate(["artists", "genre"])
.lean()
const fuse = new Fuse(rawAlbums, {
keys: ["name"],
threshold: 0.3,
distance: 100,
ignoreLocation: true,
})
results.albums = fuse.search(q.value).slice(0, limit).map((result) => result.item)
}
if (type === "artist" || type === "default") {
const rawArtists = await Artist.find({}).lean()
const fuse = new Fuse(rawArtists, {
keys: ["name"],
threshold: 0.3,
distance: 100,
ignoreLocation: true,
})
results.artists = fuse.search(q.value).slice(0, limit).map((result) => result.item)
}
// Keep the original API's split allocation, including when type selects one category.
results = {
tracks: results.tracks.slice(0, Math.ceil(limit / 3)),
albums: results.albums.slice(0, Math.floor(limit / 3)),
artists: results.artists.slice(0, Math.floor(limit / 3)),
}
return results
})
+20
View File
@@ -0,0 +1,20 @@
import { getQuery } from "h3"
import { Track } from "../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../utils/auth"
import { parseMongoIds, validationResponse } from "../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
return Track.find({ _id: { $in: parsedIds.value } })
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
.exec()
})
+20
View File
@@ -0,0 +1,20 @@
import { getRouterParam } from "h3"
import { Track } from "../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../utils/auth"
import { parseMongoId, validationResponse } from "../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
return Track.findOne({ _id: parsedId.value })
.populate({
path: "album",
select: "-artists",
populate: { path: "genre" },
})
.populate("artists")
.exec()
})
+16
View File
@@ -0,0 +1,16 @@
import { getRouterParam } from "h3"
import { Track } from "../../../models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "../../../utils/auth"
import { parseMongoId, validationResponse } from "../../../utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getRouterParam(event, "id"))
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const track = await Track.findOne({ _id: parsedId.value }).select("lyrics").exec()
if (!track) return {}
return track.lyrics
})
+33
View File
@@ -0,0 +1,33 @@
import { setResponseStatus } from "h3"
import { User } from "#server/models/user"
import { Album } from "#server/models/album"
import { Track } from "#server/models/track"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const user = await User.findById(authenticatedUser._id).select("savedTracks")
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
if (!user.savedTracks.length) return []
const tracks = await Track.find({ _id: { $in: user.savedTracks } }).select("_id album").exec()
const albumByTrack = new Map(tracks.map((track: any) => [track._id.toString(), track.album?.toString()]))
const albumIds: string[] = []
const seen = new Set<string>()
for (const savedTrack of user.savedTracks) {
const albumId = albumByTrack.get(savedTrack.toString())
if (albumId && !seen.has(albumId)) {
seen.add(albumId)
albumIds.push(albumId)
}
}
if (!albumIds.length) return []
const albums = await Album.find({ _id: { $in: albumIds } }).populate("artists").populate("genre").exec()
const albumsById = new Map(albums.map((album: any) => [album._id.toString(), album]))
return albumIds.map((id) => albumsById.get(id)).filter((album) => album != null)
})
+41
View File
@@ -0,0 +1,41 @@
import { setResponseStatus } from "h3"
import { Artist } from "#server/models/artist"
import { Track } from "#server/models/track"
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const user = await User.findById(authenticatedUser._id).select("savedTracks")
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
if (!user.savedTracks.length) return []
const tracks = await Track.find({ _id: { $in: user.savedTracks } })
.select("_id artists")
.exec()
const artistsByTrack = new Map(
tracks.map((track: any) => [
track._id.toString(),
(track.artists ?? []).map((artist: any) => artist.toString()),
]),
)
const artistIds: string[] = []
const seen = new Set<string>()
for (const savedTrack of user.savedTracks) {
for (const artistId of artistsByTrack.get(savedTrack.toString()) ?? []) {
if (!seen.has(artistId)) {
seen.add(artistId)
artistIds.push(artistId)
}
}
}
if (!artistIds.length) return []
const artists = await Artist.find({ _id: { $in: artistIds } }).exec()
const artistsById = new Map(artists.map((artist: any) => [artist._id.toString(), artist]))
return artistIds.map((id) => artistsById.get(id)).filter((artist) => artist != null)
})
+5
View File
@@ -0,0 +1,5 @@
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
export default defineAuthenticatedEventHandler(async (event) => {
return await requireAuthenticatedUser(event)
})
+26
View File
@@ -0,0 +1,26 @@
import { getQuery, setResponseStatus } from "h3"
import { User } from "#server/models/user"
import {
defineAuthenticatedEventHandler,
requireAuthenticatedUser,
} from "#server/utils/auth"
import { parseMongoIds, validationResponse } from "#server/utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const parsedIds = parseMongoIds(getQuery(event).ids ?? getQuery(event).id)
if ("error" in parsedIds) return validationResponse(event, parsedIds.error)
const user = await User.findById(authenticatedUser._id)
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
user.savedTracks = user.savedTracks.filter(
(savedTrack: { toString(): string }) =>
!parsedIds.value.includes(savedTrack.toString()),
)
await user.save()
return {}
})
+21
View File
@@ -0,0 +1,21 @@
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
import { setResponseStatus } from "h3"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const user = await User.findById(authenticatedUser._id).populate({
path: "savedTracks",
populate: [
{ path: "artists" },
{ path: "album", populate: { path: "artists" } },
],
})
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
return user.savedTracks.filter((track: unknown) => track != null)
})
+26
View File
@@ -0,0 +1,26 @@
import { getQuery, setResponseStatus } from "h3"
import mongoose, { type ObjectId } from "mongoose"
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
import { parseMongoId, validationResponse } from "#server/utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const user = await User.findById(authenticatedUser._id)
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
// Preserve the legacy comparison behavior and empty-object response.
if ((user.savedTracks as ObjectId[]).some((savedTrack) => savedTrack.toString() === parsedId.value)) {
return {}
}
user.savedTracks.push(new mongoose.Types.ObjectId(parsedId.value))
await user.save()
return {}
})
+22
View File
@@ -0,0 +1,22 @@
import { getQuery, setResponseStatus } from "h3"
import { User } from "#server/models/user"
import { defineAuthenticatedEventHandler, requireAuthenticatedUser } from "#server/utils/auth"
import { parseMongoId, validationResponse } from "#server/utils/api-validation"
export default defineAuthenticatedEventHandler(async (event) => {
const authenticatedUser = await requireAuthenticatedUser(event)
const parsedId = parseMongoId(getQuery(event).id)
if ("error" in parsedId) return validationResponse(event, parsedId.error)
const user = await User.findById(authenticatedUser._id).select("savedTracks")
if (!user) {
setResponseStatus(event, 404)
return { message: "User not found" }
}
return {
contains: user.savedTracks.some(
(savedTrack: { toString(): string }) => savedTrack.toString() === parsedId.value,
),
}
})
+17
View File
@@ -0,0 +1,17 @@
import mongoose, { Schema, type Model } from "mongoose"
import "./artist"
import "./genre"
const albumSchema = new Schema({
name: { type: String, required: true },
artists: {
type: [{ type: Schema.Types.ObjectId, ref: "Artist" }],
required: true,
},
file: { type: String, required: true },
genre: { type: Schema.Types.ObjectId, ref: "Genre", required: true },
})
export const Album =
(mongoose.models.Album as Model<any> | undefined) ??
(mongoose.model<any>("Album", albumSchema) as Model<any>)
+10
View File
@@ -0,0 +1,10 @@
import mongoose, { Schema, type Model } from "mongoose"
const artistSchema = new Schema({
name: { type: String, required: true },
file: { type: String, required: true },
})
export const Artist =
(mongoose.models.Artist as Model<any> | undefined) ??
(mongoose.model<any>("Artist", artistSchema) as Model<any>)
+9
View File
@@ -0,0 +1,9 @@
import mongoose, { Schema, type Model } from "mongoose"
const genreSchema = new Schema({
name: { type: String, required: true, unique: true },
})
export const Genre =
(mongoose.models.Genre as Model<any> | undefined) ??
(mongoose.model<any>("Genre", genreSchema) as Model<any>)
+20
View File
@@ -0,0 +1,20 @@
import { randomUUID } from "node:crypto"
import mongoose, { Schema, type Model } from "mongoose"
import "./user"
export function getSessionModel(sessionTtlMs: number) {
if (mongoose.models.Session) return mongoose.models.Session as Model<any>
const sessionSchema = new Schema({
token: { type: String, default: () => randomUUID(), required: true },
createdAt: {
type: Date,
default: Date.now,
index: { expires: Math.floor(sessionTtlMs / 1000) },
required: true,
},
userId: { type: Schema.Types.ObjectId, ref: "User", required: true },
})
return mongoose.model<any>("Session", sessionSchema)
}
+24
View File
@@ -0,0 +1,24 @@
import mongoose, { Schema, type Model } from "mongoose"
import "./album"
import "./artist"
const trackSchema = new Schema({
name: { type: String, required: true },
album: { type: Schema.Types.ObjectId, ref: "Album", required: true },
artists: {
type: [{ type: Schema.Types.ObjectId, ref: "Artist" }],
required: true,
},
fileDir: { type: String, required: true },
durationInSeconds: { type: Number, required: true },
lyrics: {
type: {
synced: { type: Boolean, required: true },
text: { type: String, required: true },
},
},
})
export const Track =
(mongoose.models.Track as Model<any> | undefined) ??
(mongoose.model<any>("Track", trackSchema) as Model<any>)
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff. Show more