No lyrics for this track.Save a .lrc next to the file, or add them with Edit.
';
+ } else {
+ // A line ends when its last word does, else when the next one starts.
+ npLines.forEach((l, i) => {
+ const next = npLines[i + 1];
+ l.end = l.words ? l.words[l.words.length - 1].e : (next && next.t != null ? next.t : (l.t ?? 0) + 4);
+ if (l.words && next && next.t != null && l.end > next.t + 8) l.end = next.t;
+ });
+ box.innerHTML = npLines.map((l, i) => {
+ if (npSynced && !l.text) {
+ return `
${ext.settings.map((entry) => extSettingFieldHtml(ext, entry, idx)).join('')}
+ ${extLocalNetworkRowHtml(ext)}
Reset to defaults
diff --git a/mornye/frontend/web-shim.js b/mornye/frontend/web-shim.js
index 03f77b2..768ce0b 100644
--- a/mornye/frontend/web-shim.js
+++ b/mornye/frontend/web-shim.js
@@ -190,7 +190,7 @@
// Each JS extension's own settings (api_mixins/extension_settings.py).
'list_extension_settings', 'get_extension_settings',
'save_extension_settings', 'reset_extension_settings',
- 'run_extension_setting_action',
+ 'run_extension_setting_action', 'set_extension_local_network',
// The Test buttons next to the self-hosted endpoints (admin-only).
'test_custom_endpoint',
];
diff --git a/mornye/webapp.py b/mornye/webapp.py
index 29eebff..bd75c7e 100644
--- a/mornye/webapp.py
+++ b/mornye/webapp.py
@@ -242,6 +242,9 @@ def _token_matches(candidate: str | None, expected: str) -> bool:
"save_extension_settings",
"reset_extension_settings",
"run_extension_setting_action",
+ # Lets one extension reach the server typed into its own settings on the
+ # LAN (extensions/local_network.py). Widens the sandbox: admin-only.
+ "set_extension_local_network",
# Probes the self-hosted endpoint typed in (or saved) for tidal-web /
# qobuz-web: an outbound request to a user-chosen URL, so admin-only.
"test_custom_endpoint",
@@ -256,6 +259,7 @@ def _token_matches(candidate: str | None, expected: str) -> bool:
"save_extension_settings",
"reset_extension_settings",
"run_extension_setting_action",
+ "set_extension_local_network",
"test_custom_endpoint",
}
diff --git a/tests/test_extension_local_network.py b/tests/test_extension_local_network.py
new file mode 100644
index 0000000..e541d59
--- /dev/null
+++ b/tests/test_extension_local_network.py
@@ -0,0 +1,134 @@
+"""The per-extension "Allow local network access" switch (Jellyfin, J1).
+
+Jellyfin takes its server's address in a plain ``server_url`` setting that
+is not marked ``grantsNetwork``, so _netguard.js refused a server on the LAN
+and the only way round it was ``MORNYE_EXT_ALLOW_PRIVATE_NETWORK=1``, which
+opens the whole local network to every extension. The switch opens only the
+hosts typed into that one extension's settings.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+from mornye.api_mixins import extension_settings
+from mornye.extensions import local_network, provider
+from mornye.extensions.sandbox import network_grants
+
+JELLYFIN = {
+ "name": "jellyfin",
+ "settings": [
+ {"key": "server_url", "type": "string", "required": True},
+ {"key": "username", "type": "string"},
+ {"key": "password", "type": "string", "secret": True},
+ {"key": "test_button", "type": "button", "action": "testConnection"},
+ ],
+}
+SAVED = {
+ "server_url": "http://192.168.1.20:8096",
+ "username": "me",
+ "password": "http://secret.example.test",
+}
+
+
+@pytest.fixture(autouse=True)
+def _data_dir(tmp_path, monkeypatch) -> Path:
+ monkeypatch.setenv("MORNYE_DATA_DIR", str(tmp_path))
+ return tmp_path
+
+
+def test_without_the_switch_an_unmarked_server_url_grants_nothing() -> None:
+ assert network_grants(JELLYFIN, SAVED) == []
+
+
+def test_with_the_switch_only_the_typed_server_is_granted() -> None:
+ # Not the secret, even when it happens to look like a URL; not the
+ # username, which is not a URL at all.
+ assert network_grants(JELLYFIN, SAVED, local_network=True) == ["192.168.1.20"]
+
+
+def test_the_switch_is_off_until_turned_on_and_persists(_data_dir) -> None:
+ assert local_network.is_allowed("jellyfin") is False
+ local_network.set_allowed("jellyfin", True)
+ assert local_network.is_allowed("jellyfin") is True
+ assert local_network.is_allowed("tidal-web") is False
+ stored = json.loads((_data_dir / "extension_local_network.json").read_text())
+ assert stored == {"jellyfin": True}
+
+ local_network.set_allowed("jellyfin", False)
+ assert local_network.is_allowed("jellyfin") is False
+
+
+def test_an_unreadable_file_means_off(_data_dir) -> None:
+ (_data_dir / "extension_local_network.json").write_text("{nope")
+ assert local_network.is_allowed("jellyfin") is False
+ (_data_dir / "extension_local_network.json").write_text('{"jellyfin": "yes"}')
+ assert local_network.is_allowed("jellyfin") is False
+
+
+def test_the_runtime_is_started_with_the_granted_host(monkeypatch) -> None:
+ seen: dict = {}
+
+ class FakeRuntime:
+ def __init__(self, **kwargs):
+ seen.update(kwargs)
+
+ def start(self):
+ pass
+
+ monkeypatch.setattr(provider, "JSRuntime", FakeRuntime)
+ fake = SimpleNamespace(
+ _ext=SimpleNamespace(name="jellyfin", manifest=JELLYFIN, index_js="x.js"),
+ _settings=SAVED,
+ _node_executable="node",
+ _signed_session=None,
+ _session_signed_fetch_handler=None,
+ _stop_requested=lambda: False,
+ _storage=None,
+ )
+ provider.JSExtensionProvider._create_runtime(fake) # type: ignore[arg-type]
+ assert seen["granted_hosts"] == []
+
+ local_network.set_allowed("jellyfin", True)
+ provider.JSExtensionProvider._create_runtime(fake) # type: ignore[arg-type]
+ assert seen["granted_hosts"] == ["192.168.1.20"]
+
+
+def test_the_api_reports_and_changes_the_switch(monkeypatch) -> None:
+ ext = SimpleNamespace(
+ name="jellyfin",
+ display_name="Jellyfin",
+ version="1.1.1",
+ runtime="javascript",
+ types=["metadata_provider"],
+ manifest=JELLYFIN,
+ settings_schema=JELLYFIN["settings"],
+ quality_options=[],
+ )
+ manager = SimpleNamespace(
+ get_installed=lambda name: ext if name == "jellyfin" else None,
+ load_settings=lambda name: dict(SAVED),
+ )
+ forgotten: list[str] = []
+ monkeypatch.setattr(extension_settings, "_manager", lambda: manager)
+ monkeypatch.setattr(extension_settings, "_forget_running", forgotten.append)
+ api = extension_settings.ExtensionSettingsMixin()
+
+ before = api.get_extension_settings("jellyfin")
+ assert before["local_network"] is False
+ assert before["local_network_hosts"] == ["192.168.1.20"]
+
+ after = api.set_extension_local_network("jellyfin", True)
+ assert after["ok"] is True and after["local_network"] is True
+ assert forgotten == ["jellyfin"] # restarted, so the grant takes effect
+ assert "error" in api.set_extension_local_network("nope", True)
+
+
+def test_the_web_frontend_can_reach_it_but_only_as_admin() -> None:
+ webapp = pytest.importorskip("mornye.webapp")
+ assert "set_extension_local_network" in webapp.ALLOWED_METHODS
+ assert "set_extension_local_network" in webapp.EXTENSION_SETTINGS_ADMIN_METHODS
From 00b7ac3af2967739408495d14ab5d44afd9984d8 Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 13:35:13 +0100
Subject: [PATCH 23/39] Use extensions that supply lyrics as lyrics providers
An installed extension of type lyrics_provider that defines fetchLyrics
(Jellyfin, Apple Music) is now a provider named ext:
, ranked with the
built-in ones. The GUI and TUI add them to their provider lists, off by
default, and --lyrics-providers accepts ext:. Line-synced answers
become LRC, unsynced ones stay plain text.
---
mornye/api_mixins/extension_health.py | 14 ++
mornye/core/extension_lyrics.py | 141 ++++++++++++++++++++
mornye/core/lyrics.py | 15 ++-
mornye/frontend/app.js | 36 +++++
mornye/frontend/web-shim.js | 2 +-
mornye/launcher.py | 54 +++++---
mornye/tui/config_state.py | 17 +++
mornye/tui/config_view.py | 4 +-
mornye/webapp.py | 2 +
tests/test_extension_lyrics.py | 181 ++++++++++++++++++++++++++
10 files changed, 446 insertions(+), 20 deletions(-)
create mode 100644 mornye/core/extension_lyrics.py
create mode 100644 tests/test_extension_lyrics.py
diff --git a/mornye/api_mixins/extension_health.py b/mornye/api_mixins/extension_health.py
index 954fac5..877e115 100644
--- a/mornye/api_mixins/extension_health.py
+++ b/mornye/api_mixins/extension_health.py
@@ -121,6 +121,20 @@ def get_download_services(self) -> dict:
except Exception as e:
return {"services": [], "error": str(e)}
+ def get_lyrics_extensions(self) -> dict:
+ """Installed extensions that can supply lyrics, as lyrics providers.
+
+ Shape: {"extensions": [{"id": "ext:", "name", "label"}, ...]}.
+ The ids go in the lyrics-provider list next to the built-in ones
+ (core/extension_lyrics.py).
+ """
+ try:
+ from ..core.extension_lyrics import lyrics_extensions
+
+ return {"extensions": lyrics_extensions()}
+ except Exception as e:
+ return {"extensions": [], "error": str(e)}
+
def _installed_extension_index(self) -> dict[str, dict]:
"""{"ext:": {"version": ..., "trust_tier": ...}} for what's installed.
diff --git a/mornye/core/extension_lyrics.py b/mornye/core/extension_lyrics.py
new file mode 100644
index 0000000..1798168
--- /dev/null
+++ b/mornye/core/extension_lyrics.py
@@ -0,0 +1,141 @@
+"""Lyrics from extensions that declare ``lyrics_provider``.
+
+A Mornye-Extension of type ``lyrics_provider`` exports
+``fetchLyrics(trackName, artistName, albumName, durationSec)`` and answers in
+the mobile runtime's shape::
+
+ {"lines": [{"startTimeMs", "endTimeMs", "words"}, ...],
+ "syncType": "LINE_SYNCED" | "UNSYNCED", "plainLyrics": "...",
+ "instrumental": false, "provider": "Jellyfin"}
+
+Nothing called it: the providers in core/lyrics.py were all built in. Now
+each such extension is a provider of its own, named ``ext:`` — the
+same prefix the download path uses — so it can be ranked among the others
+in every interface's provider list.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import re
+from pathlib import Path
+from typing import Any
+
+from .extension_enrichment import _has_javascript, _installed_extensions, _provider
+
+logger = logging.getLogger(__name__)
+
+PREFIX = "ext:"
+
+_FETCH_LYRICS_RE = re.compile(r"\bfetchLyrics\b")
+_implements_cache: dict[str, tuple[float, bool]] = {}
+
+
+def _implements_fetch_lyrics(ext: Any) -> bool:
+ """Read from the source, like enrichTrack: asking by calling would start
+ a Node process per extension just to hear "No method"."""
+ try:
+ path = Path(ext.index_js)
+ mtime = path.stat().st_mtime
+ except (OSError, AttributeError):
+ return False
+ key = str(path)
+ cached = _implements_cache.get(key)
+ if cached and cached[0] == mtime:
+ return cached[1]
+ try:
+ found = bool(
+ _FETCH_LYRICS_RE.search(path.read_text(encoding="utf-8", errors="replace"))
+ )
+ except OSError:
+ return False
+ _implements_cache[key] = (mtime, found)
+ return found
+
+
+def is_lyrics_extension(ext: Any) -> bool:
+ return (
+ _has_javascript(ext)
+ and "lyrics_provider" in (getattr(ext, "types", None) or [])
+ and _implements_fetch_lyrics(ext)
+ )
+
+
+def lyrics_extensions(manager: Any = None) -> list[dict[str, str]]:
+ """``[{"id": "ext:jellyfin", "name": "jellyfin", "label": "Jellyfin"}]``."""
+ rows = [
+ {
+ "id": PREFIX + ext.name,
+ "name": ext.name,
+ "label": getattr(ext, "display_name", "") or ext.name,
+ }
+ for ext in _installed_extensions(manager)
+ if is_lyrics_extension(ext)
+ ]
+ rows.sort(key=lambda row: row["label"].lower())
+ return rows
+
+
+def _lrc_stamp(ms: Any) -> str:
+ try:
+ total = max(0, int(ms))
+ except (TypeError, ValueError):
+ total = 0
+ minutes, rest = divmod(total, 60_000)
+ seconds, millis = divmod(rest, 1000)
+ return f"[{minutes:02d}:{seconds:02d}.{millis // 10:02d}]"
+
+
+def result_to_lrc(result: Any) -> str:
+ """The extension's answer as LRC text (or plain text when unsynced)."""
+ if isinstance(result, str):
+ return result.strip()
+ if not isinstance(result, dict) or result.get("instrumental") is True:
+ return ""
+ lines = result.get("lines")
+ if isinstance(lines, list) and lines and result.get("syncType") != "UNSYNCED":
+ out = []
+ for line in lines:
+ if not isinstance(line, dict):
+ continue
+ words = str(line.get("words") or "").strip()
+ out.append(f"{_lrc_stamp(line.get('startTimeMs'))}{words}")
+ text = "\n".join(out).strip()
+ if text:
+ return text
+ plain = result.get("plainLyrics")
+ if isinstance(plain, str) and plain.strip():
+ return plain.strip()
+ if isinstance(lines, list):
+ return "\n".join(
+ str(line.get("words") or "") for line in lines if isinstance(line, dict)
+ ).strip()
+ return ""
+
+
+async def fetch_extension_lyrics(
+ provider_id: str,
+ track_name: str,
+ artist_name: str,
+ album_name: str = "",
+ duration_s: int = 0,
+ *,
+ provider_factory: Any = None,
+) -> str:
+ """Ask one extension; ``""`` when it has nothing or fails."""
+ name = provider_id[len(PREFIX) :] if provider_id.startswith(PREFIX) else ""
+ if not name:
+ return ""
+
+ def call() -> Any:
+ return _provider(name, provider_factory)._call(
+ "fetchLyrics", track_name, artist_name, album_name, int(duration_s or 0)
+ )
+
+ try:
+ result = await asyncio.to_thread(call)
+ except Exception as exc:
+ logger.debug("[lyrics/%s] fetchLyrics failed: %s", provider_id, exc)
+ return ""
+ return result_to_lrc(result)
diff --git a/mornye/core/lyrics.py b/mornye/core/lyrics.py
index 8d9efb9..d7ed722 100644
--- a/mornye/core/lyrics.py
+++ b/mornye/core/lyrics.py
@@ -9,7 +9,7 @@
import unicodedata
import urllib.parse
from dataclasses import dataclass
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
from . import get_amazon_endpoint
from .cross_loop_lock import CrossLoopLock
@@ -1317,6 +1317,19 @@ async def run_provider(
fetcher = _PROVIDER_MAP.get(
provider_name,
)
+ if fetcher is None and provider_name.startswith("ext:"):
+ # An installed extension of type lyrics_provider
+ # (core/extension_lyrics.py), ranked like any built-in one.
+ from .extension_lyrics import fetch_extension_lyrics
+
+ def fetcher(ctx: LyricsContext) -> Any:
+ return fetch_extension_lyrics(
+ provider_name,
+ ctx.clean_track,
+ ctx.clean_artist,
+ ctx.album_name,
+ ctx.duration_s,
+ )
if not fetcher:
return "", ""
diff --git a/mornye/frontend/app.js b/mornye/frontend/app.js
index 4cfe151..0ed599c 100644
--- a/mornye/frontend/app.js
+++ b/mornye/frontend/app.js
@@ -503,6 +503,7 @@ function applySettings(settings = {}) {
}
lastAppliedServices = Array.isArray(cfg.services) ? cfg.services : lastAppliedServices;
applyListState('services-list', cfg.services);
+ if (Array.isArray(cfg.lyrics_providers)) lastAppliedLyrics = cfg.lyrics_providers;
applyListState('lyrics-list', cfg.lyrics_providers);
applyListState('enrich-list', cfg.enrich_providers);
updateAllApiConfigDisplays();
@@ -950,12 +951,46 @@ populateList('enrich-list', ALL_ENRICH.map(x => ({ ...x, badge: null })));
// registry was added): the provider list and the Extensions page re-read.
window.app_extensions_changed = function () {
refreshInstalledServices();
+ refreshLyricsExtensions();
if ($('tc-extensions')?.classList.contains('active')) {
loadExtensionSettings?.();
loadExtensionHealth?.();
}
};
+// The lyrics providers saved last, so an extension's entry ("ext:jellyfin")
+// keeps its place and tick even though the list is drawn before the backend
+// has said which extensions are installed.
+let lastAppliedLyrics = null;
+
+// Extensions of type lyrics_provider (core/extension_lyrics.py) join the
+// built-in lyrics providers, off by default, ranked like any other.
+async function refreshLyricsExtensions() {
+ const api = window.pywebview?.api;
+ if (typeof api?.get_lyrics_extensions !== 'function' || !$('lyrics-list')) return;
+ let rows;
+ try {
+ rows = (await api.get_lyrics_extensions())?.extensions || [];
+ } catch (e) {
+ return;
+ }
+ const el = $('lyrics-list');
+ const shown = [...el.querySelectorAll('.sort-item')].map((x) => x.dataset.value);
+ const wanted = getChecked('lyrics-list');
+ (lastAppliedLyrics || []).forEach((id, i) => {
+ if (!shown.includes(id) && rows.some((r) => r.id === id)) {
+ wanted.splice(Math.min(i, wanted.length), 0, id);
+ }
+ });
+ const builtIn = ALL_LYRICS.map((x) => ({ ...x, badge: null }));
+ const fromExtensions = rows.map((r) => ({
+ id: r.id, label: escHtml(r.label), on: false, icon: 'EX', iconClass: 'ext',
+ title: `the ${r.label} extension`,
+ }));
+ populateList('lyrics-list', [...builtIn, ...fromExtensions]);
+ applyListState('lyrics-list', wanted);
+}
+
async function refreshInstalledServices() {
if (typeof window.pywebview?.api?.get_download_services !== 'function') {
// An older backend that cannot say what is installed: the built-in list.
@@ -6494,6 +6529,7 @@ window.addEventListener('pywebviewready', async () => {
// saved settings yet never calls applySettings() at all, which is exactly
// the fresh install that most needs to be told what it can download from.
await refreshInstalledServices();
+ await refreshLyricsExtensions();
initSettingsTracking();
updateSearchMode();
initPasteButton();
diff --git a/mornye/frontend/web-shim.js b/mornye/frontend/web-shim.js
index 768ce0b..a861142 100644
--- a/mornye/frontend/web-shim.js
+++ b/mornye/frontend/web-shim.js
@@ -188,7 +188,7 @@
// CSV import sends the file's text, read in the browser — never a path.
'preview_csv', 'fetch_csv',
// Each JS extension's own settings (api_mixins/extension_settings.py).
- 'list_extension_settings', 'get_extension_settings',
+ 'list_extension_settings', 'get_extension_settings', 'get_lyrics_extensions',
'save_extension_settings', 'reset_extension_settings',
'run_extension_setting_action', 'set_extension_local_network',
// The Test buttons next to the self-hosted endpoints (admin-only).
diff --git a/mornye/launcher.py b/mornye/launcher.py
index 1c474bd..3a84a51 100644
--- a/mornye/launcher.py
+++ b/mornye/launcher.py
@@ -61,6 +61,38 @@
MornyeDownloader: Any | None = None
+#: Lyrics providers built in (core/lyrics.py). An installed extension of type
+#: lyrics_provider is named ext: (core/extension_lyrics.py).
+_BUILTIN_LYRICS_PROVIDERS = (
+ "spotify",
+ "apple",
+ "deezer",
+ "genius",
+ "netease",
+ "qq",
+ "youtube",
+ "kugou",
+ "musixmatch",
+ "amazon",
+ "lrclib",
+ "binilyrics",
+ "unison",
+ "jiosaavn",
+)
+
+
+def _lyrics_provider_arg(value: str) -> str:
+ value = str(value).strip()
+ if value in _BUILTIN_LYRICS_PROVIDERS:
+ return value
+ if value.startswith("ext:") and len(value) > len("ext:"):
+ return value
+ raise argparse.ArgumentTypeError(
+ f"invalid choice: {value!r} (choose from "
+ f"{', '.join(_BUILTIN_LYRICS_PROVIDERS)}, or ext:)"
+ )
+
+
def _legacy_adapter(options: DownloadOptions) -> LegacyDownloadAdapter:
if MornyeDownloader is not None:
return LegacyDownloadAdapter(MornyeDownloader(options), options=options)
@@ -855,22 +887,12 @@ def _service_type(value: str) -> str:
nargs="+",
default=pd.get("lyrics_providers", ["apple", "binilyrics", "lrclib"]),
dest="lyrics_providers",
- choices=[
- "spotify",
- "apple",
- "deezer",
- "genius",
- "netease",
- "qq",
- "youtube",
- "kugou",
- "musixmatch",
- "amazon",
- "lrclib",
- "binilyrics",
- "unison",
- "jiosaavn",
- ],
+ type=_lyrics_provider_arg,
+ metavar="PROVIDER",
+ help=(
+ f"in order of preference: {', '.join(_BUILTIN_LYRICS_PROVIDERS)}, "
+ "or ext: for an installed extension that supplies lyrics"
+ ),
)
lyrics_grp.add_argument(
"--apple-lyrics-line-synced",
diff --git a/mornye/tui/config_state.py b/mornye/tui/config_state.py
index a93e4b7..344f66e 100644
--- a/mornye/tui/config_state.py
+++ b/mornye/tui/config_state.py
@@ -54,6 +54,23 @@
"jiosaavn",
)
+
+def lyrics_provider_choices(selected: list[str] | tuple[str, ...] = ()) -> list[str]:
+ """The built-in lyrics providers, then installed extensions that supply
+ lyrics (``ext:``, core/extension_lyrics.py), then any ``ext:``
+ entry already chosen whose extension is gone — kept so a missing
+ extension does not silently fall out of a saved profile."""
+ choices = list(LYRICS_PROVIDERS)
+ try:
+ from ..core.extension_lyrics import lyrics_extensions
+
+ choices += [row["id"] for row in lyrics_extensions()]
+ except Exception:
+ pass
+ choices += [p for p in selected if p.startswith("ext:") and p not in choices]
+ return choices
+
+
#: Enrichment providers. SoundCloud is selectable but not on by default.
ENRICH_PROVIDERS: tuple[str, ...] = ("deezer", "apple", "qobuz", "tidal", "soundcloud")
diff --git a/mornye/tui/config_view.py b/mornye/tui/config_view.py
index 055b126..ef89bf9 100644
--- a/mornye/tui/config_view.py
+++ b/mornye/tui/config_view.py
@@ -42,7 +42,7 @@
from .config_state import (
ENRICH_PROVIDERS,
CANVAS_PROVIDERS,
- LYRICS_PROVIDERS,
+ lyrics_provider_choices,
POST_DOWNLOAD_ACTIONS,
TRANSCODE_BITRATES,
ConfigState,
@@ -466,7 +466,7 @@ def compose(self) -> ComposeResult:
yield SelectionList[str](
*[
(provider, provider, provider in state.lyrics_providers)
- for provider in LYRICS_PROVIDERS
+ for provider in lyrics_provider_choices(state.lyrics_providers)
],
id=_field_id("lyrics_providers"),
)
diff --git a/mornye/webapp.py b/mornye/webapp.py
index bd75c7e..b85dfc8 100644
--- a/mornye/webapp.py
+++ b/mornye/webapp.py
@@ -239,6 +239,8 @@ def _token_matches(candidate: str | None, expected: str) -> bool:
# mode, since one install's extensions serve every account.
"list_extension_settings",
"get_extension_settings",
+ # Extensions that supply lyrics (core/extension_lyrics.py): read-only.
+ "get_lyrics_extensions",
"save_extension_settings",
"reset_extension_settings",
"run_extension_setting_action",
diff --git a/tests/test_extension_lyrics.py b/tests/test_extension_lyrics.py
new file mode 100644
index 0000000..232e96a
--- /dev/null
+++ b/tests/test_extension_lyrics.py
@@ -0,0 +1,181 @@
+"""Extensions of type ``lyrics_provider`` as lyrics providers (Jellyfin, J2).
+
+Jellyfin, Apple Music and others export ``fetchLyrics``; nothing called it,
+because every lyrics provider was built in. Each such extension is now a
+provider named ``ext:``, ranked with the rest.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from mornye.core import extension_lyrics as EL
+from mornye.core import lyrics as L
+
+
+@pytest.fixture(autouse=True)
+def _no_cache(monkeypatch):
+ monkeypatch.setattr(L, "get_cached_response", lambda *a, **k: None)
+ monkeypatch.setattr(L, "put_cached_response", lambda *a, **k: None)
+
+
+JELLYFIN_SYNCED = {
+ "lines": [
+ {"startTimeMs": 2640, "words": "Damn, every time", "endTimeMs": 5000},
+ {"startTimeMs": 65_010, "words": "second line", "endTimeMs": 0},
+ ],
+ "syncType": "LINE_SYNCED",
+ "instrumental": False,
+ "plainLyrics": "",
+ "provider": "Jellyfin",
+}
+
+
+def test_line_synced_answers_become_lrc() -> None:
+ assert EL.result_to_lrc(JELLYFIN_SYNCED) == (
+ "[00:02.64]Damn, every time\n[01:05.01]second line"
+ )
+
+
+def test_unsynced_answers_stay_plain() -> None:
+ assert (
+ EL.result_to_lrc(
+ {"lines": [], "syncType": "UNSYNCED", "plainLyrics": "one\ntwo"}
+ )
+ == "one\ntwo"
+ )
+
+
+def test_nothing_instrumental_or_odd_shapes_give_no_lyrics() -> None:
+ assert EL.result_to_lrc(None) == ""
+ assert EL.result_to_lrc({"instrumental": True, "plainLyrics": "x"}) == ""
+ assert EL.result_to_lrc([1, 2]) == ""
+ assert EL.result_to_lrc(" [00:01.00]already lrc ") == "[00:01.00]already lrc"
+
+
+def _ext(tmp_path, name, types, source):
+ js = tmp_path / f"{name}.js"
+ js.write_text(source)
+ return SimpleNamespace(
+ name=name,
+ display_name=name.title(),
+ types=types,
+ runtime="javascript",
+ manifest={},
+ index_js=str(js),
+ )
+
+
+def test_only_lyrics_providers_that_define_fetch_lyrics_are_listed(tmp_path) -> None:
+ manager = SimpleNamespace(
+ list_installed=lambda: [
+ _ext(tmp_path, "jellyfin", ["lyrics_provider"], "function fetchLyrics(){}"),
+ _ext(
+ tmp_path, "tidal-web", ["download_provider"], "function fetchLyrics(){}"
+ ),
+ _ext(tmp_path, "broken", ["lyrics_provider"], "function other(){}"),
+ ]
+ )
+ assert EL.lyrics_extensions(manager) == [
+ {"id": "ext:jellyfin", "name": "jellyfin", "label": "Jellyfin"}
+ ]
+
+
+def test_the_extension_is_called_with_the_mobile_arguments() -> None:
+ calls = []
+
+ class Provider:
+ def _call(self, method, *args):
+ calls.append((method, args))
+ return JELLYFIN_SYNCED
+
+ text = asyncio.run(
+ EL.fetch_extension_lyrics(
+ "ext:jf-test",
+ "Song",
+ "Artist",
+ "Album",
+ 215,
+ provider_factory=lambda name: Provider(),
+ )
+ )
+ assert calls == [("fetchLyrics", ("Song", "Artist", "Album", 215))]
+ assert text.startswith("[00:02.64]Damn")
+ from mornye.core.extension_enrichment import close_extension_provider
+
+ close_extension_provider("jf-test")
+
+
+def test_a_failing_extension_gives_no_lyrics() -> None:
+ class Provider:
+ def _call(self, *args):
+ raise RuntimeError("server down")
+
+ assert (
+ asyncio.run(
+ EL.fetch_extension_lyrics(
+ "ext:jf-fail", "S", "A", provider_factory=lambda name: Provider()
+ )
+ )
+ == ""
+ )
+ from mornye.core.extension_enrichment import close_extension_provider
+
+ close_extension_provider("jf-fail")
+
+
+def test_an_extension_is_ranked_like_a_built_in_provider(monkeypatch) -> None:
+ async def lrclib(_ctx):
+ return "[00:01.00]from lrclib"
+
+ async def from_extension(provider_id, *args, **kw):
+ assert provider_id == "ext:jellyfin"
+ return "[00:01.00]from jellyfin"
+
+ monkeypatch.setattr(L, "_PROVIDER_MAP", {"lrclib": lrclib})
+ monkeypatch.setattr(EL, "fetch_extension_lyrics", from_extension)
+
+ text, provider = asyncio.run(
+ L.fetch_lyrics_async("S", "A", providers=["ext:jellyfin", "lrclib"])
+ )
+ assert provider == "ext:jellyfin" and "from jellyfin" in text
+
+ text, provider = asyncio.run(
+ L.fetch_lyrics_async("S", "A", providers=["lrclib", "ext:jellyfin"])
+ )
+ assert provider == "lrclib"
+
+
+def test_the_cli_accepts_extension_providers() -> None:
+ import argparse
+
+ from mornye import launcher
+
+ assert launcher._lyrics_provider_arg("ext:jellyfin") == "ext:jellyfin"
+ assert launcher._lyrics_provider_arg("lrclib") == "lrclib"
+ for bad in ("nope", "ext:"):
+ with pytest.raises(argparse.ArgumentTypeError):
+ launcher._lyrics_provider_arg(bad)
+
+
+def test_the_tui_offers_installed_and_saved_extension_providers(monkeypatch) -> None:
+ from mornye.tui import config_state
+
+ monkeypatch.setattr(
+ EL,
+ "lyrics_extensions",
+ lambda manager=None: [{"id": "ext:jellyfin", "name": "jellyfin", "label": "J"}],
+ )
+ choices = config_state.lyrics_provider_choices(["lrclib", "ext:gone"])
+ assert choices[: len(config_state.LYRICS_PROVIDERS)] == list(
+ config_state.LYRICS_PROVIDERS
+ )
+ assert choices[-2:] == ["ext:jellyfin", "ext:gone"]
+
+
+def test_the_web_frontend_can_list_them() -> None:
+ webapp = pytest.importorskip("mornye.webapp")
+ assert "get_lyrics_extensions" in webapp.ALLOWED_METHODS
From af7a99151538b8829608d6643f03b9ee65db2ef3 Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 13:35:38 +0100
Subject: [PATCH 24/39] Record Fase 0, J1 and J2 progress in the redesign plan
---
docs/redesign-plan.md | 27 +++++++++++++++++++++++----
1 file changed, 23 insertions(+), 4 deletions(-)
diff --git a/docs/redesign-plan.md b/docs/redesign-plan.md
index 4465cfa..1e7bbb3 100644
--- a/docs/redesign-plan.md
+++ b/docs/redesign-plan.md
@@ -32,12 +32,26 @@ pushati**, uno per voce, senza trailer di co-autore.
| F8 · strumenti | `2c6759a` | CI su 3.11–3.14 + macOS; hook `black` a 26.10.1 |
| `fetch()` sincrono per le estensioni | `c9d2d1d` | vedi §8 |
| Ricerca su sorgenti solo-ricerca | `2842669` | vedi §8 |
-| Jellyfin: lacune piccole | ultimo commit | vedi §9 |
+| Jellyfin: lacune piccole | `a2c6d4c` | vedi §9 |
+| Fase 0 · voce 3: contratto `id`/handler/classi | `6c9011f` | `tests/test_frontend_dom_contract.py` + inventario `tests/frontend_dom_contract.json`; unico orfano noto `hc-summary` |
+| Fase 0 · voce 2: snapshot TUI | `6ad44f5` | `tests/visual/capture_tui.py` → 48 SVG in `tests/visual/baseline/tui` (12 pannelli × 80×24/120×40 × scuro/chiaro), HOME temporanea, deterministico; `--compare` per il confronto |
+| J1 · rete locale per estensione | `f5abb18` | `extensions/local_network.py`, interruttore in Settings → Extensions, API admin `set_extension_local_network` |
+| J2 · testi dalle estensioni | `00b7ac3` | `core/extension_lyrics.py`, provider `ext:` in GUI/TUI/CLI |
### Non fatto — da qui si riparte
-1. **Fase 0** (rete di sicurezza visiva + test del contratto `id`/classi) —
- non iniziata. È il prossimo passo.
+1. **J3 (home feed)** — in corso, nessun codice scritto. Trovato: oltre a
+ Jellyfin, 8 estensioni installate hanno `getHomeFeed` (apple-music,
+ amazon, genie, bugs, melon, jiosaavn, ytmusic, spotify-web). Explore oggi
+ chiama solo `get_spotify_home_feed` (`app.py:896`) e `renderHomeSections`
+ (`app.js`) accetta già la stessa forma `{greeting, sections:[{title,
+ items:[{id, uri, type, name, artists, cover_url}]}]}`. Da fare: API
+ `get_extension_home_feed(name)` + elenco sorgenti, un selettore di
+ sorgente in Explore; il clic su una card passa `item.uri` a `onFetch()`
+ — verificare che `jellyfin:album:` venga gestito da `handleUrl`.
+2. **J4** (`skipMetadataEnrichment` / `skipBuiltInFallback`) — non iniziato.
+3. **Fase 0 voce 1** (screenshot GUI) — non iniziata. J1 e J2 toccano la GUI
+ e **non sono stati verificati nel browser**: farlo insieme.
2. **Fase 1** in poi — non iniziate. Nessun file di `mornye/frontend/` o
`mornye/tui/` è stato toccato per il look.
3. **Default della §2 ancora da confermare** dall'utente: segnale ambra,
@@ -47,7 +61,12 @@ pushati**, uno per voce, senza trailer di co-autore.
5. **F3, resto**: restano ~30 avvisi `ASYNC240`, quasi tutti un singolo
`exists()`/`getsize()` su disco locale. Lasciati di proposito: da rivedere
solo se emergono blocchi su percorsi di rete.
-6. **Jellyfin**: le quattro voci aperte della §9.
+6. **Note emerse:** a 80×24 il pannello History della TUI taglia il testo a
+ destra (si vede negli snapshot). `mypy mornye` nella venv locale 3.14
+ si ferma su uno stub di `numpy` (`python_version = "3.11"`); con
+ `--python-version 3.12` resta 1 errore preesistente in
+ `core/hires_check.py:469`. Suite completa dopo i commit Jellyfin:
+ 2209 passati, 357 saltati.
### Da sapere prima di toccare qualcosa
From 33c8b4a15aac6ca996a21c5263fb8692493cfb9c Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:13:41 +0100
Subject: [PATCH 25/39] Let Explore show the home feed of any extension that
has one
Explore gets a Feed picker: Spotify, as before, or any installed extension
declaring capabilities.homeFeed (Jellyfin, Apple Music, YouTube Music...).
Each item gets an open_url the URL box can open: the item's own web link,
the manifest's share template, or for a self-hosted source the configured
server plus its fragment link.
---
mornye/api_mixins/extension_health.py | 28 +++++
mornye/core/extension_home_feed.py | 157 ++++++++++++++++++++++++++
mornye/frontend/app.js | 43 ++++++-
mornye/frontend/index.html | 9 +-
mornye/frontend/web-shim.js | 2 +-
mornye/webapp.py | 4 +
tests/frontend_dom_contract.json | 1 +
tests/test_extension_home_feed.py | 145 ++++++++++++++++++++++++
8 files changed, 385 insertions(+), 4 deletions(-)
create mode 100644 mornye/core/extension_home_feed.py
create mode 100644 tests/test_extension_home_feed.py
diff --git a/mornye/api_mixins/extension_health.py b/mornye/api_mixins/extension_health.py
index 877e115..ec0fdd2 100644
--- a/mornye/api_mixins/extension_health.py
+++ b/mornye/api_mixins/extension_health.py
@@ -135,6 +135,34 @@ def get_lyrics_extensions(self) -> dict:
except Exception as e:
return {"extensions": [], "error": str(e)}
+ def get_home_feed_sources(self) -> dict:
+ """Where Explore can take its feed from: the built-in Spotify feed,
+ then every installed extension with ``capabilities.homeFeed``.
+
+ Shape: {"sources": [{"id": "spotify" | "ext:", "label"}, ...]}.
+ """
+ sources = [{"id": "spotify", "label": "Spotify"}]
+ try:
+ from ..core.extension_home_feed import home_feed_extensions
+
+ sources += [
+ {"id": row["id"], "label": row["label"]}
+ for row in home_feed_extensions()
+ ]
+ except Exception as e:
+ return {"sources": sources, "error": str(e)}
+ return {"sources": sources}
+
+ def get_extension_home_feed(self, source: str) -> dict:
+ """One extension's home feed, in the shape of get_spotify_home_feed,
+ each item with an ``open_url`` the URL box can open."""
+ try:
+ from ..core.extension_home_feed import fetch_home_feed
+
+ return fetch_home_feed(str(source or ""))
+ except Exception as e:
+ return {"success": False, "error": str(e)}
+
def _installed_extension_index(self) -> dict[str, dict]:
"""{"ext:": {"version": ..., "trust_tier": ...}} for what's installed.
diff --git a/mornye/core/extension_home_feed.py b/mornye/core/extension_home_feed.py
new file mode 100644
index 0000000..b9fcc05
--- /dev/null
+++ b/mornye/core/extension_home_feed.py
@@ -0,0 +1,157 @@
+"""Home feeds from extensions (``capabilities.homeFeed`` / ``getHomeFeed``).
+
+Explore only ever showed Spotify's feed. Nine installed extensions — Jellyfin
+("Recently added", "Most played", playlists from the user's own server),
+Apple Music, Amazon, YouTube Music and others — answer ``getHomeFeed()`` in
+the mobile runtime's shape::
+
+ {"success": true, "greeting": "Good evening",
+ "sections": [{"uri", "title",
+ "items": [{"id", "uri", "type", "name", "artists",
+ "description", "cover_url"}]}]}
+
+which is the shape Explore already draws. What the feed does not carry is a
+link the URL box can open — ``jellyfin:album:`` means nothing to it — so
+each item gets an ``open_url`` here (see ``_open_url``).
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Any
+from urllib.parse import urlparse
+
+from .extension_enrichment import _has_javascript, _installed_extensions, _provider
+
+logger = logging.getLogger(__name__)
+
+PREFIX = "ext:"
+
+
+def has_home_feed(ext: Any) -> bool:
+ caps = (getattr(ext, "manifest", None) or {}).get("capabilities")
+ return (
+ isinstance(caps, dict) and caps.get("homeFeed") is True and _has_javascript(ext)
+ )
+
+
+def home_feed_extensions(manager: Any = None) -> list[dict[str, str]]:
+ """``[{"id": "ext:jellyfin", "name": "jellyfin", "label": "Jellyfin"}]``."""
+ rows = [
+ {
+ "id": PREFIX + ext.name,
+ "name": ext.name,
+ "label": getattr(ext, "display_name", "") or ext.name,
+ }
+ for ext in _installed_extensions(manager)
+ if has_home_feed(ext)
+ ]
+ rows.sort(key=lambda row: row["label"].lower())
+ return rows
+
+
+def _http(value: Any) -> str:
+ if isinstance(value, dict): # Spotify-style {"spotify": "https://…"}
+ value = next(iter(value.values()), "")
+ text = str(value or "").strip()
+ return text if urlparse(text).scheme in ("http", "https") else ""
+
+
+def _server_base(manifest: dict, settings: dict) -> str:
+ """The first http(s) URL among the extension's non-secret text settings:
+ the server of a self-hosted source."""
+ for entry in manifest.get("settings") or []:
+ if not isinstance(entry, dict) or entry.get("type") != "string":
+ continue
+ if entry.get("secret"):
+ continue
+ url = _http(settings.get(entry.get("key")) or entry.get("default"))
+ if url:
+ return url.rstrip("/")
+ return ""
+
+
+def _open_url(item: dict, manifest: dict, settings: dict) -> str:
+ """A link the URL box can open, so a click lands where a pasted link would.
+
+ In order: a web link the item carries; the manifest's
+ ``shareUrlTemplates`` for the item's type; for a self-hosted source whose
+ ``urlHandler`` matches a fragment (Jellyfin's ``#/details?id=``), the
+ configured server plus that fragment; failing all of those the item's
+ ``uri`` (``spotify:album:…`` is understood as it is).
+ """
+ for key in ("external_url", "external_urls", "url", "share_url", "uri"):
+ url = _http(item.get(key))
+ if url:
+ return url
+ item_id = str(item.get("id") or "").strip()
+ kind = str(item.get("type") or "").strip()
+ caps = manifest.get("capabilities") or {}
+ templates = caps.get("shareUrlTemplates") if isinstance(caps, dict) else None
+ if item_id and isinstance(templates, dict) and isinstance(templates.get(kind), str):
+ return templates[kind].replace("{id}", item_id)
+ handler = manifest.get("urlHandler") or {}
+ patterns = handler.get("patterns") if isinstance(handler, dict) else None
+ fragment = next(
+ (p for p in patterns or [] if isinstance(p, str) and p.startswith("#")), ""
+ )
+ base = _server_base(manifest, settings) if fragment and item_id else ""
+ if base:
+ return f"{base}/web/{fragment}{item_id}"
+ return str(item.get("uri") or "")
+
+
+def normalise_feed(result: Any, manifest: dict, settings: dict) -> dict:
+ if not isinstance(result, dict):
+ return {"success": False, "error": "The extension returned no feed."}
+ if result.get("success") is False:
+ return {
+ "success": False,
+ "error": str(result.get("error") or "The extension has no feed."),
+ }
+ sections = []
+ for section in result.get("sections") or []:
+ if not isinstance(section, dict):
+ continue
+ items = []
+ for item in section.get("items") or []:
+ if not isinstance(item, dict) or not item.get("name"):
+ continue
+ items.append({**item, "open_url": _open_url(item, manifest, settings)})
+ if items:
+ sections.append({"title": str(section.get("title") or ""), "items": items})
+ if not sections:
+ return {"success": False, "error": "The feed is empty."}
+ return {
+ "success": True,
+ "greeting": str(result.get("greeting") or ""),
+ "sections": sections,
+ }
+
+
+def fetch_home_feed(
+ provider_id: str, *, manager: Any = None, provider_factory: Any = None
+) -> dict:
+ """One extension's feed, normalised; ``{"success": False, "error"}`` on
+ any failure, never an exception."""
+ name = provider_id[len(PREFIX) :] if provider_id.startswith(PREFIX) else provider_id
+ ext = next(
+ (e for e in _installed_extensions(manager) if e.name == name),
+ None,
+ )
+ if ext is None or not has_home_feed(ext):
+ return {"success": False, "error": f"'{name}' has no home feed."}
+ try:
+ if manager is None:
+ from ..extensions.manager import ExtensionManager
+
+ manager = ExtensionManager(auto_install_downloads=False)
+ settings = manager.load_settings(ext.name)
+ except Exception:
+ settings = {}
+ try:
+ result = _provider(ext.name, provider_factory)._call("getHomeFeed")
+ except Exception as exc:
+ logger.debug("[home/%s] getHomeFeed failed: %s", ext.name, exc)
+ return {"success": False, "error": str(exc)}
+ return normalise_feed(result, ext.manifest or {}, settings or {})
diff --git a/mornye/frontend/app.js b/mornye/frontend/app.js
index 0ed599c..cc3750c 100644
--- a/mornye/frontend/app.js
+++ b/mornye/frontend/app.js
@@ -6421,6 +6421,40 @@ async function doLogout() {
}
// --- EXPLORE LOGIC ---
+// Explore's feed comes from Spotify or from any installed extension with a
+// home feed (core/extension_home_feed.py) — Jellyfin's "Recently added" and
+// "Most played" from the user's own server among them. The choice is a
+// per-browser convenience, so localStorage is enough.
+const EXPLORE_SOURCE_KEY = 'mornye-explore-source';
+
+function savedExploreSource() {
+ try { return localStorage.getItem(EXPLORE_SOURCE_KEY) || 'spotify'; } catch (e) { return 'spotify'; }
+}
+
+function setExploreSource(value) {
+ try { localStorage.setItem(EXPLORE_SOURCE_KEY, value); } catch (e) { /* private mode */ }
+ loadExploreData();
+}
+
+/** Fills the source picker; returns the source to load (falls back to
+ * Spotify when the saved one is no longer installed). */
+async function refreshExploreSources() {
+ const select = $('explore-source');
+ let wanted = savedExploreSource();
+ const api = window.pywebview?.api;
+ if (!select || typeof api?.get_home_feed_sources !== 'function') return 'spotify';
+ try {
+ const sources = (await api.get_home_feed_sources())?.sources || [];
+ if (sources.length) {
+ select.innerHTML = sources.map((s) =>
+ `${escHtml(s.label)} `).join('');
+ }
+ } catch (e) { /* keep what is there */ }
+ if (![...select.options].some((o) => o.value === wanted)) wanted = 'spotify';
+ select.value = wanted;
+ return wanted;
+}
+
async function loadExploreData() {
const sectionsContainer = $('explore-sections');
const greetingEl = $('explore-greeting');
@@ -6428,9 +6462,12 @@ async function loadExploreData() {
if (!sectionsContainer) return;
sectionsContainer.innerHTML = 'Loading feed...
';
+ const source = await refreshExploreSources();
if (window.pywebview?.api?.get_spotify_home_feed) {
try {
- const homeData = await window.pywebview.api.get_spotify_home_feed();
+ const homeData = source === 'spotify' || !window.pywebview.api.get_extension_home_feed
+ ? await window.pywebview.api.get_spotify_home_feed()
+ : await window.pywebview.api.get_extension_home_feed(source);
if (homeData && homeData.success) {
if (greetingEl) greetingEl.textContent = homeData.greeting || 'Explore';
@@ -6493,7 +6530,9 @@ function renderHomeSections(sections) {
// Insert the URI
const input = $('urlInput');
if (input) {
- input.value = item.uri || `spotify:${item.type}:${item.id}`;
+ // open_url: a link the URL box can open, built by the backend for
+ // extension feeds (a Jellyfin item becomes /web/#/details?id=…).
+ input.value = item.open_url || item.uri || `spotify:${item.type}:${item.id}`;
// Scatena la ricerca
onFetch();
}
diff --git a/mornye/frontend/index.html b/mornye/frontend/index.html
index 58388fa..ffa12b8 100644
--- a/mornye/frontend/index.html
+++ b/mornye/frontend/index.html
@@ -396,7 +396,14 @@
-
Explore
+
+
Explore
+ Feed
+
+ Spotify
+
+
+
diff --git a/mornye/frontend/web-shim.js b/mornye/frontend/web-shim.js
index a861142..943a7d9 100644
--- a/mornye/frontend/web-shim.js
+++ b/mornye/frontend/web-shim.js
@@ -161,7 +161,7 @@
const REMOTE_METHODS = [
'get_version', 'get_latest_version', 'get_pypi_version', 'get_network_shares', 'get_staged_queue', 'add_to_staged_queue', 'remove_staged_items', 'clear_staged_queue', 'retry_staged_items', 'start_staged_items', 'get_download_history', 'remove_download_history', 'clear_download_history', 'clear_fetch_history', 'get_artist_images', 'get_ffmpeg_status', 'get_node_status',
'save_settings', 'save_theme', 'load_settings', 'get_registries', 'add_registry', 'remove_registry',
- 'get_history', 'get_profiles', 'load_profile_data', 'cache_image', 'get_spotify_home_feed',
+ 'get_history', 'get_profiles', 'load_profile_data', 'cache_image', 'get_home_feed_sources', 'get_extension_home_feed', 'get_spotify_home_feed',
'search_provider', 'search_provider_async', 'get_metadata_sources', 'remove_history_item',
'save_profile_data', 'delete_profile_data', 'check_animated_artwork_api',
'get_download_dir', 'open_config_folder', 'open_url', 'download_track_lyrics',
diff --git a/mornye/webapp.py b/mornye/webapp.py
index b85dfc8..1d5516a 100644
--- a/mornye/webapp.py
+++ b/mornye/webapp.py
@@ -140,6 +140,10 @@ def _token_matches(candidate: str | None, expected: str) -> bool:
"load_profile_data",
"cache_image",
"get_spotify_home_feed",
+ # Explore's other sources: extensions with a home feed
+ # (core/extension_home_feed.py). Read-only.
+ "get_home_feed_sources",
+ "get_extension_home_feed",
"search_provider",
"search_provider_async",
"get_metadata_sources",
diff --git a/tests/frontend_dom_contract.json b/tests/frontend_dom_contract.json
index 2bbf4e9..fa15fbc 100644
--- a/tests/frontend_dom_contract.json
+++ b/tests/frontend_dom_contract.json
@@ -117,6 +117,7 @@
"enrich-prov-wrap",
"explore-greeting",
"explore-sections",
+ "explore-source",
"ext-health-list",
"ext-quality-rows",
"ext-quality-section",
diff --git a/tests/test_extension_home_feed.py b/tests/test_extension_home_feed.py
new file mode 100644
index 0000000..f18a888
--- /dev/null
+++ b/tests/test_extension_home_feed.py
@@ -0,0 +1,145 @@
+"""Home feeds from extensions in Explore (Jellyfin, J3).
+
+Explore only showed Spotify. Extensions with ``capabilities.homeFeed`` answer
+``getHomeFeed()`` in the same shape; what they lack is a link the URL box
+can open, which ``open_url`` supplies.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from mornye.core import extension_home_feed as HF
+from mornye.core.extension_enrichment import close_extension_provider
+
+JELLYFIN = {
+ "capabilities": {"homeFeed": True},
+ "urlHandler": {"enabled": True, "patterns": ["#/details?id=", "#!/details?id="]},
+ "settings": [
+ {"key": "server_url", "type": "string"},
+ {"key": "password", "type": "string", "secret": True},
+ ],
+}
+APPLE = {
+ "capabilities": {
+ "homeFeed": True,
+ "shareUrlTemplates": {"album": "https://music.apple.com/us/album/{id}"},
+ }
+}
+
+
+def _ext(name, manifest):
+ return SimpleNamespace(
+ name=name, display_name=name.title(), manifest=manifest, runtime="javascript"
+ )
+
+
+def test_only_extensions_declaring_a_home_feed_are_sources() -> None:
+ manager = SimpleNamespace(
+ list_installed=lambda: [
+ _ext("jellyfin", JELLYFIN),
+ _ext("tidal-web", {"capabilities": {"relatedTracks": True}}),
+ _ext("soundcloud", {"capabilities": {"homeFeed": False}}),
+ ]
+ )
+ assert HF.home_feed_extensions(manager) == [
+ {"id": "ext:jellyfin", "name": "jellyfin", "label": "Jellyfin"}
+ ]
+
+
+@pytest.mark.parametrize(
+ ("item", "manifest", "settings", "expected"),
+ [
+ ( # a web link on the item wins
+ {"id": "1", "type": "album", "external_url": "https://x.test/a/1"},
+ APPLE,
+ {},
+ "https://x.test/a/1",
+ ),
+ ( # else the manifest's share template
+ {"id": "42", "type": "album", "uri": "apple:album:42"},
+ APPLE,
+ {},
+ "https://music.apple.com/us/album/42",
+ ),
+ ( # else, self-hosted: the configured server plus the fragment
+ {"id": "abc", "type": "album", "uri": "jellyfin:album:abc"},
+ JELLYFIN,
+ {"server_url": "http://192.168.1.20:8096/", "password": "http://no"},
+ "http://192.168.1.20:8096/web/#/details?id=abc",
+ ),
+ ( # else the uri as it is (spotify:… is understood)
+ {"id": "s", "type": "album", "uri": "spotify:album:s"},
+ {},
+ {},
+ "spotify:album:s",
+ ),
+ ],
+)
+def test_every_item_gets_a_link_the_url_box_can_open(
+ item, manifest, settings, expected
+) -> None:
+ assert HF._open_url(item, manifest, settings) == expected
+
+
+def test_the_jellyfin_link_is_one_its_url_handler_claims() -> None:
+ from mornye.extensions.url_match import url_pattern_matches
+
+ url = HF._open_url(
+ {"id": "abc", "type": "album"}, JELLYFIN, {"server_url": "http://nas:8096"}
+ )
+ assert url_pattern_matches("#/details?id=", url)
+
+
+def test_failures_and_empty_feeds_are_reported_not_raised() -> None:
+ assert HF.normalise_feed(None, {}, {})["success"] is False
+ failed = HF.normalise_feed({"success": False, "error": "login"}, {}, {})
+ assert failed == {"success": False, "error": "login"}
+ assert HF.normalise_feed({"sections": [{"items": []}]}, {}, {})["success"] is False
+
+
+def test_a_feed_is_fetched_and_normalised() -> None:
+ class Provider:
+ def _call(self, method, *args):
+ assert method == "getHomeFeed"
+ return {
+ "success": True,
+ "greeting": "Good evening",
+ "sections": [
+ {
+ "title": "Recently added",
+ "items": [
+ {"id": "abc", "type": "album", "name": "Album"},
+ {"id": "nameless"},
+ ],
+ }
+ ],
+ }
+
+ manager = SimpleNamespace(
+ list_installed=lambda: [_ext("jf-feed", JELLYFIN)],
+ load_settings=lambda name: {"server_url": "http://nas:8096"},
+ )
+ feed = HF.fetch_home_feed(
+ "ext:jf-feed", manager=manager, provider_factory=lambda name: Provider()
+ )
+ close_extension_provider("jf-feed")
+ assert feed["success"] is True and feed["greeting"] == "Good evening"
+ [section] = feed["sections"]
+ assert [i["name"] for i in section["items"]] == ["Album"]
+ assert section["items"][0]["open_url"] == "http://nas:8096/web/#/details?id=abc"
+
+
+def test_an_unknown_source_is_an_error() -> None:
+ manager = SimpleNamespace(list_installed=lambda: [])
+ assert HF.fetch_home_feed("ext:nope", manager=manager)["success"] is False
+
+
+def test_the_web_frontend_can_reach_both_calls() -> None:
+ webapp = pytest.importorskip("mornye.webapp")
+ assert {
+ "get_home_feed_sources",
+ "get_extension_home_feed",
+ } <= webapp.ALLOWED_METHODS
From e26ecc58c25e5420e669103475c687abc23913a3 Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:20:02 +0100
Subject: [PATCH 26/39] Keep extension setting fields wide enough to type in
A setting with a long description squeezed its text field to 25px, so
Jellyfin's Server URL could not be read or edited. The fields keep at
least 200px and the description wraps instead.
---
mornye/frontend/app.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/mornye/frontend/app.js b/mornye/frontend/app.js
index cc3750c..89cfce7 100644
--- a/mornye/frontend/app.js
+++ b/mornye/frontend/app.js
@@ -9172,7 +9172,7 @@ function extSettingFieldHtml(ext, entry, cardIdx) {
const isSet = (ext.secrets_set || []).includes(entry.key);
return `
${label}
+ placeholder="${isSet ? 'Saved — leave empty to keep it' : 'Not set'}" style="flex:1;min-width:200px;max-width:320px;">
`;
}
// Free text, optionally with suggestions (e.g. a storefront list).
@@ -9183,7 +9183,7 @@ function extSettingFieldHtml(ext, entry, cardIdx) {
const type = entry.type === 'number' ? 'number' : 'text';
return `
${label}
+ value="${regEscapeHtml(value ?? '')}" placeholder="${regEscapeHtml(entry.default ?? '')}" style="flex:1;min-width:200px;max-width:320px;">
${suggestions}
`;
}
From 641c0b1ffd8f1adc8eb4e8e869029c46330f19b6 Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:22:28 +0100
Subject: [PATCH 27/39] Open links only an extension can read, and honour its
skip flags
A link to a source with no built-in client, such as a Jellyfin server's
.../web/#/details?id=..., was handed to the Spotify client and could only
fail. It now goes to the extension whose urlHandler claims it, and its
tracks remember that extension. A track from an extension declaring
skipBuiltInFallback is downloaded by that extension alone, and one from an
extension declaring skipMetadataEnrichment keeps its own tags.
---
mornye/application/download_engine.py | 98 +++++++++++++++-
mornye/core/metadata_fallback.py | 14 ++-
tests/test_metadata_fallback.py | 3 +-
tests/test_source_extension_flags.py | 160 ++++++++++++++++++++++++++
4 files changed, 270 insertions(+), 5 deletions(-)
create mode 100644 tests/test_source_extension_flags.py
diff --git a/mornye/application/download_engine.py b/mornye/application/download_engine.py
index 1c1968f..ec67495 100644
--- a/mornye/application/download_engine.py
+++ b/mornye/application/download_engine.py
@@ -1651,6 +1651,78 @@ async def _replace_fake_hires_async(
return retry
+def _installed_manifest(ext_name: str) -> dict:
+ try:
+ from ..extensions.manager import ExtensionManager
+
+ ext = ExtensionManager(auto_install_downloads=False).get_installed(ext_name)
+ except Exception as exc:
+ logger.debug("[downloader] could not read extension %s: %s", ext_name, exc)
+ return {}
+ manifest = getattr(ext, "manifest", None) if ext is not None else None
+ return manifest if isinstance(manifest, dict) else {}
+
+
+def _own_source_extension_for(url: str) -> str | None:
+ """The extension that reads a link no built-in client knows.
+
+ Only for links outside Spotify, Apple Music and Tidal — those have
+ clients of their own and merge with their extension elsewhere
+ (core/metadata_fallback.get_url_with_fallback).
+ """
+ from ..core.metadata_fallback import fallback_extension_for, native_service_for
+
+ if native_service_for(url) is not None:
+ return None
+ if url_host_matches(
+ url,
+ "soundcloud.com",
+ "youtube.com",
+ "youtu.be",
+ "pandora.com",
+ "pandora.app.link",
+ ):
+ return None
+ try:
+ return fallback_extension_for(url)
+ except Exception as exc:
+ logger.debug("[downloader] no extension for %s: %s", url, exc)
+ return None
+
+
+def _honour_source_extension(
+ metadata: TrackMetadata,
+ providers: list[BaseProvider],
+ opts: DownloadOptions,
+) -> tuple[list[BaseProvider], DownloadOptions]:
+ """What the manifest of the extension a track came from asks of its download.
+
+ ``skipBuiltInFallback``: the track is downloaded by that extension alone,
+ never looked up on the other services — a song from the user's own
+ Jellyfin library must come from that library, and when the server is
+ down the right answer is a failure, not a near match from Tidal.
+ ``skipMetadataEnrichment``: its tags are the source's own and are not
+ "improved" from Deezer, Apple or Qobuz.
+ """
+ source = str((metadata.extra_info or {}).get("source_extension") or "").strip()
+ if not source:
+ return providers, opts
+ manifest = _installed_manifest(source)
+ if manifest.get("skipMetadataEnrichment") is True and opts.enrich_metadata:
+ opts = replace(opts, enrich_metadata=False)
+ if manifest.get("skipBuiltInFallback") is True:
+ own_name = f"ext:{source}"
+ own = [p for p in providers if getattr(p, "name", "") == own_name]
+ if not own:
+ try:
+ own = list(_build_providers_for_name(own_name, opts))
+ except Exception as exc:
+ logger.debug("[downloader] could not build %s: %s", own_name, exc)
+ own = []
+ providers = own
+ return providers, opts
+
+
async def download_one_async(
metadata: TrackMetadata,
output_dir: str,
@@ -1668,6 +1740,8 @@ async def download_one_async(
-download hooks, the playlist writer and the run summary all see) is
the file the user actually ends up with.
"""
+ providers, opts = _honour_source_extension(metadata, providers, opts)
+
# Taken before any provider runs, so the replacement pass can be given
# the request rather than the rejected download's version of it.
snapshot = metadata.model_copy(deep=True)
@@ -2704,10 +2778,28 @@ async def _resolve_metadata_async(
)
catalogue = parse_catalogue_url(url)
+ own_source = None if catalogue else _own_source_extension_for(url)
+ own_meta: dict = {}
try:
client: Any
- if catalogue:
+ if own_source:
+ from ..core.metadata_fallback import ExtensionUrlClient
+
+ # A link to a source no built-in client reads — a Jellyfin
+ # server's `…/web/#/details?id=…` — that an installed
+ # extension's urlHandler claims. Asking the Spotify client
+ # first, as the last branch does, could only fail.
+ (
+ collection_name,
+ tracks,
+ *own_rest,
+ ) = await ExtensionUrlClient(
+ own_source, own_source
+ ).get_url_async(url)
+ if len(own_rest) > 1 and isinstance(own_rest[1], dict):
+ own_meta = own_rest[1]
+ elif catalogue:
client = ExtensionMetadataClient.for_url(url)
(
collection_name,
@@ -2796,7 +2888,9 @@ async def _resolve_metadata_async(
if not tracks:
return collection_name, [], {}
- if catalogue:
+ if own_source:
+ info = {"type": str(own_meta.get("link_type") or "track"), "id": url}
+ elif catalogue:
info = {"type": catalogue.kind, "id": catalogue.item_id}
elif is_tidal:
info = parse_tidal_url(url)
diff --git a/mornye/core/metadata_fallback.py b/mornye/core/metadata_fallback.py
index 65ba8b8..4ba60c9 100644
--- a/mornye/core/metadata_fallback.py
+++ b/mornye/core/metadata_fallback.py
@@ -103,7 +103,9 @@ def track_from_extension(
if not raw_id or not title:
return None
- prefix = _ID_PREFIX[service]
+ # A source with no built-in client (Jellyfin, a self-hosted server) keeps
+ # the extension's own ids: there is no other client for them to match.
+ prefix = _ID_PREFIX.get(service, "")
collection = collection or {}
artists = _text(item.get("artists") or item.get("artist"))
album = _text(item.get("album_name"))
@@ -211,7 +213,15 @@ async def get_url_async(self, url: str, include_featuring: bool = True, **_: Any
f"The '{self.ext_name}' extension found no tracks behind this {kind or 'link'}.",
provider=f"ext:{self.ext_name}",
)
- return name, tracks, cover, {}
+ # Which extension the tracks came from, so the download can honour
+ # what its manifest asks (skipBuiltInFallback,
+ # skipMetadataEnrichment — application/download_engine.py).
+ for track in tracks:
+ track.extra_info = {
+ **(track.extra_info or {}),
+ "source_extension": self.ext_name,
+ }
+ return name, tracks, cover, {"link_type": kind or "track"}
class MergedSpotifySearch:
diff --git a/tests/test_metadata_fallback.py b/tests/test_metadata_fallback.py
index 4992330..fb493c3 100644
--- a/tests/test_metadata_fallback.py
+++ b/tests/test_metadata_fallback.py
@@ -187,7 +187,8 @@ def test_a_failing_spotify_client_hands_over_to_spotify_web(native) -> None:
assert provider.calls == [("handleUrl", (SPOTIFY_ALBUM,))]
assert provider.closed
- assert (name, cover, meta) == ("Palette", "https://i.scdn.co/image/cover", {})
+ assert (name, cover) == ("Palette", "https://i.scdn.co/image/cover")
+ assert meta == {"link_type": "album"}
[track] = tracks
# The id the built-in client gives, so downstream sees the same track.
assert track.id == "4NPARrLIbtMl29ZJv8ESr2"
diff --git a/tests/test_source_extension_flags.py b/tests/test_source_extension_flags.py
new file mode 100644
index 0000000..867322d
--- /dev/null
+++ b/tests/test_source_extension_flags.py
@@ -0,0 +1,160 @@
+"""Links only an extension can read, and what its manifest asks (Jellyfin, J4).
+
+A Jellyfin link (``http://nas:8096/web/#/details?id=…``) was handed to the
+Spotify client, which could only fail. It now goes to the extension whose
+urlHandler claims it, and its tracks remember that extension, so the
+download honours ``skipBuiltInFallback`` (only that extension downloads the
+track) and ``skipMetadataEnrichment`` (no tags from other services).
+"""
+
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+
+import pytest
+
+from mornye.application import download_engine as DE
+from mornye.core import metadata_fallback as MF
+from mornye.core.models import TrackMetadata
+
+JELLYFIN_URL = "http://nas:8096/web/#/details?id=0123456789abcdef0123456789abcdef"
+
+
+def _track(**extra) -> TrackMetadata:
+ return TrackMetadata(
+ id="abc",
+ title="Song",
+ artists="Artist",
+ album="Album",
+ album_artist="Artist",
+ extra_info=extra,
+ )
+
+
+def test_extension_tracks_remember_their_source_and_keep_their_ids() -> None:
+ class Provider:
+ def _call(self, method, *args):
+ assert method == "handleUrl"
+ return {
+ "success": True,
+ "type": "album",
+ "album": {
+ "name": "Album",
+ "tracks": [{"id": "0123abcd", "name": "Song", "artists": "A"}],
+ },
+ }
+
+ def close(self):
+ pass
+
+ name, tracks, _cover, meta = asyncio.run(
+ MF.ExtensionUrlClient(
+ "jellyfin", "jellyfin", provider_factory=lambda n: Provider()
+ ).get_url_async(JELLYFIN_URL)
+ )
+ assert name == "Album" and meta == {"link_type": "album"}
+ [track] = tracks
+ assert track.id == "0123abcd"
+ assert track.extra_info["source_extension"] == "jellyfin"
+
+
+def test_only_links_without_a_built_in_client_go_to_their_extension(
+ monkeypatch,
+) -> None:
+ monkeypatch.setattr(MF, "fallback_extension_for", lambda url, m=None: "jellyfin")
+ assert DE._own_source_extension_for(JELLYFIN_URL) == "jellyfin"
+ assert DE._own_source_extension_for("https://open.spotify.com/album/x") is None
+ assert DE._own_source_extension_for("https://soundcloud.com/a/b") is None
+
+
+class _P:
+ def __init__(self, name):
+ self.name = name
+
+
+@pytest.fixture
+def manifest(monkeypatch):
+ holder: dict = {}
+ monkeypatch.setattr(DE, "_installed_manifest", lambda name: holder)
+ return holder
+
+
+def test_a_track_with_no_source_is_untouched(manifest) -> None:
+ manifest.update(skipBuiltInFallback=True, skipMetadataEnrichment=True)
+ providers = [_P("ext:tidal-web")]
+ opts = DE.DownloadOptions(output_dir="/tmp/out")
+ assert DE._honour_source_extension(_track(), providers, opts) == (providers, opts)
+
+
+def test_skip_built_in_fallback_keeps_only_the_source(manifest) -> None:
+ manifest.update(skipBuiltInFallback=True)
+ providers = [_P("ext:tidal-web"), _P("ext:jellyfin"), _P("ext:qobuz-web")]
+ kept, opts = DE._honour_source_extension(
+ _track(source_extension="jellyfin"),
+ providers,
+ DE.DownloadOptions(output_dir="/tmp/out"),
+ )
+ assert [p.name for p in kept] == ["ext:jellyfin"]
+ assert opts.enrich_metadata is True
+
+
+def test_the_source_is_built_when_it_is_not_among_the_services(
+ manifest, monkeypatch
+) -> None:
+ manifest.update(skipBuiltInFallback=True)
+ asked: list[str] = []
+
+ def build(name, opts):
+ asked.append(name)
+ return [_P(name)]
+
+ monkeypatch.setattr(DE, "_build_providers_for_name", build)
+ kept, _ = DE._honour_source_extension(
+ _track(source_extension="jellyfin"),
+ [_P("ext:tidal-web")],
+ DE.DownloadOptions(output_dir="/tmp/out"),
+ )
+ assert asked == ["ext:jellyfin"] and [p.name for p in kept] == ["ext:jellyfin"]
+
+
+def test_without_the_flag_the_other_services_stay(manifest) -> None:
+ providers = [_P("ext:tidal-web")]
+ kept, _ = DE._honour_source_extension(
+ _track(source_extension="jellyfin"),
+ providers,
+ DE.DownloadOptions(output_dir="/tmp/out"),
+ )
+ assert kept == providers
+
+
+def test_skip_metadata_enrichment_turns_enrichment_off(manifest) -> None:
+ manifest.update(skipMetadataEnrichment=True)
+ opts = DE.DownloadOptions(output_dir="/tmp/out")
+ assert opts.enrich_metadata is True
+ _, changed = DE._honour_source_extension(
+ _track(source_extension="jellyfin"), [], opts
+ )
+ assert changed.enrich_metadata is False
+ assert opts.enrich_metadata is True # the caller's options are not mutated
+
+
+def test_every_download_goes_through_it(manifest, monkeypatch) -> None:
+ manifest.update(skipBuiltInFallback=True, skipMetadataEnrichment=True)
+ seen: dict = {}
+
+ async def one_pass(metadata, output_dir, providers, opts, position, is_album):
+ seen["providers"] = [p.name for p in providers]
+ seen["enrich"] = opts.enrich_metadata
+ return SimpleNamespace(success=False, skipped=False, file_path="")
+
+ monkeypatch.setattr(DE, "_download_one_pass_async", one_pass)
+ asyncio.run(
+ DE.download_one_async(
+ _track(source_extension="jellyfin"),
+ "/tmp/out",
+ [_P("ext:tidal-web"), _P("ext:jellyfin")],
+ DE.DownloadOptions(output_dir="/tmp/out"),
+ )
+ )
+ assert seen == {"providers": ["ext:jellyfin"], "enrich": False}
From f69b2312e77f0c50c168e822b228ab7d3ee10e8b Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:26:38 +0100
Subject: [PATCH 28/39] Wrap word-synced lyrics whose timed pieces hold several
words
A line timed as one piece ('<00:51.00>I don't know how ... stay') drew
its lit copy on a single row: it ran off the right edge ('...you to
trea') while the dim copy underneath wrapped and stayed grey. Every
piece is now split into words, its time shared out by length, and a
piece with no end of its own lasts about a third of a second a word,
never past the next line.
---
mornye/frontend/app.js | 53 +++++++++++++++++++++++++++++++++++++++---
1 file changed, 50 insertions(+), 3 deletions(-)
diff --git a/mornye/frontend/app.js b/mornye/frontend/app.js
index 89cfce7..050def0 100644
--- a/mornye/frontend/app.js
+++ b/mornye/frontend/app.js
@@ -7949,7 +7949,12 @@ function parseLyrics(text) {
words.push(prev);
run = [];
}
- if (prev && prev.e == null) prev.e = run.length ? run[0] : prev.s + 0.6;
+ if (prev && prev.e == null) {
+ if (run.length) prev.e = run[0];
+ // No closing stamp: a guess, which fillNowPlaying() refines once
+ // it knows when the next line starts.
+ else { prev.e = prev.s + 0.6; prev.open = true; }
+ }
if (!words.length) words = null;
}
const lineText = words ? words.map(w => w.text).join('').trim() : stripWordTags(body);
@@ -7972,6 +7977,32 @@ function parseLyrics(text) {
return { lines: plain, synced: false, wordSynced: false };
}
+// A timed piece can hold several words: a provider that times phrases, or a
+// line with a single stamp (`[00:51.00]<00:51.00>I don't know how … stay`).
+// The lit copy of a piece is laid over it on one row and cannot wrap with
+// it, so a phrase longer than the row ran off the right edge ("…you to trea")
+// while the dim copy underneath wrapped and stayed grey. Each word becomes a
+// piece of its own, the phrase's time shared out by length, so the lit copy
+// wraps word by word with the text it covers.
+function npSplitTimedPhrases(words) {
+ const out = [];
+ for (const w of words) {
+ const tokens = w.text.match(/\S+\s*/g);
+ if (!tokens || tokens.length < 2) { out.push(w); continue; }
+ const lead = w.text.match(/^\s*/)[0];
+ const total = tokens.reduce((n, t) => n + t.trim().length, 0) || 1;
+ const span = Math.max(0, (w.e ?? w.s) - w.s);
+ let at = w.s;
+ tokens.forEach((t, k) => {
+ const share = span * t.trim().length / total;
+ const last = k === tokens.length - 1;
+ out.push({ s: at, e: last ? w.e : at + share, text: (k === 0 ? lead : '') + t });
+ at += share;
+ });
+ }
+ return out;
+}
+
function npTechLabel(res) {
const i = res.info || {};
const parts = [(i.format || '').toUpperCase()];
@@ -8038,6 +8069,18 @@ function fillNowPlaying(item, res) {
// A line ends when its last word does, else when the next one starts.
npLines.forEach((l, i) => {
const next = npLines[i + 1];
+ if (l.words) {
+ // A last piece with no end of its own — one stamp for a whole line,
+ // say — lasts about a third of a second a word, never past the
+ // next line, before it is shared out word by word.
+ const last = l.words[l.words.length - 1];
+ if (last.open) {
+ const count = (last.text.match(/\S+/g) || []).length;
+ const until = next && next.t != null && next.t > last.s ? next.t : Infinity;
+ last.e = Math.min(until, last.s + Math.max(0.6, 0.32 * count));
+ }
+ l.words = npSplitTimedPhrases(l.words);
+ }
l.end = l.words ? l.words[l.words.length - 1].e : (next && next.t != null ? next.t : (l.t ?? 0) + 4);
if (l.words && next && next.t != null && l.end > next.t + 8) l.end = next.t;
});
@@ -8047,8 +8090,12 @@ function fillNowPlaying(item, res) {
}
const body = l.words
? l.words.map(w => {
- const core = w.text.replace(/\s+$/, '');
- return `
${escHtml(core)} ${escHtml(w.text.slice(core.length))}`;
+ // Spaces stay outside the word: inside, they would be part of
+ // what the lit copy draws and of the box the line wraps around.
+ const lead = w.text.match(/^\s*/)[0];
+ const core = w.text.trim();
+ const tail = w.text.slice(lead.length + core.length);
+ return `${escHtml(lead)}
${escHtml(core)} ${escHtml(tail)}`;
}).join('')
: escHtml(l.text);
const extra = (l.rom ? `
${escHtml(l.rom)}
` : '')
From 0ceeb2bf245bef7c686a4564a3c1b2585dda8c9e Mon Sep 17 00:00:00 2001
From: Bartolo <162994065+BartolomeoRusso9@users.noreply.github.com>
Date: Thu, 12 Nov 2026 14:27:57 +0100
Subject: [PATCH 29/39] Screenshot every GUI view as the pre-redesign baseline
tests/visual/capture_gui.py starts mornye --web on an empty throw-away
data folder and saves the ten views, dark and light, at desktop, 768 and
375 wide, through Playwright and the installed Chrome. The URL box's
typing animation is frozen, and --compare ignores anti-aliasing noise.
Playwright is an optional 'visual' extra.
---
pyproject.toml | 5 +
.../visual/baseline/gui/explore_375_dark.png | Bin 0 -> 43514 bytes
.../visual/baseline/gui/explore_375_light.png | Bin 0 -> 46716 bytes
.../visual/baseline/gui/explore_768_dark.png | Bin 0 -> 104268 bytes
.../visual/baseline/gui/explore_768_light.png | Bin 0 -> 103873 bytes
.../baseline/gui/explore_desktop_dark.png | Bin 0 -> 106321 bytes
.../baseline/gui/explore_desktop_light.png | Bin 0 -> 106636 bytes
.../baseline/gui/following_375_dark.png | Bin 0 -> 80727 bytes
.../baseline/gui/following_375_light.png | Bin 0 -> 82362 bytes
.../baseline/gui/following_768_dark.png | Bin 0 -> 154103 bytes
.../baseline/gui/following_768_light.png | Bin 0 -> 153560 bytes
.../baseline/gui/following_desktop_dark.png | Bin 0 -> 136505 bytes
.../baseline/gui/following_desktop_light.png | Bin 0 -> 138807 bytes
.../visual/baseline/gui/history_375_dark.png | Bin 0 -> 62949 bytes
.../visual/baseline/gui/history_375_light.png | Bin 0 -> 66316 bytes
.../visual/baseline/gui/history_768_dark.png | Bin 0 -> 119218 bytes
.../visual/baseline/gui/history_768_light.png | Bin 0 -> 117014 bytes
.../baseline/gui/history_desktop_dark.png | Bin 0 -> 120688 bytes
.../baseline/gui/history_desktop_light.png | Bin 0 -> 119527 bytes
tests/visual/baseline/gui/home_375_dark.png | Bin 0 -> 80357 bytes
tests/visual/baseline/gui/home_375_light.png | Bin 0 -> 82995 bytes
tests/visual/baseline/gui/home_768_dark.png | Bin 0 -> 131581 bytes
tests/visual/baseline/gui/home_768_light.png | Bin 0 -> 129360 bytes
.../visual/baseline/gui/home_desktop_dark.png | Bin 0 -> 137244 bytes
.../baseline/gui/home_desktop_light.png | Bin 0 -> 136456 bytes
tests/visual/baseline/gui/local_375_dark.png | Bin 0 -> 80051 bytes
tests/visual/baseline/gui/local_375_light.png | Bin 0 -> 81852 bytes
tests/visual/baseline/gui/local_768_dark.png | Bin 0 -> 157973 bytes
tests/visual/baseline/gui/local_768_light.png | Bin 0 -> 155217 bytes
.../baseline/gui/local_desktop_dark.png | Bin 0 -> 151960 bytes
.../baseline/gui/local_desktop_light.png | Bin 0 -> 149227 bytes
tests/visual/baseline/gui/logs_375_dark.png | Bin 0 -> 22770 bytes
tests/visual/baseline/gui/logs_375_light.png | Bin 0 -> 23142 bytes
tests/visual/baseline/gui/logs_768_dark.png | Bin 0 -> 47364 bytes
tests/visual/baseline/gui/logs_768_light.png | Bin 0 -> 47262 bytes
.../visual/baseline/gui/logs_desktop_dark.png | Bin 0 -> 48348 bytes
.../baseline/gui/logs_desktop_light.png | Bin 0 -> 48224 bytes
tests/visual/baseline/gui/player_375_dark.png | Bin 0 -> 46693 bytes
.../visual/baseline/gui/player_375_light.png | Bin 0 -> 48928 bytes
tests/visual/baseline/gui/player_768_dark.png | Bin 0 -> 105052 bytes
.../visual/baseline/gui/player_768_light.png | Bin 0 -> 102326 bytes
.../baseline/gui/player_desktop_dark.png | Bin 0 -> 107584 bytes
.../baseline/gui/player_desktop_light.png | Bin 0 -> 105456 bytes
tests/visual/baseline/gui/queue_375_dark.png | Bin 0 -> 79617 bytes
tests/visual/baseline/gui/queue_375_light.png | Bin 0 -> 81396 bytes
tests/visual/baseline/gui/queue_768_dark.png | Bin 0 -> 132310 bytes
tests/visual/baseline/gui/queue_768_light.png | Bin 0 -> 135025 bytes
.../baseline/gui/queue_desktop_dark.png | Bin 0 -> 129938 bytes
.../baseline/gui/queue_desktop_light.png | Bin 0 -> 134003 bytes
.../visual/baseline/gui/settings_375_dark.png | Bin 0 -> 67012 bytes
.../baseline/gui/settings_375_light.png | Bin 0 -> 68584 bytes
.../visual/baseline/gui/settings_768_dark.png | Bin 0 -> 151368 bytes
.../baseline/gui/settings_768_light.png | Bin 0 -> 152846 bytes
.../baseline/gui/settings_desktop_dark.png | Bin 0 -> 142233 bytes
.../baseline/gui/settings_desktop_light.png | Bin 0 -> 145485 bytes
tests/visual/baseline/gui/stats_375_dark.png | Bin 0 -> 63288 bytes
tests/visual/baseline/gui/stats_375_light.png | Bin 0 -> 66077 bytes
tests/visual/baseline/gui/stats_768_dark.png | Bin 0 -> 122681 bytes
tests/visual/baseline/gui/stats_768_light.png | Bin 0 -> 119846 bytes
.../baseline/gui/stats_desktop_dark.png | Bin 0 -> 123297 bytes
.../baseline/gui/stats_desktop_light.png | Bin 0 -> 123246 bytes
tests/visual/capture_gui.py | 205 ++++++++++++++++++
62 files changed, 210 insertions(+)
create mode 100644 tests/visual/baseline/gui/explore_375_dark.png
create mode 100644 tests/visual/baseline/gui/explore_375_light.png
create mode 100644 tests/visual/baseline/gui/explore_768_dark.png
create mode 100644 tests/visual/baseline/gui/explore_768_light.png
create mode 100644 tests/visual/baseline/gui/explore_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/explore_desktop_light.png
create mode 100644 tests/visual/baseline/gui/following_375_dark.png
create mode 100644 tests/visual/baseline/gui/following_375_light.png
create mode 100644 tests/visual/baseline/gui/following_768_dark.png
create mode 100644 tests/visual/baseline/gui/following_768_light.png
create mode 100644 tests/visual/baseline/gui/following_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/following_desktop_light.png
create mode 100644 tests/visual/baseline/gui/history_375_dark.png
create mode 100644 tests/visual/baseline/gui/history_375_light.png
create mode 100644 tests/visual/baseline/gui/history_768_dark.png
create mode 100644 tests/visual/baseline/gui/history_768_light.png
create mode 100644 tests/visual/baseline/gui/history_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/history_desktop_light.png
create mode 100644 tests/visual/baseline/gui/home_375_dark.png
create mode 100644 tests/visual/baseline/gui/home_375_light.png
create mode 100644 tests/visual/baseline/gui/home_768_dark.png
create mode 100644 tests/visual/baseline/gui/home_768_light.png
create mode 100644 tests/visual/baseline/gui/home_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/home_desktop_light.png
create mode 100644 tests/visual/baseline/gui/local_375_dark.png
create mode 100644 tests/visual/baseline/gui/local_375_light.png
create mode 100644 tests/visual/baseline/gui/local_768_dark.png
create mode 100644 tests/visual/baseline/gui/local_768_light.png
create mode 100644 tests/visual/baseline/gui/local_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/local_desktop_light.png
create mode 100644 tests/visual/baseline/gui/logs_375_dark.png
create mode 100644 tests/visual/baseline/gui/logs_375_light.png
create mode 100644 tests/visual/baseline/gui/logs_768_dark.png
create mode 100644 tests/visual/baseline/gui/logs_768_light.png
create mode 100644 tests/visual/baseline/gui/logs_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/logs_desktop_light.png
create mode 100644 tests/visual/baseline/gui/player_375_dark.png
create mode 100644 tests/visual/baseline/gui/player_375_light.png
create mode 100644 tests/visual/baseline/gui/player_768_dark.png
create mode 100644 tests/visual/baseline/gui/player_768_light.png
create mode 100644 tests/visual/baseline/gui/player_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/player_desktop_light.png
create mode 100644 tests/visual/baseline/gui/queue_375_dark.png
create mode 100644 tests/visual/baseline/gui/queue_375_light.png
create mode 100644 tests/visual/baseline/gui/queue_768_dark.png
create mode 100644 tests/visual/baseline/gui/queue_768_light.png
create mode 100644 tests/visual/baseline/gui/queue_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/queue_desktop_light.png
create mode 100644 tests/visual/baseline/gui/settings_375_dark.png
create mode 100644 tests/visual/baseline/gui/settings_375_light.png
create mode 100644 tests/visual/baseline/gui/settings_768_dark.png
create mode 100644 tests/visual/baseline/gui/settings_768_light.png
create mode 100644 tests/visual/baseline/gui/settings_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/settings_desktop_light.png
create mode 100644 tests/visual/baseline/gui/stats_375_dark.png
create mode 100644 tests/visual/baseline/gui/stats_375_light.png
create mode 100644 tests/visual/baseline/gui/stats_768_dark.png
create mode 100644 tests/visual/baseline/gui/stats_768_light.png
create mode 100644 tests/visual/baseline/gui/stats_desktop_dark.png
create mode 100644 tests/visual/baseline/gui/stats_desktop_light.png
create mode 100644 tests/visual/capture_gui.py
diff --git a/pyproject.toml b/pyproject.toml
index 552473c..0d1b378 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -124,6 +124,11 @@ dev = [
"mypy>=1.10",
"pre-commit>=3.0",
]
+# tests/visual/capture_gui.py: GUI screenshots for the redesign, driven
+# through the Google Chrome already installed (no browser download).
+visual = [
+ "playwright>=1.48",
+]
# `web`, `hires` and `dedup` used to live here. Everything they installed is
# part of the base install now, and pip treats a request for an extra that no
# longer exists as a warning rather than an error, so an old
diff --git a/tests/visual/baseline/gui/explore_375_dark.png b/tests/visual/baseline/gui/explore_375_dark.png
new file mode 100644
index 0000000000000000000000000000000000000000..98a653355bf8fd3d89a7d9faf81afa1a19397f72
GIT binary patch
literal 43514
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