35a1a517-8c44-4c6c-81f5-41b0ac639b76
|
|
@ -1 +1 @@
|
|||
1173
|
||||
1195
|
||||
|
|
|
|||
105
agent-ctx/2-a-music-services-framework.md
Executable 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
BIN
bin/yt-dlp
BIN
db/custom.db
BIN
public/apple-touch-icon.png
Executable file
|
After Width: | Height: | Size: 2.2 KiB |
BIN
public/demo-track.mp3
Executable file
BIN
public/favicon-16.png
Executable file
|
After Width: | Height: | Size: 274 B |
BIN
public/favicon-32.png
Executable file
|
After Width: | Height: | Size: 471 B |
|
|
@ -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
|
After Width: | Height: | Size: 218 KiB |
49
secrets/house-youtube-cookies.txt
Executable 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
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const BASE_FOLDERS = [
|
|||
'root/embedded/Amp Picks',
|
||||
'root/embedded/Dynamics',
|
||||
'root/embedded/Envision',
|
||||
'root/embedded/Zai-Magic',
|
||||
]
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
434
src/components/ava/service-login-dialog.tsx
Executable 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
|
||||
"confirm you're not a bot" 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 & 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's key
|
||||
store, and is saved on this computer only. It is sent to this site'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>
|
||||
)
|
||||
}
|
||||
368
src/components/ava/settings-tab.tsx
Executable 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
111
src/lib/server/audio-files.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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). */
|
||||
|
|
|
|||
252
src/lib/server/ytdlp-selfupdate.ts
Executable 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
|
||||
}
|
||||
}
|
||||
113
src/lib/store.ts
|
|
@ -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
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
BIN
tool-results/ove65-final-default.png
Executable file
|
After Width: | Height: | Size: 109 KiB |
BIN
tool-results/ove65-light-green.png
Executable file
|
After Width: | Height: | Size: 118 KiB |
BIN
tool-results/ove65-light.png
Executable file
|
After Width: | Height: | Size: 118 KiB |
BIN
tool-results/ove65-matte-green.png
Executable file
|
After Width: | Height: | Size: 112 KiB |
BIN
tool-results/ove65-matte.png
Executable file
|
After Width: | Height: | Size: 114 KiB |
BIN
tool-results/ove65-mobile-footer.png
Executable file
|
After Width: | Height: | Size: 67 KiB |
BIN
tool-results/ove65-mobile.png
Executable file
|
After Width: | Height: | Size: 56 KiB |
BIN
tool-results/ove65-outline.png
Executable file
|
After Width: | Height: | Size: 115 KiB |
BIN
tool-results/ove65-settings-default.png
Executable file
|
After Width: | Height: | Size: 121 KiB |
BIN
tool-results/ove67-1-home.png
Executable file
|
After Width: | Height: | Size: 100 KiB |
BIN
tool-results/ove67-10-mobile-queue.png
Executable file
|
After Width: | Height: | Size: 52 KiB |
BIN
tool-results/ove67-12-desktop-queue.png
Executable file
|
After Width: | Height: | Size: 113 KiB |
BIN
tool-results/ove67-13-light-queue.png
Executable file
|
After Width: | Height: | Size: 139 KiB |
BIN
tool-results/ove67-14-light-noqueue.png
Executable file
|
After Width: | Height: | Size: 106 KiB |
BIN
tool-results/ove67-15-light-fixed.png
Executable file
|
After Width: | Height: | Size: 106 KiB |
BIN
tool-results/ove67-16-presets-fixed.png
Executable file
|
After Width: | Height: | Size: 104 KiB |
BIN
tool-results/ove67-2-coming-soon.png
Executable file
|
After Width: | Height: | Size: 122 KiB |
BIN
tool-results/ove67-3-coming-soon-panel.png
Executable file
|
After Width: | Height: | Size: 137 KiB |
BIN
tool-results/ove67-4-playlist-picker.png
Executable file
|
After Width: | Height: | Size: 142 KiB |
BIN
tool-results/ove67-5-queue-playing.png
Executable file
|
After Width: | Height: | Size: 93 KiB |
BIN
tool-results/ove67-6-queue-list.png
Executable file
|
After Width: | Height: | Size: 70 KiB |
BIN
tool-results/ove67-7-queue-done.png
Executable file
|
After Width: | Height: | Size: 70 KiB |
BIN
tool-results/ove67-8-yt-failure-path.png
Executable file
|
After Width: | Height: | Size: 88 KiB |
BIN
tool-results/ove67-9-settings-soon.png
Executable file
|
After Width: | Height: | Size: 84 KiB |
1549
tool-results/read_1793126224284_4414fd1218e0.txt
Executable file
778
tool-results/read_1793257458971_e7e497778ba8.txt
Normal 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→
|
||||
778
tool-results/read_1793257461037_d5a576fe8974.txt
Normal 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
|
|
@ -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})`)
|
||||
}
|
||||
815
worklog.md
|
|
@ -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.
|
||||
|
|
|
|||