Add a scrubbable timed lyric editor to the admin page, LRCLIB regeneration tooling with lyrics skills, and keep the five playback buttons on one row on phones
This commit is contained in:
93
.agents/skills/lyrics-lookup/SKILL.md
Normal file
93
.agents/skills/lyrics-lookup/SKILL.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
name: lyrics-lookup
|
||||||
|
description: Find and inject lyrics for songs saved on worship.hesed.sbs that have none — LRCLIB first (free, often synced), then an agy web search (untimed), then local faster-whisper transcription. Use when the user asks to add/fill/inject lyrics, says a song has no lyrics, or asks to transcribe saved songs. For replacing lyrics that are already there but wrong, use lyrics-regenerate instead.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Add lyrics to songs that don't have any
|
||||||
|
|
||||||
|
Shared lyrics live on the server (`video_notes`, kind `lyrics`) and every user of
|
||||||
|
worship.hesed.sbs sees the same ones. Every save keeps the previous version as a
|
||||||
|
revision (`video_note_revs`), so nothing is destroyed — but **never overwrite
|
||||||
|
existing lyrics from this skill**; that is `lyrics-regenerate`'s job, and it takes
|
||||||
|
a backup first.
|
||||||
|
|
||||||
|
## Sources, cheapest and best first
|
||||||
|
|
||||||
|
| Order | Source | Timed? | Cost | Where |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | **LRCLIB** | usually synced | free, no key | server-side: `POST /api/notes/<id>/lyrics/web` |
|
||||||
|
| 2 | **agy web search** (Genius/AZLyrics/hymnary…) | no — untimed | flat-rate agy | `web_lyrics.py --agy` |
|
||||||
|
| 3 | **faster-whisper** on this machine | synced (word timings) | free, ~0.65× real time | `auto_lyrics.py` |
|
||||||
|
|
||||||
|
Never reach for ElevenLabs Scribe or any credit-billed ASR — the user asked for
|
||||||
|
free transcription only.
|
||||||
|
|
||||||
|
## Credentials
|
||||||
|
|
||||||
|
Writing needs admin. Either env var works for every script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export YTP_ADMIN_PASSWORD='…' # the /admin password, see the ytplayer-admin-password memory
|
||||||
|
export YTP_TOKEN='ytp_…' # or an API token minted at /admin
|
||||||
|
```
|
||||||
|
|
||||||
|
The password is also vault secret `YTPLAYER_ADMIN_PASSWORD`
|
||||||
|
(`~/development/.secrets/ytplayer-admin.env`) — use it through
|
||||||
|
`vault__secret_exec` when you must not print it.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/development/personal/ytplayer
|
||||||
|
|
||||||
|
# 1) LRCLIB for everything that has no lyrics, then agy for the leftovers
|
||||||
|
YTP_ADMIN_PASSWORD=… python3 scripts/lyrics/web_lyrics.py --missing --agy
|
||||||
|
|
||||||
|
# just look, change nothing
|
||||||
|
YTP_ADMIN_PASSWORD=… python3 scripts/lyrics/web_lyrics.py --missing --dry-run
|
||||||
|
|
||||||
|
# 2) transcribe what the web doesn't have (needs the venv below)
|
||||||
|
YTP_ADMIN_PASSWORD=… ~/.local/share/lyrics-asr/.venv/bin/python \
|
||||||
|
scripts/lyrics/auto_lyrics.py --missing
|
||||||
|
```
|
||||||
|
|
||||||
|
`--missing` lists songs from `GET /api/admin/media` and keeps only the ones with
|
||||||
|
`lyricsLines == 0`. Use `--ids A,B,C` to aim at specific videos. Ids are YouTube
|
||||||
|
ids (11 chars) or uploads (`upl_<12 hex>`).
|
||||||
|
|
||||||
|
Whisper venv, once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv ~/.local/share/lyrics-asr/.venv --python 3.12
|
||||||
|
~/.local/share/lyrics-asr/.venv/bin/pip install -r scripts/lyrics/requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
The same transcriber also runs unattended as the `lyrics-worker` container in
|
||||||
|
`docker-compose.yml` (`auto_lyrics.py --watch 300 --state …`), which is what gives
|
||||||
|
newly downloaded songs lyrics "at their own pace". It authenticates with
|
||||||
|
`LYRICS_WORKER_TOKEN`.
|
||||||
|
|
||||||
|
## What lands in the database
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{ "lines": [{ "t": 12.4, "text": "Holy You are", "kind": "line" }], // t null = untimed
|
||||||
|
"tags": ["from LRCLIB (synced)"], // provenance — always tag
|
||||||
|
"offset": 0 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags in use: `from LRCLIB (synced)` / `(plain text)`, `from the web (untimed) — check and Tap-sync`,
|
||||||
|
`auto-transcribed`, `from the file (synced)` (embedded in an admin upload).
|
||||||
|
The tag is how later runs tell machine lyrics from published ones — keep it accurate.
|
||||||
|
|
||||||
|
## Things that bite
|
||||||
|
|
||||||
|
- **LRCLIB 503/429** on bursts. `lrclib_regen.py:http_json` already retries with
|
||||||
|
backoff; if you write new code against LRCLIB, copy it. Keep ~0.4 s between calls.
|
||||||
|
- **Wrong-artist matches.** A common title ("Still") matches another genre's song.
|
||||||
|
`artist_ok()` in `lrclib_regen.py` is the verification — port it rather than
|
||||||
|
trusting a title+duration hit.
|
||||||
|
- **Karaoke / minus-one tracks have no vocals.** Whisper returns noise; the script
|
||||||
|
calls them instrumental below `--min-words 25` and skips them. That's correct.
|
||||||
|
- **Untimed lyrics are fine.** The app shows them as a plain scrolling list and the
|
||||||
|
user can Tap-sync them in the admin lyric editor.
|
||||||
|
- **Never redistribute.** These are third-party lyrics in a private library.
|
||||||
98
.agents/skills/lyrics-regenerate/SKILL.md
Normal file
98
.agents/skills/lyrics-regenerate/SKILL.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: lyrics-regenerate
|
||||||
|
description: Back up and replace lyrics on worship.hesed.sbs that are wrong, mistimed or misheard — typically machine transcripts — with published lyrics from LRCLIB. Use when the user says lyrics are incorrect/off/mistimed, names songs whose lyrics are bad, or asks to re-check songs on LRCLIB. Always backs up first and never writes an unverified artist match.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Replace bad lyrics from LRCLIB
|
||||||
|
|
||||||
|
Machine transcripts (Whisper, Scribe) mishear words and drift out of time.
|
||||||
|
LRCLIB's published lyrics are usually correct and often **synced**. This skill
|
||||||
|
swaps them in — backup first, artist verified, one song at a time.
|
||||||
|
|
||||||
|
Script: `scripts/lyrics/lrclib_regen.py`.
|
||||||
|
|
||||||
|
## The rule: back up before you touch anything
|
||||||
|
|
||||||
|
The server keeps every previous version as a revision, and `/admin` can restore
|
||||||
|
one, but the script **also** writes an offline JSON backup of the current lyrics
|
||||||
|
of every song it will consider — on a dry run too. Do not skip it, do not write
|
||||||
|
your own one-off loop that lacks it.
|
||||||
|
|
||||||
|
```
|
||||||
|
/mnt/c/Users/josh/Documents/ytplayer-lyrics-backup-<YYYYmmdd-HHMMSS>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
(`--backup-dir` or `YTP_BACKUP_DIR` to move it; on the devbox it falls back to `~/`.)
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/development/personal/ytplayer
|
||||||
|
export YTP_ADMIN_PASSWORD='…' # or YTP_TOKEN=ytp_…
|
||||||
|
|
||||||
|
# 1) ALWAYS dry-run first and read every line of the output
|
||||||
|
python3 scripts/lyrics/lrclib_regen.py --tagged auto-transcribed
|
||||||
|
|
||||||
|
# 2) apply once the matches look right
|
||||||
|
python3 scripts/lyrics/lrclib_regen.py --tagged auto-transcribed --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Picking the songs:
|
||||||
|
|
||||||
|
| Flag | Picks |
|
||||||
|
|---|---|
|
||||||
|
| `--ids A,B,C` | exactly those videos |
|
||||||
|
| `--tagged auto-transcribed` | songs whose lyrics carry that tag (machine transcripts) |
|
||||||
|
| `--since '2026-09-19 01:45' --until '2026-09-19 02:00'` | songs whose lyrics were **saved** in that window |
|
||||||
|
| *(none)* | every song the server has lyrics for |
|
||||||
|
|
||||||
|
`--since/--until` is the one to reach for when the user says *"the songs that got
|
||||||
|
lyrics at the same time as X"* — read X's `updatedAt` from
|
||||||
|
`GET /api/notes/<id>` and bracket it by a few minutes.
|
||||||
|
|
||||||
|
Other flags: `--tolerance 6` (max duration difference, seconds), `--loose`
|
||||||
|
(accept matches whose artist doesn't line up — risky, see below).
|
||||||
|
|
||||||
|
## Reading the output
|
||||||
|
|
||||||
|
```
|
||||||
|
eJBlOV6cM7Y LRCLIB synced | Israel Houghton – Holy You Are | 41 lines (was 38)
|
||||||
|
_n6dfB2Z-Ko UNSURE plain | Night Ranger – Still | 52 lines (was 44)
|
||||||
|
↳ artist doesn't match "Hillsong Worship" / the video title — left alone (use --loose to accept)
|
||||||
|
tYM05iaVu3I no match | Jesus At The Centre | … | keeping 60 lines (auto-transcribed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **LRCLIB** — verified match, will be written on `--apply`.
|
||||||
|
- **UNSURE** — title and duration fit but the artist doesn't appear in the channel
|
||||||
|
name or the video title. **Left alone by default. Do not pass `--loose` to make
|
||||||
|
it go away** — check the song by hand instead; this guard is what stopped a
|
||||||
|
Hillsong song being overwritten with a Night Ranger one.
|
||||||
|
- **no match** — LRCLIB doesn't have it. Existing lyrics are kept. Fall back to
|
||||||
|
`web_lyrics.py --agy --ids <id> --overwrite`, or fix it in the admin lyric editor.
|
||||||
|
|
||||||
|
Replaced songs are tagged `from LRCLIB (synced)` / `(plain text)`, which is also
|
||||||
|
how you tell later what has already been fixed.
|
||||||
|
|
||||||
|
## Restoring
|
||||||
|
|
||||||
|
- Per song, in the UI: `/admin` → recent edits → **Restore** on the older revision.
|
||||||
|
- From the JSON backup: `PUT /api/notes/<id>/lyrics` with
|
||||||
|
`{"data": <songs[id].data>, "baseRev": <current rev from GET /api/notes/<id>>}`.
|
||||||
|
Use the *current* rev, not the backed-up one — `baseRev` is optimistic
|
||||||
|
concurrency, not a version to travel back to.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **LRCLIB rate-limits**: 503/429 on bursts. `http_json()` retries with backoff and
|
||||||
|
the loop sleeps 0.4 s between songs. A song that fails all retries is reported and
|
||||||
|
skipped — re-run it later rather than hammering.
|
||||||
|
- **Duration match is ±6 s** against `/api/streams` metadata. Live or extended cuts
|
||||||
|
legitimately miss; raise `--tolerance` deliberately, per song.
|
||||||
|
- **Title cleaning** strips "(Official Video)", "Lyrics", "[HD]" etc.
|
||||||
|
`clean_title`'s `NOISE` regex is used with `.sub()` and `.search()` — never give
|
||||||
|
it a `/g`-style shared match state; a stateful regex silently skipped every other
|
||||||
|
song once already.
|
||||||
|
- **Karaoke/minus-one** versions match the original recording's lyrics, which is
|
||||||
|
usually what you want, but the timing won't line up. Check before applying.
|
||||||
|
|
||||||
|
Related skills: `lyrics-lookup` (songs with **no** lyrics), `deploy-prod`.
|
||||||
1
.claude/skills/lyrics-lookup
Symbolic link
1
.claude/skills/lyrics-lookup
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/lyrics-lookup
|
||||||
1
.claude/skills/lyrics-regenerate
Symbolic link
1
.claude/skills/lyrics-regenerate
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/lyrics-regenerate
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,3 +34,6 @@ server/public
|
|||||||
# Old Tauri/Windows desktop build outputs — published artifacts, not source.
|
# Old Tauri/Windows desktop build outputs — published artifacts, not source.
|
||||||
# They are still in git history; keeping them out of the tip keeps deploy clones small.
|
# They are still in git history; keeping them out of the tip keeps deploy clones small.
|
||||||
legacy/releases/
|
legacy/releases/
|
||||||
|
|
||||||
|
# python caches from scripts/lyrics
|
||||||
|
__pycache__/
|
||||||
|
|||||||
@@ -53,6 +53,28 @@
|
|||||||
.hidden { display: none !important; }
|
.hidden { display: none !important; }
|
||||||
.src { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
.src { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
||||||
.src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
|
.src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
|
||||||
|
|
||||||
|
/* --- lyric editor --- */
|
||||||
|
#leWrap { margin-top: 12px; }
|
||||||
|
#leWave { width: 100%; height: 72px; display: block; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-1); cursor: pointer; touch-action: none; }
|
||||||
|
.le-clock { font-family: var(--mono); font-size: 18px; min-width: 96px; text-align: center; }
|
||||||
|
.le-clock b { color: var(--accent); }
|
||||||
|
.le-lines { max-height: 56vh; overflow-y: auto; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-1); }
|
||||||
|
.le-line { display: flex; gap: 6px; align-items: center; padding: 4px 6px; border-bottom: 1px solid var(--line); }
|
||||||
|
.le-line:last-child { border-bottom: 0; }
|
||||||
|
.le-line.now { background: color-mix(in srgb, var(--accent) 16%, transparent); }
|
||||||
|
.le-line.next { background: color-mix(in srgb, var(--accent) 7%, transparent); }
|
||||||
|
.le-line.untimed .le-t { color: var(--warn); }
|
||||||
|
.le-t { font-family: var(--mono); font-size: 12px; width: 74px; flex: none; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 4px; text-align: center; }
|
||||||
|
.le-line input.le-text { flex: 1 1 auto; min-width: 80px; background: transparent; border: 0; color: var(--text); font-size: 14px; padding: 5px 4px; }
|
||||||
|
.le-line input.le-text:focus { outline: 1px solid var(--accent); border-radius: 6px; }
|
||||||
|
.le-line.section input.le-text { font-weight: 700; color: var(--text-2); }
|
||||||
|
.le-line.cue input.le-text { font-style: italic; color: var(--warn); }
|
||||||
|
.le-mini { padding: 3px 6px; font-size: 12px; line-height: 1.1; flex: none; }
|
||||||
|
.le-kind { background: var(--bg-2); color: var(--text-2); border: 1px solid var(--line); border-radius: 6px; font-size: 11px; padding: 4px; flex: none; }
|
||||||
|
#leRaw { width: 100%; min-height: 40vh; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 9px; padding: 10px; font-family: var(--mono); font-size: 12px; }
|
||||||
|
.pill { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; color: var(--text-dim); }
|
||||||
|
.pill.on { color: var(--accent); border-color: var(--accent); }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -101,6 +123,56 @@
|
|||||||
<div id="injectMsg" class="msg"></div>
|
<div id="injectMsg" class="msg"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="row" style="margin-bottom:10px">
|
||||||
|
<h2 style="margin:0">Lyric editor</h2>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span id="leState" class="pill">no song loaded</span>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Play the song here and time every line against it: <b>Set</b> stamps the playhead onto a line, <b>Tap</b> mode walks down the lines as you tap (Space), and the waveform scrubs. Saving writes a new shared revision — the old one stays restorable below.</p>
|
||||||
|
<form id="leLoadForm" class="row">
|
||||||
|
<input type="text" id="leId" placeholder="YouTube URL, video id, or upl_… id" />
|
||||||
|
<button class="primary" type="submit">Load</button>
|
||||||
|
<button type="button" id="leFromWeb" title="Fetch lyrics from LRCLIB for this song">⤓ LRCLIB</button>
|
||||||
|
</form>
|
||||||
|
<div id="leMsg" class="msg"></div>
|
||||||
|
|
||||||
|
<div id="leWrap" class="hidden">
|
||||||
|
<div class="row" style="margin-bottom:8px">
|
||||||
|
<b id="leTitle"></b><span class="muted" id="leMeta"></span>
|
||||||
|
</div>
|
||||||
|
<canvas id="leWave" height="72"></canvas>
|
||||||
|
<div class="row" style="margin:8px 0">
|
||||||
|
<button type="button" id="lePlay">▶︎ Play</button>
|
||||||
|
<button type="button" id="leBack">« 5s</button>
|
||||||
|
<button type="button" id="leFwd">5s »</button>
|
||||||
|
<span class="le-clock"><b id="leCur">0:00.00</b> / <span id="leDur">0:00</span></span>
|
||||||
|
<select id="leRate" class="le-kind" title="Playback speed">
|
||||||
|
<option value="0.5">0.5×</option><option value="0.75">0.75×</option>
|
||||||
|
<option value="1" selected>1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="leTap">⏱ Tap mode</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<label class="muted"><input type="checkbox" id="leFollow" checked /> Follow</label>
|
||||||
|
<button type="button" id="leRawBtn">✎ Text</button>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-bottom:8px">
|
||||||
|
<input type="text" id="leTags" placeholder="Tags — e.g. Key G, 70 BPM, from LRCLIB (synced)" />
|
||||||
|
<label class="muted">Shift all <input type="text" id="leShift" value="0.0" style="width:66px;flex:none" /> s</label>
|
||||||
|
<button type="button" id="leShiftBtn">Apply shift</button>
|
||||||
|
<button type="button" id="leAddBtn">+ Line</button>
|
||||||
|
</div>
|
||||||
|
<div class="le-lines" id="leLines"></div>
|
||||||
|
<textarea id="leRaw" class="hidden" spellcheck="false"></textarea>
|
||||||
|
<div class="row" style="margin-top:10px">
|
||||||
|
<button class="primary" id="leSave" type="button">Save lyrics</button>
|
||||||
|
<button type="button" id="leReload">Discard changes</button>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<span class="muted" id="leDirty"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>Upload video / audio</h2>
|
<h2>Upload video / audio</h2>
|
||||||
<p class="muted">Uploads are served by this server and appear in search and in the app's 📁 Library. Title, artist, duration, cover art and embedded lyrics are read from the file; a separate cover image and manual title/artist override them.</p>
|
<p class="muted">Uploads are served by this server and appear in search and in the app's 📁 Library. Title, artist, duration, cover art and embedded lyrics are read from the file; a separate cover image and manual title/artist override them.</p>
|
||||||
@@ -154,8 +226,9 @@
|
|||||||
|
|
||||||
function videoIdFrom(input) {
|
function videoIdFrom(input) {
|
||||||
const s = String(input || '').trim();
|
const s = String(input || '').trim();
|
||||||
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
|
if (/^[A-Za-z0-9_-]{11}$/.test(s) || /^upl_[a-f0-9]{12}$/.test(s)) return s;
|
||||||
const m = s.match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/);
|
const m = s.match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/)
|
||||||
|
|| s.match(/(upl_[a-f0-9]{12})/);
|
||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,6 +249,8 @@
|
|||||||
loadTokens();
|
loadTokens();
|
||||||
loadRevs();
|
loadRevs();
|
||||||
loadUploads();
|
loadUploads();
|
||||||
|
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
|
||||||
|
if (want) leOpen(want);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('loginForm').addEventListener('submit', async (e) => {
|
$('loginForm').addEventListener('submit', async (e) => {
|
||||||
@@ -257,6 +332,7 @@
|
|||||||
<td><span class="src ${esc(r.source)}">${esc(r.source)}</span></td>
|
<td><span class="src ${esc(r.source)}">${esc(r.source)}</span></td>
|
||||||
<td class="row">
|
<td class="row">
|
||||||
<button data-view="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">View</button>
|
<button data-view="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">View</button>
|
||||||
|
${r.kind === 'lyrics' ? `<button data-edit="${esc(r.videoId)}">Edit</button>` : ''}
|
||||||
<button data-restore="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">Restore</button>
|
<button data-restore="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">Restore</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
@@ -279,6 +355,7 @@
|
|||||||
$('revTable').addEventListener('click', async (e) => {
|
$('revTable').addEventListener('click', async (e) => {
|
||||||
const v = e.target.dataset.view;
|
const v = e.target.dataset.view;
|
||||||
const r = e.target.dataset.restore;
|
const r = e.target.dataset.restore;
|
||||||
|
if (e.target.dataset.edit) { leOpen(e.target.dataset.edit); return; }
|
||||||
if (v) {
|
if (v) {
|
||||||
const [id, kind, rev] = v.split('|');
|
const [id, kind, rev] = v.split('|');
|
||||||
const j = await api(`/api/notes/${id}/${kind}/revs/${rev}`);
|
const j = await api(`/api/notes/${id}/${kind}/revs/${rev}`);
|
||||||
@@ -306,6 +383,7 @@
|
|||||||
<td class="muted">${Math.round(u.duration)}s · ${(u.size / 1048576).toFixed(1)} MB</td>
|
<td class="muted">${Math.round(u.duration)}s · ${(u.size / 1048576).toFixed(1)} MB</td>
|
||||||
<td><code>${esc(u.id)}</code></td>
|
<td><code>${esc(u.id)}</code></td>
|
||||||
<td><a class="btn" href="/?v=${esc(u.id)}" target="_blank" rel="noopener">Play</a>
|
<td><a class="btn" href="/?v=${esc(u.id)}" target="_blank" rel="noopener">Play</a>
|
||||||
|
<button data-lyrics="${esc(u.id)}">Lyrics</button>
|
||||||
<button class="danger" data-del="${esc(u.id)}">Delete</button></td>
|
<button class="danger" data-del="${esc(u.id)}">Delete</button></td>
|
||||||
</tr>`).join('');
|
</tr>`).join('');
|
||||||
$('upTable').innerHTML = rows
|
$('upTable').innerHTML = rows
|
||||||
@@ -347,6 +425,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('upTable').addEventListener('click', async (e) => {
|
$('upTable').addEventListener('click', async (e) => {
|
||||||
|
if (e.target.dataset.lyrics) { leOpen(e.target.dataset.lyrics); return; }
|
||||||
const id = e.target.dataset.del;
|
const id = e.target.dataset.del;
|
||||||
if (!id || !confirm('Delete this upload? The file is removed from the server.')) return;
|
if (!id || !confirm('Delete this upload? The file is removed from the server.')) return;
|
||||||
const j = await api(`/api/admin/uploads/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
const j = await api(`/api/admin/uploads/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
@@ -354,6 +433,375 @@
|
|||||||
loadUploads();
|
loadUploads();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ========================================================================
|
||||||
|
* Lyric editor — the song plays here, so every line can be timed against
|
||||||
|
* what you actually hear. The rows are built ONCE per load and then only
|
||||||
|
* their classes/values change: rebuilding them would steal focus from the
|
||||||
|
* field being typed in and reset the scroll position.
|
||||||
|
* ====================================================================== */
|
||||||
|
const LE = {
|
||||||
|
id: '', rev: 0, lines: [], tags: [], offset: 0, dur: 0,
|
||||||
|
audio: null, peaks: null, rows: [], tap: false, dirty: false, raw: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const clamp2 = (n) => Math.round(n * 100) / 100;
|
||||||
|
function fmtT(t) {
|
||||||
|
if (t == null) return '';
|
||||||
|
const m = Math.floor(t / 60);
|
||||||
|
return `${m}:${(t - m * 60).toFixed(2).padStart(5, '0')}`;
|
||||||
|
}
|
||||||
|
function parseT(s) {
|
||||||
|
const v = String(s || '').trim();
|
||||||
|
if (!v) return null;
|
||||||
|
const m = v.match(/^(\d+):(\d{1,2}(?:\.\d+)?)$/);
|
||||||
|
const n = m ? Number(m[1]) * 60 + Number(m[2]) : Number(v);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? clamp2(n) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leDirty(on) {
|
||||||
|
LE.dirty = on;
|
||||||
|
$('leDirty').textContent = on ? 'unsaved changes' : '';
|
||||||
|
$('leDirty').className = on ? 'muted' : 'muted';
|
||||||
|
}
|
||||||
|
function leSay(text, cls) { const m = $('leMsg'); m.className = 'msg' + (cls ? ' ' + cls : ''); m.textContent = text; }
|
||||||
|
|
||||||
|
async function leLoad(id, keepAudio) {
|
||||||
|
leSay('Loading…');
|
||||||
|
const [notes, streams] = await Promise.all([
|
||||||
|
api(`/api/notes/${id}`),
|
||||||
|
fetch(`/api/streams?v=${encodeURIComponent(id)}`, { credentials: 'same-origin' }).then((r) => r.json()).catch(() => ({})),
|
||||||
|
]);
|
||||||
|
const doc = (notes.lyrics && notes.lyrics.data) || { lines: [], tags: [], offset: 0 };
|
||||||
|
LE.id = id;
|
||||||
|
LE.rev = (notes.lyrics && notes.lyrics.rev) || 0;
|
||||||
|
LE.lines = (doc.lines || []).map((l) => ({ t: l.t == null ? null : clamp2(l.t), text: String(l.text || ''), kind: l.kind || 'line' }));
|
||||||
|
LE.tags = doc.tags || [];
|
||||||
|
LE.offset = Number(doc.offset) || 0;
|
||||||
|
const meta = (streams.data && streams.data.meta) || {};
|
||||||
|
LE.dur = Number(meta.duration) || 0;
|
||||||
|
$('leTitle').textContent = meta.title || id;
|
||||||
|
$('leMeta').textContent = ` ${meta.channel || ''} · ${LE.lines.length} lines · rev ${LE.rev}`;
|
||||||
|
$('leTags').value = LE.tags.join(', ');
|
||||||
|
$('leState').textContent = LE.lines.length ? `${LE.lines.filter((l) => l.t != null).length}/${LE.lines.length} timed` : 'no lyrics yet';
|
||||||
|
$('leState').className = 'pill' + (LE.lines.length && LE.lines.every((l) => l.t != null) ? ' on' : '');
|
||||||
|
$('leWrap').classList.remove('hidden');
|
||||||
|
leRender();
|
||||||
|
leDirty(false);
|
||||||
|
leSay(`Loaded ${id}`, 'ok');
|
||||||
|
|
||||||
|
if (!keepAudio) {
|
||||||
|
const url = (streams.data && streams.data.audioUrl)
|
||||||
|
|| ((streams.data && streams.data.qualities || []).find((q) => q.hasAudio) || {}).url || '';
|
||||||
|
if (LE.audio) { LE.audio.pause(); LE.audio.src = ''; }
|
||||||
|
LE.audio = null;
|
||||||
|
if (url) {
|
||||||
|
const a = new Audio(url);
|
||||||
|
a.preload = 'auto';
|
||||||
|
a.addEventListener('timeupdate', leTick);
|
||||||
|
a.addEventListener('seeked', leTick);
|
||||||
|
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('leDur').textContent = fmtT(a.duration).slice(0, -3); } leDraw(); });
|
||||||
|
a.addEventListener('play', () => { $('lePlay').textContent = '❚❚ Pause'; });
|
||||||
|
a.addEventListener('pause', () => { $('lePlay').textContent = '▶︎ Play'; });
|
||||||
|
a.addEventListener('error', () => leSay('No playable audio for this song on the server — you can still edit the text.', 'err'));
|
||||||
|
LE.audio = a;
|
||||||
|
} else {
|
||||||
|
leSay('This song is not cached on the server, so there is no audio to time against.', 'err');
|
||||||
|
}
|
||||||
|
$('leDur').textContent = fmtT(LE.dur).slice(0, -3);
|
||||||
|
LE.peaks = null;
|
||||||
|
fetch(`/api/media/${id}/peaks`, { credentials: 'same-origin' })
|
||||||
|
.then((r) => r.json()).then((j) => { if (j.ok) { LE.peaks = j.peaks; if (j.duration) LE.dur = j.duration; leDraw(); } })
|
||||||
|
.catch(() => { /* peaks are a nicety */ });
|
||||||
|
}
|
||||||
|
leDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function leRow(line, i) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'le-line' + (line.t == null ? ' untimed' : '') + (line.kind !== 'line' ? ' ' + line.kind : '');
|
||||||
|
row.dataset.i = String(i);
|
||||||
|
row.innerHTML = `
|
||||||
|
<input class="le-t" value="${esc(fmtT(line.t))}" placeholder="—" title="Line time — type it, or use Set" />
|
||||||
|
<button class="le-mini" data-act="set" title="Stamp the playhead here (S)">Set</button>
|
||||||
|
<button class="le-mini" data-act="seek" title="Play from here">▶︎</button>
|
||||||
|
<button class="le-mini" data-act="nudge" data-d="-0.2" title="0.2 s earlier">−</button>
|
||||||
|
<button class="le-mini" data-act="nudge" data-d="0.2" title="0.2 s later">+</button>
|
||||||
|
<input class="le-text" value="${esc(line.text)}" />
|
||||||
|
<select class="le-kind" title="Line type">
|
||||||
|
<option value="line">line</option><option value="section">section</option><option value="cue">cue</option>
|
||||||
|
</select>
|
||||||
|
<button class="le-mini" data-act="add" title="Insert a line below">⏎</button>
|
||||||
|
<button class="le-mini danger" data-act="del" title="Delete this line">✕</button>`;
|
||||||
|
row.querySelector('.le-kind').value = line.kind;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leRender() {
|
||||||
|
const box = $('leLines');
|
||||||
|
box.innerHTML = '';
|
||||||
|
LE.rows = LE.lines.map((l, i) => { const r = leRow(l, i); box.appendChild(r); return r; });
|
||||||
|
if (!LE.lines.length) box.innerHTML = '<div class="le-line muted" style="padding:12px">No lines yet — “+ Line”, paste them in ✎ Text, or pull them from LRCLIB.</div>';
|
||||||
|
$('leMeta').textContent = $('leMeta').textContent.replace(/\d+ lines/, `${LE.lines.length} lines`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which line is sung at time t (doc offset included, as the app plays it).
|
||||||
|
function leIndexAt(t) {
|
||||||
|
let hit = -1;
|
||||||
|
for (let i = 0; i < LE.lines.length; i++) {
|
||||||
|
const lt = LE.lines[i].t;
|
||||||
|
if (lt != null && lt + LE.offset <= t) hit = i; else if (lt != null) break;
|
||||||
|
}
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
let leLastIdx = -2;
|
||||||
|
function leTick() {
|
||||||
|
const t = LE.audio ? LE.audio.currentTime : 0;
|
||||||
|
$('leCur').textContent = fmtT(t);
|
||||||
|
const i = LE.tap ? leTapIdx() : leIndexAt(t);
|
||||||
|
if (i !== leLastIdx) {
|
||||||
|
// Clear every row, not just the previous pair: in tap mode (and after a
|
||||||
|
// seek) the index jumps, and a stale highlight would be left behind.
|
||||||
|
for (const r of LE.rows) r.classList.remove('now', 'next');
|
||||||
|
if (LE.rows[i]) LE.rows[i].classList.add('now');
|
||||||
|
if (LE.rows[i + 1]) LE.rows[i + 1].classList.add('next');
|
||||||
|
if ($('leFollow').checked && LE.rows[i]) LE.rows[i].scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||||
|
leLastIdx = i;
|
||||||
|
}
|
||||||
|
leDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
function leDraw() {
|
||||||
|
const c = $('leWave');
|
||||||
|
if (!c) return;
|
||||||
|
const w = c.clientWidth || 600, h = c.height;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
if (c.width !== Math.round(w * dpr)) { c.width = Math.round(w * dpr); }
|
||||||
|
const g = c.getContext('2d');
|
||||||
|
g.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
const css = getComputedStyle(document.body);
|
||||||
|
g.clearRect(0, 0, w, h);
|
||||||
|
const dur = LE.dur || 1;
|
||||||
|
const cur = LE.audio ? LE.audio.currentTime : 0;
|
||||||
|
if (LE.peaks && LE.peaks.length) {
|
||||||
|
const n = LE.peaks.length, bw = w / n;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const bh = Math.max(1, (LE.peaks[i] / 100) * (h - 8));
|
||||||
|
g.fillStyle = (i * dur) / n <= cur ? css.getPropertyValue('--accent') : css.getPropertyValue('--line');
|
||||||
|
g.fillRect(i * bw, (h - bh) / 2, Math.max(1, bw - 0.5), bh);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
g.fillStyle = css.getPropertyValue('--line');
|
||||||
|
g.fillRect(0, h / 2 - 1, w, 2);
|
||||||
|
g.fillStyle = css.getPropertyValue('--accent');
|
||||||
|
g.fillRect(0, h / 2 - 1, (cur / dur) * w, 2);
|
||||||
|
}
|
||||||
|
// line markers
|
||||||
|
g.fillStyle = css.getPropertyValue('--text-2');
|
||||||
|
for (const l of LE.lines) {
|
||||||
|
if (l.t == null) continue;
|
||||||
|
const x = ((l.t + LE.offset) / dur) * w;
|
||||||
|
g.fillRect(x, 0, 1, 6);
|
||||||
|
g.fillRect(x, h - 6, 1, 6);
|
||||||
|
}
|
||||||
|
g.fillStyle = css.getPropertyValue('--text');
|
||||||
|
g.fillRect((cur / dur) * w - 1, 0, 2, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leSeek(t) {
|
||||||
|
if (!LE.audio) return;
|
||||||
|
LE.audio.currentTime = Math.max(0, Math.min(LE.dur || 1e9, t));
|
||||||
|
leTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tap mode: the next line without a time (or the one after the playhead).
|
||||||
|
function leTapIdx() {
|
||||||
|
const i = LE.lines.findIndex((l) => l.t == null);
|
||||||
|
if (i >= 0) return i;
|
||||||
|
return Math.min(LE.lines.length - 1, leIndexAt(LE.audio ? LE.audio.currentTime : 0) + 1);
|
||||||
|
}
|
||||||
|
function leStamp(i) {
|
||||||
|
if (!LE.audio || i < 0 || i >= LE.lines.length) return;
|
||||||
|
LE.lines[i].t = clamp2(LE.audio.currentTime);
|
||||||
|
const row = LE.rows[i];
|
||||||
|
if (row) { row.querySelector('.le-t').value = fmtT(LE.lines[i].t); row.classList.remove('untimed'); }
|
||||||
|
leDirty(true);
|
||||||
|
leLastIdx = -2;
|
||||||
|
leTick();
|
||||||
|
}
|
||||||
|
|
||||||
|
$('leLoadForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = videoIdFrom($('leId').value);
|
||||||
|
if (!id) { leSay('Not a YouTube URL, video id or upl_ id', 'err'); return; }
|
||||||
|
if (LE.dirty && !confirm('Discard the unsaved changes to the song you have open?')) return;
|
||||||
|
leLoad(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('leFromWeb').addEventListener('click', async () => {
|
||||||
|
const id = LE.id || videoIdFrom($('leId').value);
|
||||||
|
if (!id) { leSay('Load a song first', 'err'); return; }
|
||||||
|
leSay('Asking LRCLIB…');
|
||||||
|
const j = await api(`/api/notes/${id}/lyrics/web`, { method: 'POST', body: { overwrite: true } });
|
||||||
|
if (!j.ok) { leSay(j.error || 'LRCLIB has nothing for this song', 'err'); return; }
|
||||||
|
await leLoad(id, true);
|
||||||
|
leSay(`LRCLIB: ${j.lines} ${j.synced ? 'synced' : 'plain'} lines saved as rev ${j.rev} — the previous version is restorable below.`, 'ok');
|
||||||
|
loadRevs();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('leLines').addEventListener('click', (e) => {
|
||||||
|
const btn = e.target.closest('button[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
const i = Number(btn.closest('.le-line').dataset.i);
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
if (act === 'set') leStamp(i);
|
||||||
|
else if (act === 'seek') { leSeek((LE.lines[i].t || 0) + LE.offset); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); }
|
||||||
|
else if (act === 'nudge') {
|
||||||
|
if (LE.lines[i].t == null) return;
|
||||||
|
LE.lines[i].t = clamp2(Math.max(0, LE.lines[i].t + Number(btn.dataset.d)));
|
||||||
|
btn.closest('.le-line').querySelector('.le-t').value = fmtT(LE.lines[i].t);
|
||||||
|
leDirty(true); leDraw();
|
||||||
|
} else if (act === 'add') {
|
||||||
|
LE.lines.splice(i + 1, 0, { t: null, text: '', kind: 'line' });
|
||||||
|
leRender(); leDirty(true);
|
||||||
|
const next = LE.rows[i + 1]; if (next) next.querySelector('.le-text').focus();
|
||||||
|
} else if (act === 'del') {
|
||||||
|
LE.lines.splice(i, 1);
|
||||||
|
leRender(); leDirty(true); leDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('leLines').addEventListener('input', (e) => {
|
||||||
|
const row = e.target.closest('.le-line');
|
||||||
|
if (!row) return;
|
||||||
|
const i = Number(row.dataset.i);
|
||||||
|
if (e.target.classList.contains('le-text')) { LE.lines[i].text = e.target.value; leDirty(true); }
|
||||||
|
});
|
||||||
|
$('leLines').addEventListener('change', (e) => {
|
||||||
|
const row = e.target.closest('.le-line');
|
||||||
|
if (!row) return;
|
||||||
|
const i = Number(row.dataset.i);
|
||||||
|
if (e.target.classList.contains('le-t')) {
|
||||||
|
LE.lines[i].t = parseT(e.target.value);
|
||||||
|
e.target.value = fmtT(LE.lines[i].t);
|
||||||
|
row.classList.toggle('untimed', LE.lines[i].t == null);
|
||||||
|
leDirty(true); leDraw();
|
||||||
|
} else if (e.target.classList.contains('le-kind')) {
|
||||||
|
LE.lines[i].kind = e.target.value;
|
||||||
|
row.classList.remove('section', 'cue');
|
||||||
|
if (e.target.value !== 'line') row.classList.add(e.target.value);
|
||||||
|
leDirty(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Enter in a line = insert the next one below (writing lyrics by ear).
|
||||||
|
$('leLines').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Enter' || !e.target.classList.contains('le-text')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const i = Number(e.target.closest('.le-line').dataset.i);
|
||||||
|
LE.lines.splice(i + 1, 0, { t: null, text: '', kind: LE.lines[i].kind === 'section' ? 'line' : LE.lines[i].kind });
|
||||||
|
leRender(); leDirty(true);
|
||||||
|
if (LE.rows[i + 1]) LE.rows[i + 1].querySelector('.le-text').focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('lePlay').addEventListener('click', () => {
|
||||||
|
if (!LE.audio) return;
|
||||||
|
if (LE.audio.paused) LE.audio.play().catch((err) => leSay('Playback failed: ' + err.message, 'err'));
|
||||||
|
else LE.audio.pause();
|
||||||
|
});
|
||||||
|
$('leBack').addEventListener('click', () => leSeek((LE.audio ? LE.audio.currentTime : 0) - 5));
|
||||||
|
$('leFwd').addEventListener('click', () => leSeek((LE.audio ? LE.audio.currentTime : 0) + 5));
|
||||||
|
$('leRate').addEventListener('change', () => { if (LE.audio) LE.audio.playbackRate = Number($('leRate').value); });
|
||||||
|
$('leTap').addEventListener('click', () => {
|
||||||
|
LE.tap = !LE.tap;
|
||||||
|
$('leTap').classList.toggle('primary', LE.tap);
|
||||||
|
leSay(LE.tap ? 'Tap mode: press Space (or S) on the beat to stamp the highlighted line; it then moves to the next one.' : '', LE.tap ? 'ok' : '');
|
||||||
|
leLastIdx = -2; leTick();
|
||||||
|
});
|
||||||
|
$('leWave').addEventListener('pointerdown', (e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
leSeek(((e.clientX - r.left) / r.width) * (LE.dur || 0));
|
||||||
|
});
|
||||||
|
window.addEventListener('resize', leDraw);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ($('leWrap').classList.contains('hidden')) return;
|
||||||
|
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
||||||
|
if (e.code === 'Space' && LE.tap && !typing) { e.preventDefault(); leStamp(leTapIdx()); return; }
|
||||||
|
if ((e.key === 's' || e.key === 'S') && !typing && !e.metaKey && !e.ctrlKey) { e.preventDefault(); leStamp(LE.tap ? leTapIdx() : leIndexAt(LE.audio ? LE.audio.currentTime : 0)); return; }
|
||||||
|
if (e.key === 'k' && !typing) { e.preventDefault(); $('lePlay').click(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('leShiftBtn').addEventListener('click', () => {
|
||||||
|
const d = Number($('leShift').value);
|
||||||
|
if (!Number.isFinite(d) || !d) return;
|
||||||
|
for (const l of LE.lines) if (l.t != null) l.t = clamp2(Math.max(0, l.t + d));
|
||||||
|
leRender(); leDirty(true); leDraw();
|
||||||
|
leSay(`Shifted every timed line by ${d > 0 ? '+' : ''}${d}s`, 'ok');
|
||||||
|
});
|
||||||
|
$('leAddBtn').addEventListener('click', () => {
|
||||||
|
LE.lines.push({ t: LE.audio ? clamp2(LE.audio.currentTime) : null, text: '', kind: 'line' });
|
||||||
|
leRender(); leDirty(true);
|
||||||
|
const last = LE.rows[LE.rows.length - 1]; if (last) last.querySelector('.le-text').focus();
|
||||||
|
});
|
||||||
|
$('leTags').addEventListener('input', () => { LE.tags = $('leTags').value.split(',').map((s) => s.trim()).filter(Boolean); leDirty(true); });
|
||||||
|
|
||||||
|
// ✎ Text — the same LRC superset the app's own editor uses.
|
||||||
|
$('leRawBtn').addEventListener('click', () => {
|
||||||
|
LE.raw = !LE.raw;
|
||||||
|
$('leRaw').classList.toggle('hidden', !LE.raw);
|
||||||
|
$('leLines').classList.toggle('hidden', LE.raw);
|
||||||
|
$('leRawBtn').textContent = LE.raw ? '≡ Lines' : '✎ Text';
|
||||||
|
if (LE.raw) {
|
||||||
|
$('leRaw').value = LE.lines.map((l) => (l.t == null ? '' : `[${fmtT(l.t)}] `) + (l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '') + l.text).join('\n');
|
||||||
|
} else {
|
||||||
|
LE.lines = $('leRaw').value.split('\n').map((raw) => {
|
||||||
|
let rest = raw.trim(), t = null;
|
||||||
|
const m = rest.match(/^\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]\s*/);
|
||||||
|
if (m) { t = clamp2(Number(m[1]) * 60 + Number(m[2].replace(':', '.'))); rest = rest.slice(m[0].length); }
|
||||||
|
let kind = 'line';
|
||||||
|
if (rest.startsWith('# ')) { kind = 'section'; rest = rest.slice(2); }
|
||||||
|
else if (rest.startsWith('! ')) { kind = 'cue'; rest = rest.slice(2); }
|
||||||
|
return { t, text: rest, kind };
|
||||||
|
}).filter((l) => l.text);
|
||||||
|
leRender(); leDirty(true); leDraw();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('leReload').addEventListener('click', () => { if (LE.id && confirm('Throw away the unsaved changes and reload from the server?')) leLoad(LE.id, true); });
|
||||||
|
|
||||||
|
$('leSave').addEventListener('click', async () => {
|
||||||
|
if (!LE.id) return;
|
||||||
|
const lines = LE.lines.filter((l) => l.text.trim()).map((l) => ({ t: l.t, text: l.text.trim(), kind: l.kind }));
|
||||||
|
if (!lines.length) { leSay('Nothing to save', 'err'); return; }
|
||||||
|
const data = { lines, tags: LE.tags, offset: LE.offset };
|
||||||
|
leSay('Saving…');
|
||||||
|
let j = await api(`/api/notes/${LE.id}/lyrics`, { method: 'PUT', body: { data, baseRev: LE.rev } });
|
||||||
|
if (!j.ok && j.current) {
|
||||||
|
if (!confirm(`Someone else saved this song while you were editing (their rev ${j.current.rev}).\n\nOK = keep YOUR version, Cancel = load theirs.`)) {
|
||||||
|
await leLoad(LE.id, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
j = await api(`/api/notes/${LE.id}/lyrics`, { method: 'PUT', body: { data, force: true } });
|
||||||
|
}
|
||||||
|
if (!j.ok) { leSay(j.error || 'save failed', 'err'); return; }
|
||||||
|
LE.rev = j.rev;
|
||||||
|
leDirty(false);
|
||||||
|
const timed = lines.filter((l) => l.t != null).length;
|
||||||
|
$('leState').textContent = `${timed}/${lines.length} timed`;
|
||||||
|
$('leState').className = 'pill' + (timed === lines.length ? ' on' : '');
|
||||||
|
leSay(`Saved ${lines.length} lines as rev ${j.rev}.`, 'ok');
|
||||||
|
loadRevs();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', (e) => { if (LE.dirty) { e.preventDefault(); e.returnValue = ''; } });
|
||||||
|
|
||||||
|
// "Edit" from the revisions feed and ?v= in the URL open a song here.
|
||||||
|
function leOpen(id) {
|
||||||
|
$('leId').value = id;
|
||||||
|
leLoad(id);
|
||||||
|
$('leWrap').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
function renderDocs() {
|
function renderDocs() {
|
||||||
const o = location.origin;
|
const o = location.origin;
|
||||||
$('apiDocs').textContent = `# Read (public)
|
$('apiDocs').textContent = `# Read (public)
|
||||||
@@ -388,6 +836,8 @@ curl ${o}/api/uploads?q=worship # public list / search
|
|||||||
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/web \\
|
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/web \\
|
||||||
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" -d '{"overwrite": false}'
|
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" -d '{"overwrite": false}'
|
||||||
|
|
||||||
|
# Lyric editor deep link (admin): /admin?v=VIDEO_ID opens that song in the editor
|
||||||
|
|
||||||
# History (public)
|
# History (public)
|
||||||
curl ${o}/api/notes/VIDEO_ID/lyrics/revs
|
curl ${o}/api/notes/VIDEO_ID/lyrics/revs
|
||||||
curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3
|
curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3
|
||||||
|
|||||||
@@ -1530,6 +1530,20 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app { grid-template-columns: 1fr; }
|
.app { grid-template-columns: 1fr; }
|
||||||
|
|
||||||
|
/* Keep the five playback buttons on one line at the top of the control
|
||||||
|
deck; everything else (📍, A-B, volume, transition, EQ, PiP, speed…)
|
||||||
|
flows into a tool row underneath. A control with no explicit order
|
||||||
|
defaults to 0 and would otherwise jump ahead of the transport group. */
|
||||||
|
.btn-row { justify-content: center; }
|
||||||
|
.btn-row::before { content: ""; width: 100%; order: 6; height: 0; }
|
||||||
|
#shuffleBtn { order: 1; }
|
||||||
|
#prevBtn { order: 2; }
|
||||||
|
#playBtn { order: 3; }
|
||||||
|
#nextBtn { order: 4; }
|
||||||
|
#repeatBtn { order: 5; }
|
||||||
|
.btn-row > *:not(#shuffleBtn):not(#prevBtn):not(#playBtn):not(#nextBtn):not(#repeatBtn) { order: 7; }
|
||||||
|
.btn-row .spacer { display: none; }
|
||||||
|
|
||||||
.sidebar-toggle { display: inline-flex; }
|
.sidebar-toggle { display: inline-flex; }
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -2051,6 +2065,19 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
gap: 7px;
|
gap: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The five playback buttons stay together on the first line: every OTHER
|
||||||
|
control is pushed into the tool row below. Without an explicit order a
|
||||||
|
button defaults to 0 and jumps ahead of the transport group. */
|
||||||
|
.btn-row::before { content: ""; width: 100%; order: 6; height: 0; }
|
||||||
|
#shuffleBtn { order: 1; }
|
||||||
|
#prevBtn { order: 2; }
|
||||||
|
#playBtn { order: 3; }
|
||||||
|
#nextBtn { order: 4; }
|
||||||
|
#repeatBtn { order: 5; }
|
||||||
|
.btn-row > *:not(#shuffleBtn):not(#prevBtn):not(#playBtn):not(#nextBtn):not(#repeatBtn) { order: 7; }
|
||||||
|
.btn-row { justify-content: center; }
|
||||||
|
.btn-row .spacer { display: none; }
|
||||||
|
|
||||||
/* Now-playing meta: stack vertically */
|
/* Now-playing meta: stack vertically */
|
||||||
.now-meta {
|
.now-meta {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2264,7 +2291,8 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
|||||||
|
|
||||||
/* Row B — secondary tools, uniform 44px targets (sizes only: colors and
|
/* Row B — secondary tools, uniform 44px targets (sizes only: colors and
|
||||||
.active/.sleep-active states keep their class-based styling) */
|
.active/.sleep-active states keep their class-based styling) */
|
||||||
#abABtn, #abBBtn, #abClearBtn, #muteBtn, #loopBtn, #sleepTimerBtn, #fsBtn {
|
#abABtn, #abBBtn, #abClearBtn, #muteBtn, #loopBtn, #sleepTimerBtn, #fsBtn,
|
||||||
|
#rememberPosBtn, #transitionBtn, #eqBtn, #pipBtn {
|
||||||
order: 7;
|
order: 7;
|
||||||
min-width: 44px;
|
min-width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
|||||||
242
scripts/lyrics/lrclib_regen.py
Normal file
242
scripts/lyrics/lrclib_regen.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""lrclib_regen.py — back up and regenerate lyrics on worship.hesed.sbs from LRCLIB.
|
||||||
|
|
||||||
|
Machine transcripts (Whisper/Scribe) mishear words and drift; published lyrics
|
||||||
|
on LRCLIB are usually correct and often SYNCED. This tool:
|
||||||
|
|
||||||
|
1. writes a JSON backup of the CURRENT lyrics of every song it will touch
|
||||||
|
(the server also keeps each previous version as a revision — nothing is
|
||||||
|
ever lost, this file is just an offline copy),
|
||||||
|
2. looks each song up on LRCLIB by cleaned title + artist + duration,
|
||||||
|
3. replaces the lyrics only when a confident match is found.
|
||||||
|
|
||||||
|
Song metadata (title / artist / duration) comes from the public
|
||||||
|
/api/streams endpoint, so this works without admin access; writing needs
|
||||||
|
YTP_ADMIN_PASSWORD or YTP_TOKEN.
|
||||||
|
|
||||||
|
YTP_ADMIN_PASSWORD=… python3 scripts/lyrics/lrclib_regen.py --ids A,B --dry-run
|
||||||
|
YTP_ADMIN_PASSWORD=… python3 scripts/lyrics/lrclib_regen.py --since '2026-09-19 01:45' --until '2026-09-19 02:00'
|
||||||
|
YTP_ADMIN_PASSWORD=… python3 scripts/lyrics/lrclib_regen.py --tagged auto-transcribed --apply
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from auto_lyrics import Api # noqa: E402
|
||||||
|
|
||||||
|
LRCLIB = 'https://lrclib.net/api'
|
||||||
|
UA = {'User-Agent': 'ytplayer-lyrics-regen (https://worship.hesed.sbs)'}
|
||||||
|
NOISE = re.compile(r'\b(official|video|audio|lyrics?|lyric|hd|hq|4k|live|mv|karaoke|minus[\s-]?one|instrumental|cover|remaster(ed)?|visualizer|performance|version)\b', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def clean_title(raw):
|
||||||
|
t = str(raw or '').split('|')[0]
|
||||||
|
t = re.sub(r'\([^)]*\)|\[[^\]]*\]', lambda m: ' ' if NOISE.search(m.group()) else m.group(), t)
|
||||||
|
t = NOISE.sub(' ', t)
|
||||||
|
t = re.sub(r'\(\s*\)|\[\s*\]', ' ', t)
|
||||||
|
return re.sub(r'\s{2,}', ' ', t).strip(' -–—,')
|
||||||
|
|
||||||
|
|
||||||
|
def clean_artist(raw):
|
||||||
|
a = re.sub(r'\s*-\s*Topic$', '', str(raw or ''), flags=re.I)
|
||||||
|
a = re.sub(r'VEVO$', '', a, flags=re.I)
|
||||||
|
return re.sub(r'\s{2,}', ' ', NOISE.sub(' ', a)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lrc(text):
|
||||||
|
out = []
|
||||||
|
for raw in str(text or '').replace('\r', '').split('\n'):
|
||||||
|
rest = raw.strip()
|
||||||
|
if not rest or re.fullmatch(r'\[[a-z]+:[^\]]*\]', rest, re.I):
|
||||||
|
continue
|
||||||
|
stamps = []
|
||||||
|
while True:
|
||||||
|
m = re.match(r'^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]', rest)
|
||||||
|
if not m:
|
||||||
|
break
|
||||||
|
stamps.append(int(m.group(1)) * 60 + int(m.group(2)) + (float('0.' + m.group(3)) if m.group(3) else 0))
|
||||||
|
rest = rest[m.end():].strip()
|
||||||
|
if not rest:
|
||||||
|
continue
|
||||||
|
if not stamps:
|
||||||
|
out.append({'t': None, 'text': rest[:300], 'kind': 'line'})
|
||||||
|
for t in stamps:
|
||||||
|
out.append({'t': round(t, 2), 'text': rest[:300], 'kind': 'line'})
|
||||||
|
if out and all(l['t'] is not None for l in out):
|
||||||
|
out.sort(key=lambda l: l['t'])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def norm(s):
|
||||||
|
return re.sub(r'\s+', ' ', re.sub(r'[^a-z0-9 ]+', ' ', str(s or '').lower())).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def http_json(url, tries=4):
|
||||||
|
"""LRCLIB is free and rate-limits bursts with 503/429 — back off and retry."""
|
||||||
|
for attempt in range(tries):
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=UA), timeout=30) as r:
|
||||||
|
return json.loads(r.read() or b'null')
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 404:
|
||||||
|
return None
|
||||||
|
if e.code in (429, 503) and attempt < tries - 1:
|
||||||
|
time.sleep(2 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
if attempt < tries - 1:
|
||||||
|
time.sleep(2 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def artist_ok(lrc_artist, channel, video_title):
|
||||||
|
"""True when the LRCLIB artist plausibly matches the video.
|
||||||
|
|
||||||
|
Lyric-video channels ("Christian Lyrics", a person's name) carry no artist,
|
||||||
|
so the artist is also looked for in the video title. Songs whose title is
|
||||||
|
shared across genres ("Still") otherwise match the wrong recording."""
|
||||||
|
a = set(norm(lrc_artist).split()) - {'the', 'and', 'of', 'band', 'music', 'worship', 'ministries'}
|
||||||
|
if not a:
|
||||||
|
return False
|
||||||
|
hay = set(norm(channel).split()) | set(norm(video_title).split())
|
||||||
|
return bool(a & hay)
|
||||||
|
|
||||||
|
|
||||||
|
def lrclib_lookup(title, artist, duration, tolerance=6):
|
||||||
|
"""Best LRCLIB entry for a song, or None. Prefers synced lyrics."""
|
||||||
|
q = {'track_name': title, 'artist_name': artist or ''}
|
||||||
|
if duration:
|
||||||
|
q['duration'] = str(int(round(duration)))
|
||||||
|
hit = http_json(f'{LRCLIB}/get?' + urllib.parse.urlencode(q))
|
||||||
|
if not hit:
|
||||||
|
results = http_json(f'{LRCLIB}/search?' + urllib.parse.urlencode({'q': f'{title} {artist}'.strip()})) or []
|
||||||
|
want = norm(title)
|
||||||
|
scored = []
|
||||||
|
for x in results:
|
||||||
|
if x.get('instrumental') or not (x.get('syncedLyrics') or x.get('plainLyrics')):
|
||||||
|
continue
|
||||||
|
t = norm(x.get('trackName'))
|
||||||
|
dd = abs((x.get('duration') or 0) - duration) if duration else 99
|
||||||
|
title_hit = 2 if t == want else 1 if (want in t or t in want) else 0
|
||||||
|
if not title_hit or (duration and dd > tolerance):
|
||||||
|
continue
|
||||||
|
scored.append((title_hit * 10 + (3 if x.get('syncedLyrics') else 0) - min(9, dd), x))
|
||||||
|
if not scored:
|
||||||
|
return None
|
||||||
|
hit = max(scored, key=lambda p: p[0])[1]
|
||||||
|
lines = parse_lrc(hit.get('syncedLyrics') or '')
|
||||||
|
synced = bool(lines)
|
||||||
|
if not lines:
|
||||||
|
lines = parse_lrc(hit.get('plainLyrics') or '')
|
||||||
|
return {'lines': lines, 'synced': synced, 'hit': hit} if lines else None
|
||||||
|
|
||||||
|
|
||||||
|
def describe(hit, cur_lines):
|
||||||
|
h = hit['hit']
|
||||||
|
return f'{"synced" if hit["synced"] else "plain "} | {h.get("artistName", "")[:22]:22} – {h.get("trackName", "")[:26]:26} | {len(hit["lines"])} lines (was {cur_lines})'
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument('--base', default=os.environ.get('YTP_BASE', 'https://worship.hesed.sbs'))
|
||||||
|
ap.add_argument('--ids', help='comma-separated video ids')
|
||||||
|
ap.add_argument('--since', help='only songs whose lyrics were last saved after this local time (YYYY-MM-DD HH:MM)')
|
||||||
|
ap.add_argument('--until', help='…and before this one')
|
||||||
|
ap.add_argument('--tagged', help='only songs whose lyrics carry this tag, e.g. auto-transcribed')
|
||||||
|
ap.add_argument('--backup-dir', default=os.environ.get('YTP_BACKUP_DIR', ''), help='where to write the backup JSON (default: Windows Documents on WSL, else ~/)')
|
||||||
|
ap.add_argument('--apply', action='store_true', help='actually write the new lyrics (default: dry run)')
|
||||||
|
ap.add_argument('--tolerance', type=float, default=6, help='max duration difference in seconds')
|
||||||
|
ap.add_argument('--loose', action='store_true', help="accept matches whose artist doesn't line up with the video (risky)")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
api = Api(args.base, os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD'))
|
||||||
|
|
||||||
|
# Which songs? Explicit ids, or every song the server has lyrics for,
|
||||||
|
# filtered by when they were saved and/or their tag.
|
||||||
|
ids = [x.strip() for x in (args.ids or '').split(',') if x.strip()]
|
||||||
|
if not ids:
|
||||||
|
st, r = api.call('GET', '/api/admin/media')
|
||||||
|
if st != 200:
|
||||||
|
sys.exit(f'need --ids (listing needs the newer server build: {r.get("error")})')
|
||||||
|
ids = [m['id'] for m in r['media'] if m.get('lyricsLines')]
|
||||||
|
to_ts = lambda s: dt.datetime.strptime(s, '%Y-%m-%d %H:%M').timestamp() if s else None
|
||||||
|
since, until = to_ts(args.since), to_ts(args.until)
|
||||||
|
|
||||||
|
picked, backup = [], {}
|
||||||
|
for vid in ids:
|
||||||
|
st, n = api.call('GET', f'/api/notes/{vid}')
|
||||||
|
cur = (n or {}).get('lyrics') if st == 200 else None
|
||||||
|
if not cur:
|
||||||
|
continue
|
||||||
|
when = cur['updatedAt']
|
||||||
|
if (since and when < since) or (until and when > until):
|
||||||
|
continue
|
||||||
|
if args.tagged and not any(args.tagged.lower() in t.lower() for t in cur['data'].get('tags', [])):
|
||||||
|
continue
|
||||||
|
st, sres = api.call('GET', f'/api/streams?v={vid}')
|
||||||
|
meta = ((sres or {}).get('data') or {}).get('meta') or {}
|
||||||
|
picked.append({'id': vid, 'cur': cur, 'meta': meta})
|
||||||
|
backup[vid] = {'savedAt': when, 'rev': cur['rev'], 'title': meta.get('title', ''), 'data': cur['data']}
|
||||||
|
|
||||||
|
if not picked:
|
||||||
|
sys.exit('nothing matched')
|
||||||
|
|
||||||
|
# 1) Backup first — always, even on a dry run.
|
||||||
|
out_dir = args.backup_dir or ('/mnt/c/Users/josh/Documents' if os.path.isdir('/mnt/c/Users/josh/Documents') else os.path.expanduser('~'))
|
||||||
|
stamp = dt.datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||||
|
path = os.path.join(out_dir, f'ytplayer-lyrics-backup-{stamp}.json')
|
||||||
|
with open(path, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump({'exportedAt': dt.datetime.now().isoformat(), 'base': args.base, 'songs': backup}, f, ensure_ascii=False, indent=1)
|
||||||
|
print(f'backup: {len(backup)} songs → {path}\n')
|
||||||
|
|
||||||
|
# 2) Look each one up and (optionally) replace.
|
||||||
|
changed = skipped = 0
|
||||||
|
for p in picked:
|
||||||
|
vid, meta, cur = p['id'], p['meta'], p['cur']
|
||||||
|
title, artist = clean_title(meta.get('title')), clean_artist(meta.get('channel'))
|
||||||
|
dur = float(meta.get('duration') or 0)
|
||||||
|
try:
|
||||||
|
hit = lrclib_lookup(title, artist, dur, args.tolerance)
|
||||||
|
except Exception as e: # network hiccup — keep going
|
||||||
|
print(f'{vid} lookup failed: {e}')
|
||||||
|
continue
|
||||||
|
time.sleep(0.4) # be polite to a free service
|
||||||
|
if not hit:
|
||||||
|
skipped += 1
|
||||||
|
print(f'{vid} no match | {title[:42]:42} | {artist[:20]:20} | keeping {len(cur["data"]["lines"])} lines ({",".join(cur["data"].get("tags") or [])[:24]})')
|
||||||
|
continue
|
||||||
|
h = hit['hit']
|
||||||
|
sure = artist_ok(h.get('artistName'), meta.get('channel'), meta.get('title'))
|
||||||
|
mark = 'LRCLIB' if sure else 'UNSURE'
|
||||||
|
print(f'{vid} {mark} {describe(hit, len(cur["data"]["lines"]))}')
|
||||||
|
if not sure and not args.loose:
|
||||||
|
skipped += 1
|
||||||
|
print(f' ↳ artist doesn\'t match "{meta.get("channel", "")}" / the video title — left alone (use --loose to accept)')
|
||||||
|
continue
|
||||||
|
if not args.apply:
|
||||||
|
continue
|
||||||
|
doc = {
|
||||||
|
'lines': hit['lines'],
|
||||||
|
'tags': [f'from LRCLIB ({"synced" if hit["synced"] else "plain text"})'],
|
||||||
|
'offset': 0,
|
||||||
|
}
|
||||||
|
st, rr = api.call('PUT', f'/api/notes/{vid}/lyrics', {'data': doc, 'baseRev': cur['rev']})
|
||||||
|
if st == 200:
|
||||||
|
changed += 1
|
||||||
|
else:
|
||||||
|
print(f' write failed {st}: {rr.get("error")}')
|
||||||
|
print(f'\n{changed} replaced, {skipped} left alone' + ('' if args.apply else ' (dry run — add --apply)'))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user