Fetch lyrics from LRCLIB (synced when available) and serve an admin media library of uploaded video and audio with cover art and embedded lyrics

This commit is contained in:
Jonathan Sykes
2026-09-20 05:59:50 +08:00
parent 9c1ec4ac51
commit b983c6ca3c
14 changed files with 963 additions and 25 deletions

View File

@@ -142,6 +142,30 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
(upsertMedia rows + `<id>.1.m4a/.mp4` files) — Playwright's Chromium plays
AAC; launch with `--autoplay-policy=no-user-gesture-required`.
## Uploads + web lyrics
- **Uploads** (`server/uploads.js`, `UPLOAD_DIR`, table `uploads`): an admin
posts a file to `/api/admin/uploads` (multipart, ≤ 4 GB, admin cookie or API
token). ffprobe runs ONCE and gives everything: title/artist/album tags,
duration, whether there is a REAL video stream (a cover picture also appears
as a video stream — check `disposition.attached_pic`), the cover itself
(extracted to `<id>.art.jpg`; an uploaded image wins) and embedded lyrics
tags (`LYRICS` / `lyrics-eng` / `UNSYNCEDLYRICS` / `©lyr`) which are parsed
with `parseLrc` and saved as that song's shared lyrics (synced when in LRC
form). Ids are `upl_<12 hex>` and are accepted anywhere a video id is:
`/api/streams` (audio → `audioUrl` + art, video → one "Original" quality),
`/api/download`, peaks/gif/clip (`audioSourcePath`/`videoSourcePath`), notes.
`/api/search` puts the library first and still answers when yt-dlp fails.
The app: 📁 Library view, `isUploadId`/`isMediaId` helpers, audio uploads
force audio mode so the cover art shows where the video would be.
- **Web lyrics**: `POST /api/notes/:id/lyrics/web` asks **LRCLIB** (free,
key-less, often SYNCED) using a cleaned title/artist + duration
(`cleanTitle`/`cleanArtist`/`pickLrclib`, ± 6 s). Profile users, tokens and
admins may call it; the lyrics worker tries it BEFORE transcribing.
`scripts/lyrics/web_lyrics.py --missing --agy` adds an agy web-search
fallback (plain text → untimed lines, to be timed with Tap-sync).
Gotcha fixed here: a `/g` regex reused for `.test()` skips every other call
(lastIndex) — `cleanTitle` keeps a stateless copy.
## Watch party, sharing, playback extras (all in app.js unless noted)
- **Watch party** (`server/party.js`, `Party`): `/ws/party` (Bun has ONE websocket
handler — sockets are tagged `ws.data.hub = 'party'` and routed in server.js).