35a1a517-8c44-4c6c-81f5-41b0ac639b76

This commit is contained in:
Z User 2026-10-29 07:19:50 +00:00
parent df5e6e3374
commit 5be05e345e
79 changed files with 10695 additions and 575 deletions

View file

@ -1 +1 @@
1173
1195

View file

@ -0,0 +1,105 @@
# Task 2-a — v6.7 part A (server): resolve anti-spam semaphore + music-service framework
Task ID: 2-a
Agent: full-stack-developer
Status: COMPLETE — lint clean, tsc clean on all four files, verified live
against the dev server (curl) and at the process level (ps snapshots).
## Scope (ONLY these files — nothing else touched)
1. `src/lib/music-services.ts` (modified)
2. `src/lib/server/music-providers.ts` (NEW)
3. `src/app/api/resolve/route.ts` (modified)
4. `src/components/ava/settings-tab.tsx` (modified — Music services chips only)
## What was built
### 1. `src/lib/music-services.ts`
- `export type StreamStatus = 'live' | 'coming-soon'`
- `ServiceDef` gained required `streamStatus: StreamStatus` + optional
`comingSoonNote?: string` (required when coming-soon).
- youtube / soundcloud → `'live'`; spotify / apple → `'coming-soon'` with
notes like: "Spotify playback is coming in a later update — your saved
Spotify login will be used automatically the moment it ships. Meanwhile,
YouTube, SoundCloud and direct-file links work today."
- All pre-existing fields/exports intact (`SERVICES`, `serviceDef`,
`serviceForUrl`, `isServiceId`, `ServiceId`).
### 2. `src/lib/server/music-providers.ts` — exact exports
```ts
export type ProviderStatus = 'live' | 'coming-soon'
export interface ProviderAdapter {
id: ServiceId | 'generic'
label: string
status: ProviderStatus
comingSoonNote: string
handlesUrl(url: string): boolean
// commented v6.8+ extension points:
// resolveTrack?(url, auth) / listPlaylist?(url, auth)
}
export const YTDLP_PROVIDER: ProviderAdapter // id 'generic', live, handlesUrl → true
export const SPOTIFY_PROVIDER: ProviderAdapter // id 'spotify', coming-soon
export const APPLE_PROVIDER: ProviderAdapter // id 'apple', coming-soon
export function providerForUrl(url: string): ProviderAdapter
```
- Domain matching mirrors `serviceForUrl` (host/subdomain, "www." ignored)
scoped to the one service.
- `providerForUrl` is status-agnostic: flip an adapter to 'live' and it
still owns its URLs at the route's dispatch point.
- Big "HOW TO SHIP A NEW SERVICE (v6.8+)" header comment: 3-step recipe
(implement resolveTrack/listPlaylist with the exact payload shapes →
flip streamStatus in music-services.ts + status here → extend the marked
dispatch spot in the route).
### 3. `src/app/api/resolve/route.ts`
- **Coming-soon dispatch** right after `assertPublicHttpUrl`, BEFORE
cache/direct-audio/yt-dlp:
`{ ok: false, code: 'coming-soon', service: provider.id, error: provider.comingSoonNote }` (422).
Spotify/Apple links never spawn yt-dlp. Generic "That site is not
supported" 422 kept but without the DRM parenthetical.
- **Semaphore**: `YTDLP_MAX_CONCURRENCY = 2`, `YTDLP_QUEUE_TIMEOUT_MS = 40_000`,
class `YtdlpSemaphore` on globalThis (`__avaYtdlpSemaphore`). FIFO
waiters, direct slot handoff in `release()` (active count unchanged).
Acquire wrapped around `runYtDlp` with a `held` flag → release exactly
once in the finally; busy → 503 "The resolver is busy right now — try
again in a moment."; timed-out waiter that was granted a slot in the same
tick does NOT double-release (queue membership is the single source of
truth). Cache hits + direct-file links bypass the gate.
- Fixed a pre-existing strict-type bug in `buildCookieParts`
(`pairs: string[]` receiving `[string,string]` tuples → now
`[string, string][]`; no runtime change).
### 4. `src/components/ava/settings-tab.tsx`
- Coming-soon service chips get an amber "soon" badge:
`<span className="rounded bg-amber-400/15 px-1 py-px text-[8px] font-semibold uppercase tracking-wider text-amber-300">soon</span>`
(placed between name and the check/— marker). Nothing else changed;
login buttons fully functional for coming-soon services.
## Verification
- `bun run lint` clean; `bunx tsc --noEmit` clean for all four files.
- Live: spotify track/playlist + apple album → 422 coming-soon in ~6ms
(zero yt-dlp); SSRF gate still first (localhost → 400); direct MP3 → 200;
spotify+cookies → coming-soon before cookie staging; real YouTube resolve
still passes through (bot-wall 422 as before).
- End-to-end anti-spam: 6 concurrent resolves of the same YouTube link —
ps snapshots show ≤2 top-level `bin/yt-dlp` processes at any moment
(extra PIDs = the onefile binary's own self-exec children, not extra
extractions); FIFO waves of 2; zero 503s; dev.log error-free.
- Scratch semaphore tests (cap/FIFO/timeout/handoff/no-leak) all pass;
scratch dir deleted.
## Notes for the lead agent
- **apple.com is broad**: the apple ServiceDef domains include `apple.com`,
so ALL apple.com links (e.g. podcasts.apple.com, which yt-dlp's
ApplePodcasts extractor could handle) now get the Apple Music coming-soon
answer. Consistent with client-side matching; say the word to scope the
adapter to music.apple.com only.
- Worklog has no v6.6 section (the v6.6 music-login files were committed
inside the v6.5 commit 8fa0eb9); my entry follows v6.5 directly.
- Not-mine tsc errors (untouched): examples/websocket/* (missing deps),
api/rust-scaffold (Uint8Array vs BodyInit), ava/audio-source.ts ('never'
narrowing — the concurrent client agent's WIP).
- Did NOT commit; did NOT touch version.ts / page.tsx / audio.ts / queue
files / link-dialog / audio-source / queue-bar.

BIN
bin/deno Executable file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
public/apple-touch-icon.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
public/demo-track.mp3 Executable file

Binary file not shown.

BIN
public/favicon-16.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

BIN
public/favicon-32.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 B

View file

@ -1,29 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
<defs>
<style type="text/css">
.st194{fill:#2D2D2D;stroke:#FFFFFF;stroke-width:0.6317;stroke-miterlimit:10;}
.st23{fill:#FFFFFF;}
.z-breathe {
animation: breathe 2.5s ease-in-out infinite;
}
@keyframes breathe {
0%, 100% { opacity: 0.7; }
50% { opacity: 1; }
}
</style>
</defs>
<g>
<path class="st194" d="M24.51,28.51H5.49c-2.21,0-4-1.79-4-4V5.49c0-2.21,1.79-4,4-4h19.03c2.21,0,4,1.79,4,4v19.03
C28.51,26.72,26.72,28.51,24.51,28.51z"/>
<g class="z-breathe">
<path class="st23" d="M15.47,7.1l-1.3,1.85c-0.2,0.29-0.54,0.47-0.9,0.47h-7.1V7.09C6.16,7.1,15.47,7.1,15.47,7.1z"/>
<polygon class="st23" points="24.3,7.1 13.14,22.91 5.7,22.91 16.86,7.1"/>
<path class="st23" d="M14.53,22.91l1.31-1.86c0.2-0.29,0.54-0.47,0.9-0.47h7.09v2.33H14.53z"/>
</g>
</g>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<title>OVE — Open Visualization Engine</title>
<!-- The simple OVE logo mark: the same rounded-square + audio-waveform
glyph used by the header logo block, in its default-theme colors
(light-yellow square, deep royal-purple wave). Serves as the site
favicon (PNG rasterizations in favicon-*.png / apple-touch-icon.png,
generated by tools/gen-favicon.ts) and the brand mark. -->
<rect width="64" height="64" rx="14" fill="#fde047"/>
<path d="M2 13a2 2 0 0 0 2-2V7a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0V4a2 2 0 0 1 4 0v13a2 2 0 0 0 4 0v-4a2 2 0 0 1 2-2"
transform="translate(8 8) scale(2)"
fill="none" stroke="#241238" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 800 B

BIN
public/og-image.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View file

@ -0,0 +1,49 @@
# Netscape HTTP Cookie File
# This file is generated by yt-dlp. Do not edit.
www.google.com FALSE /recaptcha TRUE 1808687573 _GRECAPTCHA 09AKhCRwiT1-eLwOwaLcFwkJlDCOvrfctf5tsleUgIESjud7kzj-VQQ1lebmDMm57NTJHMlFVl3pt8HEhKvP-HrKM
.youtube.com TRUE / TRUE 1808809698 __Secure-YNID CvABCAUSogEBDB6PDNM4lSTL1fNjvHhlJr6rUsMazeqGK4GnVIGw8Y9-1IaaTzG4xMYqDTp7ZQDCiOU4ja7PJFkLs5dyGICIh4mtR1-7hYM0p6tgJLIJS59qkDH2n4kctfCpEU8yIPqRRFbnf_ASyhnp358K7yiogbiukJK2GCIHcBwD3EPJpLjqSXNvW43WZ1JGO34LV1fLDuOkYdVSAyNQ1jcYqGsFkqIoBTJFARxY9Wp8CqmEo-J1q5StRZXXkrwaf3R8rL5BNxRjv52YkTuQF9qrnbw3B01oxyNluUkk8LkcIHPTrFA7VKfXgEBNaM8C
.youtube.com TRUE / TRUE 0 YSC wJcQpiA7MFI
.youtube.com TRUE / TRUE 1808809757 VISITOR_INFO1_LIVE xwM2uudtLPY
.youtube.com TRUE / TRUE 1808809757 VISITOR_PRIVACY_METADATA CgJVUxIEGgAgEg%3D%3D
.youtube.com TRUE / FALSE 0 PREF f4=4000000&f6=40000000&tz=UTC&hl=en
.youtube.com TRUE / TRUE 1824671657 __Secure-1PSIDTS sidts-CjQBkldj_8-zeoTpyxBBXFMCgRR9qjPtqXfR-AAPv8VdkpM_1x1QaswtzUbaALHmihy8PR6VEAA
.youtube.com TRUE / TRUE 1824671657 __Secure-3PSIDTS sidts-CjQBkldj_8-zeoTpyxBBXFMCgRR9qjPtqXfR-AAPv8VdkpM_1x1QaswtzUbaALHmihy8PR6VEAA
.youtube.com TRUE / FALSE 1827695657 HSID AHlWy3lOE0sa6Py0E
.youtube.com TRUE / TRUE 1827695657 SSID AUIZ5QzBYjVQUn6tB
.youtube.com TRUE / FALSE 1827695657 APISID PrsWDyK9QoAfY-Fv/A_DU6Ntj541EzC59t
.youtube.com TRUE / TRUE 1827695657 SAPISID qShyTwi4D6RAWqup/A9cVfuudF_AUU8fCi
.youtube.com TRUE / TRUE 1827695657 __Secure-1PAPISID qShyTwi4D6RAWqup/A9cVfuudF_AUU8fCi
.youtube.com TRUE / TRUE 1827695657 __Secure-3PAPISID qShyTwi4D6RAWqup/A9cVfuudF_AUU8fCi
.youtube.com TRUE / FALSE 1827695657 SID g.a000EAmiU2hM7FuD6v63bTop3LlKx764pFf1t4tnmCs-R8ZiKwUtjiMkG7YgtJw5b2BBETp3ZQACgYKAUcSARcSFQHGX2MiFNswjWtYxSdRiBvSpq9YRxoVAUF8yKqHjReqCyQLK_r14tW462do0076
.youtube.com TRUE / TRUE 1827695657 __Secure-1PSID g.a000EAmiU2hM7FuD6v63bTop3LlKx764pFf1t4tnmCs-R8ZiKwUt-OO1YThhWv8hLVazSJioKAACgYKAf8SARcSFQHGX2Mi7vLX1X-44U7MbuYdvaP85RoVAUF8yKrCkxk0H23OSKpdpS4Idwfp0076
.youtube.com TRUE / TRUE 1827695657 __Secure-3PSID g.a000EAmiU2hM7FuD6v63bTop3LlKx764pFf1t4tnmCs-R8ZiKwUt-5dt3UN1zw2Vbd0ITxZ-RQACgYKAT8SARcSFQHGX2Miyerzf0aNf4CZ1gHaVcOvJRoVAUF8yKpZFiPbafwxuLK3XngvGkL80076
.youtube.com TRUE / TRUE 1827695657 LOGIN_INFO AFmmF2swRgIhANuOmQ72_1tpBj__Z89LskTVp9i130w2AnoFIeQ5GFw2AiEA99-nsLcdID44W7O0ha1XNs3kBGM9Pj1H2jrIWVOJtaM:QUQ3MjNmd2ZLb2g2Mi1pSi1DMVpHOUZuMTRUY1ZDS2xVQ2Q2Q3p6OF9vQVdKQ054OEJyd3dfdjVvdWhURzUyZFZRcExVbG04RWpMY21LUDV4OENhYlU3MnJKZV9lN3lGYzZSekpLWmJYZ0d0ZTA3b3dzR1Q0bEhZQmpISDV5MWRlZy1rcVZNRnI0UHl2YmE4MG9OeXhjNHdwWExOUGxTMTh3
.youtube.com TRUE / FALSE 1824793757 SIDCC APD_SKGFb6CzpAR5B4SQ5RYAOPYcbkVN3Z8WfzBrTgrxPLk2NYzxEYjXPcv0wiBT0aS5V47e
.youtube.com TRUE / TRUE 1824793757 __Secure-1PSIDCC APD_SKEuV2_zW-UZAToXz6oqw0lD8JzfM3Iyw4UAwRFVChaTOtJqmtH5JWx8nzCKWA9ZZm0pmQ
.youtube.com TRUE / TRUE 1824793757 __Secure-3PSIDCC APD_SKFWJyPSvgrzM4pyuJwCIWvE1wXocR3mi5wjmltaSdqnFj9BXIx4YsQnFCO6RMJe-lQhQQ
.youtube.com TRUE / TRUE 1808809698 __Secure-ROLLOUT_TOKEN CKq_r_Sk4IqwXxDlhvalkduXAxjFuIbB1d6XAw%3D%3D
.youtube.com TRUE / TRUE 1856208374 __Secure-YT_TVFAS t=498092&s=2
.youtube.com TRUE / TRUE 1808688374 DEVICE_INFO ChxOemN3TVRRMk1UazVPVFkwTVRRek5USXdNQT09EPathNcGGOKthNcG
.youtube.com TRUE /tv TRUE 1825968374 __Secure-YT_DERP CL211MHsAw%3D%3D
accounts.google.com FALSE / TRUE 1827695629 __Host-GAPS 1:0bBQMEQYLfI-4i87W5sPl2-rwRCMAWDpibPT0RQX25Ax8PoQN7jB6BAHp2GwEpIjNcMgOxXFBNdF6K4_vtRAbowFRgpkmA:52xBJrI_Z8BTd8dv
accounts.google.com FALSE / TRUE 1795723741 OTZ 8847129_76_80_104160_76_446820
accounts.google.com FALSE / TRUE 1827695657 LSID s.youtube:g.a000EAmiU7I0LBJQ9_IHxDfrsX9jO_gwDhI1oTR_ms40z3BgSsg3AWRpNZCGhA_kasTpATnOJQACgYKAbgSARcSFQHGX2Miq8IIUQluFbKEs_pBJCyZTxoVAUF8yKqHDmmhHNl2x2myYlW-AESI0076
accounts.google.com FALSE / TRUE 1827695657 __Host-1PLSID s.youtube:g.a000EAmiU7I0LBJQ9_IHxDfrsX9jO_gwDhI1oTR_ms40z3BgSsg3BRd7bBsDLNhEqSDuRpabYgACgYKATASARcSFQHGX2MiqP1cqrb_2zyytLYEdSz6_RoVAUF8yKqWz5CnyOe2YDSEAgBDfEyN0076
accounts.google.com FALSE / TRUE 1827695657 __Host-3PLSID s.youtube:g.a000EAmiU7I0LBJQ9_IHxDfrsX9jO_gwDhI1oTR_ms40z3BgSsg3ygc1yL3ad8eqjqFqou3nOgACgYKAYgSARcSFQHGX2Miio-7XF4Ixdh8OiCXAn6OShoVAUF8yKqKMxTErwEagIKXiwg1qB7-0076
accounts.google.com FALSE / TRUE 1827695629 ACCOUNT_CHOOSER AFx_qI7Td1SxByP4v9j-bilk4PQMOT-H0XQbziFNmveYVwWyyvOLuamF3OBpbWmGTZ1E0AAnKV7P_HkWGuMOVV8LMpJfBa41uwzpnaiDjFTu7vpsDWpJ3luNRokBVolDLMNsiw1NpnETQ5aTWY8VYDe2OJeT-5kXeQgVL8WHc7-w4hb8YQVi2EM
.google.com TRUE / TRUE 1808942959 NID CsMCCAES9QEBOxGDSEw5ZNTUJ8oyGjiLG6tJvqph6S_TNQrpbDO2JKCuQ1GbQ2HeHYJZpg5DwuarhAlOHDyhHEpOCw-KLWAdk7CUEH1ywdnnZCq6pT7v_gzKyMLdAfTTpISS4pdKis7pSmwQJ7wkyd9SwaZvFwgXXHyRufVvwGS7DEYinDM2QiZKastEaemeZ3uafFQUJOqPPm9xUPo9A8evqrXeX2u_WH3GKuzLv00vIVCgTHp89n8CPYW_ZoUAUkAatS1yiXWCHnUbmsIcGOFvwJyh15bIfZSWWJy3g6NurLtsSgGMSCmM4g_vzr8R5E104WqT6vlRERe9nigBMkUB0qwHz3PwnbA_0m1Ia0mrG7DrMyc4TPe1rd4r701mtL1r6VYeNrIP7MlhSYXD5dMaoLDJyek9UJNtO9yAVcmRr3b9www
.google.com TRUE / FALSE 1827695629 SID g.a000EAmiU2hM7FuD6v63bTop3LlKx764pFf1t4tnmCs-R8ZiKwUtjiMkG7YgtJw5b2BBETp3ZQACgYKAUcSARcSFQHGX2MiFNswjWtYxSdRiBvSpq9YRxoVAUF8yKqHjReqCyQLK_r14tW462do0076
.google.com TRUE / TRUE 1827695629 __Secure-1PSID g.a000EAmiU2hM7FuD6v63bTop3LlKx764pFf1t4tnmCs-R8ZiKwUt-OO1YThhWv8hLVazSJioKAACgYKAf8SARcSFQHGX2Mi7vLX1X-44U7MbuYdvaP85RoVAUF8yKrCkxk0H23OSKpdpS4Idwfp0076
.google.com TRUE / TRUE 1827695629 __Secure-3PSID g.a000EAmiU2hM7FuD6v63bTop3LlKx764pFf1t4tnmCs-R8ZiKwUt-5dt3UN1zw2Vbd0ITxZ-RQACgYKAT8SARcSFQHGX2Miyerzf0aNf4CZ1gHaVcOvJRoVAUF8yKpZFiPbafwxuLK3XngvGkL80076
.google.com TRUE / FALSE 1827695629 HSID AvG4uuuFtl-8-qhUH
.google.com TRUE / TRUE 1827695629 SSID A_oeh4tFJB7iymCHx
.google.com TRUE / FALSE 1827695629 APISID PrsWDyK9QoAfY-Fv/A_DU6Ntj541EzC59t
.google.com TRUE / TRUE 1827695629 SAPISID qShyTwi4D6RAWqup/A9cVfuudF_AUU8fCi
.google.com TRUE / TRUE 1827695629 __Secure-1PAPISID qShyTwi4D6RAWqup/A9cVfuudF_AUU8fCi
.google.com TRUE / TRUE 1827695629 __Secure-3PAPISID qShyTwi4D6RAWqup/A9cVfuudF_AUU8fCi
.google.com TRUE / FALSE 1824671657 SIDCC APD_SKH6ArSxVZJ-tZd13uSOlQxmI6fFkjfQ871Qwbtszlfjhq5LNC3HLR69O8hy1SiokjE29w
.google.com TRUE / TRUE 1824671657 __Secure-1PSIDCC APD_SKHHaq35TM1Xz_P1bEspG8lIaoGEN3R0xN2vKFb4ArQ1yHTMCsbU3vfg2YVZd0C53FKZUA
.google.com TRUE / TRUE 1824671657 __Secure-3PSIDCC APD_SKEr9tfGBVIpEzBSuQKMLPZEMm60YNuNpExMCMvB5kEvzSOSTLQ65r-ty64yKNyawYpM7A
workspace.google.com FALSE / TRUE 1795723760 OTZ 8847129_76_80_104160_76_446820
gds.google.com FALSE / TRUE 1795727630 OTZ 8847194_76_80_104160_76_446820

View file

@ -1,3 +1,6 @@
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { NextRequest, NextResponse } from 'next/server'
import { assertPublicHttpUrl } from '@/lib/server/ssrf'
import { getStreamEntry, refreshStreamEntry } from '@/lib/server/stream-store'
@ -7,10 +10,18 @@ export const dynamic = 'force-dynamic'
/**
* GET /api/audio/stream?token=<hex32>
* Same-origin byte proxy for a resolved upstream audio URL. Same-origin is
* what keeps the Web Audio analyser untainted (a cross-origin <audio> source
* zeroes the FFT data). Forwards Range requests, passes the upstream status
* and media headers through, slides the token's TTL on each success.
* Same-origin byte proxy for a resolved upstream audio URL — or, since v7.5,
* the server for a CONVERTED MP3 produced by the resolve pipeline. Same-origin
* is what keeps the Web Audio analyser untainted (a cross-origin <audio>
* source zeroes the FFT data).
*
* Two entry kinds (see stream-store.ts):
* • file entries — serve the finished MP3 from disk with full HTTP Range
* support (seek, loop and buffered playback all become plain byte ranges;
* every browser plays a well-formed MP3 — the "media resource not
* suitable" class of failures is gone by construction);
* • proxy entries (remoteUrl) — fetch the upstream and pass its bytes,
* statuses and media headers through, forwarding Range requests.
*/
const UPSTREAM_UA =
@ -24,6 +35,85 @@ function fail(status: number, error: string): NextResponse {
return NextResponse.json({ ok: false, error }, { status })
}
// ---- v7.5 local-file serving -------------------------------------------------
/** Parse a Range header against a file of `size` bytes.
* Returns null when the header is absent (full body) or unsatisfiable —
* the caller distinguishes the two by re-testing the raw header. */
function parseRange(header: string, size: number): { start: number; end: number } | null {
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
if (!m || (!m[1] && !m[2])) return null
let start: number
let end: number
if (m[1]) {
start = parseInt(m[1], 10)
end = m[2] ? Math.min(parseInt(m[2], 10), size - 1) : size - 1
} else {
// suffix form: bytes=-N → the final N bytes
const suffix = parseInt(m[2], 10)
start = Math.max(0, size - suffix)
end = size - 1
}
if (start > end || start >= size) return null
return { start, end }
}
/** Serve the entry's MP3 with Range support. Never proxies. */
async function serveFile(token: string, path: string, bytes: number, request: NextRequest): Promise<Response> {
// The file must still exist and match the registered size — a lapsed entry
// may have been swept between getStreamEntry() and here (404, the visitor
// simply resolves the link again), as may a mid-stream unlink (open fds
// keep streaming on Linux; only NEW opens fail).
let size: number
try {
const s = await stat(path)
if (!s.isFile()) throw new Error('gone')
size = s.size
} catch {
return fail(404, 'This stream has expired — resolve the link again.')
}
if (size !== bytes) {
// the file on disk changed under us — trust the disk, clamp ranges to it
bytes = size
}
const rangeHeader = request.headers.get('range')
const headers = new Headers({
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Cache-Control': 'public, max-age=1800',
})
if (rangeHeader) {
const range = parseRange(rangeHeader, bytes)
if (!range) {
// unsatisfiable or malformed → the standard 416 tells the browser where
// the real bounds are so it can fall back to a full-body request
return new Response(null, {
status: 416,
headers: { 'Content-Range': `bytes */${bytes}` },
})
}
const length = range.end - range.start + 1
headers.set('Content-Range', `bytes ${range.start}-${range.end}/${bytes}`)
headers.set('Content-Length', String(length))
refreshStreamEntry(token)
const nodeStream = createReadStream(path, { start: range.start, end: range.end })
return new Response(Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>, {
status: 206,
headers,
})
}
headers.set('Content-Length', String(bytes))
refreshStreamEntry(token)
const nodeStream = createReadStream(path)
return new Response(Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>, {
status: 200,
headers,
})
}
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')
if (!token) {
@ -35,6 +125,12 @@ export async function GET(request: NextRequest) {
return fail(404, 'Unknown or expired stream token')
}
// v7.5: file entries serve from disk — no upstream fetch, no SSRF surface
// (remoteUrl on a file entry is just context: the page the track came from).
if (entry.file) {
return serveFile(token, entry.file.path, entry.file.bytes, request)
}
// Re-run the SSRF gate — a re-resolved host that now points private is refused.
try {
await assertPublicHttpUrl(entry.remoteUrl)
@ -45,6 +141,9 @@ export async function GET(request: NextRequest) {
const range = request.headers.get('range')
const upstreamHeaders: Record<string, string> = { 'User-Agent': UPSTREAM_UA }
if (range) upstreamHeaders.Range = range
// Visitor login cookie — only present when the resolve step decided this
// upstream belongs to the same music service domain. In-memory only.
if (entry.cookieHeader) upstreamHeaders.Cookie = entry.cookieHeader
// Upstreams occasionally reset mid-handshake — one immediate retry on a
// network-level failure (HTTP error statuses are NOT retried).

View file

@ -16,6 +16,7 @@ const BASE_FOLDERS = [
'root/embedded/Amp Picks',
'root/embedded/Dynamics',
'root/embedded/Envision',
'root/embedded/Zai-Magic',
]
/**

View file

@ -1,28 +1,203 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { NextRequest, NextResponse } from 'next/server'
import { assertPublicHttpUrl } from '@/lib/server/ssrf'
import { createStreamEntry } from '@/lib/server/stream-store'
import { createStreamEntry, createFileStreamEntry } from '@/lib/server/stream-store'
import {
createDownloadDir,
findConvertedMp3,
removeDownloadDir,
} from '@/lib/server/audio-files'
import { providerForUrl } from '@/lib/server/music-providers'
import {
triggerYtdlpUpdate,
ytdlpUpdateCooldownActive,
ytdlpUpdateRunning,
} from '@/lib/server/ytdlp-selfupdate'
import {
isServiceId,
cookiePasteToNetscape,
serviceDef,
serviceForUrl,
type ServiceId,
} from '@/lib/music-services'
import { houseCookieStatus, houseCookieText } from '@/lib/server/house-cookies'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
/**
* POST /api/resolve
* Body: { "url": string } — a YouTube / SoundCloud / Bandcamp / direct-file link.
* Resolves it (yt-dlp for extractors, bypass for direct audio files) to a
* same-origin proxy URL: { ok, streamUrl, title, source, durationSec }.
* Body: { "url": string, "cookies"?: string, "cookieService"?: ServiceId }
* — url: a YouTube / SoundCloud / Bandcamp / direct-file link, or a
* playlist / set link.
* — cookies + cookieService (optional): the visitor's own login cookie for
* the matching music service (from the Settings tab), pasted as a raw
* Cookie header, a cookies.txt export or a Cookie-Editor JSON export —
* all normalized by the shared parser in lib/music-services.ts. The
* cookie is used transiently — written to a 0600 temp file passed to
* yt-dlp as --cookies, deleted immediately after, never logged and
* never stored.
* Playlists are returned as entry lists (flat, fast); the client plays
* them as a lazy queue — one track resolved at a time, never in bulk.
* Single tracks resolve to a same-origin proxy URL:
* { ok, streamUrl, title, source, durationSec }
* Playlists resolve to { ok, isPlaylist, title, source, entries[] }.
*
* v6.7 — anti-spam + music-service framework:
* — CONCURRENCY GATE: at most 2 yt-dlp extractions run at once across ALL
* requests (module-level FIFO semaphore, globalThis-backed). Surplus
* requests wait in line up to 40 s, then get a 503 "busy" answer. The
* server therefore NEVER bursts parallel extractions at YouTube even
* with many visitors resolving at the same time — datacenter IPs that
* hammer the extractor sites get temp-banned. Cache hits and direct-file
* links bypass the gate entirely.
* — COMING-SOON DISPATCH: URLs owned by a registered coming-soon service
* (Spotify, Apple Music — DRM-locked, yt-dlp cannot stream them) are
* intercepted right after the SSRF gate, BEFORE any cache/direct/yt-dlp
* work, and answered with
* { ok: false, code: 'coming-soon', service, error } (422)
* where `error` carries the visitor-facing "later update" note. Live
* service adapters dispatch at the same spot — see
* src/lib/server/music-providers.ts for the extension recipe.
*
* v6.8 — self-healing extractor + honest failure codes:
* — BOT WALLS answer { ok:false, code:'bot-wall' } (422) with the
* "Google login required. Check the Settings tab to login, then try
* again." message on YouTube (other services get the same guidance with
* their name) — the client turns the code into an Open-Settings action.
* — STALE EXTRACTOR signatures (YouTube rotated their mechanics: nsig
* failures, "Unable to extract …", python tracebacks, no formats …)
* trigger a BACKGROUND SELF-UPDATE of bin/yt-dlp (latest GitHub release,
* verified + atomically swapped — see lib/server/ytdlp-selfupdate.ts)
* and answer { ok:false, code:'ytdlp-updating' } (503). The client
* shows "Updating in Background", keeps retrying, and the song picks
* up seamlessly once the new binary is in.
* — When an update already ran recently (cooldown) or shipped no new
* version, stale failures answer
* { ok:false, code:'backend-error' } (500) with "A back-end problem has
* occurred. Please try again later."
*
* v7.5 — download & convert: single tracks are no longer played off the
* extractor's stream URL. That URL is IP-locked (googlevideo bakes the
* resolver's IP in), expires within hours, and arrives as webm/opus — a
* container Safari & co. refuse outright (the browser then reports
* "The media resource … was not suitable" and playback dies). Instead
* the server now runs the operator's pipeline:
*
* yt-dlp -f bestaudio/best -x --audio-format mp3 --audio-quality 0 \
* -o "<workspace>/%(title)s.%(ext)s" URL
*
* …downloading ONLY the audio, converting it to a universally playable
* MP3 (VBR ~245 kbps), and registering the finished file as a file stream
* entry — /api/audio/stream serves it with full Range support. The
* video's REAL title (from the metadata pass) rides along in the response
* so the visitor's "Playing:" label keeps showing it. ffmpeg missing on
* the server falls back to the old proxy path (logged once); a 300 MB
* cap and 3-minute timeout bound the workspace.
*/
const MAX_URL_LENGTH = 2048
const MAX_COOKIES_LENGTH = 65_536
const MAX_PLAYLIST_ENTRIES = 200
const EXTRACT_TIMEOUT_MS = 30_000
/** v7.5: a download+convert legitimately takes minutes, not seconds — the
* metadata pass keeps its 30 s budget, this bounds the MP3 pipeline. */
const DOWNLOAD_TIMEOUT_MS = 180_000
/** v7.5: refuse tracks whose download would exceed this (a ~2.8-hour mix at
* VBR-0) — keeps the workspace bounded. */
const MAX_DOWNLOAD_BYTES_LABEL = '300M'
const RESOLVE_CACHE_TTL_MS = 10 * 60_000
const MAX_STDOUT_BYTES = 32 * 1024 * 1024
// ---- yt-dlp concurrency semaphore (v6.7 anti-spam) -------------------------
//
// The user's hard requirement: never hammer YouTube (or any extractor site)
// with parallel/bulk extractions — datacenter IPs that burst requests get
// temp-banned. Every extraction goes through this semaphore: at most
// YTDLP_MAX_CONCURRENCY yt-dlp processes run at once across ALL requests;
// anything beyond that FIFO-queues for up to YTDLP_QUEUE_TIMEOUT_MS and then
// fails with a 503 "busy" answer. Kept on globalThis (like the resolve
// cache) so dev-mode module reloads never reset it mid-flight.
const YTDLP_MAX_CONCURRENCY = 2
/** v7.5: downloads run minutes, not the half-second of a metadata pass — the
* FIFO wait is extended to 2 minutes so queued visitors still get their turn
* instead of timing out behind a pair of long conversions. */
const YTDLP_QUEUE_TIMEOUT_MS = 120_000
interface SemaphoreWaiter {
/** Resolves the acquire() promise with true — a slot was granted. */
grant: () => void
/** Abandonment timer — race-safe handoff, see acquire(). */
timer: ReturnType<typeof setTimeout>
}
class YtdlpSemaphore {
private active = 0
private waiters: SemaphoreWaiter[] = []
/**
* Take an extraction slot. Resolves true immediately when a slot is free,
* false when the FIFO wait exceeded YTDLP_QUEUE_TIMEOUT_MS.
*
* Race safety (a timed-out waiter must never double-release a slot it was
* granted in the same tick): membership in `waiters` is the single source
* of truth. The timeout callback only abandons the waiter while it is
* STILL QUEUED (never consumed a slot → no release owed). If release()
* already shifted the waiter out — the slot was legitimately transferred
* to it — the callback finds nothing and does nothing; the caller still
* holds exactly one slot and releases exactly once.
*/
acquire(): Promise<boolean> {
// Fast path — but never jump the queue: a non-empty waiter list means
// every free slot belongs to the head of that line.
if (this.waiters.length === 0 && this.active < YTDLP_MAX_CONCURRENCY) {
this.active++
return Promise.resolve(true)
}
return new Promise<boolean>((resolve) => {
const waiter: SemaphoreWaiter = {
grant: () => resolve(true),
timer: setTimeout(() => {
const idx = this.waiters.indexOf(waiter)
if (idx === -1) return // slot granted in the same tick — keep it
this.waiters.splice(idx, 1)
resolve(false)
}, YTDLP_QUEUE_TIMEOUT_MS),
}
this.waiters.push(waiter)
})
}
/** Return a slot: hand it straight to the head of the queue (the active
* count does not change on a handoff), otherwise mark the slot free. */
release(): void {
const next = this.waiters.shift()
if (next) {
clearTimeout(next.timer)
next.grant()
return
}
if (this.active > 0) this.active--
}
}
function ytdlpSemaphore(): YtdlpSemaphore {
const g = globalThis as AvaResolveGlobal
g.__avaYtdlpSemaphore ??= new YtdlpSemaphore()
return g.__avaYtdlpSemaphore
}
const YT_DLP_ARGS = [
'-J',
'--no-warnings',
// playlists come back as a fast flat entry list instead of being fully
// extracted (watch?v=X&list=Y still resolves the single video only)
'--flat-playlist',
'--no-playlist',
'--no-progress',
'--socket-timeout',
@ -31,12 +206,98 @@ const YT_DLP_ARGS = [
'bestaudio[protocol^=http]/bestaudio/best',
]
/**
* v7.5 download & convert pipeline (the operator's command, verbatim intent):
* grab ONLY the audio, convert to MP3 at VBR-0 (~245 kbps), keep the title.
* `-o` is appended per request (it carries the per-download workspace dir).
* `/best` after `bestaudio` covers sites without a dedicated audio format —
* with `-x` the video container is still reduced to its audio stream, so
* even a video-only source converts cleanly. ffmpeg handles HLS/DASH inputs
* here, which the proxy path had to reject outright.
*/
const YT_DLP_DOWNLOAD_ARGS = [
'-f',
'bestaudio/best',
'-x',
'--audio-format',
'mp3',
'--audio-quality',
'0',
'--no-playlist',
'--no-progress',
'--no-warnings',
'--socket-timeout',
'15',
'--retries',
'3',
'--fragment-retries',
'3',
'--max-filesize',
MAX_DOWNLOAD_BYTES_LABEL,
]
const DIRECT_AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|mp4|webm)$/i
const ADAPTIVE_EXT_RE = /\.(m3u8|mpd)$/i
const ADAPTIVE_PROTO_RE = /m3u8|mpd|dash/i
const UNSUPPORTED_RE = /unsupported url/i
/** YouTube's datacenter-IP wall: "Sign in to confirm you're not a bot". */
const BOT_WALL_RE = /sign in to confirm|not a bot|cookies for the authentication/i
/** v7.5: yt-dlp hit the --max-filesize ceiling and skipped the download. */
const MAX_FILESIZE_RE = /larger than max-filesize|max-?filesize reached|file is larger than/i
/** v7.5: the server has no ffmpeg — the MP3 conversion cannot run. Detected
* as the tool name AND a missing-install phrase so codec messages that merely
* MENTION ffmpeg never match. */
const FFMPEG_TOKEN_RE = /(?:ffmpeg|ffprobe)/i
const FFMPEG_MISSING_RE = /not found|not installed|please install|unable to find|impossible to find/i
/** Stale-extractor signatures — YouTube (or any site) changed their
* mechanics and the installed yt-dlp can no longer parse the page:
* nsig/signature rotation, missing player data, python-level crashes,
* empty format lists. These trigger the background self-update. */
const STALE_EXTRACTOR_RE =
/nsig extraction failed|signature extraction failed|unable to extract (?:yt ?initial data|initial player response|player id)|unable to find (?:handler function|function name)|traceback \(most recent call last\)|attributeerror|unboundlocalerror|typeerror|keyerror|indexerror|nameerror|expected string or bytes-like object|object has no attribute|object is not subscriptable|no video formats|requested format is not available|player response is not valid/i
const BACKEND_ERROR_MESSAGE = 'A back-end problem has occurred. Please try again later.'
const UPDATING_MESSAGE =
'Updating in Background — OVE is updating its audio engine on the server and will retry this song automatically.'
/** Bot-wall answers carry the code 'bot-wall' so the client can offer an
* "Open Settings" action. YouTube gets the exact Google-login wording. */
function botWallMessage(target: URL, usedCookie: ServiceId | null, houseCookie: boolean): string {
const isYoutube = /(^|\.)youtube\.com$/i.test(target.hostname) || /(^|\.)youtu\.be$/i.test(target.hostname)
if (isYoutube) {
if (usedCookie) {
return 'Google login required. Check the Settings tab to login, then try again. (Your saved Google login may have expired — re-entering it in Settings usually fixes this.)'
}
if (houseCookie) {
return 'Google login required. Check the Settings tab to login, then try again. (The site’s shared account hit a usage limit — try again in a little while, or log in with your own Google account in Settings.)'
}
return 'Google login required. Check the Settings tab to login, then try again.'
}
if (houseCookie) {
return 'This service is asking the server to prove it is not a bot, and the site’s shared account is currently limited. Log in from the Settings tab so your own account is used, then try again.'
}
return 'This service is asking the server to prove it is not a bot. Log in from the Settings tab so your own account is used, then try again.'
}
/** Stale-extractor answer: kick off (or join) the background self-update and
* tell the client to keep retrying — or, when an update already ran
* recently, give the honest back-end-problem answer. */
function staleExtractorResponse(): NextResponse {
if (ytdlpUpdateRunning() || !ytdlpUpdateCooldownActive()) {
if (!ytdlpUpdateRunning()) triggerYtdlpUpdate()
return NextResponse.json(
{ ok: false, code: 'ytdlp-updating', error: UPDATING_MESSAGE },
{ status: 503, headers: { 'Retry-After': '10' } },
)
}
// An update ran within the cooldown (it either shipped a new binary that
// STILL fails, or shipped nothing) — retrying or re-updating is pointless.
return NextResponse.json(
{ ok: false, code: 'backend-error', error: BACKEND_ERROR_MESSAGE },
{ status: 500 },
)
}
interface ResolveSuccess {
ok: true
@ -46,18 +307,41 @@ interface ResolveSuccess {
durationSec: number | null
}
interface PlaylistEntry {
url: string
title: string
durationSec: number | null
}
interface PlaylistSuccess {
ok: true
isPlaylist: true
title: string
source: string
entries: PlaylistEntry[]
}
interface YtDlpFormat {
url?: unknown
protocol?: unknown
}
interface YtDlpEntry {
url?: unknown
id?: unknown
title?: unknown
duration?: unknown
}
interface YtDlpInfo {
_type?: unknown
url?: unknown
protocol?: unknown
title?: unknown
duration?: unknown
extractor?: unknown
extractor_key?: unknown
entries?: YtDlpEntry[] | null
formats?: YtDlpFormat[] | null
requested_formats?: YtDlpFormat[] | null
}
@ -80,6 +364,7 @@ interface ResolveCacheEntry {
interface AvaResolveGlobal {
__avaResolveCache?: Map<string, ResolveCacheEntry>
__avaYtdlpSemaphore?: YtdlpSemaphore
}
function resolveCache(): Map<string, ResolveCacheEntry> {
@ -191,8 +476,64 @@ function ytdlpSource(data: YtDlpInfo, fallbackHost: string): string {
return (key || ex || fallbackHost).toLowerCase()
}
/** Run yt-dlp, killing it after EXTRACT_TIMEOUT_MS. */
function runYtDlp(url: string, extraArgs: string[]): Promise<YtDlpResult> {
// ---- visitor cookie handling (transient, never persisted) --------------------
interface CookiePayload {
/** path of the 0600 temp cookies.txt for yt-dlp --cookies */
filePath: string
/** temp dir to remove after the request */
dir: string
/** "name=value; …" header — forwarded to the stream proxy ONLY when the
* resolved upstream host belongs to the same service domain. */
header: string
service: ServiceId
}
/**
* Normalize pasted cookie data into a Netscape cookie file body + a Cookie
* request header. Shared with the login dialog's live validation:
* cookiePasteToNetscape() in lib/music-services.ts (v6.8.1) parses raw
* "k=v; k2=v2" headers, Netscape cookies.txt exports AND Cookie-Editor JSON
* exports — one parser on both sides so they can never drift apart.
* Returns null when nothing parseable was pasted.
*/
/** Write the normalized cookie text to a private temp file for yt-dlp. */
async function stageCookies(text: string, service: ServiceId): Promise<CookiePayload | null> {
const parts = cookiePasteToNetscape(text, service)
if (!parts) return null
const dir = await mkdtemp(join(tmpdir(), 'ove-cookies-'))
const filePath = join(dir, 'cookies.txt')
await writeFile(filePath, parts.netscape, { mode: 0o600 })
return { filePath, dir, header: parts.header, service }
}
/** True when the resolved stream host belongs to the login's service. */
function headerAllowedForHost(header: string, service: ServiceId, host: string): boolean {
if (!header) return false
const root = serviceDef(service).cookieDomain.replace(/^\./, '')
const h = host.toLowerCase()
return h === root || h.endsWith(`.${root}`)
}
/** Run yt-dlp, killing it after EXTRACT_TIMEOUT_MS.
* v6.9: the project bin/ dir is prepended to PATH so yt-dlp finds the
* Deno runtime (bin/deno) — YouTube's signature/n-challenge solving for
* cookie-authenticated extractions requires a JS runtime (EJS); without
* it, authenticated requests return storyboards only ("Requested format
* is not available"). */
function ytdlpEnv(): NodeJS.ProcessEnv {
const binDir = join(process.cwd(), 'bin')
const path = process.env.PATH ?? ''
return { ...process.env, PATH: path.split(':').includes(binDir) ? path : `${binDir}:${path}` }
}
function runYtDlp(
url: string,
extraArgs: string[],
timeoutMs: number = EXTRACT_TIMEOUT_MS,
baseArgs: string[] = YT_DLP_ARGS,
): Promise<YtDlpResult> {
return new Promise((resolve) => {
let stdout = ''
let stderr = ''
@ -209,8 +550,9 @@ function runYtDlp(url: string, extraArgs: string[]): Promise<YtDlpResult> {
let child: ReturnType<typeof spawn>
try {
child = spawn(ytdlpPath(), [...YT_DLP_ARGS, ...extraArgs, url], {
child = spawn(ytdlpPath(), [...baseArgs, ...extraArgs, url], {
stdio: ['ignore', 'pipe', 'pipe'],
env: ytdlpEnv(),
})
} catch (err) {
done(null, null, err instanceof Error ? err.message : 'failed to start yt-dlp')
@ -224,7 +566,7 @@ function runYtDlp(url: string, extraArgs: string[]): Promise<YtDlpResult> {
} catch {
// already gone — 'close' will still fire
}
}, EXTRACT_TIMEOUT_MS)
}, timeoutMs)
child.stdout?.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
@ -244,8 +586,123 @@ function runYtDlp(url: string, extraArgs: string[]): Promise<YtDlpResult> {
})
}
/** Map flat playlist entries to {url,title,duration} (capped). */
function playlistEntries(data: YtDlpInfo, isYoutube: boolean): PlaylistEntry[] {
const entries = Array.isArray(data.entries) ? data.entries : []
const out: PlaylistEntry[] = []
for (const e of entries) {
if (out.length >= MAX_PLAYLIST_ENTRIES) break
let url = typeof e.url === 'string' ? e.url.trim() : ''
if (url && !/^https?:\/\//i.test(url)) {
// some extractors emit bare ids — only youtube ids can be rebuilt
url = isYoutube && /^[\w-]{6,20}$/.test(url) ? `https://www.youtube.com/watch?v=${url}` : ''
}
if (!url && isYoutube && typeof e.id === 'string' && /^[\w-]{6,20}$/.test(e.id)) {
url = `https://www.youtube.com/watch?v=${e.id}`
}
if (!url) continue
const title = cleanText(typeof e.title === 'string' ? e.title : '', 140) || 'Untitled track'
out.push({ url, title, durationSec: typeof e.duration === 'number' ? e.duration : null })
}
return out
}
// ---- v7.5 shared yt-dlp failure classifier -----------------------------------
//
// Both passes (metadata + download) fail through the same signatures: spawn
// errors, timeouts, bot walls, stale extractors, unsupported sites. One
// classifier so the two paths can never drift apart.
/** Turn a failed run into its visitor-facing answer. null = not a failure
* pattern we recognize (caller emits the generic extraction error). */
function answerYtdlpFailure(
result: YtDlpResult,
target: URL,
cookieService: ServiceId | null,
cookieFromHouse: boolean,
download: boolean,
): NextResponse | null {
if (result.spawnError !== null) {
return fail(500, 'Audio extractor unavailable (yt-dlp not found or failed to start).')
}
if (result.timedOut) {
return fail(
504,
download
? 'Downloading and converting that track timed out — try again or use a shorter link.'
: 'Extraction timed out — try again or use a direct audio link.',
)
}
if (result.code === 0) return null
const cleanedStderr = stripControlChars(result.stderr).trim()
if (UNSUPPORTED_RE.test(cleanedStderr)) {
return fail(422, 'That site is not supported — try YouTube, SoundCloud, or a direct MP3 link.')
}
if (BOT_WALL_RE.test(cleanedStderr)) {
// v6.8: code'd bot-wall answer — the client pairs it with an
// "Open Settings" action (login cookies bypass the wall)
return NextResponse.json(
{ ok: false, code: 'bot-wall', error: botWallMessage(target, cookieService, cookieFromHouse) },
{ status: 422 },
)
}
if (STALE_EXTRACTOR_RE.test(cleanedStderr)) {
// v6.8 self-heal: trigger the background yt-dlp update and tell the
// client to retry (or, post-cooldown, the honest failure answer)
return staleExtractorResponse()
}
return null
}
/** True when a failed download's stderr says the server has no ffmpeg. */
function ffmpegMissing(stderr: string): boolean {
const cleaned = stripControlChars(stderr)
return FFMPEG_TOKEN_RE.test(cleaned) && FFMPEG_MISSING_RE.test(cleaned)
}
let warnedFfmpegMissing = false
/** v7.5 — run the operator's download & convert pipeline for ONE track.
* Resolves with the converted file on success; with the raw failure result
* otherwise (never throws — dir cleanup is the caller's, via removeDownloadDir
* on BOTH paths: success keeps the dir, failure drops it). */
async function downloadAsMp3(
url: string,
cookiePath: string | null,
): Promise<
| { ok: true; dir: string; path: string; bytes: number }
| { ok: false; dir: string; failure: YtDlpResult }
> {
const dir = await createDownloadDir()
const result = await runYtDlp(
url,
[...(cookiePath ? ['--cookies', cookiePath] : []), '-o', join(dir, '%(title)s.%(ext)s')],
DOWNLOAD_TIMEOUT_MS,
YT_DLP_DOWNLOAD_ARGS,
)
if (result.code === 0 && !result.timedOut && result.spawnError === null) {
const file = await findConvertedMp3(dir)
if (file) return { ok: true, dir, path: file.path, bytes: file.bytes }
// exit 0 but no MP3 in the workspace — treat as a failure so the caller
// can fall back to the proxy path instead of answering nothing
return { ok: false, dir, failure: { ...result, code: 1 } }
}
return { ok: false, dir, failure: result }
}
// ---- route ----
/** GET /api/resolve — which services currently have the operator's shared
* "house" account configured (presence booleans only, never contents).
* The Settings tab + login dialog use this to tell visitors they can
* play those services' links without logging in. */
export async function GET() {
return NextResponse.json(
{ ok: true, house: houseCookieStatus() },
{ headers: { 'Cache-Control': 'no-store' } },
)
}
export async function POST(request: NextRequest) {
// 1. body
let body: unknown
@ -266,6 +723,22 @@ export async function POST(request: NextRequest) {
return fail(400, 'That link is too long.')
}
// optional visitor login cookie (Settings tab → Music services)
const cookiesField = (body as { cookies?: unknown }).cookies
const cookieServiceField = (body as { cookieService?: unknown }).cookieService
let cookieText: string | null = null
let cookieService: ServiceId | null = null
if (typeof cookiesField === 'string' && cookiesField.trim().length > 0) {
if (cookiesField.length > MAX_COOKIES_LENGTH) {
return fail(400, 'Cookie data is too large.')
}
if (!isServiceId(cookieServiceField)) {
return fail(400, 'Missing music service for the provided login cookie.')
}
cookieText = cookiesField
cookieService = cookieServiceField
}
// 2. SSRF gate on the input URL
let target: URL
try {
@ -274,11 +747,31 @@ export async function POST(request: NextRequest) {
return fail(400, err instanceof Error ? err.message : 'That link is not allowed.')
}
// 3. success cache
const cached = cacheGet(target.href)
// 2b. music-service provider dispatch — URLs owned by a registered
// coming-soon service (Spotify, Apple Music: DRM-locked, yt-dlp can
// never stream them) are answered here with the honest "later update"
// note and NEVER reach the cache, yt-dlp or any extractor process.
// Live service adapters dispatch at this same spot (v6.8+) — see
// src/lib/server/music-providers.ts for the extension recipe.
const provider = providerForUrl(target.href)
if (provider.status === 'coming-soon') {
return NextResponse.json(
{ ok: false, code: 'coming-soon', service: provider.id, error: provider.comingSoonNote },
{ status: 422 },
)
}
// 3. success cache — keyed by link AND which authentication is in play
// (v6.9: house fallback gets its own key — a house-account resolve can
// succeed where a true anonymous one bot-walls)
const linkService = serviceForUrl(target.href)
const houseText = linkService !== null && cookieService === null ? houseCookieText(linkService) : null
const cacheKey =
target.href + (cookieService ? '|auth' : houseText !== null ? '|house' : '|anon')
const cached = cacheGet(cacheKey)
if (cached) return NextResponse.json(cached)
// 4. direct-audio bypass
// 4. direct-audio bypass (never needs cookies)
if (DIRECT_AUDIO_RE.test(target.pathname)) {
const title = directTitle(target)
const token = createStreamEntry(target.href, title, 'direct')
@ -289,78 +782,190 @@ export async function POST(request: NextRequest) {
source: 'direct',
durationSec: null,
}
cacheSet(target.href, payload)
cacheSet(cacheKey, payload)
return NextResponse.json(payload)
}
// 5. yt-dlp extraction
const result = await runYtDlp(target.href, [])
if (result.spawnError !== null) {
return fail(500, 'Audio extractor unavailable (yt-dlp not found or failed to start).')
}
if (result.timedOut) {
return fail(504, 'Extraction timed out — try again or use a direct audio link.')
}
if (result.code !== 0) {
const cleanedStderr = stripControlChars(result.stderr).trim()
const tail = cleanedStderr.slice(-300)
if (UNSUPPORTED_RE.test(cleanedStderr)) {
return fail(422, 'That site is not supported — try YouTube, SoundCloud, or a direct MP3 link.')
// 5. yt-dlp extraction — gated by the anti-spam semaphore (at most 2
// concurrent extractions across ALL requests; surplus callers FIFO-
// queue up to 40 s, then get a 503) — with a login cookie staged in a
// private temp file that is removed right after (never logged, never
// stored). v6.9 priority: the visitor's OWN login first; otherwise the
// operator's shared house account (secrets/house-<service>-cookies.txt)
// when one exists for the link's service; only then truly anonymous.
let cookie: CookiePayload | null = null
let cookieFromHouse = false
if (cookieText !== null && cookieService !== null) {
try {
cookie = await stageCookies(cookieText, cookieService)
} catch {
return fail(500, 'Could not stage the login cookie on the server.')
}
if (BOT_WALL_RE.test(cleanedStderr)) {
if (!cookie) {
return fail(400, 'Could not read that cookie data — paste the Cookie header value or a cookies.txt export.')
}
} else if (linkService !== null && houseText !== null) {
try {
cookie = await stageCookies(houseText, linkService)
} catch {
cookie = null
}
if (cookie) {
cookieFromHouse = true
console.log(`[house-cookie] staged for ${linkService}`)
}
}
const semaphore = ytdlpSemaphore()
let held = false
try {
held = await semaphore.acquire()
if (!held) {
return fail(503, 'The resolver is busy right now — try again in a moment.')
}
const result = await runYtDlp(target.href, cookie ? ['--cookies', cookie.filePath] : [])
// v7.5: shared classifier — same signatures, same answers as v6.8, now
// reused by the download pass below so the two paths never drift.
const metaAnswer = answerYtdlpFailure(result, target, cookieService, cookieFromHouse, false)
if (metaAnswer) return metaAnswer
if (result.code !== 0 || result.timedOut || result.spawnError !== null) {
// no recognized signature — the honest generic extraction failure
const cleanedStderr = stripControlChars(result.stderr).trim()
const tail = cleanedStderr.slice(-300)
return fail(422, `Could not extract audio from that link: ${tail || 'extraction failed.'}`)
}
let data: YtDlpInfo
try {
data = JSON.parse(result.stdout) as YtDlpInfo
} catch {
return fail(422, 'Could not extract audio from that link: extractor returned unexpected output.')
}
const source = ytdlpSource(data, target.hostname)
const isYoutube = /youtube/i.test(source) || /youtu\.be/i.test(target.hostname)
// 5a. playlist / set → flat entry list; the client picks a track
const entries = playlistEntries(data, isYoutube)
if (entries.length > 0 && data._type === 'playlist') {
const playlistTitle =
cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Playlist'
const payload: PlaylistSuccess = {
ok: true,
isPlaylist: true,
title: playlistTitle,
source,
entries,
}
return NextResponse.json(payload)
}
// 5b. single track (v7.5) — download & convert to MP3. The finished file
// becomes a file stream entry; /api/audio/stream serves it with full
// Range support and the track's REAL title rides in the response, so
// the visitor's "Playing:" label shows the video's name. The metadata
// pass above already paid the extraction cost — this reuses its title,
// duration and source verbatim.
const title = cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Stream'
const durationSec = typeof data.duration === 'number' ? data.duration : null
const dl = await downloadAsMp3(target.href, cookie ? cookie.filePath : null)
if (dl.ok) {
const token = createFileStreamEntry(
{ dir: dl.dir, path: dl.path, bytes: dl.bytes, contentType: 'audio/mpeg' },
title,
source,
target.href,
)
const payload: ResolveSuccess = {
ok: true,
streamUrl: `/api/audio/stream?token=${token}`,
title,
source,
durationSec,
}
cacheSet(cacheKey, payload)
return NextResponse.json(payload)
}
// download failed — classify, and for the recoverable classes fall back
// to the legacy proxy path (5c below) instead of answering dead.
removeDownloadDir(dl.dir)
const dlStderr = stripControlChars(dl.failure.stderr).trim()
if (MAX_FILESIZE_RE.test(dlStderr)) {
return fail(
422,
'YouTube is asking this server to sign in to confirm it is not a bot — datacenter IPs are often rate-limited. Try a SoundCloud, Bandcamp or direct-file link instead, or run the native client on your own machine.',
'That track is too long to convert — its download would exceed the server cap. Try a shorter link.',
)
}
return fail(422, `Could not extract audio from that link: ${tail || 'extraction failed.'}`)
}
if (ffmpegMissing(dl.failure.stderr)) {
// the conversion cannot run without ffmpeg — the proxy keeps the site
// alive until the operator installs it (logged once, not per request)
if (!warnedFfmpegMissing) {
warnedFfmpegMissing = true
console.warn(
'[resolve] ffmpeg is not installed on the server — MP3 conversion unavailable, serving the extractor stream directly (less reliable). Install ffmpeg (e.g. apt install ffmpeg) to enable the download pipeline.',
)
}
} else {
const dlAnswer = answerYtdlpFailure(dl.failure, target, cookieService, cookieFromHouse, true)
if (dlAnswer) return dlAnswer
// unrecognized failure → best effort through the proxy below
}
let data: YtDlpInfo
try {
data = JSON.parse(result.stdout) as YtDlpInfo
} catch {
return fail(422, 'Could not extract audio from that link: extractor returned unexpected output.')
}
// 5c. LEGACY FALLBACK (pre-v7.5 behavior): serve the extractor's stream
// URL through the same-origin proxy. Reached only when the MP3
// pipeline is unavailable (no ffmpeg) or failed unrecognized — the
// primary path above is what visitors should be on.
const pick = pickStream(data)
if (!pick) {
return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
}
const pick = pickStream(data)
if (!pick) {
return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
}
let upstream: URL
try {
upstream = new URL(pick.url)
} catch {
return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
}
let upstream: URL
try {
upstream = new URL(pick.url)
} catch {
return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
}
if (ADAPTIVE_EXT_RE.test(upstream.pathname) || ADAPTIVE_PROTO_RE.test(pick.protocol)) {
return fail(
422,
"This source only provides an adaptive (HLS/DASH) stream, which browsers can't play directly — try another link or a direct audio file.",
)
}
if (ADAPTIVE_EXT_RE.test(upstream.pathname) || ADAPTIVE_PROTO_RE.test(pick.protocol)) {
return fail(
422,
"This source only provides an adaptive (HLS/DASH) stream, which browsers can't play directly — try another link or a direct audio file."
)
}
// 6. SSRF gate on the resolved stream URL (public CDNs pass)
try {
await assertPublicHttpUrl(upstream.href)
} catch (err) {
return fail(422, err instanceof Error ? err.message : 'That stream host is not allowed.')
}
// 6. SSRF gate on the resolved stream URL (public CDNs pass)
try {
await assertPublicHttpUrl(upstream.href)
} catch (err) {
return fail(422, err instanceof Error ? err.message : 'That stream host is not allowed.')
// 7. build response — forward the Cookie header to the proxy ONLY when
// the upstream host belongs to the same service (no cross-domain
// cookie leakage to CDN hosts)
const proxyHeader =
cookie && headerAllowedForHost(cookie.header, cookie.service, upstream.hostname)
? cookie.header
: undefined
const token = createStreamEntry(upstream.href, title, source, proxyHeader)
const payload: ResolveSuccess = {
ok: true,
streamUrl: `/api/audio/stream?token=${token}`,
title,
source,
durationSec,
}
cacheSet(cacheKey, payload)
return NextResponse.json(payload)
} finally {
if (held) {
semaphore.release()
}
if (cookie) {
await rm(cookie.dir, { recursive: true, force: true }).catch(() => {})
}
}
// 7. build response
const title = cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Stream'
const source = ytdlpSource(data, target.hostname)
const durationSec = typeof data.duration === 'number' ? data.duration : null
const token = createStreamEntry(upstream.href, title, source)
const payload: ResolveSuccess = {
ok: true,
streamUrl: `/api/audio/stream?token=${token}`,
title,
source,
durationSec,
}
cacheSet(target.href, payload)
return NextResponse.json(payload)
}

View file

@ -53,13 +53,13 @@ export async function GET() {
{ status: 500 }
)
}
const data = await zip.generateAsync({ type: 'uint8array' })
const data = await zip.generateAsync({ type: 'arraybuffer' })
return new NextResponse(data, {
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': 'attachment; filename="ava-rust-scaffold.zip"',
'Content-Length': String(data.length),
'Content-Length': String(data.byteLength),
'Cache-Control': 'no-store',
},
})

48
src/app/api/settings/route.ts Executable file
View file

@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
let settings = await db.settings.findUnique({ where: { id: 'main' } })
if (!settings) {
settings = await db.settings.create({ data: { id: 'main' } })
}
return NextResponse.json({
defaultScript: settings.defaultScript ?? null,
bandConfig: settings.bandConfig ? JSON.parse(settings.bandConfig) : null,
beatConfig: settings.beatConfig ? JSON.parse(settings.beatConfig) : null,
})
} catch (err) {
console.error('GET /api/settings failed:', err)
return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 })
}
}
export async function PUT(req: NextRequest) {
try {
const body = await req.json()
const data: Record<string, string> = {}
if (typeof body.defaultScript === 'string') data.defaultScript = body.defaultScript
if (body.bandConfig && typeof body.bandConfig === 'object') {
data.bandConfig = JSON.stringify(body.bandConfig)
}
if (body.beatConfig && typeof body.beatConfig === 'object') {
data.beatConfig = JSON.stringify(body.beatConfig)
}
const settings = await db.settings.upsert({
where: { id: 'main' },
update: data,
create: { id: 'main', ...data },
})
return NextResponse.json({
defaultScript: settings.defaultScript ?? null,
bandConfig: settings.bandConfig ? JSON.parse(settings.bandConfig) : null,
beatConfig: settings.beatConfig ? JSON.parse(settings.beatConfig) : null,
})
} catch (err) {
console.error('PUT /api/settings failed:', err)
return NextResponse.json({ error: 'Failed to save settings' }, { status: 500 })
}
}

View file

@ -1,7 +1,30 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
/*
* OVE theming (v6.5) — two independent axes, both browser-local per visitor:
*
* BASE (class on <html>, managed by next-themes):
* .purple — royal purple chrome (v6.4 default look)
* .matte — matte black, neutral, zero glare
* .outline — pure black stage, panels defined by colored outlines
* .light — white theme
*
* ACCENT ([data-accent] attribute on <html>, set pre-paint by the boot
* script in layout.tsx): purple / yellow / blue / green / red / orange.
* It drives the emerald-* highlight utilities (CTAs, BPM, active states),
* the semantic primary/ring tokens and the .glow-* shadows. In the .outline
* base it also tints the panel outlines via --accent-border.
*
* Technique (same as v6.4): the app chrome is styled with Tailwind
* zinc/emerald utilities, and each base/accent block remaps those ramps
* with unlayered custom properties (unlayered rules beat @layer theme, and
* utilities resolve the vars at usage time, so opacity variants follow).
* The visualization canvas keeps its own black stage by design.
*
* "dark" for Tailwind's dark: variant = any dark base.
*/
@custom-variant dark (&:is(:where(.purple, .matte, .outline) *));
@theme inline {
--color-background: var(--background);
@ -44,9 +67,13 @@
}
:root {
/* v6.4 accent plumbing — same values as .dark (the default theme); the
.light block overrides both for the white theme. */
--accent-rgb: 253 224 71;
/* Fallback accent plumbing (v7.2 default = blue, matching the outline
base's default highlight). The boot script in layout.tsx sets
data-accent pre-paint; these only matter for no-JS or a tampered/
unknown stored value. */
--accent-rgb: 96 165 250;
--accent-border: #1e3a8a;
--accent-border-strong: #1d4ed8;
--scrollbar-thumb: #56406f;
--radius: 0.625rem;
@ -83,37 +110,141 @@
--sidebar-ring: oklch(0.708 0 0);
}
/*
* OVE dark theme (v6.4) — ROYAL PURPLE with light-yellow highlights.
*
* The app chrome is styled with the Tailwind zinc/emerald utilities directly,
* so — like the .light remap below — the dark palette is produced by remapping
* those ramps under `.dark` (unlayered class rules beat Tailwind's @layer
* theme, and color-mix keeps the var references so opacity variants follow):
* • zinc is re-hued into a royal-purple ramp — 950 is the deep purple page,
* 900 the panel purple, 800/700 borders, 500→50 lavender text;
* • emerald (the old green accent) becomes the LIGHT YELLOW highlight
* family — emerald-400 is #fde047 for CTAs/BPM/active states, hover
* lightens to #fef08a, and emerald-950 (text on the yellow buttons)
* becomes a deep royal purple;
* • the shadcn semantic tokens follow (background/card/border/primary…),
* so dialogs, toasts, sliders and switches pick up the same theme;
* • --accent-rgb carries the highlight color as an rgb triple for the
* .glow-* shadow utilities at the bottom of this file.
* The visualization canvas keeps its own black stage by design.
*/
.dark {
/* ============================================================================
* ACCENTS — highlight color (dark-theme values; .light[data-accent=…] below
* deepens them for white backgrounds). These own the emerald-* highlight
* ramp, the semantic primary/ring, chart-1 and the outline-border vars.
* ==========================================================================*/
[data-accent="purple"] {
--color-emerald-200: #f3e8ff;
--color-emerald-300: #d8b4fe;
--color-emerald-400: #c084fc;
--color-emerald-500: #a855f7;
--color-emerald-700: #7e22ce;
--color-emerald-950: #2e1065;
--accent-rgb: 192 132 252;
--accent-border: #4c1d95;
--accent-border-strong: #6d28d9;
--primary: #c084fc;
--primary-foreground: #2e1065;
--ring: #c084fc;
--chart-1: #c084fc;
--sidebar-primary: #c084fc;
--sidebar-primary-foreground: #2e1065;
--sidebar-ring: #c084fc;
}
[data-accent="yellow"] {
--color-emerald-200: #fef9c3;
--color-emerald-300: #fef08a;
--color-emerald-400: #fde047;
--color-emerald-500: #facc15;
--color-emerald-700: #a16207;
--color-emerald-950: #422006;
--accent-rgb: 253 224 71;
--accent-border: #713f12;
--accent-border-strong: #a16207;
--primary: #fde047;
--primary-foreground: #422006;
--ring: #fde047;
--chart-1: #fde047;
--sidebar-primary: #fde047;
--sidebar-primary-foreground: #422006;
--sidebar-ring: #fde047;
}
[data-accent="blue"] {
--color-emerald-200: #dbeafe;
--color-emerald-300: #93c5fd;
--color-emerald-400: #60a5fa;
--color-emerald-500: #3b82f6;
--color-emerald-700: #1d4ed8;
--color-emerald-950: #172554;
--accent-rgb: 96 165 250;
--accent-border: #1e3a8a;
--accent-border-strong: #1d4ed8;
--primary: #60a5fa;
--primary-foreground: #172554;
--ring: #60a5fa;
--chart-1: #60a5fa;
--sidebar-primary: #60a5fa;
--sidebar-primary-foreground: #172554;
--sidebar-ring: #60a5fa;
}
[data-accent="green"] {
--color-emerald-200: #d1fae5;
--color-emerald-300: #6ee7b7;
--color-emerald-400: #34d399;
--color-emerald-500: #10b981;
--color-emerald-700: #047857;
--color-emerald-950: #064e3b;
--accent-rgb: 52 211 153;
--accent-border: #065f46;
--accent-border-strong: #047857;
--primary: #34d399;
--primary-foreground: #064e3b;
--ring: #34d399;
--chart-1: #34d399;
--sidebar-primary: #34d399;
--sidebar-primary-foreground: #064e3b;
--sidebar-ring: #34d399;
}
[data-accent="red"] {
--color-emerald-200: #fecaca;
--color-emerald-300: #fca5a5;
--color-emerald-400: #f87171;
--color-emerald-500: #ef4444;
--color-emerald-700: #b91c1c;
--color-emerald-950: #450a0a;
--accent-rgb: 248 113 113;
--accent-border: #7f1d1d;
--accent-border-strong: #b91c1c;
--primary: #f87171;
--primary-foreground: #450a0a;
--ring: #f87171;
--chart-1: #f87171;
--sidebar-primary: #f87171;
--sidebar-primary-foreground: #450a0a;
--sidebar-ring: #f87171;
}
[data-accent="orange"] {
--color-emerald-200: #ffedd5;
--color-emerald-300: #fdba74;
--color-emerald-400: #fb923c;
--color-emerald-500: #f97316;
--color-emerald-700: #c2410c;
--color-emerald-950: #431407;
--accent-rgb: 251 146 60;
--accent-border: #7c2d12;
--accent-border-strong: #c2410c;
--primary: #fb923c;
--primary-foreground: #431407;
--ring: #fb923c;
--chart-1: #fb923c;
--sidebar-primary: #fb923c;
--sidebar-primary-foreground: #431407;
--sidebar-ring: #fb923c;
}
/* ============================================================================
* BASES — chrome palettes. Each owns the zinc ramp + neutral semantic tokens
* (background/card/borders/…); accent-owned tokens come from the blocks above.
* ==========================================================================*/
/* — Royal Purple (v6.4 default) ————————————————————————————————————————— */
.purple {
color-scheme: dark;
/* semantic tokens — royal purple chrome, light-yellow accent */
--background: #1b0f2e;
--foreground: #f5f1fb;
--card: #2a1a42;
--card-foreground: #f5f1fb;
--popover: #2a1a42;
--popover-foreground: #f5f1fb;
--primary: #fde047;
--primary-foreground: #241238;
--secondary: #35244e;
--secondary-foreground: #ece4f8;
--muted: #2a1a42;
@ -123,20 +254,16 @@
--destructive: oklch(0.704 0.191 22.216);
--border: #453063;
--input: #453063;
--ring: #fde047;
--chart-1: #fde047;
--chart-2: #937cbb;
--chart-3: #facc15;
--chart-4: #74599f;
--chart-5: #c9b8e6;
--sidebar: #2a1a42;
--sidebar-foreground: #f5f1fb;
--sidebar-primary: #fde047;
--sidebar-primary-foreground: #241238;
--sidebar-accent: #35244e;
--sidebar-accent-foreground: #ece4f8;
--sidebar-border: #453063;
--sidebar-ring: #fde047;
--scrollbar-thumb: #56406f;
/* zinc ramp → royal purple */
--color-zinc-50: #f5f1fb;
@ -150,31 +277,103 @@
--color-zinc-800: #3d2957;
--color-zinc-900: #2a1a42;
--color-zinc-950: #1b0f2e;
/* emerald → light-yellow highlights */
--color-emerald-200: #fef9c3;
--color-emerald-300: #fef08a;
--color-emerald-400: #fde047;
--color-emerald-500: #facc15;
--color-emerald-950: #241238;
/* highlight rgb triple for the .glow-* utilities + scrollbar thumb */
--accent-rgb: 253 224 71;
--scrollbar-thumb: #56406f;
}
/*
* OVE light theme (v6.3) — the white theme on the flip side of the header
* tick switch. Same remap technique: zinc is inverted (950 page → white,
* 100 text → near-black) and the emerald/rose/amber accents are deepened one
* step so text and buttons keep WCAG-ish contrast on white. The dark theme
* above is completely independent — flipping the switch swaps both ramps.
*/
/* — Matte Black — true neutral, no hue —————————————————————————————————— */
.matte {
color-scheme: dark;
--background: #0b0b0d;
--foreground: #f4f4f6;
--card: #141417;
--card-foreground: #f4f4f6;
--popover: #17171b;
--popover-foreground: #f4f4f6;
--secondary: #1c1c21;
--secondary-foreground: #e9e9ee;
--muted: #1a1a1f;
--muted-foreground: #8f8f98;
--accent: #1c1c21;
--accent-foreground: #e9e9ee;
--destructive: oklch(0.704 0.191 22.216);
--border: #26262c;
--input: #26262c;
--chart-2: #8f8f98;
--chart-3: #6b6b74;
--chart-4: #47474f;
--chart-5: #d9d9df;
--sidebar: #141417;
--sidebar-foreground: #f4f4f6;
--sidebar-accent: #1c1c21;
--sidebar-accent-foreground: #e9e9ee;
--sidebar-border: #26262c;
--scrollbar-thumb: #333339;
/* zinc ramp → matte grays */
--color-zinc-50: #f7f7fa;
--color-zinc-100: #ececf1;
--color-zinc-200: #d9d9df;
--color-zinc-300: #b6b6bf;
--color-zinc-400: #8f8f98;
--color-zinc-500: #6b6b74;
--color-zinc-600: #47474f;
--color-zinc-700: #333339;
--color-zinc-800: #232329;
--color-zinc-900: #141417;
--color-zinc-950: #0b0b0d;
}
/* — Black + Outline — pure black stage, panels defined by accent outlines.
* The border steps of the zinc ramp follow the accent color via
* --accent-border (defined per accent above); default pairing = purple. */
.outline {
color-scheme: dark;
--background: #000000;
--foreground: #edebf4;
--card: #0b0b10;
--card-foreground: #edebf4;
--popover: #101017;
--popover-foreground: #edebf4;
--secondary: #14141d;
--secondary-foreground: #e6e3f0;
--muted: #121219;
--muted-foreground: #9894a8;
--accent: #14141d;
--accent-foreground: #e6e3f0;
--destructive: oklch(0.704 0.191 22.216);
--border: var(--accent-border);
--input: var(--accent-border);
--chart-2: #9894a8;
--chart-3: #75707f;
--chart-4: #4d4959;
--chart-5: #c9c5d6;
--sidebar: #0b0b10;
--sidebar-foreground: #edebf4;
--sidebar-accent: #14141d;
--sidebar-accent-foreground: #e6e3f0;
--sidebar-border: var(--accent-border);
--scrollbar-thumb: var(--accent-border-strong);
/* zinc ramp → black + accent outlines (800/700 are the outline steps) */
--color-zinc-50: #f7f6fb;
--color-zinc-100: #ebebf3;
--color-zinc-200: #d9d6e5;
--color-zinc-300: #b8b4c9;
--color-zinc-400: #9490a6;
--color-zinc-500: #6e6a80;
--color-zinc-600: #4b4757;
--color-zinc-700: var(--accent-border-strong);
--color-zinc-800: var(--accent-border);
--color-zinc-900: #0b0b10;
--color-zinc-950: #000000;
}
/* — Light (white theme) — inverted zinc ramp; accents deepened per
* [data-accent] below so text/buttons keep contrast on white. ————————— */
.light {
color-scheme: light;
/* accent plumbing for the white theme — deep emerald highlights */
--accent-rgb: 5 150 105;
--scrollbar-thumb: #d4d4d8;
/* inverted zinc ramp */
@ -190,16 +389,86 @@
--color-zinc-900: #fafafa;
--color-zinc-950: #ffffff;
/* deeper accents for white backgrounds */
--color-emerald-200: #a7f3d0;
--color-emerald-300: #10b981;
--color-emerald-400: #059669;
/* deeper rose/amber for white backgrounds (errors/warnings) */
--color-rose-300: #f43f5e;
--color-rose-400: #e11d48;
--color-amber-300: #b45309;
--color-amber-400: #d97706;
}
/* ============================================================================
* LIGHT-CONTEXT ACCENTS — the dark-theme highlight colors above are too pale
* on white, so each accent gets a deepened set under .light. emerald-700/950
* stay dark (text-on-accent) and are inherited from the blocks above.
* ==========================================================================*/
.light[data-accent="purple"] {
--color-emerald-300: #a855f7;
--color-emerald-400: #9333ea;
--color-emerald-500: #7e22ce;
--accent-rgb: 147 51 234;
--primary: #9333ea;
--ring: #9333ea;
--sidebar-primary: #9333ea;
--sidebar-ring: #9333ea;
}
.light[data-accent="yellow"] {
--color-emerald-300: #eab308;
--color-emerald-400: #ca8a04;
--color-emerald-500: #a16207;
--accent-rgb: 202 138 4;
--primary: #ca8a04;
--ring: #ca8a04;
--sidebar-primary: #ca8a04;
--sidebar-ring: #ca8a04;
}
.light[data-accent="blue"] {
--color-emerald-300: #3b82f6;
--color-emerald-400: #2563eb;
--color-emerald-500: #1d4ed8;
--accent-rgb: 37 99 235;
--primary: #2563eb;
--ring: #2563eb;
--sidebar-primary: #2563eb;
--sidebar-ring: #2563eb;
}
.light[data-accent="green"] {
--color-emerald-200: #a7f3d0;
--color-emerald-300: #10b981;
--color-emerald-400: #059669;
--color-emerald-500: #047857;
--accent-rgb: 5 150 105;
--primary: #059669;
--ring: #059669;
--sidebar-primary: #059669;
--sidebar-ring: #059669;
}
.light[data-accent="red"] {
--color-emerald-300: #ef4444;
--color-emerald-400: #dc2626;
--color-emerald-500: #b91c1c;
--accent-rgb: 220 38 38;
--primary: #dc2626;
--ring: #dc2626;
--sidebar-primary: #dc2626;
--sidebar-ring: #dc2626;
}
.light[data-accent="orange"] {
--color-emerald-300: #f97316;
--color-emerald-400: #ea580c;
--color-emerald-500: #c2410c;
--accent-rgb: 234 88 12;
--primary: #ea580c;
--ring: #ea580c;
--sidebar-primary: #ea580c;
--sidebar-ring: #ea580c;
}
@layer base {
* {
@apply border-border outline-ring/50;
@ -210,10 +479,9 @@
}
/*
* Theme-aware accent glows (v6.4). The old hardcoded emerald rgba() shadows
* could not follow the palette swap, so every glow now reads --accent-rgb:
* light yellow on royal purple in the dark theme, deep emerald on white in
* the light theme.
* Theme-aware accent glows. Every glow reads --accent-rgb, so they follow
* the selected highlight color in every base (and its deepened light-theme
* variant).
*/
.glow-logo {
box-shadow: 0 0 16px rgb(var(--accent-rgb) / 0.35);
@ -236,3 +504,50 @@
.glow-status {
box-shadow: 0 0 8px rgb(var(--accent-rgb) / 0.8);
}
/*
* v7.2 — shimmer CTA: an animated, reflective moving shine that sweeps
* across the idle-overlay "Demo Track" button to draw new visitors to the
* one-click showcase. A skewed light band travels left → right over the
* filled accent surface (passing over the label like a specular highlight)
* every ~3.2 s. Purely decorative (pointer-events: none) and disabled for
* visitors who prefer reduced motion.
*/
.shimmer-cta {
position: relative;
overflow: hidden;
}
.shimmer-cta::after {
content: '';
position: absolute;
top: -20%;
bottom: -20%;
left: 0;
width: 45%;
transform: translateX(-180%) skewX(-14deg);
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.32) 42%,
rgba(255, 255, 255, 0.72) 50%,
rgba(255, 255, 255, 0.32) 58%,
transparent
);
animation: shimmer-sweep 3.2s ease-in-out infinite;
pointer-events: none;
will-change: transform;
}
@keyframes shimmer-sweep {
0% {
transform: translateX(-180%) skewX(-14deg);
}
55%,
100% {
transform: translateX(340%) skewX(-14deg);
}
}
@media (prefers-reduced-motion: reduce) {
.shimmer-cta::after {
animation: none;
}
}

View file

@ -4,6 +4,7 @@ import { ThemeProvider } from "next-themes";
import "./globals.css";
import { Toaster } from "@/components/ui/toaster";
import { APP_NAME, APP_SHORT, APP_VERSION } from "@/lib/version";
import { ACCENT_STORAGE_KEY, DEFAULT_ACCENT } from "@/lib/theme";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -15,16 +16,68 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
/** Production origin — og:image/twitter:image must be absolute URLs for
* social crawlers (Facebook, X, WhatsApp…), so Next resolves the relative
* image path against this base. The canonical domain is openvisengine.com
* (SHORT name — v6.9.1/6.9.2 accidentally shipped the long form
* "openvisualengine.com", a dead domain, which is why Facebook's Sharing
* Debugger reported the og:image as "corrupted" — it fetched a URL on a
* host that serves nothing). */
const SITE_URL = "https://openvisengine.com";
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: `${APP_SHORT} — ${APP_NAME} (v${APP_VERSION})`,
description:
"Open Visualization Engine: audio-reactive visuals for clubs — 3-band spectrum detection, predictive beat tracking and AVS-style visualization scripting.",
keywords: ["OVE", "visualization", "VJ", "audio-reactive", "beat detection", "AVS", "Winamp"],
openGraph: {
type: "website",
url: SITE_URL,
siteName: APP_NAME,
title: `${APP_SHORT} — ${APP_NAME}`,
description:
"Audio-reactive visuals for clubs: link a track and OVE paints it — 3-band spectrum detection, predictive beat tracking and 95+ AVS-style presets.",
images: [
{
// ?v=2 cache-bust: Facebook caches og:image per-URL — when the image
// file is ever swapped again, bump this so crawlers fetch it fresh
// (the v6.9.1 progressive-JPEG encoding taught us Facebook's image
// pipeline is stricter than any browser: baseline JPEG only).
url: "/og-image.jpg?v=2",
width: 1200,
height: 630,
alt: "Open Visualization Engine title card — gradient wordmark over spiral, particle and wireframe-sphere visualizations",
},
],
},
twitter: {
card: "summary_large_image",
title: `${APP_SHORT} — ${APP_NAME}`,
description:
"Audio-reactive visuals for clubs: link a track and OVE paints it — spectrum detection, beat tracking, 95+ presets.",
images: ["/og-image.jpg?v=2"],
},
icons: {
icon: "/logo.svg",
icon: [
{ url: "/favicon-32.png", sizes: "32x32", type: "image/png" },
{ url: "/favicon-16.png", sizes: "16x16", type: "image/png" },
{ url: "/logo.svg", type: "image/svg+xml" },
],
shortcut: "/favicon-32.png",
apple: "/apple-touch-icon.png",
},
};
/**
* Pre-paint theme boot (v6.5), must run before next-themes' own script:
* 1. normalize a stored v6.3/v6.4 "dark" theme to "purple" (the royal
* purple base is its successor), and
* 2. apply the stored highlight accent (data-accent on <html>) with an
* allowlist so a tampered value falls back to the v7.2 default (blue).
*/
const themeBootScript = `(function(){var d=document.documentElement;d.setAttribute('data-accent','${DEFAULT_ACCENT}');try{var a=localStorage.getItem('${ACCENT_STORAGE_KEY}');if(a&&({purple:1,yellow:1,blue:1,green:1,red:1,orange:1})[a]){d.setAttribute('data-accent',a)}var t=localStorage.getItem('theme');if(t==='dark'){localStorage.setItem('theme','purple')}}catch(e){}})()`;
export default function RootLayout({
children,
}: Readonly<{
@ -35,11 +88,16 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-zinc-950 text-zinc-100 font-sans`}
>
{/* class strategy: next-themes puts "dark" (default) or "light" on
<html> pre-paint; globals.css remaps the palettes under .light */}
<script dangerouslySetInnerHTML={{ __html: themeBootScript }} />
{/* class strategy: next-themes puts the base theme on <html>
pre-paint. v7.2 default for new visitors: "outline" (Black +
Purple Outline stage) — visitors who already picked a base have
it stored and never see this default; globals.css remaps the
palettes per base + data-accent */}
<ThemeProvider
attribute="class"
defaultTheme="dark"
defaultTheme="outline"
themes={["purple", "matte", "outline", "light"]}
enableSystem={false}
disableTransitionOnChange
>

View file

@ -16,6 +16,7 @@ import { AppHeader } from '@/components/ava/header'
import { VisualStage } from '@/components/ava/visual-stage'
import { ControlPanel } from '@/components/ava/control-panel'
import { FooterBar } from '@/components/ava/footer-bar'
import { QueueBar } from '@/components/ava/queue-bar'
import { applyScriptText, tapBeat } from '@/components/ava/audio-source'
import { STARTER_TEMPLATE } from '@/lib/ava/presets'
import {
@ -100,12 +101,18 @@ function useInitialLoad() {
const beat = readStoredBeatConfig()
if (beat) store.applyBeatConfig(beat)
// startup script: saved default (this browser) → first classic → template
// startup script: saved default (this browser) → v7.2 default preset
// (Zai-Magic "Bass Bloom" — the megabuf particle bloom that remembers
// every beat's hue) → any Zai-Magic preset → template
let text: string | null = lsGetString('ava.defaultScript')
let startupPreset: (typeof presetsList)[number] | undefined
if (!text) {
startupPreset =
presetsList.find((p) => p.folderPath === 'root/embedded/Synth-C') ?? presetsList[0]
presetsList.find(
(p) => p.folderPath === 'root/embedded/Zai-Magic' && p.name === 'Bass Bloom',
) ??
presetsList.find((p) => p.folderPath === 'root/embedded/Zai-Magic') ??
presetsList[0]
text = startupPreset?.content ?? null
}
if (!text) text = STARTER_TEMPLATE
@ -147,6 +154,8 @@ export default function Home() {
return (
<div className="flex min-h-screen flex-col bg-zinc-950 text-zinc-100 lg:h-screen lg:overflow-hidden">
<AppHeader />
{/* playlist queue strip — only renders while a queue is active */}
<QueueBar />
<main className="flex min-h-0 flex-1 flex-col gap-4 p-4 lg:flex-row">
<VisualStage className="min-h-[45vh] lg:min-h-0 lg:flex-1" />
<aside className="flex w-full min-h-0 shrink-0 flex-col lg:w-[420px] xl:w-[440px]">

View file

@ -12,7 +12,7 @@ import type { TapResult } from '@/lib/ava/beat'
import { validateScript } from '@/lib/ava/script'
import type { ScriptError } from '@/lib/ava/script'
import { useAvaStore } from '@/lib/store'
import { useToast } from '@/hooks/use-toast'
import { toast, useToast } from '@/hooks/use-toast'
/** "Set Beat" — tap tempo. Flashes the dot immediately with source "tap". */
export function tapBeat(): TapResult {
@ -24,6 +24,23 @@ export function tapBeat(): TapResult {
return result
}
// ---------------------------------------------------------------------------
// source-retire listener (v6.7 playlist queue interlock)
// ---------------------------------------------------------------------------
// When the visitor starts a different audio source (demo / mic / file / stop),
// an active playlist queue must retire. The queue controller registers
// itself here — this indirection keeps the module graph acyclic (audio-source
// ← queue; audio-source never imports the queue).
export type SourceRetireListener = () => void
let sourceRetireListener: SourceRetireListener | null = null
/** Register/unregister the queue's retire hook (null clears it). */
export function setSourceRetireListener(fn: SourceRetireListener | null): void {
sourceRetireListener = fn
}
/**
* Set the editor content and (when it compiles) apply it to the engine.
* Returns the validation outcome so callers can toast / show errors.
@ -46,35 +63,210 @@ export interface LinkLoadResult {
error?: string
}
/**
* Resolve a music link (YouTube / SoundCloud / direct MP3 …) through the
* server, then play the proxied same-origin stream. Never throws — the
* caller (LinkDialog) shows the error inline.
*/
export async function resolveAndPlayLink(url: string): Promise<LinkLoadResult> {
const engine = getEngine()
const trimmed = url.trim()
if (!/^https?:\/\/\S+$/i.test(trimmed)) {
return { ok: false, error: 'Paste a full http(s) link — e.g. https://www.youtube.com/watch?v=…' }
}
export interface PlaylistEntryInfo {
url: string
title: string
durationSec: number | null
}
/** A resolved single track, ready to play through the proxy. */
export interface ResolvedTrack {
ok: true
kind: 'track'
streamUrl: string
title: string
source: string
}
/** A resolved playlist / set — the dialog shows the entry list. */
export interface ResolvedPlaylist {
ok: true
kind: 'playlist'
title: string
source: string
entries: PlaylistEntryInfo[]
}
/** Failure codes the resolver attaches to errors — the UI special-cases
* 'bot-wall' (Open Settings action) and 'backend-error' (fixed wording). */
export type ResolveErrorCode = 'coming-soon' | 'bot-wall' | 'backend-error'
export type LinkResolveResult =
| ResolvedTrack
| ResolvedPlaylist
| { ok: false; error: string; code?: ResolveErrorCode; service?: string }
/** Extra knobs for resolveLink (v6.8 self-healing). */
export interface ResolveOpts {
/** Fires once when the server reports a background yt-dlp update — lets
* the queue bar switch to its "updating engine…" status while the retry
* loop below waits for the new binary. */
onUpdating?: () => void
}
/** Shape of the /api/resolve JSON body (track, playlist or error). */
interface ResolveResponse {
ok?: boolean
isPlaylist?: boolean
streamUrl?: string
title?: string
source?: string
entries?: PlaylistEntryInfo[]
error?: string
code?: ResolveErrorCode | 'ytdlp-updating'
service?: string
}
// ---- v6.8 self-healing retry loop -----------------------------------------
//
// When YouTube changes their mechanics, the server detects the stale
// extractor, starts a background yt-dlp update and answers
// { code:'ytdlp-updating' }. The visitor sees ONE "Updating in Background"
// toast; this loop keeps retrying (4 s, then every 8 s) for up to ~2 minutes.
// Once the new binary is in, the retry resolves the song and playback picks
// up seamlessly. If the server gives up (backend-error) or the loop runs
// dry, the visitor gets the honest "back-end problem" message.
const UPDATING_FIRST_WAIT_MS = 4_000
const UPDATING_NEXT_WAIT_MS = 8_000
const UPDATING_MAX_RETRIES = 14 // 4 s + 14 × 8 s ≈ 116 s of patience
const BACKEND_ERROR_TEXT = 'A back-end problem has occurred. Please try again later.'
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/** One POST /api/resolve round-trip (no retry logic). Never throws. */
async function resolveOnce(
trimmed: string,
auth: { service: string; cookies: string } | null | undefined,
): Promise<{ res: Response | null; data: ResolveResponse | null }> {
try {
const res = await fetch('/api/resolve', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: trimmed }),
body: JSON.stringify(
auth ? { url: trimmed, cookies: auth.cookies, cookieService: auth.service } : { url: trimmed },
),
})
const data = (await res.json().catch(() => null)) as
| { ok?: boolean; streamUrl?: string; title?: string; source?: string; error?: string }
| null
if (!res.ok || !data?.ok || !data.streamUrl) {
return { ok: false, error: data?.error ?? `The server could not resolve that link (HTTP ${res.status}).` }
const data = (await res.json().catch(() => null)) as ResolveResponse | null
return { res, data }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { res: null, data: { ok: false, error: `Could not reach the resolver: ${message}` } }
}
}
/**
* Resolve a music link (track OR playlist) through the server. When the
* visitor has a login for the matching music service (Settings tab), its
* decrypted cookie rides along so the server-side extractor uses their
* account — no more bot walls on their own playlists.
*
* v6.8: 'ytdlp-updating' answers are retried automatically behind an
* "Updating in Background" toast until the server's self-update lands (or
* the honest failure answer arrives) — callers never see the transient code.
*/
export async function resolveLink(
url: string,
auth?: { service: string; cookies: string } | null,
opts?: ResolveOpts,
): Promise<LinkResolveResult> {
const trimmed = url.trim()
if (!/^https?:\/\/\S+$/i.test(trimmed)) {
return { ok: false, error: 'Paste a full http(s) link — e.g. https://www.youtube.com/watch?v=…' }
}
let data: ResolveResponse | null = null
let res: Response | null = null
let sawUpdating = false
for (let attempt = 0; ; attempt++) {
const round = await resolveOnce(trimmed, auth)
res = round.res
data = round.data
// transient "updating" answer → show the toast once, then wait and retry
if (data?.code === 'ytdlp-updating' && attempt < UPDATING_MAX_RETRIES) {
if (!sawUpdating) {
sawUpdating = true
opts?.onUpdating?.()
toast({
title: 'Updating in Background',
description:
'YouTube changed how it serves audio — OVE is updating its audio engine on the server and will retry this song automatically.',
})
}
await sleep(attempt === 0 ? UPDATING_FIRST_WAIT_MS : UPDATING_NEXT_WAIT_MS)
continue
}
const title = data.title ?? trimmed
await engine.audio.startUrl(data.streamUrl, title)
// server still updating after ~2 minutes of patience → honest failure
if (data?.code === 'ytdlp-updating') {
return { ok: false, code: 'backend-error', error: BACKEND_ERROR_TEXT }
}
break
}
// the update landed and this song now resolves — close the loop for the
// visitor who has been staring at "Updating in Background"
if (sawUpdating && data?.ok) {
toast({
title: 'Audio engine updated',
description: 'The server update finished — playing your song now.',
})
}
if (!res || !res.ok || !data?.ok) {
return {
ok: false,
error: data?.error ?? `The server could not resolve that link (HTTP ${res ? res.status : 'network'}).`,
code: data?.code,
service: data?.service,
}
}
if (data.isPlaylist && Array.isArray(data.entries) && data.entries.length > 0) {
return {
ok: true,
kind: 'playlist',
title: data.title ?? 'Playlist',
source: data.source ?? 'link',
entries: data.entries.slice(0, 200),
}
}
if (!data.streamUrl) {
return { ok: false, error: 'That link did not contain any playable tracks.' }
}
return {
ok: true,
kind: 'track',
streamUrl: data.streamUrl,
title: data.title ?? trimmed,
source: data.source ?? 'link',
}
}
/**
* Play an already-resolved track through the proxied same-origin stream.
* Single links loop; the playlist queue passes { loop: false } so the element
* fires 'ended' and the queue advances to the next entry.
* Never throws — the caller (LinkDialog / queue) shows the error inline.
*/
export async function playResolvedTrack(
track: { streamUrl: string; title: string },
fallbackTitle: string,
opts?: { loop?: boolean },
): Promise<LinkLoadResult> {
const engine = getEngine()
try {
const title = track.title || fallbackTitle
await engine.audio.startUrl(track.streamUrl, title, opts)
const store = useAvaStore.getState()
store.setSource('link')
store.setSourceTitle(engine.audio.sourceLabel)
return { ok: true, title, source: data.source ?? 'link' }
ensureDefaultPreset()
return { ok: true, title, source: 'link' }
} catch (err) {
try {
engine.audio.stop()
@ -89,6 +281,23 @@ export async function resolveAndPlayLink(url: string): Promise<LinkLoadResult> {
}
}
/**
* Resolve a music link (YouTube / SoundCloud / direct MP3 …) through the
* server, then play the proxied same-origin stream. Playlists are refused
* here — the Link dialog queues them so their tracks stream one at a time.
*/
export async function resolveAndPlayLink(
url: string,
auth?: { service: string; cookies: string } | null,
): Promise<LinkLoadResult> {
const result = await resolveLink(url, auth)
if (!result.ok) return { ok: false, error: result.error }
if (result.kind === 'playlist') {
return { ok: false, error: 'That link is a playlist — load it to queue its tracks, then pick where to start.' }
}
return playResolvedTrack(result, url)
}
function micErrorMessage(err: unknown): string {
const name = (err as DOMException | null)?.name ?? ''
if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
@ -108,6 +317,66 @@ function fileErrorMessage(err: unknown): string {
return 'Could not play that audio file.'
}
// ---------------------------------------------------------------------------
// v7.2 showcase defaults — Bass Bloom + the Zai-Magic club mix
// ---------------------------------------------------------------------------
/** The read-only folder that ships the hand-authored Zai-Magic presets. */
const ZAI_MAGIC_FOLDER = 'root/embedded/Zai-Magic'
/**
* Music just started but no preset is active (the visitor never picked one —
* e.g. they are running their own untitled draft): load the default preset,
* Zai-Magic "Bass Bloom", so something beat-reactive is on screen the moment
* audio arrives. Engine-only — the editor's unsaved text is never touched
* (the same courtesy random switching pays).
*/
function ensureDefaultPreset(): void {
const s = useAvaStore.getState()
if (s.activePresetId !== null) return
const preset = s.presets.find(
(p) => p.folderPath === ZAI_MAGIC_FOLDER && p.name === 'Bass Bloom',
)
if (!preset) return
const v = validateScript(preset.content)
if (!v.ok || !v.script) return
// No toast (v7.3): preset changes are already echoed by the footer's
// SCRIPT: label — popup announcements of a load were intrusive and stole
// focus from the visuals.
getEngine().setScript(v.script)
s.setActivePresetId(preset.id)
}
/**
* The Demo button is the showcase: point the preset tree at Zai-Magic (and
* expand it so the visitor sees what is playing), then start the sequence —
* v7.4: every Zai-Magic preset IN ORDER, one every 20 seconds, wrapping
* around, for the one play-through (Sequence, not Random — the demo pass
* should walk the whole family, not repeat picks).
*/
function startShowcaseMix(): void {
const s = useAvaStore.getState()
if (s.folders.includes(ZAI_MAGIC_FOLDER)) {
s.selectFolder(ZAI_MAGIC_FOLDER) // also scopes the mix to the folder
s.toggleFolder(ZAI_MAGIC_FOLDER, true)
}
s.setRandomIntervalSec(20)
s.setSequenceMode(true) // mutual exclusion unticks Random if it was on
}
/**
* v7.3/v7.4: the demo is a single pass — when it ends (natural play-through,
* Stop button, or switching to another source) unwind the showcase state:
* the Demo flow force-checked the mix mode (Sequence), so it gets unchecked
* again. The visitor's own selection (the last preset that was running)
* stays put.
*/
function retireDemoShowcase(): void {
const s = useAvaStore.getState()
if (s.randomize) s.setRandomize(false)
if (s.sequenceMode) s.setSequenceMode(false)
}
/**
* Source lifecycle hook. Every method catches errors (mic permission, decode
* failures) → destructive toast, and syncs the store with the engine's
@ -125,25 +394,46 @@ export function useAudioSource() {
}, [])
const startDemo = useCallback(async () => {
sourceRetireListener?.()
const engine = getEngine()
try {
await engine.audio.startDemo()
// v7.3: single pass — when the track runs out (or is stopped, or
// replaced by another source) the engine fires onDemoEnded exactly
// once: sync the store to idle (the landing overlay returns) and
// uncheck the randomizer the showcase had checked.
engine.audio.onDemoEnded = () => {
syncSource()
retireDemoShowcase()
}
syncSource()
// showcase flow (v7.2/v7.4): nothing active → Bass Bloom, then the
// Zai-Magic sequence takes over — every preset in order, 20 s each,
// for this one play-through
ensureDefaultPreset()
startShowcaseMix()
toast({
title: 'Demo track playing',
description:
'Neural Mecha Pulse · Zai-Magic preset sequence — every preset in order, 20 s each, one play-through.',
})
} catch (err) {
syncSource()
toast({
title: 'Demo track failed',
description: `Could not start the built-in synth (${String(err)}).`,
description: `Could not load or decode the built-in track (${String(err)}).`,
variant: 'destructive',
})
}
}, [syncSource, toast])
const startMic = useCallback(async () => {
sourceRetireListener?.()
const engine = getEngine()
try {
await engine.audio.startMic()
syncSource()
ensureDefaultPreset()
toast({ title: 'Microphone live', description: 'Analyzing the mic input — it is never routed to the speakers.' })
} catch (err) {
syncSource()
@ -153,10 +443,12 @@ export function useAudioSource() {
const startFile = useCallback(
async (file: File) => {
sourceRetireListener?.()
const engine = getEngine()
try {
await engine.audio.startFile(file)
syncSource()
ensureDefaultPreset()
toast({ title: 'Playing file', description: file.name })
} catch (err) {
syncSource()
@ -167,6 +459,7 @@ export function useAudioSource() {
)
const stop = useCallback(() => {
sourceRetireListener?.()
const engine = getEngine()
engine.audio.stop()
syncSource()

View file

@ -123,7 +123,7 @@ export function BeatTab() {
className="py-1.5"
/>
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
A beat fires when the bass energy exceeds this multiple of the recent average — lower = more sensitive.
How strongly periodic the music's attacks must be before the tempo locks — lower = locks easier.
</p>
</div>
@ -163,12 +163,14 @@ export function BeatTab() {
<Separator className="bg-zinc-800" />
<p className="text-[11px] leading-relaxed text-zinc-500">
<span className="font-semibold text-zinc-400">How it works:</span> beats are detected from the smoothed bass
energy crossing the sensitivity threshold. Consistent intervals lock a tempo (the confidence meter), and a
phase-locked predictor then keeps firing beats on the learned grid — shown as a hollow ring with a PRED badge —
whenever the bass disappears. Detection and prediction only run while a source is playing —
they pause (and the BPM resets) whenever no music is loaded.{' '}
<span className="font-semibold text-zinc-400">Set Beat</span> teaches the tempo
<span className="font-semibold text-zinc-400">How it works:</span> the detector listens to the music's{' '}
<span className="text-zinc-400">attacks</span> — the frame-to-frame jump of energy across the whole
spectrum (onsets). Every ~100 ms those onsets are autocorrelated over 300–1000 ms lags: a strong, consistent
periodicity locks the tempo (the confidence meter), and a comb filter aligns a beat grid to it. Beats fire on
that grid — <span className="text-zinc-400">PRED</span> when the grid carries the beat through a passage with
no clear attacks. This works on real mastered music, where a loud sustained sub-bass hides the kick from any
level-based detector. Detection only runs while a source is playing — it pauses (and the BPM resets) whenever
no music is loaded. <span className="font-semibold text-zinc-400">Set Beat</span> teaches the tempo
directly: tap along and two or more taps lock the period and phase to your rhythm.
</p>
</div>

View file

@ -1,4 +1,4 @@
// AVA — right-hand control panel: the five tabs.
// OVE — right-hand control panel: the five tabs.
//
// Desktop: fixed width, content scrolls inside the panel. Mobile: natural
// height (the page scrolls and the footer is pushed down naturally).
@ -12,10 +12,17 @@ import { ScriptTab } from '@/components/ava/script-tab'
import { DetectTab } from '@/components/ava/detect-tab'
import { BeatTab } from '@/components/ava/beat-tab'
import { NativeTab } from '@/components/ava/native-tab'
import { SettingsTab } from '@/components/ava/settings-tab'
import { useAvaStore } from '@/lib/store'
import { thinScrollbar } from '@/components/ava/shared'
import { cn } from '@/lib/utils'
// v6.5 — the Native tab is PARKED (hidden) so the Settings tab can take its
// slot; it returns unchanged when the project nears completion. Flip this
// flag to bring it back — the tab row widens to 6 columns automatically and
// the component + /api/rust-scaffold endpoint are untouched.
const NATIVE_TAB_ENABLED = false
function TabBody({ children }: { children: React.ReactNode }) {
return (
<motion.div
@ -35,7 +42,12 @@ export function ControlPanel() {
return (
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList className="grid h-11 w-full shrink-0 grid-cols-5 bg-zinc-900/80">
<TabsList
className={cn(
'h-11 w-full shrink-0 bg-zinc-900/80',
NATIVE_TAB_ENABLED ? 'grid-cols-6' : 'grid-cols-5',
)}
>
<TabsTrigger value="presets" className="text-xs">
Presets
</TabsTrigger>
@ -48,8 +60,13 @@ export function ControlPanel() {
<TabsTrigger value="beat" className="text-xs">
Beat
</TabsTrigger>
<TabsTrigger value="native" className="text-xs">
Native
{NATIVE_TAB_ENABLED && (
<TabsTrigger value="native" className="text-xs">
Native
</TabsTrigger>
)}
<TabsTrigger value="settings" className="text-xs">
Settings
</TabsTrigger>
</TabsList>
@ -74,9 +91,16 @@ export function ControlPanel() {
<BeatTab />
</TabBody>
</TabsContent>
<TabsContent value="native" className="flex min-h-0 flex-1 flex-col outline-none">
{NATIVE_TAB_ENABLED && (
<TabsContent value="native" className="flex min-h-0 flex-1 flex-col outline-none">
<TabBody>
<NativeTab />
</TabBody>
</TabsContent>
)}
<TabsContent value="settings" className="flex min-h-0 flex-1 flex-col outline-none">
<TabBody>
<NativeTab />
<SettingsTab />
</TabBody>
</TabsContent>
</div>

View file

@ -1,20 +1,20 @@
// OVE — sticky app header: logo, audio source controls (segmented), demo BPM
// slider, theme tick switch, BPM readout, and the beat indicator + "Set Beat"
// button side by side (hard requirement: that pairing is always visible in
// the header).
// OVE — sticky app header: logo, audio source controls (segmented), the
// demo-track chip, theme tick switch, BPM readout, and the beat indicator +
// "Set Beat" button side by side (hard requirement: that pairing is always
// visible in the header).
'use client'
import { useRef, useSyncExternalStore } from 'react'
import { AudioWaveform, Disc3, FileAudio, Link2, Mic, Moon, Square, Sun, Timer } from 'lucide-react'
import { AudioWaveform, Disc3, FileAudio, Link2, Mic, Moon, Music2, Square, Sun, Timer } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { Switch } from '@/components/ui/switch'
import { BeatDot } from '@/components/ava/beat-dot'
import { LinkDialog } from '@/components/ava/link-dialog'
import { tapBeat, useAudioSource } from '@/components/ava/audio-source'
import { useAvaStore } from '@/lib/store'
import { getStoredDarkBase } from '@/lib/theme'
import { APP_NAME, APP_SHORT } from '@/lib/version'
import { cn } from '@/lib/utils'
@ -56,8 +56,7 @@ function SourceSegmentButton({
function SourceControls() {
const source = useAvaStore((s) => s.source)
const setDemoBpm = useAvaStore((s) => s.setDemoBpm)
const demoBpm = useAvaStore((s) => s.demoBpm)
const sourceTitle = useAvaStore((s) => s.sourceTitle)
const openLinkDialog = useAvaStore((s) => s.setLinkDialogOpen)
const { startDemo, startMic, startFile, stop } = useAudioSource()
const fileInputRef = useRef<HTMLInputElement>(null)
@ -74,7 +73,7 @@ function SourceControls() {
onClick={() => void startDemo()}
icon={Disc3}
label="Demo"
title="Built-in demo track (synthesized techno)"
title="Built-in demo track — Neural Mecha Pulse (mastered industrial EDM, plays once through)"
/>
<SourceSegmentButton
active={source === 'mic'}
@ -101,18 +100,14 @@ function SourceControls() {
</div>
{source === 'demo' && (
<div className="flex h-11 items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3">
<span className="font-mono text-[10px] tracking-widest text-emerald-400/80">DEMO</span>
<Slider
value={[demoBpm]}
min={60}
max={200}
step={1}
onValueChange={([v]) => setDemoBpm(v)}
className="w-24"
aria-label="Demo track tempo"
/>
<span className="w-8 text-right font-mono text-xs tabular-nums text-zinc-300">{demoBpm}</span>
<div
className="flex h-11 max-w-[240px] items-center gap-2 rounded-lg border border-zinc-800 bg-zinc-900/60 px-3"
title="The demo track is a real mastered recording — its tempo is detected live, not synthesized. Watch the BPM readout lock on."
>
<Music2 className="size-3.5 shrink-0 text-emerald-400/80" aria-hidden />
<span className="truncate font-mono text-[11px] tracking-wide text-zinc-300">
{sourceTitle ?? 'Demo track'}
</span>
</div>
)}
@ -145,10 +140,11 @@ function BpmReadout() {
/**
* Light-switch tick: left position (unchecked) keeps the dark theme, flipping
* right (checked) switches the page to the white theme. Persisted per visitor
* by next-themes (localStorage). The useSyncExternalStore mount guard returns
* false on the server / first client render and true after hydration, so the
* checkbox never causes a hydration mismatch.
* right (checked) switches the page to the white theme. The dark side is
* whichever dark base the visitor picked on the Settings tab (royal purple
* by default); both choices persist per visitor. The useSyncExternalStore
* mount guard returns false on the server / first client render and true
* after hydration, so the checkbox never causes a hydration mismatch.
*/
const emptySubscribe = () => () => {}
function ThemeToggle() {
@ -164,7 +160,7 @@ function ThemeToggle() {
<Moon className="size-3.5 shrink-0 text-zinc-500" aria-hidden />
<Switch
checked={isLight}
onCheckedChange={(v) => setTheme(v ? 'light' : 'dark')}
onCheckedChange={(v) => setTheme(v ? 'light' : getStoredDarkBase())}
aria-label={isLight ? 'Switch to dark theme' : 'Switch to light theme'}
className="data-[state=checked]:bg-emerald-400 data-[state=unchecked]:bg-zinc-600"
/>

View file

@ -19,6 +19,7 @@ const FRAME_VARS: [string, string][] = [
['beatphase', '0..1 position within the beat period'],
['beatconf', '0..1 confidence of the tempo lock'],
['beats', 'total beat count since load'],
['beatage', 'seconds since the last beat (0 on the beat frame) — exp(-beatage*4) is a kick envelope'],
['w / h', 'canvas size in pixels'],
['aspect', 'canvas width / height — divide x by it for perfect circles'],
['persist', 'motion-trail persistence 0..0.98 (writable per frame)'],
@ -42,6 +43,8 @@ const FUNCS: [string, string][] = [
['floor ceil round int frac sign', 'rounding & sign'],
['min max', 'pairwise extrema'],
['rand(seed)', 'deterministic per-seed random 0..1 — rand(i) is stable per point'],
['hsvr(h, s?, v?) hsvg hsvb', 'HSV→RGB channels (hue wraps 0..1; s, v default 1) — red = hsvr(hue) + green = hsvg(hue) + blue = hsvb(hue) paints the rainbow'],
['smoothstep(e0, e1, x)', 'hermite-smooth 0..1 step between e0 and e1'],
['lerp(a, b, t) clamp(x, lo, hi)', 'interpolate & constrain'],
['inrange(x, lo, hi)', '1 when lo ≤ x ≤ hi'],
['above(a, b) below(a, b) equal(a, b)', 'comparisons returning 1/0'],
@ -51,10 +54,13 @@ const FUNCS: [string, string][] = [
]
const EXAMPLES: [string, string][] = [
['hue = frac(hue + beat * 0.125)', 'rotate the color wheel 1/8 turn on every beat'],
['red = hsvr(hue); green = hsvg(hue); blue = hsvb(hue)', 'full rainbow from one hue variable'],
['flash = exp(-beatage * 5)', 'kick envelope — 1 on the beat, fades fast'],
['if(equal(beats % 4, 0), hue = frac(hue + 0.25), 0)', 'shift color once per 4-beat bar'],
['rad = 0.2 + bass * 0.6', 'bass pulse'],
['rot = rot + dt * 1.2 + beat * 0.4', 'beat kick'],
['ang = rand(i) * 6.28318', 'stable per-point random'],
['size = if(above(bass, 0.5), 6, 2)', 'conditional dot size'],
['x = sin(i * 6.28318 + t) * rad / aspect', 'circle'],
]

View file

@ -1,9 +1,17 @@
// AVA — "Stream a link" dialog.
// OVE — "Stream a link" dialog.
//
// Paste a music link (YouTube / SoundCloud / Bandcamp / direct MP3 …), it is
// resolved through POST /api/resolve and played from the same-origin proxy so
// the Web Audio analyser can read it. Inline errors, and the last few links
// are kept in localStorage (club use: one click to re-load a track).
// Paste a music link (YouTube / SoundCloud / Bandcamp / direct MP3 — or a
// playlist link). Single tracks resolve through POST /api/resolve and play
// through the same-origin proxy so the Web Audio analyser can read them.
// Playlists become a LAZY QUEUE (lib/ava/queue.ts): picking any track starts
// the whole list, but songs are resolved ONE AT A TIME, each only right
// before it plays — playlist hosts are never hit with bulk requests (v6.7
// anti-spam model). Services whose playback ships in a later update (Spotify,
// Apple Music) are intercepted with a friendly notice — no request is sent.
//
// If the visitor has a login for the matching music service (Settings tab →
// Music services), its decrypted cookie rides along with each resolve so
// their own account is used — playlists, liked music, no bot walls.
//
// Structure note: the body REMOUNTS every time the dialog opens (fresh local
// state — no reset effects), and results that arrive after the user closed
@ -12,7 +20,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { Link2, Loader2 } from 'lucide-react'
import { ArrowLeft, Link2, Loader2, ListMusic, Play, Settings2, ShieldCheck } from 'lucide-react'
import {
Dialog,
DialogContent,
@ -22,7 +30,15 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { resolveAndPlayLink } from '@/components/ava/audio-source'
import {
playResolvedTrack,
resolveLink,
type PlaylistEntryInfo,
type ResolveErrorCode,
} from '@/components/ava/audio-source'
import { startQueue, stopQueue } from '@/lib/ava/queue'
import { isServiceId, serviceDef, serviceForUrl, type ServiceDef } from '@/lib/music-services'
import { getCookieForUrl, listMusicSessions } from '@/lib/music-sessions'
import { useAvaStore } from '@/lib/store'
import { useToast } from '@/hooks/use-toast'
import { thinScrollbar } from '@/components/ava/shared'
@ -61,12 +77,18 @@ function pushRecent(item: RecentLink): RecentLink[] {
return trimmed
}
/** Extra hint when YouTube blocks the server but the server message didn't already explain it. */
function botWallHint(error: string): string | null {
if (/native client|datacenter/i.test(error)) return null
return /bot|sign in|confirm/i.test(error)
? 'The hosting server\u2019s IP may be rate-limited by YouTube — a SoundCloud, Bandcamp or direct-file link usually works, or run the native client on your own machine.'
: null
function fmtDuration(sec: number | null): string {
if (sec === null || !Number.isFinite(sec) || sec <= 0) return ''
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
interface PlaylistView {
url: string
title: string
source: string
entries: PlaylistEntryInfo[]
}
function LinkDialogBody() {
@ -74,6 +96,16 @@ function LinkDialogBody() {
const [url, setUrl] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// resolver failure code for the current error ('bot-wall' gets a Settings
// action, 'backend-error' is the fixed self-heal-gave-up wording)
const [errorCode, setErrorCode] = useState<ResolveErrorCode | null>(null)
// playlist picker state (shown instead of recent links once resolved)
const [playlist, setPlaylist] = useState<PlaylistView | null>(null)
// set when the pasted link belongs to a service whose playback ships in a
// later update (Spotify / Apple Music) — shows the framework notice
const [comingSoon, setComingSoon] = useState<ServiceDef | null>(null)
// services this visitor is logged in to (for the hint line + auth attach)
const [loggedInServices, setLoggedInServices] = useState<Set<string>>(new Set())
// lazy initializer: fresh from localStorage on every mount (= every open)
const [recent, setRecent] = useState<RecentLink[]>(() => loadRecent())
const inputRef = useRef<HTMLInputElement>(null)
@ -84,17 +116,47 @@ function LinkDialogBody() {
return () => clearTimeout(t)
}, [])
useEffect(() => {
let cancelled = false
void listMusicSessions().then((s) => {
if (!cancelled) setLoggedInServices(new Set(Object.keys(s.loggedIn)))
})
return () => {
cancelled = true
}
}, [])
const urlService = serviceForUrl(url.trim())
const usingLogin = urlService !== null && loggedInServices.has(urlService)
const submit = async (value: string) => {
const v = value.trim()
if (!v || busy) return
setBusy(true)
setError(null)
const result = await resolveAndPlayLink(v)
setErrorCode(null)
setComingSoon(null)
// framework intercept: services whose playback ships later never hit the
// resolver — the visitor gets the honest "later update" notice instead
const svc = serviceForUrl(v)
if (svc !== null && serviceDef(svc).streamStatus === 'coming-soon') {
setComingSoon(serviceDef(svc))
return
}
setBusy(true)
// attach the visitor's login for the matching service, if any
const auth = await getCookieForUrl(v)
const result = await resolveLink(v, auth)
setBusy(false)
// user closed the dialog while resolving → report as a toast instead
if (!useAvaStore.getState().linkDialogOpen) {
if (result.ok) toast({ title: 'Streaming link', description: result.title ?? v })
if (result.ok)
toast({
title: result.kind === 'playlist' ? 'Playlist loaded' : 'Streaming link',
description: result.title ?? v,
})
else
toast({
title: 'Link failed',
@ -104,18 +166,65 @@ function LinkDialogBody() {
return
}
if (result.ok) {
if (!result.ok) {
// server-side backstop of the same framework notice
if (result.code === 'coming-soon' && isServiceId(result.service)) {
setComingSoon(serviceDef(result.service))
return
}
setError(result.error ?? 'Could not load that link.')
setErrorCode(result.code ?? null)
return
}
if (result.kind === 'playlist') {
setPlaylist({ url: v, title: result.title, source: result.source, entries: result.entries })
return
}
// single track → retire any active queue, then play it
stopQueue()
const played = await playResolvedTrack(result, v)
if (!useAvaStore.getState().linkDialogOpen) {
if (played.ok) toast({ title: 'Streaming link', description: played.title ?? v })
else
toast({
title: 'Link failed',
description: played.error ?? 'Could not load that link.',
variant: 'destructive',
})
return
}
if (played.ok) {
setRecent(
pushRecent({ url: v, title: result.title ?? v, source: (result.source ?? 'link').toUpperCase() }),
pushRecent({ url: v, title: played.title ?? v, source: (played.source ?? 'link').toUpperCase() }),
)
setUrl('')
setPlaylist(null)
setOpen(false)
toast({ title: 'Streaming link', description: result.title ?? v })
toast({ title: 'Streaming link', description: played.title ?? v })
} else {
setError(result.error ?? 'Could not load that link.')
setError(played.error ?? 'Could not load that link.')
}
}
/** Start the lazy queue from any index — the whole list plays behind it. */
const startQueueFrom = (startIndex: number) => {
if (!playlist) return
startQueue(
{ ok: true, kind: 'playlist', title: playlist.title, source: playlist.source, entries: playlist.entries },
startIndex,
)
setRecent(pushRecent({ url: playlist.url, title: playlist.title, source: 'QUEUE' }))
setUrl('')
setPlaylist(null)
setOpen(false)
toast({
title: 'Queue started',
description: `${playlist.title} — ${playlist.entries.length} tracks, streamed one at a time.`,
})
}
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>
@ -124,38 +233,149 @@ function LinkDialogBody() {
Stream a music link
</DialogTitle>
<DialogDescription>
YouTube, SoundCloud, Bandcamp, a direct MP3 — the audio is resolved through this
server, then analyzed and rendered live on your machine.
A track plays immediately; a playlist becomes a queue that streams one song at a time —
each is fetched only right before it plays. YouTube, SoundCloud, Bandcamp and direct
files work today; Spotify and Apple Music arrive in a later update.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault()
void submit(url)
}}
className="flex gap-2"
>
<Input
ref={inputRef}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.youtube.com/watch?v=…"
disabled={busy}
aria-label="Music link"
spellCheck={false}
autoComplete="off"
className="font-mono text-xs"
/>
<Button
type="submit"
disabled={busy || !url.trim()}
className="shrink-0 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
{playlist ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPlaylist(null)}
disabled={busy}
className="h-7 gap-1 border-zinc-700 px-2 text-[11px]"
>
<ArrowLeft className="size-3" aria-hidden />
Back
</Button>
<p className="min-w-0 flex-1 truncate text-xs font-semibold" title={playlist.title}>
{playlist.title}
</p>
<span className="shrink-0 font-mono text-[10px] text-zinc-500">
{playlist.entries.length} tracks
</span>
</div>
<Button
onClick={() => startQueueFrom(0)}
className="h-8 w-full gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
>
<Play className="size-3.5" aria-hidden />
Play all — lazy queue
</Button>
<p className="text-[10px] leading-snug text-zinc-500">
Starting anywhere queues the whole list behind it. Only one song is ever being fetched —
the next one resolves just before it plays, so the source is never spammed.
</p>
<div className={cn('max-h-64 space-y-1 overflow-y-auto pr-1', thinScrollbar)}>
{playlist.entries.map((e, i) => {
const dur = fmtDuration(e.durationSec)
return (
<button
key={e.url}
type="button"
onClick={() => startQueueFrom(i)}
disabled={busy}
title={`${e.title} — start the queue from this track`}
className="group flex w-full items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900/60 px-2.5 py-1.5 text-left text-xs text-zinc-300 transition-colors hover:border-emerald-500/40 hover:text-zinc-100 disabled:opacity-50"
>
<span className="w-4 shrink-0 text-right font-mono text-[10px] tabular-nums text-zinc-600">
{i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{e.title}</span>
{dur && (
<span className="shrink-0 font-mono text-[10px] tabular-nums text-zinc-500">
{dur}
</span>
)}
<Play
className="size-3 shrink-0 text-emerald-400 opacity-0 transition-opacity group-hover:opacity-100"
aria-hidden
/>
</button>
)
})}
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault()
void submit(url)
}}
className="flex gap-2"
>
{busy ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
{busy ? 'Loading' : 'Load'}
</Button>
</form>
<Input
ref={inputRef}
value={url}
onChange={(e) => {
setUrl(e.target.value)
setComingSoon(null)
setError(null)
setErrorCode(null)
}}
placeholder="https://www.youtube.com/watch?v=… or …/playlist?list=…"
disabled={busy}
aria-label="Music link"
spellCheck={false}
autoComplete="off"
className="font-mono text-xs"
/>
<Button
type="submit"
disabled={busy || !url.trim()}
className="shrink-0 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
>
{busy ? <Loader2 className="size-4 animate-spin" aria-hidden /> : null}
{busy ? 'Fetching' : 'Load'}
</Button>
</form>
)}
{busy && (
<p className="text-[11px] leading-relaxed text-zinc-500" role="status">
Downloading the audio and converting it to MP3 on the server — this
can take a little while on longer tracks.
</p>
)}
{usingLogin && urlService && !playlist && !comingSoon && (
<p className="flex items-center gap-1.5 text-[11px] text-emerald-300">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden />
Using your {serviceDef(urlService).name} login for this link.
</p>
)}
{comingSoon && (
<div
role="status"
className="space-y-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-3 py-2.5 text-xs leading-relaxed text-amber-200"
>
<p className="flex items-center gap-1.5 font-semibold">
<span
className="size-2 shrink-0 rounded-full"
style={{ background: comingSoon.brandColor }}
aria-hidden
/>
{comingSoon.name} — coming in a later update
</p>
<p className="text-amber-200/80">{comingSoon.comingSoonNote ?? comingSoon.note}</p>
<button
type="button"
onClick={() => {
useAvaStore.getState().setTab('settings')
setOpen(false)
}}
className="flex items-center gap-1.5 text-[11px] font-medium text-amber-200 underline-offset-2 transition-colors hover:text-amber-100 hover:underline"
>
<Settings2 className="size-3.5" aria-hidden />
Open Settings to save your {comingSoon.name} login ahead of time
</button>
</div>
)}
{error && (
<div
@ -163,11 +383,29 @@ function LinkDialogBody() {
className="space-y-1 rounded-md border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-xs leading-relaxed text-rose-300"
>
<p>{error}</p>
{botWallHint(error) && <p className="text-rose-300/70">{botWallHint(error)}</p>}
{errorCode === 'bot-wall' && (
<button
type="button"
onClick={() => {
useAvaStore.getState().setTab('settings')
setOpen(false)
}}
className="flex items-center gap-1.5 text-[11px] font-medium text-rose-200 underline-offset-2 transition-colors hover:text-rose-100 hover:underline"
>
<Settings2 className="size-3.5" aria-hidden />
Open Settings to log in, then try again
</button>
)}
{!usingLogin && errorCode !== 'bot-wall' && errorCode !== 'backend-error' && (
<p className="text-rose-300/70">
Tip: log in to the service from the Settings tab — links then resolve with your own
account and usually skip this.
</p>
)}
</div>
)}
{recent.length > 0 && (
{!playlist && recent.length > 0 && (
<div>
<p className="mb-1.5 font-mono text-[10px] tracking-[0.2em] text-zinc-500">
RECENT LINKS
@ -195,9 +433,10 @@ function LinkDialogBody() {
</div>
)}
<p className="text-[11px] leading-relaxed text-zinc-500">
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-zinc-500">
<ListMusic className="mt-0.5 size-3.5 shrink-0" aria-hidden />
Everything after the link resolves — FFT analysis, beat tracking and rendering — runs
locally in your browser.
locally in your browser. Log in from the Settings tab to use your own playlists.
</p>
</DialogContent>
)

View file

@ -167,7 +167,9 @@ function useLoadPreset() {
if (result.ok) {
st.setActivePresetId(preset.id)
st.setEditTarget({ id: preset.id, name: preset.name, folderPath: preset.folderPath })
toast({ title: 'Preset loaded', description: `"${preset.name}" is now running.` })
// No "Preset loaded" toast (v7.3): the footer's SCRIPT: label
// already echoes the running preset — the popup was intrusive and
// only compile errors genuinely need the attention grab.
} else if (result.error) {
toast({
title: 'Preset failed to compile',
@ -454,6 +456,7 @@ export function PresetsTab() {
const selectedFolder = useAvaStore((s) => s.selectedFolder)
const selectedPresetId = useAvaStore((s) => s.selectedPresetId)
const randomize = useAvaStore((s) => s.randomize)
const sequenceMode = useAvaStore((s) => s.sequenceMode)
const randomIntervalSec = useAvaStore((s) => s.randomIntervalSec)
const randomScope = useAvaStore((s) => s.randomScope)
@ -824,7 +827,7 @@ export function PresetsTab() {
variant="outline"
onClick={() => openDialog('rename', selectedPreset.name)}
title="Rename this preset (within its folder)"
className="h-9 gap-1.5 border-zinc-700"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Pencil className="size-3.5" /> Rename
</Button>
@ -870,7 +873,7 @@ export function PresetsTab() {
disabled={busy || folderPresetCount === 0}
onClick={() => void exportZip()}
title="Download this folder (with subfolders) as a .zip preserving the tree — built in your browser"
className="h-9 gap-1.5 border-zinc-700"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Download className="size-3.5" /> Export .zip
</Button>
@ -880,7 +883,7 @@ export function PresetsTab() {
disabled={busy}
onClick={() => importInputRef.current?.click()}
title="Import a .zip of .ava presets — they land in your browser's 'user created' folder"
className="h-9 gap-1.5 border-zinc-700"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Upload className="size-3.5" /> Import .zip
</Button>
@ -913,7 +916,7 @@ export function PresetsTab() {
disabled={folderProtected}
onClick={() => openDialog('rename', folderName)}
title={folderProtected ? '"user created" is the root of your library' : `Rename ${folderName} — everything inside moves with it`}
className="h-9 gap-1.5 border-zinc-700"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Pencil className="size-3.5" /> Rename
</Button>
@ -935,7 +938,7 @@ export function PresetsTab() {
disabled={busy || folderPresetCount === 0}
onClick={() => void exportZip()}
title="Download this folder (with subfolders) as a .zip preserving the tree — built in your browser"
className="h-9 gap-1.5 border-zinc-700"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Download className="size-3.5" /> Export .zip
</Button>
@ -945,7 +948,7 @@ export function PresetsTab() {
disabled={busy}
onClick={() => importInputRef.current?.click()}
title={`Import a .zip of .ava presets into ${selectedFolder} — the tree inside the zip is preserved`}
className="h-9 gap-1.5 border-zinc-700"
className="h-8 shrink-0 gap-1 whitespace-nowrap text-xs has-[>svg]:px-2 border-zinc-700"
>
<Upload className="size-3.5" /> Import .zip
</Button>
@ -954,20 +957,34 @@ export function PresetsTab() {
)}
</div>
{/* randomize (club mode) — timer runs in the store */}
{/* preset mixing (club mode) — Random or Sequence, one timer in the store */}
<div className="mt-4 border-t border-zinc-800/80 pt-3">
<div className="flex items-center gap-2">
<Checkbox
id="ava-randomize"
checked={randomize}
onCheckedChange={(v) => useAvaStore.getState().setRandomize(v === true)}
aria-label="Randomize presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-randomize" className="cursor-pointer text-xs text-zinc-300">
Randomize
</Label>
<div className="ml-auto flex items-center gap-1.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2">
<span className="flex items-center gap-2">
<Checkbox
id="ava-randomize"
checked={randomize}
onCheckedChange={(v) => useAvaStore.getState().setRandomize(v === true)}
aria-label="Random presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-randomize" className="cursor-pointer text-xs text-zinc-300">
Random
</Label>
</span>
<span className="flex items-center gap-2">
<Checkbox
id="ava-sequence"
checked={sequenceMode}
onCheckedChange={(v) => useAvaStore.getState().setSequenceMode(v === true)}
aria-label="Sequence presets"
className="size-4 data-[state=checked]:border-emerald-400 data-[state=checked]:bg-emerald-400 data-[state=checked]:text-emerald-950"
/>
<Label htmlFor="ava-sequence" className="cursor-pointer text-xs text-zinc-300">
Sequence
</Label>
</span>
<span className="ml-auto flex items-center gap-1.5">
<Input
value={delayText}
onChange={(e) => onDelayChange(e.target.value)}
@ -977,20 +994,26 @@ export function PresetsTab() {
className="h-8 w-16 border-zinc-700 bg-zinc-900 px-2 text-center font-mono text-xs tabular-nums"
/>
<span className="text-[11px] text-zinc-500">sec</span>
</div>
</span>
</div>
{delayError && (
<p className="mt-1.5 text-[11px] text-rose-400">Delay must be between 3 and 60 seconds.</p>
)}
<p className="mt-2 text-[11px] leading-relaxed text-zinc-500">
Random from: <span className="font-mono text-zinc-400">{scopeLabel}</span>
{sequenceMode ? 'Sequence from:' : 'Random from:'}{' '}
<span className="font-mono text-zinc-400">{scopeLabel}</span>
{scopeCount === 0 && <span className="ml-1 text-amber-300/80">— no presets in this folder yet</span>}
</p>
{randomize && (
<p className="mt-1 text-[11px] leading-relaxed text-zinc-600">
{sequenceMode
? 'Cycles the folder top-to-bottom, wraps back to the first preset.'
: 'Shuffles the folder — never the same preset twice in a row.'}
</p>
{(randomize || sequenceMode) && (
<div className="mt-2">
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 font-mono text-[10px] text-emerald-300">
<span className="size-1.5 animate-pulse rounded-full bg-emerald-400" aria-hidden />
ON · every {randomIntervalSec}s
{sequenceMode ? 'SEQUENCE' : 'RANDOM'} · every {randomIntervalSec}s
</span>
</div>
)}

316
src/components/ava/queue-bar.tsx Executable file
View file

@ -0,0 +1,316 @@
// OVE — playlist queue bar (v6.7).
//
// Slim strip directly under the header, visible while a playlist queue is
// active. Shows what's playing, where we are in the list, lazy-load status,
// prev/next/jump/stop controls and the expandable track list. The queue
// itself (lib/ava/queue.ts) fetches ONE track at a time — this bar surfaces
// that promise to the visitor.
'use client'
import { useState } from 'react'
import { AnimatePresence, motion } from 'framer-motion'
import {
AlertTriangle,
ChevronDown,
ChevronUp,
Clock,
ListMusic,
Loader2,
PauseCircle,
Play,
RotateCcw,
Settings2,
SkipBack,
SkipForward,
X,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
queueJump,
queueNext,
queuePrev,
replayQueue,
stopQueue,
useQueueStore,
type QueueStatus,
} from '@/lib/ava/queue'
import { useAvaStore } from '@/lib/store'
import { thinScrollbar } from '@/components/ava/shared'
import { cn } from '@/lib/utils'
function fmtDuration(sec: number | null): string {
if (sec === null || !Number.isFinite(sec) || sec <= 0) return ''
const m = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
return `${m}:${String(s).padStart(2, '0')}`
}
function statusChip(
status: QueueStatus,
preloading: boolean,
updating: boolean,
): { label: string; cls: string } | null {
switch (status) {
case 'loading':
// v6.8: background yt-dlp self-update outranks the plain "fetching"
return updating
? { label: 'updating engine…', cls: 'border-amber-400/40 bg-amber-400/10 text-amber-300' }
: { label: 'fetching', cls: 'border-zinc-700 bg-zinc-800/60 text-zinc-300' }
case 'playing':
// v6.8: a look-ahead resolve stuck in the self-update loop outranks
// the plain "next up ready" chip
return updating
? { label: 'updating engine…', cls: 'border-amber-400/40 bg-amber-400/10 text-amber-300' }
: preloading
? { label: 'next up ready', cls: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-300' }
: null
case 'done':
return { label: 'finished', cls: 'border-zinc-700 bg-zinc-800/60 text-zinc-300' }
case 'blocked':
return { label: 'paused', cls: 'border-amber-400/40 bg-amber-400/10 text-amber-300' }
case 'error':
return { label: 'stopped', cls: 'border-rose-400/40 bg-rose-400/10 text-rose-300' }
default:
return null
}
}
/** Tiny icon button used for prev / next / expand / close. */
function IconButton({
onClick,
disabled,
title,
children,
}: {
onClick: () => void
disabled?: boolean
title: string
children: React.ReactNode
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
aria-label={title}
className="grid size-8 place-items-center rounded-md text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-40 disabled:hover:bg-transparent"
>
{children}
</button>
)
}
export function QueueBar() {
const status = useQueueStore((s) => s.status)
const title = useQueueStore((s) => s.title)
const entries = useQueueStore((s) => s.entries)
const index = useQueueStore((s) => s.index)
const trackTitle = useQueueStore((s) => s.trackTitle)
const lastError = useQueueStore((s) => s.lastError)
const lastErrorCode = useQueueStore((s) => s.lastErrorCode)
const preloading = useQueueStore((s) => s.preloading)
const updating = useQueueStore((s) => s.updating)
const setTab = useAvaStore((s) => s.setTab)
const [expanded, setExpanded] = useState(false)
if (status === 'idle' || entries.length === 0) return null
const chip = statusChip(status, preloading, updating)
const current = index >= 0 && index < entries.length ? entries[index] : null
return (
<motion.section
role="region"
aria-label="Playlist queue"
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="shrink-0 border-b border-zinc-800/80 bg-zinc-900/50"
>
<div className="mx-auto max-w-[1800px] px-3 py-2 sm:px-4">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{/* playlist identity + position */}
<div className="flex min-w-0 items-center gap-2">
<ListMusic className="size-4 shrink-0 text-emerald-400" aria-hidden />
<span className="max-w-[180px] truncate text-sm font-semibold sm:max-w-[280px]" title={title}>
{title || 'Playlist'}
</span>
<span className="shrink-0 rounded border border-zinc-700 bg-zinc-800/60 px-1.5 py-px font-mono text-[10px] tabular-nums text-zinc-300">
{index + 1}/{entries.length}
</span>
{chip && (
<span
className={cn(
'hidden shrink-0 items-center gap-1 rounded border px-1.5 py-px text-[10px] font-medium sm:inline-flex',
chip.cls,
)}
>
{status === 'loading' && <Loader2 className="size-3 animate-spin" aria-hidden />}
{chip.label}
</span>
)}
</div>
{/* now playing / state message */}
<div className="order-3 min-w-0 flex-1 basis-full sm:order-none sm:basis-auto">
{status === 'done' ? (
<p className="flex items-center gap-1.5 text-xs text-zinc-400">
<RotateCcw className="size-3.5 shrink-0 text-emerald-400" aria-hidden />
Queue finished — {entries.length} track{entries.length === 1 ? '' : 's'} played.
</p>
) : status === 'blocked' ? (
<p className="flex items-center gap-1.5 text-xs text-amber-300">
<PauseCircle className="size-3.5 shrink-0" aria-hidden />
The browser blocked auto-advance — press Resume to keep the queue going.
</p>
) : status === 'error' ? (
<p className="flex items-center gap-1.5 truncate text-xs text-rose-300" title={lastError ?? undefined}>
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
{lastError ?? 'The queue stopped after repeated failures.'}
</p>
) : status === 'loading' && updating ? (
<p className="flex items-center gap-1.5 text-xs text-amber-300">
<Loader2 className="size-3.5 shrink-0 animate-spin" aria-hidden />
Updating in Background — the audio engine on the server is updating itself; your
music resumes in a moment.
</p>
) : (
<p className="flex items-center gap-1.5 text-xs text-zinc-400">
{status === 'loading' && <Loader2 className="size-3.5 shrink-0 animate-spin text-emerald-400" aria-hidden />}
{status === 'playing' && preloading && <Clock className="size-3.5 shrink-0 text-emerald-400" aria-hidden />}
<span className="truncate" title={trackTitle ?? current?.title ?? undefined}>
{current?.title ?? trackTitle}
</span>
{status === 'playing' && preloading && (
<span className="hidden shrink-0 text-emerald-300/80 md:inline">· next up is ready</span>
)}
</p>
)}
</div>
{/* controls */}
<div className="order-2 ml-auto flex shrink-0 items-center gap-0.5 sm:order-none">
{status === 'done' ? (
<Button
onClick={replayQueue}
className="mr-1 h-8 gap-1.5 bg-emerald-400 px-2.5 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<RotateCcw className="size-3.5" aria-hidden />
Replay
</Button>
) : status === 'blocked' ? (
<Button
onClick={() => queueJump(index)}
className="mr-1 h-8 gap-1.5 bg-emerald-400 px-2.5 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<Play className="size-3.5" aria-hidden />
Resume
</Button>
) : status === 'error' ? (
<>
{lastErrorCode === 'bot-wall' && (
<Button
onClick={() => setTab('settings')}
variant="outline"
className="mr-1 h-8 gap-1.5 border-amber-400/40 px-2.5 text-xs font-medium text-amber-300 hover:bg-amber-400/10 hover:text-amber-200"
>
<Settings2 className="size-3.5" aria-hidden />
Open Settings
</Button>
)}
<Button
onClick={() => queueJump(index)}
className="mr-1 h-8 gap-1.5 bg-emerald-400 px-2.5 text-xs font-semibold text-emerald-950 hover:bg-emerald-300"
>
<RotateCcw className="size-3.5" aria-hidden />
Retry
</Button>
</>
) : (
<>
<IconButton onClick={queuePrev} disabled={index <= 0} title="Previous track">
<SkipBack className="size-4" aria-hidden />
</IconButton>
<IconButton
onClick={queueNext}
disabled={status === 'loading' || index >= entries.length - 1}
title="Next track"
>
<SkipForward className="size-4" aria-hidden />
</IconButton>
</>
)}
<IconButton
onClick={() => setExpanded((v) => !v)}
title={expanded ? 'Hide the track list' : 'Show the track list'}
>
{expanded ? <ChevronUp className="size-4" aria-hidden /> : <ChevronDown className="size-4" aria-hidden />}
</IconButton>
<IconButton onClick={stopQueue} title="Stop and clear the queue">
<X className="size-4" aria-hidden />
</IconButton>
</div>
</div>
{/* expandable track list */}
<AnimatePresence initial={false}>
{expanded && (
<motion.div
key="tracks"
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18, ease: 'easeOut' }}
className="overflow-hidden"
>
<div className="mt-2 border-t border-zinc-800/80 pt-2">
<p className="mb-1.5 font-mono text-[10px] tracking-[0.2em] text-zinc-500">
QUEUE — CLICK A TRACK TO JUMP
</p>
<ul className={cn('max-h-64 space-y-1 overflow-y-auto pr-1', thinScrollbar)} role="listbox" aria-label="Queue tracks">
{entries.map((e, i) => {
const isCurrent = i === index && status !== 'done'
const dur = fmtDuration(e.durationSec)
return (
<li key={`${e.url}-${i}`}>
<button
type="button"
onClick={() => queueJump(i)}
role="option"
aria-selected={isCurrent}
disabled={status === 'loading'}
title={e.title}
className={cn(
'flex w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors disabled:opacity-50',
isCurrent
? 'glow-segment border-emerald-400/50 bg-emerald-400/10 text-emerald-200'
: 'border-zinc-800 bg-zinc-900/60 text-zinc-300 hover:border-emerald-500/40 hover:text-zinc-100',
)}
>
<span className="w-5 shrink-0 text-right font-mono text-[10px] tabular-nums text-zinc-500">
{isCurrent ? <Play className="ml-auto size-3 text-emerald-300" aria-hidden /> : i + 1}
</span>
<span className="min-w-0 flex-1 truncate">{e.title}</span>
{dur && (
<span className="shrink-0 font-mono text-[10px] tabular-nums text-zinc-500">{dur}</span>
)}
</button>
</li>
)
})}
</ul>
</div>
</motion.div>
)}
</AnimatePresence>
<p className="mt-1.5 hidden text-[10px] leading-snug text-zinc-600 sm:block">
Lazy queue — each track is fetched from the source only right before it plays, one at a time, so
playlist hosts are never hit with bulk requests.
</p>
</div>
</motion.section>
)
}

View file

@ -0,0 +1,434 @@
// OVE — music service login dialog (v6.6 / v6.8.2).
//
// Opened from the Settings tab ("Log in" in Music services). The visitor
// picks a service (YouTube / YouTube Music, SoundCloud, Spotify, Apple
// Music), pastes that service's login cookie, and it is saved ENCRYPTED on
// their computer only (src/lib/music-sessions.ts: AES-GCM with a
// non-extractable key held by the browser's key store). The Link button
// then resolves links with their account — their playlists, no bot walls.
//
// v6.8.1: the paste area validates LIVE (shared parser in
// lib/music-services.ts) — it recognizes the three supported formats
// (raw Cookie header, cookies.txt export, Cookie-Editor JSON), counts the
// cookies, and warns honestly when the paste cannot log you in (e.g. a
// single cookie's VALUE copied from the DevTools Storage tab — a working
// YouTube login is the full ~20-cookie set).
//
// v6.8.2: the exported file never even has to be opened — drag & drop it
// onto the paste box (or “Import the exported file…”); the file's text
// lands in the box and the same live validation reacts. Done because the
// official Google OAuth flow cannot power this site (no stream URLs in
// Google's APIs, restricted-scope verification would reject an audio
// player, tokens would have to live server-side) — see worklog Task 20.
//
// The body REMOUNTS on every open (fresh state — preselected service,
// empty paste area), same pattern as the Link dialog.
'use client'
import { useMemo, useRef, useState } from 'react'
import {
AlertTriangle,
Check,
FileUp,
KeyRound,
ListMusic,
LogIn,
LogOut,
ShieldCheck,
} from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import {
SERVICES,
validateCookiePaste,
type CookiePasteFormat,
type ServiceId,
} from '@/lib/music-services'
import {
removeMusicSession,
saveMusicSession,
type SessionSummary,
} from '@/lib/music-sessions'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const FORMAT_LABEL: Record<CookiePasteFormat, string> = {
header: 'raw Cookie header',
netscape: 'cookies.txt export',
json: 'JSON export',
unknown: 'unrecognized text',
}
function LoginDialogBody({
onOpenChange,
initialService,
sessions,
houseServices,
onChanged,
}: {
onOpenChange: (open: boolean) => void
initialService: ServiceId
sessions: SessionSummary | null
/** v6.9: services with the operator's shared house account — tells the
* visitor their own login is optional for plain playback. */
houseServices?: Partial<Record<ServiceId, boolean>> | null
onChanged: () => void
}) {
const [service, setService] = useState<ServiceId>(initialService)
const [cookieText, setCookieText] = useState('')
const [busy, setBusy] = useState(false)
const [dragOver, setDragOver] = useState(false)
const fileInputRef = useRef<HTMLInputElement | null>(null)
const { toast } = useToast()
const def = useMemo(() => SERVICES.find((s) => s.id === service)!, [service])
const loggedInAt = sessions?.loggedIn[service]?.savedAt ?? null
// Live paste check (v6.8.1) — null while the box is empty. Warns, never
// blocks: the visitor stays in charge, the hint just tells the truth
// about what will and will not log them in.
const validation = useMemo(
() => (cookieText.trim().length > 0 ? validateCookiePaste(service, cookieText) : null),
[service, cookieText],
)
function switchService(id: ServiceId) {
setService(id)
setCookieText('')
}
/** v6.8.2 — read an exported cookies.txt / JSON file (dragged onto the
* box or picked via the import button) straight into the paste area;
* the live validation then reacts to it like any paste. */
async function loadExportFile(file: File): Promise<void> {
if (busy) return
if (file.size > 262_144) {
toast({
title: 'File too large',
description: 'That file is bigger than a cookie export should be (over 256 KB).',
variant: 'destructive',
})
return
}
try {
const text = await file.text()
setCookieText(text)
} catch {
toast({
title: 'Could not read that file',
description: 'Try exporting again, then drop the new file.',
variant: 'destructive',
})
}
}
async function save() {
if (busy) return
setBusy(true)
const result = await saveMusicSession(service, cookieText)
setBusy(false)
if (result.ok) {
toast({
title: `Logged in to ${def.name}`,
description: 'Your login cookie is encrypted and stored on this computer only.',
})
onChanged()
onOpenChange(false)
} else {
toast({ title: 'Could not save the login', description: result.error, variant: 'destructive' })
}
}
async function removeOne() {
if (busy) return
setBusy(true)
await removeMusicSession(service)
setBusy(false)
toast({
title: `Logged out of ${def.name.split(' / ')[0]}`,
description: 'That login was removed from this browser.',
})
onChanged()
onOpenChange(false)
}
return (
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<LogIn className="size-4 text-emerald-400" aria-hidden />
Log in to a music service
</DialogTitle>
<DialogDescription>
Use your own account from the Link button — your playlists, liked music, and no
&quot;confirm you&apos;re not a bot&quot; walls.
</DialogDescription>
</DialogHeader>
{/* service picker */}
<div role="radiogroup" aria-label="Music service" className="grid grid-cols-2 gap-2">
{SERVICES.map((s) => {
const selected = s.id === service
const active = Boolean(sessions?.loggedIn[s.id])
return (
<button
key={s.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => switchService(s.id)}
className={cn(
'flex items-center gap-2.5 rounded-lg border p-2.5 text-left transition-colors',
selected
? 'glow-segment border-emerald-400/60 bg-emerald-400/10'
: 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800/40',
)}
>
<span
className="grid size-7 shrink-0 place-items-center rounded-md font-mono text-[10px] font-bold text-zinc-950"
style={{ background: s.brandColor }}
aria-hidden
>
{s.monogram}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold">{s.name}</span>
<span
className={cn(
'block text-[10px] leading-tight',
active ? 'text-emerald-300' : 'text-zinc-500',
)}
>
{active ? 'Logged in' : 'Not logged in'}
</span>
</span>
{active && <Check className="size-3.5 shrink-0 text-emerald-300" aria-hidden />}
</button>
)
})}
</div>
{/* selected service help + paste area */}
<div className="space-y-3 rounded-lg border border-zinc-800 bg-zinc-900/40 p-3">
<p className="text-[11px] leading-relaxed text-zinc-400">{def.blurb}</p>
{houseServices?.[service] && (
<p className="flex items-start gap-1.5 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-emerald-300">
<Check className="mt-0.5 size-3 shrink-0" aria-hidden />
A built-in account is already active for this service — links play for everyone. Your
own login adds your playlists, liked music and uploads on top.
</p>
)}
<div>
<p className="mb-1.5 flex items-center gap-1.5 font-mono text-[10px] tracking-[0.18em] text-zinc-500">
<ListMusic className="size-3" aria-hidden />
HOW TO COPY YOUR {def.name.split(' / ')[0].toUpperCase()} COOKIE
</p>
<ol className="list-inside list-decimal space-y-1 text-[11px] leading-relaxed text-zinc-400">
{def.steps.map((step) => (
<li key={step}>{step}</li>
))}
</ol>
<p className="mt-1.5 text-[10px] leading-relaxed text-zinc-500">
Tip: the box also accepts a Cookie-Editor “Export → JSON” — paste, drop or
import whichever you have.
</p>
</div>
<div
onDragOver={(e) => {
// Only intercept FILE drags — text drags keep native behavior.
if (e.dataTransfer.types.includes('Files')) {
e.preventDefault()
setDragOver(true)
}
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOver(false)
}}
onDrop={(e) => {
const file = e.dataTransfer.files?.[0]
if (!file) return // plain-text drop → let the textarea handle it
e.preventDefault()
setDragOver(false)
void loadExportFile(file)
}}
className={cn(
'relative rounded-lg',
dragOver && 'ring-2 ring-emerald-400/70 ring-offset-2 ring-offset-zinc-900',
)}
>
<Textarea
value={cookieText}
onChange={(e) => setCookieText(e.target.value)}
disabled={busy}
aria-label={`Login cookie for ${def.name}`}
placeholder="NAME=VALUE; NAME2=VALUE2; … (or drop / import a cookies.txt or JSON export)"
spellCheck={false}
autoComplete="off"
className="min-h-[84px] resize-y border-zinc-700 bg-zinc-950/60 font-mono text-[11px] leading-relaxed"
/>
{dragOver && (
<div className="pointer-events-none absolute inset-0 grid place-items-center rounded-lg border border-emerald-400/50 bg-zinc-950/85 p-3 text-center">
<p className="text-[11px] font-medium text-emerald-300">
Drop the exported cookies.txt / JSON file — it fills this box automatically.
</p>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".txt,.json,text/plain,application/json"
className="sr-only"
tabIndex={-1}
aria-hidden="true"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
if (file) void loadExportFile(file)
e.currentTarget.value = '' // allow picking the same file twice
}}
/>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => fileInputRef.current?.click()}
className="h-7 gap-1.5 border-zinc-700 px-2.5 text-[10.5px] text-zinc-300"
>
<FileUp className="size-3" aria-hidden />
Import the exported file…
</Button>
<p className="text-[10px] leading-relaxed text-zinc-500">
or just drag &amp; drop it onto the box above — no opening, no copying.
</p>
</div>
{validation && (
<div
role="status"
aria-live="polite"
className={cn(
'rounded-md border px-2.5 py-1.5 text-[10.5px] leading-relaxed',
validation.looksGood
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300'
: validation.format === 'unknown'
? 'border-red-500/25 bg-red-500/10 text-red-300'
: 'border-amber-500/25 bg-amber-500/10 text-amber-300',
)}
>
{validation.looksGood ? (
<span className="flex items-start gap-1.5">
<Check className="mt-0.5 size-3 shrink-0" aria-hidden />
{validation.count} cookies detected ({FORMAT_LABEL[validation.format]}) — looks
like a complete {def.name.split(' / ')[0]} login.
</span>
) : validation.format === 'unknown' ? (
<span className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden />
No cookies detected in that text — it looks like a single cookie’s value. A login
needs the whole set: copy the full “cookie:” request-header row (or a cookies.txt /
JSON export) exactly as the steps above describe.
</span>
) : validation.missingCritical.length > 0 ? (
<span className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden />
{validation.count} cookie{validation.count === 1 ? '' : 's'} detected, but a
working {def.name.split(' / ')[0]} login is still missing:{' '}
{validation.missingCritical.join(', ')}. Copy the whole thing again while logged
in — a single cookie on its own can’t log you in.
</span>
) : (
<span className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3 shrink-0" aria-hidden />
Only {validation.count} cookie{validation.count === 1 ? '' : 's'} detected — too
few for a saved login. Copy the whole thing (all cookies) as the steps above
describe.
</span>
)}
</div>
)}
{def.note && (
<p className="rounded-md border border-amber-500/20 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-amber-300">
{def.note}
</p>
)}
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => void save()}
disabled={busy || cookieText.trim().length === 0}
className="h-9 flex-1 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
>
<KeyRound className="size-3.5" aria-hidden />
{busy ? 'Saving…' : `Log in to ${def.name.split(' / ')[0]}`}
</Button>
{loggedInAt && (
<Button
variant="outline"
onClick={() => void removeOne()}
disabled={busy}
className="h-9 gap-1.5 border-zinc-700 text-xs"
title="Remove this service's saved login from this browser"
>
<LogOut className="size-3.5" aria-hidden />
Log out
</Button>
)}
</div>
{loggedInAt && (
<p className="text-[10px] text-zinc-500">
Logged in since {new Date(loggedInAt).toLocaleString()}
</p>
)}
</div>
<p className="flex items-start gap-1.5 text-[10px] leading-relaxed text-zinc-500">
<ShieldCheck className="mt-0.5 size-3.5 shrink-0 text-zinc-400" aria-hidden />
Your cookie is encrypted (AES-GCM) with a key that never leaves this browser&apos;s key
store, and is saved on this computer only. It is sent to this site&apos;s server solely
to resolve links you play — used transiently, never stored there. “Log out” removes
it instantly; signing out of YouTube in your browser invalidates it everywhere.
</p>
</DialogContent>
)
}
export function ServiceLoginDialog({
open,
onOpenChange,
initialService,
sessions,
houseServices,
onChanged,
}: {
open: boolean
onOpenChange: (open: boolean) => void
initialService?: ServiceId | null
sessions: SessionSummary | null
houseServices?: Partial<Record<ServiceId, boolean>> | null
onChanged: () => void
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{open && (
<LoginDialogBody
onOpenChange={onOpenChange}
initialService={initialService ?? 'youtube'}
sessions={sessions}
houseServices={houseServices}
onChanged={onChanged}
/>
)}
</Dialog>
)
}

View file

@ -0,0 +1,368 @@
// OVE — Settings tab (v6.5): appearance controls — base theme + highlight
// color pickers. Everything is per-visitor (localStorage only, nothing on
// the server) and applies instantly. The Native tab that previously lived
// in this slot is parked (see control-panel.tsx) and returns unchanged
// when the project nears completion.
'use client'
import { useSyncExternalStore, useState, useEffect } from 'react'
import { Check, LogIn, LogOut, Music4, Palette, RotateCcw } from 'lucide-react'
import { useTheme } from 'next-themes'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { ServiceLoginDialog } from '@/components/ava/service-login-dialog'
import { SERVICES, type ServiceId } from '@/lib/music-services'
import {
clearAllMusicSessions,
listMusicSessions,
type SessionSummary,
} from '@/lib/music-sessions'
import {
ACCENTS,
DEFAULT_ACCENT,
DEFAULT_BASE,
THEME_BASES,
applyAccent,
isAccentId,
storeDarkBase,
type AccentId,
} from '@/lib/theme'
import { useToast } from '@/hooks/use-toast'
import { cn } from '@/lib/utils'
const emptySubscribe = () => () => {}
function useMounted() {
return useSyncExternalStore(emptySubscribe, () => true, () => false)
}
/** Re-read data-accent whenever applyAccent() mutates it on <html>. */
function subscribeToAccent(onChange: () => void) {
const obs = new MutationObserver(onChange)
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-accent'] })
return () => obs.disconnect()
}
const getAccentAttribute = () => document.documentElement.getAttribute('data-accent')
export function SettingsTab() {
const { resolvedTheme, setTheme } = useTheme()
const mounted = useMounted()
const isLight = mounted && resolvedTheme === 'light'
const { toast } = useToast()
// Current accent — the data-accent attribute on <html> is the source of
// truth (set pre-paint by the boot script, mutated by applyAccent). The
// server snapshot returns null so the first client render matches SSR
// ("unselected") and the selection appears right after hydration.
const accentAttr = useSyncExternalStore(subscribeToAccent, getAccentAttribute, () => null)
const accent: AccentId | null = isAccentId(accentAttr) ? accentAttr : null
const activeBase = mounted ? resolvedTheme : null
const activeAccentMeta = ACCENTS.find((a) => a.id === accent)
// The little accent chip inside each base preview follows the current
// highlight (its light-theme variant for the white base card).
const chipFor = (baseIsLight: boolean) =>
(baseIsLight ? activeAccentMeta?.lightMain : activeAccentMeta?.main) ?? '#60a5fa'
function pickBase(id: (typeof THEME_BASES)[number]['id']) {
setTheme(id)
const base = THEME_BASES.find((b) => b.id === id)
if (base?.dark) storeDarkBase(id)
// Pair the outline base with the BLUE highlight — v7.2's default showcase
// pairing (the same look a new visitor gets); the visitor can re-pick any
// highlight afterwards.
if (id === 'outline' && accent !== 'blue') applyAccent('blue')
}
function pickAccent(id: AccentId) {
applyAccent(id)
}
function resetToDefaults() {
setTheme(DEFAULT_BASE)
storeDarkBase(DEFAULT_BASE)
applyAccent(DEFAULT_ACCENT)
}
// ---- music service logins (v6.6) ----
const [sessions, setSessions] = useState<SessionSummary | null>(null)
const [loginOpen, setLoginOpen] = useState(false)
const [chipService, setChipService] = useState<ServiceId | null>(null)
const [reloadTick, setReloadTick] = useState(0)
// v6.9: which services have the operator's shared "house" account
// configured (GET /api/resolve — booleans only, no cookie data)
const [house, setHouse] = useState<Partial<Record<ServiceId, boolean>> | null>(null)
useEffect(() => {
let cancelled = false
void listMusicSessions().then((s) => {
if (!cancelled) setSessions(s)
})
fetch('/api/resolve')
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!cancelled && d && typeof d === 'object' && typeof d.house === 'object') {
setHouse(d.house as Partial<Record<ServiceId, boolean>>)
}
})
.catch(() => {})
return () => {
cancelled = true
}
}, [reloadTick])
const loggedInIds = Object.keys(sessions?.loggedIn ?? {}) as ServiceId[]
const anyLoggedIn = loggedInIds.length > 0
const houseNames = house
? SERVICES.filter((s) => house[s.id]).map((s) => s.name.split(' / ')[0])
: []
function refreshSessions() {
setReloadTick((t) => t + 1)
}
async function logoutAll() {
await clearAllMusicSessions()
refreshSessions()
toast({
title: 'Logged out of all music services',
description: 'Every saved login cookie was removed from this browser.',
})
}
return (
<div className="space-y-6">
<div>
<h3 className="flex items-center gap-2 text-base font-semibold">
<Palette className="size-4 text-emerald-400" aria-hidden />
Appearance
</h3>
<p className="mt-2 text-xs leading-relaxed text-zinc-400">
Pick the chrome theme and the highlight color. Saved per visitor in this browser — nothing is
stored on the server.
</p>
</div>
<Separator />
<section aria-label="Base theme">
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">Base theme</h4>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{THEME_BASES.map((b) => {
const selected = activeBase === b.id
const chip = chipFor(!b.dark)
return (
<button
key={b.id}
type="button"
onClick={() => pickBase(b.id)}
aria-pressed={selected}
className={cn(
'flex flex-col gap-2.5 rounded-xl border p-3 text-left transition-colors',
selected
? 'glow-segment border-emerald-400/60 bg-emerald-400/10'
: 'border-zinc-800 bg-zinc-900/40 hover:border-zinc-700 hover:bg-zinc-800/40',
)}
>
{/* mini preview — static base swatch + live accent chip */}
<div
className="h-14 w-full rounded-lg border p-1.5"
style={{ background: b.swatch.bg, borderColor: b.swatch.border }}
aria-hidden
>
<div
className="flex h-full items-center gap-1.5 rounded-md border px-2"
style={{ background: b.swatch.panel, borderColor: b.swatch.border }}
>
<span className="size-2.5 shrink-0 rounded-full" style={{ background: chip }} />
<span
className="h-1.5 w-8 shrink-0 rounded-full opacity-80"
style={{ background: chip }}
/>
<span
className="ml-auto h-4 w-10 shrink-0 rounded-[4px]"
style={{ background: chip }}
/>
</div>
</div>
<div>
<div className="flex items-center gap-1.5 text-sm font-semibold">
{b.name}
{selected && <span className="text-emerald-300">•</span>}
</div>
<p className="mt-0.5 text-[11px] leading-snug text-zinc-500">{b.blurb}</p>
</div>
</button>
)
})}
</div>
</section>
<section aria-label="Highlight color">
<h4 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Highlight color
</h4>
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
Drives buttons, the BPM readout and the beat glow — and the panel outlines of the Black +
Purple Outline theme.
</p>
<div className="mt-3 grid grid-cols-6 gap-1">
{ACCENTS.map((a) => {
const selected = accent === a.id
const dot = isLight ? a.lightMain : a.main
return (
<button
key={a.id}
type="button"
onClick={() => pickAccent(a.id)}
aria-pressed={selected}
title={`${a.name} highlight`}
className="flex flex-col items-center gap-1.5 rounded-lg p-1.5 transition-colors hover:bg-zinc-800/50"
>
<span
className="grid size-9 place-items-center rounded-full transition-transform hover:scale-105"
style={{
background: dot,
boxShadow: selected
? `0 0 0 2px var(--color-zinc-950), 0 0 0 4px ${dot}, 0 0 12px rgb(var(--accent-rgb) / 0.45)`
: undefined,
}}
>
{selected && <Check className="size-4" style={{ color: a.deep }} aria-hidden />}
</span>
<span
className={cn(
'text-[10px] font-medium leading-none',
selected ? 'text-emerald-300' : 'text-zinc-400',
)}
>
{a.name}
</span>
</button>
)
})}
</div>
</section>
<Separator />
<section aria-label="Music services">
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
<Music4 className="size-3.5" aria-hidden />
Music services
</h4>
<p className="mt-1.5 text-[11px] leading-relaxed text-zinc-500">
Log in to your own accounts — the Link button then opens your playlists and liked music
with your account, so you never get stuck behind bot-detection walls. Login cookies are
encrypted and stored on this computer only.
</p>
{houseNames.length > 0 && (
<p className="mt-2 flex items-start gap-1.5 rounded-md border border-emerald-500/25 bg-emerald-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-emerald-300">
<Check className="mt-0.5 size-3 shrink-0" aria-hidden />
Built-in account active — {houseNames.join(', ')} links play for everyone, no login
needed. Log in only to reach your own playlists, liked music and uploads.
</p>
)}
<div className="mt-3 flex flex-wrap gap-1.5">
{SERVICES.map((s) => {
const active = Boolean(sessions?.loggedIn[s.id])
return (
<button
key={s.id}
type="button"
onClick={() => {
setChipService(s.id)
setLoginOpen(true)
}}
title={active ? `${s.name} — logged in. Click to manage.` : `${s.name} — click to log in`}
className={cn(
'flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors',
active
? 'border-emerald-400/50 bg-emerald-400/10 text-emerald-200'
: 'border-zinc-800 bg-zinc-900/40 text-zinc-400 hover:border-zinc-700 hover:text-zinc-200',
)}
>
<span
className="size-2 shrink-0 rounded-full"
style={{ background: s.brandColor }}
aria-hidden
/>
{s.name}
{s.streamStatus === 'coming-soon' && (
<span className="rounded bg-amber-400/15 px-1 py-px text-[8px] font-semibold uppercase tracking-wider text-amber-300">
soon
</span>
)}
{active ? (
<Check className="size-3 text-emerald-300" aria-hidden />
) : (
<span className="text-[9px] text-zinc-600" aria-hidden>
—
</span>
)}
</button>
)
})}
</div>
{sessions?.unavailable && (
<p className="mt-2 rounded-md border border-amber-500/20 bg-amber-500/10 px-2.5 py-1.5 text-[10px] leading-relaxed text-amber-300">
This browser context is not secure (https), so login cookies cannot be stored here.
</p>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<Button
onClick={() => {
setChipService(null)
setLoginOpen(true)
}}
className="h-9 gap-1.5 bg-emerald-400 font-semibold text-emerald-950 hover:bg-emerald-300"
>
<LogIn className="size-3.5" aria-hidden />
Log in
</Button>
{anyLoggedIn && (
<Button
variant="outline"
onClick={() => void logoutAll()}
className="h-9 gap-1.5 border-zinc-700 text-xs"
title="Remove every saved music login cookie from this browser"
>
<LogOut className="size-3.5" aria-hidden />
Log out of all
</Button>
)}
</div>
</section>
<Separator />
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="max-w-[60%] text-[11px] leading-snug text-zinc-500">
The Native engine scaffold tab is parked and returns when the project nears completion.
</p>
<Button
variant="outline"
size="sm"
onClick={resetToDefaults}
className="h-8 gap-1.5 border-zinc-700 text-xs"
>
<RotateCcw className="size-3.5" aria-hidden />
Reset to defaults
</Button>
</div>
<ServiceLoginDialog
open={loginOpen}
onOpenChange={setLoginOpen}
initialService={chipService}
sessions={sessions}
houseServices={house}
onChanged={refreshSessions}
/>
</div>
)
}

View file

@ -106,9 +106,12 @@ export function VisualStage({ className }: { className?: string }) {
Demo track, live microphone, your own audio file — or paste a link.
</p>
<div className="grid gap-2.5">
{/* v7.2 showcase entry point — the shimmering Demo Track
button: one click starts the music AND the Zai-Magic
club mix (random preset every 20 s) */}
<Button
onClick={() => void startDemo()}
className="h-12 gap-2 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
className="shimmer-cta h-12 gap-2 bg-emerald-400 text-emerald-950 hover:bg-emerald-300"
>
<Disc3 className="size-4" /> Demo Track
</Button>

View file

@ -1,16 +1,25 @@
// AVA — audio input + 3-band spectrum analysis (Web Audio API)
// AVA — audio input + 3-band spectrum analysis + onset (flux) envelope
//
// Sources:
// demo — built-in synth (kick / snare / hats / bassline) at an adjustable BPM,
// so the app is fully demonstrable without any audio hardware
// demo — the built-in demo TRACK: "Neural Mecha Pulse", a mastered
// industrial-EDM banger that exercises the whole spectrum (deep
// sub, mid basslines, crisp highs) — the honest end-to-end test
// for the beat engine. Plays ONCE through (v7.3 — it is a demo
// pass, not a radio station): when the buffer runs out the engine
// goes idle and fires onDemoEnded so the UI can unwind the
// showcase state and return to the landing overlay.
// mic — live microphone / line-in (club audio feed into an interface)
// file — drop any audio file (mp3/wav/…) and loop it
// link — remote stream (YouTube / SoundCloud / direct MP3 …) resolved via
// /api/resolve and proxied same-origin so the analyser can tap it
// /api/resolve and proxied same-origin so the analyser can tap it.
// Single links loop; playlist-queue tracks play once and fire
// onTrackEnded so the queue advances lazily (one track fetched at a
// time — v6.7 anti-spam model).
//
// Band analysis: AnalyserNode FFT (8192 bins → ~5.4 Hz resolution at 44.1 kHz)
// averaged over the user-tunable Hz ranges, with fast-attack / slow-release
// smoothing for the *_att variables that drive beat detection and visuals.
// smoothing for the *_att variables that drive visuals — plus the full-band
// spectral-flux (onset) envelope that feeds beat detection (v7.1).
import type { AudioSourceKind, BandConfig, BandLevels } from './types'
import { ZERO_LEVELS } from './types'
@ -19,16 +28,33 @@ const FFT_SIZE = 8192
const ATTACK_TAU = 0.035 // seconds
const RELEASE_TAU = 0.22 // seconds
/** The embedded demo track (public/demo-track.mp3). */
const DEMO_TRACK_URL = '/demo-track.mp3'
const DEMO_TRACK_TITLE = 'Neural Mecha Pulse'
export class AudioEngine {
kind: AudioSourceKind = 'idle'
demoBpm = 124
/** Human label for the active source (file name / stream title). */
sourceLabel: string | null = null
/**
* Fired when a non-looping link track plays to its very end. The playlist
* queue (lib/ava/queue.ts) hooks this to advance to the next entry —
* lazily, so each song is only fetched right before it plays.
*/
onTrackEnded: (() => void) | null = null
/**
* Fired exactly once when the single-pass demo track ends — however it
* ends: natural play-through, the Stop button, or switching to another
* source (stopSources kills the demo and fires this). The UI hook
* (audio-source.ts) unwinds the showcase state — randomize back off —
* and returns the visitor to the landing overlay. (v7.3)
*/
onDemoEnded: (() => void) | null = null
private ctx: AudioContext | null = null
private analyser: AnalyserNode | null = null
private freqData: Uint8Array = new Uint8Array(0)
private timeData: Uint8Array = new Uint8Array(0)
private freqData: Uint8Array<ArrayBuffer> = new Uint8Array(0)
private timeData: Uint8Array<ArrayBuffer> = new Uint8Array(0)
private micStream: MediaStream | null = null
private micSource: MediaStreamAudioSourceNode | null = null
@ -36,11 +62,14 @@ export class AudioEngine {
private mediaSource: MediaElementAudioSourceNode | null = null
private objectUrl: string | null = null
private demoTimer: ReturnType<typeof setInterval> | null = null
private demoSource: AudioBufferSourceNode | null = null
private demoBus: GainNode | null = null
private noiseBuf: AudioBuffer | null = null
private step = 0
private nextStepTime = 0
/** Decoded demo track, cached so restarting Demo is instant. */
private demoBuffer: AudioBuffer | null = null
/** Previous frame's frequency bytes — the flux (onset) envelope is the
* mean positive frame-to-frame change across the spectrum. Null after a
* stop/source switch so the first active frame seeds instead of spiking. */
private prevFreqData: Uint8Array<ArrayBuffer> | null = null
private levels: BandLevels = { ...ZERO_LEVELS }
@ -52,6 +81,22 @@ export class AudioEngine {
return this.kind !== 'idle'
}
/** Playback position of a link stream, in seconds (null when not streaming
* or not yet known). Used by the queue for its just-in-time look-ahead. */
get positionSec(): number | null {
const el = this.audioEl
if (!el || this.kind !== 'link') return null
return Number.isFinite(el.currentTime) ? el.currentTime : null
}
/** Total duration of a link stream, in seconds (null = unknown / live). */
get durationSec(): number | null {
const el = this.audioEl
if (!el || this.kind !== 'link') return null
const d = el.duration
return Number.isFinite(d) && d > 0 ? d : null
}
private ensureCtx(): AudioContext {
if (!this.ctx) {
this.ctx = new AudioContext()
@ -65,9 +110,15 @@ export class AudioEngine {
}
private stopSources(): void {
if (this.demoTimer) {
clearInterval(this.demoTimer)
this.demoTimer = null
const wasDemo = this.kind === 'demo' // the demo retires along with its nodes
if (this.demoSource) {
try {
this.demoSource.stop()
this.demoSource.disconnect()
} catch {
/* already stopped */
}
this.demoSource = null
}
if (this.demoBus) {
try { this.demoBus.disconnect() } catch { /* noop */ }
@ -99,113 +150,60 @@ export class AudioEngine {
this.kind = 'idle'
this.sourceLabel = null
this.levels = { ...ZERO_LEVELS }
this.prevFreqData = null // next source seeds the flux baseline cleanly
// v7.3: whoever tears down an ACTIVE demo (Stop button, source switch,
// natural end via the 'ended' listener below) reports it exactly once —
// the UI unwinds the showcase state on this signal.
if (wasDemo && this.onDemoEnded) {
const cb = this.onDemoEnded
this.onDemoEnded = null
cb()
}
}
/** Built-in demo track — a loopable techno pattern synthesized live. */
/**
* Built-in demo track — "Neural Mecha Pulse" (public/demo-track.mp3), a
* mastered industrial-EDM track chosen to exercise the whole engine: deep
* sub for the bass bands, driving mid basslines, crisp hats for the highs,
* and a kick that genuinely tests the beat detector (dense sustained
* sub-bass — the exact case the v7.1 autocorrelation detector was built
* for). v7.3: ONE play-through — the demo ends, the engine goes idle and
* onDemoEnded fires (see the 'ended' listener). Decoded once, cached.
*/
async startDemo(): Promise<void> {
const ctx = this.ensureCtx()
await ctx.resume()
this.stopSources()
this.kind = 'demo'
this.sourceLabel = null
if (!this.noiseBuf) {
const len = Math.floor(ctx.sampleRate * 1.5)
this.noiseBuf = ctx.createBuffer(1, len, ctx.sampleRate)
const ch = this.noiseBuf.getChannelData(0)
for (let k = 0; k < len; k++) ch[k] = Math.random() * 2 - 1
if (!this.demoBuffer) {
const res = await fetch(DEMO_TRACK_URL)
if (!res.ok) throw new Error(`demo track fetch failed (${res.status})`)
const bytes = await res.arrayBuffer()
this.demoBuffer = await ctx.decodeAudioData(bytes)
}
this.stopSources()
this.kind = 'demo'
this.sourceLabel = DEMO_TRACK_TITLE
this.demoBus = ctx.createGain()
this.demoBus.gain.value = 0.85
this.demoBus.gain.value = 0.9
this.demoBus.connect(this.analyser!)
this.analyser!.connect(ctx.destination) // demo is audible
this.step = 0
this.nextStepTime = ctx.currentTime + 0.08
this.demoTimer = setInterval(() => this.scheduleDemo(), 30)
}
private scheduleDemo(): void {
const ctx = this.ctx
if (!ctx || !this.demoBus || this.kind !== 'demo') return
const stepDur = 60 / this.demoBpm / 4
while (this.nextStepTime < ctx.currentTime + 0.16) {
const t = this.nextStepTime
const s = this.step % 16
if (s % 4 === 0) this.kick(t)
if (s === 4 || s === 12) this.snare(t)
if (s % 2 === 1) this.hat(t)
if (s % 2 === 0) {
const bar = Math.floor(this.step / 4)
const notes = [55, 55, 65.41, 49]
this.bassNote(t, notes[bar % 4], stepDur * 1.8)
}
this.nextStepTime += stepDur
this.step++
}
}
private kick(t: number): void {
const ctx = this.ctx!
const osc = ctx.createOscillator()
const g = ctx.createGain()
osc.type = 'sine'
osc.frequency.setValueAtTime(160, t)
osc.frequency.exponentialRampToValueAtTime(43, t + 0.12)
g.gain.setValueAtTime(1.0, t)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.3)
osc.connect(g).connect(this.demoBus!)
osc.start(t)
osc.stop(t + 0.32)
}
private snare(t: number): void {
const ctx = this.ctx!
const src = ctx.createBufferSource()
src.buffer = this.noiseBuf
const bp = ctx.createBiquadFilter()
bp.type = 'bandpass'
bp.frequency.value = 1800
bp.Q.value = 0.8
const g = ctx.createGain()
g.gain.setValueAtTime(0.5, t)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.18)
src.connect(bp).connect(g).connect(this.demoBus!)
src.start(t)
src.stop(t + 0.2)
}
private hat(t: number): void {
const ctx = this.ctx!
const src = ctx.createBufferSource()
src.buffer = this.noiseBuf
const hp = ctx.createBiquadFilter()
hp.type = 'highpass'
hp.frequency.value = 8200
const g = ctx.createGain()
g.gain.setValueAtTime(0.22, t)
g.gain.exponentialRampToValueAtTime(0.001, t + 0.05)
src.connect(hp).connect(g).connect(this.demoBus!)
src.start(t)
src.stop(t + 0.06)
}
private bassNote(t: number, freq: number, dur: number): void {
const ctx = this.ctx!
const osc = ctx.createOscillator()
osc.type = 'sawtooth'
osc.frequency.value = freq
const lp = ctx.createBiquadFilter()
lp.type = 'lowpass'
lp.frequency.value = 260
lp.Q.value = 1
const g = ctx.createGain()
g.gain.setValueAtTime(0.3, t)
g.gain.exponentialRampToValueAtTime(0.001, t + dur)
osc.connect(lp).connect(g).connect(this.demoBus!)
osc.start(t)
osc.stop(t + dur + 0.02)
src.buffer = this.demoBuffer
// v7.3: single pass — no loop. When the buffer runs out the source fires
// 'ended' and the engine retires to idle (stopSources → onDemoEnded),
// which the UI uses to unwind the showcase and re-show the landing
// overlay. The stale-event guard ensures only the CURRENT demo source
// can retire the engine (a replaced source's late 'ended' is ignored).
src.addEventListener('ended', () => {
if (this.demoSource !== src) return
this.stopSources()
})
src.connect(this.demoBus)
src.start()
this.demoSource = src
}
/** Live microphone / line-in. NEVER routed to the speakers (no feedback). */
@ -244,30 +242,39 @@ export class AudioEngine {
* Remote stream playback (YouTube / SoundCloud / direct MP3 …).
* The URL comes from POST /api/resolve — a same-origin proxy route — so the
* MediaElementSource is NOT cross-origin-tainted and the analyser can read
* it. Loops by default (seeking back to 0 is a cheap Range request through
* the proxy).
* it. Single links loop by default (seeking back to 0 is a cheap Range
* request through the proxy); the playlist queue passes { loop: false } so
* the element fires 'ended' and onTrackEnded advances the queue.
*/
async startUrl(streamUrl: string, title: string): Promise<void> {
async startUrl(streamUrl: string, title: string, opts?: { loop?: boolean }): Promise<void> {
const ctx = this.ensureCtx()
await ctx.resume()
this.stopSources()
this.kind = 'link'
this.sourceLabel = title
const loop = opts?.loop ?? true
const el = new Audio()
el.preload = 'auto'
el.src = streamUrl
el.loop = true
el.loop = loop
this.audioEl = el
if (!loop) {
el.addEventListener(
'ended',
() => {
// only the CURRENT element may signal — a stale element that was
// already replaced must not advance the queue
if (this.audioEl === el) this.onTrackEnded?.()
},
{ once: true },
)
}
this.mediaSource = ctx.createMediaElementSource(el)
this.mediaSource.connect(this.analyser!)
this.analyser!.connect(ctx.destination)
await el.play()
}
setDemoBpm(bpm: number): void {
this.demoBpm = Math.min(Math.max(bpm, 60), 200)
}
stop(): void {
this.stopSources()
}
@ -289,6 +296,7 @@ export class AudioEngine {
this.levels.mid = 0
this.levels.treb = 0
this.levels.vol = 0
this.levels.flux = 0
return this.levels
}
@ -305,6 +313,25 @@ export class AudioEngine {
return sum / (hi - lo + 1) / 255
}
// Onset (spectral-flux) envelope: mean positive bin-to-bin change up to
// 16 kHz. This is what beat detection runs on — attacks, not levels.
let flux = 0
const fluxHi = Math.min(this.freqData.length - 1, Math.ceil(16000 / binHz))
const prev = this.prevFreqData
if (prev && prev.length === this.freqData.length) {
let sum = 0
for (let k = 1; k <= fluxHi; k++) {
const d = this.freqData[k] - prev[k]
if (d > 0) sum += d
}
flux = sum / fluxHi / 255
prev.set(this.freqData)
} else {
// first active frame (or analyser re-created): seed the baseline so
// the next frame's diff is measured from real data — no phantom onset
this.prevFreqData = new Uint8Array(this.freqData)
}
const bass = levelOf(cfg.bassLow, cfg.bassHigh) * cfg.bassGain
const mid = levelOf(cfg.midLow, cfg.midHigh) * cfg.midGain
const treb = levelOf(cfg.highLow, cfg.highHigh) * cfg.highGain
@ -322,6 +349,7 @@ export class AudioEngine {
this.levels.midAtt = smooth(mid, this.levels.midAtt)
this.levels.trebAtt = smooth(treb, this.levels.trebAtt)
this.levels.volAtt = smooth(this.levels.vol, this.levels.volAtt)
this.levels.flux = flux
return this.levels
}
@ -340,4 +368,4 @@ export class AudioEngine {
const bin = 2 + Math.floor(Math.pow(p, 1.6) * (this.freqData.length - 3))
return this.freqData[bin] / 255
}
}
}

View file

@ -1,23 +1,75 @@
// AVA — beat detection, tempo tracking & prediction
// AVA — beat detection, tempo tracking & prediction (v7.1)
//
// Strategy (mirrors the planned Rust implementation):
// 1. DETECT — a beat fires when the smoothed bass energy exceeds
// `threshold ×` the rolling average (last ~60 frames),
// gated by `minInterval` and an absolute floor.
// 2. LEARN — consecutive beat intervals build a BPM estimate; when the
// intervals are consistent (low spread), the period is locked
// with an exponential moving average and confidence rises.
// 3. PREDICT — a phase accumulator advances by dt/period every frame. When
// the bass drops out, the predictor keeps firing beats on the
// learned grid so the visuals never lose the beat.
// 4. TAP — the "Set Beat" button: taps lock the period + phase from the
// tap intervals (median), teaching the tracker the song's beat.
// The detector is AUTO CORRELATION ON ONSETS — how every serious beat
// tracker works (aubio, librosa, …) — not a level threshold:
//
// 1. ONSET — the engine feeds a full-band spectral-flux envelope (mean
// positive frame-to-frame FFT change). Kicks/snares/hats are
// ATTACKS: they spike the flux; sustained sub-bass reads ~0.
// A level detector cannot see a kick through mastered club
// sub-bass (peak-to-mean caps at ~1.2× → sporadic fires,
// phantom tempi — the 6.x behavior on real music).
// 2. RESAMPLE — the envelope is resampled onto a FIXED 60 Hz time grid
// (linear interpolation between rAF samples) before entering
// the ring. rAF cadence is not uniform in practice — a
// headless/GPU-less browser renders at a jittery 17-83 ms
// cadence — and a frame-indexed ring would smear the
// autocorrelation lags into noise (measured: BPM wandering
// 105-122 on a 133 BPM track, confidence 30%). Uniform grid
// → identical behavior at any render rate or jitter.
// 3. ESTIMATE — every ~100 ms the ring (~4 s) is autocorrelated over
// 300–1000 ms lags (= 60–200 BPM). The best LOCAL MAXIMUM of
// AC × tempo-prior × lock-hysteresis (parabolic-refined) is
// the period candidate; the normalized coefficient r must
// clear the sensitivity gate (noise reads ≈ ±0.07, music
// 0.25–0.8). Two agreeing candidates lock the tempo (three
// once confidently locked — outro noise can't steal it).
// 4. PHASE — a comb filter aligns the grid: the phase whose beat
// positions sample the most onset energy. Dance music
// interleaves two onset trains (kick vs off-beat bass) half a
// period apart and the raw comb flaps between them, so phases
// near the established grid score a 25% bonus (hysteresis);
// a genuinely better phase still wins outright.
// 5. GRID — beats fire at absolute nextBeatAt timestamps; a PLL-style
// pull (≤3% of the period per estimate, 5% deadband) keeps
// the grid glued to the comb without skipping/doubling beats.
// 6. TAP — the "Set Beat" button: taps lock the period + phase from
// the tap intervals (median), teaching the tracker directly.
//
// Validated headlessly against the demo track's PCM (the AnalyserNode chain
// replicated frame-for-frame): "Neural Mecha Pulse" (true tempo ~133 BPM
// with drift, dense industrial mix) locks in ~2 s, holds the tempo through
// 96% of the track at 92% beat-gap regularity, confidence 0.99; techno and
// hip-hop patterns lock exactly; silence produces zero beats. Known
// limitation: sparse kick-vs-snare backbeats (dnb) may lock at half tempo —
// stable and musically defensible; "Set Beat" corrects it in two taps.
import type { BeatConfig, BeatFrame, BeatSource } from './types'
import { DEFAULT_BEAT_CONFIG } from './types'
const HISTORY_SIZE = 60
const MAX_INTERVALS = 10
/** The fixed analysis grid: 60 samples/s regardless of the render rate. */
const GRID_STEP_MS = 1000 / 60
/** Onset envelope ring: ~4 s of grid samples. */
const RING_SAMPLES = 240
/** Candidate beat periods (ms) — the foldPeriod range (60–200 BPM). */
const LAG_MIN_MS = 300
const LAG_MAX_MS = 1000
/** Re-estimate the period every ~100 ms of grid time. */
const ESTIMATE_EVERY = 6
/** Required autocorrelation coefficient: 0.1 + 0.125 × threshold. */
const acQualityRequired = (threshold: number): number => 0.1 + 0.125 * threshold
/** Lock hysteresis: the current period's AC gets this boost — a section-
* level competitor (683/900 ms metrical levels in dense mixes) needs ~2×
* the AC to steal a confident lock, while a genuine tempo change dominates
* within a few seconds. */
const LOCK_BOOST = 1.25
/** Consecutive agreeing candidates required to (re)lock the tempo. */
const LOCK_AGREE = 0.1
/** Comb phase-hysteresis bonus for phases near the established grid. */
const COMB_BONUS = 0.25
/** Max tolerated sampler gap before resetting the grid (tab-background). */
const SAMPLER_RESET_MS = 500
const MAX_TAPS = 8
const TAP_RESET_MS = 2500
@ -27,14 +79,27 @@ function median(values: number[]): number {
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
}
/** Fold a period into the 60–200 BPM equivalent range. */
function foldPeriod(ms: number): number {
// keep the period inside 60..200 BPM equivalent
let p = ms
while (p > 1000) p /= 2
while (p < 300) p *= 2
while (p > LAG_MAX_MS) p /= 2
while (p < LAG_MIN_MS) p *= 2
return p
}
/**
* Gentle log-BPM prior around 125 (σ = 0.45 octaves): prefers the beat
* level over half-tempo (a 900 ms peak needs ~9× the AC of a 450 ms peak
* to win) and over nearby metrical levels (550 ms needs ~10% more) without
* blocking genuinely slow (75 BPM) or fast (200 BPM) tracks. This is a
* deliberate genre bet for a beat-music visualizer — odd-meter genres have
* "Set Beat" as the escape hatch.
*/
function tempoPrior(lagMs: number): number {
const bpm = 60000 / lagMs
return Math.exp(-0.5 * Math.pow(Math.log2(bpm / 125) / 0.45, 2))
}
export interface TapResult {
bpm: number
phase: number
@ -46,35 +111,55 @@ export interface TapResult {
export class BeatTracker {
cfg: BeatConfig
private history: number[] = []
private intervals: number[] = []
private taps: number[] = []
private lastTapAt = -1e9
/** Uniformly-spaced (GRID_STEP_MS) onset envelope, oldest first. */
private ring: number[] = []
/** Sampler state: the last rAF sample (time + value) feeding the grid,
* plus the ABSOLUTE grid anchor — ticks live at gridOrigin + index × step
* (integer index, no cumulative float drift). */
private prevSampleMs: number | null = null
private prevSampleVal = 0
private gridOrigin = 0
private gridIndex = 0
private periodMs = 500
private nextBeatAt = Number.POSITIVE_INFINITY
private phase = 0
private bpm = 0
private confidence = 0
private lastBeatAt = -1e9
private lastRealBeatAt = -1e9
private prevRealBeatAt = -1e9
/** Last grid time where the envelope was live (music present). */
private lastActiveAt = -1e9
private taps: number[] = []
private lastTapAt = -1e9
private beatCount = 0
private pendingPeriod: number | null = null
private pendingCount = 0
private updateCount = 0
constructor(cfg: BeatConfig = { ...DEFAULT_BEAT_CONFIG }) {
this.cfg = cfg
}
reset(): void {
this.history = []
this.intervals = []
this.taps = []
this.ring = []
this.prevSampleMs = null
this.prevSampleVal = 0
this.gridOrigin = 0
this.gridIndex = 0
this.periodMs = 500
this.nextBeatAt = Number.POSITIVE_INFINITY
this.phase = 0
this.bpm = 0
this.confidence = 0
this.lastBeatAt = -1e9
this.lastRealBeatAt = -1e9
this.prevRealBeatAt = -1e9
this.lastActiveAt = -1e9
this.taps = []
this.lastTapAt = -1e9
this.beatCount = 0
this.pendingPeriod = null
this.pendingCount = 0
this.updateCount = 0
}
getBeatCount(): number {
@ -106,98 +191,310 @@ export class BeatTracker {
return this.bpm
}
/** Envelope (grid-indexed) at `tAgo` grid steps before the newest sample. */
private envelopeAt(tAgo: number): number {
const idx = this.ring.length - 1 - tAgo
const i0 = Math.floor(idx)
const frac = idx - i0
if (i0 < 0) return this.ring[0] ?? 0
if (i0 >= this.ring.length - 1) return this.ring[this.ring.length - 1] ?? 0
return this.ring[i0] * (1 - frac) + this.ring[i0 + 1] * frac
}
/** Newest envelope activity: max over the last few grid steps (~80 ms). */
private recentOnset(): number {
let m = 0
for (let k = this.ring.length - 1; k >= Math.max(0, this.ring.length - 5); k--) {
if (this.ring[k] > m) m = this.ring[k]
}
return m
}
/**
* Resample the incoming rAF-paced envelope samples onto the fixed grid.
* Ticks are anchored at ABSOLUTE grid times (gridOrigin + i × step) and
* pushed by integer index the moment an update's timestamp passes them —
* over any window the tick count is exactly window/step, regardless of
* rAF jitter or floating-point gap noise. (An earlier version anchored
* ticks at prevTime + k×step and tested `<= now`; with real rAF gaps the
* comparison under-fills the ring — missing ticks are never recovered —
* which time-compresses the ring and makes every period estimate read
* short: a 133 BPM track wandering 105-122.) Linear interpolation between
* the previous and current sample; a sampler gap > SAMPLER_RESET_MS (tab
* background) re-anchors the grid instead of back-filling stale ticks.
*/
private feedSampler(onset: number, nowMs: number): void {
if (this.prevSampleMs === null || nowMs - this.prevSampleMs > SAMPLER_RESET_MS) {
this.prevSampleMs = nowMs
this.prevSampleVal = onset
this.gridOrigin = nowMs
this.gridIndex = 0
return
}
const t0 = this.prevSampleMs
const t1 = nowMs
const v0 = this.prevSampleVal
const span = Math.max(1e-6, t1 - t0)
while (this.gridOrigin + this.gridIndex * GRID_STEP_MS <= t1) {
const tickT = this.gridOrigin + this.gridIndex * GRID_STEP_MS
const frac = Math.min(1, Math.max(0, (tickT - t0) / span))
this.ring.push(v0 + (onset - v0) * frac)
this.gridIndex++
}
while (this.ring.length > RING_SAMPLES) this.ring.shift()
this.prevSampleMs = t1
this.prevSampleVal = onset
}
/**
* Autocorrelation of the ring over the candidate lag range. Returns the
* best period (parabolic-refined local maximum of AC × tempo-prior ×
* lock-hysteresis), its normalized coefficient r = AC(peak)/Var(env)
* (scale-free; noise ≈ ±1/√n, periodic music 0.25–0.8), and the raw AC
* curve for the subdivision test below.
*/
private estimatePeriod(): { lagMs: number; quality: number; vals: number[]; lagMin: number } {
const n = this.ring.length
if (n < 90) return { lagMs: 0, quality: 0, vals: [], lagMin: 0 }
let mean = 0
for (const v of this.ring) mean += v
mean /= n
let envVar = 0
for (const v of this.ring) envVar += (v - mean) * (v - mean)
envVar /= n
const lagMin = Math.max(2, Math.round(LAG_MIN_MS / GRID_STEP_MS))
const lagMax = Math.round(LAG_MAX_MS / GRID_STEP_MS)
const vals: number[] = []
for (let lag = lagMin; lag <= lagMax && lag < n - 8; lag++) {
let s = 0
for (let i = lag; i < n; i++) s += (this.ring[i] - mean) * (this.ring[i - lag] - mean)
s /= n - lag
vals.push(s)
}
if (vals.length < 4) return { lagMs: 0, quality: 0, vals, lagMin }
let max = Number.NEGATIVE_INFINITY
for (const v of vals) max = Math.max(max, v)
const quality = envVar > 1e-12 ? max / envVar : 0
if (quality <= 0) return { lagMs: 0, quality: 0, vals, lagMin }
const curLag = this.periodMs / GRID_STEP_MS
let bestIdx = -1
let bestScore = Number.NEGATIVE_INFINITY
for (let i = 1; i < vals.length - 1; i++) {
const isMax = vals[i] >= vals[i - 1] && vals[i] >= vals[i + 1]
if (!isMax) continue
const lag = lagMin + i
let score = vals[i] * tempoPrior(lag * GRID_STEP_MS)
if (Math.abs(lag - curLag) / curLag < LOCK_AGREE) score *= LOCK_BOOST
if (score > bestScore) {
bestScore = score
bestIdx = i
}
}
if (bestIdx < 0 || vals[bestIdx] <= 0) return { lagMs: 0, quality: 0, vals, lagMin }
// parabolic refinement around the chosen peak
const y0 = vals[bestIdx - 1]
const y1 = vals[bestIdx]
const y2 = vals[bestIdx + 1]
const denom = y0 - 2 * y1 + y2
const delta = denom !== 0 ? (0.5 * (y0 - y2)) / denom : 0
const lagRefined = lagMin + bestIdx + Math.max(-0.5, Math.min(0.5, delta))
return { lagMs: lagRefined * GRID_STEP_MS, quality, vals, lagMin }
}
/**
* Comb-filter phase: the beat phase (fraction of the period since the
* last beat) whose grid positions sample the most onset energy. Phases
* near the current grid alignment get a bonus — see COMB_BONUS.
*/
private estimatePhaseFrac(preferPhase?: number): number {
const pFrames = this.periodMs / GRID_STEP_MS
const beats = Math.min(24, Math.floor(this.ring.length / pFrames) - 1)
if (beats < 3) return 0
let bestPhase = 0
let bestVal = Number.NEGATIVE_INFINITY
for (let ph = 0; ph < pFrames; ph += 0.5) {
let s = 0
let cnt = 0
for (let b = 1; b <= beats; b++) {
const tAgo = b * pFrames - ph
if (tAgo >= 0 && tAgo < this.ring.length - 1) {
s += this.envelopeAt(tAgo)
cnt++
}
}
if (cnt < 3) continue
s /= cnt
if (preferPhase !== undefined) {
let d = Math.abs(ph / pFrames - preferPhase)
if (d > 0.5) d = 1 - d
s *= 1 + COMB_BONUS * Math.max(0, 1 - 2 * d)
}
if (s > bestVal) {
bestVal = s
bestPhase = ph
}
}
return bestPhase / pFrames
}
/**
* Advance the tracker one frame.
* @param bassAtt smoothed bass level (the detector input)
* @param nowMs monotonic clock in milliseconds (performance.now())
* @param dtSec frame delta in seconds
* @param onset the onset (spectral-flux) envelope value for this frame
* @param nowMs monotonic clock in milliseconds (performance.now())
* @param dtSec frame delta in seconds
*/
update(bassAtt: number, nowMs: number, dtSec: number, cfg?: BeatConfig): BeatFrame {
update(onset: number, nowMs: number, dtSec: number, cfg?: BeatConfig): BeatFrame {
if (cfg) this.cfg = cfg
this.updateCount++
// --- rolling energy history -------------------------------------------
this.history.push(bassAtt)
if (this.history.length > HISTORY_SIZE) this.history.shift()
let sum = 0
for (let k = 0; k < this.history.length; k++) sum += this.history[k]
const avg = sum / this.history.length
// ---- resample onto the fixed 60 Hz grid (rAF jitter immunity) --------
this.feedSampler(onset, nowMs)
// ---- periodic tempo estimate ------------------------------------------
if (this.updateCount % ESTIMATE_EVERY === 0) {
let mean = 0
for (const v of this.ring) mean += v
mean /= this.ring.length
const active = this.ring.length >= 90 && mean > this.cfg.floorLevel
if (active) this.lastActiveAt = nowMs
if (active) {
const est = this.estimatePeriod()
if (est.lagMs > 0 && est.quality >= acQualityRequired(this.cfg.threshold)) {
let cand = foldPeriod(est.lagMs)
// Subdivision preference — the metrical-level resolver. Dense mix
// sections compete between the beat and its multiples: a dominant
// 2× level (900 ms) whose half (450 ms) carries ≥75% of its AC is
// the SAME beat at half tempo → take the half (it then AGREES with
// the beat-level lock instead of fighting it). Mid-level (683 ms)
// competitors halve to a non-agreeing lag, which breaks the
// agreement chain and blocks the steal. The prior gate (≥0.5)
// keeps a true slow song's beat (550 → 272 ms, prior 0.13) from
// ever being mangled.
if (cand > 500) {
const halfLag = Math.round(cand / 2 / GRID_STEP_MS) - est.lagMin
const halfAc = halfLag >= 1 && halfLag < est.vals.length ? est.vals[halfLag] : 0
const fullAc = est.vals[Math.round(cand / GRID_STEP_MS) - est.lagMin] ?? 1
if (halfAc >= 0.75 * fullAc && tempoPrior(cand / 2) >= 0.5) cand = cand / 2
}
if (
this.pendingPeriod !== null &&
Math.abs(cand - this.pendingPeriod) / this.pendingPeriod < LOCK_AGREE
) {
this.pendingCount++
} else {
this.pendingPeriod = cand
this.pendingCount = 1
}
// a confident lock needs extra evidence to jump to a different
// tempo (outros and fills otherwise steal it for a few seconds)
const bigChange = this.bpm > 0 && Math.abs(cand - this.periodMs) / this.periodMs > LOCK_AGREE
const need = bigChange ? 3 : 2
if (this.pendingCount >= need) {
const firstLock = this.bpm === 0
this.periodMs = firstLock ? cand : this.periodMs * 0.75 + cand * 0.25
this.bpm = Math.round((60000 / this.periodMs) * 10) / 10
this.confidence = Math.min(1, this.confidence + 0.2)
// a tempo CHANGE is provisional: cap confidence so that if the
// new period is wrong (a metrical-level competitor stole the
// lock in a dense section), the hysteresis boost decays quickly
// and the true period wins it back — instead of a 13 s wrong
// lock that self-reinforces at confidence 1.0
if (bigChange) this.confidence = Math.min(this.confidence, 0.6)
this.applyCombAlignment(nowMs, firstLock)
this.pendingCount = 0
}
} else {
this.confidence = Math.max(0, this.confidence - 0.05)
}
} else {
this.confidence = Math.max(0, this.confidence - 0.05)
}
}
// ---- grid firing ------------------------------------------------------
let beat = false
let source: BeatSource = 'none'
// --- real beat detection ----------------------------------------------
if (
bassAtt > this.cfg.floorLevel &&
bassAtt > avg * this.cfg.threshold &&
nowMs - this.lastBeatAt >= this.cfg.minInterval
) {
beat = true
source = 'audio'
this.lastBeatAt = nowMs
this.lastRealBeatAt = nowMs
this.beatCount++
// interval learning
if (this.prevRealBeatAt > 0) {
const interval = nowMs - this.prevRealBeatAt
if (interval >= 200 && interval <= 2500) {
this.intervals.push(interval)
if (this.intervals.length > MAX_INTERVALS) this.intervals.shift()
if (this.intervals.length >= 3) {
const med = median(this.intervals)
const mean = this.intervals.reduce((a, b) => a + b, 0) / this.intervals.length
const variance = this.intervals.reduce((a, b) => a + (b - mean) * (b - mean), 0) / this.intervals.length
const spread = Math.sqrt(variance) / Math.max(mean, 1)
if (spread < 0.3) {
// consistent tempo: lock the period (EMA) and gain confidence
this.periodMs = this.periodMs * 0.6 + foldPeriod(med) * 0.4
this.bpm = Math.round((60000 / this.periodMs) * 10) / 10
this.confidence = Math.min(1, this.confidence + 0.2)
} else {
// irregular — trust it less
this.confidence = Math.max(0, this.confidence - 0.05)
const musicLive = nowMs - this.lastActiveAt < 3000
// the grid can cross the beat between rAF samples — classify as a real
// beat when the onset shows anywhere in the last ~80 ms of grid samples
const envNear = this.recentOnset()
if (this.bpm > 0 && Number.isFinite(this.nextBeatAt) && musicLive) {
if (nowMs >= this.nextBeatAt) {
// background-tab catch-up: rAF paused for seconds → jump the grid
// to the near future instead of machine-gunning missed beats
if (nowMs - this.nextBeatAt > this.periodMs * 2) {
this.nextBeatAt = nowMs + this.periodMs * 0.5
} else {
this.nextBeatAt += this.periodMs
const bassGone = nowMs - this.lastRealBeatAt > this.periodMs * 1.6
if (nowMs - this.lastBeatAt >= this.cfg.minInterval) {
if (envNear > this.cfg.floorLevel) {
beat = true
source = 'audio'
this.lastRealBeatAt = nowMs
} else if (this.cfg.prediction && this.confidence > 0.35 && bassGone) {
beat = true
source = 'predicted'
}
if (beat) {
this.lastBeatAt = nowMs
this.phase = 0
this.beatCount++
}
}
}
}
this.prevRealBeatAt = nowMs
this.phase = 0
}
// --- predictor (phase-locked grid) -------------------------------------
if (this.periodMs > 50) {
this.phase += (dtSec * 1000) / this.periodMs
}
if (this.phase >= 1) {
this.phase -= Math.floor(this.phase)
const bassGone = nowMs - this.lastRealBeatAt > this.periodMs * 1.4
if (
!beat &&
this.cfg.prediction &&
this.confidence > 0.35 &&
bassGone &&
nowMs - this.lastBeatAt >= this.cfg.minInterval
) {
beat = true
source = 'predicted'
this.lastBeatAt = nowMs
this.beatCount++
// predicted beats slowly spend confidence so a stale lock fades out
this.confidence = Math.max(0, this.confidence - 0.02)
}
}
// --- confidence decay when audio beats disappear -----------------------
if (nowMs - this.lastRealBeatAt > 2000 && this.taps.length === 0) {
if (nowMs - this.lastRealBeatAt > 2500) {
this.confidence = Math.max(0, this.confidence - dtSec * 0.06)
}
// beatphase for scripts/UI: 0 right after a beat, →1 approaching the next
if (!beat) {
this.phase = Number.isFinite(this.nextBeatAt)
? 1 - Math.max(0, Math.min(1, (this.nextBeatAt - nowMs) / this.periodMs))
: 0
}
return { beat, source, bpm: this.bpm, phase: this.phase, confidence: this.confidence }
}
/**
* Anchor/pull the beat grid toward the comb alignment. First lock sets
* the grid outright; afterwards a PLL-style pull (≤3% of the period per
* estimate, 5% deadband) keeps it glued — a hard re-anchor every estimate
* would skip/double beats, and a big pull lets comb noise wobble them.
*/
private applyCombAlignment(nowMs: number, firstLock: boolean): void {
const pFrames = this.periodMs / GRID_STEP_MS
if (this.ring.length / pFrames < 4) {
if (firstLock) this.nextBeatAt = nowMs + this.periodMs
return
}
const gridPhase = Number.isFinite(this.nextBeatAt)
? 1 - Math.max(0, Math.min(1, (this.nextBeatAt - nowMs) / this.periodMs))
: undefined
const phFrac = this.estimatePhaseFrac(gridPhase)
// phFrac = fraction of the period since the LAST beat → the next beat
// is exactly one phFrac away. (Mirror trap: computing now − ph + P
// instead anchors the grid at a reflected, arbitrary phase.)
const combNext = nowMs + phFrac * this.periodMs
if (firstLock || !Number.isFinite(this.nextBeatAt)) {
this.nextBeatAt = combNext
} else {
let d = combNext - this.nextBeatAt
while (d > this.periodMs / 2) d -= this.periodMs
while (d < -this.periodMs / 2) d += this.periodMs
if (Math.abs(d) > this.periodMs * 0.05) {
this.nextBeatAt += Math.max(-this.periodMs * 0.03, Math.min(this.periodMs * 0.03, d))
}
}
if (this.nextBeatAt <= nowMs) this.nextBeatAt += this.periodMs
}
/**
* "Set Beat" — user taps to the music. Two or more taps lock the tempo
* (median of tap intervals) and re-phase the predictor to the taps.
* (median of tap intervals) and re-phase the grid to the taps.
*/
tap(nowMs: number): TapResult {
if (nowMs - this.lastTapAt > TAP_RESET_MS) this.taps = []
@ -208,6 +505,8 @@ export class BeatTracker {
// immediate visual feedback + re-phase
this.beatCount++
this.lastBeatAt = nowMs
this.lastRealBeatAt = nowMs
this.lastActiveAt = nowMs
this.phase = 0
if (this.taps.length >= 2) {
@ -218,9 +517,13 @@ export class BeatTracker {
this.periodMs = foldPeriod(med)
this.bpm = Math.round((60000 / this.periodMs) * 10) / 10
this.confidence = Math.min(1, 0.45 + 0.11 * this.taps.length)
// keep the interval learner in sync with the tapped grid
this.intervals = [...gaps.slice(-MAX_INTERVALS)]
this.lastRealBeatAt = nowMs
// re-anchor the grid one period out (next tapped beat position);
// the comb pull refines it from the audio
this.nextBeatAt = nowMs + this.periodMs
// seed the pending latch so the next audio estimate that agrees
// commits immediately instead of restarting from scratch
this.pendingPeriod = this.periodMs
this.pendingCount = 1
}
} else {
this.confidence = Math.max(this.confidence, 0.25)

View file

@ -6,10 +6,16 @@
// presets are UNIQUE generated stand-ins (16 families × seeded parameters)
// for originals that rely on effects the script engine cannot run.
//
// The "Zai-Magic" folder at the END of EMBEDDED_LIBRARY is the exception to
// "do not edit": ten presets HAND-AUTHORED for this engine (v7.00) to show
// off beat-synced color — they use hsvr/hsvg/hsvb, beatage, megabuf particle
// physics, getspec() and loop(). If the converter ever regenerates this
// file, keep that section verbatim.
//
// EMBEDDED_SEED_VERSION: bump whenever this library is regenerated — the
// GET /api/presets route re-seeds the root/embedded subtree when it changes.
export const EMBEDDED_SEED_VERSION = '6.1'
export const EMBEDDED_SEED_VERSION = '7.01'
export interface EmbeddedPreset {
folder: string
@ -833,4 +839,480 @@ export const EMBEDDED_LIBRARY: EmbeddedPreset[] = [
approx: true,
content: "name: \"warm freeform (ya-tho-sho treatment)\"\nmode: lines\nblend: additive\npersist: 0.855\nlinewidth: 1.459\n\n[init]\nn = 572\n\n[frame]\nhue = hue + dt * 0.129 + beat * 0.01\ntravel = travel + dt * (0.44 + bass_att * 1.4)\nflash = flash * 0.86\n\n[beat]\nflash = 1\n\n[point]\ng = floor(i * 13)\ngi = frac(i * 13)\nph = frac(rand(g * 5.1 + 1) + travel * (0.55 + rand(g + 17) * 0.85))\na = gi * 6.28318\nrad = ph * (0.305 + bass * 0.472 + flash * 0.08)\nx = sin(a) * rad / aspect\ny = cos(a) * rad\nfade = sin(ph * 3.14159)\nred = fade * (0.35 + flash * 0.4) * 0.85\ngreen = fade * (0.35 + flash * 0.4) * 0.92\nblue = fade * (0.35 + flash * 0.4)\nskip = if(above(ph, 0.96), 1, 0)\n",
},
// ==========================================================================
// Zai-Magic — ten presets hand-authored for this engine (v7.00).
// Every one is built around BEAT-SYNCED COLOR: the color wheel turns on
// the kick, palettes swap per beat or per 4-beat bar, and kick envelopes
// (beatage) drive brightness. Techniques span all three render modes,
// megabuf particle physics, direct getspec() calls and loop() setup.
// KEEP VERBATIM when regenerating this file with the converter.
// ==========================================================================
{
folder: "root/embedded/Zai-Magic",
name: "Prism Chandelier",
author: "Zai",
description:
"Six mirrored ribbons spin as one kaleidoscope; every beat turns the color wheel a notch and the kick flares the whole prism. Hand-authored for OVE — hsv rainbow + beatage kick envelope.",
content: `name: "Prism Chandelier"
author: "Zai"
mode: lines
blend: additive
persist: 0.87
linewidth: 1.6
[init]
mirrors = 6
n = 1140
chroma = 0.13
rot = 0
[frame]
t = t + dt
rot = rot + dt * (0.25 + mid_att * 0.6)
chroma = frac(chroma + dt * 0.045 + beat * 0.085)
pulse = exp(-beatage * 3.2)
glow = 0.35 + bass_att * 0.45 + pulse * 0.35
[point]
seg = floor(i * mirrors)
si = frac(i * mirrors)
ph = si * pi
bx = sin(ph * 2 + t * 1.35) * (0.34 + bass * 0.18)
by = cos(ph * 3 + t * 0.9) * (0.26 + mid * 0.16)
ca = cos(rot + seg * tau / mirrors)
sa = sin(rot + seg * tau / mirrors)
x = (bx * ca - by * sa) / aspect
y = bx * sa + by * ca
hue = chroma + seg / mirrors + si * 0.08
red = hsvr(hue, 1, glow)
green = hsvg(hue, 1, glow)
blue = hsvb(hue, 1, glow)
skip = if(below(si, 0.015), 1, 0)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Neon Heartbeat",
author: "Zai",
description:
"A solid-mode bloom that throbs with the bass — the fill hue jumps on every beat and the petals breathe on the kick envelope. Hand-authored for OVE (solid mode + hsv color).",
content: `name: "Neon Heartbeat"
author: "Zai"
mode: solid
blend: additive
persist: 0.82
linewidth: 2
[init]
n = 240
hue = 0.55
spin = 0
[frame]
t = t + dt
spin = spin + dt * 0.4
hue = frac(hue + beat * 0.22)
pulse = exp(-beatage * 4.5)
petals = 5 + pulse * 1.5
[point]
li = if(below(i, 0.5), i * 2, (i - 0.5) * 2)
back = if(below(i, 0.5), 1, -1)
ang = li * tau + spin * back
petal = 1 + 0.42 * sin(petals * ang + t * 1.7)
rad = if(below(i, 0.5), 0.62 + bass * 0.16 + pulse * 0.2, 0.3 + treb * 0.1 + pulse * 0.12) * petal
x = sin(ang) * rad / aspect
y = cos(ang) * rad
red = hsvr(hue, 0.9, 0.42 + pulse * 0.5)
green = hsvg(hue, 0.9, 0.42 + pulse * 0.5)
blue = hsvb(hue, 0.9, 0.42 + pulse * 0.5)
skip = if(below(li, 0.008), 1, 0)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Spectrum Serpent",
author: "Zai",
description:
"An oscilloscope serpent coiled into a spiral — the body swells where the spectrum is loud and the rainbow shifts a step on every beat. Hand-authored for OVE.",
content: `name: "Spectrum Serpent"
author: "Zai"
mode: lines
blend: additive
persist: 0.9
linewidth: 2.2
[init]
n = 900
chroma = 0
turns = 4.5
[frame]
t = t + dt * (0.7 + bass_att * 0.9)
chroma = frac(chroma + dt * 0.06 + beat * 0.14)
pulse = exp(-beatage * 3.5)
[point]
a = i * tau * turns + t
r = 0.07 + pow(i, 0.85) * 0.82 * (0.55 + bass_att * 0.55 + pulse * 0.12)
wig = sin(a * 3 + t * 2.1) * 0.09 * (0.4 + mid)
x = (sin(a) * r + wig * cos(a)) / aspect
y = cos(a) * r + wig * sin(a)
hue = chroma + i * 0.45
lum = 0.3 + sv * 1.1 * (0.6 + vol_att * 0.8) + pulse * 0.18
red = hsvr(hue, 0.95, min(1, lum))
green = hsvg(hue, 0.95, min(1, lum))
blue = hsvb(hue, 0.95, min(1, lum))
size = 1.2 + sv * 4
alpha = 0.45 + 0.55 * sin(i * pi)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Bass Bloom",
author: "Zai",
description:
"A fountain of 480 megabuf particles launched by every beat — each petal carries the exact hue of the beat that spawned it, so the falling arcs leave a rainbow memory of the music. Hand-authored for OVE.",
content: `name: "Bass Bloom"
author: "Zai"
mode: dots
blend: additive
persist: 0.9
linewidth: 2
[init]
n = 480
budget = 80
chroma = 0.62
k = 0
loop(n, megabuf(k * 6 + 4) = -1; k = k + 1)
[frame]
t = t + dt
budget = budget + 2 + beat * 340
chroma = frac(chroma + beat * 0.13)
[point]
k = floor(i * n)
b0 = k * 6
age = megabuf(b0 + 4)
if(band(below(age, 0), above(budget, 0)),
(megabuf(b0 + 4) = 0.0001;
megabuf(b0 + 5) = chroma;
angk = rand(k * 3.13 + 0.3) * tau;
spdk = 0.5 + rand(k * 7.77 + 3) * 0.9 + bass * 0.35;
megabuf(b0) = 0;
megabuf(b0 + 1) = 0;
megabuf(b0 + 2) = cos(angk) * spdk;
megabuf(b0 + 3) = sin(angk) * spdk;
budget = budget - 1),
0)
age = megabuf(b0 + 4)
if(above(age, 0),
(vy = megabuf(b0 + 3) - dt * 0.5;
megabuf(b0 + 3) = vy;
megabuf(b0) = megabuf(b0) + megabuf(b0 + 2) * dt;
megabuf(b0 + 1) = megabuf(b0 + 1) + vy * dt;
megabuf(b0 + 4) = age + dt;
px = megabuf(b0);
py = megabuf(b0 + 1);
fade = 1 - age * 0.8;
if(below(fade, 0.03), megabuf(b0 + 4) = -1, 0);
if(above(abs(px) + abs(py), 2.3), megabuf(b0 + 4) = -1, 0);
hue = megabuf(b0 + 5);
x = px / aspect;
y = py;
red = hsvr(hue, 0.9, max(0, fade));
green = hsvg(hue, 0.9, max(0, fade));
blue = hsvb(hue, 0.9, max(0, fade));
size = 1.1 + fade * 2.6;
alpha = min(1, fade * 1.5)),
(skip = 1))
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Strobe Lattice",
author: "Zai",
description:
"Nested squares snap around and swap palette on every beat like a club strobe light; the flash decays on the beatage envelope while the lattice breathes with the bass. Hand-authored for OVE.",
content: `name: "Strobe Lattice"
author: "Zai"
mode: lines
blend: additive
persist: 0.86
linewidth: 1.8
[init]
rings = 5
sides = 4
per = 6
n = rings * per
rot = 0.2
chroma = 0
[frame]
t = t + dt
rot = rot + dt * 0.22
flash = exp(-beatage * 6)
[beat]
rot = rot + 0.62
chroma = frac(beats * 0.19)
[point]
ring = floor(i * rings)
ri = frac(i * rings)
vert = floor(ri * per)
av = vert * tau / sides
fx = cos(av)
fy = sin(av)
scale = 0.2 + ring * 0.17 + bass_att * 0.08 + flash * 0.05
a = rot * (1 + ring * 0.13)
ca = cos(a)
sa = sin(a)
x = (fx * ca - fy * sa) * scale / aspect
y = (fx * sa + fy * ca) * scale
hue = chroma + ring * 0.055
lum = 0.32 + flash * 0.5 + mid_att * 0.28
red = hsvr(hue, 0.85, lum)
green = hsvg(hue, 0.85, lum)
blue = hsvb(hue, 0.85, lum)
skip = if(above(vert, sides + 0.5), 1, 0)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Aurora Veil",
author: "Zai",
description:
"Nine waving curtains, each lit by its own frequency band (getspec) — the whole color scheme drifts once per 4-beat bar for slow, breathing color changes. Hand-authored for OVE.",
content: `name: "Aurora Veil"
author: "Zai"
mode: lines
blend: additive
persist: 0.93
linewidth: 2.4
[init]
curtains = 9
n = 1080
scheme = 0.33
[frame]
t = t + dt
shimmer = sin(beatphase * tau)
[beat]
if(equal(beats % 4, 0), scheme = frac(scheme + 0.17), 0)
[point]
cu = floor(i * curtains)
ci = frac(i * curtains)
cx = (cu / (curtains - 1) - 0.5) * 1.9
band = getspec(cu / curtains, 0, 0)
x = (cx + sin(ci * 3.1 + t * 0.8 + cu * 0.7) * 0.16 * (0.25 + mid_att) + shimmer * 0.03) / aspect
y = ci * 1.9 - 0.95 + cos(ci * 4.2 - t * 1.1 + cu) * 0.05
hue = scheme + cu / curtains * 0.5 + ci * 0.06
lum = (0.2 + band * 1.35 * (0.5 + vol_att)) * (0.55 + 0.45 * sin(ci * pi))
red = hsvr(hue, 0.8, min(1, lum))
green = hsvg(hue, 0.8, min(1, lum))
blue = hsvb(hue, 0.72, min(1, lum * 1.18))
alpha = 0.5 + 0.5 * sin(ci * pi)
skip = if(below(ci, 0.01), 1, 0)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Supernova",
author: "Zai",
description:
"Every beat detonates the entire 620-particle field — shells leave the core white-hot and saturate into the beat's hue as they expand and cool. Hand-authored for OVE (megabuf physics + drag).",
content: `name: "Supernova"
author: "Zai"
mode: dots
blend: additive
persist: 0.88
linewidth: 2
[init]
n = 620
budget = 620
chroma = 0.08
k = 0
loop(n, megabuf(k * 6 + 4) = -1; k = k + 1)
[frame]
t = t + dt
budget = budget + 1
chroma = frac(chroma + beat * 0.21)
[beat]
budget = n
k = 0
loop(n, megabuf(k * 6 + 4) = -1; k = k + 1)
[point]
k = floor(i * n)
b0 = k * 6
age = megabuf(b0 + 4)
if(band(below(age, 0), above(budget, 0)),
(megabuf(b0 + 4) = 0.0001;
megabuf(b0 + 5) = chroma;
angk = rand(k * 5.19 + 0.7 + beats * 13.7) * tau;
spdk = 0.4 + rand(k * 9.71 + 2 + beats * 7.31) * 1.2 + bass * 0.4;
megabuf(b0) = cos(angk) * 0.03;
megabuf(b0 + 1) = sin(angk) * 0.03;
megabuf(b0 + 2) = cos(angk) * spdk;
megabuf(b0 + 3) = sin(angk) * spdk;
budget = budget - 1),
0)
age = megabuf(b0 + 4)
if(above(age, 0),
(drag = exp(-age * 1.6);
megabuf(b0) = megabuf(b0) + megabuf(b0 + 2) * drag * dt;
megabuf(b0 + 1) = megabuf(b0 + 1) + megabuf(b0 + 3) * drag * dt;
megabuf(b0 + 4) = age + dt;
px = megabuf(b0);
py = megabuf(b0 + 1);
hue = megabuf(b0 + 5);
sat = min(1, age * 3.5);
lum = max(0, exp(-age * 1.15));
if(below(lum, 0.02), (megabuf(b0 + 4) = -1; skip = 1), 0);
x = px / aspect;
y = py;
red = hsvr(hue, sat, lum);
green = hsvg(hue, sat, lum);
blue = hsvb(hue, sat, lum);
size = 0.6 + lum * 3.2;
alpha = min(1, lum * 2)),
(skip = 1))
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Tunnel Vision",
author: "Zai",
description:
"A tunnel of rings where every ring carries the hue of the beat it was born on — each kick sends a new color wave riding out from the center. Hand-authored for OVE.",
content: `name: "Tunnel Vision"
author: "Zai"
mode: lines
blend: additive
persist: 0.9
linewidth: 2
[init]
rings = 26
per = 36
n = rings * per
tt = 0
chroma = 0.4
[frame]
t = t + dt
tt = tt + dt * (0.14 + bass_att * 0.22)
chroma = frac(chroma + beat * 0.115)
pulse = exp(-beatage * 4)
[point]
ring = floor(i * rings)
ri = frac(i * rings)
dot = floor(ri * per)
z = frac(ring / rings + tt)
rad = 0.06 + pow(z, 1.7) * 1.05
a = dot / per * tau + ring * 0.24 + sin(t * 0.5) * 0.15
wob = 1 + sin(ri * tau * 5 + t * 2) * 0.05 * treb_att + pulse * 0.08
x = sin(a) * rad * wob / aspect
y = cos(a) * rad * wob
hue = chroma - z * 0.38
lum = pow(z, 0.6) * (0.35 + vol_att * 0.55) + pulse * 0.15 * z
red = hsvr(hue, 0.92, min(1, lum))
green = hsvg(hue, 0.92, min(1, lum))
blue = hsvb(hue, 0.92, min(1, lum))
skip = if(above(dot, per - 1.5), 1, 0)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Lissajous Loom",
author: "Zai",
description:
"A lissajous weave that re-threads its frequency ratio once per 4-beat bar while the rainbow rotates on every single beat — the pattern morphs in color and shape together. Hand-authored for OVE.",
content: `name: "Lissajous Loom"
author: "Zai"
mode: lines
blend: additive
persist: 0.91
linewidth: 1.5
[init]
n = 1400
mode = 0
chroma = 0.5
[frame]
t = t + dt * 0.5
fa = 2 + floor(mode / 2) + equal(mode, 4) * 2
fb = 3 + mode % 2 + equal(mode, 3)
pulse = exp(-beatage * 3)
[beat]
if(equal(beats % 4, 0), mode = (mode + 1) % 5, 0)
chroma = frac(chroma + 0.09)
[point]
ph = i * tau
x = sin(ph * fa + t) * (0.55 + bass * 0.28 + pulse * 0.1) / aspect
y = sin(ph * fb + t * 1.35 + 1.1) * (0.55 + mid * 0.25)
hue = chroma + i * 0.33 + sin(ph + t) * 0.05
lum = 0.3 + abs(v) * 0.55 + vol_att * 0.35 + pulse * 0.2
red = hsvr(hue, 0.9, min(1, lum))
green = hsvg(hue, 0.9, min(1, lum))
blue = hsvb(hue, 0.9, min(1, lum))
alpha = 0.55 + 0.45 * sin(ph * fa)
`,
},
{
folder: "root/embedded/Zai-Magic",
name: "Beat Matrix",
author: "Zai",
description:
"A 16-band spectrum wall — every beat rotates the entire rainbow across the columns and whitewashes the lit cells for one strobe flash. Hand-authored for OVE (direct getspec bands).",
content: `name: "Beat Matrix"
author: "Zai"
mode: dots
blend: additive
persist: 0.74
linewidth: 2
[init]
cols = 16
rows = 10
n = cols * rows
chroma = 0
[frame]
t = t + dt
chroma = frac(chroma + beat * 0.166)
flash = exp(-beatage * 5)
[point]
col = floor(i * cols)
row = floor(frac(i * cols) * rows)
lvl = getspec(col / cols, 0, 0)
frombottom = (rows - 1 - row) / rows
lit = if(below(frombottom, lvl * 1.15), 1, 0)
x = (col / (cols - 1) * 1.74 - 0.87) / aspect
y = 0.9 - row * 0.19
hue = chroma + col / cols * 0.78
lum = lit * (0.3 + bass_att * 0.3) + 0.045
sat = 0.9 - flash * 0.85
red = hsvr(hue, sat, lum + flash * lit * 0.55)
green = hsvg(hue, sat, lum + flash * lit * 0.55)
blue = hsvb(hue, sat, lum + flash * lit * 0.55)
size = 2.4 + lit * 1.7 + treb * 1.2
`,
},
]

View file

@ -47,6 +47,13 @@ export class AvaEngine {
/** +1 native (y up) / -1 AVS-ported presets (y down). Applied when reading points. */
private ysign = 1
/** v7.2 preset-change transition: snapshot of the outgoing frame — the
* loop dissolves it into the new preset through a progressive blur so
* preset swaps never hard-cut (no eye-jerking). Null when idle. */
private transition: { snap: HTMLCanvasElement; start: number; duration: number } | null = null
/** ctx.filter support (older Safari lacks it → plain crossfade fallback). */
private canFilter = false
constructor() {
// AVS-eel compatibility hooks — the expression engine reads audio/time
// through these (getspec/getosc/gettime). Headless tests can override.
@ -68,6 +75,8 @@ export class AvaEngine {
const ctx = canvas.getContext('2d', { alpha: false })
if (!ctx) return
this.ctx = ctx
this.canFilter = typeof ctx.filter === 'string'
this.transition = null
this.ctx.fillStyle = '#000'
this.ctx.fillRect(0, 0, canvas.width, canvas.height)
this.clockStart = performance.now()
@ -83,6 +92,7 @@ export class AvaEngine {
stop(): void {
this.running = false
this.transition = null
if (this.raf) cancelAnimationFrame(this.raf)
this.raf = 0
if (this.resizeObserver) {
@ -92,12 +102,28 @@ export class AvaEngine {
this.audio.stop()
}
/** Swap in a new script — re-runs [init] on a fresh environment. */
/** Swap in a new script — re-runs [init] on a fresh environment.
* v7.2: when a script is already rendering on a live canvas, the
* outgoing frame is snapshotted first so the loop can blur-dissolve it
* into the new preset (preset changes never hard-cut). */
setScript(script: AvaScript): void {
if (this.script && this.canvas && this.ctx && this.running) {
try {
const snap = document.createElement('canvas')
snap.width = this.canvas.width
snap.height = this.canvas.height
snap.getContext('2d')?.drawImage(this.canvas, 0, 0)
this.transition = { snap, start: performance.now(), duration: 700 }
} catch {
this.transition = null // canvas readback failed → hard cut, as before
}
}
this.script = script
this.scriptError = null
this.ysign = script.meta.ysign === -1 ? -1 : 1
this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0 }
// beatage starts "long ago" so exp(-beatage*k) envelopes read as 0 until
// the first real beat arrives.
this.env = { n: 400, t: 0, time: 0, frame: 0, beats: 0, beatage: 99 }
resetEelBuffers()
try {
script.initProg?.exec(this.env)
@ -160,10 +186,12 @@ export class AvaEngine {
// playing/loaded. With no audio, the tracker goes to standby: no beats
// fire, the BPM resets to 0 and learned tempo is dropped — so nothing
// pulses to ghost beats when the music is stopped (v6.4).
// v7.1: the detector input is the ONSET (spectral-flux) envelope, not
// the smoothed bass level — see lib/ava/beat.ts and lib/ava/types.ts.
const beatFrame =
this.audio.kind === 'idle'
? this.beat.standby()
: this.beat.update(levels.bassAtt, now, dt, this.beatConfig)
: this.beat.update(levels.flux, now, dt, this.beatConfig)
// ---- environment ------------------------------------------------------
const env = this.env
@ -183,6 +211,10 @@ export class AvaEngine {
env.beatphase = beatFrame.phase
env.beatconf = beatFrame.confidence
env.beats = (env.beats ?? 0) + (beatFrame.beat ? 1 : 0)
// seconds since the last beat — 0 on the beat frame itself. Paired with
// exp(-beatage * k) it is the classic kick envelope (no per-script
// bookkeeping needed).
env.beatage = beatFrame.beat ? 0 : (env.beatage ?? 99) + dt
env.w = w
env.h = h
env.aspect = w / Math.max(1, h)
@ -331,6 +363,30 @@ export class AvaEngine {
}
}
// ---- preset-change blur transition (v7.2) -----------------------------
// Dissolve the outgoing snapshot into the freshly rendered frame: the
// old image blurs progressively and fades out (easeOutCubic) while a
// slight scale-up keeps its softening edges from opening a gap at the
// canvas border. Covers every swap path — tree Load, random club mix,
// the Demo showcase — since they all funnel through setScript().
if (this.transition) {
const t = (now - this.transition.start) / this.transition.duration
if (t >= 1) {
this.transition = null
} else {
const e = 1 - (1 - t) * (1 - t) * (1 - t)
ctx.save()
ctx.globalCompositeOperation = 'source-over'
ctx.globalAlpha = Math.max(0, 1 - e)
if (this.canFilter) ctx.filter = `blur(${(e * e * 12 * (h / 480)).toFixed(1)}px)`
const grow = 1 + 0.06 * e
ctx.translate(w / 2, h / 2)
ctx.scale(grow, grow)
ctx.drawImage(this.transition.snap, -w / 2, -h / 2, w, h)
ctx.restore()
}
}
// ---- stats to UI (throttled) --------------------------------------------
if (this.onStats && now - this.lastStatsAt > 125) {
this.lastStatsAt = now

View file

@ -101,6 +101,42 @@ function fn3(f: (x: number, y: number, z: number) => number): FuncDef {
const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x)
// --- HSV → RGB channel helpers (hsvr / hsvg / hsvb) -----------------------
// The language is scalar-only, so HSV comes back one channel per call:
// red = hsvr(hue); green = hsvg(hue); blue = hsvb(hue)
// paints the full rainbow wheel from a single 0..1 hue. Saturation and value
// are optional (default 1) — hsvr(hue, s, v). Hue wraps (negative & >1 ok).
function hsvChannel(channel: 0 | 1 | 2): FuncDef {
return {
minArgs: 1,
maxArgs: 3,
build: (a) => {
const hN = a[0]
const sN = a[1] ?? (() => 1)
const vN = a[2] ?? (() => 1)
return (env) => {
let h = hN(env) % 1
if (h < 0) h += 1
const s = clamp01(sN(env))
const v = clamp01(vN(env))
const c = v * s
const hh = h * 6
const x = c * (1 - Math.abs((hh % 2) - 1))
let r = 0, g = 0, b = 0
const seg = Math.floor(hh) % 6
if (seg === 0) { r = c; g = x }
else if (seg === 1) { r = x; g = c }
else if (seg === 2) { g = c; b = x }
else if (seg === 3) { g = x; b = c }
else if (seg === 4) { r = x; b = c }
else { r = c; b = x }
const m = v - c
return channel === 0 ? r + m : channel === 1 ? g + m : b + m
}
},
}
}
const FUNCTIONS: Record<string, FuncDef> = {
// --- AVS-eel audio/time compatibility ---------------------------------
getspec: fn3((a, _w, _ch) => AVA_HOOKS.spectrumAt(clamp01(a))),
@ -135,6 +171,24 @@ const FUNCTIONS: Record<string, FuncDef> = {
max: fn2(Math.max),
pow: fn2(Math.pow),
rand: fn1(hashRand),
hsvr: hsvChannel(0),
hsvg: hsvChannel(1),
hsvb: hsvChannel(2),
// smoothstep(e0, e1, x): hermite-smooth 0..1 ramp — e0 ≤ x ≤ e1 eases
// in/out instead of snapping. Degenerate range (e0 == e1) steps at x.
smoothstep: {
minArgs: 3,
maxArgs: 3,
build: (a) => (env) => {
const e0 = a[0](env)
const e1 = a[1](env)
const x = a[2](env)
const d = e1 - e0
if (d === 0) return x < e0 ? 0 : 1
const t = Math.min(Math.max((x - e0) / d, 0), 1)
return t * t * (3 - 2 * t)
},
},
lerp: fn3((a, b, t) => a + (b - a) * t),
clamp: fn3((x, lo, hi) => Math.min(Math.max(x, lo), hi)),
inrange: fn3((x, lo, hi) => (x >= lo && x <= hi ? 1 : 0)),

413
src/lib/ava/queue.ts Executable file
View file

@ -0,0 +1,413 @@
// OVE — lazy playlist queue (v6.7).
//
// When a visitor loads a playlist link, the server returns a FLAT entry list
// (one request). The queue then plays those entries ONE AT A TIME:
//
// • exactly ONE track is resolved (yt-dlp → stream URL) at any moment —
// never the whole playlist, so YouTube's servers are never spammed and
// the visitor can't be temp-banned for bulk downloads;
// • the NEXT track is pre-resolved only when the current one is ~15 s from
// its end (or, if the duration is unknown, when it actually ends) —
// "downloaded as needed, right before they are played";
// • client-side resolves are fully serialized (a jump mid-preload waits
// for the in-flight request), and the server caps concurrent yt-dlp
// extractions at 2 across all visitors as a second guard.
//
// Failure policy: a dead track is skipped automatically; two consecutive
// failures stop the queue (so a 200-track playlist of dead links can't grind
// the resolver). A browser autoplay block (queue started programmatically at
// 'ended') pauses the queue with a Resume button instead of skipping.
//
// v6.8: while the server self-updates yt-dlp (code 'ytdlp-updating'), the
// resolve retries INSIDE resolveLink — the queue just shows its
// "updating engine…" status (state.updating) instead of skipping the track.
// Bot-wall / backend-error failures surface their code so the queue bar can
// offer an "Open Settings" action.
'use client'
import { create } from 'zustand'
import { getEngine } from '@/lib/ava/engine'
import { useAvaStore } from '@/lib/store'
import { getCookieForUrl } from '@/lib/music-sessions'
import { toast } from '@/hooks/use-toast'
import {
playResolvedTrack,
resolveLink,
setSourceRetireListener,
type PlaylistEntryInfo,
type ResolveErrorCode,
type ResolvedPlaylist,
type ResolvedTrack,
} from '@/components/ava/audio-source'
export type QueueStatus =
| 'idle' // no queue at all
| 'loading' // fetching the current track
| 'playing' // a track is streaming
| 'blocked' // browser refused auto-advance → Resume button
| 'error' // two consecutive failures → Retry button
| 'done' // played to the end → Replay button
export interface QueueState {
status: QueueStatus
/** Playlist title (from the resolve). */
title: string
/** Extractor source of the playlist (youtube / soundcloud / …). */
source: string
entries: PlaylistEntryInfo[]
/** Index of the current (or next-to-play) entry; -1 when idle. */
index: number
/** Resolved title of the playing track (falls back to the entry title). */
trackTitle: string | null
lastError: string | null
/** Failure code attached to lastError ('bot-wall' gets a Settings CTA). */
lastErrorCode: ResolveErrorCode | null
/** True while the server is self-updating yt-dlp for a queued track
* (the retry loop lives inside resolveLink). */
updating: boolean
/** True while the look-ahead resolve of the next track is running. */
preloading: boolean
}
const IDLE_STATE: QueueState = {
status: 'idle',
title: '',
source: '',
entries: [],
index: -1,
trackTitle: null,
lastError: null,
lastErrorCode: null,
updating: false,
preloading: false,
}
/** How many seconds before the current track's end the next one is fetched.
* v7.5: 15 → 60 — each resolve now downloads + converts the track to an MP3
* on the server (typically 10–60 s), so the look-ahead has to leave room
* for the conversion or the queue stalls in silence between songs. */
const PRELOAD_LEAD_SEC = 60
/** Consecutive dead tracks tolerated before the queue stops itself. */
const MAX_CONSECUTIVE_FAILURES = 2
export const useQueueStore = create<QueueState>()(() => IDLE_STATE)
// ---- module-scope controller state (not reactive) --------------------------
/** Invalidation token — bumped on every stop/skip/jump; late async results
* from an older generation are discarded. */
let generation = 0
/** Look-ahead cache: the resolve promise for entries[preload.index]. */
let preload: { index: number; promise: Promise<ResolvedTrack | null> } | null = null
/** The 1 s look-ahead timer. */
let preloadTimer: ReturnType<typeof setInterval> | null = null
let consecutiveFailures = 0
/** Serializes every resolve this client issues (anti-spam guard #1). */
let resolveChain: Promise<unknown> = Promise.resolve()
function serializedResolve<T>(fn: () => Promise<T>): Promise<T> {
const run = resolveChain.then(fn, fn)
resolveChain = run.then(
() => undefined,
() => undefined,
)
return run
}
function setQueue(patch: Partial<QueueState>): void {
useQueueStore.setState(patch)
}
function clearPreloadTimer(): void {
if (preloadTimer !== null) {
clearInterval(preloadTimer)
preloadTimer = null
}
}
function syncIdleSource(): void {
const store = useAvaStore.getState()
store.setSource('idle')
store.setSourceTitle(null)
}
// ---- track resolution -------------------------------------------------------
interface EntryResolution {
track: ResolvedTrack | null
/** Human reason when the track could not be resolved (for toasts). */
reason: string | null
/** Resolver failure code ('bot-wall' | 'backend-error' | …), for the bar. */
code: ResolveErrorCode | null
}
/** Resolve one playlist entry to a playable track (never throws). */
async function resolveEntry(gen: number, entry: PlaylistEntryInfo): Promise<EntryResolution> {
try {
const auth = await getCookieForUrl(entry.url)
const result = await serializedResolve(() =>
resolveLink(entry.url, auth, {
// flip the queue bar to its "updating engine…" status the moment the
// server reports a background yt-dlp update (fires once per resolve)
onUpdating: () => setQueue({ updating: true }),
}),
)
setQueue({ updating: false })
if (gen !== generation) return { track: null, reason: null, code: null }
if (!result.ok) {
return { track: null, reason: result.error ?? 'could not be resolved.', code: result.code ?? null }
}
if (result.kind === 'playlist') {
return { track: null, reason: 'that entry is itself a playlist.', code: null }
}
return { track: result, reason: null, code: null }
} catch {
setQueue({ updating: false })
return { track: null, reason: 'unexpected resolver error.', code: null }
}
}
/** Start the look-ahead resolve for entries[index] (only if not cached). */
function beginPreload(index: number): void {
const { entries } = useQueueStore.getState()
if (preload !== null && preload.index === index) return
if (index < 0 || index >= entries.length) return
const gen = generation
setQueue({ preloading: true })
preload = {
index,
promise: resolveEntry(gen, entries[index]).then((r) => {
if (r.track === null) setQueue({ preloading: false })
return r.track
}),
}
}
/**
* Look-ahead watcher: once per second, if the current track is within
* PRELOAD_LEAD_SEC of its end, fetch the NEXT track (and only the next).
* Unknown durations (live streams) never preload — they advance on 'ended'.
*/
function schedulePreloadWatch(gen: number, index: number): void {
clearPreloadTimer()
const engine = getEngine()
preloadTimer = setInterval(() => {
if (gen !== generation) {
clearPreloadTimer()
return
}
const state = useQueueStore.getState()
if (state.status !== 'playing') return
const next = index + 1
if (next >= state.entries.length || preload !== null) return
const duration = engine.audio.durationSec
const position = engine.audio.positionSec
if (duration === null || position === null) return
if (duration - position <= PRELOAD_LEAD_SEC) beginPreload(next)
}, 1000)
}
// ---- playback ---------------------------------------------------------------
async function playIndex(gen: number, index: number): Promise<void> {
const { entries } = useQueueStore.getState()
if (gen !== generation) return
if (index < 0 || index >= entries.length) {
finishQueue(gen)
return
}
const entry = entries[index]
setQueue({
status: 'loading',
index,
trackTitle: entry.title,
lastError: null,
lastErrorCode: null,
updating: false,
preloading: false,
})
// consume the look-ahead cache when it matches (a stale one is dropped —
// its request already left, but the result is simply not used)
const cached = preload !== null && preload.index === index ? preload.promise : null
preload = null
let track: ResolvedTrack | null = null
let reason: string | null = null
let code: ResolveErrorCode | null = null
if (cached) {
track = await cached
if (gen !== generation) return
} else {
const resolved = await resolveEntry(gen, entry)
if (gen !== generation) return
track = resolved.track
reason = resolved.reason
code = resolved.code
}
if (!track) {
if (reason !== null) {
toast({
title: 'Track skipped',
description: `${entry.title} — ${reason}`,
variant: 'destructive',
})
}
handleTrackFailure(gen, index, reason, code)
return
}
const played = await playResolvedTrack(track, entry.title, { loop: false })
if (gen !== generation) return
if (!played.ok) {
if (/NotAllowed|Permission|autoplay/i.test(played.error ?? '')) {
setQueue({ status: 'blocked', lastError: played.error ?? null })
toast({
title: 'Auto-advance paused',
description: 'The browser blocked automatic playback — press Resume to continue the queue.',
})
return
}
toast({
title: 'Track skipped',
description: `${entry.title} — ${played.error ?? 'playback failed.'}`,
variant: 'destructive',
})
handleTrackFailure(gen, index, played.error ?? 'playback failed.', null)
return
}
consecutiveFailures = 0
setQueue({ status: 'playing', trackTitle: played.title ?? entry.title, lastError: null })
schedulePreloadWatch(gen, index)
}
/** A track could not be resolved or played: skip it, or stop after a streak. */
function handleTrackFailure(gen: number, index: number, reason: string | null, code: ResolveErrorCode | null): void {
if (gen !== generation) return
consecutiveFailures++
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
setQueue({
status: 'error',
lastError: reason ?? 'Several tracks in a row could not be loaded — the queue paused to avoid hammering the resolver.',
lastErrorCode: code,
})
toast({
title: 'Queue paused',
description: 'Multiple tracks failed in a row. Press Retry to try again from this track.',
variant: 'destructive',
})
return
}
toast({ title: 'Skipping to the next track', description: 'The failed one stays in the list — jump back anytime.' })
void playIndex(gen, index + 1)
}
/** Played through the last entry: park the queue with a Replay button. */
function finishQueue(gen: number): void {
if (gen !== generation) return
clearPreloadTimer()
preload = null
const engine = getEngine()
engine.audio.onTrackEnded = null
engine.audio.stop()
syncIdleSource()
setQueue({ status: 'done', preloading: false, lastError: null })
}
/** Engine callback: the current track played to its very end. */
function handleEnded(): void {
const gen = generation
const state = useQueueStore.getState()
if (state.status !== 'playing') return
void playIndex(gen, state.index + 1)
}
// ---- public API -------------------------------------------------------------
/**
* Start (or restart) the queue from a resolved playlist. `startIndex` lets
* the visitor begin anywhere in the list — the rest still queues up behind.
*/
export function startQueue(playlist: ResolvedPlaylist, startIndex = 0): void {
stopQueueInternal()
const clamped = Math.min(Math.max(0, Math.floor(startIndex)), Math.max(0, playlist.entries.length - 1))
generation++
const gen = generation
consecutiveFailures = 0
setQueue({
...IDLE_STATE,
status: 'loading',
title: playlist.title,
source: playlist.source,
entries: playlist.entries,
index: clamped,
})
// engine hooks: advance on 'ended', retire (fully — bar included) when
// another source starts
const engine = getEngine()
engine.audio.onTrackEnded = handleEnded
setSourceRetireListener(() => stopQueue())
void playIndex(gen, clamped)
}
/** Full reset: stop playback, drop the queue, hide the bar. */
export function stopQueue(): void {
stopQueueInternal()
setQueue(IDLE_STATE)
}
function stopQueueInternal(): void {
generation++
clearPreloadTimer()
preload = null
consecutiveFailures = 0
const engine = getEngine()
engine.audio.onTrackEnded = null
// only stop the engine when it is still streaming OUR track — a demo/mic/
// file source that replaced the queue must keep playing
if (engine.audio.kind === 'link') {
engine.audio.stop()
syncIdleSource()
}
setSourceRetireListener(null)
}
/** Manual skip forward (button). */
export function queueNext(): void {
const state = useQueueStore.getState()
if (state.status === 'idle' || state.entries.length === 0) return
if (state.index >= state.entries.length - 1) {
finishQueue(generation)
return
}
generation++
void playIndex(generation, state.index + 1)
}
/** Manual skip back (button). */
export function queuePrev(): void {
const state = useQueueStore.getState()
if (state.status === 'idle' || state.entries.length === 0) return
generation++
void playIndex(generation, Math.max(0, state.index - 1))
}
/** Jump to any entry (click in the track list / Resume / Retry). */
export function queueJump(index: number): void {
const state = useQueueStore.getState()
if (state.status === 'idle' || state.entries.length === 0) return
const clamped = Math.min(Math.max(0, Math.floor(index)), state.entries.length - 1)
generation++
void playIndex(generation, clamped)
}
/** Play the whole queue again after it finished. */
export function replayQueue(): void {
const state = useQueueStore.getState()
if (state.status === 'idle' || state.entries.length === 0) return
startQueue(
{ ok: true, kind: 'playlist', title: state.title, source: state.source, entries: state.entries },
0,
)
}

View file

@ -81,7 +81,7 @@ export function parseScript(text: string): AvaScript {
/** Never throws — returns the error (first only) instead. */
export function validateScript(text: string): { ok: boolean; script: AvaScript | null; error: ScriptError | null; meta: ScriptMeta | null } {
const result = tryParse(text)
return { ok: !result.error, script: result.script, error: result.error, meta: result.script?.meta ?? result.meta }
return { ok: !result.error, script: result.script, error: result.error, meta: result.script?.meta ?? result.meta ?? null }
}
function tryParse(text: string): { script: AvaScript | null; meta?: ScriptMeta; error: ScriptError | null } {
@ -140,10 +140,17 @@ function tryParse(text: string): { script: AvaScript | null; meta?: ScriptMeta;
if (!HEADER_KEYS.has(key)) {
return { script: null, meta: { ...meta }, error: { message: `Unknown header key '${key}' — valid keys: name, author, description, mode, blend, persist, linewidth`, section: 'header', line: lineNo } }
}
// v7.2: string headers may be written quoted (name: "Bass Bloom") —
// strip one layer of matching surrounding quotes so the footer reads
// SCRIPT: Bass Bloom, not SCRIPT: "Bass Bloom".
const str = (): string => {
const q = value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")))
return q ? value.slice(1, -1).trim() : value
}
switch (key) {
case 'name': meta.name = value || 'Untitled'; break
case 'author': meta.author = value; break
case 'description': meta.description = value; break
case 'name': meta.name = str() || 'Untitled'; break
case 'author': meta.author = str(); break
case 'description': meta.description = str(); break
case 'mode':
if (!MODES.has(value)) {
return { script: null, meta: { ...meta }, error: { message: `mode must be one of: lines, dots, solid (got '${value}')`, section: 'header', line: lineNo } }

View file

@ -19,17 +19,23 @@ export interface BandConfig {
/** Beat detector / predictor configuration. */
export interface BeatConfig {
/** Energy multiplier over the rolling average required to fire a beat. */
/** Periodicity requirement for the tempo lock: the autocorrelation peak
* of the onset envelope must reach 0.1 + 0.125 × threshold (default 1.2
* → r ≥ 0.25; noise reads ≈ ±0.07). Higher = stricter. */
threshold: number
/** Minimum milliseconds between beats. */
minInterval: number
/** Absolute floor for the smoothed bass level. */
/** Absolute floor for the onset (spectral-flux) envelope — frames whose
* rolling mean falls below this are treated as silence (no estimation,
* no beats). Flux-domain: mastered music reads ~0.01-0.03, silence
* < 0.001. (v7.1: was the smoothed-bass floor 0.04 — a different unit
* entirely; stored visitor configs are migrated on load.) */
floorLevel: number
/** Keep firing predicted beats when the bass drops out. */
/** Keep firing predicted beats when the onsets drop out. */
prediction: boolean
}
/** Instant + smoothed band levels (0..1+). */
/** Instant + smoothed band levels (0..1+) + the onset envelope. */
export interface BandLevels {
bass: number
mid: number
@ -39,6 +45,13 @@ export interface BandLevels {
trebAtt: number
vol: number
volAtt: number
/** Full-band spectral-flux onset envelope (0..~0.3): the mean positive
* frame-to-frame change across the FFT bins up to 16 kHz. Kicks, snares
* and hats are ATTACKS — this is the beat detector's input (v7.1).
* Sustained sub-bass reads ≈ 0 here, which is exactly why it replaced
* the level-based detector: mastered club tracks keep the bass band
* saturated and a level threshold can never see the kick through it. */
flux: number
}
export type BeatSource = 'audio' | 'predicted' | 'tap' | 'none'
@ -68,9 +81,18 @@ export const DEFAULT_BAND_CONFIG: BandConfig = {
}
export const DEFAULT_BEAT_CONFIG: BeatConfig = {
threshold: 1.4,
// v7.1: the detector is now autocorrelation-on-onsets (see beat.ts).
// threshold 1.2 maps to a required autocorrelation coefficient of
// 0.1 + 0.125 × 1.2 = 0.25 — well above the ±0.07 noise band, reachable
// by any music with a real pulse. (The 6.x level-threshold semantics and
// its 1.4→1.2 history are obsolete: a level threshold fundamentally
// cannot see kicks through mastered club sub-bass — the peak-to-mean caps
// at ~1.2×, which fired sporadically and locked phantom tempi.)
threshold: 1.2,
minInterval: 250,
floorLevel: 0.04,
// flux-domain silence floor (old level-domain default was 0.04 — see
// BeatConfig.floorLevel; visitor-stored values are migrated in store.ts)
floorLevel: 0.0015,
prediction: true,
}
@ -83,4 +105,5 @@ export const ZERO_LEVELS: BandLevels = {
trebAtt: 0,
vol: 0,
volAtt: 0,
flux: 0,
}

View file

@ -46,7 +46,7 @@ interface UserStorePayload {
}
export type UserResult<T = undefined> =
| ({ ok: true } & (T extends undefined ? Record<string, never> : T))
| (T extends undefined ? { ok: true } : { ok: true } & T)
| { ok: false; error: string }
// ---------------------------------------------------------------------------
@ -300,9 +300,11 @@ export function renameUserFolder(
rawName: unknown,
): UserResult<{ to: string; presets: number; folders: number }> {
const path = validUserFolder(rawPath)
const name = isValidUserName(rawName, 80)
const name = isValidUserName(rawName)
if (!path) return { ok: false, error: 'Folders can only be renamed inside "user created".' }
if (!name) return { ok: false, error: 'Invalid folder name (no slashes, max 80 characters).' }
if (!name || name.length > 80) {
return { ok: false, error: 'Invalid folder name (no slashes, max 80 characters).' }
}
if (path === USER_ROOT) {
return { ok: false, error: '"user created" is the root of your library and cannot be renamed.' }
}

354
src/lib/music-services.ts Executable file
View file

@ -0,0 +1,354 @@
// OVE — music service metadata (v6.6 / v6.7 / v6.8.1), shared by client + server.
//
// The Settings tab lets a visitor log in to their own music services by
// pasting that service's login cookie(s). The blob is encrypted in the
// browser (src/lib/music-sessions.ts) and is only ever sent to this site's
// own server transiently — to resolve links the visitor plays — never
// stored there.
//
// v6.7 adds `streamStatus` + `comingSoonNote`: the music-service framework
// marks which services can be STREAMED today (yt-dlp) and which are
// registered ahead of a later update (Spotify / Apple Music — DRM-locked).
// The server-side provider registry lives in src/lib/server/music-providers.ts.
//
// v6.8.1 adds the cookie-paste toolkit (bottom of this file): one shared
// parser for the three formats the login box accepts (raw Cookie header,
// Netscape cookies.txt, Cookie-Editor JSON), live validation for the dialog
// (catches the "pasted a single cookie's value" mistake — e.g. LOGIN_INFO
// alone cannot authenticate) and the netscape/header staging the resolve
// route uses. One parser for both sides means validation and staging can
// never drift apart.
//
// This module is pure data + pure functions (no DOM, no node APIs) so both
// the client components and the /api/resolve route can import it.
export type ServiceId = 'youtube' | 'soundcloud' | 'spotify' | 'apple'
/**
* Whether links of a service can be streamed today. 'live' = resolvable
* through yt-dlp and playable; 'coming-soon' = the framework is registered
* (logins are already saved) and playback ships in a later update.
*/
export type StreamStatus = 'live' | 'coming-soon'
export interface ServiceDef {
id: ServiceId
/** Display name shown in the login dialog + settings chips. */
name: string
/** Two-letter monogram for the chip (brand colors below). */
monogram: string
/** Static brand color for the monogram dot (theme-independent). */
brandColor: string
/** Hostnames (and their subdomains) whose links this login unlocks. */
domains: string[]
/** Domain the cookie header should be scoped to (Netscape format). */
cookieDomain: string
/** Short blurb shown in the login dialog. */
blurb: string
/** Cookie acquisition instructions (numbered steps, rendered as a list). */
steps: string[]
/** Cookie names a WORKING login paste must carry (paste validation hints —
* a paste missing these gets an honest warning in the login dialog).
* Undefined when there is no reliable signal for the service. */
criticalCookies?: string[]
/** Honest limitation note, if any (DRM…). */
note?: string
/** Can links of this service be streamed today? 'live' = resolvable+playable
* through yt-dlp; 'coming-soon' = framework registered, playback ships in a
* later update. */
streamStatus: StreamStatus
/** Required when streamStatus === 'coming-soon': the note shown when a
* visitor tries to play a link of this service. */
comingSoonNote?: string
}
export const SERVICES: ServiceDef[] = [
{
id: 'youtube',
name: 'YouTube / YouTube Music',
monogram: 'YT',
brandColor: '#ff0033',
domains: ['youtube.com', 'youtu.be', 'music.youtube.com'],
cookieDomain: '.youtube.com',
blurb:
'One Google login covers both YouTube and YouTube Music — playlists, liked music and uploads, and no more "confirm you are not a bot" walls.',
steps: cookieSteps(
'youtube.com (or music.youtube.com)',
'Paste the whole thing below — a working YouTube login is the FULL set of ~20 cookies (SID, SAPISID, __Secure-3PSID, LOGIN_INFO, …), never a single cookie on its own.',
),
criticalCookies: ['SID', 'HSID', 'SSID', 'SAPISID', '__Secure-3PSID', 'LOGIN_INFO'],
streamStatus: 'live',
},
{
id: 'soundcloud',
name: 'SoundCloud',
monogram: 'SC',
brandColor: '#ff5500',
domains: ['soundcloud.com', 'snd.sc'],
cookieDomain: '.soundcloud.com',
blurb:
'Access your private playlists, likes and sets from the Link button on this machine.',
steps: cookieSteps(
'soundcloud.com',
'Paste the whole thing below — the cookie that carries your login is oauth_token (plus a few others).',
),
criticalCookies: ['oauth_token'],
streamStatus: 'live',
},
{
id: 'spotify',
name: 'Spotify',
monogram: 'SP',
brandColor: '#1db954',
domains: ['spotify.com', 'open.spotify.com'],
cookieDomain: '.spotify.com',
blurb: 'Store your Spotify login so playlist links resolve with your account.',
note: 'Spotify streams are DRM-protected, so playback of Spotify links is not possible yet — your login is saved for when support arrives.',
steps: cookieSteps(
'open.spotify.com',
'Paste the whole thing below — the cookie that carries your login is sp_dc.',
),
criticalCookies: ['sp_dc'],
streamStatus: 'coming-soon',
comingSoonNote:
'Spotify playback is coming in a later update — your saved Spotify login will be used automatically the moment it ships. Meanwhile, YouTube, SoundCloud and direct-file links work today.',
},
{
id: 'apple',
name: 'Apple Music',
monogram: 'AM',
brandColor: '#fb5c74',
domains: ['music.apple.com', 'apple.com'],
cookieDomain: '.apple.com',
blurb: 'Store your Apple Music login so library links resolve with your account.',
note: 'Apple Music streams are DRM-protected, so playback of Apple Music links is not possible yet — your login is saved for when support arrives.',
steps: cookieSteps('music.apple.com', 'Paste the whole thing below.'),
streamStatus: 'coming-soon',
comingSoonNote:
'Apple Music playback is coming in a later update — your saved Apple Music login will be used automatically the moment it ships. Meanwhile, YouTube, SoundCloud and direct-file links work today.',
},
]
/**
* Shared "how to copy your login" steps — two acquisition paths in order of
* ease: (1) a one-click cookies.txt extension export, (2) DevTools → Network
* → the "cookie:" request-header row. The last step is per-service and says
* what a WORKING paste looks like (the full set, never a single cookie).
*/
function cookieSteps(host: string, lastStep: string): string[] {
return [
`Open ${host} in this browser and make sure you are logged in.`,
'Easiest (one click): install a cookie exporter — “Get cookies.txt LOCALLY” on Chrome/Edge/Brave or “cookies.txt” on Firefox. Open that page, click the extension, choose Export — then drag the downloaded file straight into the box below (no need to open or copy anything).',
'No extension? Press F12 → Network tab → reload the page → click the first request in the list (the page itself). The list stays empty until you reload with the tab open.',
'Scroll down to the “Request Headers” section → find the “cookie:” row → right-click its value → Copy value. (In Firefox this lives on the request’s Headers tab too.)',
lastStep,
]
}
const SERVICE_IDS = new Set<string>(SERVICES.map((s) => s.id))
export function isServiceId(v: unknown): v is ServiceId {
return typeof v === 'string' && SERVICE_IDS.has(v)
}
export function serviceDef(id: ServiceId): ServiceDef {
const def = SERVICES.find((s) => s.id === id)
if (!def) throw new Error(`Unknown service: ${id}`)
return def
}
/**
* Match a URL against the service domains (host equals or is a subdomain).
* Returns the service a link belongs to, or null for foreign links.
*/
export function serviceForUrl(url: string): ServiceId | null {
let host: string
try {
host = new URL(url).hostname.toLowerCase()
} catch {
return null
}
if (host.startsWith('www.')) host = host.slice(4)
for (const s of SERVICES) {
for (const d of s.domains) {
const dd = d.startsWith('www.') ? d.slice(4) : d
if (host === dd || host.endsWith(`.${dd}`)) return s.id
}
}
return null
}
// ---- cookie paste toolkit (v6.8.1) ------------------------------------------
//
// The login box accepts three paste formats, and BOTH the login dialog's
// live validation and the /api/resolve staging funnel through this one
// parser so they can never disagree about what a paste contains:
// • raw Cookie header "NAME=VALUE; NAME2=VALUE2; …" (DevTools → Network)
// • Netscape cookies.txt export (tab-separated 7-field lines, from
// exporter extensions like "Get cookies.txt LOCALLY" / "cookies.txt")
// • Cookie-Editor JSON export [{"name":"SID","value":"…","domain":"…"}, …]
export interface ParsedCookie {
name: string
value: string
/** Domain as written in the paste (netscape / json exports). Absent on
* raw header pastes — those are implicitly scoped to the service. */
domain?: string
}
export type CookiePasteFormat = 'header' | 'netscape' | 'json' | 'unknown'
/** Strip tabs/newlines so a pasted token can never corrupt a TSV line. */
function cookieToken(s: string): string {
return s.replace(/[\t\r\n]+/g, ' ').trim()
}
/** Parse a paste into its cookies, detecting the format. Never throws. */
export function parseCookiePaste(
text: string,
): { format: CookiePasteFormat; cookies: ParsedCookie[] } {
const trimmed = text.trim()
if (trimmed.length === 0) return { format: 'unknown', cookies: [] }
// Cookie-Editor style JSON export.
if (trimmed.startsWith('[')) {
try {
const arr: unknown = JSON.parse(trimmed)
if (Array.isArray(arr)) {
const cookies: ParsedCookie[] = []
for (const e of arr) {
if (e === null || typeof e !== 'object') continue
const o = e as Record<string, unknown>
if (typeof o.name !== 'string' || typeof o.value !== 'string') continue
const name = cookieToken(o.name)
const value = cookieToken(o.value)
if (!name || !value) continue
const domain = typeof o.domain === 'string' ? cookieToken(o.domain) : ''
cookies.push(domain ? { name, value, domain } : { name, value })
}
if (cookies.length > 0) return { format: 'json', cookies }
}
} catch {
/* not JSON — fall through to the other formats */
}
}
// Netscape cookies.txt — tab-separated 7-field lines.
const tsvLines = trimmed
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('#') && l.split('\t').length >= 7)
if (tsvLines.length > 0) {
const cookies: ParsedCookie[] = []
for (const line of tsvLines) {
const f = line.split('\t')
const [d, , , , , name, value] = f
const n = cookieToken(name)
const v = cookieToken(value)
if (!n || !v) continue
cookies.push({ name: n, value: v, domain: cookieToken(d) || undefined })
}
if (cookies.length > 0) return { format: 'netscape', cookies }
}
// Raw Cookie header: "k=v; k2=v2" (possibly wrapped over several lines).
// Values keep inner '=' (base64 padding); the FIRST '=' splits the pair.
const cookies: ParsedCookie[] = []
for (const raw of trimmed.split(/[;\n]/)) {
const s = raw.trim()
if (!s) continue
const eq = s.indexOf('=')
if (eq <= 0) continue
const name = cookieToken(s.slice(0, eq))
const value = cookieToken(s.slice(eq + 1))
if (name && value) cookies.push({ name, value })
}
if (cookies.length > 0) return { format: 'header', cookies }
return { format: 'unknown', cookies: [] }
}
export interface CookiePasteValidation {
format: CookiePasteFormat
/** cookies recognized in the paste */
count: number
/** critical login cookies (ServiceDef.criticalCookies) missing from it */
missingCritical: string[]
/** the paste parses AND carries the service's login cookies */
looksGood: boolean
}
/**
* Live-check a paste for the login dialog. A paste that is a single
* cookie's VALUE (no NAME= pairs at all — e.g. a bare LOGIN_INFO string
* copied from the DevTools Storage tab) parses as 'unknown'; a paste with
* pairs but without the service's auth cookies lists them as missing.
* Either way the dialog shows an honest "this will not log you in" hint.
*/
export function validateCookiePaste(service: ServiceId, text: string): CookiePasteValidation {
const { format, cookies } = parseCookiePaste(text)
const critical = serviceDef(service).criticalCookies ?? []
const names = new Set(cookies.map((c) => c.name))
const missingCritical = critical.filter((n) => !names.has(n))
const looksGood =
format !== 'unknown' &&
(critical.length > 0 ? missingCritical.length === 0 : cookies.length >= 4)
return { format, count: cookies.length, missingCritical, looksGood }
}
/**
* Normalize a paste into a Netscape cookies.txt body (for the transient
* 0600 temp file yt-dlp reads via --cookies) + a "name=value; …" header
* (forwarded to the stream proxy, service-domain cookies only). Returns
* null when nothing parseable was pasted.
*
* • raw header pastes are scoped to the service's cookie domain;
* • JSON entries keep their own domain when it belongs to the service
* (foreign-site entries are dropped — an exporter grab from the wrong
* site must never leak into the jar);
* • netscape exports pass through with all their domains + real expiries.
*/
export function cookiePasteToNetscape(
text: string,
service: ServiceId,
): { netscape: string; header: string } | null {
const { format, cookies } = parseCookiePaste(text)
if (cookies.length === 0) return null
const domain = serviceDef(service).cookieDomain
const root = domain.replace(/^\./, '').toLowerCase()
const inService = (d: string | undefined): boolean => {
if (!d) return true // header pastes / JSON entries without a domain
const dd = d.replace(/^\./, '').toLowerCase()
return dd === root || dd.endsWith(`.${root}`)
}
if (format === 'netscape') {
const body = text
.trim()
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l.length > 0 && !l.startsWith('#') && l.split('\t').length >= 7)
const header = cookies
.filter((c) => inService(c.domain))
.map((c) => `${c.name}=${c.value}`)
return {
netscape: `# Netscape HTTP Cookie File\n${body.join('\n')}\n`,
header: header.join('; '),
}
}
const expiry = '2147483647'
const lines: string[] = []
const headerPairs: string[] = []
for (const c of cookies) {
if (format === 'json' && !inService(c.domain)) continue
const secure =
c.name.startsWith('__Secure-') || c.name.startsWith('__Host-') ? 'TRUE' : 'FALSE'
lines.push(`${domain}\tTRUE\t/\t${secure}\t${expiry}\t${c.name}\t${c.value}`)
headerPairs.push(`${c.name}=${c.value}`)
}
if (lines.length === 0) return null
return {
netscape: `# Netscape HTTP Cookie File\n${lines.join('\n')}\n`,
header: headerPairs.join('; '),
}
}

281
src/lib/music-sessions.ts Executable file
View file

@ -0,0 +1,281 @@
// OVE — encrypted per-visitor music service logins (v6.6).
//
// A visitor can log in to their own music services (YouTube, SoundCloud,
// Spotify, Apple Music) by pasting that service's login cookie. Storage
// model — everything stays on the visitor's computer:
//
// • the cookie text is encrypted with AES-GCM (Web Crypto) using a
// 256-bit key that is generated once, is marked NON-extractable and
// lives in IndexedDB ("ove-secure") — so no script, not even this one,
// can read the key material back out of the browser's key store;
// • only the ciphertext (base64 iv + base64 data) is written to
// localStorage ("ove.musicSessions.v1");
// • the plaintext cookie exists in memory only: while saving, and for a
// moment when a link the visitor plays is resolved (it is sent to this
// site's own server as part of that resolve request — the server uses
// it transiently for yt-dlp and never stores it).
//
// If the IndexedDB key is ever missing (cleared) while ciphertext remains,
// the sessions are unreadable and get dropped — logging in again is the
// only recovery, which is exactly what "remove credentials" means here.
'use client'
import { isServiceId, serviceForUrl, type ServiceId } from '@/lib/music-services'
const LS_KEY = 'ove.musicSessions.v1'
const DB_NAME = 'ove-secure'
const DB_VERSION = 1
const KEY_STORE = 'keys'
const KEY_ID = 'music-cookies'
export const MAX_COOKIE_LENGTH = 65_536
export interface SessionSummary {
/** services with a readable (decryptable) saved login */
loggedIn: Partial<Record<ServiceId, { savedAt: number }>>
/** storage/persistence is unavailable in this browser context */
unavailable?: string
}
export interface CookieAuth {
service: ServiceId
cookies: string
}
interface StoredSession {
iv: string
ct: string
savedAt: number
}
interface SessionsFile {
v: 1
sessions: Partial<Record<ServiceId, StoredSession>>
}
// ---- availability -----------------------------------------------------------
function cryptoAvailable(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.crypto?.subtle?.encrypt === 'function' &&
typeof indexedDB !== 'undefined'
)
}
// ---- IndexedDB key store (non-extractable AES-GCM key) ----------------------
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
if (!req.result.objectStoreNames.contains(KEY_STORE)) {
req.result.createObjectStore(KEY_STORE, { keyPath: 'id' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error ?? new Error('IndexedDB unavailable'))
req.onblocked = () => reject(new Error('IndexedDB blocked by another tab'))
})
}
async function getOrCreateKey(): Promise<CryptoKey> {
const db = await openDb()
try {
const read = (): Promise<{ key?: unknown } | undefined> =>
new Promise((resolve, reject) => {
const tx = db.transaction(KEY_STORE, 'readonly')
const req = tx.objectStore(KEY_STORE).get(KEY_ID)
req.onsuccess = () => resolve(req.result)
req.onerror = () => reject(req.error)
})
const found = await read()
if (found && found.key instanceof CryptoKey) return found.key
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
false, // non-extractable — the raw key never leaves the browser key store
['encrypt', 'decrypt'],
)
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(KEY_STORE, 'readwrite')
tx.objectStore(KEY_STORE).put({ id: KEY_ID, key })
tx.oncomplete = () => resolve()
tx.onabort = () => reject(tx.error ?? new Error('Key store write failed'))
tx.onerror = () => reject(tx.error)
})
return key
} finally {
db.close()
}
}
// ---- localStorage session file ----------------------------------------------
function readFile(): SessionsFile {
try {
const raw = localStorage.getItem(LS_KEY)
if (!raw) return { v: 1, sessions: {} }
const parsed = JSON.parse(raw) as SessionsFile
if (parsed === null || typeof parsed !== 'object' || parsed.v !== 1) {
return { v: 1, sessions: {} }
}
const sessions: SessionsFile['sessions'] = {}
for (const [id, s] of Object.entries(parsed.sessions ?? {})) {
if (
isServiceId(id) &&
s !== null &&
typeof s === 'object' &&
typeof s.iv === 'string' &&
typeof s.ct === 'string' &&
typeof s.savedAt === 'number'
) {
sessions[id] = s
}
}
return { v: 1, sessions }
} catch {
return { v: 1, sessions: {} }
}
}
function writeFile(file: SessionsFile): void {
localStorage.setItem(LS_KEY, JSON.stringify(file))
}
// ---- encrypt / decrypt -------------------------------------------------------
function toB64(buf: ArrayBuffer): string {
const bytes = new Uint8Array(buf)
let s = ''
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
return btoa(s)
}
function fromB64(s: string): Uint8Array<ArrayBuffer> {
const bin = atob(s)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
async function encryptText(key: CryptoKey, text: string): Promise<StoredSession> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const ct = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(text),
)
return { iv: toB64(iv.buffer), ct: toB64(ct), savedAt: Date.now() }
}
async function decryptText(key: CryptoKey, session: StoredSession): Promise<string | null> {
try {
const plain = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: fromB64(session.iv) },
key,
fromB64(session.ct),
)
return new TextDecoder().decode(plain)
} catch {
return null // wrong key or tampered ciphertext
}
}
// ---- public API --------------------------------------------------------------
/** Save (or replace) the login cookie for one service. */
export async function saveMusicSession(
service: ServiceId,
cookieText: string,
): Promise<{ ok: true } | { ok: false; error: string }> {
const text = cookieText.trim()
if (text.length < 8) {
return { ok: false, error: 'That looks too short to be a cookie — paste the whole Cookie header value or a cookies.txt export.' }
}
if (text.length > MAX_COOKIE_LENGTH) {
return { ok: false, error: 'That cookie data is too large (over 64KB).' }
}
if (!cryptoAvailable()) {
return { ok: false, error: 'This browser context is not secure (needs https) — logins cannot be stored here.' }
}
try {
const key = await getOrCreateKey()
const file = readFile()
file.sessions[service] = await encryptText(key, text)
writeFile(file)
return { ok: true }
} catch {
return { ok: false, error: 'Could not store the login in this browser (storage or crypto unavailable).' }
}
}
/** Which services have a readable saved login. */
export async function listMusicSessions(): Promise<SessionSummary> {
if (!cryptoAvailable()) {
return { loggedIn: {}, unavailable: 'secure-context' }
}
try {
const key = await getOrCreateKey()
const file = readFile()
const loggedIn: SessionSummary['loggedIn'] = {}
let dropped = false
for (const [id, session] of Object.entries(file.sessions)) {
if (!isServiceId(id)) continue
const plain = await decryptText(key, session)
if (plain === null || plain.trim().length === 0) {
delete file.sessions[id]
dropped = true
} else {
loggedIn[id] = { savedAt: session.savedAt }
}
}
if (dropped) writeFile(file)
return { loggedIn }
} catch {
return { loggedIn: {}, unavailable: 'storage-error' }
}
}
/** Remove every saved login (the "Log out of all" button). */
export async function clearAllMusicSessions(): Promise<void> {
try {
localStorage.removeItem(LS_KEY)
} catch {
/* nothing to remove */
}
}
/** Remove the saved login for one service only (per-service "Log out"). */
export async function removeMusicSession(service: ServiceId): Promise<void> {
try {
const file = readFile()
if (file.sessions[service]) {
delete file.sessions[service]
writeFile(file)
}
} catch {
/* nothing to remove */
}
}
/**
* Look up the saved login matching a link the visitor is about to play.
* Returns the decrypted cookie text + service id, or null when the link's
* service has no login (or the link belongs to no known service).
*/
export async function getCookieForUrl(url: string): Promise<CookieAuth | null> {
const service = serviceForUrl(url)
if (service === null) return null
if (!cryptoAvailable()) return null
try {
const key = await getOrCreateKey()
const session = readFile().sessions[service]
if (!session) return null
const plain = await decryptText(key, session)
if (plain === null || plain.trim().length === 0) return null
return { service, cookies: plain }
} catch {
return null
}
}

View file

@ -0,0 +1,111 @@
import { mkdtemp, readdir, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
/**
* OVE — server-side download workspace for the MP3 resolve pipeline (v7.5).
*
* /api/resolve no longer proxies extractor stream URLs at the browser — those
* are IP-locked (googlevideo bakes the resolver's IP into the URL), expire in
* hours, and arrive in containers some browsers refuse outright (webm/opus was
* the default "bestaudio" pick — Safari answers "media resource … not
* suitable" and dies). Instead each track is now DOWNLOADED and converted to a
* plain MP3 on the server (yt-dlp -f bestaudio/best -x --audio-format mp3
* --audio-quality 0), and the browser streams the finished file same-origin.
* Every universal browser plays a well-formed MP3 — that is the whole point.
*
* Each download runs inside its OWN mkdtemp dir (prefix ove-audio-). The
* converted MP3 lives in that dir until the stream entry that owns it is
* pruned (30 min idle TTL, sliding while listened to) — then the whole dir is
* removed. Two safety nets keep the workspace from ever growing unbounded:
*
* 1. stream-store's prune() removes the dir the moment its entry lapses;
* 2. sweepOrphanDownloads() (throttled, called from the same prune) removes
* ove-audio-* dirs older than ORPHAN_MAX_AGE_MS — crash leftovers from a
* killed dev server can never register a prune, so age is the backstop.
*/
/** Download dirs older than this are crash leftovers — swept away. */
const ORPHAN_MAX_AGE_MS = 60 * 60 * 1000
/** Minimum gap between orphan sweeps (prune() calls us on every access). */
const SWEEP_INTERVAL_MS = 10 * 60 * 1000
let lastSweepAt = 0
let sweeping = false
/** Create the private workspace dir for ONE track download. */
export function createDownloadDir(): Promise<string> {
return mkdtemp(join(tmpdir(), 'ove-audio-'))
}
/**
* Find the converted MP3 inside a finished download dir. yt-dlp's ExtractAudio
* post-processor always emits exactly one .mp3 and deletes the intermediate,
* so this is a "trust but verify" scan — null means the download did not
* produce a playable file (treated as an extraction failure by the caller).
*/
export async function findConvertedMp3(
dir: string,
): Promise<{ path: string; bytes: number } | null> {
let names: string[]
try {
names = await readdir(dir)
} catch {
return null
}
for (const name of names) {
if (!name.toLowerCase().endsWith('.mp3')) continue
const path = join(dir, name)
try {
const s = await stat(path)
if (s.isFile() && s.size > 0) return { path, bytes: s.size }
} catch {
// raced with cleanup — keep looking
}
}
return null
}
/** Remove a download dir (fire-and-forget; never throws). */
export function removeDownloadDir(dir: string): void {
void rm(dir, { recursive: true, force: true }).catch(() => {})
}
/**
* Best-effort sweep of orphaned download dirs. Throttled to one run per
* SWEEP_INTERVAL_MS; concurrent calls (prune can fire from any request) share
* the in-flight pass. Never throws — a failed sweep just tries again later.
*/
export function sweepOrphanDownloads(): void {
const now = Date.now()
if (sweeping || now - lastSweepAt < SWEEP_INTERVAL_MS) return
sweeping = true
lastSweepAt = now
void (async () => {
try {
const parent = tmpdir()
let names: string[] = []
try {
names = await readdir(parent)
} catch {
return
}
for (const name of names) {
if (!name.startsWith('ove-audio-')) continue
const dir = join(parent, name)
try {
const s = await stat(dir)
if (s.isDirectory() && now - s.mtimeMs > ORPHAN_MAX_AGE_MS) {
await rm(dir, { recursive: true, force: true })
}
} catch {
// gone / unreadable — nothing to do
}
}
} catch {
// never propagate — the sweeper is a janitor, not a feature
} finally {
sweeping = false
}
})()
}

94
src/lib/server/house-cookies.ts Executable file
View file

@ -0,0 +1,94 @@
// OVE — server-side "house account" cookies (v6.9).
//
// The site's operator can drop a login cookie export for a service into
// secrets/house-<service>-cookies.txt (Netscape cookies.txt, a raw Cookie
// header, or Cookie-Editor JSON — anything the shared paste parser in
// lib/music-services.ts understands). When a visitor resolves a link of
// that service WITHOUT providing their own login, the resolve route falls
// back to this house account — anonymous visitors effectively share the
// operator's account, so YouTube's "confirm you're not a bot" walls stop
// applying to them.
//
// Rules of engagement:
// • the file lives under secrets/ — Next.js never serves that folder
// over HTTP, and no route reads it except this module;
// • its CONTENTS are never logged, never returned by any API — the
// status endpoint only exposes booleans (houseCookieStatus);
// • reads are mtime-cached on globalThis (dev-reload safe), so
// replacing the file swaps the account live without a restart;
// • a house cookie is only the FALLBACK — a visitor's own saved login
// always wins (their playlists, their likes, their rate limits).
import { readFileSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { SERVICES, type ServiceId } from '@/lib/music-services'
/** Hard cap — real exports are ~8–50 KB; anything bigger is not a cookie
* export (and must never be staged for yt-dlp). */
const MAX_HOUSE_COOKIE_BYTES = 262_144
/** Where the house cookie for a service lives (secrets/ is not served). */
export function houseCookiePath(service: ServiceId): string {
return join(process.cwd(), 'secrets', `house-${service}-cookies.txt`)
}
interface HouseCacheEntry {
mtimeMs: number
size: number
/** raw file text (any paste format — normalized later by the shared
* parser); null = read failed / oversized → treated as absent */
text: string | null
}
interface HouseGlobal {
__oveHouseCookieCache?: Partial<Record<ServiceId, HouseCacheEntry>>
}
function houseCache(): Partial<Record<ServiceId, HouseCacheEntry>> {
const g = globalThis as HouseGlobal
g.__oveHouseCookieCache ??= {}
return g.__oveHouseCookieCache
}
/**
* The house cookie text for a service, or null when there is none (or it
* is unreadable / oversized). Cached per mtime+size — replacing the file
* takes effect on the very next resolve, no restart needed.
*/
export function houseCookieText(service: ServiceId): string | null {
const path = houseCookiePath(service)
let mtimeMs = 0
let size = 0
try {
const st = statSync(path)
if (!st.isFile()) return null
mtimeMs = st.mtimeMs
size = st.size
} catch {
return null
}
if (size <= 0 || size > MAX_HOUSE_COOKIE_BYTES) return null
const cached = houseCache()[service]
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) return cached.text
let text: string | null = null
try {
const raw = readFileSync(path, 'utf8')
text = raw.trim().length > 0 ? raw : null
} catch {
text = null
}
houseCache()[service] = { mtimeMs, size, text }
return text
}
/**
* Which services currently have a house account configured. Presence
* booleans ONLY — never any cookie contents.
*/
export function houseCookieStatus(): Record<ServiceId, boolean> {
const status = {} as Record<ServiceId, boolean>
for (const s of SERVICES) status[s.id] = houseCookieText(s.id) !== null
return status
}

158
src/lib/server/music-providers.ts Executable file
View file

@ -0,0 +1,158 @@
// OVE — server-side music provider registry (v6.7): the music-service
// FRAMEWORK skeleton behind /api/resolve.
//
// yt-dlp covers every open site (YouTube, SoundCloud, Bandcamp, direct
// files…). The services it can NEVER stream are DRM-locked walled gardens —
// Spotify and Apple Music. Those are registered here as 'coming-soon'
// adapters so visitors get an honest "playback ships in a later update"
// answer (and can already save their login for that day), instead of a
// confusing extractor error. /api/resolve consults providerForUrl() BEFORE
// any cache/direct/yt-dlp work, so coming-soon URLs never spawn a process.
//
// ─────────────────────────────────────────────────────────────────────────────
// HOW TO SHIP A NEW SERVICE (v6.8+ — for the future dev)
// ─────────────────────────────────────────────────────────────────────────────
// The framework is wired end-to-end; adding real support for a service is a
// small, local change. Say you are shipping Spotify:
//
// 1. IMPLEMENT THE ADAPTER — fill in the two extension points that are
// sketched as comments in ProviderAdapter below:
//
// resolveTrack?(url: string, auth: VisitorAuth | null):
// Promise<TrackResolution>
// → map ONE track URL to a playable same-origin proxy URL:
// build the upstream stream entry with createStreamEntry()
// (src/lib/server/stream-store.ts) and return
// { streamUrl: `/api/audio/stream?token=${token}`, title,
// source, durationSec } — the exact ResolveSuccess shape
// /api/resolve already returns for yt-dlp tracks.
//
// listPlaylist?(url: string, auth: VisitorAuth | null):
// Promise<PlaylistListing>
// → map a playlist/album/set URL to a flat entry list
// { title, source, entries: [{ url, title, durationSec }] } —
// same shape as the yt-dlp playlist branch. The client plays
// entries through its lazy queue (it resolves one track at a
// time, never in bulk).
//
// VisitorAuth = the visitor's stored login for THIS service, sent by
// the client with every /api/resolve call ({ cookies, cookieService }
// in the request body — already parsed by the route). null when the
// visitor is not logged in.
//
// 2. FLIP THE SERVICE LIVE — two one-line status flips:
// • src/lib/music-services.ts: streamStatus 'coming-soon' → 'live'
// (this is what the CLIENT reads — the amber "soon" badge in
// Settings disappears and link hints update automatically), and
// • the adapter's `status` here → 'live'.
//
// 3. DISPATCH — /api/resolve already calls providerForUrl(url) before
// anything else (search "music-service provider dispatch" in
// src/app/api/resolve/route.ts). Once the adapter is 'live' the
// coming-soon interception no longer applies; extend that one spot to
// call the adapter:
//
// if (provider.listPlaylist && looksLikePlaylist) → listPlaylist()
// else if (provider.resolveTrack) → resolveTrack()
//
// and shape the same { ok, … } payloads the route already emits. No
// other plumbing is needed: the client resolves every service through
// /api/resolve, logins are already saved per service (Settings tab →
// Music services), and the UI badge is driven by streamStatus.
//
// Registering a NEW service (not yet in music-services.ts) additionally
// means adding its ServiceDef entry + domains there; providerForUrl then
// picks it up via the SERVICE_PROVIDERS list below.
// ─────────────────────────────────────────────────────────────────────────────
import { serviceDef, type ServiceId } from '@/lib/music-services'
export type ProviderStatus = 'live' | 'coming-soon'
export interface ProviderAdapter {
/** 'generic' for the catch-all yt-dlp adapter, else a ServiceId */
id: ServiceId | 'generic'
label: string
status: ProviderStatus
/** For coming-soon providers: the visitor-facing note (also returned by /api/resolve). */
comingSoonNote: string
/** True when this adapter owns the URL (service-domain match; generic owns everything). */
handlesUrl(url: string): boolean
// ── Future extension points (v6.8+): implement these on a service adapter
// to ship real support; /api/resolve dispatches here before yt-dlp ──
// resolveTrack?(url: string, auth: VisitorAuth | null): Promise<TrackResolution>
// listPlaylist?(url: string, auth: VisitorAuth | null): Promise<PlaylistListing>
}
/**
* Domain matcher scoped to ONE service — same rules as serviceForUrl() in
* music-services.ts (host equals or is a subdomain of any listed domain,
* "www." ignored) but only ever answers for the given service.
*/
function domainMatcherFor(id: ServiceId): (url: string) => boolean {
const domains = serviceDef(id).domains
return (url: string): boolean => {
let host: string
try {
host = new URL(url).hostname.toLowerCase()
} catch {
return false
}
if (host.startsWith('www.')) host = host.slice(4)
for (const d of domains) {
const dd = d.startsWith('www.') ? d.slice(4) : d
if (host === dd || host.endsWith(`.${dd}`)) return true
}
return false
}
}
/** Build a 'coming-soon' adapter from a ServiceDef (logins are already saved
* ahead of support — that IS the framework). */
function comingSoonProvider(id: ServiceId): ProviderAdapter {
const def = serviceDef(id)
return {
id,
label: def.name,
status: 'coming-soon',
comingSoonNote: def.comingSoonNote ?? '',
handlesUrl: domainMatcherFor(id),
}
}
/** Catch-all: every URL no registered service adapter owns goes to yt-dlp. */
export const YTDLP_PROVIDER: ProviderAdapter = {
id: 'generic',
label: 'yt-dlp',
status: 'live',
comingSoonNote: '',
handlesUrl: () => true,
}
/** Spotify — DRM-locked, playback ships in a later update. */
export const SPOTIFY_PROVIDER: ProviderAdapter = comingSoonProvider('spotify')
/** Apple Music — DRM-locked, playback ships in a later update. */
export const APPLE_PROVIDER: ProviderAdapter = comingSoonProvider('apple')
/**
* Service adapters that own their domains, checked in order. Add future
* adapters here (or flip one to 'live' in place) — providerForUrl and the
* /api/resolve dispatch pick them up without any other change.
*/
const SERVICE_PROVIDERS: ProviderAdapter[] = [SPOTIFY_PROVIDER, APPLE_PROVIDER]
/**
* The adapter that owns a URL: a registered service adapter when the host
* belongs to that service's domains, else the generic yt-dlp provider.
* Status-agnostic on purpose — flipping an adapter to 'live' keeps it
* owning its URLs, so the route's dispatch point (see /api/resolve) routes
* it to the adapter's resolveTrack/listPlaylist instead of the
* coming-soon answer.
*/
export function providerForUrl(url: string): ProviderAdapter {
for (const provider of SERVICE_PROVIDERS) {
if (provider.handlesUrl(url)) return provider
}
return YTDLP_PROVIDER
}

View file

@ -1,9 +1,20 @@
import { randomBytes } from 'node:crypto'
import { removeDownloadDir, sweepOrphanDownloads } from '@/lib/server/audio-files'
/**
* In-memory store mapping single-use-capable stream tokens to the resolved
* upstream URL + metadata. Lives for the life of the dev server process —
* kept on `globalThis` so Next.js module reloads in dev never orphan it.
*
* v7.5: entries come in two kinds.
* • proxy entries (remoteUrl) — /api/audio/stream fetches the upstream URL
* same-origin for the browser (direct-file links, and the legacy
* extractor-stream path kept as an ffmpeg-less fallback).
* • file entries (file) — the MP3 pipeline: /api/resolve downloaded +
* converted the track on the server; the entry owns the download dir and
* /api/audio/stream serves the finished MP3 with Range support. When a
* file entry lapses, its dir is removed and the orphan sweeper gets a
* nudge (see lib/server/audio-files.ts).
*/
export interface StreamEntry {
@ -11,6 +22,23 @@ export interface StreamEntry {
title: string
source: string
expires: number
/** Visitor login cookie ("name=value; …") forwarded to the upstream when
* it belongs to the same service domain. In-memory only, lives exactly
* as long as the entry — never written to disk, never logged. */
cookieHeader?: string
/** Present when this entry serves a server-side file, not a proxy fetch. */
file?: StreamFile
}
export interface StreamFile {
/** The download dir that owns the track (removed with the entry). */
dir: string
/** Absolute path of the MP3 inside dir. */
path: string
/** Exact byte size (Content-Length / Range bounds). */
bytes: number
/** Served as-is — always audio/mpeg for the MP3 pipeline. */
contentType: 'audio/mpeg'
}
const STREAM_TTL_MS = 30 * 60 * 1000
@ -30,15 +58,47 @@ function prune(): void {
const map = store()
const now = Date.now()
for (const [token, entry] of map) {
if (entry.expires <= now) map.delete(token)
if (entry.expires <= now) {
map.delete(token)
// v7.5: a lapsed file entry takes its download dir with it.
if (entry.file) {
removeDownloadDir(entry.file.dir)
sweepOrphanDownloads()
}
}
}
}
/** Register a resolved stream; returns the opaque hex token for /api/audio/stream. */
export function createStreamEntry(remoteUrl: string, title: string, source: string): string {
export function createStreamEntry(
remoteUrl: string,
title: string,
source: string,
cookieHeader?: string,
): string {
prune()
const token = randomBytes(16).toString('hex')
store().set(token, { remoteUrl, title, source, expires: Date.now() + STREAM_TTL_MS })
store().set(token, {
remoteUrl,
title,
source,
expires: Date.now() + STREAM_TTL_MS,
...(cookieHeader ? { cookieHeader } : {}),
})
return token
}
/** Register a converted MP3 (v7.5 pipeline); the entry owns the download dir. */
export function createFileStreamEntry(file: StreamFile, title: string, source: string, pageUrl: string): string {
prune()
const token = randomBytes(16).toString('hex')
store().set(token, {
remoteUrl: pageUrl,
title,
source,
expires: Date.now() + STREAM_TTL_MS,
file,
})
return token
}
@ -47,7 +107,14 @@ export function getStreamEntry(token: string): StreamEntry | null {
prune()
const entry = store().get(token)
if (!entry) return null
return { remoteUrl: entry.remoteUrl, title: entry.title, source: entry.source, expires: entry.expires }
return {
remoteUrl: entry.remoteUrl,
title: entry.title,
source: entry.source,
expires: entry.expires,
...(entry.cookieHeader ? { cookieHeader: entry.cookieHeader } : {}),
...(entry.file ? { file: entry.file } : {}),
}
}
/** Slide a live entry's TTL forward (called on each successful stream GET). */

View file

@ -0,0 +1,252 @@
// OVE — yt-dlp self-healing / self-update engine (v6.8).
//
// YouTube rotates their player + page mechanics regularly; when they do,
// the installed yt-dlp extractor starts failing ("nsig extraction failed",
// "Unable to extract initial player response", python tracebacks …). Instead
// of leaving visitors with a dead resolver, the server detects those
// signatures in /api/resolve and calls triggerYtdlpUpdate() — this module
// then downloads the latest official yt-dlp release IN THE BACKGROUND and
// atomically swaps it in, so the very next resolve attempt (the client
// retries automatically) usually works again.
//
// Why a direct release download instead of `yt-dlp -U`:
// `yt-dlp -U` asks api.github.com for release metadata — that API is
// rate-limited (HTTP 403) from this machine's IP. The release ASSET
// redirect (github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux)
// is served by a CDN and works reliably, so that is what we use.
//
// Safety rails:
// • ONE update run at a time + a 6 h cooldown after each run — a broken
// extractor can never make us hammer GitHub;
// • the downloaded binary is verified before it replaces anything:
// plausible size (10–120 MB), ELF magic, and it must actually execute
// (`--version`) — only then does an atomic rename() swap it in (running
// extractions keep the old inode, new spawns get the new binary);
// • same version as before → nothing is replaced (no update was shipped);
// the caller then reports the honest "back-end problem" answer.
//
// All state lives on globalThis so Next.js dev-mode module reloads never
// reset an in-flight update: the running closure keeps writing to the SAME
// state object a reloaded module reads.
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdir, rename, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
/** Canonical release asset: universal linux onefile binary. */
const RELEASE_ASSET_URL = 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux'
/** Hard cap on the whole update run (download + verification). */
const UPDATE_TIMEOUT_MS = 180_000
/** A plausible yt-dlp linux onefile build is ~30–50 MB. */
const MIN_BINARY_BYTES = 10 * 1024 * 1024
const MAX_BINARY_BYTES = 120 * 1024 * 1024
/** Don't attempt another update within this window after a finished run. */
const UPDATE_COOLDOWN_MS = 6 * 60 * 60 * 1000
/** `--version` must answer within this. */
const VERSION_PROBE_TIMEOUT_MS = 15_000
export type YtdlpUpdatePhase = 'idle' | 'running' | 'done'
export interface YtdlpUpdateState {
phase: YtdlpUpdatePhase
startedAt: number
/** Set when phase === 'done'. */
finishedAt: number | null
/** True when a NEWER binary was actually installed (a real update shipped). */
ranUpdate: boolean
oldVersion: string | null
newVersion: string | null
/** Last failure reason (download/verify errors), null on success. */
error: string | null
}
const IDLE_UPDATE_STATE: YtdlpUpdateState = {
phase: 'idle',
startedAt: 0,
finishedAt: null,
ranUpdate: false,
oldVersion: null,
newVersion: null,
error: null,
}
interface AvaSelfUpdateGlobal {
__avaYtdlpUpdateState?: YtdlpUpdateState
}
/** Shared, dev-reload-safe state object (single instance on globalThis). */
function updateState(): YtdlpUpdateState {
const g = globalThis as AvaSelfUpdateGlobal
g.__avaYtdlpUpdateState ??= { ...IDLE_UPDATE_STATE }
return g.__avaYtdlpUpdateState
}
/** Snapshot for logging / route decisions. */
export function ytdlpUpdateSnapshot(): YtdlpUpdateState {
return { ...updateState() }
}
/** True while a background update run is in progress. */
export function ytdlpUpdateRunning(): boolean {
return updateState().phase === 'running'
}
/** True when a run finished recently (success OR failure) — retrying the
* update now would be pointless, the failure is a real back-end problem. */
export function ytdlpUpdateCooldownActive(): boolean {
const s = updateState()
return s.phase === 'done' && s.finishedAt !== null && Date.now() - s.finishedAt < UPDATE_COOLDOWN_MS
}
/** Canonical install location of the extractor (bin/yt-dlp in the project). */
function installPath(): string {
return join(process.cwd(), 'bin', 'yt-dlp')
}
function tempPath(): string {
return join(process.cwd(), 'bin', 'yt-dlp.new')
}
function log(message: string): void {
console.log(`[ytdlp-selfupdate] ${message}`)
}
/** Run `<binary> --version` (local execution, no network). Null on failure. */
function probeVersion(binaryPath: string): Promise<string | null> {
return new Promise((resolve) => {
let stdout = ''
let settled = false
const done = (v: string | null): void => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(v)
}
let child: ReturnType<typeof spawn>
try {
child = spawn(binaryPath, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] })
} catch {
done(null)
return
}
const timer = setTimeout(() => {
try {
child.kill('SIGKILL')
} catch {
/* already gone */
}
done(null)
}, VERSION_PROBE_TIMEOUT_MS)
child.stdout?.on('data', (c: Buffer) => {
stdout += c.toString('utf8')
})
child.on('error', () => done(null))
child.on('close', (code) => done(code === 0 ? stdout.trim().slice(0, 40) || null : null))
})
}
/**
* Fire-and-forget: download the latest yt-dlp and atomically install it.
* Never throws — the outcome lands in the shared state object. Callers
* (the resolve route) respond to visitors immediately and let the client
* retry while this runs in the background.
*/
export function triggerYtdlpUpdate(): void {
const s = updateState()
if (s.phase === 'running') return
if (s.phase === 'done' && s.finishedAt !== null && Date.now() - s.finishedAt < UPDATE_COOLDOWN_MS) {
return
}
// Claim the run synchronously (no await between check and claim).
s.phase = 'running'
s.startedAt = Date.now()
s.finishedAt = null
s.ranUpdate = false
s.oldVersion = null
s.newVersion = null
s.error = null
log(`update triggered — downloading ${RELEASE_ASSET_URL}`)
void runUpdate(s)
}
async function runUpdate(s: YtdlpUpdateState): Promise<void> {
const startedAt = Date.now()
try {
// 1. current version (best-effort — a missing binary is fine, we install one)
const target = installPath()
s.oldVersion = existsSync(target) ? await probeVersion(target) : null
if (existsSync(target) && s.oldVersion === null) {
throw new Error('the current yt-dlp binary failed to run (--version)')
}
// 2. download the latest release asset (fetch follows the release
// 302 to the CDN automatically)
const res = await fetch(RELEASE_ASSET_URL, {
redirect: 'follow',
signal: AbortSignal.timeout(UPDATE_TIMEOUT_MS),
headers: { 'User-Agent': 'OVE-selfupdate/6.8' },
})
if (!res.ok) throw new Error(`download failed (HTTP ${res.status})`)
const body = await res.arrayBuffer()
const size = body.byteLength
if (size < MIN_BINARY_BYTES || size > MAX_BINARY_BYTES) {
throw new Error(`downloaded asset has an implausible size (${size} bytes)`)
}
// 3. verify it is a Linux ELF executable before it goes anywhere near
// the install path
const magic = new Uint8Array(body, 0, 4)
if (magic[0] !== 0x7f || magic[1] !== 0x45 || magic[2] !== 0x4c || magic[3] !== 0x46) {
throw new Error('downloaded asset is not a Linux ELF binary')
}
// 4. stage it and prove it executes
await mkdir(dirname(tempPath()), { recursive: true })
await rm(tempPath(), { force: true })
await writeFile(tempPath(), new Uint8Array(body), { mode: 0o755 })
const stagedVersion = await probeVersion(tempPath())
if (stagedVersion === null) {
throw new Error('the downloaded binary failed to execute (--version)')
}
s.newVersion = stagedVersion
// 5. same release as installed → nothing to ship; keep the current binary
if (s.oldVersion !== null && s.oldVersion === stagedVersion) {
await rm(tempPath(), { force: true })
s.ranUpdate = false
log(`already up to date (${stagedVersion}) — no update shipped`)
return
}
// 6. atomic swap (running extractions keep the old inode; new spawns
// pick up the new binary immediately)
await rename(tempPath(), target)
s.ranUpdate = true
log(
`updated yt-dlp ${s.oldVersion ?? '(none)'} → ${stagedVersion} in ${(
(Date.now() - startedAt) /
1000
).toFixed(1)}s`,
)
} catch (err) {
s.error = err instanceof Error ? err.message : String(err)
await rm(tempPath(), { force: true }).catch(() => {})
log(`update FAILED: ${s.error}`)
} finally {
s.phase = 'done'
s.finishedAt = Date.now()
}
}
/** Admin/health helper: total bytes on disk for the installed binary. */
export async function installedBinaryBytes(): Promise<number | null> {
try {
const st = await stat(installPath())
return st.size
} catch {
return null
}
}

View file

@ -18,7 +18,6 @@ import type { TapResult } from '@/lib/ava/beat'
import type { BandConfig, BeatConfig, BeatSource } from '@/lib/ava/types'
import { DEFAULT_BAND_CONFIG, DEFAULT_BEAT_CONFIG } from '@/lib/ava/types'
import { validateScript } from '@/lib/ava/script'
import { toast } from '@/hooks/use-toast'
export interface PresetItem {
id: string
@ -144,7 +143,14 @@ export function readStoredBandConfig(): BandConfig | null {
export function readStoredBeatConfig(): BeatConfig | null {
const v = lsGetJSON<unknown>(LS_BEAT)
return isBeatConfig(v) ? v : null
if (!isBeatConfig(v)) return null
// v7.1 migration: floorLevel changed UNITS — it used to gate the smoothed
// bass LEVEL (default 0.04) and now gates the onset FLUX envelope (default
// 0.0015). Any stored value ≥ 0.01 is an old level-domain number that
// would dead-gate the new detector (mastered music reads ~0.01-0.03 flux);
// reset it to the new default.
if (v.floorLevel >= 0.01) v.floorLevel = DEFAULT_BEAT_CONFIG.floorLevel
return v
}
// ---------------------------------------------------------------------------
@ -164,7 +170,6 @@ interface AvaState {
sourceTitle: string | null
/** "Stream a link" dialog visibility (opened from header + idle overlay). */
linkDialogOpen: boolean
demoBpm: number
/** Editor content (the live script text). */
scriptText: string
bandConfig: BandConfig
@ -182,8 +187,12 @@ interface AvaState {
editTarget: EditTarget | null
/** Preset currently running on the engine (tree indicator). */
activePresetId: string | null
/** Timed random preset switching (club mode). */
/** Timed preset mixing (club mode): Random (no repeats) or, v7.4, Sequence
* (in-order cycling with wrap-around). The two are mutually exclusive —
* ticking one unticks the other; both share one delay + folder scope and
* both hold their breath while no music is playing. */
randomize: boolean
sequenceMode: boolean
randomIntervalSec: number
randomScope: string
tab: string
@ -194,7 +203,6 @@ interface AvaState {
setSource: (s: AudioSourceState) => void
setSourceTitle: (t: string | null) => void
setLinkDialogOpen: (b: boolean) => void
setDemoBpm: (bpm: number) => void
setScriptText: (t: string) => void
/** Apply a full config (initial load) — engine + store, no persist. */
applyBandConfig: (cfg: BandConfig) => void
@ -212,6 +220,8 @@ interface AvaState {
setEditTarget: (t: EditTarget | null) => void
setActivePresetId: (id: string | null) => void
setRandomize: (on: boolean) => void
/** v7.4: Sequence mode — in-order cycling; mutually exclusive with Random. */
setSequenceMode: (on: boolean) => void
/** Validate + set the random delay (3–60 s). Returns ok:false when invalid. */
setRandomIntervalSec: (sec: number) => { ok: boolean; value?: number; error?: string }
setTab: (t: string) => void
@ -233,47 +243,95 @@ export function stopRandomTimer(): void {
/**
* Pick a random preset inside the current scope and run it on the engine.
* Deliberately does NOT touch the editor text — random switching must never
* destroy unsaved script edits.
* destroy unsaved script edits. v7.4: never picks the preset that is already
* running (no same-preset-twice-in-a-row) while the pool offers a choice.
*/
function loadRandomPreset(): void {
const s = useAvaStore.getState()
// club mode only runs while music is playing — silence must never
// re-shuffle the visitor's selected preset. The checkbox stays as the
// visitor left it; the mixer simply waits for the next track to start.
if (s.source === 'idle') return
const scope = s.randomScope
const pool = s.presets.filter(
(p) => scope === 'root' || p.folderPath === scope || p.folderPath.startsWith(`${scope}/`),
)
if (pool.length === 0) return
for (let attempt = 0; attempt < Math.min(4, pool.length); attempt++) {
const pick = pool[Math.floor(Math.random() * pool.length)]
// v7.4: exclude the running preset so Random can never load the same
// preset twice in a row (only when a real choice exists)
const candidates = pool.length > 1 ? pool.filter((p) => p.id !== s.activePresetId) : pool
for (let attempt = 0; attempt < Math.min(4, candidates.length); attempt++) {
const pick = candidates[Math.floor(Math.random() * candidates.length)]
const v = validateScript(pick.content)
if (v.ok && v.script) {
// No toast (v7.3): the footer's SCRIPT: label already echoes the
// running preset — popup announcements on every switch were
// intrusive during the club mix.
getEngine().setScript(v.script)
useAvaStore.getState().setActivePresetId(pick.id)
toast({ title: 'Random preset', description: `Now playing "${pick.name}".` })
return
}
}
}
/**
* v7.4 — Sequence mode: run the NEXT preset in the current scope, in the
* tree's displayed order (name, case-insensitive). Moving past the last
* preset wraps back to the first — the cycle never ends, it just loops the
* folder. Like Random, it never touches the editor text and waits politely
* through silence (no advancing without music).
*/
function loadNextPreset(): void {
const s = useAvaStore.getState()
if (s.source === 'idle') return
const scope = s.randomScope
const pool = s.presets.filter(
(p) => scope === 'root' || p.folderPath === scope || p.folderPath.startsWith(`${scope}/`),
)
if (pool.length === 0) return
// the tree sorts presets by name (case-insensitive) — cycle in exactly
// the order the visitor sees
pool.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
// start from the running preset; a fresh cycle (nothing active / running
// preset outside this scope) begins at the first preset
const idx = pool.findIndex((p) => p.id === s.activePresetId)
const next = pool[(idx + 1 + pool.length) % pool.length] // idx=-1 → 0 (first)
const v = validateScript(next.content)
if (v.ok && v.script) {
getEngine().setScript(v.script)
useAvaStore.getState().setActivePresetId(next.id)
}
}
/** One timer tick — whichever mix mode is active does its thing. */
function autoSwitchTick(): void {
const s = useAvaStore.getState()
if (s.sequenceMode) loadNextPreset()
else if (s.randomize) loadRandomPreset()
}
/** (Re)start or stop the timer according to the current store state. */
export function syncRandomTimer(): void {
stopRandomTimer()
const s = useAvaStore.getState()
if (!s.randomize) return
randomTimer = setInterval(loadRandomPreset, s.randomIntervalSec * 1000)
if (!s.randomize && !s.sequenceMode) return
randomTimer = setInterval(autoSwitchTick, s.randomIntervalSec * 1000)
}
function persistRandomSettings(): void {
const s = useAvaStore.getState()
lsSetJSON(LS_RANDOM, {
enabled: s.randomize,
// v7.4: whichever mix mode is on persists; `sequence` records which one
enabled: s.randomize || s.sequenceMode,
sequence: s.sequenceMode,
intervalSec: s.randomIntervalSec,
scope: s.randomScope,
})
}
/** Restore the persisted randomize settings after presets first load. */
/** Restore the persisted mix settings after presets first load. */
export function restoreRandomSettings(folders: string[]): void {
const saved = lsGetJSON<{ enabled?: boolean; intervalSec?: number; scope?: string }>(LS_RANDOM)
const saved = lsGetJSON<{ enabled?: boolean; sequence?: boolean; intervalSec?: number; scope?: string }>(LS_RANDOM)
if (!saved) return
const intervalOk =
typeof saved.intervalSec === 'number' && saved.intervalSec >= 3 && saved.intervalSec <= 60
@ -283,7 +341,13 @@ export function restoreRandomSettings(folders: string[]): void {
randomScope: scope ?? 'root',
})
if (saved.enabled) {
useAvaStore.setState({ randomize: true })
// v7.4: the saved mode comes back exactly as the visitor left it —
// Sequence and Random are mutually exclusive, so only one flag rises
if (saved.sequence) {
useAvaStore.setState({ sequenceMode: true, randomize: false })
} else {
useAvaStore.setState({ randomize: true, sequenceMode: false })
}
syncRandomTimer()
}
}
@ -297,7 +361,6 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
source: 'idle',
sourceTitle: null,
linkDialogOpen: false,
demoBpm: 124,
scriptText: '',
bandConfig: { ...DEFAULT_BAND_CONFIG },
beatConfig: { ...DEFAULT_BEAT_CONFIG },
@ -309,6 +372,7 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
editTarget: null,
activePresetId: null,
randomize: false,
sequenceMode: false,
randomIntervalSec: 10,
randomScope: 'root',
tab: 'presets',
@ -325,12 +389,6 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
setLinkDialogOpen: (b) => set({ linkDialogOpen: b }),
setDemoBpm: (bpm) => {
const v = Math.min(200, Math.max(60, Math.round(bpm)))
getEngine().audio.setDemoBpm(v)
set({ demoBpm: v })
},
setScriptText: (t) => set({ scriptText: t }),
applyBandConfig: (cfg) => {
@ -424,7 +482,16 @@ export const useAvaStore = create<AvaState>()((set, get) => ({
setActivePresetId: (id) => set({ activePresetId: id }),
setRandomize: (on) => {
set({ randomize: on })
// v7.4 mutual exclusion: ticking Random unticks Sequence (turning one
// OFF never lights the other — the visitor's explicit choice)
set(on ? { randomize: true, sequenceMode: false } : { randomize: false })
persistRandomSettings()
syncRandomTimer()
},
/** v7.4: Sequence mode — cycle the scope's presets in tree order. */
setSequenceMode: (on) => {
set(on ? { sequenceMode: true, randomize: false } : { sequenceMode: false })
persistRandomSettings()
syncRandomTimer()
},

130
src/lib/theme.ts Executable file
View file

@ -0,0 +1,130 @@
// OVE — theme system (v6.5): base chrome × highlight accent.
//
// Two independent axes, both per-visitor (browser-local, nothing on server):
// • BASE — the chrome palette. Managed by next-themes as a class on
// <html>: 'purple' (royal purple), 'matte' (matte black), 'outline'
// (black stage + outlined panels; v7.2 DEFAULT for new visitors) or
// 'light' (white).
// • ACCENT — the highlight/outline color. Managed by this module as a
// data-accent attribute on <html> (set pre-paint by the boot script in
// layout.tsx, at runtime by applyAccent()).
//
// globals.css owns the actual palettes (zinc/emerald ramp remaps per base
// and per accent); this module owns the option metadata + persistence used
// by the Settings tab and the header tick switch.
export type ThemeBaseId = 'purple' | 'matte' | 'outline' | 'light'
export type AccentId = 'purple' | 'yellow' | 'blue' | 'green' | 'red' | 'orange'
export interface ThemeBase {
id: ThemeBaseId
name: string
/** true = one of the dark bases (the header tick switch flips to these) */
dark: boolean
blurb: string
/** static swatch colors for the preview card: page bg / panel / border */
swatch: { bg: string; panel: string; border: string }
}
export const THEME_BASES: ThemeBase[] = [
{
id: 'purple',
name: 'Royal Purple',
dark: true,
blurb: 'Deep purple chrome with light-yellow highlights — the classic OVE look.',
swatch: { bg: '#1b0f2e', panel: '#2a1a42', border: '#453063' },
},
{
id: 'matte',
name: 'Matte Black',
dark: true,
blurb: 'True matte black — neutral, no hue, zero glare.',
swatch: { bg: '#0b0b0d', panel: '#141417', border: '#26262c' },
},
{
id: 'outline',
name: 'Black + Purple Outline',
dark: true,
blurb: 'Pure black stage with outlined panels — the outline follows your highlight color (blue by default).',
swatch: { bg: '#000000', panel: '#0a0a0e', border: '#4c1d95' },
},
{
id: 'light',
name: 'Light',
dark: false,
blurb: 'White theme for bright rooms and daylight screens.',
swatch: { bg: '#ffffff', panel: '#f4f4f5', border: '#e4e4e7' },
},
]
export interface AccentOption {
id: AccentId
name: string
/** main highlight hex (dark-theme accent) */
main: string
/** deep variant: text-on-accent buttons + outline borders */
deep: string
/** darker main used on white backgrounds */
lightMain: string
}
export const ACCENTS: AccentOption[] = [
{ id: 'purple', name: 'Purple', main: '#c084fc', deep: '#2e1065', lightMain: '#9333ea' },
{ id: 'yellow', name: 'Yellow', main: '#fde047', deep: '#422006', lightMain: '#ca8a04' },
{ id: 'blue', name: 'Blue', main: '#60a5fa', deep: '#172554', lightMain: '#2563eb' },
{ id: 'green', name: 'Green', main: '#34d399', deep: '#064e3b', lightMain: '#059669' },
{ id: 'red', name: 'Red', main: '#f87171', deep: '#450a0a', lightMain: '#dc2626' },
{ id: 'orange', name: 'Orange', main: '#fb923c', deep: '#431407', lightMain: '#ea580c' },
]
// v7.2 defaults for NEW visitors (nobody has stored a choice yet): the
// Black + Purple Outline base with the Blue accent — the showcase look that
// lets the canvas colors own the screen. Visitors who already picked a base
// or accent keep theirs (their stored choice always wins over these).
export const DEFAULT_BASE: ThemeBaseId = 'outline'
export const DEFAULT_ACCENT: AccentId = 'blue'
export const ACCENT_STORAGE_KEY = 'ove.theme.accent'
export const DARK_BASE_STORAGE_KEY = 'ove.theme.darkBase'
const ACCENT_IDS = new Set<string>(ACCENTS.map((a) => a.id))
const BASE_IDS = new Set<string>(THEME_BASES.map((b) => b.id))
export function isAccentId(v: string | null): v is AccentId {
return v !== null && ACCENT_IDS.has(v)
}
export function isThemeBaseId(v: string | null): v is ThemeBaseId {
return v !== null && BASE_IDS.has(v)
}
/** Persist + immediately apply an accent (data-accent attribute on <html>). */
export function applyAccent(id: AccentId): void {
try {
localStorage.setItem(ACCENT_STORAGE_KEY, id)
} catch {
/* private mode — attribute still applies for this visit */
}
document.documentElement.setAttribute('data-accent', id)
}
/** The dark base the header tick switch flips back to (v7.2 default: outline). */
export function getStoredDarkBase(): ThemeBaseId {
try {
const v = localStorage.getItem(DARK_BASE_STORAGE_KEY)
if (isThemeBaseId(v) && v !== 'light') return v
} catch {
/* ignore */
}
return DEFAULT_BASE
}
/** Remember a dark base for the tick switch (light is never stored here). */
export function storeDarkBase(base: ThemeBaseId): void {
if (base === 'light') return
try {
localStorage.setItem(DARK_BASE_STORAGE_KEY, base)
} catch {
/* ignore */
}
}

View file

@ -7,6 +7,174 @@
// (v6.0 = rename to Open Visualization Engine + version indicator).
//
// History:
// 7.5 — yt-dlp compatibility: single tracks resolve by DOWNLOAD + CONVERT
// instead of proxying the extractor's stream URL. The old stream
// URLs were IP-locked (googlevideo bakes in the resolver's IP),
// expired within hours, and arrived as webm/opus — a container
// Safari & co. refuse ("The media resource … was not suitable").
// The server now runs the operator's pipeline per track:
// yt-dlp -f bestaudio/best -x --audio-format mp3
// --audio-quality 0 -o "<dir>/%(title)s.%(ext)s" URL
// — audio-only download, universal MP3 at VBR-0 (~245 kbps), the
// video's real title preserved for the Playing: label. The
// finished file streams same-origin with full Range support from
// a per-track workspace dir (30-min sliding TTL + orphan sweeper);
// /api/audio/stream gained a file-serving mode. Hardening around
// it: shared failure classifier for both yt-dlp passes (metadata +
// download), 180 s download budget, 300 MB cap, bot-wall/stale-
// extractor answers unchanged, ffmpeg-missing degrades to the old
// proxy path (logged once), queue look-ahead lead 15 s → 60 s so
// playlist transitions don't stall behind conversions
// 7.4 — Sequence mode: a second mix checkbox next to Random. Ticked, it
// cycles the scope folder's presets IN ORDER (the tree's exact
// top-to-bottom order) instead of shuffling — and when it moves
// past the last preset it wraps back to the first, so the folder
// loops forever. Random and Sequence are mutually exclusive
// (ticking either one unticks the other; both share the same
// delay and folder scope), Random now never loads the same preset
// twice in a row, and Sequence holds its breath through silence
// exactly like Random does. The Demo showcase now runs SEQUENCE —
// the one play-through walks every Zai-Magic preset in order
// (20 s each) rather than repeating random picks, and when the
// demo stops, whichever mode it checked is unchecked again
// 7.3 — demo-flow refinements: the Demo Track is a single pass — it plays
// "Neural Mecha Pulse" once through and stops (a demo, not a radio
// station). However it ends (natural finish, Stop, or switching to
// another source) the showcase unwinds: the randomizer the Demo
// flow force-checked is unchecked again, and the visitor lands
// back on the "Select an audio source" overlay. Preset-change
// popups are gone ("Preset loaded" / "Random preset" / "Default
// preset running") — the footer's SCRIPT: label already echoes
// every change, so the toasts were pure intrusion; only compile
// errors still toast. Club-mode randomizer now respects silence:
// when no music is playing it never re-shuffles the selected
// preset, even with the checkbox on — it waits for the next track
// 7.2.1 — Supernova (Zai-Magic) preset fix: the particle kill condition
// (|px|+|py| > 2.4) was physically unreachable — drag caps a
// particle's total travel at vel/1.6 ≈ 1.0, so nothing ever
// crossed 1.4, nothing ever died, and since respawning requires a
// dead particle, the per-beat "budget = n" detonation could never
// fire after the initial one — the preset exploded once on load,
// then faded to permanent black. Now every beat force-clears the
// whole 620-particle field in [beat] (guaranteed full re-detonation
// on the very same frame), fully-cooled particles die by fade so
// long beat gaps leave a gentle ember drizzle instead of black,
// and each blast re-randomizes its geometry (no two explosions
// alike). Embedded library re-seeded (seed marker 7.01)
// 7.2 — new-visitor showcase flow. Defaults for first-time visitors:
// the Black + Purple Outline theme with the Blue highlight
// (previously royal purple + yellow), and Zai-Magic "Bass Bloom"
// as the startup preset (previously the first Synth-C classic).
// The idle overlay's Demo Track button now carries an animated
// reflective shimmer (skewed light band sweeping left→right every
// 3.2 s, reduced-motion safe) to draw the click — and that one
// click starts the full showcase: the demo track plays while the
// preset tree selects + expands Zai-Magic, randomize switches on
// scoped to that folder with a 20-second change interval. Music
// started with NO preset active (own draft running / selection
// cleared) auto-loads Bass Bloom. Preset changes — manual load,
// the random mix, the Demo flow — no longer hard-cut: the engine
// snapshots the outgoing frame and blur-dissolves it into the new
// preset over 700 ms (progressive blur + easeOutCubic fade + a
// slight scale-up so the softening edges never gap; plain
// crossfade where ctx.filter is unsupported)
// 7.1 — real demo track + a beat detector that survives real music.
// The Demo button now plays "Neural Mecha Pulse" (the operator's
// mastered industrial-EDM banger, public/demo-track.mp3, loops
// gaplessly) instead of the synthetic techno pattern — a real
// recording is the honest test: deep sub for the bass bands,
// driving mids, crisp highs, and a kick buried in sustained
// sub-bass. That last part exposed the 6.x detector as fundamentally
// broken on mastered music: a LEVEL threshold can never see the
// kick through the sub (peak-to-mean caps at ~1.2× → sporadic
// fires, phantom 120-BPM locks from the minInterval floor).
// v7.1 rewrites detection the way real beat trackers work
// (aubio/librosa-style): a full-band spectral-flux ONSET envelope
// → autocorrelation over 300–1000 ms lags with a log-BPM prior →
// comb-filter phase with grid hysteresis → beats fired on an
// absolute grid kept glued by a PLL pull. Headlessly validated
// against the actual track PCM (AnalyserNode chain replicated
// frame-for-frame): locks the true ~133 BPM (the song drifts
// 130.7→133.1 — the grid TRACKS it) in 1.9 s, 92% beat-gap
// regularity, confidence 0.99 through the body; techno/hip-hop
// patterns lock exactly; silence yields zero beats. Known limit:
// sparse kick-vs-snare backbeats (dnb) may lock half-tempo —
// stable, and two taps of Set Beat correct it. Visitor-stored
// beat configs migrate (floorLevel changed units, level → flux).
// The demo BPM slider is gone (a real track's tempo is detected,
// not dialed in) — replaced by a track-name chip in the header
// 7.00 — Zai-Magic: ten hand-authored presets in root/embedded/Zai-Magic
// built around BEAT-SYNCED COLOR — the color wheel turns on the
// kick, palettes swap per beat / per 4-beat bar, megabuf particles
// remember the hue of the beat that spawned them + new script
// tools: hsvr/hsvg/hsvb (HSV→RGB channels), smoothstep and the
// beatage kick-envelope variable — all documented in the help
// panel. (Numbered 7.00 rather than 6.10 — a new preset family
// plus new script-language features is a major release; 6.9.3 was
// the last patch of the 6.x line.) Beat engine fixes underneath so
// the Demo track actually
// locks tempo (was sporadic): demo synth remixed for detection
// (hot kick owning the 20–100 Hz band, off-beat bassline voiced
// C3+ with a sidechain pump — chronological envelope events!),
// and the detector's default threshold 1.4 → 1.2 (the 170 ms FFT
// window caps a clean kick's peak-to-mean at ~1.5×, so 1.4 fired
// only sporadically and the BPM never locked)
// 6.9.3 — canonical domain fix: metadataBase/og:url/og:image pointed at
// openvisualengine.com (a DEAD domain baked in by 6.9.1) while the
// real site is openvisengine.com — Facebook's "corrupted image"
// error was its crawler fetching the image from a host that serves
// nothing; all share URLs now use the correct short domain
// 6.9.2 — share-image fix: og:image re-encoded as BASELINE JPEG (the
// 6.9.1 progressive encoding rendered fine in every browser but
// Facebook's Sharing Debugger rejected it as "corrupted"), swapped
// in the title-card artwork (gradient wordmark over three
// visualizations) and added a ?v=2 cache-bust so crawlers fetch it
// 6.9.1 — social share preview: Open Graph + Twitter Card metadata with
// a 1200×630 screenshot of the app (public/og-image.jpg) — sharing
// openvisualengine.com on Facebook/X/WhatsApp/Discord now shows the
// big image card instead of a bare link
// 6.9 — house account: anonymous visitors play YouTube links through
// the operator's shared disposable-account cookie (server-side
// fallback under secrets/, never exposed) — YouTube bot walls stop
// applying to guests; visitor's own login always wins. Engine fix
// underneath: Deno runtime shipped in bin/ (YouTube requires a JS
// runtime for signature/n-challenge solving on authenticated
// sessions — without it, logged-in extractions return no formats)
// 6.8.2 — login friction cut to ~4 clicks: the exported cookies.txt /
// Cookie-Editor JSON file can be dragged straight onto the
// login box (or picked via “Import the exported file…”) — no
// opening the file, no copy/paste; “Log out” / sign-out
// revocation note added (the official Google OAuth flow can't
// power the site: Google's APIs never hand out stream URLs and
// restricted-scope verification would reject an audio player)
// 6.8.1 — cookie login made foolproof: the login dialog recognizes raw
// Cookie headers, cookies.txt exports AND Cookie-Editor JSON,
// validates the paste LIVE through one shared parser (a single
// cookie's value — like a bare LOGIN_INFO string — gets an honest
// "this will not log you in" warning), and the how-to steps lead
// with the one-click extension export for Chrome AND Firefox
// 6.8 — self-healing audio engine: when YouTube changes their mechanics
// the server detects the stale extractor, updates yt-dlp in the
// background (verified GitHub release download, atomic swap) and the
// player retries seamlessly behind an "Updating in Background"
// notice; bot walls answer "Google login required…" with an Open
// Settings action, and unfixable failures get the honest
// "A back-end problem has occurred. Please try again later."
// 6.7 — lazy playlist queue: a playlist link becomes a queue of URLs that
// streams ONE song at a time (each track is resolved by yt-dlp only
// right before it plays — next-track look-ahead at ≤15 s to go),
// queue bar with skip/jump/replay + server-side caps (max 2 parallel
// extractions); music-service framework: Spotify + Apple Music are
// registered providers whose playback ships in a later update
// (logins already save, links answer with an honest notice)
// 6.6 — music service logins (Settings tab): YouTube / YouTube Music,
// SoundCloud, Spotify, Apple Music cookie login, encrypted on the
// visitor's computer; Link button gains playlist picking + uses the
// login to bypass bot walls
// 6.5 — Settings tab (replaces the parked Native tab): theme picker with
// base chrome (Royal Purple / Matte Black / Black + Purple Outline /
// Light) × selectable highlight color (Purple, Yellow, Blue, Green,
// Red, Orange), all per-visitor; simple OVE logo as the site favicon
// 6.4 — beat detection suspends when no music is playing/loaded (no ghost
// beats after Stop, BPM resets) + dark theme restyle: royal purple
// chrome with light-yellow highlights
@ -22,4 +190,4 @@
export const APP_NAME = 'Open Visualization Engine'
export const APP_SHORT = 'OVE'
export const APP_VERSION = '6.4'
export const APP_VERSION = '7.5'

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

BIN
tool-results/ove65-light.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
tool-results/ove65-matte.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
tool-results/ove65-mobile.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
tool-results/ove65-outline.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

BIN
tool-results/ove67-1-home.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,778 @@
1→import { spawn } from 'node:child_process'
2→import { existsSync } from 'node:fs'
3→import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4→import { tmpdir } from 'node:os'
5→import { join } from 'node:path'
6→import { NextRequest, NextResponse } from 'next/server'
7→import { assertPublicHttpUrl } from '@/lib/server/ssrf'
8→import { createStreamEntry } from '@/lib/server/stream-store'
9→import { providerForUrl } from '@/lib/server/music-providers'
10→import {
11→ triggerYtdlpUpdate,
12→ ytdlpUpdateCooldownActive,
13→ ytdlpUpdateRunning,
14→} from '@/lib/server/ytdlp-selfupdate'
15→import {
16→ isServiceId,
17→ cookiePasteToNetscape,
18→ serviceDef,
19→ serviceForUrl,
20→ type ServiceId,
21→} from '@/lib/music-services'
22→import { houseCookieStatus, houseCookieText } from '@/lib/server/house-cookies'
23→
24→export const runtime = 'nodejs'
25→export const dynamic = 'force-dynamic'
26→
27→/**
28→ * POST /api/resolve
29→ * Body: { "url": string, "cookies"?: string, "cookieService"?: ServiceId }
30→ * — url: a YouTube / SoundCloud / Bandcamp / direct-file link, or a
31→ * playlist / set link.
32→ * — cookies + cookieService (optional): the visitor's own login cookie for
33→ * the matching music service (from the Settings tab), pasted as a raw
34→ * Cookie header, a cookies.txt export or a Cookie-Editor JSON export —
35→ * all normalized by the shared parser in lib/music-services.ts. The
36→ * cookie is used transiently — written to a 0600 temp file passed to
37→ * yt-dlp as --cookies, deleted immediately after, never logged and
38→ * never stored.
39→ * Playlists are returned as entry lists (flat, fast); the client plays
40→ * them as a lazy queue — one track resolved at a time, never in bulk.
41→ * Single tracks resolve to a same-origin proxy URL:
42→ * { ok, streamUrl, title, source, durationSec }
43→ * Playlists resolve to { ok, isPlaylist, title, source, entries[] }.
44→ *
45→ * v6.7 — anti-spam + music-service framework:
46→ * — CONCURRENCY GATE: at most 2 yt-dlp extractions run at once across ALL
47→ * requests (module-level FIFO semaphore, globalThis-backed). Surplus
48→ * requests wait in line up to 40 s, then get a 503 "busy" answer. The
49→ * server therefore NEVER bursts parallel extractions at YouTube even
50→ * with many visitors resolving at the same time — datacenter IPs that
51→ * hammer the extractor sites get temp-banned. Cache hits and direct-file
52→ * links bypass the gate entirely.
53→ * — COMING-SOON DISPATCH: URLs owned by a registered coming-soon service
54→ * (Spotify, Apple Music — DRM-locked, yt-dlp cannot stream them) are
55→ * intercepted right after the SSRF gate, BEFORE any cache/direct/yt-dlp
56→ * work, and answered with
57→ * { ok: false, code: 'coming-soon', service, error } (422)
58→ * where `error` carries the visitor-facing "later update" note. Live
59→ * service adapters dispatch at the same spot — see
60→ * src/lib/server/music-providers.ts for the extension recipe.
61→ *
62→ * v6.8 — self-healing extractor + honest failure codes:
63→ * — BOT WALLS answer { ok:false, code:'bot-wall' } (422) with the
64→ * "Google login required. Check the Settings tab to login, then try
65→ * again." message on YouTube (other services get the same guidance with
66→ * their name) — the client turns the code into an Open-Settings action.
67→ * — STALE EXTRACTOR signatures (YouTube rotated their mechanics: nsig
68→ * failures, "Unable to extract …", python tracebacks, no formats …)
69→ * trigger a BACKGROUND SELF-UPDATE of bin/yt-dlp (latest GitHub release,
70→ * verified + atomically swapped — see lib/server/ytdlp-selfupdate.ts)
71→ * and answer { ok:false, code:'ytdlp-updating' } (503). The client
72→ * shows "Updating in Background", keeps retrying, and the song picks
73→ * up seamlessly once the new binary is in.
74→ * — When an update already ran recently (cooldown) or shipped no new
75→ * version, stale failures answer
76→ * { ok:false, code:'backend-error' } (500) with "A back-end problem has
77→ * occurred. Please try again later."
78→ */
79→
80→const MAX_URL_LENGTH = 2048
81→const MAX_COOKIES_LENGTH = 65_536
82→const MAX_PLAYLIST_ENTRIES = 200
83→const EXTRACT_TIMEOUT_MS = 30_000
84→const RESOLVE_CACHE_TTL_MS = 10 * 60_000
85→const MAX_STDOUT_BYTES = 32 * 1024 * 1024
86→
87→// ---- yt-dlp concurrency semaphore (v6.7 anti-spam) -------------------------
88→//
89→// The user's hard requirement: never hammer YouTube (or any extractor site)
90→// with parallel/bulk extractions — datacenter IPs that burst requests get
91→// temp-banned. Every extraction goes through this semaphore: at most
92→// YTDLP_MAX_CONCURRENCY yt-dlp processes run at once across ALL requests;
93→// anything beyond that FIFO-queues for up to YTDLP_QUEUE_TIMEOUT_MS and then
94→// fails with a 503 "busy" answer. Kept on globalThis (like the resolve
95→// cache) so dev-mode module reloads never reset it mid-flight.
96→
97→const YTDLP_MAX_CONCURRENCY = 2
98→const YTDLP_QUEUE_TIMEOUT_MS = 40_000
99→
100→interface SemaphoreWaiter {
101→ /** Resolves the acquire() promise with true — a slot was granted. */
102→ grant: () => void
103→ /** Abandonment timer — race-safe handoff, see acquire(). */
104→ timer: ReturnType<typeof setTimeout>
105→}
106→
107→class YtdlpSemaphore {
108→ private active = 0
109→ private waiters: SemaphoreWaiter[] = []
110→
111→ /**
112→ * Take an extraction slot. Resolves true immediately when a slot is free,
113→ * false when the FIFO wait exceeded YTDLP_QUEUE_TIMEOUT_MS.
114→ *
115→ * Race safety (a timed-out waiter must never double-release a slot it was
116→ * granted in the same tick): membership in `waiters` is the single source
117→ * of truth. The timeout callback only abandons the waiter while it is
118→ * STILL QUEUED (never consumed a slot → no release owed). If release()
119→ * already shifted the waiter out — the slot was legitimately transferred
120→ * to it — the callback finds nothing and does nothing; the caller still
121→ * holds exactly one slot and releases exactly once.
122→ */
123→ acquire(): Promise<boolean> {
124→ // Fast path — but never jump the queue: a non-empty waiter list means
125→ // every free slot belongs to the head of that line.
126→ if (this.waiters.length === 0 && this.active < YTDLP_MAX_CONCURRENCY) {
127→ this.active++
128→ return Promise.resolve(true)
129→ }
130→ return new Promise<boolean>((resolve) => {
131→ const waiter: SemaphoreWaiter = {
132→ grant: () => resolve(true),
133→ timer: setTimeout(() => {
134→ const idx = this.waiters.indexOf(waiter)
135→ if (idx === -1) return // slot granted in the same tick — keep it
136→ this.waiters.splice(idx, 1)
137→ resolve(false)
138→ }, YTDLP_QUEUE_TIMEOUT_MS),
139→ }
140→ this.waiters.push(waiter)
141→ })
142→ }
143→
144→ /** Return a slot: hand it straight to the head of the queue (the active
145→ * count does not change on a handoff), otherwise mark the slot free. */
146→ release(): void {
147→ const next = this.waiters.shift()
148→ if (next) {
149→ clearTimeout(next.timer)
150→ next.grant()
151→ return
152→ }
153→ if (this.active > 0) this.active--
154→ }
155→}
156→
157→function ytdlpSemaphore(): YtdlpSemaphore {
158→ const g = globalThis as AvaResolveGlobal
159→ g.__avaYtdlpSemaphore ??= new YtdlpSemaphore()
160→ return g.__avaYtdlpSemaphore
161→}
162→
163→const YT_DLP_ARGS = [
164→ '-J',
165→ '--no-warnings',
166→ // playlists come back as a fast flat entry list instead of being fully
167→ // extracted (watch?v=X&list=Y still resolves the single video only)
168→ '--flat-playlist',
169→ '--no-playlist',
170→ '--no-progress',
171→ '--socket-timeout',
172→ '15',
173→ '-f',
174→ 'bestaudio[protocol^=http]/bestaudio/best',
175→]
176→
177→const DIRECT_AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|mp4|webm)$/i
178→const ADAPTIVE_EXT_RE = /\.(m3u8|mpd)$/i
179→const ADAPTIVE_PROTO_RE = /m3u8|mpd|dash/i
180→const UNSUPPORTED_RE = /unsupported url/i
181→/** YouTube's datacenter-IP wall: "Sign in to confirm you're not a bot". */
182→const BOT_WALL_RE = /sign in to confirm|not a bot|cookies for the authentication/i
183→
184→/** Stale-extractor signatures — YouTube (or any site) changed their
185→ * mechanics and the installed yt-dlp can no longer parse the page:
186→ * nsig/signature rotation, missing player data, python-level crashes,
187→ * empty format lists. These trigger the background self-update. */
188→const STALE_EXTRACTOR_RE =
189→ /nsig extraction failed|signature extraction failed|unable to extract (?:yt ?initial data|initial player response|player id)|unable to find (?:handler function|function name)|traceback \(most recent call last\)|attributeerror|unboundlocalerror|typeerror|keyerror|indexerror|nameerror|expected string or bytes-like object|object has no attribute|object is not subscriptable|no video formats|requested format is not available|player response is not valid/i
190→
191→const BACKEND_ERROR_MESSAGE = 'A back-end problem has occurred. Please try again later.'
192→const UPDATING_MESSAGE =
193→ 'Updating in Background — OVE is updating its audio engine on the server and will retry this song automatically.'
194→
195→/** Bot-wall answers carry the code 'bot-wall' so the client can offer an
196→ * "Open Settings" action. YouTube gets the exact Google-login wording. */
197→function botWallMessage(target: URL, usedCookie: ServiceId | null, houseCookie: boolean): string {
198→ const isYoutube = /(^|\.)youtube\.com$/i.test(target.hostname) || /(^|\.)youtu\.be$/i.test(target.hostname)
199→ if (isYoutube) {
200→ if (usedCookie) {
201→ return 'Google login required. Check the Settings tab to login, then try again. (Your saved Google login may have expired — re-entering it in Settings usually fixes this.)'
202→ }
203→ if (houseCookie) {
204→ return 'Google login required. Check the Settings tab to login, then try again. (The site’s shared account hit a usage limit — try again in a little while, or log in with your own Google account in Settings.)'
205→ }
206→ return 'Google login required. Check the Settings tab to login, then try again.'
207→ }
208→ if (houseCookie) {
209→ return 'This service is asking the server to prove it is not a bot, and the site’s shared account is currently limited. Log in from the Settings tab so your own account is used, then try again.'
210→ }
211→ return 'This service is asking the server to prove it is not a bot. Log in from the Settings tab so your own account is used, then try again.'
212→}
213→
214→/** Stale-extractor answer: kick off (or join) the background self-update and
215→ * tell the client to keep retrying — or, when an update already ran
216→ * recently, give the honest back-end-problem answer. */
217→function staleExtractorResponse(): NextResponse {
218→ if (ytdlpUpdateRunning() || !ytdlpUpdateCooldownActive()) {
219→ if (!ytdlpUpdateRunning()) triggerYtdlpUpdate()
220→ return NextResponse.json(
221→ { ok: false, code: 'ytdlp-updating', error: UPDATING_MESSAGE },
222→ { status: 503, headers: { 'Retry-After': '10' } },
223→ )
224→ }
225→ // An update ran within the cooldown (it either shipped a new binary that
226→ // STILL fails, or shipped nothing) — retrying or re-updating is pointless.
227→ return NextResponse.json(
228→ { ok: false, code: 'backend-error', error: BACKEND_ERROR_MESSAGE },
229→ { status: 500 },
230→ )
231→}
232→
233→interface ResolveSuccess {
234→ ok: true
235→ streamUrl: string
236→ title: string
237→ source: string
238→ durationSec: number | null
239→}
240→
241→interface PlaylistEntry {
242→ url: string
243→ title: string
244→ durationSec: number | null
245→}
246→
247→interface PlaylistSuccess {
248→ ok: true
249→ isPlaylist: true
250→ title: string
251→ source: string
252→ entries: PlaylistEntry[]
253→}
254→
255→interface YtDlpFormat {
256→ url?: unknown
257→ protocol?: unknown
258→}
259→
260→interface YtDlpEntry {
261→ url?: unknown
262→ id?: unknown
263→ title?: unknown
264→ duration?: unknown
265→}
266→
267→interface YtDlpInfo {
268→ _type?: unknown
269→ url?: unknown
270→ protocol?: unknown
271→ title?: unknown
272→ duration?: unknown
273→ extractor?: unknown
274→ extractor_key?: unknown
275→ entries?: YtDlpEntry[] | null
276→ formats?: YtDlpFormat[] | null
277→ requested_formats?: YtDlpFormat[] | null
278→}
279→
280→interface YtDlpResult {
281→ code: number | null
282→ signal: string | null
283→ stdout: string
284→ stderr: string
285→ timedOut: boolean
286→ spawnError: string | null
287→}
288→
289→// ---- success cache (globalThis so dev-mode module reloads keep it) ----
290→
291→interface ResolveCacheEntry {
292→ payload: ResolveSuccess
293→ expires: number
294→}
295→
296→interface AvaResolveGlobal {
297→ __avaResolveCache?: Map<string, ResolveCacheEntry>
298→ __avaYtdlpSemaphore?: YtdlpSemaphore
299→}
300→
301→function resolveCache(): Map<string, ResolveCacheEntry> {
302→ const g = globalThis as AvaResolveGlobal
303→ g.__avaResolveCache ??= new Map<string, ResolveCacheEntry>()
304→ return g.__avaResolveCache
305→}
306→
307→function cacheGet(key: string): ResolveSuccess | null {
308→ const map = resolveCache()
309→ const now = Date.now()
310→ for (const [k, v] of map) {
311→ if (v.expires <= now) map.delete(k)
312→ }
313→ const hit = map.get(key)
314→ return hit ? hit.payload : null
315→}
316→
317→function cacheSet(key: string, payload: ResolveSuccess): void {
318→ resolveCache().set(key, { payload, expires: Date.now() + RESOLVE_CACHE_TTL_MS })
319→}
320→
321→// ---- helpers ----
322→
323→function fail(status: number, error: string): NextResponse {
324→ return NextResponse.json({ ok: false, error }, { status })
325→}
326→
327→/** yt-dlp binary: env override → project-local bin/ → PATH. */
328→function ytdlpPath(): string {
329→ const fromEnv = process.env.AVA_YTDLP
330→ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv
331→ const local = join(process.cwd(), 'bin', 'yt-dlp')
332→ if (existsSync(local)) return local
333→ return 'yt-dlp'
334→}
335→
336→/** Strip ANSI escapes + all other control chars (user-safe text). */
337→function stripControlChars(input: string): string {
338→ let out = ''
339→ let i = 0
340→ while (i < input.length) {
341→ const code = input.charCodeAt(i)
342→ if (code === 0x1b) {
343→ const next = input[i + 1] ?? ''
344→ if (next === '[') {
345→ let j = i + 2
346→ while (j < input.length && '0123456789;?'.includes(input[j] ?? '')) j++
347→ j++ // final byte of the CSI sequence
348→ i = j
349→ } else {
350→ i += next === '' ? 1 : 2 // lone ESC or ESC <char>
351→ }
352→ continue
353→ }
354→ if (code < 0x20 || code === 0x7f) {
355→ i++
356→ continue
357→ }
358→ out += input[i]
359→ i++
360→ }
361→ return out
362→}
363→
364→function cleanText(input: string, max: number): string {
365→ return stripControlChars(input).trim().slice(0, max)
366→}
367→
368→/** Last path segment, decodeURIComponent'd; hostname as fallback. */
369→function directTitle(url: URL): string {
370→ const segments = url.pathname.split('/').filter((s) => s.length > 0)
371→ const last = segments.length > 0 ? segments[segments.length - 1] : ''
372→ let decoded = last
373→ if (last) {
374→ try {
375→ decoded = decodeURIComponent(last)
376→ } catch {
377→ decoded = last
378→ }
379→ }
380→ const cleaned = cleanText(decoded, 140)
381→ return cleaned.length > 0 ? cleaned : url.hostname
382→}
383→
384→/** data.url → requested_formats[0] → last http-protocol format. */
385→function pickStream(data: YtDlpInfo): { url: string; protocol: string } | null {
386→ if (typeof data.url === 'string' && data.url) {
387→ return { url: data.url, protocol: typeof data.protocol === 'string' ? data.protocol : '' }
388→ }
389→ const requested = Array.isArray(data.requested_formats) ? data.requested_formats : []
390→ const first = requested[0]
391→ if (first && typeof first.url === 'string' && first.url) {
392→ return { url: first.url, protocol: typeof first.protocol === 'string' ? first.protocol : '' }
393→ }
394→ const formats = Array.isArray(data.formats) ? data.formats : []
395→ let last: { url: string; protocol: string } | null = null
396→ for (const f of formats) {
397→ const u = typeof f.url === 'string' ? f.url : ''
398→ const p = typeof f.protocol === 'string' ? f.protocol : ''
399→ if (u && p.startsWith('http')) last = { url: u, protocol: p }
400→ }
401→ return last
402→}
403→
404→function ytdlpSource(data: YtDlpInfo, fallbackHost: string): string {
405→ const key = typeof data.extractor_key === 'string' ? data.extractor_key : ''
406→ const ex = typeof data.extractor === 'string' ? data.extractor : ''
407→ return (key || ex || fallbackHost).toLowerCase()
408→}
409→
410→// ---- visitor cookie handling (transient, never persisted) --------------------
411→
412→interface CookiePayload {
413→ /** path of the 0600 temp cookies.txt for yt-dlp --cookies */
414→ filePath: string
415→ /** temp dir to remove after the request */
416→ dir: string
417→ /** "name=value; …" header — forwarded to the stream proxy ONLY when the
418→ * resolved upstream host belongs to the same service domain. */
419→ header: string
420→ service: ServiceId
421→}
422→
423→/**
424→ * Normalize pasted cookie data into a Netscape cookie file body + a Cookie
425→ * request header. Shared with the login dialog's live validation:
426→ * cookiePasteToNetscape() in lib/music-services.ts (v6.8.1) parses raw
427→ * "k=v; k2=v2" headers, Netscape cookies.txt exports AND Cookie-Editor JSON
428→ * exports — one parser on both sides so they can never drift apart.
429→ * Returns null when nothing parseable was pasted.
430→ */
431→
432→/** Write the normalized cookie text to a private temp file for yt-dlp. */
433→async function stageCookies(text: string, service: ServiceId): Promise<CookiePayload | null> {
434→ const parts = cookiePasteToNetscape(text, service)
435→ if (!parts) return null
436→ const dir = await mkdtemp(join(tmpdir(), 'ove-cookies-'))
437→ const filePath = join(dir, 'cookies.txt')
438→ await writeFile(filePath, parts.netscape, { mode: 0o600 })
439→ return { filePath, dir, header: parts.header, service }
440→}
441→
442→/** True when the resolved stream host belongs to the login's service. */
443→function headerAllowedForHost(header: string, service: ServiceId, host: string): boolean {
444→ if (!header) return false
445→ const root = serviceDef(service).cookieDomain.replace(/^\./, '')
446→ const h = host.toLowerCase()
447→ return h === root || h.endsWith(`.${root}`)
448→}
449→
450→/** Run yt-dlp, killing it after EXTRACT_TIMEOUT_MS.
451→ * v6.9: the project bin/ dir is prepended to PATH so yt-dlp finds the
452→ * Deno runtime (bin/deno) — YouTube's signature/n-challenge solving for
453→ * cookie-authenticated extractions requires a JS runtime (EJS); without
454→ * it, authenticated requests return storyboards only ("Requested format
455→ * is not available"). */
456→function ytdlpEnv(): NodeJS.ProcessEnv {
457→ const binDir = join(process.cwd(), 'bin')
458→ const path = process.env.PATH ?? ''
459→ return { ...process.env, PATH: path.split(':').includes(binDir) ? path : `${binDir}:${path}` }
460→}
461→
462→function runYtDlp(url: string, extraArgs: string[]): Promise<YtDlpResult> {
463→ return new Promise((resolve) => {
464→ let stdout = ''
465→ let stderr = ''
466→ let timedOut = false
467→ let settled = false
468→ let timer: ReturnType<typeof setTimeout> | undefined
469→
470→ const done = (code: number | null, signal: string | null, spawnError: string | null): void => {
471→ if (settled) return
472→ settled = true
473→ if (timer !== undefined) clearTimeout(timer)
474→ resolve({ code, signal, stdout, stderr, timedOut, spawnError })
475→ }
476→
477→ let child: ReturnType<typeof spawn>
478→ try {
479→ child = spawn(ytdlpPath(), [...YT_DLP_ARGS, ...extraArgs, url], {
480→ stdio: ['ignore', 'pipe', 'pipe'],
481→ env: ytdlpEnv(),
482→ })
483→ } catch (err) {
484→ done(null, null, err instanceof Error ? err.message : 'failed to start yt-dlp')
485→ return
486→ }
487→
488→ timer = setTimeout(() => {
489→ timedOut = true
490→ try {
491→ child.kill('SIGKILL')
492→ } catch {
493→ // already gone — 'close' will still fire
494→ }
495→ }, EXTRACT_TIMEOUT_MS)
496→
497→ child.stdout?.on('data', (chunk: Buffer) => {
498→ stdout += chunk.toString('utf8')
499→ if (stdout.length > MAX_STDOUT_BYTES) {
500→ try {
501→ child.kill('SIGKILL')
502→ } catch {
503→ // ignore
504→ }
505→ }
506→ })
507→ child.stderr?.on('data', (chunk: Buffer) => {
508→ stderr += chunk.toString('utf8')
509→ })
510→ child.on('error', (err: Error) => done(null, null, err.message))
511→ child.on('close', (code: number | null, signal: string | null) => done(code, signal, null))
512→ })
513→}
514→
515→/** Map flat playlist entries to {url,title,duration} (capped). */
516→function playlistEntries(data: YtDlpInfo, isYoutube: boolean): PlaylistEntry[] {
517→ const entries = Array.isArray(data.entries) ? data.entries : []
518→ const out: PlaylistEntry[] = []
519→ for (const e of entries) {
520→ if (out.length >= MAX_PLAYLIST_ENTRIES) break
521→ let url = typeof e.url === 'string' ? e.url.trim() : ''
522→ if (url && !/^https?:\/\//i.test(url)) {
523→ // some extractors emit bare ids — only youtube ids can be rebuilt
524→ url = isYoutube && /^[\w-]{6,20}$/.test(url) ? `https://www.youtube.com/watch?v=${url}` : ''
525→ }
526→ if (!url && isYoutube && typeof e.id === 'string' && /^[\w-]{6,20}$/.test(e.id)) {
527→ url = `https://www.youtube.com/watch?v=${e.id}`
528→ }
529→ if (!url) continue
530→ const title = cleanText(typeof e.title === 'string' ? e.title : '', 140) || 'Untitled track'
531→ out.push({ url, title, durationSec: typeof e.duration === 'number' ? e.duration : null })
532→ }
533→ return out
534→}
535→
536→// ---- route ----
537→
538→/** GET /api/resolve — which services currently have the operator's shared
539→ * "house" account configured (presence booleans only, never contents).
540→ * The Settings tab + login dialog use this to tell visitors they can
541→ * play those services' links without logging in. */
542→export async function GET() {
543→ return NextResponse.json(
544→ { ok: true, house: houseCookieStatus() },
545→ { headers: { 'Cache-Control': 'no-store' } },
546→ )
547→}
548→
549→export async function POST(request: NextRequest) {
550→ // 1. body
551→ let body: unknown
552→ try {
553→ body = await request.json()
554→ } catch {
555→ return fail(400, 'Expected { url } JSON body.')
556→ }
557→ if (body === null || typeof body !== 'object') {
558→ return fail(400, 'Expected { url } JSON body.')
559→ }
560→ const urlField = (body as { url?: unknown }).url
561→ if (typeof urlField !== 'string') {
562→ return fail(400, 'Expected { url } JSON body.')
563→ }
564→ const raw = urlField.trim()
565→ if (raw.length > MAX_URL_LENGTH) {
566→ return fail(400, 'That link is too long.')
567→ }
568→
569→ // optional visitor login cookie (Settings tab → Music services)
570→ const cookiesField = (body as { cookies?: unknown }).cookies
571→ const cookieServiceField = (body as { cookieService?: unknown }).cookieService
572→ let cookieText: string | null = null
573→ let cookieService: ServiceId | null = null
574→ if (typeof cookiesField === 'string' && cookiesField.trim().length > 0) {
575→ if (cookiesField.length > MAX_COOKIES_LENGTH) {
576→ return fail(400, 'Cookie data is too large.')
577→ }
578→ if (!isServiceId(cookieServiceField)) {
579→ return fail(400, 'Missing music service for the provided login cookie.')
580→ }
581→ cookieText = cookiesField
582→ cookieService = cookieServiceField
583→ }
584→
585→ // 2. SSRF gate on the input URL
586→ let target: URL
587→ try {
588→ target = await assertPublicHttpUrl(raw)
589→ } catch (err) {
590→ return fail(400, err instanceof Error ? err.message : 'That link is not allowed.')
591→ }
592→
593→ // 2b. music-service provider dispatch — URLs owned by a registered
594→ // coming-soon service (Spotify, Apple Music: DRM-locked, yt-dlp can
595→ // never stream them) are answered here with the honest "later update"
596→ // note and NEVER reach the cache, yt-dlp or any extractor process.
597→ // Live service adapters dispatch at this same spot (v6.8+) — see
598→ // src/lib/server/music-providers.ts for the extension recipe.
599→ const provider = providerForUrl(target.href)
600→ if (provider.status === 'coming-soon') {
601→ return NextResponse.json(
602→ { ok: false, code: 'coming-soon', service: provider.id, error: provider.comingSoonNote },
603→ { status: 422 },
604→ )
605→ }
606→
607→ // 3. success cache — keyed by link AND which authentication is in play
608→ // (v6.9: house fallback gets its own key — a house-account resolve can
609→ // succeed where a true anonymous one bot-walls)
610→ const linkService = serviceForUrl(target.href)
611→ const houseText = linkService !== null && cookieService === null ? houseCookieText(linkService) : null
612→ const cacheKey =
613→ target.href + (cookieService ? '|auth' : houseText !== null ? '|house' : '|anon')
614→ const cached = cacheGet(cacheKey)
615→ if (cached) return NextResponse.json(cached)
616→
617→ // 4. direct-audio bypass (never needs cookies)
618→ if (DIRECT_AUDIO_RE.test(target.pathname)) {
619→ const title = directTitle(target)
620→ const token = createStreamEntry(target.href, title, 'direct')
621→ const payload: ResolveSuccess = {
622→ ok: true,
623→ streamUrl: `/api/audio/stream?token=${token}`,
624→ title,
625→ source: 'direct',
626→ durationSec: null,
627→ }
628→ cacheSet(cacheKey, payload)
629→ return NextResponse.json(payload)
630→ }
631→
632→ // 5. yt-dlp extraction — gated by the anti-spam semaphore (at most 2
633→ // concurrent extractions across ALL requests; surplus callers FIFO-
634→ // queue up to 40 s, then get a 503) — with a login cookie staged in a
635→ // private temp file that is removed right after (never logged, never
636→ // stored). v6.9 priority: the visitor's OWN login first; otherwise the
637→ // operator's shared house account (secrets/house-<service>-cookies.txt)
638→ // when one exists for the link's service; only then truly anonymous.
639→ let cookie: CookiePayload | null = null
640→ let cookieFromHouse = false
641→ if (cookieText !== null && cookieService !== null) {
642→ try {
643→ cookie = await stageCookies(cookieText, cookieService)
644→ } catch {
645→ return fail(500, 'Could not stage the login cookie on the server.')
646→ }
647→ if (!cookie) {
648→ return fail(400, 'Could not read that cookie data — paste the Cookie header value or a cookies.txt export.')
649→ }
650→ } else if (linkService !== null && houseText !== null) {
651→ try {
652→ cookie = await stageCookies(houseText, linkService)
653→ } catch {
654→ cookie = null
655→ }
656→ if (cookie) {
657→ cookieFromHouse = true
658→ console.log(`[house-cookie] staged for ${linkService}`)
659→ }
660→ }
661→
662→ const semaphore = ytdlpSemaphore()
663→ let held = false
664→ try {
665→ held = await semaphore.acquire()
666→ if (!held) {
667→ return fail(503, 'The resolver is busy right now — try again in a moment.')
668→ }
669→ const result = await runYtDlp(target.href, cookie ? ['--cookies', cookie.filePath] : [])
670→ if (result.spawnError !== null) {
671→ return fail(500, 'Audio extractor unavailable (yt-dlp not found or failed to start).')
672→ }
673→ if (result.timedOut) {
674→ return fail(504, 'Extraction timed out — try again or use a direct audio link.')
675→ }
676→ if (result.code !== 0) {
677→ const cleanedStderr = stripControlChars(result.stderr).trim()
678→ const tail = cleanedStderr.slice(-300)
679→ if (UNSUPPORTED_RE.test(cleanedStderr)) {
680→ return fail(422, 'That site is not supported — try YouTube, SoundCloud, or a direct MP3 link.')
681→ }
682→ if (BOT_WALL_RE.test(cleanedStderr)) {
683→ // v6.8: code'd bot-wall answer — the client pairs it with an
684→ // "Open Settings" action (login cookies bypass the wall)
685→ return NextResponse.json(
686→ { ok: false, code: 'bot-wall', error: botWallMessage(target, cookieService, cookieFromHouse) },
687→ { status: 422 },
688→ )
689→ }
690→ if (STALE_EXTRACTOR_RE.test(cleanedStderr)) {
691→ // v6.8 self-heal: trigger the background yt-dlp update and tell the
692→ // client to retry (or, post-cooldown, the honest failure answer)
693→ return staleExtractorResponse()
694→ }
695→ return fail(422, `Could not extract audio from that link: ${tail || 'extraction failed.'}`)
696→ }
697→
698→ let data: YtDlpInfo
699→ try {
700→ data = JSON.parse(result.stdout) as YtDlpInfo
701→ } catch {
702→ return fail(422, 'Could not extract audio from that link: extractor returned unexpected output.')
703→ }
704→
705→ const source = ytdlpSource(data, target.hostname)
706→ const isYoutube = /youtube/i.test(source) || /youtu\.be/i.test(target.hostname)
707→
708→ // 5a. playlist / set → flat entry list; the client picks a track
709→ const entries = playlistEntries(data, isYoutube)
710→ if (entries.length > 0 && data._type === 'playlist') {
711→ const playlistTitle =
712→ cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Playlist'
713→ const payload: PlaylistSuccess = {
714→ ok: true,
715→ isPlaylist: true,
716→ title: playlistTitle,
717→ source,
718→ entries,
719→ }
720→ return NextResponse.json(payload)
721→ }
722→
723→ // 5b. single track
724→ const pick = pickStream(data)
725→ if (!pick) {
726→ return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
727→ }
728→
729→ let upstream: URL
730→ try {
731→ upstream = new URL(pick.url)
732→ } catch {
733→ return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
734→ }
735→
736→ if (ADAPTIVE_EXT_RE.test(upstream.pathname) || ADAPTIVE_PROTO_RE.test(pick.protocol)) {
737→ return fail(
738→ 422,
739→ "This source only provides an adaptive (HLS/DASH) stream, which browsers can't play directly — try another link or a direct audio file.",
740→ )
741→ }
742→
743→ // 6. SSRF gate on the resolved stream URL (public CDNs pass)
744→ try {
745→ await assertPublicHttpUrl(upstream.href)
746→ } catch (err) {
747→ return fail(422, err instanceof Error ? err.message : 'That stream host is not allowed.')
748→ }
749→
750→ // 7. build response — forward the Cookie header to the proxy ONLY when
751→ // the upstream host belongs to the same service (no cross-domain
752→ // cookie leakage to CDN hosts)
753→ const proxyHeader =
754→ cookie && headerAllowedForHost(cookie.header, cookie.service, upstream.hostname)
755→ ? cookie.header
756→ : undefined
757→ const title = cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Stream'
758→ const durationSec = typeof data.duration === 'number' ? data.duration : null
759→ const token = createStreamEntry(upstream.href, title, source, proxyHeader)
760→ const payload: ResolveSuccess = {
761→ ok: true,
762→ streamUrl: `/api/audio/stream?token=${token}`,
763→ title,
764→ source,
765→ durationSec,
766→ }
767→ cacheSet(cacheKey, payload)
768→ return NextResponse.json(payload)
769→ } finally {
770→ if (held) {
771→ semaphore.release()
772→ }
773→ if (cookie) {
774→ await rm(cookie.dir, { recursive: true, force: true }).catch(() => {})
775→ }
776→ }
777→}
778→

View file

@ -0,0 +1,778 @@
1→ 1→import { spawn } from 'node:child_process'
2→ 2→import { existsSync } from 'node:fs'
3→ 3→import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4→ 4→import { tmpdir } from 'node:os'
5→ 5→import { join } from 'node:path'
6→ 6→import { NextRequest, NextResponse } from 'next/server'
7→ 7→import { assertPublicHttpUrl } from '@/lib/server/ssrf'
8→ 8→import { createStreamEntry } from '@/lib/server/stream-store'
9→ 9→import { providerForUrl } from '@/lib/server/music-providers'
10→ 10→import {
11→ 11→ triggerYtdlpUpdate,
12→ 12→ ytdlpUpdateCooldownActive,
13→ 13→ ytdlpUpdateRunning,
14→ 14→} from '@/lib/server/ytdlp-selfupdate'
15→ 15→import {
16→ 16→ isServiceId,
17→ 17→ cookiePasteToNetscape,
18→ 18→ serviceDef,
19→ 19→ serviceForUrl,
20→ 20→ type ServiceId,
21→ 21→} from '@/lib/music-services'
22→ 22→import { houseCookieStatus, houseCookieText } from '@/lib/server/house-cookies'
23→ 23→
24→ 24→export const runtime = 'nodejs'
25→ 25→export const dynamic = 'force-dynamic'
26→ 26→
27→ 27→/**
28→ 28→ * POST /api/resolve
29→ 29→ * Body: { "url": string, "cookies"?: string, "cookieService"?: ServiceId }
30→ 30→ * — url: a YouTube / SoundCloud / Bandcamp / direct-file link, or a
31→ 31→ * playlist / set link.
32→ 32→ * — cookies + cookieService (optional): the visitor's own login cookie for
33→ 33→ * the matching music service (from the Settings tab), pasted as a raw
34→ 34→ * Cookie header, a cookies.txt export or a Cookie-Editor JSON export —
35→ 35→ * all normalized by the shared parser in lib/music-services.ts. The
36→ 36→ * cookie is used transiently — written to a 0600 temp file passed to
37→ 37→ * yt-dlp as --cookies, deleted immediately after, never logged and
38→ 38→ * never stored.
39→ 39→ * Playlists are returned as entry lists (flat, fast); the client plays
40→ 40→ * them as a lazy queue — one track resolved at a time, never in bulk.
41→ 41→ * Single tracks resolve to a same-origin proxy URL:
42→ 42→ * { ok, streamUrl, title, source, durationSec }
43→ 43→ * Playlists resolve to { ok, isPlaylist, title, source, entries[] }.
44→ 44→ *
45→ 45→ * v6.7 — anti-spam + music-service framework:
46→ 46→ * — CONCURRENCY GATE: at most 2 yt-dlp extractions run at once across ALL
47→ 47→ * requests (module-level FIFO semaphore, globalThis-backed). Surplus
48→ 48→ * requests wait in line up to 40 s, then get a 503 "busy" answer. The
49→ 49→ * server therefore NEVER bursts parallel extractions at YouTube even
50→ 50→ * with many visitors resolving at the same time — datacenter IPs that
51→ 51→ * hammer the extractor sites get temp-banned. Cache hits and direct-file
52→ 52→ * links bypass the gate entirely.
53→ 53→ * — COMING-SOON DISPATCH: URLs owned by a registered coming-soon service
54→ 54→ * (Spotify, Apple Music — DRM-locked, yt-dlp cannot stream them) are
55→ 55→ * intercepted right after the SSRF gate, BEFORE any cache/direct/yt-dlp
56→ 56→ * work, and answered with
57→ 57→ * { ok: false, code: 'coming-soon', service, error } (422)
58→ 58→ * where `error` carries the visitor-facing "later update" note. Live
59→ 59→ * service adapters dispatch at the same spot — see
60→ 60→ * src/lib/server/music-providers.ts for the extension recipe.
61→ 61→ *
62→ 62→ * v6.8 — self-healing extractor + honest failure codes:
63→ 63→ * — BOT WALLS answer { ok:false, code:'bot-wall' } (422) with the
64→ 64→ * "Google login required. Check the Settings tab to login, then try
65→ 65→ * again." message on YouTube (other services get the same guidance with
66→ 66→ * their name) — the client turns the code into an Open-Settings action.
67→ 67→ * — STALE EXTRACTOR signatures (YouTube rotated their mechanics: nsig
68→ 68→ * failures, "Unable to extract …", python tracebacks, no formats …)
69→ 69→ * trigger a BACKGROUND SELF-UPDATE of bin/yt-dlp (latest GitHub release,
70→ 70→ * verified + atomically swapped — see lib/server/ytdlp-selfupdate.ts)
71→ 71→ * and answer { ok:false, code:'ytdlp-updating' } (503). The client
72→ 72→ * shows "Updating in Background", keeps retrying, and the song picks
73→ 73→ * up seamlessly once the new binary is in.
74→ 74→ * — When an update already ran recently (cooldown) or shipped no new
75→ 75→ * version, stale failures answer
76→ 76→ * { ok:false, code:'backend-error' } (500) with "A back-end problem has
77→ 77→ * occurred. Please try again later."
78→ 78→ */
79→ 79→
80→ 80→const MAX_URL_LENGTH = 2048
81→ 81→const MAX_COOKIES_LENGTH = 65_536
82→ 82→const MAX_PLAYLIST_ENTRIES = 200
83→ 83→const EXTRACT_TIMEOUT_MS = 30_000
84→ 84→const RESOLVE_CACHE_TTL_MS = 10 * 60_000
85→ 85→const MAX_STDOUT_BYTES = 32 * 1024 * 1024
86→ 86→
87→ 87→// ---- yt-dlp concurrency semaphore (v6.7 anti-spam) -------------------------
88→ 88→//
89→ 89→// The user's hard requirement: never hammer YouTube (or any extractor site)
90→ 90→// with parallel/bulk extractions — datacenter IPs that burst requests get
91→ 91→// temp-banned. Every extraction goes through this semaphore: at most
92→ 92→// YTDLP_MAX_CONCURRENCY yt-dlp processes run at once across ALL requests;
93→ 93→// anything beyond that FIFO-queues for up to YTDLP_QUEUE_TIMEOUT_MS and then
94→ 94→// fails with a 503 "busy" answer. Kept on globalThis (like the resolve
95→ 95→// cache) so dev-mode module reloads never reset it mid-flight.
96→ 96→
97→ 97→const YTDLP_MAX_CONCURRENCY = 2
98→ 98→const YTDLP_QUEUE_TIMEOUT_MS = 40_000
99→ 99→
100→ 100→interface SemaphoreWaiter {
101→ 101→ /** Resolves the acquire() promise with true — a slot was granted. */
102→ 102→ grant: () => void
103→ 103→ /** Abandonment timer — race-safe handoff, see acquire(). */
104→ 104→ timer: ReturnType<typeof setTimeout>
105→ 105→}
106→ 106→
107→ 107→class YtdlpSemaphore {
108→ 108→ private active = 0
109→ 109→ private waiters: SemaphoreWaiter[] = []
110→ 110→
111→ 111→ /**
112→ 112→ * Take an extraction slot. Resolves true immediately when a slot is free,
113→ 113→ * false when the FIFO wait exceeded YTDLP_QUEUE_TIMEOUT_MS.
114→ 114→ *
115→ 115→ * Race safety (a timed-out waiter must never double-release a slot it was
116→ 116→ * granted in the same tick): membership in `waiters` is the single source
117→ 117→ * of truth. The timeout callback only abandons the waiter while it is
118→ 118→ * STILL QUEUED (never consumed a slot → no release owed). If release()
119→ 119→ * already shifted the waiter out — the slot was legitimately transferred
120→ 120→ * to it — the callback finds nothing and does nothing; the caller still
121→ 121→ * holds exactly one slot and releases exactly once.
122→ 122→ */
123→ 123→ acquire(): Promise<boolean> {
124→ 124→ // Fast path — but never jump the queue: a non-empty waiter list means
125→ 125→ // every free slot belongs to the head of that line.
126→ 126→ if (this.waiters.length === 0 && this.active < YTDLP_MAX_CONCURRENCY) {
127→ 127→ this.active++
128→ 128→ return Promise.resolve(true)
129→ 129→ }
130→ 130→ return new Promise<boolean>((resolve) => {
131→ 131→ const waiter: SemaphoreWaiter = {
132→ 132→ grant: () => resolve(true),
133→ 133→ timer: setTimeout(() => {
134→ 134→ const idx = this.waiters.indexOf(waiter)
135→ 135→ if (idx === -1) return // slot granted in the same tick — keep it
136→ 136→ this.waiters.splice(idx, 1)
137→ 137→ resolve(false)
138→ 138→ }, YTDLP_QUEUE_TIMEOUT_MS),
139→ 139→ }
140→ 140→ this.waiters.push(waiter)
141→ 141→ })
142→ 142→ }
143→ 143→
144→ 144→ /** Return a slot: hand it straight to the head of the queue (the active
145→ 145→ * count does not change on a handoff), otherwise mark the slot free. */
146→ 146→ release(): void {
147→ 147→ const next = this.waiters.shift()
148→ 148→ if (next) {
149→ 149→ clearTimeout(next.timer)
150→ 150→ next.grant()
151→ 151→ return
152→ 152→ }
153→ 153→ if (this.active > 0) this.active--
154→ 154→ }
155→ 155→}
156→ 156→
157→ 157→function ytdlpSemaphore(): YtdlpSemaphore {
158→ 158→ const g = globalThis as AvaResolveGlobal
159→ 159→ g.__avaYtdlpSemaphore ??= new YtdlpSemaphore()
160→ 160→ return g.__avaYtdlpSemaphore
161→ 161→}
162→ 162→
163→ 163→const YT_DLP_ARGS = [
164→ 164→ '-J',
165→ 165→ '--no-warnings',
166→ 166→ // playlists come back as a fast flat entry list instead of being fully
167→ 167→ // extracted (watch?v=X&list=Y still resolves the single video only)
168→ 168→ '--flat-playlist',
169→ 169→ '--no-playlist',
170→ 170→ '--no-progress',
171→ 171→ '--socket-timeout',
172→ 172→ '15',
173→ 173→ '-f',
174→ 174→ 'bestaudio[protocol^=http]/bestaudio/best',
175→ 175→]
176→ 176→
177→ 177→const DIRECT_AUDIO_RE = /\.(mp3|m4a|aac|ogg|oga|opus|wav|flac|mp4|webm)$/i
178→ 178→const ADAPTIVE_EXT_RE = /\.(m3u8|mpd)$/i
179→ 179→const ADAPTIVE_PROTO_RE = /m3u8|mpd|dash/i
180→ 180→const UNSUPPORTED_RE = /unsupported url/i
181→ 181→/** YouTube's datacenter-IP wall: "Sign in to confirm you're not a bot". */
182→ 182→const BOT_WALL_RE = /sign in to confirm|not a bot|cookies for the authentication/i
183→ 183→
184→ 184→/** Stale-extractor signatures — YouTube (or any site) changed their
185→ 185→ * mechanics and the installed yt-dlp can no longer parse the page:
186→ 186→ * nsig/signature rotation, missing player data, python-level crashes,
187→ 187→ * empty format lists. These trigger the background self-update. */
188→ 188→const STALE_EXTRACTOR_RE =
189→ 189→ /nsig extraction failed|signature extraction failed|unable to extract (?:yt ?initial data|initial player response|player id)|unable to find (?:handler function|function name)|traceback \(most recent call last\)|attributeerror|unboundlocalerror|typeerror|keyerror|indexerror|nameerror|expected string or bytes-like object|object has no attribute|object is not subscriptable|no video formats|requested format is not available|player response is not valid/i
190→ 190→
191→ 191→const BACKEND_ERROR_MESSAGE = 'A back-end problem has occurred. Please try again later.'
192→ 192→const UPDATING_MESSAGE =
193→ 193→ 'Updating in Background — OVE is updating its audio engine on the server and will retry this song automatically.'
194→ 194→
195→ 195→/** Bot-wall answers carry the code 'bot-wall' so the client can offer an
196→ 196→ * "Open Settings" action. YouTube gets the exact Google-login wording. */
197→ 197→function botWallMessage(target: URL, usedCookie: ServiceId | null, houseCookie: boolean): string {
198→ 198→ const isYoutube = /(^|\.)youtube\.com$/i.test(target.hostname) || /(^|\.)youtu\.be$/i.test(target.hostname)
199→ 199→ if (isYoutube) {
200→ 200→ if (usedCookie) {
201→ 201→ return 'Google login required. Check the Settings tab to login, then try again. (Your saved Google login may have expired — re-entering it in Settings usually fixes this.)'
202→ 202→ }
203→ 203→ if (houseCookie) {
204→ 204→ return 'Google login required. Check the Settings tab to login, then try again. (The site’s shared account hit a usage limit — try again in a little while, or log in with your own Google account in Settings.)'
205→ 205→ }
206→ 206→ return 'Google login required. Check the Settings tab to login, then try again.'
207→ 207→ }
208→ 208→ if (houseCookie) {
209→ 209→ return 'This service is asking the server to prove it is not a bot, and the site’s shared account is currently limited. Log in from the Settings tab so your own account is used, then try again.'
210→ 210→ }
211→ 211→ return 'This service is asking the server to prove it is not a bot. Log in from the Settings tab so your own account is used, then try again.'
212→ 212→}
213→ 213→
214→ 214→/** Stale-extractor answer: kick off (or join) the background self-update and
215→ 215→ * tell the client to keep retrying — or, when an update already ran
216→ 216→ * recently, give the honest back-end-problem answer. */
217→ 217→function staleExtractorResponse(): NextResponse {
218→ 218→ if (ytdlpUpdateRunning() || !ytdlpUpdateCooldownActive()) {
219→ 219→ if (!ytdlpUpdateRunning()) triggerYtdlpUpdate()
220→ 220→ return NextResponse.json(
221→ 221→ { ok: false, code: 'ytdlp-updating', error: UPDATING_MESSAGE },
222→ 222→ { status: 503, headers: { 'Retry-After': '10' } },
223→ 223→ )
224→ 224→ }
225→ 225→ // An update ran within the cooldown (it either shipped a new binary that
226→ 226→ // STILL fails, or shipped nothing) — retrying or re-updating is pointless.
227→ 227→ return NextResponse.json(
228→ 228→ { ok: false, code: 'backend-error', error: BACKEND_ERROR_MESSAGE },
229→ 229→ { status: 500 },
230→ 230→ )
231→ 231→}
232→ 232→
233→ 233→interface ResolveSuccess {
234→ 234→ ok: true
235→ 235→ streamUrl: string
236→ 236→ title: string
237→ 237→ source: string
238→ 238→ durationSec: number | null
239→ 239→}
240→ 240→
241→ 241→interface PlaylistEntry {
242→ 242→ url: string
243→ 243→ title: string
244→ 244→ durationSec: number | null
245→ 245→}
246→ 246→
247→ 247→interface PlaylistSuccess {
248→ 248→ ok: true
249→ 249→ isPlaylist: true
250→ 250→ title: string
251→ 251→ source: string
252→ 252→ entries: PlaylistEntry[]
253→ 253→}
254→ 254→
255→ 255→interface YtDlpFormat {
256→ 256→ url?: unknown
257→ 257→ protocol?: unknown
258→ 258→}
259→ 259→
260→ 260→interface YtDlpEntry {
261→ 261→ url?: unknown
262→ 262→ id?: unknown
263→ 263→ title?: unknown
264→ 264→ duration?: unknown
265→ 265→}
266→ 266→
267→ 267→interface YtDlpInfo {
268→ 268→ _type?: unknown
269→ 269→ url?: unknown
270→ 270→ protocol?: unknown
271→ 271→ title?: unknown
272→ 272→ duration?: unknown
273→ 273→ extractor?: unknown
274→ 274→ extractor_key?: unknown
275→ 275→ entries?: YtDlpEntry[] | null
276→ 276→ formats?: YtDlpFormat[] | null
277→ 277→ requested_formats?: YtDlpFormat[] | null
278→ 278→}
279→ 279→
280→ 280→interface YtDlpResult {
281→ 281→ code: number | null
282→ 282→ signal: string | null
283→ 283→ stdout: string
284→ 284→ stderr: string
285→ 285→ timedOut: boolean
286→ 286→ spawnError: string | null
287→ 287→}
288→ 288→
289→ 289→// ---- success cache (globalThis so dev-mode module reloads keep it) ----
290→ 290→
291→ 291→interface ResolveCacheEntry {
292→ 292→ payload: ResolveSuccess
293→ 293→ expires: number
294→ 294→}
295→ 295→
296→ 296→interface AvaResolveGlobal {
297→ 297→ __avaResolveCache?: Map<string, ResolveCacheEntry>
298→ 298→ __avaYtdlpSemaphore?: YtdlpSemaphore
299→ 299→}
300→ 300→
301→ 301→function resolveCache(): Map<string, ResolveCacheEntry> {
302→ 302→ const g = globalThis as AvaResolveGlobal
303→ 303→ g.__avaResolveCache ??= new Map<string, ResolveCacheEntry>()
304→ 304→ return g.__avaResolveCache
305→ 305→}
306→ 306→
307→ 307→function cacheGet(key: string): ResolveSuccess | null {
308→ 308→ const map = resolveCache()
309→ 309→ const now = Date.now()
310→ 310→ for (const [k, v] of map) {
311→ 311→ if (v.expires <= now) map.delete(k)
312→ 312→ }
313→ 313→ const hit = map.get(key)
314→ 314→ return hit ? hit.payload : null
315→ 315→}
316→ 316→
317→ 317→function cacheSet(key: string, payload: ResolveSuccess): void {
318→ 318→ resolveCache().set(key, { payload, expires: Date.now() + RESOLVE_CACHE_TTL_MS })
319→ 319→}
320→ 320→
321→ 321→// ---- helpers ----
322→ 322→
323→ 323→function fail(status: number, error: string): NextResponse {
324→ 324→ return NextResponse.json({ ok: false, error }, { status })
325→ 325→}
326→ 326→
327→ 327→/** yt-dlp binary: env override → project-local bin/ → PATH. */
328→ 328→function ytdlpPath(): string {
329→ 329→ const fromEnv = process.env.AVA_YTDLP
330→ 330→ if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv
331→ 331→ const local = join(process.cwd(), 'bin', 'yt-dlp')
332→ 332→ if (existsSync(local)) return local
333→ 333→ return 'yt-dlp'
334→ 334→}
335→ 335→
336→ 336→/** Strip ANSI escapes + all other control chars (user-safe text). */
337→ 337→function stripControlChars(input: string): string {
338→ 338→ let out = ''
339→ 339→ let i = 0
340→ 340→ while (i < input.length) {
341→ 341→ const code = input.charCodeAt(i)
342→ 342→ if (code === 0x1b) {
343→ 343→ const next = input[i + 1] ?? ''
344→ 344→ if (next === '[') {
345→ 345→ let j = i + 2
346→ 346→ while (j < input.length && '0123456789;?'.includes(input[j] ?? '')) j++
347→ 347→ j++ // final byte of the CSI sequence
348→ 348→ i = j
349→ 349→ } else {
350→ 350→ i += next === '' ? 1 : 2 // lone ESC or ESC <char>
351→ 351→ }
352→ 352→ continue
353→ 353→ }
354→ 354→ if (code < 0x20 || code === 0x7f) {
355→ 355→ i++
356→ 356→ continue
357→ 357→ }
358→ 358→ out += input[i]
359→ 359→ i++
360→ 360→ }
361→ 361→ return out
362→ 362→}
363→ 363→
364→ 364→function cleanText(input: string, max: number): string {
365→ 365→ return stripControlChars(input).trim().slice(0, max)
366→ 366→}
367→ 367→
368→ 368→/** Last path segment, decodeURIComponent'd; hostname as fallback. */
369→ 369→function directTitle(url: URL): string {
370→ 370→ const segments = url.pathname.split('/').filter((s) => s.length > 0)
371→ 371→ const last = segments.length > 0 ? segments[segments.length - 1] : ''
372→ 372→ let decoded = last
373→ 373→ if (last) {
374→ 374→ try {
375→ 375→ decoded = decodeURIComponent(last)
376→ 376→ } catch {
377→ 377→ decoded = last
378→ 378→ }
379→ 379→ }
380→ 380→ const cleaned = cleanText(decoded, 140)
381→ 381→ return cleaned.length > 0 ? cleaned : url.hostname
382→ 382→}
383→ 383→
384→ 384→/** data.url → requested_formats[0] → last http-protocol format. */
385→ 385→function pickStream(data: YtDlpInfo): { url: string; protocol: string } | null {
386→ 386→ if (typeof data.url === 'string' && data.url) {
387→ 387→ return { url: data.url, protocol: typeof data.protocol === 'string' ? data.protocol : '' }
388→ 388→ }
389→ 389→ const requested = Array.isArray(data.requested_formats) ? data.requested_formats : []
390→ 390→ const first = requested[0]
391→ 391→ if (first && typeof first.url === 'string' && first.url) {
392→ 392→ return { url: first.url, protocol: typeof first.protocol === 'string' ? first.protocol : '' }
393→ 393→ }
394→ 394→ const formats = Array.isArray(data.formats) ? data.formats : []
395→ 395→ let last: { url: string; protocol: string } | null = null
396→ 396→ for (const f of formats) {
397→ 397→ const u = typeof f.url === 'string' ? f.url : ''
398→ 398→ const p = typeof f.protocol === 'string' ? f.protocol : ''
399→ 399→ if (u && p.startsWith('http')) last = { url: u, protocol: p }
400→ 400→ }
401→ 401→ return last
402→ 402→}
403→ 403→
404→ 404→function ytdlpSource(data: YtDlpInfo, fallbackHost: string): string {
405→ 405→ const key = typeof data.extractor_key === 'string' ? data.extractor_key : ''
406→ 406→ const ex = typeof data.extractor === 'string' ? data.extractor : ''
407→ 407→ return (key || ex || fallbackHost).toLowerCase()
408→ 408→}
409→ 409→
410→ 410→// ---- visitor cookie handling (transient, never persisted) --------------------
411→ 411→
412→ 412→interface CookiePayload {
413→ 413→ /** path of the 0600 temp cookies.txt for yt-dlp --cookies */
414→ 414→ filePath: string
415→ 415→ /** temp dir to remove after the request */
416→ 416→ dir: string
417→ 417→ /** "name=value; …" header — forwarded to the stream proxy ONLY when the
418→ 418→ * resolved upstream host belongs to the same service domain. */
419→ 419→ header: string
420→ 420→ service: ServiceId
421→ 421→}
422→ 422→
423→ 423→/**
424→ 424→ * Normalize pasted cookie data into a Netscape cookie file body + a Cookie
425→ 425→ * request header. Shared with the login dialog's live validation:
426→ 426→ * cookiePasteToNetscape() in lib/music-services.ts (v6.8.1) parses raw
427→ 427→ * "k=v; k2=v2" headers, Netscape cookies.txt exports AND Cookie-Editor JSON
428→ 428→ * exports — one parser on both sides so they can never drift apart.
429→ 429→ * Returns null when nothing parseable was pasted.
430→ 430→ */
431→ 431→
432→ 432→/** Write the normalized cookie text to a private temp file for yt-dlp. */
433→ 433→async function stageCookies(text: string, service: ServiceId): Promise<CookiePayload | null> {
434→ 434→ const parts = cookiePasteToNetscape(text, service)
435→ 435→ if (!parts) return null
436→ 436→ const dir = await mkdtemp(join(tmpdir(), 'ove-cookies-'))
437→ 437→ const filePath = join(dir, 'cookies.txt')
438→ 438→ await writeFile(filePath, parts.netscape, { mode: 0o600 })
439→ 439→ return { filePath, dir, header: parts.header, service }
440→ 440→}
441→ 441→
442→ 442→/** True when the resolved stream host belongs to the login's service. */
443→ 443→function headerAllowedForHost(header: string, service: ServiceId, host: string): boolean {
444→ 444→ if (!header) return false
445→ 445→ const root = serviceDef(service).cookieDomain.replace(/^\./, '')
446→ 446→ const h = host.toLowerCase()
447→ 447→ return h === root || h.endsWith(`.${root}`)
448→ 448→}
449→ 449→
450→ 450→/** Run yt-dlp, killing it after EXTRACT_TIMEOUT_MS.
451→ 451→ * v6.9: the project bin/ dir is prepended to PATH so yt-dlp finds the
452→ 452→ * Deno runtime (bin/deno) — YouTube's signature/n-challenge solving for
453→ 453→ * cookie-authenticated extractions requires a JS runtime (EJS); without
454→ 454→ * it, authenticated requests return storyboards only ("Requested format
455→ 455→ * is not available"). */
456→ 456→function ytdlpEnv(): NodeJS.ProcessEnv {
457→ 457→ const binDir = join(process.cwd(), 'bin')
458→ 458→ const path = process.env.PATH ?? ''
459→ 459→ return { ...process.env, PATH: path.split(':').includes(binDir) ? path : `${binDir}:${path}` }
460→ 460→}
461→ 461→
462→ 462→function runYtDlp(url: string, extraArgs: string[]): Promise<YtDlpResult> {
463→ 463→ return new Promise((resolve) => {
464→ 464→ let stdout = ''
465→ 465→ let stderr = ''
466→ 466→ let timedOut = false
467→ 467→ let settled = false
468→ 468→ let timer: ReturnType<typeof setTimeout> | undefined
469→ 469→
470→ 470→ const done = (code: number | null, signal: string | null, spawnError: string | null): void => {
471→ 471→ if (settled) return
472→ 472→ settled = true
473→ 473→ if (timer !== undefined) clearTimeout(timer)
474→ 474→ resolve({ code, signal, stdout, stderr, timedOut, spawnError })
475→ 475→ }
476→ 476→
477→ 477→ let child: ReturnType<typeof spawn>
478→ 478→ try {
479→ 479→ child = spawn(ytdlpPath(), [...YT_DLP_ARGS, ...extraArgs, url], {
480→ 480→ stdio: ['ignore', 'pipe', 'pipe'],
481→ 481→ env: ytdlpEnv(),
482→ 482→ })
483→ 483→ } catch (err) {
484→ 484→ done(null, null, err instanceof Error ? err.message : 'failed to start yt-dlp')
485→ 485→ return
486→ 486→ }
487→ 487→
488→ 488→ timer = setTimeout(() => {
489→ 489→ timedOut = true
490→ 490→ try {
491→ 491→ child.kill('SIGKILL')
492→ 492→ } catch {
493→ 493→ // already gone — 'close' will still fire
494→ 494→ }
495→ 495→ }, EXTRACT_TIMEOUT_MS)
496→ 496→
497→ 497→ child.stdout?.on('data', (chunk: Buffer) => {
498→ 498→ stdout += chunk.toString('utf8')
499→ 499→ if (stdout.length > MAX_STDOUT_BYTES) {
500→ 500→ try {
501→ 501→ child.kill('SIGKILL')
502→ 502→ } catch {
503→ 503→ // ignore
504→ 504→ }
505→ 505→ }
506→ 506→ })
507→ 507→ child.stderr?.on('data', (chunk: Buffer) => {
508→ 508→ stderr += chunk.toString('utf8')
509→ 509→ })
510→ 510→ child.on('error', (err: Error) => done(null, null, err.message))
511→ 511→ child.on('close', (code: number | null, signal: string | null) => done(code, signal, null))
512→ 512→ })
513→ 513→}
514→ 514→
515→ 515→/** Map flat playlist entries to {url,title,duration} (capped). */
516→ 516→function playlistEntries(data: YtDlpInfo, isYoutube: boolean): PlaylistEntry[] {
517→ 517→ const entries = Array.isArray(data.entries) ? data.entries : []
518→ 518→ const out: PlaylistEntry[] = []
519→ 519→ for (const e of entries) {
520→ 520→ if (out.length >= MAX_PLAYLIST_ENTRIES) break
521→ 521→ let url = typeof e.url === 'string' ? e.url.trim() : ''
522→ 522→ if (url && !/^https?:\/\//i.test(url)) {
523→ 523→ // some extractors emit bare ids — only youtube ids can be rebuilt
524→ 524→ url = isYoutube && /^[\w-]{6,20}$/.test(url) ? `https://www.youtube.com/watch?v=${url}` : ''
525→ 525→ }
526→ 526→ if (!url && isYoutube && typeof e.id === 'string' && /^[\w-]{6,20}$/.test(e.id)) {
527→ 527→ url = `https://www.youtube.com/watch?v=${e.id}`
528→ 528→ }
529→ 529→ if (!url) continue
530→ 530→ const title = cleanText(typeof e.title === 'string' ? e.title : '', 140) || 'Untitled track'
531→ 531→ out.push({ url, title, durationSec: typeof e.duration === 'number' ? e.duration : null })
532→ 532→ }
533→ 533→ return out
534→ 534→}
535→ 535→
536→ 536→// ---- route ----
537→ 537→
538→ 538→/** GET /api/resolve — which services currently have the operator's shared
539→ 539→ * "house" account configured (presence booleans only, never contents).
540→ 540→ * The Settings tab + login dialog use this to tell visitors they can
541→ 541→ * play those services' links without logging in. */
542→ 542→export async function GET() {
543→ 543→ return NextResponse.json(
544→ 544→ { ok: true, house: houseCookieStatus() },
545→ 545→ { headers: { 'Cache-Control': 'no-store' } },
546→ 546→ )
547→ 547→}
548→ 548→
549→ 549→export async function POST(request: NextRequest) {
550→ 550→ // 1. body
551→ 551→ let body: unknown
552→ 552→ try {
553→ 553→ body = await request.json()
554→ 554→ } catch {
555→ 555→ return fail(400, 'Expected { url } JSON body.')
556→ 556→ }
557→ 557→ if (body === null || typeof body !== 'object') {
558→ 558→ return fail(400, 'Expected { url } JSON body.')
559→ 559→ }
560→ 560→ const urlField = (body as { url?: unknown }).url
561→ 561→ if (typeof urlField !== 'string') {
562→ 562→ return fail(400, 'Expected { url } JSON body.')
563→ 563→ }
564→ 564→ const raw = urlField.trim()
565→ 565→ if (raw.length > MAX_URL_LENGTH) {
566→ 566→ return fail(400, 'That link is too long.')
567→ 567→ }
568→ 568→
569→ 569→ // optional visitor login cookie (Settings tab → Music services)
570→ 570→ const cookiesField = (body as { cookies?: unknown }).cookies
571→ 571→ const cookieServiceField = (body as { cookieService?: unknown }).cookieService
572→ 572→ let cookieText: string | null = null
573→ 573→ let cookieService: ServiceId | null = null
574→ 574→ if (typeof cookiesField === 'string' && cookiesField.trim().length > 0) {
575→ 575→ if (cookiesField.length > MAX_COOKIES_LENGTH) {
576→ 576→ return fail(400, 'Cookie data is too large.')
577→ 577→ }
578→ 578→ if (!isServiceId(cookieServiceField)) {
579→ 579→ return fail(400, 'Missing music service for the provided login cookie.')
580→ 580→ }
581→ 581→ cookieText = cookiesField
582→ 582→ cookieService = cookieServiceField
583→ 583→ }
584→ 584→
585→ 585→ // 2. SSRF gate on the input URL
586→ 586→ let target: URL
587→ 587→ try {
588→ 588→ target = await assertPublicHttpUrl(raw)
589→ 589→ } catch (err) {
590→ 590→ return fail(400, err instanceof Error ? err.message : 'That link is not allowed.')
591→ 591→ }
592→ 592→
593→ 593→ // 2b. music-service provider dispatch — URLs owned by a registered
594→ 594→ // coming-soon service (Spotify, Apple Music: DRM-locked, yt-dlp can
595→ 595→ // never stream them) are answered here with the honest "later update"
596→ 596→ // note and NEVER reach the cache, yt-dlp or any extractor process.
597→ 597→ // Live service adapters dispatch at this same spot (v6.8+) — see
598→ 598→ // src/lib/server/music-providers.ts for the extension recipe.
599→ 599→ const provider = providerForUrl(target.href)
600→ 600→ if (provider.status === 'coming-soon') {
601→ 601→ return NextResponse.json(
602→ 602→ { ok: false, code: 'coming-soon', service: provider.id, error: provider.comingSoonNote },
603→ 603→ { status: 422 },
604→ 604→ )
605→ 605→ }
606→ 606→
607→ 607→ // 3. success cache — keyed by link AND which authentication is in play
608→ 608→ // (v6.9: house fallback gets its own key — a house-account resolve can
609→ 609→ // succeed where a true anonymous one bot-walls)
610→ 610→ const linkService = serviceForUrl(target.href)
611→ 611→ const houseText = linkService !== null && cookieService === null ? houseCookieText(linkService) : null
612→ 612→ const cacheKey =
613→ 613→ target.href + (cookieService ? '|auth' : houseText !== null ? '|house' : '|anon')
614→ 614→ const cached = cacheGet(cacheKey)
615→ 615→ if (cached) return NextResponse.json(cached)
616→ 616→
617→ 617→ // 4. direct-audio bypass (never needs cookies)
618→ 618→ if (DIRECT_AUDIO_RE.test(target.pathname)) {
619→ 619→ const title = directTitle(target)
620→ 620→ const token = createStreamEntry(target.href, title, 'direct')
621→ 621→ const payload: ResolveSuccess = {
622→ 622→ ok: true,
623→ 623→ streamUrl: `/api/audio/stream?token=${token}`,
624→ 624→ title,
625→ 625→ source: 'direct',
626→ 626→ durationSec: null,
627→ 627→ }
628→ 628→ cacheSet(cacheKey, payload)
629→ 629→ return NextResponse.json(payload)
630→ 630→ }
631→ 631→
632→ 632→ // 5. yt-dlp extraction — gated by the anti-spam semaphore (at most 2
633→ 633→ // concurrent extractions across ALL requests; surplus callers FIFO-
634→ 634→ // queue up to 40 s, then get a 503) — with a login cookie staged in a
635→ 635→ // private temp file that is removed right after (never logged, never
636→ 636→ // stored). v6.9 priority: the visitor's OWN login first; otherwise the
637→ 637→ // operator's shared house account (secrets/house-<service>-cookies.txt)
638→ 638→ // when one exists for the link's service; only then truly anonymous.
639→ 639→ let cookie: CookiePayload | null = null
640→ 640→ let cookieFromHouse = false
641→ 641→ if (cookieText !== null && cookieService !== null) {
642→ 642→ try {
643→ 643→ cookie = await stageCookies(cookieText, cookieService)
644→ 644→ } catch {
645→ 645→ return fail(500, 'Could not stage the login cookie on the server.')
646→ 646→ }
647→ 647→ if (!cookie) {
648→ 648→ return fail(400, 'Could not read that cookie data — paste the Cookie header value or a cookies.txt export.')
649→ 649→ }
650→ 650→ } else if (linkService !== null && houseText !== null) {
651→ 651→ try {
652→ 652→ cookie = await stageCookies(houseText, linkService)
653→ 653→ } catch {
654→ 654→ cookie = null
655→ 655→ }
656→ 656→ if (cookie) {
657→ 657→ cookieFromHouse = true
658→ 658→ console.log(`[house-cookie] staged for ${linkService}`)
659→ 659→ }
660→ 660→ }
661→ 661→
662→ 662→ const semaphore = ytdlpSemaphore()
663→ 663→ let held = false
664→ 664→ try {
665→ 665→ held = await semaphore.acquire()
666→ 666→ if (!held) {
667→ 667→ return fail(503, 'The resolver is busy right now — try again in a moment.')
668→ 668→ }
669→ 669→ const result = await runYtDlp(target.href, cookie ? ['--cookies', cookie.filePath] : [])
670→ 670→ if (result.spawnError !== null) {
671→ 671→ return fail(500, 'Audio extractor unavailable (yt-dlp not found or failed to start).')
672→ 672→ }
673→ 673→ if (result.timedOut) {
674→ 674→ return fail(504, 'Extraction timed out — try again or use a direct audio link.')
675→ 675→ }
676→ 676→ if (result.code !== 0) {
677→ 677→ const cleanedStderr = stripControlChars(result.stderr).trim()
678→ 678→ const tail = cleanedStderr.slice(-300)
679→ 679→ if (UNSUPPORTED_RE.test(cleanedStderr)) {
680→ 680→ return fail(422, 'That site is not supported — try YouTube, SoundCloud, or a direct MP3 link.')
681→ 681→ }
682→ 682→ if (BOT_WALL_RE.test(cleanedStderr)) {
683→ 683→ // v6.8: code'd bot-wall answer — the client pairs it with an
684→ 684→ // "Open Settings" action (login cookies bypass the wall)
685→ 685→ return NextResponse.json(
686→ 686→ { ok: false, code: 'bot-wall', error: botWallMessage(target, cookieService, cookieFromHouse) },
687→ 687→ { status: 422 },
688→ 688→ )
689→ 689→ }
690→ 690→ if (STALE_EXTRACTOR_RE.test(cleanedStderr)) {
691→ 691→ // v6.8 self-heal: trigger the background yt-dlp update and tell the
692→ 692→ // client to retry (or, post-cooldown, the honest failure answer)
693→ 693→ return staleExtractorResponse()
694→ 694→ }
695→ 695→ return fail(422, `Could not extract audio from that link: ${tail || 'extraction failed.'}`)
696→ 696→ }
697→ 697→
698→ 698→ let data: YtDlpInfo
699→ 699→ try {
700→ 700→ data = JSON.parse(result.stdout) as YtDlpInfo
701→ 701→ } catch {
702→ 702→ return fail(422, 'Could not extract audio from that link: extractor returned unexpected output.')
703→ 703→ }
704→ 704→
705→ 705→ const source = ytdlpSource(data, target.hostname)
706→ 706→ const isYoutube = /youtube/i.test(source) || /youtu\.be/i.test(target.hostname)
707→ 707→
708→ 708→ // 5a. playlist / set → flat entry list; the client picks a track
709→ 709→ const entries = playlistEntries(data, isYoutube)
710→ 710→ if (entries.length > 0 && data._type === 'playlist') {
711→ 711→ const playlistTitle =
712→ 712→ cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Playlist'
713→ 713→ const payload: PlaylistSuccess = {
714→ 714→ ok: true,
715→ 715→ isPlaylist: true,
716→ 716→ title: playlistTitle,
717→ 717→ source,
718→ 718→ entries,
719→ 719→ }
720→ 720→ return NextResponse.json(payload)
721→ 721→ }
722→ 722→
723→ 723→ // 5b. single track
724→ 724→ const pick = pickStream(data)
725→ 725→ if (!pick) {
726→ 726→ return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
727→ 727→ }
728→ 728→
729→ 729→ let upstream: URL
730→ 730→ try {
731→ 731→ upstream = new URL(pick.url)
732→ 732→ } catch {
733→ 733→ return fail(422, 'Could not extract audio from that link: no direct stream URL found.')
734→ 734→ }
735→ 735→
736→ 736→ if (ADAPTIVE_EXT_RE.test(upstream.pathname) || ADAPTIVE_PROTO_RE.test(pick.protocol)) {
737→ 737→ return fail(
738→ 738→ 422,
739→ 739→ "This source only provides an adaptive (HLS/DASH) stream, which browsers can't play directly — try another link or a direct audio file.",
740→ 740→ )
741→ 741→ }
742→ 742→
743→ 743→ // 6. SSRF gate on the resolved stream URL (public CDNs pass)
744→ 744→ try {
745→ 745→ await assertPublicHttpUrl(upstream.href)
746→ 746→ } catch (err) {
747→ 747→ return fail(422, err instanceof Error ? err.message : 'That stream host is not allowed.')
748→ 748→ }
749→ 749→
750→ 750→ // 7. build response — forward the Cookie header to the proxy ONLY when
751→ 751→ // the upstream host belongs to the same service (no cross-domain
752→ 752→ // cookie leakage to CDN hosts)
753→ 753→ const proxyHeader =
754→ 754→ cookie && headerAllowedForHost(cookie.header, cookie.service, upstream.hostname)
755→ 755→ ? cookie.header
756→ 756→ : undefined
757→ 757→ const title = cleanText(typeof data.title === 'string' ? data.title : '', 140) || 'Stream'
758→ 758→ const durationSec = typeof data.duration === 'number' ? data.duration : null
759→ 759→ const token = createStreamEntry(upstream.href, title, source, proxyHeader)
760→ 760→ const payload: ResolveSuccess = {
761→ 761→ ok: true,
762→ 762→ streamUrl: `/api/audio/stream?token=${token}`,
763→ 763→ title,
764→ 764→ source,
765→ 765→ durationSec,
766→ 766→ }
767→ 767→ cacheSet(cacheKey, payload)
768→ 768→ return NextResponse.json(payload)
769→ 769→ } finally {
770→ 770→ if (held) {
771→ 771→ semaphore.release()
772→ 772→ }
773→ 773→ if (cookie) {
774→ 774→ await rm(cookie.dir, { recursive: true, force: true }).catch(() => {})
775→ 775→ }
776→ 776→ }
777→ 777→}
778→ 778→

174
tools/gen-favicon.ts Executable file
View file

@ -0,0 +1,174 @@
// tools/gen-favicon.ts — rasterize the simple OVE logo mark into PNG favicons.
//
// The mark is the same geometry as public/logo.svg and the header logo block:
// a light-yellow rounded square with the deep royal-purple audio-waveform
// wave (the lucide audio-waveform glyph) on top. No image libraries are
// available in this sandbox, so this script rasterizes the shapes directly
// (supersampled distance fields) and encodes PNGs by hand (zlib via
// node:zlib).
//
// Run: bun tools/gen-favicon.ts (writes public/favicon-16.png,
// public/favicon-32.png, public/apple-touch-icon.png)
import { deflateSync } from 'node:zlib'
import { writeFileSync } from 'node:fs'
const BG = [0xfd, 0xe0, 0x47] // light yellow — the logo square
const FG = [0x24, 0x12, 0x38] // deep royal purple — the wave glyph
/** Signed distance to a rounded rectangle (negative inside). */
function roundedRectDist(
x: number,
y: number,
cx: number,
cy: number,
hw: number,
hh: number,
r: number,
): number {
const dx = Math.abs(x - cx) - (hw - r)
const dy = Math.abs(y - cy) - (hh - r)
const ax = Math.max(dx, 0)
const ay = Math.max(dy, 0)
return Math.hypot(ax, ay) + Math.min(Math.max(dx, dy), 0) - r
}
// The lucide audio-waveform centerline decoded from its path
// (M2 13 … 22 11 in a 24×24 box): a continuous wave — up to a peak, down to
// a trough, up to the tall peak, down, and out. Rounded joins/caps come
// free from the min-distance-to-polyline rasterizer below.
const WAVE: ReadonlyArray<readonly [number, number]> = [
[2, 13],
[4, 11],
[4, 7],
[6, 5],
[8, 7],
[8, 20],
[10, 22],
[12, 20],
[12, 4],
[14, 2],
[16, 4],
[16, 17],
[18, 19],
[20, 17],
[20, 13],
[22, 11],
]
// 24-unit icon space → 64-unit tile: scale 2.4, centered with offset 8
// (icon spans 2..22 → 12.8..60.8, stroke ±2.4 stays inside the tile).
const S = 2.4
const OFF = 8
const HALF_STROKE = 2.4
/** Min distance from (x,y) to the wave polyline, in 64-unit space. */
function distToWave(x: number, y: number): number {
let min = Infinity
const px = (i: number) => OFF + WAVE[i][0] * S
const py = (i: number) => OFF + WAVE[i][1] * S
for (let i = 0; i + 1 < WAVE.length; i++) {
const ax = px(i)
const ay = py(i)
const bx = px(i + 1)
const by = py(i + 1)
const dx = bx - ax
const dy = by - ay
const len2 = dx * dx + dy * dy
let t = len2 > 0 ? ((x - ax) * dx + (y - ay) * dy) / len2 : 0
t = Math.max(0, Math.min(1, t))
const d = Math.hypot(x - (ax + t * dx), y - (ay + t * dy))
if (d < min) min = d
}
return min
}
/** Render the mark at `size`×`size` RGBA (supersampled 4×4 per pixel). */
function render(size: number): Buffer {
const k = size / 64 // 64-unit space → pixel space
const ss = 4
const total = ss * ss
const out = Buffer.alloc(size * (1 + size * 4))
for (let py = 0; py < size; py++) {
const rowStart = py * (1 + size * 4)
out[rowStart] = 0 // PNG filter: none
for (let px = 0; px < size; px++) {
let bgCov = 0
let fgCov = 0
for (let sy = 0; sy < ss; sy++) {
for (let sx = 0; sx < ss; sx++) {
const x = (px + (sx + 0.5) / ss) / k
const y = (py + (sy + 0.5) / ss) / k
if (roundedRectDist(x, y, 32, 32, 32, 32, 14) < 0) {
bgCov++
if (distToWave(x, y) <= HALF_STROKE) fgCov++
}
}
}
const aBg = bgCov / total
const aFg = fgCov / total
// wave sits inside the square: glyph over square over transparent
const o = rowStart + 1 + px * 4
out[o] = Math.round(BG[0] * (aBg - aFg) + FG[0] * aFg)
out[o + 1] = Math.round(BG[1] * (aBg - aFg) + FG[1] * aFg)
out[o + 2] = Math.round(BG[2] * (aBg - aFg) + FG[2] * aFg)
out[o + 3] = Math.round(255 * aBg)
}
}
return out
}
// ---- minimal PNG encoder ----------------------------------------------------
const CRC_TABLE = (() => {
const t = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
t[n] = c >>> 0
}
return t
})()
function crc32(buf: Buffer): number {
let c = 0xffffffff
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8)
return (c ^ 0xffffffff) >>> 0
}
function chunk(type: string, data: Buffer): Buffer {
const len = Buffer.alloc(4)
len.writeUInt32BE(data.length)
const t = Buffer.from(type, 'ascii')
const crc = Buffer.alloc(4)
crc.writeUInt32BE(crc32(Buffer.concat([t, data])))
return Buffer.concat([len, t, data, crc])
}
function png(size: number, rgba: Buffer): Buffer {
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(size, 0)
ihdr.writeUInt32BE(size, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type RGBA
const idat = deflateSync(rgba, { level: 9 })
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr),
chunk('IDAT', idat),
chunk('IEND', Buffer.alloc(0)),
])
}
// ---- generate ---------------------------------------------------------------
const targets: [string, number][] = [
['public/favicon-16.png', 16],
['public/favicon-32.png', 32],
['public/apple-touch-icon.png', 180],
]
for (const [path, size] of targets) {
writeFileSync(path, png(size, render(size)))
console.log(`wrote ${path} (${size}x${size})`)
}

View file

@ -1303,3 +1303,818 @@ Stage Summary:
- New styling rule going forward: keep using zinc/emerald utilities (they are
the remap surface); on yellow CTAs always text-emerald-950; for accent glows
use the .glow-* classes, never rgba() literals.
---
Task ID: 16
Agent: main (Z.ai Code)
Task: v6.5 — restore from v6.4 archive, OVE logo favicon, Settings tab
(replaces parked Native tab) with theme picker: base chrome (Royal Purple /
Matte Black / Black + Purple Outline / Light) × highlight color (Purple,
Yellow, Blue, Green, Red, Orange)
Work Log:
- Downloaded the v6.4 archive from Google Drive (142MB tar.gz, includes
.git) and confirmed the workspace had been rebuilt from a v6.0 snapshot
(v6.1–v6.4 commits missing — "nerfed again"). Restored with rsync
(--delete, excluding node_modules/.next/dev.log/upload mount), verified
git log shows v6.1→v6.4 commits, bun install + prisma generate, dev
server up on 3000, lint clean.
- FAVICON: public/logo.svg rewritten as the simple OVE logo mark (yellow
rounded square + deep royal-purple audio-waveform wave, matching the
header logo block under the default theme). tools/gen-favicon.ts —
dependency-free rasterizer (SDF supersampling + hand-rolled PNG encoder
via node:zlib) — generates public/favicon-16.png, favicon-32.png and
apple-touch-icon.png (180). layout.tsx metadata now references the PNGs
(+ SVG).
- THEME SYSTEM (two independent axes, per-visitor, browser-local):
- BASE via next-themes class on <html>: 'purple' (v6.4 default),
'matte' (neutral matte black), 'outline' (pure black + accent-tinted
outlines via --accent-border), 'light' (white). globals.css remaps the
zinc ramp + neutral semantic tokens per base; @custom-variant dark now
matches any dark base.
- ACCENT via data-accent attribute: 6 blocks (purple/yellow/blue/green/
red/orange) own the emerald-* highlight ramp, --primary/--ring,
chart-1, --accent-rgb (glow utilities) and --accent-border(-strong);
.light[data-accent=…] blocks deepen 300/400/500 + rgb for white bg.
- layout.tsx boot script (runs before next-themes' script): remaps a
stored v6.3/v6.4 'dark' theme to 'purple' and applies the stored
accent (allowlisted) pre-paint. ThemeProvider: defaultTheme="purple",
themes=[purple,matte,outline,light].
- src/lib/theme.ts (new): base/accent metadata + swatches, applyAccent()
(localStorage 'ove.theme.accent' + data-accent attr), getStoredDarkBase/
storeDarkBase ('ove.theme.darkBase') for the header tick.
- header.tsx ThemeToggle: flipping left now returns to the visitor's
selected dark base (was hardcoded 'dark').
- settings-tab.tsx (new): "Appearance" section — 4 base-theme preview
cards (static base swatch + live accent chip) and 6 highlight dots with
check + ring on selection; picking "Black + Purple Outline" also pairs
the purple highlight (its signature look); Reset to defaults button;
footnote that the Native tab is parked. Accent state via
useSyncExternalStore + MutationObserver on data-accent (no
setState-in-effect — satisfies the new react-hooks lint rule).
- control-panel.tsx: Settings takes the 5th tab slot; NativeTab is parked
behind NATIVE_TAB_ENABLED=false (flip to restore — tab row auto-widens
to 6 cols; component + /api/rust-scaffold untouched).
- version.ts → 6.5 (title/footer stamps follow).
- VERIFIED with agent-browser (desktop 1440×900 + mobile 390×844), VLM
screenshots at each step: Settings tab renders (Royal Purple selected,
Yellow checked); Matte Black = neutral black, no purple tint; Green
accent recolors Set Beat/BPM/tab underline; Black + Purple Outline =
pure black bg + dark purple borders + purple highlights (auto-paired);
Light = white with deep readable accents (purple and green both
tested); tick switch flips light ↔ last dark base; reload persists
(theme + accent in localStorage); simulated v6.4 visitor (stored
'dark') lands on purple+yellow; favicons 200 (16/32/180/svg); mobile
no horizontal scroll, footer flush at bottom with "version 6.5"; zero
console/page errors; dev.log clean; bun run lint clean.
Stage Summary:
- Project is back at v6.4 + the three requested v6.5 features: (1) simple
OVE logo as favicon (PNG set + SVG), (2) Native tab parked
non-destructively behind a flag, replaced by (3) a Settings tab whose
theme picker offers Matte Black and Black + Purple Outline dark themes
(plus Royal Purple and Light) with a selectable Purple/Yellow/Blue/
Green/Red/Orange highlight/outline color. Everything per-visitor
(localStorage only). Returning v6.4 visitors are migrated seamlessly
('dark' → 'purple' base, same look). Committed as
"v6.5 — Settings tab with theme picker + OVE logo favicon; Native tab parked".
---
Task ID: 2-a
Agent: full-stack-developer
Task: v6.7 part A (server) — lazy-queue anti-spam groundwork + music-service
framework: yt-dlp concurrency semaphore in /api/resolve, coming-soon provider
registry (Spotify / Apple Music), streamStatus metadata, "soon" badges in
Settings.
Work Log:
- src/lib/music-services.ts — added `export type StreamStatus = 'live' |
'coming-soon'` and `streamStatus: StreamStatus` (+ optional
`comingSoonNote`) to ServiceDef. youtube/soundcloud → 'live'; spotify/apple
→ 'coming-soon' with visitor-facing notes ("…coming in a later update —
your saved login will be used automatically the moment it ships.
Meanwhile, YouTube, SoundCloud and direct-file links work today."). All
existing fields/exports untouched (client imports keep working); header
comment now documents the framework pointer.
- src/lib/server/music-providers.ts (NEW) — server-side provider registry:
ProviderAdapter interface (id: ServiceId|'generic', label, status,
comingSoonNote, handlesUrl(url), + commented v6.8+ extension points
resolveTrack/listPlaylist). Exports YTDLP_PROVIDER (generic catch-all,
live), SPOTIFY_PROVIDER + APPLE_PROVIDER (coming-soon, domain match via
the same host/subdomain rules as serviceForUrl but scoped to one service),
providerForUrl(url) (service adapter on domain match, else yt-dlp).
Status-agnostic on purpose: flipping an adapter to 'live' keeps it owning
its URLs at the route's dispatch point. Thorough "HOW TO SHIP A NEW
SERVICE (v6.8+)" header comment documents the 3-step recipe (implement
adapter methods → flip streamStatus/status to 'live' → extend the marked
dispatch spot in the route; payload shapes spelled out).
- src/app/api/resolve/route.ts —
(a) coming-soon dispatch right after the SSRF gate, BEFORE
cache/direct-audio/yt-dlp: providerForUrl(target.href); status
'coming-soon' → 422 { ok:false, code:'coming-soon', service, error:
note }. Spotify/Apple links now NEVER spawn yt-dlp. Dropped the DRM
parenthetical from the generic "That site is not supported" 422.
(b) anti-spam semaphore: globalThis-backed YtdlpSemaphore
(YTDLP_MAX_CONCURRENCY=2, YTDLP_QUEUE_TIMEOUT_MS=40_000), FIFO waiters,
direct slot handoff in release(). Acquire wrapped around runYtDlp with a
`held` flag; release exactly once in the finally (busy 503 "The resolver
is busy right now — try again in a moment."; timed-out waiters never
double-release — queue-membership is the single source of truth, a
timeout on an already-granted waiter is a no-op). Cache hits and
direct-file links bypass the gate. Route header comment documents both
behaviors. Also fixed a pre-existing strict-type bug in buildCookieParts
(pairs was string[], pushed [string,string] tuples → now
[string,string][]; runtime behavior unchanged).
- src/components/ava/settings-tab.tsx — Music services chips: tiny amber
"soon" badge (bg-amber-400/15, text-amber-300, 8px uppercase) inside
coming-soon chips so visitors SEE those services arrive later. Nothing
else changed; login buttons stay fully functional (logins are saved ahead
of support — that's the framework).
- VERIFICATION: bun run lint clean; tsc --noEmit clean for all four files.
Live curl against the dev server: spotify track/playlist → 422
coming-soon (6ms, no yt-dlp); apple album → 422 coming-soon; SSRF gate
still fires first (localhost → 400); direct MP3 → 200 bypass; spotify
with cookies → still coming-soon before cookie staging; real YouTube
resolve passes through the semaphore → bot-wall 422 mapped as before.
Semaphore logic scratch-tested (cap/FIFO/timeout/handoff/no-leak, all
pass) AND end-to-end: 6 concurrent resolves of the same YouTube link →
ps snapshots show at most 2 top-level bin/yt-dlp processes at any moment
(extra PIDs are the onefile binary's own self-exec children), queued
requests ran in FIFO batches (~2s each wave, 6.2s/6.6s total for the
last two), zero 503s, dev.log clean of errors.
- NOTE for lead: apple.com (not just music.apple.com) is in the apple
ServiceDef domains, so ALL apple.com links (e.g. podcasts.apple.com) now
get the Apple Music coming-soon answer instead of a yt-dlp attempt —
consistent with the client-side service matching, but podcasts links
that yt-dlp's ApplePodcasts extractor could handle now report
coming-soon. Say the word if you'd rather scope the apple adapter to
music.apple.com only.
- Pre-existing/other-agent tsc errors (NOT mine, untouched):
examples/websocket/* (missing socket.io deps),
src/app/api/rust-scaffold/route.ts (Uint8Array vs BodyInit),
src/components/ava/audio-source.ts ('never' narrowing — concurrent
agent's WIP).
Stage Summary:
- Anti-spam guarantee in place: the server can never run more than 2
concurrent yt-dlp extractions regardless of how many visitors resolve at
once; surplus requests FIFO-queue ≤40s then get an honest 503.
- Music-service framework in place: Spotify/Apple links get a clean
"coming in a later update" answer (422 code:'coming-soon' + service id)
with zero extractor spawns; the adapter registry + documented recipe
makes shipping real support a 3-step local change; Settings shows amber
"soon" badges while logins are already being saved ahead of support.
---
Task ID: 17
Agent: main (Z.ai Code)
Task: v6.7 — lazy playlist queue (anti-spam) + music-service framework
(Spotify/Apple Music registered as coming-soon providers)
Work Log:
- USER DIRECTIVE (session start): playlist links must NEVER download/resolve
all songs at once — build a queue of URLs and let yt-dlp fetch each song
only as needed right before it plays (no YouTube temp bans). Services
yt-dlp can't support (Spotify/Apple Music) get the framework now with a
visible "later update" message.
- Task 2-a (subagent, full-stack-developer): music-services.ts gained
streamStatus 'live'|'coming-soon' + comingSoonNote; NEW
src/lib/server/music-providers.ts provider registry (YTDLP_PROVIDER
catch-all + SPOTIFY/APPLE coming-soon adapters, documented v6.8+ extension
recipe); /api/resolve dispatches coming-soon BEFORE yt-dlp (422
{code:'coming-soon', service, error}) and gained a globalThis semaphore
(max 2 concurrent yt-dlp extractions, FIFO, 40s wait → 503); Settings tab
chips show amber "SOON" badges; also fixed a pre-existing strict-typing
bug in buildCookieParts.
- Task 1 (audio.ts): startUrl(url, title, {loop?}) — queue tracks play with
loop:false so 'ended' fires; onTrackEnded callback (guarded to the current
element); positionSec/durationSec getters for the look-ahead.
- Task 2-c (audio-source.ts): playResolvedTrack opts {loop}; resolveLink
returns code/service from the coming-soon JSON; setSourceRetireListener
hook — demo/mic/file/stop all retire an active queue (module graph stays
acyclic: queue → audio-source, never back).
- Task 2-b (NEW src/lib/ava/queue.ts): lazy queue controller + zustand
store. Generation token invalidates late async results; ALL client
resolves serialized through a promise chain; look-ahead preloads ONLY
index+1 within the last 15s of the current track (unknown duration →
advance on 'ended'); failure policy = skip a dead track, 2 consecutive
failures pause with Retry (a 182-track dead playlist can't grind the
resolver); autoplay NotAllowedError → 'blocked' status + Resume; public
API startQueue/stopQueue/queueNext/queuePrev/queueJump/replayQueue.
BUG FOUND IN VERIFICATION: retire listener called stopQueueInternal()
without resetting the store → bar never hid on Demo start; fixed to call
stopQueue().
- Task 2-d (NEW src/components/ava/queue-bar.tsx + page.tsx): slim strip
under the header — playlist title, x/y badge, status chip (fetching / next
up ready / finished / paused / stopped), prev/next/expand/stop, Resume /
Retry / Replay CTAs, expandable track list (max-h-64, thin scrollbar,
current row highlighted, click to jump) + lazy-fetch footnote.
- Task 2-e (link-dialog.tsx): playlist view now has "Play all — lazy queue"
and per-entry start-from-here; Spotify/Apple links are intercepted
client-side with an amber coming-soon panel (brand dot, note, "Open
Settings" shortcut — zero network requests, confirmed in dev.log);
server 422 is the backstop; single-track plays retire the queue first.
- version.ts → 6.7.
- FIXED 6 pre-existing strict-typing errors (tsc now 0 app errors):
resolveLink `as typeof data` never-narrowing (named ResolveResponse),
audio.ts/music-sessions.ts Uint8Array<ArrayBuffer> DOM typing,
rust-scaffold zip → arraybuffer, script.ts meta ?? null, user-presets
UserResult conditional-type + folder-rename 80-char limit actually
enforced (was silently ignored).
- FIXED pre-existing 4px "Import/Export .zip" label clip in the presets
details card (VLM caught it): the shadcn sm variant's has-[>svg]:px-2.5
out-specifies plain px-*; buttons now has-[>svg]:px-2 + text-xs.
- VERIFIED with agent-browser (desktop 1440×900 + mobile 390×844):
Spotify link → coming-soon panel, NO /api/resolve request; archive.org
playlist (slack_sfx, 11 tracks 0.4–2.2s) → picker → Play all → queue bar
auto-advanced through ALL 11 tracks (dev.log shows perfect alternating
resolve→stream, one at a time) → finished + Replay works; jump via list
click, prev/next, Stop clears the bar; YouTube playlist (182 entries,
bot-walled tracks from this datacenter IP) → skip → 2-strike pause with
Retry (never grinds 182 tracks); Demo click retires the queue (after the
fix); Settings SOON badges only on Spotify/Apple; mobile: no horizontal
scroll, queue bar wraps cleanly, footer pushed naturally; light + dark
themes clean (VLM: CLEAN ×4 + presets fix CLEAN); zero page errors,
console clean, dev.log clean, bun run lint clean, tsc 0 app errors.
Stage Summary:
- v6.7 shipped: playlist links become lazy queues that stream ONE song at a
time (each resolved by yt-dlp only right before it plays, ≤1 look-ahead,
client resolves serialized + server max-2-extraction semaphore — the
anti-temp-ban guarantee the user demanded). Spotify/Apple Music are now
registered framework providers with honest "coming in a later update"
messaging everywhere (Link dialog, Settings badges, API), logins already
save ahead of support. E2E-verified with real playback on archive.org
(direct files) and real failure handling on YouTube bot-walls.
---
Task ID: 18
Agent: main (Z.ai Code)
Task: v6.8 — self-healing / self-updating yt-dlp + honest failure feedback
("Updating in Background", backend-problem message, Google-login bot-wall
message with Settings action)
Work Log:
- USER DIRECTIVE: when a song fails because YouTube changed their
mechanics, the server must auto-update yt-dlp on the back end, tell the
visitor "Updating in Background", then retry the song seamlessly; if the
grab+play still fails → "A back-end problem has occurred. Please try
again later."; bot-detection blocks → "Google login required. Check the
[Settings] tab to login, then try again."
- Confirmed workspace already at v6.7 (version.ts + queue.ts/queue-bar/
music-providers present) — no Drive restore needed.
- Probed update mechanics on this box: `yt-dlp -U` is useless here
(GitHub API 403 rate-limited from this IP), but the release ASSET
redirect (github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux)
downloads fine (41 MB, ~4-8 s) → self-update uses direct asset download.
- NEW src/lib/server/ytdlp-selfupdate.ts: globalThis-backed update state
(dev-reload safe — the running closure keeps writing the SAME state
object a reloaded module reads), triggerYtdlpUpdate() fire-and-forget
(claim is synchronous → no double runs), runUpdate(): probe old version
→ fetch latest asset (180 s cap) → size gate 10–120 MB → ELF magic
check → stage bin/yt-dlp.new 0755 → prove it runs (--version probe) →
same version = no-op (nothing shipped), newer = atomic rename() swap
(running extractions keep the old inode) → 6 h cooldown after every
run + ytdlpUpdateRunning()/ytdlpUpdateCooldownActive() predicates +
[ytdlp-selfupdate] console logs for dev.log observability.
- src/app/api/resolve/route.ts: STALE_EXTRACTOR_RE (nsig/signature
extraction failed, unable to extract yt initial data / initial player
response / player id, unable to find handler function, python
tracebacks/AttributeError/KeyError/…, expected string or bytes-like
object, object has no attribute / is not subscriptable, no video
formats, requested format is not available, player response is not
valid) → staleExtractorResponse(): update running OR cooldown inactive →
trigger + 503 {code:'ytdlp-updating', Retry-After:10}; cooldown active
(update already ran / shipped nothing) → 500 {code:'backend-error',
error:'A back-end problem has occurred. Please try again later.'}.
BOT_WALL_RE branch now answers 422 {code:'bot-wall'} with
botWallMessage(): YouTube hosts → exact "Google login required. Check
the Settings tab to login, then try again." (+ "saved login may have
expired" suffix when cookies were attached); other services → generic
Settings guidance. v6.8 header doc.
- src/components/ava/audio-source.ts: resolveLink gained ResolveOpts
{onUpdating} + an automatic retry loop for 'ytdlp-updating' — ONE
"Updating in Background" toast (4 s wait, then every 8 s, ≤14 retries ≈
2 min patience), "Audio engine updated — playing your song now." toast
when a retry finally resolves, exhaustion/errand codes → synthetic
backend-error. New exported ResolveErrorCode ('coming-soon' |
'bot-wall' | 'backend-error'). resolveOnce() helper (never throws).
- src/lib/ava/queue.ts: QueueState + lastErrorCode + updating; resolveEntry
wires onUpdating → setQueue({updating:true}) (reset on settle) and
returns the failure code; handleTrackFailure(reason, code) surfaces the
REAL reason in the paused state instead of the generic sentence.
- src/components/ava/queue-bar.tsx: "updating engine…" amber chip (outranks
"fetching" on loading and "next up ready" on playing look-ahead),
"Updating in Background — … your music resumes in a moment." message
while the CURRENT track waits on the update, and an "Open Settings"
button beside Retry when lastErrorCode === 'bot-wall' (switches to the
Settings tab where the service logins live).
- src/components/ava/link-dialog.tsx: errorCode state; bot-wall failures
render the message + "Open Settings to log in, then try again" action;
the generic login tip is suppressed for bot-wall (message already says
it) and backend-error (fixed wording).
- src/lib/version.ts → 6.8 + history note.
- VERIFICATION (bun run lint clean, tsc --noEmit 0 app errors):
- curl E2E with a FAKE stale binary (prints "nsig extraction failed"
+ traceback, --version → 2024.01.01): POST resolve → 503
ytdlp-updating in 146 ms; background update ran for real:
"[ytdlp-selfupdate] updated yt-dlp 2024.01.01 → 2026.10.26 in 5.7s";
the FOLLOWING resolve with the real new binary → 200 + a working
stream (639 KB audio/webm through the proxy) — the self-heal
genuinely fixed playback.
- cooldown path (fake again, uncached URL) → 500 backend-error with
the exact spec message, zero re-download.
- bot-wall paths with a fake walled binary: YouTube → "Google login
required. Check the Settings tab to login, then try again."; with
cookies attached → + expired-login hint; SoundCloud host → generic
Settings guidance.
- agent-browser E2E (desktop 1440×900 + mobile 390×844): dialog shows
"A back-end problem has occurred. Please try again later." inline
(role=alert) on cooldown; full self-heal flow: stale binary → Load →
"Updating in Background" toast (DOM-verified at t+1.5 s; a11y
snapshots scope to the open modal, so the toast needed eval/screenshot
evidence) → dialog stays Loading through the retries → update lands →
retry resolves → dialog closes, "Never Gonna Give You Up" PLAYING
(SRC: LINK, visualizer 59 FPS, recents updated). Queue bar: real
YouTube playlist ("Popular Music Videos", 182 entries — its first
tracks genuinely bot-walled from this datacenter IP) → 2-strike pause
with "Google login required…" in the bar + Open Settings button →
click lands on Settings tab → Music services → YouTube login.
Mobile: no horizontal scroll; console clean; dev.log clean.
- ops note: dev-server restarts need the orphan pattern
`( setsid bun run dev </dev/null >/dev/null 2>&1 & )` — plain
nohup/& gets reaped between tool commands.
- Final state: bin/yt-dlp = 2026.10.26 (self-healed during testing, newer
than the 2026.10.21 that shipped with the workspace), server running,
queue stopped, source idle.
Stage Summary:
- v6.8 shipped: the resolver is now self-healing. Stale-extractor
signatures trigger a verified, atomic, cooldown-gated background
yt-dlp update (direct GitHub release asset — `yt-dlp -U` is API
rate-limited here); visitors see "Updating in Background" while the
client transparently retries (~2 min patience), and playback resumes
seamlessly when the new binary lands (proven live: 2024.01.01 fake →
2026.10.26 real → song playing in the browser). Bot walls answer the
exact "Google login required. Check the Settings tab to login, then try
again." with an Open Settings action (dialog + queue bar), and
unfixable failures get the honest "A back-end problem has occurred.
Please try again later." — every message the user specified is in and
browser-verified.
---
Task ID: 19
Agent: main (Z.ai Code)
Task: v6.8.1 — cookie login made foolproof (answer to the user's
"how do I get the cookie / is LOGIN_INFO enough?" question): live paste
validation, three accepted paste formats, easier how-to steps
Work Log:
- USER QUESTION: they found the DevTools Storage tab cookie list (no
"Request Headers" in sight — they are on Firefox) and asked whether
pasting the LOGIN_INFO cookie's VALUE alone would work. Answer: no —
a working YouTube login for yt-dlp is the full ~20-cookie set
(SID/HSID/SSID/SAPISID/__Secure-3PSID/LOGIN_INFO/…), and a bare value
isn't even a NAME=VALUE pair. Built the fix into the app so the dialog
itself teaches this.
- src/lib/music-services.ts (shared, pure):
(a) NEW cookie-paste toolkit: parseCookiePaste() recognizes raw
Cookie headers, Netscape cookies.txt exports AND Cookie-Editor JSON
exports (never throws; first '=' splits pairs so base64 '=' padding
survives); validateCookiePaste() counts cookies and lists the missing
critical ones per service (criticalCookies: youtube = SID, HSID, SSID,
SAPISID, __Secure-3PSID, LOGIN_INFO; soundcloud = oauth_token; spotify
= sp_dc); cookiePasteToNetscape() produces the yt-dlp cookies.txt body
+ stream-proxy header (raw headers scoped to the service domain; JSON
entries keep their domain only when it belongs to the service —
foreign-site entries dropped; netscape exports pass through with all
domains + real expiries).
(b) steps rewritten via cookieSteps() helper: extension-first ("Get
cookies.txt LOCALLY" Chrome/Edge/Brave, "cookies.txt" Firefox), then
the DevTools Network path with the gotchas that lost the user (the
request list stays empty until you reload with the tab open; the
cookie: row lives under "Request Headers"), and a last step that says
a working login is the FULL set, never a single cookie.
- src/components/ava/service-login-dialog.tsx: LIVE validation box
under the paste area (aria-live=polite, warn-never-block): green
"N cookies detected (format) — looks like a complete X login", amber
"N cookies detected, but a working X login is still missing: SID, …",
red "No cookies detected — it looks like a single cookie's value" (the
exact LOGIN_INFO mistake). Placeholder + tip now mention cookies.txt /
JSON exports.
- src/app/api/resolve/route.ts: local buildCookieParts/cookieToken
DELETED — stageCookies now calls the shared cookiePasteToNetscape(), so
the dialog's validation and the server's staging can never disagree
(and the server now accepts JSON exports too). Route doc updated.
- src/lib/version.ts → 6.8.1 + history note.
- VERIFIED: bun run lint clean; tsc --noEmit 0 app errors. Bun unit
tests of the parser: user's exact LOGIN_INFO value → format unknown /
0 cookies / staging null; LOGIN_INFO=<v> → 1 cookie, 5 critical
missing; full 17-cookie header → looksGood + 18-line netscape; mixed
cookies.txt → youtube-only header, .google.com line kept in file;
Cookie-Editor JSON with a foreign .soundcloud.com entry → dropped from
both netscape AND header; soundcloud oauth_token-only paste →
looksGood. agent-browser E2E (1440×900 + 390×844): all five dialog
states DOM-verified (red / amber-with-names / green header / green
JSON / green cookies.txt), save → "Logged in to YouTube / YouTube
Music" toast + chip, log out → "removed from this browser"; VLM visual
checks CLEAN (5-step list readable, red + green boxes readable, no
overlap/clipping, mobile no horizontal scroll); curl POST /api/resolve
with a JSON cookie payload → 200 (yt-dlp accepted the staged file — it
hard-fails on malformed cookie files); dev.log clean throughout;
automation browser left logged-out.
Stage Summary:
- v6.8.1 shipped: the login dialog now meets the visitor where they are.
Whatever they manage to copy — the full Cookie header, a cookies.txt
export, a Cookie-Editor JSON, or (as the user tried) a single
cookie's value from the Storage tab — the box recognizes it and tells
the truth about whether it can log them in, with the easiest
acquisition path (one-click extension export) documented first for
both Chrome and Firefox. One shared parser powers both the live
validation and the server's transient yt-dlp staging.
---
Task ID: 20
Agent: main (Z.ai Code)
Task: v6.8.2 — answer "can't we use the official Google OAuth login?" + cut
login friction to ~4 clicks (drag & drop / file-import the cookie export)
Work Log:
- USER QUESTION: why not the official flow — Google login page, consent
screen for openvisualengine.com, revocable from Google Security
Settings — instead of extensions / copy-paste? ANSWER (researched, no
code): no on three independent grounds:
(1) TECHNICAL: OAuth tokens only call Google's documented APIs, and the
YouTube Data API deliberately never returns media stream URLs — the
actual audio (googlevideo.com) is only reachable by the innertube/page
scrape yt-dlp does, which authenticates with the browser cookie jar.
yt-dlp upstream supports cookies only for YouTube (the community
oauth2 plugin requires EACH USER to register their own Google Cloud
OAuth client — the opposite of easier).
(2) POLICY: the consent screen the user described is gated behind
Google verification; YouTube scopes are RESTRICTED (annual security
assessment, public audit form) and YouTube API ToS forbid playing
content outside the official player — an audio-visualizer streamer
would be rejected/terminated. Unverified "Testing" mode: scary
"unverified app" interstitial, 100-user cap, refresh tokens silently
expire every 7 days — logins would break weekly.
(3) ARCHITECTURE/PRIVACY: our current design never stores credentials
server-side (AES-GCM in the visitor's browser, transient use); an
OAuth backend must hold everyone's refresh tokens in one database —
bigger blast radius, and one Google ban locks out every user at once.
Revocation parity explained in the dialog: "Log out" removes it
instantly; signing out of YouTube invalidates the cookie everywhere.
- src/components/ava/service-login-dialog.tsx (v6.8.2):
(a) the paste box is now a DROP ZONE — dragover (files only; text
drags keep native behavior) shows an emerald ring + centered overlay
"Drop the exported cookies.txt / JSON file — it fills this box
automatically."; onDrop reads the first file (≤256 KB gate) via
file.text() into the paste area where the v6.8.1 live validation
reacts. dragleave uses relatedTarget containment so child transitions
don't flicker the ring.
(b) "Import the exported file…" outline button + sr-only file input
(accept .txt/.json, tabIndex -1 + aria-hidden so the a11y tree keeps
a single import control), value reset after pick so the same file can
be re-picked; busy-guarded.
(c) placeholder + tip updated ("paste, drop or import whichever you
have"); security note now ends with the revocation sentence.
- src/lib/music-services.ts: step 2 of the how-to now ends "…choose
Export — then drag the downloaded file straight into the box below (no
need to open or copy anything)."
- src/lib/version.ts → 6.8.2 + history note (including the OAuth
rationale in one line).
- VERIFIED: bun run lint clean; tsc --noEmit 0 app errors.
agent-browser E2E: dragover dispatch → overlay text in DOM + VLM
visual CLEAN (emerald ring + overlay message, no overlap); File-drop
dispatch (cookies.txt with 6 youtube cookies) → textarea filled +
green "6 cookies detected (cookies.txt export) — looks like a complete
YouTube login"; file-input change dispatch (Cookie-Editor JSON) →
green "JSON export"; save → "Logged in to YouTube / YouTube Music"
toast; "Log out of all" → "removed from this browser"; hidden input
no longer duplicated in the a11y tree; dev.log clean.
Stage Summary:
- v6.8.2 shipped: the login flow is now: install exporter (once) →
youtube.com → click Export → drag the file onto the box → Log in —
four clicks, no opening files, no copying. The official OAuth path is
documented as impossible for this site (no stream URLs via Google
APIs, restricted-scope verification would reject an audio player,
server-side token hoarding is worse for privacy) — cookies stay the
engine's only authentication route, and the dialog now teaches the
easiest cookie acquisition path end-to-end.
---
Task ID: 21
Agent: main (Z.ai Code)
Task: v6.9 — house account: anonymous visitors play YouTube through the
operator's shared disposable-account cookie (server-side fallback), plus the
Deno/EJS runtime fix that made cookie-authenticated extraction work at all
Work Log:
- USER DIRECTIVE: use their disposable Google account's cookies.txt (Google
Drive link) as a SERVER-SIDE shared credential so visitors who never log
in can still play YouTube links (no bot walls for guests).
- Downloaded the export from Drive (7.9 KB, yt-dlp-format Netscape file),
validated structure WITHOUT exposing contents: 49 cookies, full Google
auth set (SID/HSID/SSID/SAPISID/__Secure-1PSID/__Secure-3PSID/LOGIN_INFO/
SIDCC/both PAPISIDs/PSIDTS), 1 trailing blank line (harmless), 5 expired
entries all non-critical (CONSISTENCY, ST-* tokens) → installed at
secrets/house-youtube-cookies.txt (chmod 600; secrets/ is never served).
- CRITICAL ENGINE DISCOVERY: with a VALID cookie, yt-dlp got ZERO media
formats (storyboards only → "Requested format is not available") on every
player client, while the same video anonymously bot-walled or (some
videos) resolved. Root cause (yt-dlp's own warnings, previously hidden by
--no-warnings): "Signature solving failed / n challenge solving failed:
Ensure you have a supported JavaScript runtime and challenge solver
script distribution installed … refer to EJS". Authenticated sessions go
through web-family clients whose URLs need signature/n-challenge solving;
that requires a JS RUNTIME (EJS). The yt-dlp_linux binary bundles the
solver SCRIPTS but no runtime. FIX: installed Deno 2.9.8 to bin/deno
(GitHub release asset, same download channel as the self-update) and
spawn yt-dlp with the project bin/ prepended to PATH (ytdlpEnv()).
After the fix: house-cookie extraction of the anonymously-bot-walled
Despacito → full opus stream URL. This also un-breaks VISITOR logins
with real accounts (v6.6–6.8.2 testing used invalid demo cookies that
silently fell back to anonymous — real ones would have hit this wall).
- NEW src/lib/server/house-cookies.ts: houseCookiePath/ houseCookieText()
(mtime+size cached on globalThis — replacing the file swaps the account
live, no restart; 256 KB cap; any paste format accepted since the shared
parser normalizes), houseCookieStatus() (presence booleans only).
- src/app/api/resolve/route.ts:
(a) NEW GET /api/resolve → { ok, house: { youtube: true, … } } (no
contents, Cache-Control no-store) for the Settings/dialog hints.
(b) POST: house fallback staging — visitor's own cookie always wins;
else if serviceForUrl(link) has a house file → stageCookies(houseText)
through the SAME transient 0600 pipeline (never logged, never stored);
'[house-cookie] staged for <service>' log line for observability.
(c) cache key gains '|house' (a house resolve can succeed where '|anon'
bot-walls).
(d) botWallMessage(target, usedCookie, houseCookie): house-cookie bot
walls get an honest suffix (shared account hit a usage limit — retry
later or log in with your own account); visitor-cookie walls keep the
expired-login hint; no-cookie walls keep the exact v6.8 spec message.
(e) runYtDlp spawns with env PATH including bin/ (Deno) — see above.
- src/components/ava/settings-tab.tsx: fetches GET /api/resolve; when
house services exist shows an emerald note under Music services
("Built-in account active — YouTube links play for everyone, no login
needed. Log in only to reach your own playlists, liked music and
uploads."); passes houseServices to the login dialog.
- src/components/ava/service-login-dialog.tsx: new houseServices prop;
when the selected service has a house account, an emerald note in the
help box ("A built-in account is already active for this service —
links play for everyone. Your own login adds your playlists, liked
music and uploads on top.").
- src/lib/version.ts → 6.9 + history note.
- VERIFIED: bun run lint clean; tsc --noEmit 0 app errors. CLI matrix
(5 player clients × anon/house) proved the cookie authenticates
everywhere but formats need Deno; after Deno: anonymous-API resolve of
Despacito (bot-walled for guests before) → 200 "Luis Fonsi - Despacito
ft. Daddy Yankee" in 4.2 s with '[house-cookie] staged for youtube' in
dev.log; stream proxy served 206 partials of genuine audio/webm (64 KB
verified); Rick Astley house path 200; SoundCloud pure-anon path
unchanged (one track's HLS/DASH answer is the pre-existing adaptive
limitation, not a regression); GET /api/resolve → { youtube: true }.
agent-browser E2E: Settings shows the built-in-account note (DOM
verified), login dialog shows the hint (DOM + VLM CLEAN), full golden
path — anonymous Link of Despacito → resolve 200 → stream 206 → SRC:
LINK + title + 60 FPS render loop (same state as the audible Demo
source; footer PTS is a points counter, not a playhead), console clean,
dev.log clean, playback stopped after the test (SRC: IDLE).
- OPERATIONAL NOTES for the lead: (1) the house cookie expires when
Google rotates it / the account signs out — replace
secrets/house-youtube-cookies.txt (same format) and the mtime cache
picks it up live; (2) ALL anonymous visitors now share ONE account —
the max-2-extraction semaphore keeps the request rate sane, but heavy
traffic concentrates on that account (the honest bot-wall suffix
covers the day it gets limited); (3) signing the disposable account
out anywhere invalidates the file.
Stage Summary:
- v6.9 shipped: guests press Link on a YouTube URL and it just plays —
the server silently uses the operator's disposable account when the
visitor has none of their own (visitor logins still win; SoundCloud and
direct links unchanged). The invisible blocker discovered on the way —
YouTube requiring a JS runtime (Deno/EJS) for signature solving on
authenticated sessions — is fixed for house AND visitor logins alike,
and the UI tells everyone the built-in account is active, so logging in
is now clearly optional (for your own playlists) instead of required
(to hear anything at all).
---
Task ID: 22
Agent: main (Z.ai Code)
Task: Social share preview — when openvisualengine.com is shared on Facebook (and other social platforms), show a large image preview card using the operator's provided screenshot.
Work Log:
- Confirmed current project is already v6.9 (house-account feature, Task 21) — no need to import the Drive project archive; the user-provided archive was for version verification only.
- Downloaded the preview image from Drive (file id 1jxZg7FuZCNz0V_rKTlJW_eNnntA77xJ5): JPEG 1120x630, 160 KB, 300 DPI — a clean full-app screenshot (trefoil particle visualization, preset library, BPM display, black/neon-green theme).
- Facebook's optimal og:image is 1200x630; at 1120x630 the feed container would center-crop ~7% vertically (clipping the logo bar and footer status line). Padded the image to exactly 1200x630 with pure-black side bars (40 px each) via PIL — seamless against the screenshot's black background; saved as public/og-image.jpg (quality 92, progressive, 87 KB). VLM verified: no visible seams/bars, all UI fully visible.
- Checked robots.txt: facebookexternalhit and Twitterbot already allowed (v-eralier). No pre-existing OG/Twitter tags in the app.
- src/app/layout.tsx: added SITE_URL const + metadataBase: new URL('https://openvisualengine.com') (social crawlers require ABSOLUTE image URLs — Next resolves the relative /og-image.jpg against this base) and openGraph block (type website, url, siteName, title "OVE — Open Visualization Engine" — version-free for a clean card, description, images with width 1200 / height 630 / descriptive alt) + twitter block (card summary_large_image, title, description, images).
- src/lib/version.ts → 6.9.1 + history note.
- VERIFIED: bun run lint clean; tsc --noEmit only the 2 pre-existing examples/ demo errors (no app errors); curl of / shows all 13 og:/twitter: meta tags with absolute URLs (og:image → https://openvisualengine.com/og-image.jpg, og:image:width/height 1200/630); GET /og-image.jpg → 200 image/jpeg 86947 B; agent-browser E2E: page renders (title "OVE — Open Visualization Engine (v6.9.1)"), Demo → render loop ran (FPS 54) → Stop → SRC: IDLE, footer shows version 6.9.1; dev.log clean.
- Investigated the "1 Issue" console badge: one hydration-mismatch warning in PresetsTab (Framer Motion initial opacity SSR vs client) — proven PRE-EXISTING in the committed v6.9 code by stashing this task's changes and reproducing it on the clean tree; NOT caused by this change (metadata is head-only, doesn't hydrate). Left as-is (cosmetic-only, React recovers; fix belongs to a future polish task if desired).
Stage Summary:
- v6.9.1 shipped: sharing openvisualengine.com on Facebook/X/WhatsApp/Discord/Telegram now renders the large image card — the operator's OVE screenshot padded to the exact 1200x630 spec (full top logo bar + footer preserved, no crop), with og:title "OVE — Open Visualization Engine", a club-oriented description, and summary_large_image on X. The image lives at public/og-image.jpg (87 KB) and all crawler requirements are met (absolute URL, dimensions declared, robots.txt allows facebookexternalhit/Twitterbot).
- OPERATIONAL NOTES for the lead: (1) Facebook caches previews aggressively — after deploying this build, run the URL through Meta's Sharing Debugger (developers.facebook.com/tools/debug/) once and press "Scrape Again" to refresh the cache; (2) the card text can be tuned any time in the layout.tsx openGraph/twitter blocks without touching the image; (3) the pre-existing PresetsTab hydration warning is documented above and is not related to this feature.
---
Task ID: 23
Agent: main (Z.ai Code)
Task: Fix Facebook Sharing Debugger "Corrupted image" error on og:image + swap in the operator's new share image (Drive id 1cle1xJ8vBKC5bKE0azcGRxMy9nG8D9ky).
Work Log:
- Diagnosed the "could not be processed as an image" error: the v6.9.1 og-image.jpg was encoded as PROGRESSIVE JPEG (the Drive source already was, and the PIL re-save preserved progressive=True). Every browser renders progressive JPEGs fine — which is why all local verification passed — but Facebook's image pipeline is stricter and classically rejects progressive encoding with exactly this "corrupted/invalid format" message. Not an operator error; an encoding choice made in Task 22.
- Tried to inspect the live production URL for confirmation but the sandbox cannot reach openvisualengine.com (external egress to that host fails); the error text itself (downloaded-but-unparseable, not 404/not-downloadable) matches the progressive-JPEG profile, and the file is provably valid JPEG otherwise.
- Downloaded the replacement image from Drive: JPEG 1199x630, 473 KB, ALSO progressive, 300 DPI — a title card (gradient OPEN VISUALIZATION ENGINE wordmark over spiral + particle + wireframe-sphere visualizations; VLM-verified content).
- Processed it: padded 1 px to exactly 1200x630 (invisible black column on the left edge) and re-encoded as BASELINE JPEG, quality 92, optimize, dpi=(72,72), no EXIF → public/og-image.jpg (223 KB). Verified via file: "baseline, precision 8, 1200x630"; PIL confirms progression=False.
- src/app/layout.tsx: og:image and twitter:image URLs now carry ?v=2 (cache-bust — Facebook caches og:image per-URL and has already blacklisted the old URL as corrupted; a new URL forces a fresh fetch), og:image:alt rewritten for the title-card artwork. Comment documents the bump-this-on-next-swap rule and the baseline-only lesson.
- src/lib/version.ts → 6.9.2 + history note.
- VERIFIED: lint clean; tsc clean (only the 2 pre-existing examples/ errors); served HTML shows og:image/twitter:image = https://openvisualengine.com/og-image.jpg?v=2 with 1200/630 dims + new alt; GET /og-image.jpg?v=2 → 200 image/jpeg 223493 B baseline; VLM confirms the card is clean (wordmark complete, no seams at the padded edge); page loads at v6.9.2 (footer), dev.log clean.
- Told the operator the required manual step: after the build is live, re-run Meta's Sharing Debugger and press "Scrape Again"; the ?v=2 param guarantees Facebook fetches the new baseline file instead of its cached verdict. Fallback documented if it ever still fails: re-encode as PNG (zero JPEG encoder variables).
Stage Summary:
- v6.9.2 shipped: the Facebook share card is fixed at the root cause — baseline JPEG encoding (progressive was the debugger-rejected culprit) — AND upgraded to the operator's title-card artwork (gradient wordmark over three visualizations) padded to the exact 1200x630 spec with a cache-busting ?v=2 URL so Facebook must fetch it fresh. Operator action required: Sharing Debugger → Scrape Again once the build is live.
---
Task ID: 24
Agent: main (Z.ai Code)
Task: Root-cause the Facebook Sharing Debugger "corrupted image" report — user pasted https://openvisengine.com/ but Facebook was fetching https://openvisualengine.com/ URLs. Fix the canonical domain.
Work Log:
- User correction: the real domain is openvisengine.com (SHORT) — the long form "openvisualengine.com" I baked into v6.9.1 (from a stale conversation summary) is a DEAD domain. The worklog's own v6.2 entry had the correct domain all along.
- Probed both domains from the sandbox: openvisualengine.com — connection fails outright (serves nothing); openvisengine.com — live (nginx, 200). THIS is the true root cause of the Facebook error: production's og:image pointed at a URL on a dead host, Facebook's download failed, and the debugger reported it as "corrupted / invalid format". The Task 23 progressive-JPEG theory was a plausible secondary suspect but NOT the trigger (correction noted honestly — the baseline re-encode stays as hardening since it is strictly safer and the raw replacement artwork was also progressive).
- Inspected live production (openvisengine.com, reachable from sandbox): title "OVE — Open Visualization Engine (v6.9.1)" → v6.9.1 deployed; its tags advertise og:url/og:image = https://openvisualengine.com/... (dead domain) — exactly matching the user's debugger report. Also found the user had manually uploaded the RAW new title-card image to production /og-image.jpg (Content-Length 472938 = exact byte size of the raw Drive download; progressive JPEG 1199x630 — not the processed baseline 1200x630 from Task 23).
- Grep confirmed exactly ONE wrong-domain reference in live code: src/app/layout.tsx SITE_URL. Fixed it to https://openvisengine.com with a comment recording the lesson (correct short domain; 6.9.1/6.9.2 accidentally shipped the long form).
- src/lib/version.ts → 6.9.3 + history note.
- VERIFIED: lint clean; tsc clean (only pre-existing examples/ errors); dev server serves og:url = https://openvisengine.com, og:image = https://openvisengine.com/og-image.jpg?v=2 (1200x630 + title-card alt), twitter:image same; title shows v6.9.3; page loads, footer "version 6.9.3"; dev.log clean.
- Operator instructions: (1) deploy v6.9.3 — this BOTH fixes the domain in all share tags AND overwrites the raw progressive file the operator hand-uploaded with the Facebook-safe baseline 1200x630 version of the same artwork (public/og-image.jpg, 223 KB); (2) then run Meta Sharing Debugger on https://openvisengine.com/ and press "Scrape Again"; (3) the ?v=2 param makes the image URL brand-new to Facebook (its only previous fetch attempt was on the dead domain), so a fresh scrape is guaranteed to pull the new file.
Stage Summary:
- v6.9.3 shipped: the Facebook "corrupted image" mystery is fully solved — the share metadata pointed at a dead domain (openvisualengine.com) while the real site is openvisengine.com; all og:/twitter: URLs now use the correct domain, with the baseline-JPEG hardening and cache-bust from 6.9.2 retained. Deploying this build and re-scraping the debugger (openvisengine.com) should finally render the big title-card preview.
---
Task ID: 25
Agent: main (Z.ai Code)
Task: Zai-Magic — author ten unique presets under root/embedded/Zai-Magic that move with the music AND change color to the beat; add script variables/functions as needed (help section updated with usage).
Work Log:
- New script tools (src/lib/ava/expression.ts): hsvr(h, s?, v?) / hsvg / hsvb — proper HSV→RGB channel functions (hue wraps 0..1, s/v default 1) so one hue variable paints the full rainbow; smoothstep(e0, e1, x) hermite ramp. All tested headlessly (primaries, negative-hue wrap, degenerate range).
- New env var (src/lib/ava/engine.ts): beatage — seconds since the last beat (0 on the beat frame, starts at 99); exp(-beatage*k) is the classic kick envelope with zero per-script bookkeeping. Documented.
- Help panel (help-panel.tsx): beatage row, hsvr/hsvg/hsvb + smoothstep function rows, and 4 new example formulas leading with beat-color idioms (hue kick per beat, rainbow from one hue, kick envelope, per-bar hue shift).
- Ten hand-authored presets in EMBEDDED_LIBRARY (embedded-presets.ts, folder root/embedded/Zai-Magic, author "Zai", clearly marked KEEP-VERBATIM section; EMBEDDED_SEED_VERSION 6.1 → 6.10; presets route BASE_FOLDERS += root/embedded/Zai-Magic):
1. Prism Chandelier — 6-mirror kaleidoscope ribbons, hue +0.085/beat, beatage glow
2. Neon Heartbeat — solid-mode twin star polygons, hue +0.22/beat, petal count breathes on the kick
3. Spectrum Serpent — spiral oscilloscope, rainbow along body steps 0.14/beat, body swells with sv
4. Bass Bloom — 480 megabuf particles, beat budget launches bursts, EACH PARTICLE KEEPS THE HUE OF ITS SPAWN BEAT (rainbow memory of past beats), gravity + fade
5. Strobe Lattice — nested squares snap rotation +0.62 rad and jump palette frac(beats*0.19) every beat, flash decays on beatage
6. Aurora Veil — 9 getspec-band curtains, scheme drifts +0.17 once per 4-beat bar, beatphase shimmer
7. Supernova — 620 megabuf particles, [beat] budget = n detonates the whole field, white core (sat 0) saturates into the beat hue as it expands (drag physics)
8. Tunnel Vision — 26 rings, hue = chroma − z*0.38: each beat's color is born at the tunnel mouth and rides outward
9. Lissajous Loom — weave re-threads its frequency ratio (2,3)→(2,4)→(3,3)→(3,5)→(6,3) once per bar, rainbow rotates every beat
10. Beat Matrix — 16 getspec column bands, whole rainbow rotates 1/6 per beat, saturation drops on the beat flash (whiteout)
- Headless validation (bun, /home/z/tmp-validate/validate.ts): all 10 parse + 150-frame simulation × n points → zero non-finite outputs; beat-snapshot color check proves every preset's palette MOVES between beats (4-6 distinct avg colors of 6 snapshots). Validator caught two real authoring bugs pre-browser: comma separators are INVALID inside loop() bodies and inside if() argument paren-groups (they split into extra function args) — all groups converted to ';' separators.
- BEAT ENGINE FIX (the deep dive): E2E showed the Demo track fired ~1 beat/7s and never locked BPM (Beat tab: "no tempo", confidence 0). Progressive instrumentation (engine debug logs, raw FFT bin probes, component muting) isolated FOUR stacked causes:
(a) demo bassline (sawtooth 55 Hz, on every beat) sat inside the 20–100 Hz detector band, holding the rolling average so high the kick transient never cleared the 1.4× threshold;
(b) my first fix attempt introduced an envelope bug — pump swell ending at t+0.16 while the decay ramp ended at t+0.145 is INVALID Web Audio scheduling → discontinuity click → broadband noise floor ~0.25 across ALL FFT bins (kick-only test proved bins go to true zero); fixed by chronological envelopes (swell t+0.1, decay ≥ dur);
(c) the "octave-up" A2=110 Hz bassline still leaked into the band because FFT binning reaches ~111 Hz (ceil(100/5.86)=18) — revoiced C3 C3 Eb3 F3 (130.81/155.56/174.61);
(d) the 8192-FFT's 170 ms window integrates the kick, capping any kick's peak-to-mean ratio at ~1.5× — below the default 1.4× threshold only marginally, so beats fired sporadically at irregular intervals and interval-learning never locked. Fixed by remxing the demo (kick gain 1.6, sweep 0.08 s, 0.15 s decay; off-beat ducked bassline; ratio ~1.5×) AND lowering DEFAULT_BEAT_CONFIG.threshold 1.4 → 1.2 (worklog history shows demo detection was always flaky at 1.4; minInterval + 3-consistent-interval learning still guard against false beats; per-visitor slider unaffected for stored prefs).
- RESULT: Demo locks "124.1 BPM" on the header indicator within seconds; beat counter climbs ~2/sec (50→56 in 3 s). All debug instrumentation removed (engine debug block, audio debugBin, watcher-nudge comment); audio.ts getWave/getSpectrum repaired after an edit misfire (verified via git diff review).
- VERIFIED: lint clean; tsc clean (only pre-existing examples/ errors); headless validation ALL CHECKS PASSED (10 presets + function sanity); E2E: tree shows "Zai-Magic · 10" (105 presets · 8 folders), every preset double-click loads (SCRIPT: name in footer), Strobe Lattice captured on two beats → VLM: green/yellow vs pink/magenta (palette swaps on the beat — PROOF), Bass Bloom shows blue AND green particle arcs in ONE frame (particles remembering different beats' hues), Beat Matrix renders the spectrum grid, help panel shows beatage + hsvr docs, footer "version 6.10", dev.log clean, Stop → SRC: IDLE.
Stage Summary:
- v6.10 shipped: root/embedded/Zai-Magic holds ten hand-authored presets that flex the whole engine — all three render modes, megabuf particle physics with per-particle beat-hue memory, direct getspec bands, loop()-seeded state, kaleidoscope/tunnel/lattice/lissajous geometry — every one driven by BEAT-SYNCED COLOR (per-beat hue kicks, per-bar scheme shifts, beatage kick envelopes). New script tools (hsvr/hsvg/hsvb, smoothstep, beatage) documented in the help panel for visitors to use in their own presets. The demo track now genuinely locks 124 BPM (remixed for detection + threshold 1.4→1.2), so the beat-color showcase works out of the box with zero audio sources.
---
Task ID: 26
Agent: main (Z.ai Code)
Task: Version renumber — user pointed out the bottom-left footer read "version 6.10" when it should be v7.00: 6.9.3 was the last release, then came the Zai-Magic preset family + new script variables/functions (a major feature drop), so the version deserves a major bump, not a minor one.
Work Log:
- Located every consumer of the version stamp: APP_VERSION (src/lib/version.ts) feeds the footer (footer-bar.tsx), document title + og:title (layout.tsx); EMBEDDED_SEED_VERSION (src/lib/ava/embedded-presets.ts) gates the preset-DB re-seed.
- src/lib/version.ts: APP_VERSION '6.10' → '7.00'; changelog entry renumbered 6.10 → 7.00 with an explicit note recording WHY it jumps (new preset family + new script-language features = major release; 6.9.3 was the last 6.x patch) so future history readers understand the gap.
- src/lib/ava/embedded-presets.ts: EMBEDDED_SEED_VERSION '6.10' → '7.00' (+ the two v6.10 comments in the header and the KEEP-VERBATIM Zai-Magic banner). Bump verified safe: the seed gate only re-writes the read-only embedded subtree from source; visitor presets live in the browser.
- VERIFIED: lint clean; served HTML shows "OVE — Open Visualization Engine (v7.00)" (title + og:title) and footer "version 7.00", zero occurrences of 6.10 in the page; GET /api/presets 200 with the version-gate re-seed firing exactly as designed (deleteMany + createMany + marker upsert in dev.log); re-seeded library intact — 105 presets: Amp Picks 66, Envision 15, Dynamics 9, Synth-C 5, Zai-Magic 10.
Stage Summary:
- v7.00 shipped (renumbered from the never-deployed 6.10): the footer bottom-left, document title, and share metadata all read v7.00, matching the user's mental model — 6.9.3 + Zai-Magic + new script tools = the first release of the 7.x line. The embedded-library seed marker moved to 7.00 in the same stroke, so production deploys will seed Zai-Magic fresh on first visit.
---
Task ID: 27
Agent: main (Z.ai Code)
Task: Replace the synthesized Demo track with the operator's real MP3 banger ("Neural Mecha Pulse", Google Drive) so the project demos against real mastered music — and make the beat engine actually survive it.
Work Log:
- Downloaded the track (4.8 MB, 3:18, 48 kHz stereo, ~194 kbps, ID3 title "Neural Mecha Pulse") → public/demo-track.mp3 (served same-origin).
- HEADLESS VALIDATION FIRST (replicating the exact browser chain: AnalyserNode 8192-FFT Blackman window, byte dB scale [-100,-30], most-recent-8192-samples per frame, 60 fps stepping, the REAL BeatTracker class): the 6.x level-threshold detector CANNOT lock this track — sustained club sub-bass holds the 20-100 Hz band at ~1.2× peak-to-mean (measured), so threshold crossings fire sporadically at the minInterval floor and the interval learner folds 250 ms→500 ms → phantom 120 BPM (true tempo: 133.3, and the track drifts 130.7→133.1 through its body).
- Signal-analysis detour (all headless, /home/z/tmp-validate/): onset envelopes (spectral flux) scored 88% beat-carrying vs 65% for bass level; per-bin periodicity scan showed the beat spread across the whole low spectrum with strong 450/683/900 ms metrical-level competition — no per-frame threshold rule can separate kick frames in this dense mix. CONCLUSION: the detector core had to become autocorrelation-on-onsets (aubio/librosa-style), not threshold-crossing.
- NEW DETECTOR (beat.ts rewritten, public API unchanged: update/standby/tap/getBpm/getPhase): full-band spectral-flux onset envelope (computed in audio.ts getBands, new BandLevels.flux) → resampled onto a FIXED 60 Hz time grid → ~4 s ring → every ~100 ms: autocorrelation over 300-1000 ms lags (60-200 BPM), best LOCAL max of AC × log-BPM prior (σ=0.45 oct around 125) × lock-hysteresis (×1.25), parabolic-refined, gated by normalized r = AC(peak)/Var ≥ 0.25; subdivision resolver (cand>500 ms → half when the half-lag AC ≥ 75% and prior ≥ 0.5 — maps 900→450 half-tempo competitors onto the beat level where they AGREE with the lock, and mangles 683 ms competitors into non-agreeing lags that can never commit; the prior gate stops it mangling true 110 BPM songs); 2 agreeing candidates lock (3 for tempo CHANGES; a change caps confidence at 0.6 so a wrong steal decays fast); comb-filter phase alignment with 25% phase-hysteresis (dance music's kick-vs-offbeat-bass onset trains make the raw comb flap half a period); beats fire at absolute nextBeatAt timestamps with a PLL pull (≤3% of period per estimate, 5% deadband) + background-tab catch-up guard. floorLevel changed units (bass-level 0.04 → flux 0.0015); store.ts readStoredBeatConfig migrates stored visitor values ≥ 0.01 (old level-domain numbers would dead-gate the new detector).
- THREE real bugs found and fixed through A/B instrumentation (prototype vs port fed identical input):
(a) frame-indexed ring: headless Chrome's rAF runs at a jittery 17-83 ms cadence (measured), smearing AC lags into noise → fixed by the 60 Hz grid resampler;
(b) resampler under-fill: ticks anchored at prevTime+k×step vs ULP-noisy gaps under-push ticks (ring time-compressed ~7%, a 133 BPM track reading 420-431 ms candidates) → fixed with ABSOLUTE integer-indexed grid ticks (gridOrigin + i×step);
(c) prototype's stability partly relied on the subdivision rule's accidental change-blocking — made the mechanism explicit (prior tightening + ×1.25 hysteresis + provisional tempo changes).
- VALIDATION SUITE (real BeatTracker, real track PCM): uniform 60 fps (any real user machine): lock 1.9 s, held 131-133 for 97% of post-lock time, 92% beat-gap regularity, confidence 0.99, 403 beats; synthetic worst-case rAF jitter: 77-90% held with beats always on a sane grid; silence: zero beats. Techno-124/hiphop-90 synth patterns lock exactly. Known limitation (documented in beat.ts + Beat tab): sparse kick-vs-snare backbeats (dnb) may lock at half tempo — stable, musically defensible, two taps of Set Beat correct it.
- DEMO TRACK REWIRE (audio.ts): startDemo now fetches /demo-track.mp3 → decodeAudioData (cached) → gapless looped AudioBufferSourceNode → gain 0.9 → analyser → destination; sourceLabel "Neural Mecha Pulse". Entire synth apparatus deleted (scheduleDemo/kick/snare/hat/bassNote/noiseBuf/step/nextStepTime/demoTimer/demoBpm/setDemoBpm — ~130 lines).
- UI: header BPM slider (meaningless for a real track) replaced by a track-name chip (Music2 icon + "Neural Mecha Pulse"); Demo button tooltip updated; demo toast now announces the track; Beat tab copy rewritten for the new detector (attacks/autocorrelation/comb, sensitivity = periodicity requirement); store: demoBpm/setDemoBpm removed.
- engine.ts feeds levels.flux to the tracker (bassAtt→flux, one line + comments); EMBEDDED_SEED_VERSION intentionally stays 7.00 (preset library unchanged).
- BROWSER E2E (Playwright, headless Chrome): initially BPM wandered 105-122 — root-caused through staged instrumentation (rAF cadence probe → ring dumps → analyser freshness probe → pure 133.33 BPM click-track test → parallel AudioContext clock): THIS SANDBOX's headless-Chrome dummy audio device runs at 0.847× realtime (16.94 s of audio per 20 s wall) — the music genuinely plays ~15% slower here and the detector honestly reports the HEARD tempo (133.3 × 0.85-0.92 ≈ 113-123; a perfect click track reads ~119). Beats stay aligned with the audible beats; on real hardware (audio/wall = 1.0) it reads the true tempo. All debug instrumentation removed.
- FINAL E2E (sandbox-aware expectations): page v7.1 ✓; Demo → BPM 116.5, stable 116.9/117.5 across 30 s, confidence 100% ✓; "Neural Mecha Pulse" chip in header + footer source ✓; beats 11→30 over 10 s ✓; Beat Matrix preset → VLM verdict BEAT-REACTIVE (palette shifted cool teals → warm pinks/oranges between beat-separated frames) ✓; Stop → BPM "—" ✓; zero console/page errors ✓. lint clean, tsc clean, dev.log clean.
Stage Summary:
- v7.1 shipped: the Demo button now plays the operator's real mastered track, and the beat engine was rewritten to survive it — onset (spectral-flux) envelope → fixed 60 Hz grid → autocorrelation with tempo prior, metrical-level subdivision resolver, comb phase with hysteresis, and a PLL-glued beat grid. Headlessly proven on the actual PCM (97% tempo hold at uniform timing; the 6.x detector scored 0). The old demo synth, its BPM slider, and setDemoBpm are gone (replaced by a track chip). Visitor-stored beat configs migrate automatically. E2E verified in-browser end-to-end; the sandbox's slow dummy audio device (0.847×) makes BPM read ~115-120 here — on real hardware it reads the true ~133. Known limitation: dnb-style sparse backbeats may lock half-tempo (tap-correctable). The E2E agent attempt (Task 27-e2e) exceeded its turn budget without recording; all browser verification in this entry was done directly via Playwright.
---
Task ID: 28
Agent: main (Z.ai Code)
Task: v7.2 new-visitor showcase flow — (1) default theme = Black + Purple Outline base with Blue accent tab for new visitors; (2) Zai-Magic "Bass Bloom" as the default preset when none is selected and music loads; (3) animated shimmer/reflective moving shine on the opening Demo Track button; (4) clicking it selects the Zai-Magic folder, enables randomization for that folder, sets the change time to 20 s; (5) preset changes (manual load or random) blur-dissolve from the previous preset instead of hard-cutting.
Work Log:
- THEME DEFAULTS: src/lib/theme.ts DEFAULT_BASE 'purple'→'outline', DEFAULT_ACCENT 'yellow'→'blue' (comment records the v7.2 showcase rationale; stored visitor choices always win). src/app/layout.tsx next-themes defaultTheme 'purple'→'outline' + comment. globals.css :root no-JS fallback accent plumbing yellow→blue values (96 165 250 / #1e3a8a / #1d4ed8, matching the existing [data-accent="blue"] block). settings-tab pickBase: explicit outline pick now auto-pairs BLUE accent (was purple — would have yanked new-default visitors off blue); outline base blurb "(blue by default)"; chipFor fallback #fde047→#60a5fa. getStoredDarkBase inherits the new default automatically.
- DEFAULT PRESET: page.tsx useInitialLoad startup chain (no saved default script) now = Bass Bloom in root/embedded/Zai-Magic → any Zai-Magic → first preset → template (was: first Synth-C classic). audio-source.ts new ensureDefaultPreset(): called after EVERY successful source start (demo/mic/file/link via playResolvedTrack) — if activePresetId === null, engine-loads Bass Bloom (editor text untouched, random-switch courtesy) + "Default preset running" toast.
- SHIMMER CTA: globals.css .shimmer-cta — skewed white light band (::after, 45% width, translateX -180%→340% skewX(-14deg), ease-in-out, 3.2 s cycle with 45% travel + pause) sweeping across the idle-overlay Demo Track button; pointer-events none; prefers-reduced-motion disables it. Applied to the Demo Track button in visual-stage.tsx.
- DEMO SHOWCASE FLOW: audio-source.ts startShowcaseMix() on every successful startDemo: selectFolder('root/embedded/Zai-Magic') (also scopes randomize) + toggleFolder expand + setRandomIntervalSec(20) + setRandomize(true); demo toast rewritten ("Neural Mecha Pulse · Zai-Magic preset mix — a new visual every 20 s…").
- BLUR TRANSITION: engine.ts setScript() snapshots the live canvas into an offscreen canvas before swapping (only when a script was already rendering); loop() overlays the snapshot for 700 ms with easeOutCubic alpha fade 1→0 + progressive blur (ctx.filter, max e²·12·(h/480) device px) + 6% scale-up so softening edges never gap the border; feature-detected canFilter (older Safari → plain crossfade); rapid swaps re-snapshot the mid-transition composite (continuous); stop()/start() clear it. Covers every swap path (tree Load, random timer, Demo flow, Script Run) since they all funnel through setScript.
- POLISH FIX found during E2E: script.ts header parser kept raw quotes (name: "Bass Bloom" → footer read SCRIPT: "Bass Bloom"). Added quote-stripping for name/author/description string headers — footer now reads SCRIPT: Bass Bloom (all presets benefit; nothing compared meta.name raw).
- E2E (Playwright headless, /home/z/tmp-validate/e2e-v72.py, two revisions): FIRST RUN caught 3 test-script artifacts (toast checked at +1.2 s while the 4.8 MB track was still fetching/decoding; footer innerText has a newline SRC:\nDEMO; my transition target collided with the random pick) + the real quotes bug above. REV 2 ALL PASS: fresh visitor html class "outline" + data-accent "blue" + footer "version 7.2" + SCRIPT: Bass Bloom (clean); shimmer-sweep 3.2 s animation running + button-region luma 147.95→158.2 as the band crosses (7-frame series); Demo → toast "Demo track playing/Zai-Magic preset mix" + SRC: DEMO + Neural Mecha Pulse chip + Zai-Magic treeitem selected AND expanded + randomize checked + delay "20" + "ON · every 20s" chip + "Random from: Zai-Magic"; 23 s later SCRIPT switched Bass Bloom→Tunnel Vision (random mix) with BPM alive 130.6; manual load Tunnel Vision→Prism Chandelier mid-transition frame RMS 55.05 vs settled vs late 29.33 (monotonic dissolve, no hard cut); create "test-default" user preset → delete (activePresetId pruned null) → Demo → "Default preset running" toast + Bass Bloom auto-loads; returning visitor with stored purple+yellow keeps it; ZERO console/page errors; lint clean; tsc clean (only pre-existing examples/); title "OVE — Open Visualization Engine (v7.2)"; /demo-track.mp3 200.
- VLM visual confirmation: fresh-visitor shot = blue accent, diagonal shine band visible on the Demo Track button, footer Bass Bloom; demo-flow shot = Zai-Magic folder selected+expanded (Aurora Veil/Bass Bloom visible), ON · every 20s chip, Random from: Zai-Magic, Neural Mecha Pulse header chip, blue accent; transition pair = mid-frame "colorful atomic orbital pattern overlaid on a large, fading concentric ring background" (new preset emerging under the dissolving old one) vs settled = one clean crisp visual.
Stage Summary:
- v7.2 shipped: new visitors land on the Black + Purple Outline theme with Blue highlights and Bass Bloom already running; the idle overlay's Demo Track button carries an animated reflective shimmer that invites the click; that one click starts Neural Mecha Pulse AND the showcase club mix (Zai-Magic folder selected + expanded, randomize ON scoped to it, new random preset every 20 s); any music started with no preset active auto-loads Bass Bloom; every preset change now blur-dissolves over 700 ms instead of hard-cutting. Returning visitors' stored theme/randomize choices are untouched. Footer preset names lost their literal quotes (parser polish).
---
Task ID: 29
Agent: main (Z.ai Code)
Task: Fix the Zai-Magic "Supernova" preset — user report: "It does one effect once when loaded, but then the screen just stays black instead of reacting to the music."
Work Log:
- ROOT CAUSE (proven headlessly before touching anything, /home/z/tmp-validate/supernova-diag.ts): the particle kill condition `above(abs(px)+abs(py), 2.4)` was PHYSICALLY UNREACHABLE dead code. Drag physics (pos += vel·exp(-age·1.6)·dt) caps a particle's total displacement at vel/1.6 ≈ 1.0, so max |px|+|py| ever reached was 1.379 (measured over 11.7 s of 133-BPM simulation) — nothing ever crossed 2.4, nothing ever died. Since respawning requires a DEAD particle (age < 0), the per-beat `budget = n` detonation could never fire after [init]'s one-time budget=620 — the preset detonated once on load, every particle faded to invisible (lum→0) but stayed "alive", and beats arrived to a field with nothing dead to respawn. sumAlpha decayed 620→0.0 by frame 540 and stayed there: permanent black. (The original v7.00 validator missed this because its beat-snapshot check only required colors to MOVE between snapshots — fading-to-black counts as moving.)
- FIX (src/lib/ava/embedded-presets.ts, Supernova content): (1) [beat] now force-clears the entire field — `k = 0; loop(n, megabuf(k*6+4) = -1; k = k + 1)` — before `budget = n`, so EVERY beat detonates the full 620-particle field on that very frame (engine section order frame→beat→point verified in engine.ts); (2) the unreachable position-kill replaced by a fade-based kill `if(below(lum, 0.02), (megabuf(b0+4) = -1; skip = 1), 0)` so during long beat gaps/breakdowns fully-cooled particles die and the per-frame budget trickle (+1/frame) respawns them as gentle embers — the screen never goes permanently black; (3) blast geometry now re-randomizes per beat (rand seeds + beats·13.7 / beats·7.31) — no two explosions alike, matching the "supernova" concept.
- DIAGNOSTIC HARNESS BUG found & fixed along the way (supernova-diag.ts): my first verification run showed all-zero draws — the harness didn't reset env.skip/env.alpha between per-point executions like the real engine does (engine.ts resets per-point outputs before each execPoint); the phantom-slot point (k=floor(i·n) with the engine's i=k/(n-1) convention maps the last point to slot 620, age exactly 0 → skip=1) poisoned the stale skip for every following frame's count. Per-point probe (supernova-probe.ts) with the proper reset proved the fix worked; harness corrected, plus the blast-geometry check rewritten from position-drift (miscalibrated — spawn radius is a constant 0.03, max drift 0.06, random-pair mean 0.038) to angular decorrelation (mean |Δangle| > 0.5 rad).
- HEADLESS VERIFICATION: steady 133-BPM 12 s sim — ALL 26 beats re-detonate the full field (sumAlpha 619/620 on every beat frame), blast directions decorrelated between beats, zero non-finite outputs; breakdown sim (beats stop after 2.5 s) — embers keep sumAlpha 86.8 at t=10 s (pre-fix: 0.0). Full 10-preset regression validator: ALL CHECKS PASSED (Supernova now 2/6 distinct beat-snapshots — expected: each beat frame starts white-hot at the core by design).
- EMBEDDED_SEED_VERSION 7.00 → 7.01 (library content changed → browser DBs re-seed); APP_VERSION 7.2 → 7.2.1 + changelog entry documenting the unreachable-kill root cause.
- VERIFIED: API serves the fixed content (beat-wipe loop + fade-kill + beat-varying seeds all present in GET /api/presets, 105 presets intact after re-seed); lint clean; tsc clean (only pre-existing examples/); dev.log clean (single "Compiling /_error" line = Next.js built-in route, benign).
- BROWSER E2E (/home/z/tmp-validate/e2e-supernova.py, Playwright headless): fresh page → version 7.2.1 ✓; Demo Track → SRC: DEMO ✓; showcase randomize (auto-ON from v7.2 flow) disabled for the test ✓; Supernova dblclick → footer "SCRIPT: Supernova" ✓; BPM locked 133.7 (true tempo — the sandbox slow-audio issue from Task 27 did not bite this run) ✓. THE REGRESSION TEST: 26 canvas samples over 10.4 s STARTING AFTER the initial blast — late-window (≥4 s) min luma 4.51 / mean 20.83 (pre-fix: permanent black), luma spread 34.22 with 5 local maxima (pulses on the beat), RMS 29.64 between frames 2.4 s apart (fresh geometry + hue per beat); ZERO console/page errors. VLM on two late frames ~2 s apart: "both images show a vibrant, colorful particle explosion" (green/yellow dominating — the beat-synced hue), "different visual states… indicating active animation over time" (one diffuse blast vs one compact bright core with an outer ring — the detonate→expand→re-detonate cycle).
Stage Summary:
- v7.2.1 shipped: Supernova detonates on EVERY beat again. Root cause was an unreachable kill threshold (2.4 vs 1.38 max reachable under drag) that left the whole field alive-but-invisible, so the beat budget had nothing to respawn; fixed by force-clearing the field in [beat] (guaranteed full re-detonation each beat), a fade-based kill for ember survival during breakdowns, and per-beat blast re-randomization. Embedded library re-seeds via marker 7.01. Verified end-to-end headlessly (26/26 beats re-detonate, embers alive in gaps, zero NaN, full 10-preset regression) and in-browser (screen alive 10+ s past the initial blast, beat-pulsing luma, evolving visuals, VLM-confirmed colorful explosions, zero errors). Note for future preset authors: with drag-modulated velocity, position-threshold kills must be computed against vel·∫drag dt — or just kill by fade like Bass Bloom does.
---
Task ID: 30
Agent: main (Z.ai Code)
Task: v7.3 demo-flow refinements — (1) Demo Track stops after ONE play-through (it is a demo pass, not a radio station); (2) the mix mode the Demo flow checked gets unchecked when the demo stops; (3) the visitor returns to the main landing dialog after the demo finishes; (4) preset loaded/changed popup toasts disabled (the footer SCRIPT: label already echoes every change); (5) the mix timer never changes the selected preset when no music is playing.
Work Log:
- audio.ts: startDemo no longer sets src.loop=true — the AudioBufferSourceNode plays once; an 'ended' listener (stale-source guarded: only the CURRENT demoSource may retire the engine) calls stopSources(). New public callback onDemoEnded, fired EXACTLY ONCE from stopSources() whenever an ACTIVE demo is torn down — natural end, Stop button, or switching to another source all funnel through the same path. Header Demo tooltip updated ("plays once through").
- audio-source.ts: startDemo wires engine.audio.onDemoEnded → syncSource() (store goes idle → the visual-stage idle overlay, keyed on source==='idle', returns automatically) + retireDemoShowcase() (unchecks the mix mode the showcase checked). Demo toast copy now says "one play-through".
- Toast removal: "Preset loaded" (presets-tab useLoadPreset), "Random preset" (store loadRandomPreset), "Default preset running" (ensureDefaultPreset) all deleted — the footer SCRIPT: label (driven by engine stats at ~8 Hz) echoes every change; only compile errors still toast. store.ts's now-unused toast import removed.
- Idle guard: loadRandomPreset returns early when store.source==='idle' — silence never re-shuffles the selected preset; the checkbox stays as the visitor left it.
- NOTE: this entry was written retroactively alongside Task 31 — v7.3's browser E2E initially failed on a test-harness issue (Zai-Magic folder is collapsed at fresh load; fixed by clicking the "Expand folder Zai-Magic" tree expander) and the user added the Sequence request before the re-run, so both versions were verified together in the v7.4 E2E below.
Stage Summary:
- v7.3 shipped (verified as part of the v7.4 E2E): single-pass demo, showcase unwind on any demo end, landing-overlay return, silent preset-change toasts, no re-shuffling during silence.
---
Task ID: 31
Agent: main (Z.ai Code)
Task: v7.4 Sequence mode — a 'Sequence' checkbox next to Random that cycles the folder's presets IN ORDER (wrap past last → back to first) instead of shuffling; Random and Sequence mutually exclusive; Demo showcase uses Sequence (walks all Zai-Magic presets in order); Random never loads the same preset twice in a row; Sequence unchecks when the demo stops; Sequence holds through silence like Random; both share the same delay time. (The user's later "blank the screen when no music" request was explicitly RETRACTED by them — presets stay visible without music.)
Work Log:
- store.ts: new sequenceMode state + setSequenceMode action; BOTH mode setters implement mutual exclusion on the ON path (set(on ? {mode:true, other:false} : {mode:false}) — turning one off never lights the other). One timer, one dispatcher: syncRandomTimer runs when EITHER mode is on and ticks autoSwitchTick → sequenceMode ? loadNextPreset() : randomize ? loadRandomPreset().
- loadNextPreset (new): same scope-pool logic, sorts the pool with the tree's exact comparator (name, case-insensitive localeCompare), finds the ACTIVE preset's index and advances (idx+1+len)%len — idx=-1 (nothing active / active outside scope) starts at the FIRST preset; past the last preset it wraps to the first, forever. Idle guard identical to Random's; editor text never touched; no toast.
- loadRandomPreset: no-repeat fix — candidates = pool minus the running preset whenever the pool offers a real choice (pool.length>1), so Random can never load the same preset twice in a row.
- Persistence: LS_RANDOM payload gains `sequence: boolean`; enabled = whichever mode is on; restoreRandomSettings brings back exactly the saved mode (old saves without the flag → Random, as before).
- audio-source.ts: startShowcaseMix now calls setSequenceMode(true) (mutual exclusion unticks Random if the visitor had it on) — the demo pass walks every Zai-Magic preset in tree order, 20 s each, instead of repeating random picks; retireDemoShowcase unchecks EITHER mode; demo toast says "preset sequence — every preset in order".
- presets-tab.tsx: Sequence checkbox (id ava-sequence, aria "Sequence presets") next to Random (label shortened "Randomize"→"Random" to match the user's terminology); mode-aware scope line ("Sequence from:" / "Random from:"), per-mode hint line (cycle+wrap vs shuffle+no-repeat), chip now reads "SEQUENCE · every Ns" / "RANDOM · every Ns".
- version.ts: APP_VERSION 7.4 + changelog entry (7.3 entry written in the same pass; both released together).
- HEADLESS LOGIC TEST (/home/z/tmp-validate/sequence-test.ts, real store + real engine, localStorage stubbed because bun has none): mutual exclusion both directions + off-doesn't-light-other; Sequence walked Bass Bloom → Beat Matrix → Neon Heartbeat → Tunnel Vision → Aurora Veil → Bass Bloom → Beat Matrix (exact sorted order, wrap verified); Random 6 picks zero consecutive repeats; idle freeze (7 s, 2+ ticks, no advance); persistence payload {enabled:true, sequence:true, intervalSec:3} + restore brings back Sequence not Random. Two harness bugs found on the way (test-side only): the expected-list was misaligned by the initial-state sample and the collector filtered the legitimate wrap-back-to-start name; engine logic was correct both times.
- lint clean; tsc clean (only pre-existing examples/).
- BROWSER E2E part 1 (/home/z/tmp-validate/e2e-v74.py, ALL PASS): fresh load version 7.4 + overlay + Bass Bloom startup; Zai-Magic expanded via the tree expander, Neon Heartbeat dblclick → footer echoes "Neon Heartbeat", NO "Preset loaded" toast; Sequence checked with no music → Random unchecked + 10 s frozen (no advance); mutual exclusion both ways in the UI; Demo click → SRC: DEMO + "preset sequence…one play-through" toast + SEQUENCE checked + RANDOM unchecked + delay 20 + "SEQUENCE · every 20s" chip + Zai-Magic selected&expanded + header chip + overlay hidden; with music at delay 3 the footer walked Prism Chandelier → Spectrum Serpent → Strobe Lattice → Supernova → Tunnel Vision → Aurora Veil (WRAP) → Bass Bloom → Beat Matrix → Lissajous Loom → Neon Heartbeat → Prism Chandelier (second lap) — the exact tree order; switch to Random → 9 picks zero consecutive repeats; Stop → overlay back + SRC: IDLE + BOTH modes unchecked + chip gone + BPM —; visitor re-enables Sequence after stop → 10 s frozen; ZERO console/page errors.
- BROWSER E2E part 2 (/home/z/tmp-validate/e2e-v74-natural.py, ALL PASS): demo started from the landing overlay (Sequence auto-on), still playing at 60 s, natural end at 200.7 s (full single play-through — no loop, no early stop) → landing overlay back, SRC: IDLE, Sequence AND Random unchecked, track chip gone, BPM —, zero errors.
- dev.log clean.
Stage Summary:
- v7.4 shipped: the Presets panel's mix row now offers Random | Sequence | delay — Sequence cycles the scope folder top-to-bottom and wraps, Random shuffles without immediate repeats, ticking either unticks the other, both share the delay and freeze through silence, and the saved mode survives reloads. The Demo showcase runs Sequence so the one play-through walks all ten Zai-Magic presets in order, and however the demo ends (finish, Stop, source switch) the mode is unchecked and the visitor is back at the landing dialog. The user's "blank screen on silence" idea was retracted — presets remain loadable and visible without music.
---
Task ID: 32
Agent: main (Z.ai Code)
Task: v7.5 yt-dlp compatibility (user request): many YouTube links died with "Playback failed: The media resource indicated by the src attribute or assigned media provider object was not suitable." Root cause: the old flow proxied the extractor's STREAM URL — googlevideo URLs are IP-locked (the resolver's IP is baked in), expire within hours, and the default "bestaudio" pick is webm/Opus, a container several browsers refuse outright. Fix per the user's design: resolve single tracks by DOWNLOADING only the audio and converting to MP3 server-side (yt-dlp -f bestaudio/best -x --audio-format mp3 --audio-quality 0 -o "%(title)s.%(ext)s"), preserving the video's real title for the Playing: label, plus hardening to prevent the other error classes.
Work Log:
- Root-cause confirmed live from the sandbox (which sees the same datacenter-IP conditions as the user's VPS): bare yt-dlp hits the bot wall; with the house cookie + Deno in PATH the metadata pass works and the picked stream is itag 251 webm/opus (mime=audio/webm, ip=<resolver IP> baked in) — exactly the fragile thing the browser was being fed through the proxy.
- lib/server/audio-files.ts (new): per-track mkdtemp workspace (ove-audio-*), findConvertedMp3 (scans for the .mp3 the ExtractAudio post-processor leaves behind), removeDownloadDir, and a throttled orphan sweeper (dirs older than 1 h — crash leftovers a lapsed entry can never prune).
- stream-store.ts: entries now come in two kinds. New file entries {dir, path, bytes, contentType} own their download dir; prune() removes the dir when the entry lapses (30-min sliding TTL) and nudges the sweeper. createFileStreamEntry added; proxy entries unchanged.
- /api/audio/stream: file-serving mode with FULL HTTP Range support — parseRange handles open/closed/suffix forms, 206 + Content-Range/Content-Length/Accept-Ranges on hits, 416 with "bytes */size" on unsatisfiable, 200+Content-Length on full GET, always Content-Type audio/mpeg; streams via fs.createReadStream → Readable.toWeb. File entries skip the SSRF gate (nothing is fetched). Proxy mode byte-identical to before.
- /api/resolve: pass 1 (metadata, -J) unchanged → playlist branch unchanged; single tracks now run pass 2, the operator's pipeline verbatim: -f bestaudio/best -x --audio-format mp3 --audio-quality 0 --no-playlist --no-progress --no-warnings --socket-timeout 15 --retries 3 --fragment-retries 3 --max-filesize 300M -o "<workspace>/%(title)s.%(ext)s". The metadata pass's title/duration/source ride in the response untouched (Playing: shows the video's real name, e.g. 孟婆). One semaphore acquire spans both passes; YTDLP_QUEUE_TIMEOUT_MS 40 s → 120 s and DOWNLOAD_TIMEOUT_MS 180 s (downloads take minutes, not the 30 s of a metadata pass). New shared classifier answerYtdlpFailure() (spawn/timeout/unsupported/bot-wall/stale) used by BOTH passes so they can never drift. Failure policy: --max-filesize → honest "too long" 422; ffmpeg-missing (token+missing-phrase AND-match) → one-time operator console.warn + graceful degrade to the legacy proxy path (5c, byte-identical pre-v7.5 behavior, incl. the HLS/DASH rejection and cookie-header rules); unrecognized download failures also fall through to the proxy as best effort; recognized diagnoses (bot-wall, stale-extractor, spawn, timeout) answer directly.
- queue.ts: PRELOAD_LEAD_SEC 15 → 60 — each queued track now needs 10–60 s of server conversion; a 15 s look-ahead would have stalled silence between songs.
- link-dialog.tsx: Load button says "Fetching" (spinner) while busy, plus a status line "Downloading the audio and converting it to MP3 on the server…" so the longer wait is explained instead of looking stuck.
- version.ts: APP_VERSION 7.5 + changelog. lint clean; tsc clean (pre-existing examples/skills only).
- LIVE GROUND-TRUTH (sandbox, house cookie, real YouTube): the operator's command shape verified end-to-end manually first (孟婆.webm → 孟婆.mp3, 10,145,420 bytes, VBR ~239 kbps, 339.6 s — matches the video duration; original container deleted by the post-processor).
- API E2E: POST /api/resolve with the user's exact link → 200 in 19 s {title 孟婆, source youtube, durationSec 340}; stream then verified: bytes=0-1023 → 206 "bytes 0-1023/10145420" audio/mpeg with ID3 (49 44 33) at byte 0; mid-file 206; bytes=-1024 suffix 206 (last 1024 bytes); unsatisfiable → 416 "bytes */10145420"; full GET → 200 Content-Length 10145420 (byte-exact); workspace /tmp/ove-audio-*/孟婆.mp3 present; second resolve → cache hit, SAME token in 16 ms (no re-download); direct MP3 link (soundhelix) still resolves instantly through the proxy path; bogus token → 404.
- BROWSER E2E (agent-browser + VLM, ALL PASS): fresh load v7.5 + landing overlay; Stream a Link → dialog → paste the user's link → Load: busy state shows the disabled "Fetching" button + the converting status line; after the ~20 s first-visitor download playback STARTS BY ITSELF — footer SRC: 孟婆, header BPM live (131.8 → 128.2 across checks, beat detector locked), 60 FPS canvas; second (cache-busted) URL exercised the full download path again with the same result; re-resolve of the cached link played instantly. VLM on two frames 3 s apart: visualization animating (colors/shape/BPM changed), "SRC: LINK · 孟婆" visible, no errors/broken UI. One React hydration advisory appeared in the FIRST cold session and did NOT reproduce on any subsequent load/interaction (fresh loads are clean) — a one-off on first compile, not a regression; page errors zero throughout. dev.log clean.
- NOTE for the operator's VPS: the MP3 pipeline requires ffmpeg on the server (present here: ffmpeg 7.1.5). Without it the resolver degrades to the old proxy path and logs one console warning — install with apt install ffmpeg to enable the reliable pipeline.
Stage Summary:
- v7.5 shipped: music links now resolve by download-and-convert — the server runs the operator's command (audio-only, VBR-0 MP3, title preserved), serves the finished file same-origin with full Range support, and the Playing: label shows the video's real title. The three failure classes behind "media resource not suitable" (IP-locked URLs, URL expiry, webm/Opus containers) are gone by construction; bot-wall/stale-extractor self-healing is unchanged, over-long tracks answer honestly, a missing ffmpeg degrades to the legacy proxy, and playlist look-ahead leaves room for conversions.