Add presenter view, stats, lyrics worker, watch party with voice chat, timestamp sharing, soundbites, notes, transcript search, PiP, EQ, sleep fade, gestures and external players; fix the crossfade end-of-song race
This commit is contained in:
71
CLAUDE.md
71
CLAUDE.md
@@ -127,6 +127,11 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
|
|||||||
`playing` (failsafe 5 s / 20 s). afterLoad skips resume/A-marker seeks
|
`playing` (failsafe 5 s / 20 s). afterLoad skips resume/A-marker seeks
|
||||||
during a handoff; `onTrackEnded` ignores `ended` while one runs.
|
during a handoff; `onTrackEnded` ignores `ended` while one runs.
|
||||||
- iOS ignores `element.volume`, so crossfade degrades to gapless there.
|
- iOS ignores `element.volume`, so crossfade degrades to gapless there.
|
||||||
|
- **End-of-song race (fixed):** the song's last `timeupdate` arrives with the
|
||||||
|
element already `paused` (it ended), and `ended` can beat the fade timer.
|
||||||
|
Both used to look like "user paused" → the crossfade was cancelled and the
|
||||||
|
next song restarted from 0. `tick()` and `onTrackEnded` (`Transition.onEnded()`)
|
||||||
|
now finish the handoff when a fade is running at the end.
|
||||||
- Waveform: `GET /api/media/:id/peaks` = 400 RMS buckets (0..100, ^0.7 so
|
- Waveform: `GET /api/media/:id/peaks` = 400 RMS buckets (0..100, ^0.7 so
|
||||||
quiet verses stay visible) from ffmpeg over the server-cached copy, memoised
|
quiet verses stay visible) from ffmpeg over the server-cached copy, memoised
|
||||||
per file path (gen). Audio-only mode draws it on `#waveCanvas` BEHIND the
|
per file path (gen). Audio-only mode draws it on `#waveCanvas` BEHIND the
|
||||||
@@ -137,6 +142,70 @@ 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
|
(upsertMedia rows + `<id>.1.m4a/.mp4` files) — Playwright's Chromium plays
|
||||||
AAC; launch with `--autoplay-policy=no-user-gesture-required`.
|
AAC; launch with `--autoplay-policy=no-user-gesture-required`.
|
||||||
|
|
||||||
|
## 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).
|
||||||
|
Host drives; guests extrapolate the host's `state` from the server-stamped
|
||||||
|
`ts` (clock offset from `hello.now`), seek when > 1.5 s off, mirror
|
||||||
|
play/pause. Guest actions within the 1.5 s "quiet" window after a sync are
|
||||||
|
ignored; later ones → a `cmd` (if the host allows control) or "off sync"
|
||||||
|
(Re-sync button). `playNext/playPrev` → `Party.intercept`, and guests never
|
||||||
|
auto-advance or crossfade. Chat is relayed (≤ 500 chars, 400 ms flood guard,
|
||||||
|
last 60 kept). Voice = WebRTC mesh, the smaller pid offers, STUN only (no
|
||||||
|
TURN — strict NATs won't connect). Host resumes after a reload with the
|
||||||
|
`secret` from `hello` (party survives 5 min without its host).
|
||||||
|
- **Timestamp sharing** (`Share`): `/?v=<id>&t=<sec>` deep links (handled at
|
||||||
|
boot via `playVideoAt`), YouTube `youtu.be/<id>?t=`, GIFs from
|
||||||
|
`GET /api/media/:id/gif` (palettegen, ≤ 6 s, server-cached videos only) and
|
||||||
|
soundbites from `GET /api/media/:id/clip?fmt=mp3|m4r` (m4r ≤ 40 s, written to a
|
||||||
|
temp file so it's a normal faststart MP4 — iPhones reject fragmented ones).
|
||||||
|
- **External players** (`External`): hands out `/api/media/<id>` (the single-file
|
||||||
|
server copy) via Android `intent:` (VLC / MX / chooser), iOS `vlc-x-callback`,
|
||||||
|
desktop `vlc://`, or an `.m3u` (this video or the queue's cached songs).
|
||||||
|
- **Notes**: bookmarks with `kind:'note'` hold multi-line text (≤ 2000) and carry
|
||||||
|
`vt`/`vc` (video title/channel); the 📝 Notes view lists them all, exports
|
||||||
|
Markdown. **Transcript search** = 🔎 tab over the lyrics or the YouTube
|
||||||
|
captions (`/api/notes/:id/captions`, cached per video).
|
||||||
|
- **PiP** mirrors the PiP window's play/pause onto the whole player (dual mode's
|
||||||
|
sound is on `<audio>`). **EQ**: 5 BiquadFilters; media elements are wired in
|
||||||
|
only once a non-flat curve is picked (irreversible), disabled on iOS (Web
|
||||||
|
Audio suspends on lock → would kill background audio). **Sleep timer**: fade
|
||||||
|
over `sleepFade` s, then pause / "close" (window.close, falls back) / black
|
||||||
|
curtain; "end of this song" also blocks Transition. **Gestures**: double-tap
|
||||||
|
thirds ±10 s / play, pinch 1–3× (CSS transform), vertical swipes for volume /
|
||||||
|
brightness (CSS filter) only in fullscreen or `body.landscape-fs` (inline the
|
||||||
|
page keeps `touch-action: pan-y` so it still scrolls).
|
||||||
|
- Local media tests: seed `media_cache` rows + `<id>.1.m4a/.mp4` files; the
|
||||||
|
Playwright `channel: 'chromium'` build plays H.264/AAC; `--use-fake-device-for-media-stream`
|
||||||
|
gives WebRTC a fake mic.
|
||||||
|
|
||||||
|
## Presenter view, stats, lyrics worker
|
||||||
|
- **Presenter** (`Presenter` in app.js, `/?present=<code>`): a lyrics-only
|
||||||
|
projector/TV screen. Pairs through the phone-remote relay like a phone
|
||||||
|
(listen-only); the host's `state` carries `v.id`, `cur`, `rate`, `paused` and
|
||||||
|
`off` (the host's personal lyric offset), and the presenter extrapolates the
|
||||||
|
position between the ~1/s updates. Host modal → "Open presenter window
|
||||||
|
here" opens a popup to drag onto a second display; `/api/remote/qr/<code>?kind=present`
|
||||||
|
is its QR. Keys: F / double-click fullscreen, B blank screen. Forced dark
|
||||||
|
tokens so it stays readable if the device is in light theme.
|
||||||
|
- **`fitLyricLines(list)`** is the shared one-row font fitter for service-mode
|
||||||
|
lyrics and the presenter — re-run it on font load, resize and +900 ms
|
||||||
|
(first fit on a fresh page happens before Bricolage loads).
|
||||||
|
- **Stats** (`frontend/stats-core.js`, pure + tested; `StatsTrack` + `renderStats`):
|
||||||
|
`data.stats.days[YYYY-MM-DD] = {s, p, songs:{id:plays}}` (local days, 400
|
||||||
|
kept) synced with the profile. Seconds = media-time deltas while playing
|
||||||
|
(seeks/pauses excluded, divided by playback rate); a play = 30 s heard in one
|
||||||
|
load; commits every 60 s / on play / on page hide to avoid profile-push spam.
|
||||||
|
Streak = consecutive days ≥ 5 min (today may still be pending).
|
||||||
|
- **lyrics-worker** (compose service, `scripts/lyrics/Dockerfile`): runs
|
||||||
|
`auto_lyrics.py --missing --watch 300` forever — one song per cycle, nice 10,
|
||||||
|
`cpus: 2`, `mem_limit: 3g`, audio fetched from `http://ytplayer:3000` over the
|
||||||
|
private `lyrics` network (never the WAN). Auth = env `LYRICS_WORKER_TOKEN`
|
||||||
|
(≥ 24 chars) accepted by notes.js as `api:lyrics-worker` without a DB row;
|
||||||
|
unset → the worker idles. `/data/state.json` remembers instrumentals (never
|
||||||
|
retried) and failures (backoff 15 min·2^n). Model (~1.6 GB) downloads to the
|
||||||
|
`lyrics-models` volume on first start.
|
||||||
|
|
||||||
## Phone remote (`server/remote.js`, `Remote` in app.js)
|
## Phone remote (`server/remote.js`, `Remote` in app.js)
|
||||||
- Desktop tab playing to a TV = **host**; phone = **remote**. Both are browser
|
- Desktop tab playing to a TV = **host**; phone = **remote**. Both are browser
|
||||||
tabs of this app, so the server relays over `/ws/remote` (Bun `server.upgrade`
|
tabs of this app, so the server relays over `/ws/remote` (Bun `server.upgrade`
|
||||||
@@ -281,7 +350,7 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f
|
|||||||
`render()` — so every playlist mutation refreshes both in one place.
|
`render()` — so every playlist mutation refreshes both in one place.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
- Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit).
|
- Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit, lyrics-core, stats-core).
|
||||||
Server: **`cd server && bun run test`** — runs each file in its own process (db.js is a
|
Server: **`cd server && bun run test`** — runs each file in its own process (db.js is a
|
||||||
singleton, so two test files in one `bun test` run share one temp DB; bare `bun test`
|
singleton, so two test files in one `bun test` run share one temp DB; bare `bun test`
|
||||||
also drags in the frontend node tests via the `public` symlink). media-cache needs a
|
also drags in the frontend node tests via the `public` symlink). media-cache needs a
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ services:
|
|||||||
# Phone remote: "1" = a phone may only pair with a screen on the same
|
# Phone remote: "1" = a phone may only pair with a screen on the same
|
||||||
# network (same public IP as seen by the server). Off by default.
|
# network (same public IP as seen by the server). Off by default.
|
||||||
REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}"
|
REMOTE_SAME_NETWORK: "${REMOTE_SAME_NETWORK:-0}"
|
||||||
|
# Shared secret the lyrics-worker uses to list songs and upload lyrics.
|
||||||
|
# Set in Dokploy's Environment tab (same value feeds both services).
|
||||||
|
LYRICS_WORKER_TOKEN: "${LYRICS_WORKER_TOKEN:-}"
|
||||||
# Optional: override yt-dlp binary path if you mount a custom one
|
# Optional: override yt-dlp binary path if you mount a custom one
|
||||||
# YTDLP_PATH: "/usr/local/bin/yt-dlp"
|
# YTDLP_PATH: "/usr/local/bin/yt-dlp"
|
||||||
# Server media cache (server/media-cache.js) — defaults shown.
|
# Server media cache (server/media-cache.js) — defaults shown.
|
||||||
@@ -36,6 +39,7 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
networks:
|
networks:
|
||||||
- dokploy-network
|
- dokploy-network
|
||||||
|
- lyrics
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.ytplayer-http.rule=Host(`worship.hesed.sbs`)"
|
- "traefik.http.routers.ytplayer-http.rule=Host(`worship.hesed.sbs`)"
|
||||||
@@ -46,10 +50,39 @@ services:
|
|||||||
- "traefik.http.routers.ytplayer-https.tls.certresolver=letsencrypt"
|
- "traefik.http.routers.ytplayer-https.tls.certresolver=letsencrypt"
|
||||||
- "traefik.http.services.ytplayer-svc.loadbalancer.server.port=3000"
|
- "traefik.http.services.ytplayer-svc.loadbalancer.server.port=3000"
|
||||||
|
|
||||||
|
# Transcribes saved songs that have no lyrics yet, one at a time, with
|
||||||
|
# faster-whisper on CPU (no API keys, no credits). Separate container so it
|
||||||
|
# never competes with playback/downloads: capped CPU + memory, low priority,
|
||||||
|
# and it fetches audio from the ytplayer service over the private network.
|
||||||
|
lyrics-worker:
|
||||||
|
build: ./scripts/lyrics
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- ytplayer
|
||||||
|
environment:
|
||||||
|
YTP_BASE: "http://ytplayer:3000"
|
||||||
|
YTP_TOKEN: "${LYRICS_WORKER_TOKEN:-}"
|
||||||
|
WHISPER_MODEL: "large-v3-turbo" # ~1.6 GB download on first start
|
||||||
|
WHISPER_THREADS: "2"
|
||||||
|
WATCH_SECONDS: "300"
|
||||||
|
volumes:
|
||||||
|
- lyrics-models:/models
|
||||||
|
- lyrics-state:/data
|
||||||
|
cpus: "2"
|
||||||
|
mem_limit: 3g
|
||||||
|
networks:
|
||||||
|
- lyrics
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
ytplayer-data:
|
ytplayer-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
lyrics-models:
|
||||||
|
driver: local
|
||||||
|
lyrics-state:
|
||||||
|
driver: local
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
dokploy-network:
|
dokploy-network:
|
||||||
external: true
|
external: true
|
||||||
|
lyrics:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
1822
frontend/app.js
1822
frontend/app.js
File diff suppressed because it is too large
Load Diff
@@ -39,7 +39,10 @@
|
|||||||
<button class="nav-item" data-view="history">🕘 History</button>
|
<button class="nav-item" data-view="history">🕘 History</button>
|
||||||
<button class="nav-item" data-view="saved">💾 Saved</button>
|
<button class="nav-item" data-view="saved">💾 Saved</button>
|
||||||
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
|
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
|
||||||
|
<button class="nav-item" data-view="notes">📝 Notes</button>
|
||||||
|
<button class="nav-item" data-view="stats">📊 Stats</button>
|
||||||
<button class="nav-item" data-view="settings">⚙ Settings</button>
|
<button class="nav-item" data-view="settings">⚙ Settings</button>
|
||||||
|
<button class="nav-item" id="navPartyBtn" type="button" title="Watch together with friends">🎉 Watch party</button>
|
||||||
<button class="nav-item" id="navRemoteBtn" type="button" title="Control this screen from a phone, or use this phone as a remote">📱 Remote</button>
|
<button class="nav-item" id="navRemoteBtn" type="button" title="Control this screen from a phone, or use this phone as a remote">📱 Remote</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -135,6 +138,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bufferSpinner" class="spinner hidden"></div>
|
<div id="bufferSpinner" class="spinner hidden"></div>
|
||||||
|
<!-- Touch gesture feedback (Gestures in app.js) -->
|
||||||
|
<div id="gestureHud" class="gesture-hud hidden"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="controls" class="controls hidden">
|
<div id="controls" class="controls hidden">
|
||||||
@@ -193,6 +198,8 @@
|
|||||||
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
|
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
|
||||||
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
|
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
|
||||||
<button id="transitionBtn" class="ctrl" title="Between songs in a playlist: Normal — click to change">↦</button>
|
<button id="transitionBtn" class="ctrl" title="Between songs in a playlist: Normal — click to change">↦</button>
|
||||||
|
<button id="eqBtn" class="ctrl" title="Equalizer (E)">🎚</button>
|
||||||
|
<button id="pipBtn" class="ctrl hidden" title="Picture-in-picture (I)">⧉</button>
|
||||||
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
|
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,6 +219,8 @@
|
|||||||
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
|
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
|
||||||
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit & download</button>
|
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit & download</button>
|
||||||
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
|
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
|
||||||
|
<button id="shareMomentBtn" class="np-btn" title="Share a link or GIF of this exact moment">🔗 Share moment</button>
|
||||||
|
<button id="externalBtn" class="np-btn" title="Play this in VLC, MX Player or another app">📺 Open in…</button>
|
||||||
<button id="brokenBtn" class="np-btn" title="Video won't play or looks broken? Re-download the server's copy" hidden>⚠ Broken</button>
|
<button id="brokenBtn" class="np-btn" title="Video won't play or looks broken? Re-download the server's copy" hidden>⚠ Broken</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,6 +230,7 @@
|
|||||||
<div class="notes-tabs" role="tablist">
|
<div class="notes-tabs" role="tablist">
|
||||||
<button class="notes-tab active" data-tab="lyrics" role="tab" type="button">Lyrics</button>
|
<button class="notes-tab active" data-tab="lyrics" role="tab" type="button">Lyrics</button>
|
||||||
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters & bookmarks <span id="markersCount" class="notes-count"></span></button>
|
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters & bookmarks <span id="markersCount" class="notes-count"></span></button>
|
||||||
|
<button class="notes-tab" data-tab="search" role="tab" type="button">🔎 Search</button>
|
||||||
<span id="notesStatus" class="notes-status"></span>
|
<span id="notesStatus" class="notes-status"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,11 +258,24 @@
|
|||||||
<div id="lyricsList" class="lyrics-list"></div>
|
<div id="lyricsList" class="lyrics-list"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="searchPane" class="notes-pane hidden">
|
||||||
|
<div class="ts-bar">
|
||||||
|
<input id="tsQuery" class="ts-query" type="search" placeholder="Search what's said or sung…" autocomplete="off" />
|
||||||
|
<select id="tsSource" class="ts-source" aria-label="Search in">
|
||||||
|
<option value="lyrics">Lyrics</option>
|
||||||
|
<option value="captions">YouTube transcript</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="tsInfo" class="ts-info"></div>
|
||||||
|
<div id="tsResults" class="ts-results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="markersPane" class="notes-pane hidden">
|
<div id="markersPane" class="notes-pane hidden">
|
||||||
<div id="markerChips" class="marker-chips"></div>
|
<div id="markerChips" class="marker-chips"></div>
|
||||||
<div class="markers-toolbar">
|
<div class="markers-toolbar">
|
||||||
<button id="addChapterBtn" class="np-btn" type="button">+ Chapter</button>
|
<button id="addChapterBtn" class="np-btn" type="button">+ Chapter</button>
|
||||||
<button id="addBookmarkBtn" class="np-btn" type="button" title="Pin a private bookmark here [P]">📌 Bookmark</button>
|
<button id="addBookmarkBtn" class="np-btn" type="button" title="Pin a private bookmark here [P]">📌 Bookmark</button>
|
||||||
|
<button id="addNoteBtn" class="np-btn" type="button" title="Write a note tied to this moment [N]">📝 Note</button>
|
||||||
<span class="notes-spacer"></span>
|
<span class="notes-spacer"></span>
|
||||||
<button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button>
|
<button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button>
|
||||||
<button id="exportMarkersBtn" class="np-btn" type="button">⤓ Export</button>
|
<button id="exportMarkersBtn" class="np-btn" type="button">⤓ Export</button>
|
||||||
@@ -262,7 +285,7 @@
|
|||||||
<div id="chapterList" class="marker-list"></div>
|
<div id="chapterList" class="marker-list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="markers-section">
|
<div class="markers-section">
|
||||||
<div class="markers-heading">My bookmarks <span class="markers-scope">only you</span></div>
|
<div class="markers-heading">My notes & bookmarks <span class="markers-scope">only you</span></div>
|
||||||
<div id="bookmarkList" class="marker-list"></div>
|
<div id="bookmarkList" class="marker-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -357,6 +380,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Sleep timer "screen off": black curtain; tap to wake -->
|
||||||
|
<div id="sleepCurtain" class="sleep-curtain hidden" role="button" aria-label="Wake">
|
||||||
|
<span id="sleepCurtainMsg">Sleep timer ended · tap to wake</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
|
||||||
<!-- Mini now-playing bar -->
|
<!-- Mini now-playing bar -->
|
||||||
@@ -376,11 +404,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
|
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
|
||||||
|
<!-- Watch party panel (Party in app.js) -->
|
||||||
|
<aside id="partyPanel" class="party-panel hidden" aria-label="Watch party">
|
||||||
|
<div class="pt-head"><span id="ptTitle">🎉 Watch party</span><span id="ptStatus" class="pt-status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div>
|
||||||
|
<div class="pt-body"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<!-- Desktop: shown while phones are driving this screen (remote.js) -->
|
<!-- Desktop: shown while phones are driving this screen (remote.js) -->
|
||||||
<button id="remoteChip" class="remote-chip hidden" type="button" title="Phone remote settings">
|
<button id="remoteChip" class="remote-chip hidden" type="button" title="Phone remote settings">
|
||||||
<span class="remote-chip-dot"></span><span id="remoteChipText">Remote connected</span>
|
<span class="remote-chip-dot"></span><span id="remoteChipText">Remote connected</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Presenter / projector screen: lyrics of whatever the paired screen plays -->
|
||||||
|
<section id="presenterView" class="presenter-view hidden" aria-label="Presenter view">
|
||||||
|
<div id="pvList" class="sl-list pv-list"></div>
|
||||||
|
<div id="pvTitle" class="pv-title hidden"></div>
|
||||||
|
<div id="pvBar" class="pv-bar">
|
||||||
|
<span id="pvStatus" class="pv-status">Connecting…</span>
|
||||||
|
<span class="pv-hint">Double-click or F = fullscreen · B = blank screen</span>
|
||||||
|
<button id="pvFull" class="pv-btn" type="button">⛶ Fullscreen</button>
|
||||||
|
</div>
|
||||||
|
<div id="pvPair" class="pv-pair hidden">
|
||||||
|
<div class="pv-pair-title">Presenter screen</div>
|
||||||
|
<p>Enter the 6-digit code shown on the playing computer (📱 Remote → Control this screen → Presenter).</p>
|
||||||
|
<input id="pvCode" inputmode="numeric" maxlength="7" placeholder="000 000" />
|
||||||
|
<button id="pvPairBtn" class="rv-primary" type="button">Connect</button>
|
||||||
|
<div id="pvMsg" class="rv-pair-msg"></div>
|
||||||
|
</div>
|
||||||
|
<div id="pvBlank" class="pv-blank hidden"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Phone: remote control for a desktop instance -->
|
<!-- Phone: remote control for a desktop instance -->
|
||||||
<section id="remoteView" class="remote-view hidden" aria-label="Phone remote">
|
<section id="remoteView" class="remote-view hidden" aria-label="Phone remote">
|
||||||
<div class="rv-top">
|
<div class="rv-top">
|
||||||
@@ -481,6 +534,9 @@
|
|||||||
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
|
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
|
||||||
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
|
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
|
||||||
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
|
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>N</kbd><span>Write a note at this moment</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>I</kbd><span>Picture-in-picture</span></div>
|
||||||
|
<div class="shortcut-row"><kbd>E</kbd><span>Equalizer</span></div>
|
||||||
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
|
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
|
||||||
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
|
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -491,6 +547,7 @@
|
|||||||
<script src="opfs.js"></script>
|
<script src="opfs.js"></script>
|
||||||
<script src="video-edit.js"></script>
|
<script src="video-edit.js"></script>
|
||||||
<script src="lyrics-core.js"></script>
|
<script src="lyrics-core.js"></script>
|
||||||
|
<script src="stats-core.js"></script>
|
||||||
<script src="async-guard.js"></script>
|
<script src="async-guard.js"></script>
|
||||||
<script src="sw-update.js"></script>
|
<script src="sw-update.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
|
|||||||
152
frontend/stats-core.js
Normal file
152
frontend/stats-core.js
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* stats-core.js — listening statistics (pure; window.StatsCore / node)
|
||||||
|
*
|
||||||
|
* Stored in data.stats (synced with the profile):
|
||||||
|
* { v: 1,
|
||||||
|
* days: { 'YYYY-MM-DD': { s: secondsListened, p: plays, songs: { id: plays } } },
|
||||||
|
* meta: { id: { t: title, c: channel } } }
|
||||||
|
* Days are LOCAL calendar days. A play counts once a song has been listened
|
||||||
|
* to for 30 s in one load. Days older than KEEP_DAYS are pruned.
|
||||||
|
* ========================================================================== */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const KEEP_DAYS = 400;
|
||||||
|
const META_MAX = 600;
|
||||||
|
const STREAK_MIN_SEC = 5 * 60; // a day counts toward the streak at 5 min
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
const dayKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||||
|
const addDays = (d, n) => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() + n); return x; };
|
||||||
|
|
||||||
|
function empty() { return { v: 1, days: {}, meta: {} }; }
|
||||||
|
|
||||||
|
function ensure(stats) {
|
||||||
|
if (!stats || typeof stats !== 'object' || !stats.days) return empty();
|
||||||
|
if (!stats.meta) stats.meta = {};
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add listened seconds (and optionally one play) for a song on `now`'s day.
|
||||||
|
function record(stats, song, seconds, countPlay, now = new Date()) {
|
||||||
|
stats = ensure(stats);
|
||||||
|
const k = dayKey(now);
|
||||||
|
const d = stats.days[k] || (stats.days[k] = { s: 0, p: 0, songs: {} });
|
||||||
|
if (seconds > 0) d.s = Math.round((d.s + seconds) * 10) / 10;
|
||||||
|
if (countPlay && song && song.id) {
|
||||||
|
d.p += 1;
|
||||||
|
d.songs[song.id] = (d.songs[song.id] || 0) + 1;
|
||||||
|
}
|
||||||
|
if (song && song.id) {
|
||||||
|
stats.meta[song.id] = { t: String(song.title || '').slice(0, 200), c: String(song.channel || '').slice(0, 120) };
|
||||||
|
const ids = Object.keys(stats.meta);
|
||||||
|
if (ids.length > META_MAX) {
|
||||||
|
const used = new Set();
|
||||||
|
Object.values(stats.days).forEach((x) => Object.keys(x.songs || {}).forEach((id) => used.add(id)));
|
||||||
|
for (const id of ids) if (!used.has(id) && Object.keys(stats.meta).length > META_MAX) delete stats.meta[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prune(stats, now);
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prune(stats, now = new Date()) {
|
||||||
|
const cutoff = dayKey(addDays(now, -KEEP_DAYS));
|
||||||
|
for (const k of Object.keys(stats.days)) if (k < cutoff) delete stats.days[k];
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Hillsong Worship - Topic", "BethelVEVO" → artist name for grouping.
|
||||||
|
function artistOf(channel) {
|
||||||
|
return String(channel || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(/\s+Official$/i, '').trim() || 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function streaks(stats, now = new Date()) {
|
||||||
|
const active = (d) => ((stats.days[dayKey(d)] || {}).s || 0) >= STREAK_MIN_SEC;
|
||||||
|
// Current streak: ending today, or yesterday if today hasn't counted yet.
|
||||||
|
let cur = 0;
|
||||||
|
let d = active(now) ? now : addDays(now, -1);
|
||||||
|
while (active(d)) { cur++; d = addDays(d, -1); }
|
||||||
|
let best = 0, run = 0;
|
||||||
|
const keys = Object.keys(stats.days).sort();
|
||||||
|
if (keys.length) {
|
||||||
|
const [y, m, dd] = keys[0].split('-').map(Number);
|
||||||
|
for (let x = new Date(y, m - 1, dd); dayKey(x) <= dayKey(now); x = addDays(x, 1)) {
|
||||||
|
run = active(x) ? run + 1 : 0;
|
||||||
|
if (run > best) best = run;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { current: cur, best: Math.max(best, cur) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(statsIn, now = new Date()) {
|
||||||
|
const stats = ensure(statsIn);
|
||||||
|
const monthKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
||||||
|
const thisMonth = monthKey(now);
|
||||||
|
const lastMonth = monthKey(new Date(now.getFullYear(), now.getMonth() - 1, 1));
|
||||||
|
let thisMonthSec = 0, lastMonthSec = 0, totalSec = 0, totalPlays = 0;
|
||||||
|
const songsAll = {}, artistsMonth = {};
|
||||||
|
for (const [k, d] of Object.entries(stats.days)) {
|
||||||
|
totalSec += d.s || 0;
|
||||||
|
totalPlays += d.p || 0;
|
||||||
|
if (k.startsWith(thisMonth)) thisMonthSec += d.s || 0;
|
||||||
|
if (k.startsWith(lastMonth)) lastMonthSec += d.s || 0;
|
||||||
|
for (const [id, n] of Object.entries(d.songs || {})) {
|
||||||
|
songsAll[id] = (songsAll[id] || 0) + n;
|
||||||
|
if (k.startsWith(thisMonth)) {
|
||||||
|
const a = artistOf((stats.meta[id] || {}).c);
|
||||||
|
artistsMonth[a] = (artistsMonth[a] || 0) + n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topArtists = Object.entries(artistsMonth).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
||||||
|
.map(([name, plays]) => ({ name, plays }));
|
||||||
|
|
||||||
|
// Top songs over the last 30 days, each with a 30-point daily sparkline.
|
||||||
|
const last30 = Array.from({ length: 30 }, (_, i) => dayKey(addDays(now, i - 29)));
|
||||||
|
const recent = {};
|
||||||
|
for (const k of last30) for (const [id, n] of Object.entries((stats.days[k] || {}).songs || {})) recent[id] = (recent[id] || 0) + n;
|
||||||
|
const topSongs = Object.entries(recent).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, plays]) => ({
|
||||||
|
id, plays, title: (stats.meta[id] || {}).t || id, channel: (stats.meta[id] || {}).c || '',
|
||||||
|
spark: last30.map((k) => ((stats.days[k] || {}).songs || {})[id] || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 16-week heatmap: columns are weeks (oldest → current), rows Mon..Sun.
|
||||||
|
const WEEKS = 16;
|
||||||
|
const monIdx = (d) => (d.getDay() + 6) % 7; // Mon=0 … Sun=6
|
||||||
|
const thisMonday = addDays(now, -monIdx(now));
|
||||||
|
const start = addDays(thisMonday, -(WEEKS - 1) * 7);
|
||||||
|
const heat = [];
|
||||||
|
for (let w = 0; w < WEEKS; w++) {
|
||||||
|
const col = [];
|
||||||
|
for (let r = 0; r < 7; r++) {
|
||||||
|
const d = addDays(start, w * 7 + r);
|
||||||
|
const k = dayKey(d);
|
||||||
|
col.push({ date: k, minutes: Math.round(((stats.days[k] || {}).s || 0) / 60), future: k > dayKey(now) });
|
||||||
|
}
|
||||||
|
heat.push(col);
|
||||||
|
}
|
||||||
|
// Peak weekday: average hours on that weekday across the heatmap window.
|
||||||
|
const byDow = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }));
|
||||||
|
heat.forEach((col) => col.forEach((c, r) => { if (!c.future) { byDow[r].sum += c.minutes; byDow[r].n += 1; } }));
|
||||||
|
const names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||||
|
let peak = null;
|
||||||
|
byDow.forEach((x, i) => { const avg = x.n ? x.sum / x.n / 60 : 0; if (avg > 0 && (!peak || avg > peak.avgHours)) peak = { day: names[i], avgHours: avg }; });
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSec, totalPlays, thisMonthSec, lastMonthSec,
|
||||||
|
songsPlayed: Object.keys(songsAll).length,
|
||||||
|
streak: streaks(stats, now), topArtists, topSongs, heat, peak,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtHours(sec) {
|
||||||
|
const m = Math.round((sec || 0) / 60);
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
return h ? `${h}h ${pad(m % 60)}m` : `${m}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StatsCore = { empty, ensure, record, prune, summarize, streaks, artistOf, dayKey, fmtHours, STREAK_MIN_SEC };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = StatsCore;
|
||||||
|
else root.StatsCore = StatsCore;
|
||||||
|
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||||
67
frontend/stats-core.test.js
Normal file
67
frontend/stats-core.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert');
|
||||||
|
const SC = require('./stats-core');
|
||||||
|
|
||||||
|
const at = (y, m, d, h = 12) => new Date(y, m - 1, d, h);
|
||||||
|
const songA = { id: 'aaaaaaaaaaa', title: 'Goodness of God', channel: 'Bethel Music - Topic' };
|
||||||
|
const songB = { id: 'bbbbbbbbbbb', title: 'Build My Life', channel: 'Housefires' };
|
||||||
|
|
||||||
|
test('record accumulates seconds and plays per local day', () => {
|
||||||
|
let s = SC.empty();
|
||||||
|
s = SC.record(s, songA, 100, true, at(2026, 9, 18));
|
||||||
|
s = SC.record(s, songA, 50, false, at(2026, 9, 18));
|
||||||
|
s = SC.record(s, songB, 30, true, at(2026, 9, 19));
|
||||||
|
assert.deepStrictEqual(s.days['2026-09-18'], { s: 150, p: 1, songs: { aaaaaaaaaaa: 1 } });
|
||||||
|
assert.strictEqual(s.days['2026-09-19'].p, 1);
|
||||||
|
assert.strictEqual(s.meta.aaaaaaaaaaa.t, 'Goodness of God');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('old days are pruned', () => {
|
||||||
|
let s = SC.empty();
|
||||||
|
s = SC.record(s, songA, 10, true, at(2024, 1, 1));
|
||||||
|
s = SC.record(s, songA, 10, true, at(2026, 9, 18));
|
||||||
|
assert.deepStrictEqual(Object.keys(s.days), ['2026-09-18']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('streak counts consecutive 5-minute days, allowing today to be pending', () => {
|
||||||
|
let s = SC.empty();
|
||||||
|
for (const d of [10, 11, 12, 15, 16, 17]) s = SC.record(s, songA, 400, true, at(2026, 9, d));
|
||||||
|
s = SC.record(s, songA, 60, true, at(2026, 9, 18)); // today, under 5 min so far
|
||||||
|
const st = SC.streaks(s, at(2026, 9, 18));
|
||||||
|
assert.strictEqual(st.current, 3); // 15,16,17
|
||||||
|
assert.strictEqual(st.best, 3);
|
||||||
|
s = SC.record(s, songA, 400, true, at(2026, 9, 18));
|
||||||
|
assert.strictEqual(SC.streaks(s, at(2026, 9, 18)).current, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summary: month totals, top artists (Topic stripped), top songs with sparkline, heatmap', () => {
|
||||||
|
let s = SC.empty();
|
||||||
|
s = SC.record(s, songA, 3600, true, at(2026, 8, 20)); // last month
|
||||||
|
s = SC.record(s, songA, 1800, true, at(2026, 9, 14)); // this month (Mon)
|
||||||
|
s = SC.record(s, songA, 1800, true, at(2026, 9, 17));
|
||||||
|
s = SC.record(s, songB, 600, true, at(2026, 9, 17));
|
||||||
|
const sum = SC.summarize(s, at(2026, 9, 18));
|
||||||
|
assert.strictEqual(sum.thisMonthSec, 4200);
|
||||||
|
assert.strictEqual(sum.lastMonthSec, 3600);
|
||||||
|
assert.strictEqual(sum.totalPlays, 4);
|
||||||
|
assert.strictEqual(sum.songsPlayed, 2);
|
||||||
|
assert.deepStrictEqual(sum.topArtists, [{ name: 'Bethel Music', plays: 2 }, { name: 'Housefires', plays: 1 }]);
|
||||||
|
assert.strictEqual(sum.topSongs[0].title, 'Goodness of God');
|
||||||
|
assert.strictEqual(sum.topSongs[0].plays, 3);
|
||||||
|
assert.strictEqual(sum.topSongs[0].spark.length, 30);
|
||||||
|
assert.strictEqual(sum.topSongs[0].spark.at(-2), 1); // Sep 17
|
||||||
|
assert.strictEqual(sum.heat.length, 16);
|
||||||
|
const cur = sum.heat[15];
|
||||||
|
assert.strictEqual(cur[0].date, '2026-09-14'); // Monday of this week
|
||||||
|
assert.strictEqual(cur[0].minutes, 30);
|
||||||
|
assert.strictEqual(cur[6].future, true); // Sunday Sep 20 hasn't happened
|
||||||
|
assert.strictEqual(sum.peak.day, 'Thursday'); // Sep 17 had 40 min
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fmtHours and artistOf', () => {
|
||||||
|
assert.strictEqual(SC.fmtHours(152280), '42h 18m');
|
||||||
|
assert.strictEqual(SC.fmtHours(600), '10m');
|
||||||
|
assert.strictEqual(SC.artistOf('BethelVEVO'), 'Bethel');
|
||||||
|
assert.strictEqual(SC.artistOf(''), 'Unknown');
|
||||||
|
});
|
||||||
@@ -3802,3 +3802,234 @@ body.remote-open { overflow: hidden; }
|
|||||||
color: var(--text-2); font-size: 12px; cursor: pointer;
|
color: var(--text-2); font-size: 12px; cursor: pointer;
|
||||||
}
|
}
|
||||||
.wave-loud:hover { color: var(--text); border-color: var(--accent); }
|
.wave-loud:hover { color: var(--text); border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Stats view (renderStats in app.js)
|
||||||
|
* ========================================================================== */
|
||||||
|
.stats { grid-column: 1 / -1; position: relative; display: flex; flex-direction: column; gap: 16px; padding-bottom: 24px; }
|
||||||
|
.stats-empty { grid-column: 1 / -1; }
|
||||||
|
.st-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
|
||||||
|
.st-tile, .st-panel {
|
||||||
|
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.st-label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim); }
|
||||||
|
.st-big { font-family: var(--mono); font-size: 32px; font-weight: 700; color: var(--text); margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
|
||||||
|
.st-sub { font-size: 12px; color: var(--text-2); }
|
||||||
|
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
|
||||||
|
.st-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 12px; font-weight: 700; font-size: 14px; color: var(--text); }
|
||||||
|
.st-panel-head small { font-weight: 400; font-size: 11px; color: var(--text-dim); text-align: right; }
|
||||||
|
.st-none { font-size: 13px; color: var(--text-dim); }
|
||||||
|
.st-bar-row { display: grid; grid-template-columns: minmax(80px, 34%) 1fr 34px; align-items: center; gap: 10px; padding: 5px 0; }
|
||||||
|
.st-bar-name { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.st-bar-track { height: 10px; }
|
||||||
|
.st-bar { display: block; height: 10px; border-radius: 0 4px 4px 0; background: var(--accent); }
|
||||||
|
.st-bar-row:hover .st-bar { background: var(--accent-bright); }
|
||||||
|
.st-bar-val { font-family: var(--mono); font-size: 12px; color: var(--text-2); text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.st-song { display: grid; grid-template-columns: 24px 1fr 96px 34px; align-items: center; gap: 10px; padding: 6px 0; cursor: pointer; border-bottom: 1px solid var(--line-soft); }
|
||||||
|
.st-song:last-child { border-bottom: none; }
|
||||||
|
.st-song:hover b { color: var(--accent-bright); }
|
||||||
|
.st-rank { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
|
||||||
|
.st-song-text { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.st-song-text b { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.st-song-text small { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.st-spark polyline { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
.st-heat-wrap { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
|
||||||
|
.st-heat-days { display: grid; grid-template-rows: 14px repeat(7, 14px); row-gap: 3px; font-size: 9px; color: var(--text-dim); font-family: var(--mono); }
|
||||||
|
.st-heat-days span { line-height: 14px; }
|
||||||
|
.st-heat-months { display: grid; grid-template-columns: repeat(16, 14px); column-gap: 3px; height: 14px; font-size: 9px; color: var(--text-dim); font-family: var(--mono); white-space: nowrap; margin-bottom: 3px; }
|
||||||
|
.st-heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-template-columns: repeat(16, 14px); gap: 3px; }
|
||||||
|
.st-cell { width: 14px; height: 14px; border-radius: 3px; background: var(--bg-3); }
|
||||||
|
.st-cell.l1 { background: color-mix(in srgb, var(--accent) 28%, var(--bg-3)); }
|
||||||
|
.st-cell.l2 { background: color-mix(in srgb, var(--accent) 50%, var(--bg-3)); }
|
||||||
|
.st-cell.l3 { background: color-mix(in srgb, var(--accent) 74%, var(--bg-3)); }
|
||||||
|
.st-cell.l4 { background: var(--accent); }
|
||||||
|
.st-cell.future { background: transparent; border: 1px dashed var(--line); box-sizing: border-box; }
|
||||||
|
.st-heat .st-cell:hover { outline: 2px solid var(--text); outline-offset: 1px; }
|
||||||
|
.st-legend { display: flex; align-items: center; gap: 4px; margin-top: 10px; font-size: 11px; color: var(--text-dim); }
|
||||||
|
.st-legend span:first-child { margin-right: 4px; }
|
||||||
|
.st-legend span:last-child { margin-left: 4px; }
|
||||||
|
.st-table { margin-top: 10px; font-size: 12px; color: var(--text-2); }
|
||||||
|
.st-table summary { cursor: pointer; color: var(--text-dim); }
|
||||||
|
.st-table table { margin-top: 8px; border-collapse: collapse; }
|
||||||
|
.st-table td, .st-table th { padding: 3px 12px 3px 0; text-align: left; font-variant-numeric: tabular-nums; }
|
||||||
|
.st-tip {
|
||||||
|
position: absolute; transform: translate(-50%, -100%); pointer-events: none; z-index: 5;
|
||||||
|
background: var(--bg-3); border: 1px solid var(--line); color: var(--text);
|
||||||
|
padding: 5px 9px; border-radius: 8px; font-size: 12px; white-space: nowrap; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Presenter / projector view (Presenter in app.js) — always dark
|
||||||
|
* ========================================================================== */
|
||||||
|
.presenter-view {
|
||||||
|
--text: #f7f5f2; --text-2: #c9c8d0; --text-dim: #8a8994; --line: #2a2a33;
|
||||||
|
position: fixed; inset: 0; z-index: 420;
|
||||||
|
background: #000; color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
body.presenter-open { overflow: hidden; }
|
||||||
|
.pv-list { position: absolute; inset: 4vh 0 8vh 0; }
|
||||||
|
.presenter-view .sl-line { cursor: default; }
|
||||||
|
.pv-title {
|
||||||
|
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
text-align: center; padding: 0 6vw; font-family: var(--display); font-weight: 800;
|
||||||
|
font-size: clamp(32px, 6vw, 96px); line-height: 1.1; color: var(--text);
|
||||||
|
}
|
||||||
|
.pv-title small { font-family: var(--ui); font-weight: 500; font-size: clamp(16px, 2vw, 28px); color: var(--text-dim); margin-top: 16px; }
|
||||||
|
.pv-bar {
|
||||||
|
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 16px;
|
||||||
|
padding: 10px 18px; font-size: 13px; color: var(--text-dim);
|
||||||
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); transition: opacity 0.4s;
|
||||||
|
}
|
||||||
|
.pv-status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pv-hint { font-size: 11px; }
|
||||||
|
.pv-btn { background: #16161b; color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; }
|
||||||
|
.presenter-view.idle { cursor: none; }
|
||||||
|
.presenter-view.idle .pv-bar { opacity: 0; }
|
||||||
|
.pv-pair {
|
||||||
|
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||||
|
width: min(420px, 90vw); display: flex; flex-direction: column; gap: 12px; text-align: center;
|
||||||
|
background: #111116; border: 1px solid var(--line); border-radius: 16px; padding: 24px;
|
||||||
|
}
|
||||||
|
.pv-pair-title { font-family: var(--display); font-size: 26px; font-weight: 700; }
|
||||||
|
.pv-pair p { margin: 0; font-size: 13px; color: var(--text-2); line-height: 1.5; }
|
||||||
|
.pv-pair input {
|
||||||
|
font-family: var(--mono); font-size: 32px; letter-spacing: 0.2em; text-align: center;
|
||||||
|
background: #000; color: var(--text); border: 1px solid var(--line); border-radius: 12px; padding: 12px;
|
||||||
|
}
|
||||||
|
.pv-blank { position: absolute; inset: 0; background: #000; z-index: 5; }
|
||||||
|
.rh-present { display: flex; gap: 14px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
|
||||||
|
.rh-qr-sm { width: 96px; height: 96px; }
|
||||||
|
.rh-present code { font-family: var(--mono); font-size: 11px; word-break: break-all; }
|
||||||
|
|
||||||
|
/* Timestamp sharing (Share in app.js) */
|
||||||
|
.share-moment { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.sm-row { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.sm-time { width: 90px; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 7px 9px; font-family: var(--mono); font-size: 14px; }
|
||||||
|
.sm-links { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sm-link { display: grid; grid-template-columns: 64px 1fr auto; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.sm-link code { font-family: var(--mono); font-size: 11px; color: var(--text); background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: all; }
|
||||||
|
.sm-gif { border-top: 1px solid var(--line-soft); padding-top: 12px; }
|
||||||
|
.sm-gif-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.sm-gif-controls select { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; }
|
||||||
|
.sm-gif-out { margin-top: 10px; }
|
||||||
|
.sm-preview { display: block; max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }
|
||||||
|
.sm-gif-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
.sm-busy, .sm-err { font-size: 13px; color: var(--text-2); }
|
||||||
|
.sm-err { color: var(--accent-bright); }
|
||||||
|
|
||||||
|
/* Sleep timer panel, curtain, equalizer */
|
||||||
|
.sleep-panel, .eq-panel { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.sp-label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim); margin-top: 6px; }
|
||||||
|
.sp-chips, .eq-presets { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.sp-chip {
|
||||||
|
background: var(--bg-1); color: var(--text-2); border: 1px solid var(--line); border-radius: 999px;
|
||||||
|
padding: 7px 12px; font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sp-chip:hover { color: var(--text); border-color: var(--text-dim); }
|
||||||
|
.sp-chip.on { color: var(--text); border-color: var(--accent); background: var(--bg-3); }
|
||||||
|
.sp-note { margin: 6px 0 0; font-size: 11px; line-height: 1.5; color: var(--text-dim); }
|
||||||
|
.sleep-curtain {
|
||||||
|
position: fixed; inset: 0; z-index: 500; background: #000; cursor: pointer;
|
||||||
|
display: flex; align-items: flex-end; justify-content: center; padding-bottom: 12vh;
|
||||||
|
}
|
||||||
|
.sleep-curtain span { color: #666; font-size: 14px; transition: opacity 2s; }
|
||||||
|
.sleep-curtain.dim span { opacity: 0; }
|
||||||
|
.eq-bands { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-top: 8px; }
|
||||||
|
.eq-band { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
|
||||||
|
.eq-band input[type=range] { writing-mode: vertical-lr; direction: rtl; width: 28px; height: 150px; accent-color: var(--accent); }
|
||||||
|
.eq-val { font-family: var(--mono); font-size: 11px; color: var(--text); min-height: 14px; }
|
||||||
|
.eq-f { font-family: var(--mono); }
|
||||||
|
|
||||||
|
/* Touch gestures (Gestures in app.js) */
|
||||||
|
.player-stage { touch-action: pan-y; } /* page still scrolls; we get double-tap + pinch */
|
||||||
|
:fullscreen .player-stage, .player-stage:fullscreen,
|
||||||
|
body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or the iOS landscape one): swipes too */
|
||||||
|
#video { transform-origin: center center; transition: transform 0.05s linear; }
|
||||||
|
.gesture-hud {
|
||||||
|
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 6;
|
||||||
|
min-width: 140px; padding: 12px 16px; border-radius: 14px; text-align: center;
|
||||||
|
background: rgba(0, 0, 0, 0.62); color: #fff; pointer-events: none; font-family: var(--ui);
|
||||||
|
}
|
||||||
|
.gh-label { font-size: 14px; font-weight: 600; }
|
||||||
|
.gh-bar { margin-top: 8px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.25); overflow: hidden; }
|
||||||
|
.gh-bar span { display: block; height: 100%; background: #fff; }
|
||||||
|
.gh-seek { font-size: 22px; font-weight: 700; }
|
||||||
|
|
||||||
|
/* Notes: long personal notes + the 📝 Notes view */
|
||||||
|
.marker-form textarea {
|
||||||
|
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||||
|
padding: 9px 10px; font: 14px/1.5 var(--ui); resize: vertical;
|
||||||
|
}
|
||||||
|
.mk-note.long { white-space: pre-wrap; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
|
||||||
|
.notes-view { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
|
||||||
|
.nv-filter { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 11px; font-size: 14px; }
|
||||||
|
.nv-group { background: linear-gradient(180deg, var(--bg-2), var(--bg-1)); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; }
|
||||||
|
.nv-head { display: flex; flex-direction: column; margin-bottom: 6px; }
|
||||||
|
.nv-head b { font-size: 14px; color: var(--text); }
|
||||||
|
.nv-head small { font-size: 11px; color: var(--text-dim); }
|
||||||
|
.nv-item { display: flex; gap: 10px; width: 100%; text-align: left; background: transparent; border: none; border-top: 1px solid var(--line-soft); padding: 8px 2px; cursor: pointer; color: var(--text-2); }
|
||||||
|
.nv-item:hover .nv-title { color: var(--accent-bright); }
|
||||||
|
.nv-time { flex: none; width: 48px; font-family: var(--mono); font-size: 12px; color: var(--accent-bright); }
|
||||||
|
.nv-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||||
|
.nv-title { font-size: 13px; color: var(--text); }
|
||||||
|
.nv-note { font-size: 12px; white-space: pre-wrap; color: var(--text-2); }
|
||||||
|
|
||||||
|
/* Transcript search tab */
|
||||||
|
.ts-bar { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.ts-query { flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; }
|
||||||
|
.ts-source { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 6px 8px; font-size: 12px; }
|
||||||
|
.ts-info { font-size: 11px; color: var(--text-dim); margin-bottom: 6px; }
|
||||||
|
.ts-results { max-height: min(46vh, 420px); overflow-y: auto; overscroll-behavior: contain; }
|
||||||
|
.ts-hit { display: flex; gap: 10px; width: 100%; text-align: left; background: transparent; border: none; border-radius: 8px; padding: 6px; cursor: pointer; color: var(--text-2); font-size: 14px; line-height: 1.4; }
|
||||||
|
.ts-hit:hover { background: var(--bg-3); color: var(--text); }
|
||||||
|
.ts-time { flex: none; width: 42px; font-family: var(--mono); font-size: 11px; color: var(--accent-bright); padding-top: 2px; }
|
||||||
|
.ts-hit mark { background: color-mix(in srgb, var(--accent) 35%, transparent); color: var(--text); border-radius: 3px; padding: 0 1px; }
|
||||||
|
.sm-bite input { width: 70px; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 5px 7px; font-family: var(--mono); font-size: 12px; }
|
||||||
|
.sm-bite-out { margin-top: 10px; }
|
||||||
|
.sm-audio { width: 100%; }
|
||||||
|
.ext-panel { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ext-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.ext-buttons a.np-btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||||
|
|
||||||
|
/* Watch party panel (Party in app.js) */
|
||||||
|
.party-panel {
|
||||||
|
position: fixed; right: 16px; top: 76px; bottom: 96px; width: 340px; z-index: 160;
|
||||||
|
display: flex; flex-direction: column; gap: 10px; padding: 12px 14px;
|
||||||
|
background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.party-panel.min { bottom: auto; }
|
||||||
|
.party-panel.min .pt-body { display: none; }
|
||||||
|
.pt-head { display: flex; align-items: center; gap: 8px; font-weight: 700; }
|
||||||
|
.pt-head .rv-icon { width: 30px; height: 30px; font-size: 12px; margin-left: auto; }
|
||||||
|
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
|
||||||
|
.pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
|
||||||
|
.pt-row input, .pt-msg, .pt-name {
|
||||||
|
flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.pt-code { font-family: var(--mono); letter-spacing: 0.2em; text-transform: uppercase; }
|
||||||
|
.pt-or { text-align: center; font-size: 11px; color: var(--text-dim); }
|
||||||
|
.pt-share { display: flex; gap: 6px; align-items: center; }
|
||||||
|
.pt-share code { flex: 1; min-width: 0; font-family: var(--mono); font-size: 11px; background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-now { font-size: 12px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.pt-members { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
|
.pt-member { font-size: 12px; padding: 3px 9px; border-radius: 999px; background: var(--bg-3); color: var(--text-2); }
|
||||||
|
.pt-member.me { color: var(--text); border: 1px solid var(--accent); }
|
||||||
|
.pt-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); }
|
||||||
|
.pt-voice { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.pt-voice .np-btn.on { border-color: #7ee0a8; }
|
||||||
|
.pt-voice-n { font-size: 11px; color: var(--text-dim); }
|
||||||
|
.pt-chat { flex: 1; min-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 6px; background: var(--bg-1); border: 1px solid var(--line-soft); border-radius: var(--radius-sm); }
|
||||||
|
.pt-line { font-size: 13px; color: var(--text-2); line-height: 1.4; overflow-wrap: anywhere; }
|
||||||
|
.pt-line b { color: var(--text); }
|
||||||
|
.pt-line.me b { color: var(--accent-bright); }
|
||||||
|
.pt-sys { font-size: 11px; color: var(--text-dim); text-align: center; }
|
||||||
|
.pt-send { display: flex; gap: 6px; }
|
||||||
|
.pt-leave { background: transparent; border: none; color: var(--text-dim); font-size: 12px; cursor: pointer; text-decoration: underline; align-self: center; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.party-panel { left: 8px; right: 8px; width: auto; top: auto; bottom: 8px; height: 62vh; }
|
||||||
|
.party-panel.min { height: auto; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const SHELL = [
|
|||||||
'/opfs-worker.js',
|
'/opfs-worker.js',
|
||||||
'/video-edit.js',
|
'/video-edit.js',
|
||||||
'/lyrics-core.js',
|
'/lyrics-core.js',
|
||||||
|
'/stats-core.js',
|
||||||
'/app.js',
|
'/app.js',
|
||||||
'/manifest.webmanifest',
|
'/manifest.webmanifest',
|
||||||
'/icons/icon-192.png',
|
'/icons/icon-192.png',
|
||||||
|
|||||||
1
scripts/lyrics/.dockerignore
Normal file
1
scripts/lyrics/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__pycache__/
|
||||||
15
scripts/lyrics/Dockerfile
Normal file
15
scripts/lyrics/Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Lyrics worker — transcribes saved songs that have no lyrics yet, one at a
|
||||||
|
# time, with faster-whisper on CPU (no API keys). Talks to the ytplayer
|
||||||
|
# service over the compose network only; see scripts/lyrics/auto_lyrics.py.
|
||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
HF_HOME=/models \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
COPY auto_lyrics.py .
|
||||||
|
# Model weights (downloaded on first use) and the worker's memory of
|
||||||
|
# instrumentals/failures live on volumes, so rebuilds don't re-download.
|
||||||
|
VOLUME ["/models", "/data"]
|
||||||
|
CMD ["sh", "-c", "exec python auto_lyrics.py --missing --watch ${WATCH_SECONDS:-300} --state /data/state.json --model ${WHISPER_MODEL:-large-v3-turbo} --threads ${WHISPER_THREADS:-2}"]
|
||||||
@@ -121,9 +121,83 @@ def main():
|
|||||||
ap.add_argument('--threads', type=int, default=os.cpu_count() or 4)
|
ap.add_argument('--threads', type=int, default=os.cpu_count() or 4)
|
||||||
ap.add_argument('--dry-run', action='store_true', help='transcribe and print, do not upload')
|
ap.add_argument('--dry-run', action='store_true', help='transcribe and print, do not upload')
|
||||||
ap.add_argument('--min-words', type=int, default=25, help='fewer words = treat as instrumental')
|
ap.add_argument('--min-words', type=int, default=25, help='fewer words = treat as instrumental')
|
||||||
|
ap.add_argument('--watch', type=int, default=0, metavar='SECONDS',
|
||||||
|
help='keep running: re-check for songs without lyrics every SECONDS (worker mode)')
|
||||||
|
ap.add_argument('--state', default='', help='JSON file remembering instrumentals/failures (worker mode)')
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
if args.watch:
|
||||||
|
return watch(args)
|
||||||
|
|
||||||
api = Api(args.base, os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD'))
|
run_once(args, Api(args.base, os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD')))
|
||||||
|
|
||||||
|
|
||||||
|
def load_state(path):
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_state(path, state):
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
tmp = path + '.tmp'
|
||||||
|
with open(tmp, 'w') as f:
|
||||||
|
json.dump(state, f)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def watch(args):
|
||||||
|
"""Worker mode: poll for saved songs without lyrics and transcribe them one
|
||||||
|
at a time, forever. Runs at low CPU priority; the state file remembers
|
||||||
|
instrumentals (never retried) and failures (retried with backoff)."""
|
||||||
|
try:
|
||||||
|
os.nice(10)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
token, password = os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD')
|
||||||
|
if not (token or password):
|
||||||
|
print('lyrics worker: no YTP_TOKEN set (LYRICS_WORKER_TOKEN in the server env) — idle', flush=True)
|
||||||
|
while True:
|
||||||
|
time.sleep(3600)
|
||||||
|
args.missing, args.ids, args.overwrite, args.dry_run = True, None, False, False
|
||||||
|
model = None
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
api = Api(args.base, token, password)
|
||||||
|
st, r = api.call('GET', '/api/admin/media')
|
||||||
|
if st != 200:
|
||||||
|
raise RuntimeError(f'listing failed ({st}): {r.get("error")}')
|
||||||
|
state = load_state(args.state)
|
||||||
|
now = time.time()
|
||||||
|
todo = [m for m in r['media'] if not m['lyricsLines']
|
||||||
|
and state.get(m['id'], {}).get('status') != 'instrumental'
|
||||||
|
and state.get(m['id'], {}).get('retry_at', 0) <= now]
|
||||||
|
if todo:
|
||||||
|
if model is None:
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
print(f'lyrics worker: loading {args.model}', flush=True)
|
||||||
|
model = WhisperModel(args.model, device='cpu', compute_type='int8', cpu_threads=args.threads)
|
||||||
|
m = todo[0] # one song per cycle keeps the worker's footprint small
|
||||||
|
result = transcribe_one(args, api, model, m['id'])
|
||||||
|
entry = state.get(m['id'], {})
|
||||||
|
if result.startswith('instrumental'):
|
||||||
|
state[m['id']] = {'status': 'instrumental', 'at': now}
|
||||||
|
elif result.startswith('saved') or result.startswith('skip'):
|
||||||
|
state.pop(m['id'], None)
|
||||||
|
else:
|
||||||
|
fails = entry.get('fails', 0) + 1
|
||||||
|
state[m['id']] = {'status': 'failed', 'fails': fails, 'retry_at': now + min(86400, 900 * 2 ** fails), 'error': result[:200]}
|
||||||
|
save_state(args.state, state)
|
||||||
|
print(f'lyrics worker: {m["id"]} ({m.get("title", "")[:60]}): {result}', flush=True)
|
||||||
|
continue # straight on to the next song
|
||||||
|
except Exception as e: # never die: the next cycle retries
|
||||||
|
print(f'lyrics worker: {e}', flush=True)
|
||||||
|
time.sleep(args.watch)
|
||||||
|
|
||||||
|
|
||||||
|
def run_once(args, api):
|
||||||
if args.missing:
|
if args.missing:
|
||||||
st, r = api.call('GET', '/api/admin/media')
|
st, r = api.call('GET', '/api/admin/media')
|
||||||
if st != 200:
|
if st != 200:
|
||||||
@@ -137,17 +211,19 @@ def main():
|
|||||||
|
|
||||||
from faster_whisper import WhisperModel # imported late: listing works without it
|
from faster_whisper import WhisperModel # imported late: listing works without it
|
||||||
model = WhisperModel(args.model, device='cpu', compute_type='int8', cpu_threads=args.threads)
|
model = WhisperModel(args.model, device='cpu', compute_type='int8', cpu_threads=args.threads)
|
||||||
summary = []
|
summary = [(vid, transcribe_one(args, api, model, vid)) for vid in todo]
|
||||||
for vid in todo:
|
print('\n'.join(f'{v} {s}' for v, s in summary))
|
||||||
|
|
||||||
|
|
||||||
|
def transcribe_one(args, api, model, vid):
|
||||||
|
"""Transcribe one saved song and upload it. Returns a one-line result."""
|
||||||
st, cur = api.call('GET', f'/api/notes/{vid}')
|
st, cur = api.call('GET', f'/api/notes/{vid}')
|
||||||
live = (cur or {}).get('lyrics') if st == 200 else None
|
live = (cur or {}).get('lyrics') if st == 200 else None
|
||||||
if live and live['data']['lines'] and not args.overwrite:
|
if live and live['data']['lines'] and not args.overwrite:
|
||||||
summary.append((vid, 'skip: has lyrics'))
|
return 'skip: has lyrics'
|
||||||
continue
|
|
||||||
st, audio = api.call('GET', f'/api/media/{vid}?a=1', raw=True)
|
st, audio = api.call('GET', f'/api/media/{vid}?a=1', raw=True)
|
||||||
if st != 200:
|
if st != 200:
|
||||||
summary.append((vid, f'no cached audio ({st})'))
|
return f'no cached audio ({st})'
|
||||||
continue
|
|
||||||
with tempfile.NamedTemporaryFile(suffix='.m4a') as f:
|
with tempfile.NamedTemporaryFile(suffix='.m4a') as f:
|
||||||
f.write(audio)
|
f.write(audio)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -160,20 +236,17 @@ def main():
|
|||||||
for s in segs for w in (s.words or []) if w.word.strip()]
|
for s in segs for w in (s.words or []) if w.word.strip()]
|
||||||
took = time.time() - t0
|
took = time.time() - t0
|
||||||
if len(words) < args.min_words:
|
if len(words) < args.min_words:
|
||||||
summary.append((vid, f'instrumental? only {len(words)} words — skipped'))
|
return f'instrumental? only {len(words)} words — skipped'
|
||||||
continue
|
|
||||||
lines = segment(words)
|
lines = segment(words)
|
||||||
doc = {'lines': lines, 'tags': ['auto-transcribed (whisper)'], 'offset': 0}
|
doc = {'lines': lines, 'tags': ['auto-transcribed (whisper)'], 'offset': 0}
|
||||||
head = ' / '.join(l['text'] for l in lines[:3])
|
head = ' / '.join(l['text'] for l in lines[:3])
|
||||||
print(f'{vid}: {len(lines)} lines, lang={info.language}, {took:.0f}s for {info.duration:.0f}s audio | {head[:100]}', flush=True)
|
print(f'{vid}: {len(lines)} lines, lang={info.language}, {took:.0f}s for {info.duration:.0f}s audio | {head[:100]}', flush=True)
|
||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
print(json.dumps(doc, ensure_ascii=False)[:2000])
|
print(json.dumps(doc, ensure_ascii=False)[:2000])
|
||||||
summary.append((vid, f'dry-run {len(lines)} lines'))
|
return f'dry-run {len(lines)} lines'
|
||||||
continue
|
|
||||||
body = {'data': doc, 'baseRev': live['rev'] if live else 0}
|
body = {'data': doc, 'baseRev': live['rev'] if live else 0}
|
||||||
st, r = api.call('PUT', f'/api/notes/{vid}/lyrics', body)
|
st, r = api.call('PUT', f'/api/notes/{vid}/lyrics', body)
|
||||||
summary.append((vid, f'saved rev {r.get("rev")}' if st == 200 else f'upload failed {st}: {r.get("error")}'))
|
return f'saved rev {r.get("rev")}' if st == 200 else f'upload failed {st}: {r.get("error")}'
|
||||||
print('\n'.join(f'{v} {s}' for v, s in summary))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now()
|
|||||||
|
|
||||||
export function registerNoteRoutes(app, deps) {
|
export function registerNoteRoutes(app, deps) {
|
||||||
const {
|
const {
|
||||||
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath,
|
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
|
||||||
} = deps;
|
} = deps;
|
||||||
const ADMIN_COOKIE = 'ytp_admin';
|
const ADMIN_COOKIE = 'ytp_admin';
|
||||||
const ADMIN_TTL = 30 * 24 * 3600;
|
const ADMIN_TTL = 30 * 24 * 3600;
|
||||||
@@ -226,6 +226,8 @@ export function registerNoteRoutes(app, deps) {
|
|||||||
async function resolveWriter(c, body) {
|
async function resolveWriter(c, body) {
|
||||||
const m = (c.req.header('authorization') || '').match(/^Bearer\s+(\S+)$/i);
|
const m = (c.req.header('authorization') || '').match(/^Bearer\s+(\S+)$/i);
|
||||||
if (m) {
|
if (m) {
|
||||||
|
// The lyrics-worker container's shared token (env, never stored in the DB).
|
||||||
|
if (workerToken && workerToken.length >= 24 && safeEqual(m[1], workerToken)) return { via: 'api', by: 'api:lyrics-worker' };
|
||||||
const tok = await db.useApiToken(hashToken(m[1]));
|
const tok = await db.useApiToken(hashToken(m[1]));
|
||||||
return tok ? { via: 'api', by: `api:${tok.label}` } : { invalid: true };
|
return tok ? { via: 'api', by: `api:${tok.label}` } : { invalid: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ beforeAll(async () => {
|
|||||||
adminPassword: PASSWORD,
|
adminPassword: PASSWORD,
|
||||||
backupDir: join(root, 'backups'),
|
backupDir: join(root, 'backups'),
|
||||||
adminHtmlPath: htmlPath,
|
adminHtmlPath: htmlPath,
|
||||||
|
workerToken: 'w'.repeat(32),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,6 +196,13 @@ describe('routes', () => {
|
|||||||
expect((await app.request('/api/admin/media')).status).toBe(401);
|
expect((await app.request('/api/admin/media')).status).toBe(401);
|
||||||
expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200);
|
expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200);
|
||||||
|
|
||||||
|
// The lyrics-worker's env token works without a DB row.
|
||||||
|
const wk = { Authorization: `Bearer ${'w'.repeat(32)}` };
|
||||||
|
expect((await app.request('/api/admin/media', { headers: wk })).status).toBe(200);
|
||||||
|
res = await app.request(`/api/notes/${VID}/chapters`, json('PUT', { data: { items: [{ t: 5, title: 'W' }] }, baseRev: 0, force: true }, wk));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect((await (await app.request(`/api/notes/${VID}`)).json()).chapters.updatedBy).toBe('api:lyrics-worker');
|
||||||
|
|
||||||
// Revoked tokens stop working.
|
// Revoked tokens stop working.
|
||||||
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
|
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
|
||||||
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));
|
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "bun server.js",
|
"start": "bun server.js",
|
||||||
"dev": "bun --hot server.js",
|
"dev": "bun --hot server.js",
|
||||||
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js"
|
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./remote.test.js && bun test ./party.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hono/node-server": "^1.14.0",
|
"@hono/node-server": "^1.14.0",
|
||||||
|
|||||||
170
server/party.js
Normal file
170
server/party.js
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* party.js — watch party relay: synced playback, text chat, voice signalling
|
||||||
|
*
|
||||||
|
* One host drives playback; guests follow. The server only relays:
|
||||||
|
* /ws/party?role=host[&code=…&secret=…]&pid=…&name=… create or resume a party
|
||||||
|
* /ws/party?role=guest&code=…&pid=…&name=… join one
|
||||||
|
*
|
||||||
|
* Messages (JSON):
|
||||||
|
* host → all {type:'state', state} (server stamps state.ts = server ms)
|
||||||
|
* host → all {type:'settings', allowControl}
|
||||||
|
* any → all {type:'chat', text} (server adds from/pid/at; ≤ 500 chars)
|
||||||
|
* any → all {type:'voice', on} (voice-chat presence)
|
||||||
|
* any → one {type:'rtc', to, data} (WebRTC offer/answer/ICE, relayed)
|
||||||
|
* guest → host {type:'cmd', cmd, …} (only when the host allows control)
|
||||||
|
* server → you {type:'hello', code, secret?, you, now, members, state, chat, allowControl}
|
||||||
|
* server → all {type:'members', members} · {type:'ended'}
|
||||||
|
* A party survives its host dropping for PARTY_GRACE_MS (a reload, a flaky
|
||||||
|
* link); the host resumes it with the secret it was given.
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
|
||||||
|
const PARTY_GRACE_MS = 5 * 60_000;
|
||||||
|
const CHAT_KEEP = 60;
|
||||||
|
const MAX_MSG = 64 * 1024;
|
||||||
|
export const PARTY_COMMANDS = new Set(['toggle', 'play', 'pause', 'seek', 'next', 'prev']);
|
||||||
|
|
||||||
|
const clean = (v, max, fallback = '') =>
|
||||||
|
String(v || '').replace(/[\u0000-\u001f\u007f<>]+/g, ' ').trim().slice(0, max) || fallback;
|
||||||
|
const same = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
||||||
|
|
||||||
|
export function createPartyHub({ now = () => Date.now() } = {}) {
|
||||||
|
const parties = new Map(); // code → party
|
||||||
|
const joinFails = new Map();
|
||||||
|
|
||||||
|
const send = (ws, m) => { try { ws.send(JSON.stringify(m)); } catch { /* gone */ } };
|
||||||
|
const members = (p) => [...p.members.values()].map((m) => ({ pid: m.pid, name: m.name, host: m.pid === p.hostPid, voice: !!m.voice }));
|
||||||
|
const broadcast = (p, m, except) => { for (const x of p.members.values()) if (x.ws !== except) send(x.ws, m); };
|
||||||
|
|
||||||
|
function newCode() {
|
||||||
|
let c;
|
||||||
|
do { c = Array.from({ length: 6 }, () => ALPHABET[randomInt(ALPHABET.length)]).join(''); } while (parties.has(c));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sweeper = setInterval(() => {
|
||||||
|
const t = now();
|
||||||
|
for (const [code, p] of parties) {
|
||||||
|
if (!p.hostOnline && t - p.hostLeftAt > PARTY_GRACE_MS) {
|
||||||
|
broadcast(p, { type: 'ended', reason: 'The host left' });
|
||||||
|
for (const m of p.members.values()) { try { m.ws.close(4010, 'party ended'); } catch { /* gone */ } }
|
||||||
|
parties.delete(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000);
|
||||||
|
sweeper.unref?.();
|
||||||
|
|
||||||
|
function upgrade(req, server, ip) {
|
||||||
|
const u = new URL(req.url);
|
||||||
|
const q = (k) => u.searchParams.get(k) || '';
|
||||||
|
const role = q('role');
|
||||||
|
const pid = q('pid');
|
||||||
|
if (!/^[A-Za-z0-9_-]{8,40}$/.test(pid)) return new Response('bad pid', { status: 400 });
|
||||||
|
const name = clean(q('name'), 40, 'Guest');
|
||||||
|
const code = q('code').toUpperCase();
|
||||||
|
if (role === 'guest') {
|
||||||
|
const t = now();
|
||||||
|
const fails = (joinFails.get(ip) || []).filter((x) => t - x < 10 * 60_000);
|
||||||
|
if (fails.length >= 20) return new Response('too many attempts', { status: 429 });
|
||||||
|
if (!parties.has(code)) {
|
||||||
|
fails.push(t); joinFails.set(ip, fails);
|
||||||
|
if (joinFails.size > 5000) joinFails.clear();
|
||||||
|
return new Response('no such party', { status: 404 });
|
||||||
|
}
|
||||||
|
} else if (role !== 'host') {
|
||||||
|
return new Response('unknown role', { status: 400 });
|
||||||
|
}
|
||||||
|
const ok = server.upgrade(req, { data: { hub: 'party', role, pid, name, code, secret: q('secret') } });
|
||||||
|
return ok ? undefined : new Response('upgrade failed', { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(ws) {
|
||||||
|
const d = ws.data;
|
||||||
|
let p;
|
||||||
|
if (d.role === 'host') {
|
||||||
|
p = d.code && parties.get(d.code);
|
||||||
|
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
|
||||||
|
if (!p) {
|
||||||
|
const code = newCode();
|
||||||
|
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], allowControl: false, hostOnline: true, hostLeftAt: 0 };
|
||||||
|
parties.set(code, p);
|
||||||
|
}
|
||||||
|
p.hostPid = d.pid;
|
||||||
|
p.hostOnline = true;
|
||||||
|
d.code = p.code;
|
||||||
|
} else {
|
||||||
|
p = parties.get(d.code);
|
||||||
|
if (!p) { ws.close(4004, 'no such party'); return; }
|
||||||
|
}
|
||||||
|
const prev = p.members.get(d.pid);
|
||||||
|
if (prev && prev.ws !== ws) { try { prev.ws.close(4000, 'replaced'); } catch { /* gone */ } }
|
||||||
|
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 });
|
||||||
|
send(ws, {
|
||||||
|
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid,
|
||||||
|
now: now(), members: members(p), state: p.state, chat: p.chat, allowControl: p.allowControl,
|
||||||
|
});
|
||||||
|
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
function message(ws, raw) {
|
||||||
|
if (typeof raw !== 'string' || raw.length > MAX_MSG) return;
|
||||||
|
let m;
|
||||||
|
try { m = JSON.parse(raw); } catch { return; }
|
||||||
|
const d = ws.data;
|
||||||
|
const p = parties.get(d.code);
|
||||||
|
const me = p && p.members.get(d.pid);
|
||||||
|
if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
|
||||||
|
const isHost = d.pid === p.hostPid;
|
||||||
|
if (m.type === 'state' && isHost) {
|
||||||
|
p.state = { ...(m.state || {}), ts: now() };
|
||||||
|
broadcast(p, { type: 'state', state: p.state }, ws);
|
||||||
|
} else if (m.type === 'settings' && isHost) {
|
||||||
|
p.allowControl = !!m.allowControl;
|
||||||
|
broadcast(p, { type: 'settings', allowControl: p.allowControl });
|
||||||
|
} else if (m.type === 'chat') {
|
||||||
|
const t = now();
|
||||||
|
if (t - me.lastChat < 400) return; // flood guard
|
||||||
|
me.lastChat = t;
|
||||||
|
const text = clean(m.text, 500);
|
||||||
|
if (!text) return;
|
||||||
|
const msg = { from: me.name, pid: me.pid, text, at: t };
|
||||||
|
p.chat.push(msg);
|
||||||
|
if (p.chat.length > CHAT_KEEP) p.chat.shift();
|
||||||
|
broadcast(p, { type: 'chat', msg });
|
||||||
|
} else if (m.type === 'voice') {
|
||||||
|
me.voice = !!m.on;
|
||||||
|
broadcast(p, { type: 'members', members: members(p) });
|
||||||
|
} else if (m.type === 'rtc' && typeof m.to === 'string') {
|
||||||
|
const target = p.members.get(m.to);
|
||||||
|
if (target) send(target.ws, { type: 'rtc', from: me.pid, data: m.data });
|
||||||
|
} else if (m.type === 'cmd' && !isHost && p.allowControl && PARTY_COMMANDS.has(m.cmd)) {
|
||||||
|
const host = p.members.get(p.hostPid);
|
||||||
|
const { type, ...args } = m;
|
||||||
|
if (host) send(host.ws, { type: 'cmd', from: me.name, ...args });
|
||||||
|
} else if (m.type === 'end' && isHost) {
|
||||||
|
broadcast(p, { type: 'ended', reason: 'The host ended the party' });
|
||||||
|
for (const x of p.members.values()) { try { x.ws.close(4010, 'party ended'); } catch { /* gone */ } }
|
||||||
|
parties.delete(p.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close(ws) {
|
||||||
|
const d = ws.data;
|
||||||
|
const p = parties.get(d.code);
|
||||||
|
if (!p) return;
|
||||||
|
const me = p.members.get(d.pid);
|
||||||
|
if (!me || me.ws !== ws) return;
|
||||||
|
p.members.delete(d.pid);
|
||||||
|
if (d.pid === p.hostPid) { p.hostOnline = false; p.hostLeftAt = now(); }
|
||||||
|
broadcast(p, { type: 'members', members: members(p), left: d.name, hostAway: !p.hostOnline });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
upgrade,
|
||||||
|
websocket: { maxPayloadLength: MAX_MSG, idleTimeout: 120, open, message: (ws, msg) => message(ws, typeof msg === 'string' ? msg : Buffer.from(msg).toString('utf8')), close },
|
||||||
|
_parties: parties,
|
||||||
|
stop() { clearInterval(sweeper); },
|
||||||
|
};
|
||||||
|
}
|
||||||
117
server/party.test.js
Normal file
117
server/party.test.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Watch-party relay: real Bun server + real WebSocket clients.
|
||||||
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
|
import { createPartyHub } from './party.js';
|
||||||
|
|
||||||
|
let hub, server, base;
|
||||||
|
beforeAll(() => {
|
||||||
|
hub = createPartyHub();
|
||||||
|
server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch(req, srv) {
|
||||||
|
if (new URL(req.url).pathname === '/ws/party') return hub.upgrade(req, srv, '203.0.113.9');
|
||||||
|
return new Response('nope', { status: 404 });
|
||||||
|
},
|
||||||
|
websocket: hub.websocket,
|
||||||
|
});
|
||||||
|
base = `localhost:${server.port}`;
|
||||||
|
});
|
||||||
|
afterAll(() => { server.stop(true); hub.stop(); });
|
||||||
|
|
||||||
|
function client(query) {
|
||||||
|
const ws = new WebSocket(`ws://${base}/ws/party?${new URLSearchParams(query)}`);
|
||||||
|
const inbox = [], waiters = [];
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const m = JSON.parse(e.data);
|
||||||
|
const i = waiters.findIndex((w) => w.type === m.type);
|
||||||
|
if (i >= 0) waiters.splice(i, 1)[0].resolve(m); else inbox.push(m);
|
||||||
|
};
|
||||||
|
const closed = new Promise((r) => { ws.onclose = (e) => r(e.code); });
|
||||||
|
const errored = new Promise((r) => { ws.onerror = () => r(true); });
|
||||||
|
return {
|
||||||
|
ws, closed, errored,
|
||||||
|
send: (m) => ws.send(JSON.stringify(m)),
|
||||||
|
next(type, ms = 2000) {
|
||||||
|
const i = inbox.findIndex((m) => m.type === type);
|
||||||
|
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const w = { type, resolve };
|
||||||
|
waiters.push(w);
|
||||||
|
setTimeout(() => { const k = waiters.indexOf(w); if (k >= 0) { waiters.splice(k, 1); reject(new Error('timeout ' + type)); } }, ms);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
drain(type) { for (let i = inbox.length - 1; i >= 0; i--) if (inbox[i].type === type) inbox.splice(i, 1); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('watch party', () => {
|
||||||
|
let host, guest, code, secret;
|
||||||
|
|
||||||
|
test('host creates a party with a 6-char code', async () => {
|
||||||
|
host = client({ role: 'host', pid: 'hostpid0001', name: 'Josh' });
|
||||||
|
const hello = await host.next('hello');
|
||||||
|
expect(hello.code).toMatch(/^[A-HJ-NP-Z2-9]{6}$/);
|
||||||
|
expect(hello.secret.length).toBeGreaterThan(10);
|
||||||
|
code = hello.code; secret = hello.secret;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown code is refused; guest joins and gets the state + chat history', async () => {
|
||||||
|
const bad = client({ role: 'guest', pid: 'guestpid9999', name: 'X', code: 'ZZZZZZ' });
|
||||||
|
expect(await Promise.race([bad.errored, bad.closed.then(() => true)])).toBe(true);
|
||||||
|
host.send({ type: 'state', state: { v: { id: 'rXFZsRhdT78', title: 'As the Deer' }, cur: 12, paused: false, rate: 1 } });
|
||||||
|
host.send({ type: 'chat', text: 'welcome!' });
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
guest = client({ role: 'guest', pid: 'guestpid0001', name: 'Rex', code: code.toLowerCase() });
|
||||||
|
const hello = await guest.next('hello');
|
||||||
|
expect(hello.state.v.id).toBe('rXFZsRhdT78');
|
||||||
|
expect(typeof hello.state.ts).toBe('number');
|
||||||
|
expect(hello.chat.map((c) => c.text)).toEqual(['welcome!']);
|
||||||
|
expect(hello.members.map((m) => m.name).sort()).toEqual(['Josh', 'Rex']);
|
||||||
|
expect(hello.secret).toBeUndefined();
|
||||||
|
const m = await host.next('members');
|
||||||
|
expect(m.joined).toBe('Rex');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state, chat (flood-guarded), voice presence and targeted rtc relay', async () => {
|
||||||
|
host.send({ type: 'state', state: { v: { id: 'x' }, cur: 30, paused: true } });
|
||||||
|
expect((await guest.next('state')).state.cur).toBe(30);
|
||||||
|
host.drain('chat'); // its own "welcome!" echo
|
||||||
|
guest.send({ type: 'chat', text: 'hi <b>all</b>' });
|
||||||
|
guest.send({ type: 'chat', text: 'spam' }); // < 400 ms later → dropped
|
||||||
|
const c = await host.next('chat');
|
||||||
|
expect(c.msg).toMatchObject({ from: 'Rex', text: 'hi b all /b' });
|
||||||
|
await expect(host.next('chat', 300)).rejects.toThrow();
|
||||||
|
guest.send({ type: 'voice', on: true });
|
||||||
|
const mem = await host.next('members');
|
||||||
|
expect(mem.members.find((x) => x.name === 'Rex').voice).toBe(true);
|
||||||
|
guest.send({ type: 'rtc', to: 'hostpid0001', data: { sdp: 'offer' } });
|
||||||
|
expect(await host.next('rtc')).toEqual({ type: 'rtc', from: 'guestpid0001', data: { sdp: 'offer' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guest commands reach the host only when control is allowed', async () => {
|
||||||
|
guest.send({ type: 'cmd', cmd: 'toggle' });
|
||||||
|
await expect(host.next('cmd', 300)).rejects.toThrow();
|
||||||
|
host.send({ type: 'settings', allowControl: true });
|
||||||
|
expect((await guest.next('settings')).allowControl).toBe(true);
|
||||||
|
guest.send({ type: 'cmd', cmd: 'seek', t: 50 });
|
||||||
|
guest.send({ type: 'cmd', cmd: 'rm' });
|
||||||
|
const cmd = await host.next('cmd');
|
||||||
|
expect(cmd).toMatchObject({ cmd: 'seek', t: 50, from: 'Rex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host drops and resumes with its secret; a stranger cannot take over', async () => {
|
||||||
|
guest.drain('members');
|
||||||
|
host.ws.close();
|
||||||
|
const away = await guest.next('members');
|
||||||
|
expect(away.hostAway).toBe(true);
|
||||||
|
const thief = client({ role: 'host', pid: 'thief000001', name: 'T', code, secret: 'wrong' });
|
||||||
|
expect(await thief.closed).toBe(4003);
|
||||||
|
host = client({ role: 'host', pid: 'hostpid0001', name: 'Josh', code, secret });
|
||||||
|
expect((await host.next('hello')).code).toBe(code);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('host ends the party', async () => {
|
||||||
|
host.send({ type: 'end' });
|
||||||
|
expect((await guest.next('ended')).reason).toContain('ended');
|
||||||
|
expect(await guest.closed).toBe(4010);
|
||||||
|
});
|
||||||
|
});
|
||||||
123
server/server.js
123
server/server.js
@@ -13,6 +13,8 @@
|
|||||||
* GET /api/download/:videoId server-cached copy (fetched by a background job) → binary
|
* GET /api/download/:videoId server-cached copy (fetched by a background job) → binary
|
||||||
* GET /api/media/:id?g=<gen>[&a=1] Range-aware server-cached mp4 (or m4a audio sidecar)
|
* GET /api/media/:id?g=<gen>[&a=1] Range-aware server-cached mp4 (or m4a audio sidecar)
|
||||||
* GET /api/media/:id/peaks loudness envelope (400 buckets, 0..100) for the waveform seek bar
|
* GET /api/media/:id/peaks loudness envelope (400 buckets, 0..100) for the waveform seek bar
|
||||||
|
* GET /api/media/:id/gif?t=&d=&w= short looping GIF of a moment (timestamp sharing)
|
||||||
|
* GET /api/media/:id/clip?start=&end=&fmt=mp3|m4r soundbite / iPhone ringtone
|
||||||
* GET /api/media/:id/status { status, size, height, optimized, error }
|
* GET /api/media/:id/status { status, size, height, optimized, error }
|
||||||
* POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again
|
* POST /api/media/:id/redownload "Broken" button: drop the copy, fetch it again
|
||||||
* GET /api/media/stats cache totals, budget, free disk, queue
|
* GET /api/media/stats cache totals, budget, free disk, queue
|
||||||
@@ -43,6 +45,7 @@ import { createMediaCache, HIGH, LOW } from './media-cache.js';
|
|||||||
import * as notesDb from './db.js';
|
import * as notesDb from './db.js';
|
||||||
import { registerNoteRoutes } from './notes.js';
|
import { registerNoteRoutes } from './notes.js';
|
||||||
import { createRemoteHub } from './remote.js';
|
import { createRemoteHub } from './remote.js';
|
||||||
|
import { createPartyHub } from './party.js';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { dirname, join as pathJoin } from 'node:path';
|
import { dirname, join as pathJoin } from 'node:path';
|
||||||
|
|
||||||
@@ -1120,6 +1123,103 @@ app.get('/api/media/:id/peaks', async (c) => {
|
|||||||
return c.json({ ok: true, ...hit }, 200, { 'Cache-Control': 'public, max-age=3600' });
|
return c.json({ ok: true, ...hit }, 200, { 'Cache-Control': 'public, max-age=3600' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/media/:id/gif?t=<sec>&d=<sec>&w=<px> — a short looping GIF of an
|
||||||
|
// exact moment, cut from the server-cached copy (timestamp sharing). Palette
|
||||||
|
// is generated per clip (palettegen/paletteuse) so it stays small and clean.
|
||||||
|
const gifCache = new Map();
|
||||||
|
let gifRunning = 0;
|
||||||
|
app.get('/api/media/:id/gif', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||||
|
const t = Math.max(0, Number(c.req.query('t')) || 0);
|
||||||
|
const d = Math.min(6, Math.max(1, Number(c.req.query('d')) || 3));
|
||||||
|
const w = Math.min(640, Math.max(240, Math.round((Number(c.req.query('w')) || 480) / 2) * 2));
|
||||||
|
const path = await media.filePath(id, null, 'mp4');
|
||||||
|
if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404);
|
||||||
|
const key = `${path}|${t.toFixed(1)}|${d}|${w}`;
|
||||||
|
let gif = gifCache.get(key);
|
||||||
|
if (!gif) {
|
||||||
|
if (gifRunning >= 2) return c.json({ ok: false, error: 'busy — try again in a moment' }, 503);
|
||||||
|
gifRunning++;
|
||||||
|
try {
|
||||||
|
gif = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(FFMPEG, ['-v', 'error', '-ss', t.toFixed(2), '-t', String(d), '-i', path, '-an',
|
||||||
|
'-vf', `fps=12,scale=${w}:-2:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4`,
|
||||||
|
'-loop', '0', '-f', 'gif', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
const chunks = [];
|
||||||
|
let err = '';
|
||||||
|
child.stdout.on('data', (b) => chunks.push(b));
|
||||||
|
child.stderr.on('data', (b) => { err = (err + b).slice(-1500); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
const buf = Buffer.concat(chunks);
|
||||||
|
if (code !== 0 || buf.length < 100) reject(new Error(err.trim() || 'could not make the GIF'));
|
||||||
|
else resolve(buf);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
} finally {
|
||||||
|
gifRunning--;
|
||||||
|
}
|
||||||
|
gifCache.set(key, gif);
|
||||||
|
if (gifCache.size > 40) gifCache.delete(gifCache.keys().next().value);
|
||||||
|
}
|
||||||
|
return c.body(gif, 200, {
|
||||||
|
'Content-Type': 'image/gif',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
'Content-Disposition': `inline; filename="${id}-${Math.floor(t)}s.gif"`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/media/:id/clip?start=&end=&fmt=mp3|m4r — a soundbite of the
|
||||||
|
// server-cached audio with short fades: MP3 (≤ 60 s) or an iPhone ringtone
|
||||||
|
// (.m4r = AAC in an iPod MP4 container, ≤ 40 s — Apple's ringtone limit).
|
||||||
|
app.get('/api/media/:id/clip', async (c) => {
|
||||||
|
const id = c.req.param('id');
|
||||||
|
if (!/^[A-Za-z0-9_-]{11}$/.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
|
||||||
|
const fmt = c.req.query('fmt') === 'm4r' ? 'm4r' : 'mp3';
|
||||||
|
const start = Math.max(0, Number(c.req.query('start')) || 0);
|
||||||
|
const maxLen = fmt === 'm4r' ? 40 : 60;
|
||||||
|
const len = Math.min(maxLen, Math.max(1, (Number(c.req.query('end')) || start + 20) - start));
|
||||||
|
const path = (await media.filePath(id, null, 'm4a')) || (await media.filePath(id, null, 'mp4'));
|
||||||
|
if (!path) return c.json({ ok: false, error: 'this video is not cached on the server yet — play it once, then try again' }, 404);
|
||||||
|
const fade = Math.min(0.5, len / 4);
|
||||||
|
const af = `afade=t=in:st=0:d=${fade},afade=t=out:st=${(len - fade).toFixed(2)}:d=${fade}`;
|
||||||
|
const args = ['-v', 'error', '-ss', start.toFixed(2), '-t', len.toFixed(2), '-i', path, '-vn', '-af', af];
|
||||||
|
// A ringtone must be a regular (non-fragmented) MP4 for iPhone imports,
|
||||||
|
// which needs a seekable output — so .m4r goes through a temp file.
|
||||||
|
const tmpOut = fmt === 'm4r' ? `${tmpdir()}/ytp-clip-${id}-${Date.now()}-${Math.random().toString(36).slice(2)}.m4r` : null;
|
||||||
|
if (fmt === 'mp3') args.push('-c:a', 'libmp3lame', '-b:a', '192k', '-f', 'mp3', 'pipe:1');
|
||||||
|
else args.push('-c:a', 'aac', '-b:a', '192k', '-movflags', '+faststart', '-f', 'ipod', '-y', tmpOut);
|
||||||
|
try {
|
||||||
|
const buf = await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(FFMPEG, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
const chunks = [];
|
||||||
|
let err = '';
|
||||||
|
child.stdout.on('data', (b) => chunks.push(b));
|
||||||
|
child.stderr.on('data', (b) => { err = (err + b).slice(-1500); });
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
let out = Buffer.concat(chunks);
|
||||||
|
if (tmpOut) {
|
||||||
|
try { out = readFileSync(tmpOut); } catch { out = Buffer.alloc(0); }
|
||||||
|
try { unlinkSync(tmpOut); } catch { /* gone */ }
|
||||||
|
}
|
||||||
|
if (code !== 0 || out.length < 200) reject(new Error(err.trim() || 'could not cut the soundbite'));
|
||||||
|
else resolve(out);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return c.body(buf, 200, {
|
||||||
|
'Content-Type': fmt === 'mp3' ? 'audio/mpeg' : 'audio/mp4',
|
||||||
|
'Content-Disposition': `attachment; filename="${id}-${Math.floor(start)}s.${fmt}"`,
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ ok: false, error: err.message }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/media/:id/status', async (c) => {
|
app.get('/api/media/:id/status', async (c) => {
|
||||||
try {
|
try {
|
||||||
return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' });
|
return c.json({ ok: true, ...(await media.status(c.req.param('id'))) }, 200, { 'Cache-Control': 'no-store' });
|
||||||
@@ -1617,6 +1717,18 @@ app.get('/api/user/data', async (c) => {
|
|||||||
// Phone remote for a desktop instance — see remote.js
|
// Phone remote for a desktop instance — see remote.js
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
const remote = createRemoteHub({ requireSameNetwork: process.env.REMOTE_SAME_NETWORK === '1' });
|
||||||
|
const party = createPartyHub();
|
||||||
|
|
||||||
|
// Bun allows ONE websocket handler per server: party sockets are tagged
|
||||||
|
// (ws.data.hub === 'party'), everything else belongs to the remote relay.
|
||||||
|
const pickHub = (ws) => (ws.data && ws.data.hub === 'party' ? party.websocket : remote.websocket);
|
||||||
|
const websocketHandler = {
|
||||||
|
maxPayloadLength: 256 * 1024,
|
||||||
|
idleTimeout: 120,
|
||||||
|
open: (ws) => pickHub(ws).open(ws),
|
||||||
|
message: (ws, msg) => pickHub(ws).message(ws, msg),
|
||||||
|
close: (ws, code, reason) => pickHub(ws).close(ws, code, reason),
|
||||||
|
};
|
||||||
|
|
||||||
// The public IP a request came from. Behind Traefik that is the first
|
// The public IP a request came from. Behind Traefik that is the first
|
||||||
// X-Forwarded-For hop; locally, the socket address.
|
// X-Forwarded-For hop; locally, the socket address.
|
||||||
@@ -1640,7 +1752,9 @@ app.get('/api/remote/qr/:code', async (c) => {
|
|||||||
if (!/^\d{6}$/.test(code)) return c.text('bad code', 400);
|
if (!/^\d{6}$/.test(code)) return c.text('bad code', 400);
|
||||||
const proto = c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '');
|
const proto = c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '');
|
||||||
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).host;
|
const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).host;
|
||||||
const svg = await QRCode.toString(`${proto}://${host}/?pair=${code}`, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
// ?kind=present → the presenter-screen link instead of the phone-remote one.
|
||||||
|
const param = c.req.query('kind') === 'present' ? 'present' : 'pair';
|
||||||
|
const svg = await QRCode.toString(`${proto}://${host}/?${param}=${code}`, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||||
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
|
return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'no-store' });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1655,6 +1769,7 @@ const notes = registerNoteRoutes(app, {
|
|||||||
adminPassword: process.env.ADMIN_PASSWORD || '',
|
adminPassword: process.env.ADMIN_PASSWORD || '',
|
||||||
backupDir: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'),
|
backupDir: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'),
|
||||||
adminHtmlPath: './public/admin.html',
|
adminHtmlPath: './public/admin.html',
|
||||||
|
workerToken: process.env.LYRICS_WORKER_TOKEN || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1734,10 +1849,12 @@ async function main() {
|
|||||||
port: PORT,
|
port: PORT,
|
||||||
// /ws/remote is upgraded here, before Hono — see remote.js.
|
// /ws/remote is upgraded here, before Hono — see remote.js.
|
||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
if (new URL(req.url).pathname === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server));
|
const path = new URL(req.url).pathname;
|
||||||
|
if (path === '/ws/remote') return remote.upgrade(req, server, clientIpOf(req, server));
|
||||||
|
if (path === '/ws/party') return party.upgrade(req, server, clientIpOf(req, server));
|
||||||
return app.fetch(req, server);
|
return app.fetch(req, server);
|
||||||
},
|
},
|
||||||
websocket: remote.websocket,
|
websocket: websocketHandler,
|
||||||
// Default is 10s, which killed /api/download proxy streams whenever the
|
// Default is 10s, which killed /api/download proxy streams whenever the
|
||||||
// connection went idle mid-transfer. 240s then killed every save whose
|
// connection went idle mid-transfer. 240s then killed every save whose
|
||||||
// server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min —
|
// server-side yt-dlp phase (no bytes sent yet) ran longer than 4 min —
|
||||||
|
|||||||
Reference in New Issue
Block a user