- full-screen lyrics view that follows the song: highlighted current line with a colour sweep (word by word when the source has word timing), tap a line to jump, text size, timing nudge, 'Wrong lyrics?' search - Karaoke mode: the current line large and centered - lyrics from Plex first, then the bundled Lyrica service (LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz); YouTube captions never used; wrong-length recordings refused; answers kept in /data/lyrics - lyrica/: Lyrica pinned to one commit, built by the workflow as hub-lyrica; compose service sharing the hub's network - tools/lyrics-test.js Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfd37967c3
commit
fc0ae05415
10 files changed
+397
-10
No files matched your search
@@ -21,6 +21,7 @@ jobs:
|
||||
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
|
||||
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
|
||||
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
|
||||
- run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
|
||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||
- run: node tools/webmail-test.js # the /webmail/ pass-through to the bundled webmail (Cypht)
|
||||
|
||||
@@ -71,3 +72,21 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta_rc.outputs.tags }}
|
||||
labels: ${{ steps.meta_rc.outputs.labels }}
|
||||
|
||||
# The lyrics container (Lyrica, pinned)
|
||||
- id: meta_ly
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.necloud.us/nate/hub-lyrica
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,format=short
|
||||
type=ref,event=tag
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./lyrica
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta_ly.outputs.tags }}
|
||||
labels: ${{ steps.meta_ly.outputs.labels }}
|
||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js signal.js ./
|
||||
COPY server.js auth.js cal.js ics.js caldav.js tasks.js settings.js search.js users.js admin.js vault.js signal.js lyrics.js ./
|
||||
COPY public ./public
|
||||
ENV PORT=8080 DATA_DIR=/data
|
||||
RUN mkdir -p /data && chown node:node /data
|
||||
|
||||
@@ -46,7 +46,7 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
for iOS 13+, so for most accounts only NE Hub lists are available.
|
||||
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
|
||||
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Music tab shows a Now Playing card with the controls above the albums, artists and playlists; the queue (play next / add to queue) and position are remembered.
|
||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Music tab shows a Now Playing card with the controls above the albums, artists and playlists; the queue (play next / add to queue) and position are remembered. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
|
||||
- **Audiobooks**: embeds audiobooks.necloud.us in a frame that keeps playing while you use other tabs, with reload, full screen and open-in-new-tab buttons.
|
||||
Back-door sign-in: set `AUDIOBOOKS_LOGIN_LINK` to the site's secret link (the key is part of it). The frame loads `/api/audiobooks/enter`, which checks
|
||||
the person may use the Audiobooks tab and redirects to that link, so the key stays in the server's environment and never appears in the app's page,
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
WEBMAIL_BACKEND_URL: http://127.0.0.1:80 # the bundled webmail (Cypht, below, shares this container's network); the hub shows it at /webmail/
|
||||
CYPHT_USER: ${CYPHT_USER:-hub} # the webmail's login; the Mail tab signs in with it for you
|
||||
CYPHT_PASSWORD: ${CYPHT_PASSWORD:-}
|
||||
LYRICS_API_URL: http://127.0.0.1:9999 # the bundled lyrics service (Lyrica, below, shares this container's network); remove this line and the service to skip it
|
||||
SIGNAL_API_URL: http://127.0.0.1:8085 # the bundled Signal bridge (below, shares this container's network); remove this line and the service to skip Signal
|
||||
PODCAST_URL: ${PODCAST_URL:-https://podcast.necloud.us/}
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-} # same value as the OnlyOffice server's JWT secret
|
||||
@@ -72,6 +73,18 @@ services:
|
||||
volumes:
|
||||
- signal-data:/home/.local/share/signal-cli
|
||||
|
||||
# Lyrics and karaoke for the music player. Lyrica looks songs up on public lyrics sources (LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz);
|
||||
# the song's artist and title are sent to them. Plex's own lyrics are used first when the song has them. See README -> Music.
|
||||
lyrica:
|
||||
image: git.necloud.us/nate/hub-lyrica:latest # built by the Gitea workflow from ./lyrica (Lyrica pinned to one commit)
|
||||
build: ./lyrica
|
||||
restart: unless-stopped
|
||||
network_mode: "service:hub" # reached by the hub at 127.0.0.1:9999 and by nothing else
|
||||
depends_on:
|
||||
- hub
|
||||
environment:
|
||||
PORT: "9999"
|
||||
|
||||
volumes:
|
||||
hub-data:
|
||||
name: nehub-data # fixed name so the data survives redeploys and renamed stacks
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Lyrica (https://github.com/Wilooper/Lyrica, MIT): a lyrics service that tries several sources and returns plain, line-synced and word-synced lyrics.
|
||||
# Pinned to one commit so what runs is what was tested; change LYRICA_REF to update.
|
||||
FROM python:3.11-slim
|
||||
ARG LYRICA_REF=2c77f81752f3e1ae9a1adbd7b68f633d60b2de23
|
||||
ENV PYTHONUNBUFFERED=1 PORT=9999
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends build-essential gcc git libxml2-dev libxslt1-dev libffi-dev libssl-dev curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& curl -fsSL "https://github.com/Wilooper/Lyrica/archive/${LYRICA_REF}.tar.gz" | tar xz --strip-components=1 -C /app \
|
||||
&& pip install --no-cache-dir --upgrade pip && pip install --no-cache-dir -r requirements.txt \
|
||||
&& useradd -m lyrica && chown -R lyrica /app
|
||||
USER lyrica
|
||||
EXPOSE 9999
|
||||
CMD ["gunicorn", "-c", "gunicorn.config.py", "run:app"]
|
||||
@@ -0,0 +1,94 @@
|
||||
// Song lyrics for the music player, from Lyrica (a lyrics service that tries several sources: LRCLIB, Musixmatch through Lrcmux, NetEase, Megalobiz).
|
||||
// The hub asks it for synced lyrics only from sources that return real lyrics (not auto-captions), checks that the answer is the same recording,
|
||||
// and keeps what it finds on disk so a song is looked up once.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const cfg = { api: '', dir: '' };
|
||||
const HIT_DAYS = 90, MISS_HOURS = 24;
|
||||
const inflight = new Map();
|
||||
|
||||
function init({ dataDir, apiUrl }) {
|
||||
cfg.api = (apiUrl || '').replace(/\/$/, ''); cfg.dir = path.join(dataDir, 'lyrics');
|
||||
if (cfg.api) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
const enabled = () => !!cfg.api;
|
||||
const norm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
const keyOf = (artist, title) => crypto.createHash('sha1').update(norm(artist) + '|' + norm(title)).digest('hex');
|
||||
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||
|
||||
// "[01:23.45]text" lines (and enhanced "<01:23.45>word" tags) into timed lines; text without times stays plain.
|
||||
function parseLrc(text) {
|
||||
const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
|
||||
const lines = [];
|
||||
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||
const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
|
||||
if (!tags.length) continue;
|
||||
let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = [];
|
||||
if (/<\d+:\d{1,2}/.test(body)) { // word timing inside the line
|
||||
const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)];
|
||||
words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim());
|
||||
words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; });
|
||||
body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) });
|
||||
}
|
||||
lines.sort((a, b) => a.t - b.t);
|
||||
lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } });
|
||||
return lines;
|
||||
}
|
||||
|
||||
// What Lyrica answered, in the shape the player uses: { lines: [{ t, e, text, words? }], sync: 'word' | 'line' | 'none', ... }
|
||||
function shape(d, want) {
|
||||
if (!d || d.status === 'error' || !d.data) return { found: false, why: 'not found' };
|
||||
const x = d.data, src = String(x.source || '');
|
||||
if (/youtube/i.test(src)) return { found: false, why: 'only captions found' }; // auto-captions are not lyrics
|
||||
if (x.instrumental) return { found: true, instrumental: true, source: src, sync: 'none', lines: [] };
|
||||
if (want && x.duration && Math.abs(+x.duration - want) > 10) return { found: false, why: 'different version' };
|
||||
let lines = [], sync = 'none';
|
||||
if (Array.isArray(x.timed_lyrics) && x.timed_lyrics.length) {
|
||||
lines = x.timed_lyrics.filter(l => String(l.text || '').trim() || l.words).map(l => {
|
||||
const o = { t: Math.round(l.start_time), e: Math.round(l.end_time || 0), text: String(l.text || '').trim() };
|
||||
const words = Array.isArray(l.words) ? l.words.filter(w => String(w.text || '').trim()).map(w => ({ t: Math.round(w.start), e: Math.round(w.end), text: w.text })) : [];
|
||||
if (words.length) o.words = words; return o;
|
||||
});
|
||||
sync = lines.some(l => l.words) ? 'word' : 'line';
|
||||
} else if (/\[\d+:\d{2}/.test(x.lyrics || '')) { lines = parseLrc(x.lyrics); sync = lines.some(l => l.words) ? 'word' : 'line'; }
|
||||
else lines = String(x.lyrics || '').split(/\r?\n/).map(text => ({ text: text.trim() }));
|
||||
if (!lines.some(l => l.text)) return { found: false, why: 'empty' };
|
||||
lines.forEach((l, i) => { if (sync !== 'none' && (!l.e || l.e <= l.t)) l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; });
|
||||
return { found: true, source: src, sync, lines, album: x.album || '' };
|
||||
}
|
||||
|
||||
async function lookup(artist, title, seconds) {
|
||||
const k = keyOf(artist, title), f = path.join(cfg.dir, k + '.json');
|
||||
try { // already known?
|
||||
const c = JSON.parse(fs.readFileSync(f, 'utf8')), age = Date.now() - c.at;
|
||||
if (c.result && (c.result.found ? age < HIT_DAYS * 864e5 : age < MISS_HOURS * 36e5)) return c.result;
|
||||
} catch { /* not cached */ }
|
||||
if (inflight.has(k)) return inflight.get(k);
|
||||
const p = (async () => {
|
||||
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
|
||||
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
|
||||
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
|
||||
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
|
||||
const d = await r.json().catch(() => null);
|
||||
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
|
||||
const result = shape(d, seconds);
|
||||
try { fs.writeFileSync(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ }
|
||||
return result;
|
||||
})().finally(() => inflight.delete(k));
|
||||
inflight.set(k, p); return p;
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
if (u.pathname !== '/api/lyrics') return json(res, 404, { error: 'Not found' });
|
||||
const artist = String(u.searchParams.get('artist') || '').trim().slice(0, 200), title = String(u.searchParams.get('title') || '').trim().slice(0, 200), seconds = +u.searchParams.get('duration') || 0;
|
||||
if (!title) return json(res, 400, { error: 'Which song?' });
|
||||
try { return json(res, 200, await lookup(artist, title, seconds)); }
|
||||
catch (e) { return json(res, e.status || 502, { error: e.status ? e.message : 'The lyrics service is not answering' }); }
|
||||
}
|
||||
|
||||
module.exports = { init, handle, enabled, parseLrc, shape };
|
||||
+136
-7
@@ -51,6 +51,7 @@ async function loadSettings() {
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
lyrics: '<path d="M5 6h14M5 11h10M5 16h14M5 21h8"/>',
|
||||
chat: '<path d="M4 5h16a1 1 0 011 1v10a1 1 0 01-1 1H10l-5 4v-4H4a1 1 0 01-1-1V6a1 1 0 011-1z"/>',
|
||||
clip: '<path d="M20 11l-8 8a5 5 0 01-7-7l8-8a3.5 3.5 0 015 5l-8 8a2 2 0 01-3-3l7-7"/>',
|
||||
send: '<path d="M4 12l16-8-6 16-3-7z"/>',
|
||||
@@ -1210,6 +1211,7 @@ function loadCur(delay = 220) {
|
||||
const tok = ++loadTok; pl.buf = true;
|
||||
if (delay) pl.a.pause();
|
||||
setMeta(t); renderMini(); updateNow(); renderNpCard(); syncUI(); markPlaying(); saveState();
|
||||
if (lyr.open) { lyrShell(); lyrReload(); }
|
||||
const go = () => {
|
||||
if (tok !== loadTok) return;
|
||||
const hit = tc.m.get(t.src);
|
||||
@@ -1313,12 +1315,12 @@ function renderMini() {
|
||||
el.hidden = false;
|
||||
el.innerHTML = `<div class="mini"><div class="bar2" id="mseek" role="slider" aria-label="Seek"><u id="mbuf"></u><i id="mp"></i></div><img class="art" alt="" src="${px.art(t.thumb, 100)}">
|
||||
<button class="meta" id="mo"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small></button><span class="mtime" id="mtm"></span>
|
||||
<button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn wide-only" id="ml" aria-label="Lyrics" title="Lyrics">${icon('lyrics')}</button><button class="ibtn wide-only" id="ms" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn wide-only" id="mr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="ibtn" id="mpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn" id="mt" aria-label="Play or pause"></button><button class="ibtn" id="mn" aria-label="Next">${icon('next')}</button>
|
||||
<button class="ibtn wide-only" id="mx" aria-label="Close player">${icon('x')}</button></div>`;
|
||||
$('#mt').onclick = toggle; $('#mn').onclick = next; $('#mpv').onclick = prev; $('#mo').onclick = () => { if (!miniSwiped) openNow(); };
|
||||
$('#ms').onclick = () => setShuffle(!pl.shuffle); $('#mr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
$('#mx').onclick = stopPlayer;
|
||||
$('#mx').onclick = stopPlayer; $('#ml').onclick = lyrOpen;
|
||||
// tap or drag along the thin bar to seek
|
||||
const sk = $('#mseek'), at = e => { const r = sk.getBoundingClientRect(), d = pl.a.duration || (cur()?.dur || 0) / 1000; if (d) pl.a.currentTime = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * d; };
|
||||
sk.onpointerdown = e => { sk.setPointerCapture(e.pointerId); at(e); sk.onpointermove = at; sk.onpointerup = sk.onpointercancel = () => { sk.onpointermove = null; }; };
|
||||
@@ -1334,7 +1336,7 @@ let miniSwiped = false;
|
||||
function stopPlayer() {
|
||||
pl.a.pause(); clearTimeout(loadTimer); loadTok++; pl.q = []; pl.i = -1; pl.orig = null; pl.shuffle = false; pl.a.removeAttribute('src'); try { pl.a.load(); } catch {}
|
||||
if ('mediaSession' in navigator) { navigator.mediaSession.metadata = null; navigator.mediaSession.playbackState = 'none'; }
|
||||
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying();
|
||||
tcKick(); saveState(); renderMini(); renderNpCard(); closeSheet(); markPlaying(); lyrClose();
|
||||
}
|
||||
// Shuffle and repeat buttons (in the mini player and the Now Playing sheet) show their state.
|
||||
function modeUI() {
|
||||
@@ -1355,6 +1357,7 @@ function syncUI() {
|
||||
const sk = $('#npk'); if (sk && !sk.dataset.drag) { sk.max = d || 0; sk.value = a.currentTime; }
|
||||
const c = $('#npc'); if (c) c.textContent = fmtDur(a.currentTime * 1000);
|
||||
const dd = $('#npd'); if (dd) dd.textContent = fmtDur(d * 1000);
|
||||
const lt = $('#lyrt'); if (lt && lt.dataset.s !== state) { lt.innerHTML = ic; lt.dataset.s = state; }
|
||||
// the Now Playing card on the Music tab
|
||||
const ct = $('#cnt'); if (ct && ct.dataset.s !== state) { ct.innerHTML = ic; ct.dataset.s = state; }
|
||||
const ck = $('#cnk'); if (ck && !ck.dataset.drag) { ck.max = d || 0; ck.value = a.currentTime; }
|
||||
@@ -1373,11 +1376,11 @@ function renderNpCard() {
|
||||
<div class="npc-seek"><span id="cnc">0:00</span><input type="range" id="cnk" min="0" step="1" value="0" aria-label="Seek"><span id="cnd">0:00</span></div>
|
||||
<div class="npc-ctl"><button class="ibtn" id="cns" aria-label="Shuffle">${icon('shuffle')}</button><button class="ibtn" id="cnp" aria-label="Previous">${icon('prev')}</button>
|
||||
<button class="ibtn big" id="cnt" aria-label="Play or pause"></button><button class="ibtn" id="cnn" aria-label="Next">${icon('next')}</button><button class="ibtn" id="cnr" aria-label="Repeat">${icon('repeat')}</button>
|
||||
<button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button></div>`;
|
||||
<button class="btn ghost npc-l" id="cnl">Lyrics</button><button class="btn ghost npc-q" id="cnq">Queue · ${Math.max(0, pl.q.length - pl.i - 1)} up next</button></div>`;
|
||||
const sk = el.querySelector('#cnk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#cnc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue;
|
||||
el.querySelector('#cnart').onclick = openNow; el.querySelector('#cnq').onclick = openQueue; el.querySelector('#cnl').onclick = lyrOpen;
|
||||
el.querySelector('#cnp').onclick = prev; el.querySelector('#cnn').onclick = next; el.querySelector('#cnt').onclick = toggle;
|
||||
el.querySelector('#cns').onclick = () => setShuffle(!pl.shuffle); el.querySelector('#cnr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
modeUI(); syncUI();
|
||||
@@ -1397,17 +1400,143 @@ function openNow() {
|
||||
<input type="range" id="npk" min="0" step="1" value="0" aria-label="Seek"><div class="times"><span id="npc">0:00</span><span id="npd">0:00</span></div>
|
||||
<div class="ctl"><button class="ibtn" id="npp" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="npt" aria-label="Play or pause"></button>
|
||||
<button class="ibtn" id="npn" aria-label="Next">${icon('next')}</button></div>
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
<div class="bar" style="justify-content:center"><button class="ibtn" id="nps" aria-label="Shuffle">${icon('shuffle')}</button><button class="btn ghost" id="npl">Lyrics</button><button class="btn ghost" id="npq">Queue · ${pl.q.length - pl.i - 1} up next</button><button class="ibtn" id="npr" aria-label="Repeat">${icon('repeat')}</button></div></div>`);
|
||||
const sk = el.querySelector('#npk');
|
||||
sk.oninput = () => { sk.dataset.drag = 1; el.querySelector('#npc').textContent = fmtDur(sk.value * 1000); };
|
||||
sk.onchange = () => { pl.a.currentTime = +sk.value; delete sk.dataset.drag; };
|
||||
el.querySelector('#npq').onclick = openQueue;
|
||||
el.querySelector('#npq').onclick = openQueue; el.querySelector('#npl').onclick = () => { closeSheet(); lyrOpen(); };
|
||||
el.querySelector('#nps').onclick = () => setShuffle(!pl.shuffle);
|
||||
el.querySelector('#npr').onclick = () => setRepeat(pl.repeat === 'off' ? 'all' : pl.repeat === 'all' ? 'one' : 'off');
|
||||
el.querySelector('#npp').onclick = prev; el.querySelector('#npn').onclick = next; el.querySelector('#npt').onclick = toggle;
|
||||
updateNow(); syncUI();
|
||||
}
|
||||
|
||||
// ---- Lyrics and karaoke: lyrics from Plex when the song has them, otherwise from the Lyrica service on the server; synced ones follow the song.
|
||||
function lyrParseLrc(text) {
|
||||
const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000);
|
||||
const lines = [];
|
||||
for (const raw of String(text || '').split(/\r?\n/)) {
|
||||
const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)];
|
||||
if (!tags.length) continue;
|
||||
let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = [];
|
||||
if (/<\d+:\d{1,2}/.test(body)) {
|
||||
const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)];
|
||||
words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim());
|
||||
words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; });
|
||||
body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) });
|
||||
}
|
||||
lines.sort((a, b) => a.t - b.t);
|
||||
lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } });
|
||||
return lines;
|
||||
}
|
||||
const lyr = { open: false, cache: new Map(), data: null, key: null, idx: -2, raf: 0, el: null, loading: false };
|
||||
const lyrPref = () => { try { return { size: 30, karaoke: false, ...JSON.parse(localStorage.getItem('lyr_pref')) }; } catch { return { size: 30, karaoke: false }; } };
|
||||
const lyrSetPref = p => { try { localStorage.setItem('lyr_pref', JSON.stringify({ ...lyrPref(), ...p })); } catch {} };
|
||||
const lyrOffset = k => { try { return +localStorage.getItem('lyr_off:' + k) || 0; } catch { return 0; } };
|
||||
const lyrSetOffset = (k, v) => { try { localStorage.setItem('lyr_off:' + k, String(v)); } catch {} };
|
||||
const lyrQuery = t => { try { return JSON.parse(localStorage.getItem('lyr_q:' + t.key)) || { artist: t.artist, title: t.title }; } catch { return { artist: t.artist, title: t.title }; } };
|
||||
const lyrCleanTitle = s => String(s || '').replace(/\s*[\(\[](feat\.?|ft\.?|with|remaster(ed)?|live|deluxe|version|mono|stereo)[^\)\]]*[\)\]]/gi, '').replace(/\s+-\s+(remaster(ed)?|live|mono|stereo|single version).*$/i, '').trim();
|
||||
|
||||
async function lyrFromPlex(t) { // lyrics stored with the song (embedded, a .lrc file next to it, or downloaded by Plex)
|
||||
try {
|
||||
const mc = await px.get('/library/metadata/' + t.key), st = ((mc.Metadata || [])[0]?.Media?.[0]?.Part?.[0]?.Stream || []).find(x => x.streamType === 4 && x.key);
|
||||
if (!st) return null;
|
||||
const r = await fetch('/plex' + st.key + (st.key.includes('?') ? '&' : '?') + 'format=' + (st.format || 'txt'), { headers: { Accept: 'text/plain, */*' } }); if (!r.ok) return null;
|
||||
let text = await r.text(); if (/^\s*[{<]/.test(text) && /MediaContainer|"Lyrics"/.test(text)) return null; // (not lyrics)
|
||||
const timed = lyrParseLrc(text);
|
||||
if (timed.length >= 2) return { found: true, source: 'Plex', sync: timed.some(l => l.words) ? 'word' : 'line', lines: timed.filter(l => l.text || l.words) };
|
||||
const lines = text.split(/\r?\n/).map(x => ({ text: x.trim() })); return lines.some(l => l.text) ? { found: true, source: 'Plex', sync: 'none', lines } : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
async function lyrLoad(t, force) {
|
||||
if (!force && lyr.cache.has(t.key)) return lyr.cache.get(t.key);
|
||||
let res = force ? null : await lyrFromPlex(t);
|
||||
if (!res && cfg.lyrics) {
|
||||
const q = lyrQuery(t), u = '/api/lyrics?' + new URLSearchParams({ artist: q.artist, title: lyrCleanTitle(q.title), duration: (t.dur || 0) / 1000 || '' });
|
||||
try { res = await jfetch(u); if (!res.found && lyrCleanTitle(q.title) !== q.title) { /* the cleaned title is what we asked; nothing else to try */ } } catch (e) { res = { found: false, error: e.message }; }
|
||||
}
|
||||
res = res || { found: false };
|
||||
if (!res.error) lyr.cache.set(t.key, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function lyrOpen() {
|
||||
const t = cur(); if (!t || lyr.open) return;
|
||||
lyr.open = true; document.body.classList.add('lyropen');
|
||||
const el = lyr.el = document.createElement('div'); el.id = 'lyr'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Lyrics');
|
||||
document.body.appendChild(el); lyrShell(); lyrReload();
|
||||
addEventListener('keydown', lyrKey); lyr.raf = requestAnimationFrame(lyrTick);
|
||||
}
|
||||
function lyrClose() {
|
||||
if (!lyr.open) return; lyr.open = false; cancelAnimationFrame(lyr.raf); removeEventListener('keydown', lyrKey);
|
||||
if (lyr.el) lyr.el.remove(); lyr.el = null; lyr.idx = -2; document.body.classList.remove('lyropen');
|
||||
}
|
||||
const lyrKey = e => { if (e.key === 'Escape' && !document.getElementById('sheet')) lyrClose(); };
|
||||
function lyrShell() {
|
||||
const t = cur(), pref = lyrPref(); if (!t || !lyr.el) return;
|
||||
lyr.el.className = pref.karaoke ? 'kar' : '';
|
||||
lyr.el.style.setProperty('--lf', pref.size + 'px');
|
||||
lyr.el.innerHTML = `<div class="lyrbg" style="background-image:url('${px.art(t.thumb, 300)}')"></div>
|
||||
<div class="lyrtop"><img class="lyrart" alt="" src="${px.art(t.thumb, 120)}"><div class="grow"><b>${esc(t.title)}</b><small>${esc(t.artist)}</small><small id="lyrsrc"></small></div>
|
||||
<button class="btn ghost lyrmode" id="lyrk" aria-pressed="${pref.karaoke}">Karaoke</button>
|
||||
<button class="ibtn" id="lyrsm" aria-label="Smaller text" title="Smaller text">A−</button><button class="ibtn" id="lyrbg" aria-label="Larger text" title="Larger text">A+</button>
|
||||
<button class="ibtn" id="lyrx" aria-label="Close lyrics" title="Close (Esc)">${icon('x')}</button></div>
|
||||
<div class="lyrbody" id="lyrbody"><p class="empty">Looking for the lyrics…</p></div>
|
||||
<div class="lyrfoot"><button class="btn ghost" id="lyrs">Wrong lyrics?</button><span class="grow"></span>
|
||||
<span class="lyrnudge" id="lyrnudge" hidden><button class="ibtn" id="lyrm" aria-label="Lyrics earlier" title="Lyrics too late? Show them earlier">−</button><small id="lyroff">0.0 s</small><button class="ibtn" id="lyrp" aria-label="Lyrics later" title="Lyrics too early? Show them later">+</button></span>
|
||||
<button class="ibtn" id="lyrpv" aria-label="Previous">${icon('prev')}</button><button class="ibtn big" id="lyrt" aria-label="Play or pause"></button><button class="ibtn" id="lyrnx" aria-label="Next">${icon('next')}</button></div>`;
|
||||
const $l = q => lyr.el.querySelector(q);
|
||||
$l('#lyrx').onclick = lyrClose; $l('#lyrpv').onclick = prev; $l('#lyrnx').onclick = next; $l('#lyrt').onclick = toggle;
|
||||
$l('#lyrk').onclick = () => { lyrSetPref({ karaoke: !lyrPref().karaoke }); lyr.el.className = lyrPref().karaoke ? 'kar' : ''; $l('#lyrk').setAttribute('aria-pressed', lyrPref().karaoke); lyr.idx = -2; };
|
||||
const size = d => { const v = Math.max(18, Math.min(64, lyrPref().size + d)); lyrSetPref({ size: v }); lyr.el.style.setProperty('--lf', v + 'px'); };
|
||||
$l('#lyrsm').onclick = () => size(-4); $l('#lyrbg').onclick = () => size(4);
|
||||
$l('#lyrs').onclick = lyrSearchAgain;
|
||||
const nudge = d => { const k = cur().key, v = lyrOffset(k) + d; lyrSetOffset(k, v); $l('#lyroff').textContent = (v / 1000).toFixed(1) + ' s'; };
|
||||
$l('#lyrm').onclick = () => nudge(-500); $l('#lyrp').onclick = () => nudge(500);
|
||||
syncUI();
|
||||
}
|
||||
async function lyrReload(force) {
|
||||
const t = cur(); if (!t || !lyr.el) return; lyr.key = t.key; lyr.data = null; lyr.idx = -2;
|
||||
const body = lyr.el.querySelector('#lyrbody'); body.innerHTML = '<p class="empty">Looking for the lyrics…</p>';
|
||||
const res = await lyrLoad(t, force); if (!lyr.el || lyr.key !== t.key) return;
|
||||
lyr.data = res; lyrDraw();
|
||||
}
|
||||
function lyrDraw() {
|
||||
const t = cur(), d = lyr.data, body = lyr.el && lyr.el.querySelector('#lyrbody'); if (!body || !t) return;
|
||||
const src = lyr.el.querySelector('#lyrsrc'), nudge = lyr.el.querySelector('#lyrnudge');
|
||||
nudge.hidden = !(d && d.found && d.sync !== 'none'); lyr.el.querySelector('#lyroff').textContent = (lyrOffset(t.key) / 1000).toFixed(1) + ' s';
|
||||
if (!d || !d.found) { src.textContent = ''; body.innerHTML = `<p class="empty">${d && d.error ? esc(d.error) : 'No lyrics found for this song.'}</p>`; return; }
|
||||
if (d.instrumental) { src.textContent = ''; body.innerHTML = '<p class="empty">This one is instrumental.</p>'; return; }
|
||||
src.textContent = (d.source ? d.source + ' · ' : '') + (d.sync === 'word' ? 'word-synced' : d.sync === 'line' ? 'line-synced' : 'not time-synced');
|
||||
body.innerHTML = d.lines.map((l, i) => l.text || l.words ? `<div class="ll" data-i="${i}"><span class="lt">${l.words ? l.words.map((w, j) => `<span class="w" data-j="${j}">${esc(w.text)}</span>`).join('') : esc(l.text)}</span></div>` : '<div class="lgap"></div>').join('');
|
||||
body.onclick = e => { const ln = e.target.closest('.ll'); if (!ln || d.sync === 'none') return; const l = d.lines[+ln.dataset.i]; pl.a.currentTime = Math.max(0, (l.t - lyrOffset(t.key)) / 1000); };
|
||||
body.scrollTop = 0; lyr.idx = -2;
|
||||
}
|
||||
function lyrSearchAgain() {
|
||||
const t = cur(); if (!t) return; const q = lyrQuery(t);
|
||||
const el = openSheet('Search for the lyrics', '', `<form id="lyrf"><label for="lyra">Artist</label><input type="text" id="lyra" value="${esc(q.artist)}" autocomplete="off"><label for="lyrti">Song</label><input type="text" id="lyrti" value="${esc(lyrCleanTitle(q.title))}" autocomplete="off">
|
||||
<div class="bar"><button class="btn ghost" type="button" id="lyrc" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">Search</button></div></form>`);
|
||||
el.querySelector('#lyrc').onclick = closeSheet;
|
||||
el.querySelector('#lyrf').onsubmit = e => { e.preventDefault(); try { localStorage.setItem('lyr_q:' + t.key, JSON.stringify({ artist: el.querySelector('#lyra').value.trim(), title: el.querySelector('#lyrti').value.trim() })); } catch {} closeSheet(); lyr.cache.delete(t.key); lyrReload(true); };
|
||||
}
|
||||
function lyrTick() { // follows the song: which line is current, and how far into it we are
|
||||
lyr.raf = requestAnimationFrame(lyrTick);
|
||||
const d = lyr.data, t = cur(); if (!lyr.el || !d || !d.found || d.sync === 'none' || !t) return;
|
||||
const pos = pl.a.currentTime * 1000 + lyrOffset(t.key), L = d.lines;
|
||||
let lo = 0, hi = L.length - 1, idx = -1; while (lo <= hi) { const m = (lo + hi) >> 1; if (L[m].t <= pos) { idx = m; lo = m + 1; } else hi = m - 1; }
|
||||
const body = lyr.el.querySelector('#lyrbody'), nodes = body.querySelectorAll('.ll');
|
||||
if (idx !== lyr.idx) {
|
||||
lyr.idx = idx; let n = -1;
|
||||
nodes.forEach(x => { const i = +x.dataset.i; x.classList.toggle('on', i === idx); x.classList.toggle('past', i < idx); x.classList.toggle('near', Math.abs(i - idx) === 1); x.classList.toggle('upnext', i === idx + 1); if (i === idx) n = i; });
|
||||
const cur1 = body.querySelector('.ll.on'); if (cur1) cur1.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
const on = body.querySelector('.ll.on'); if (!on) return; const l = L[idx];
|
||||
if (l.words) on.querySelectorAll('.w').forEach((w, j) => { const x = l.words[j]; if (!x) return; const p = pos >= x.e ? 1 : pos <= x.t ? 0 : (pos - x.t) / Math.max(1, x.e - x.t); w.classList.toggle('sung', p >= 1); w.style.setProperty('--p', p); });
|
||||
else { const e = l.e && l.e > l.t ? l.e : l.t + 4000, p = Math.max(0, Math.min(1, (pos - l.t) / Math.max(400, Math.min(e - l.t, 7000)))); on.style.setProperty('--p', p); } // (no word timing: the colour sweeps across the line)
|
||||
}
|
||||
|
||||
// ---- People (admin): who can sign in, which tabs each may use, one-time invite codes
|
||||
let peopleTabs = [];
|
||||
async function loadPeople() {
|
||||
|
||||
@@ -446,3 +446,39 @@ body:not(.editing) #view { padding-top: 8px; }
|
||||
.npc-text { align-self: center; } .npc-text b { font-size: 16px; }
|
||||
.npc-ctl { justify-content: space-between; } .npc-q { display: none; }
|
||||
}
|
||||
|
||||
/* Lyrics and karaoke */
|
||||
body.lyropen { overflow: hidden; }
|
||||
#lyr { position: fixed; inset: 0; z-index: 18; background: var(--bg); display: flex; flex-direction: column; padding-top: var(--sat); overflow: hidden; }
|
||||
.lyrbg { position: absolute; inset: -40px; background-size: cover; background-position: center; filter: blur(46px) saturate(1.3); opacity: .35; pointer-events: none; }
|
||||
#lyr > * { position: relative; }
|
||||
.lyrtop { display: flex; align-items: center; gap: 10px; padding: 10px 16px; }
|
||||
.lyrart { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; background: var(--panel2); flex: none; }
|
||||
.lyrtop .grow { min-width: 0; } .lyrtop b, .lyrtop small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .lyrtop small { color: var(--muted); font-size: 12px; }
|
||||
.lyrtop .ibtn { font-weight: 700; font-size: 15px; }
|
||||
.lyrmode[aria-pressed="true"] { background: var(--accent); color: var(--accent-t); }
|
||||
.lyrbody { flex: 1; overflow-y: auto; padding: 24vh 20px 30vh; text-align: center; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
|
||||
.lyrbody::-webkit-scrollbar { display: none; }
|
||||
.lyrbody .empty { padding-top: 10vh; }
|
||||
.ll { font-size: var(--lf, 30px); font-weight: 700; line-height: 1.3; margin: 0 auto 18px; max-width: 900px; color: var(--text); opacity: .32; transition: opacity .3s, transform .3s; transform-origin: center; cursor: pointer; }
|
||||
.ll.past { opacity: .22; }
|
||||
.ll.on { opacity: 1; transform: scale(1.04); }
|
||||
.lgap { height: 24px; }
|
||||
.ll .lt { color: inherit; }
|
||||
.ll.on .lt { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.ll.on .lt:has(.w) { background: none; color: inherit; }
|
||||
.ll .w { color: inherit; }
|
||||
.ll.on .w { background: linear-gradient(90deg, var(--accent) calc(var(--p, 0) * 100%), var(--text) calc(var(--p, 0) * 100%)); -webkit-background-clip: text; background-clip: text; color: transparent; white-space: pre-wrap; }
|
||||
.ll.on .w.sung { background: none; color: var(--accent); }
|
||||
#lyr.kar .lyrbody { display: flex; flex-direction: column; justify-content: center; padding: 8vh 24px; overflow: hidden; }
|
||||
#lyr.kar .ll { display: none; font-size: calc(var(--lf, 30px) * 1.7); opacity: .5; }
|
||||
#lyr.kar .ll.on { display: block; opacity: 1; transform: none; }
|
||||
#lyr.kar .ll.upnext { display: block; font-size: calc(var(--lf, 30px) * 1.05); opacity: .45; margin-top: 8px; }
|
||||
#lyr.kar .ll.past, #lyr.kar .lgap { display: none; }
|
||||
.lyrfoot { display: flex; align-items: center; gap: 6px; padding: 10px 16px calc(12px + var(--sab)); border-top: 1px solid var(--line); background: color-mix(in srgb, var(--bg) 80%, transparent); }
|
||||
.lyrfoot .grow { flex: 1; }
|
||||
.lyrnudge { display: inline-flex; align-items: center; gap: 2px; color: var(--muted); } .lyrnudge[hidden] { display: none; } .lyrnudge .ibtn { width: 36px; height: 36px; font-size: 20px; }
|
||||
.lyrfoot .ibtn.big { width: 56px; height: 56px; }
|
||||
.npc-l { min-height: 38px; font-size: 14px; }
|
||||
@media (max-width: 560px) { .lyrtop .lyrmode { display: none; } .lyrfoot #lyrs { padding: 0 10px; font-size: 13px; } .npc-l { display: inline-flex; } .npc-ctl { gap: 0; } .npc-q { display: none; } }
|
||||
.lyrnudge small, .lyrfoot #lyrs { white-space: nowrap; }
|
||||
@@ -35,6 +35,7 @@ const settings = require('./settings');
|
||||
const search = require('./search');
|
||||
const users = require('./users');
|
||||
const admin = require('./admin');
|
||||
const lyrics = require('./lyrics'); // lyrics for the music player, through Lyrica (LYRICS_API_URL)
|
||||
const signal = require('./signal'); // Signal messages, through signal-cli-rest-api (SIGNAL_API_URL)
|
||||
const vault = require('./vault')('nehub-plex');
|
||||
const VERSE_URL = process.env.VERSE_URL || 'https://versebyversewithnate.us/admin';
|
||||
@@ -409,6 +410,7 @@ function storageInfo() {
|
||||
startedAt: STARTED };
|
||||
}
|
||||
const STARTED = Date.now();
|
||||
lyrics.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.LYRICS_API_URL || '' });
|
||||
signal.init({ dataDir: path.resolve(process.env.DATA_DIR || path.join(__dirname, 'data')), apiUrl: process.env.SIGNAL_API_URL || '' });
|
||||
{ const i = storageInfo(); console.log(`[data] ${i.dir}: ${i.passkeys} passkeys, ${i.calendarLinks} calendar links, ${i.events} events; mounted: ${i.mounted === null ? 'unknown' : i.mounted ? (i.source.kind + ' ' + i.source.name) : 'NO (data is lost on redeploy)'}; writable: ${i.writable}; COOKIE_SECRET from env: ${i.secretFromEnv}`);
|
||||
if (i.source && i.source.kind === 'anonymous') console.warn('[data] WARNING: /data is an anonymous Docker volume, not your host folder. It is deleted when the container is recreated. Check that your stack mounts the host folder at /data.');
|
||||
@@ -445,6 +447,7 @@ const ACCESS = [
|
||||
[/^\/(api\/plex\/|plex\/)/, ['music']],
|
||||
[/^\/api\/audiobooks\//, ['audio']],
|
||||
[/^\/api\/signal\//, ['signal']],
|
||||
[/^\/api\/lyrics$/, ['music']],
|
||||
[/^\/(webmail(\/|$)|api\/webmail\/)/, ['mail']],
|
||||
[/^\/api\/cal\//, ['calendar']],
|
||||
[/^\/api\/tasks/, ['tasks']],
|
||||
@@ -480,7 +483,7 @@ async function handleRequest(req, res) {
|
||||
const c = t => users.can(user, t);
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
return res.end(JSON.stringify({ seafile: c('files') || c('office') ? SEAFILE_URL : '', onlyoffice: c('office') ? ONLYOFFICE_URL : '', audiobooks: c('audio') ? AUDIOBOOKS_URL : '', verse: c('verse') ? VERSE_URL : '',
|
||||
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
|
||||
podcast: c('podcast') ? PODCAST_URL : '', signal: c('signal') && signal.enabled() ? '1' : '', lyrics: c('music') && lyrics.enabled() ? '1' : '', webmail: c('mail') ? (WEBMAIL_URL || (WEBMAIL_BACKEND_URL ? '/webmail/' : '')) : '', editor: c('office') || c('files'), jwt: !!JWT_SECRET, build: BUILD }));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/api/oo/config') return ooConfig(req, res);
|
||||
if (pathname === '/api/ddg') return ddgAnswer(req, res);
|
||||
@@ -494,6 +497,7 @@ async function handleRequest(req, res) {
|
||||
res.writeHead(302, { location: to || AUDIOBOOKS_URL, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); return res.end();
|
||||
}
|
||||
if (pathname === '/api/webmail/login') { res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }); return res.end(JSON.stringify(WEBMAIL_BACKEND_URL && CYPHT_PASSWORD ? { user: CYPHT_USER, pass: CYPHT_PASSWORD } : {})); }
|
||||
if (pathname === '/api/lyrics') return lyrics.enabled() ? lyrics.handle(req, res) : forbidden(res, 'Lyrics are not set up on this server');
|
||||
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||
if (WEBMAIL_BACKEND_URL && isWebmailPath(pathname)) return proxyWebmail(req, res);
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
// Lyrics: what Lyrica answers is turned into the player's format, wrong matches are refused, answers are remembered, and only people with the Music tab can ask.
|
||||
// Uses a fake Lyrica. Run: node tools/lyrics-test.js
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
|
||||
const root = path.join(__dirname, '..');
|
||||
let failed = 0;
|
||||
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
const SECRET = 'lyrics-test-secret-'.repeat(2);
|
||||
const lrcLib = require('../lyrics');
|
||||
|
||||
(async () => {
|
||||
// ---- the LRC reader (also used for lyrics that come from Plex)
|
||||
const l = lrcLib.parseLrc('[ti:x]\n[00:10.50]Hello there\n[00:14.00]<00:14.00>Second <00:14.80>line\n[00:20.00][00:30.25]Chorus\n');
|
||||
ok('LRC lines are read with their times', l.length === 4 && l[0].t === 10500 && l[0].text === 'Hello there' && l[0].e === 14000, l);
|
||||
ok('word timing inside a line is read', l[1].words && l[1].words.length === 2 && l[1].words[0].t === 14000 && l[1].words[1].t === 14800 && l[1].text === 'Second line', l[1]);
|
||||
ok('a line that repeats (two times) appears twice, in order', l[2].text === 'Chorus' && l[3].text === 'Chorus' && l[3].t === 30250, l.slice(2));
|
||||
const client = (() => { // the same reader in the app itself must agree
|
||||
const src = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8'), a = src.indexOf('function lyrParseLrc'), b = src.indexOf('\n}\n', a) + 3;
|
||||
return new Function(src.slice(a, b) + '; return lyrParseLrc;')();
|
||||
})();
|
||||
ok('and the app reads it the same way', JSON.stringify(client('[ti:x]\n[00:10.50]Hello there\n[00:14.00]<00:14.00>Second <00:14.80>line\n[00:20.00][00:30.25]Chorus\n')) === JSON.stringify(l));
|
||||
|
||||
// ---- fake Lyrica
|
||||
const calls = []; let mode = 'ok';
|
||||
const fake = http.createServer((rq, rs) => {
|
||||
const u = new URL(rq.url, 'http://x'); calls.push(Object.fromEntries(u.searchParams));
|
||||
const j = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
|
||||
const song = u.searchParams.get('song');
|
||||
if (mode === 'busy') return j(429, { status: 'error' });
|
||||
if (mode === 'junk') { rs.writeHead(200); return rs.end('<html>nope'); }
|
||||
if (song === 'Yellow') return j(200, { status: 'success', data: { source: 'lrclib', artist: 'Coldplay', title: 'Yellow', duration: 268, hasTimestamps: true, lyrics: '[00:32.47]Look at the stars', timed_lyrics: [{ id: 'lrc_0', text: 'Look at the stars', start_time: 32470, end_time: 36650 }, { id: 'lrc_1', text: 'Look how they shine for you', start_time: 36650, end_time: 41520 }] } });
|
||||
if (song === 'Karaoke') return j(200, { status: 'success', data: { source: 'lrcmux', duration: 200, sync_level: 'word', timed_lyrics: [{ id: 'a', text: 'Hello you', start_time: 1000, end_time: 3000, words: [{ text: 'Hello', start: 1000, end: 1800 }, { text: ' ', start: 1800, end: 1900 }, { text: 'you', start: 1900, end: 3000 }] }] } });
|
||||
if (song === 'Plain Only') return j(200, { status: 'success', data: { source: 'genius', lyrics: 'First line\nSecond line\n\nThird' } });
|
||||
if (song === 'Captions') return j(200, { status: 'success', data: { source: 'youtube_subtitles', timed_lyrics: [{ text: 'noticed it getting more kind of like', start_time: 1, end_time: 2 }] } });
|
||||
if (song === 'Wrong Cut') return j(200, { status: 'success', data: { source: 'lrclib', duration: 600, timed_lyrics: [{ text: 'a live version', start_time: 1, end_time: 2 }] } });
|
||||
if (song === 'Quiet') return j(200, { status: 'success', data: { source: 'lrclib', instrumental: true } });
|
||||
j(200, { status: 'error', error: { message: 'No lyrics found' } });
|
||||
});
|
||||
await new Promise(r => fake.listen(0, '127.0.0.1', r));
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lyrics-test-')), port = 20000 + Math.floor(Math.random() * 20000);
|
||||
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [
|
||||
{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: [], handle: 'h', epoch: 1, disabled: false, created: 1 },
|
||||
{ id: 'ufriend1', name: 'Friend', role: 'user', tabs: ['mail'], handle: 'h2', epoch: 1, disabled: false, created: 2 }] }));
|
||||
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, LYRICS_API_URL: 'http://127.0.0.1:' + fake.address().port, SEAFILE_URL: 'http://127.0.0.1:9' } });
|
||||
child.stdout.on('data', () => {}); child.stderr.on('data', () => {}); await wait(1500);
|
||||
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5;
|
||||
const ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
|
||||
const get = (p, uid = 'uadmin1') => fetch(base + p, { redirect: 'manual', headers: { cookie: ck(uid) } });
|
||||
const ask = async (artist, title, dur) => { const r = await get('/api/lyrics?artist=' + encodeURIComponent(artist) + '&title=' + encodeURIComponent(title) + (dur ? '&duration=' + dur : '')); return { status: r.status, body: await r.json() }; };
|
||||
|
||||
let r = await get('/api/config'); ok('the app is told lyrics are available', (await r.json()).lyrics === '1');
|
||||
r = await get('/api/config', 'ufriend1'); ok('but not for someone without the Music tab', !(await r.json()).lyrics);
|
||||
r = await get('/api/lyrics?artist=a&title=b', 'ufriend1'); ok('who also cannot ask', r.status === 403, r.status);
|
||||
r = await ask('Coldplay', 'Yellow', 268);
|
||||
ok('line-synced lyrics come back in the player\'s format', r.body.found && r.body.sync === 'line' && r.body.lines.length === 2 && r.body.lines[0].t === 32470 && r.body.lines[0].e === 36650 && r.body.lines[1].text === 'Look how they shine for you', r.body);
|
||||
ok('Lyrica was asked only for real-lyrics sources, with word sync preferred', calls[0].sequence === '7,2,4,5,1' && calls[0].pass === 'true' && calls[0].timestamps === 'true' && calls[0].word === 'true', calls[0]);
|
||||
r = await ask('coldplay ', 'YELLOW'); ok('asking again (spelled a little differently) uses what was kept', r.body.found && calls.length === 1, calls.length);
|
||||
r = await ask('Someone', 'Karaoke'); ok('word-synced lyrics keep their words', r.body.sync === 'word' && r.body.lines[0].words.length === 2 && r.body.lines[0].words[1].text === 'you', r.body);
|
||||
r = await ask('Someone', 'Plain Only'); ok('lyrics without times come back as plain lines', r.body.found && r.body.sync === 'none' && r.body.lines.length === 4 && r.body.lines[0].text === 'First line', r.body);
|
||||
r = await ask('Someone', 'Captions'); ok('auto-captions are refused (they are not lyrics)', r.body.found === false, r.body);
|
||||
r = await ask('Someone', 'Wrong Cut', 200); ok('a recording of a very different length is refused', r.body.found === false && r.body.why === 'different version', r.body);
|
||||
r = await ask('Someone', 'Quiet'); ok('an instrumental is reported as one', r.body.found && r.body.instrumental === true, r.body);
|
||||
const n0 = calls.length; r = await ask('Nobody', 'Unknown Song'); const r2 = await ask('Nobody', 'Unknown Song');
|
||||
ok('"not found" is remembered for a while, not asked again at once', r.body.found === false && r2.body.found === false && calls.length === n0 + 1, calls.length - n0);
|
||||
r = await get('/api/lyrics?artist=x'); ok('a missing title is refused', r.status === 400, r.status);
|
||||
mode = 'busy'; r = await ask('Rate', 'Limited'); ok('when the lyrics service says it is busy, the app is told so', r.status === 429, r);
|
||||
mode = 'junk'; r = await ask('Rate', 'Garbled'); ok('and an unreadable answer is an error, not a "no lyrics"', r.status === 502, r);
|
||||
mode = 'ok'; r = await ask('Rate', 'Garbled'); ok('which is not remembered', r.status === 200, r);
|
||||
const par = await Promise.all([ask('Par', 'Allel'), ask('Par', 'Allel'), ask('Par', 'Allel')]); const cnt = calls.filter(c => c.song === 'Allel').length;
|
||||
ok('three people asking at once cause one lookup', cnt === 1, cnt);
|
||||
fake.close(); await wait(200); r = await ask('Zed', 'Offline'); ok('with Lyrica down, a clear error (and the hub keeps running)', r.status === 502 && (await get('/api/config')).status === 200, r);
|
||||
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll lyrics checks passed'); process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
Reference in new issue
Block a user