diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml
index 0f94f63..ac9cd95 100644
--- a/.gitea/workflows/build.yml
+++ b/.gitea/workflows/build.yml
@@ -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 }}
diff --git a/Dockerfile b/Dockerfile
index a149e8c..cd35dc8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/README.md b/README.md
index f1d013c..afb3f55 100644
--- a/README.md
+++ b/README.md
@@ -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,
diff --git a/docker-compose.yml b/docker-compose.yml
index 59820e6..27f7311 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/lyrica/Dockerfile b/lyrica/Dockerfile
new file mode 100644
index 0000000..4c599c6
--- /dev/null
+++ b/lyrica/Dockerfile
@@ -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"]
diff --git a/lyrics.js b/lyrics.js
new file mode 100644
index 0000000..85836f1
--- /dev/null
+++ b/lyrics.js
@@ -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 };
diff --git a/public/app.js b/public/app.js
index 451d71d..aa2ecbb 100644
--- a/public/app.js
+++ b/public/app.js
@@ -51,6 +51,7 @@ async function loadSettings() {
}
const ICONS = {
+ lyrics: '',
chat: '',
clip: '',
send: '',
@@ -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 = `
-
+
`;
$('#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() {