diff --git a/README-sendspin.md b/README-sendspin.md new file mode 100644 index 000000000..b82ed4c41 --- /dev/null +++ b/README-sendspin.md @@ -0,0 +1,188 @@ +# SendSpin Multi-Room Audio for moOde + +SendSpin is a synchronized multi-room audio receiver. This integration adds SendSpin as a full renderer in moOde's web UI, on par with AirPlay, Spotify, Bluetooth, and other existing renderers. + +## Features + +- **ON/OFF toggle** with auto-save in the Renderers page +- **Resume MPD** — optionally resume MPD playback after SendSpin disconnects +- **Config page** — configure audio format (codec/sample rate/bit depth), log level +- **Version info** — displays installed version and latest available on PyPI (cached hourly) +- **Update button** — upgrades SendSpin CLI in the background +- **Metadata overlay** — shows cover art, title, artist, album using moOde's built-in `#inpsrc-indicator` (same element used by AirPlay/Spotify) +- **Native volume** — uses moOde's `_audioout` ALSA device (same as AirPlay, Spotify, MPD); volume knob works natively +- **Auto-start on boot** — honors the Renderers-page toggle (worker.php boot-time startup block; systemd service enabled) +- **Status detection** — shows active/inactive/streaming + +## Requirements + +- moOde 9.x or later — verified against the current release, moOde 10.3.2 (r1032, Trixie), and the r1033 development tree; the fork is re-merged with every new upstream release +- Raspberry Pi 3/4/5 +- Network connection to a SendSpin server (e.g., Music Assistant) +- Home Assistant (optional — for metadata display via HA polling) + +The installer automatically installs Python 3, `uv` (Python package manager), and the `sendspin` CLI — no manual prerequisite installation is needed. + +## Key Design Decisions + +1. **No custom overlay HTML/CSS** — The metadata display uses moOde's built-in `#inpsrc-indicator` element (already in `header.php`), matching AirPlay/Spotify/Deezer display pattern exactly. Zero additional HTML/CSS footprint. +2. **Uses moOde's `_audioout` device** — Same ALSA path as AirPlay, Spotify, MPD. No separate ALSA config needed. Volume knob works natively without attenuation hacks. +3. **Minimal playerlib.js change** — the installer adds only a `FEAT_SENDSPIN` feature-flag constant to `playerlib.js` (falling back to `lib.min.js` on older moOde). The renderer switch and FECmd path are untouched — `sendspinactive` FECmd is unused; the frontend JS polls the metadata API directly instead. +4. **Stop/start matches other renderers** — Calls `vol.sh -restore`, CamillaDSP volume sync, and `sendFECmd('sspactive0')` on stop, exactly like AirPlay/Spotify/RoonBridge. + +## Installer + +**`moode-sendspin-installer.sh`** — Full-featured installer with backup, uninstall, 19-component detection, and all features. **Current version: v4.1.4** (moOde 10.3.2 / r1033 support; idempotent re-runs — safe to run repeatedly, no duplicate DB rows; partial installations detected and repaired automatically; boot-time auto-start honors the UI toggle). + +### Installation + +```bash +git clone https://github.com/kiwipaulrob/moode.git +cd moode +git checkout sendspin-advanced +sudo bash moode-sendspin-installer.sh +``` + +The installer auto-detects the PHP version and automatically installs Python 3, `uv`, and the `sendspin` CLI if not already present. It then creates all necessary files, configures the database, enables systemd services, and creates a timestamped backup of all modified files. + +### Install from URL + +```bash +curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash +``` + +### Command Line Options + +| Option | Description | +|--------|-------------| +| *(no flag)* | Full install — all features, config page, metadata overlay | +| `--minimal` | Minimal install — ON/OFF toggle + Resume MPD only (no config page) | +| `--check` | Check current installation status of all components (flags partial installs) | +| `--uninstall` | Uninstall — restores original moOde files from the most recent backup | +| `--force` | Skip all confirmation prompts (for automated/scripted installs) | +| `--no-backup` | Skip creating backup (for testing) | + +Examples: + +```bash +# Minimal install +curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash -s -- --minimal + +# Check status +sudo bash moode-sendspin-installer.sh --check + +# Uninstall +sudo bash moode-sendspin-installer.sh --uninstall +``` + +### Running from moOde's Built-in SSH Terminal + +moOde has a built-in SSH terminal (System → SSH Terminal). You can run the installer directly from there: + +1. Open moOde web UI → System → SSH Terminal +2. Paste the commands above +3. Enter your password when prompted + +## Backup System + +Before modifying any moOde file, the installer creates a **timestamped backup** at `/var/backups/moode-sendspin-YYYYMMDD-HHMMSS/`. Files backed up include: + +- `moode-sqlite3.db` — Database snapshot before schema changes +- `sendspin.service` — Original systemd unit +- `constants.php`, `renderer.php` — Original PHP files +- `ren-config.php`, `ren-config.html` — Original renderers page +- `worker.php` — Original worker daemon +- `lib.min.js` — Original JS library + +The `--uninstall` command finds the **most recent** backup and restores all files, making uninstallation safe and reversible. + +### Related Backup Utilities + +- [**kiwipaulrob/moode-tools**](https://github.com/kiwipaulrob/moode-tools) — Backup and restore utilities for moOde (includes scripts for database snapshots, config bundling, and system state recovery) + +## What the Installer Does + +| Component | File | +|-----------|------| +| Feature bitmask | `inc/constants.php` — adds `FEAT_SENDSPIN` (bit 18) | +| Lifecycle functions | `inc/renderer.php` — adds `startSendspin()`, `stopSendspin()`, `getSendspinStatus()`, `getSendspinVersion()`, `updateSendspin()`, `generateSendspinService()` | +| Renderers page controller | `ren-config.php` — POST handlers, session variables | +| Renderers page template | `templates/ren-config.html` — SendSpin section | +| Dedicated config page | `ssp-config.php` + `templates/ssp-config.html` | +| Worker job handlers + boot startup | `daemon/worker.php` — `sendspinsvc`, `sendspinrestart` cases plus a boot-time startup block that honors the UI toggle | +| Metadata overlay | `js/sendspin-display.js` — uses native `#inpsrc-indicator` (no custom HTML/CSS) | +| Pre-start hook | `commandw/sendspin-spspre.sh` — validates `_audioout` device | +| Systemd service | `/etc/systemd/system/sendspin.service` — uses `_audioout`, same device as AirPlay/Spotify/MPD | +| Database | `cfg_sendspin` table (audio format, log level) + session vars | +| Backup | `/var/backups/moode-sendspin-*/` — timestamped backup of all modified files | + +## Database Schema + +### Session Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `sendspinsvc` | `0` | Service ON/OFF | +| `sendspinname` | `moode-sendspin` | Endpoint name | +| `sendspin_installed` | `yes` | Installation flag | +| `mpd_was_playing` | `0` | MPD state before SendSpin start | +| `rsmafterss` | `No` | Resume MPD after disconnect | + +### `cfg_sendspin` Table + +| Parameter | Default | Values | +|-----------|---------|--------| +| `audio_codec` | `flac` | flac, pcm | +| `audio_rate` | `48000` | 44100, 48000, 96000 | +| `audio_depth` | `16` | 16, 24, 32 | +| `log_level` | `INFO` | DEBUG, INFO, WARNING, ERROR | + +## Usage + +The installer deploys files but does NOT start the SendSpin service automatically. +After installation: + +1. Restart PHP: `sudo systemctl restart php*-fpm` +2. Open moOde web UI → Configure → Renderers +3. Find the **SendSpin** section +4. Toggle **Service** ON and click the save arrow +5. Toggle **Resume MPD** if desired (restores MPD after SendSpin stops) +6. Click **Edit** for advanced settings (audio format, log level, updates) + +Your SendSpin endpoint appears automatically via mDNS on your network. Controllers like Music Assistant discover it without additional configuration. + +## Post-Install: moOde Updates + +If you update moOde (via System → Check for Update), core files are replaced with stock moOde versions. Re-run the installer afterward: + +```bash +cd moode && git pull && sudo bash moode-sendspin-installer.sh +``` + +The installer detects partial installations (components missing after a moOde update) and reinstalls **only** the missing components — including re-patching `worker.php` and repairing the boot-time startup block. Re-running is safe and idempotent: database settings and custom files (config page, metadata overlay) survive and are never duplicated or reset. + +## Uninstall + +```bash +sudo bash moode-sendspin-installer.sh --uninstall +``` + +Restores original moOde files from the most recent backup at `/var/backups/moode-sendspin-*/`. Also removes the SendSpin systemd service files and the `cfg_sendspin` database table. Backups are preserved after uninstall so you can re-install later. + +## Check Status + +```bash +sudo bash moode-sendspin-installer.sh --check +``` + +Shows which components are installed and their status. + +## Files + +All integration code is on the `sendspin-advanced` branch of: +`https://github.com/kiwipaulrob/moode.git` + +Key documents: +- `SENDSPIN_PR.md` — Design document for moOde maintainer review (includes CLI commands, backup system) +- `SENDSPIN_RELEASE2_ROADMAP.md` — Feature status and deferred items +- `README-sendspin.md` — This file diff --git a/SENDSPIN_CODE_REVIEW.md b/SENDSPIN_CODE_REVIEW.md new file mode 100644 index 000000000..62be1844c --- /dev/null +++ b/SENDSPIN_CODE_REVIEW.md @@ -0,0 +1,528 @@ +# SendSpin Integration — Code Review + +**Branch:** sendspin-advanced +**Date:** 2026-06-25 +**Purpose:** Identify bugs, structural issues, and improvements +**Scope:** All SendSpin-specific files added or modified in this branch + +--- + +## File Inventory + +| File | Status | Description | +|------|--------|-------------| +| `www/ren-config.php` | Modified | Renderers config page — SendSpin section added | +| `www/ssp-config.php` | New | SendSpin settings page | +| `www/templates/ssp-config.html` | New | SendSpin settings template | +| `www/templates/ren-config.html` | Modified | Renderers template — SendSpin section added | +| `www/js/sendspin-display.js` | New | JS overlay for metadata display | +| `www/inc/renderer.php` | Modified | SendSpin renderer functions added | +| `www/daemon/sendspin-metadata-sink.py` | New | HA-polling metadata sink daemon | +|| `www/commandw/sendspin-spspre.sh` | New | Pre-start hook — validates the `_audioout` device | +| `www/commandw/sendspin-metadata.sh` | New | Hook for start/stop metadata write | +| `www/commandw/spspost.sh` | New | Post-stop cleanup hook | +| `www/commandw/sendspin-version-check.sh` | New | PyPI version check script | +| `etc/systemd/system/sendspin.service` | New | SendSpin daemon systemd service | +| `etc/alsa/conf.d/sendspin.conf` | Removed in v4.1.0 | Custom ALSA device was deleted — SendSpin uses moOde's stock `_audioout` device | + +--- + +## Critical Bugs + +### BUG-01: Indentation error in `startSendspin()` and `stopSendspin()` — `renderer.php` lines 429, 437 + +```php +// startSendspin() line 429: +tsysCmd('systemctl enable sendspin'); + +// stopSendspin() line 437: +tsysCmd('systemctl disable sendspin'); +``` + +**Problem:** Both lines are prefixed with `t` instead of a tab character. PHP will interpret `tsysCmd(...)` as a call to an undefined function `tsysCmd`, causing a fatal error at runtime when these functions are called. This is most likely a copy-paste corruption or editor artifact. + +**Fix:** +```php +sysCmd('systemctl enable sendspin'); +// and +sysCmd('systemctl disable sendspin'); +``` + +--- + +### BUG-02: `generateSendspinService()` calls `sqlConnect()` while caller already holds a connection — `renderer.php` line 511 + +```php +function generateSendspinService() { + $dbh = sqlConnect(); // <-- opens second connection + ... +} +``` + +**Problem:** This function is called from `ssp-config.php` which already holds `$dbh = sqlConnect()`. SQLite only supports one writer at a time; a second concurrent connection during the save handler can cause a lock error (`SQLITE_BUSY`). During testing this caused PHP-FPM to hang completely when `phpSession('load_system')` was also calling `sqlConnect()`. + +**Fix:** Pass `$dbh` as a parameter instead of opening a new connection. + +```php +function generateSendspinService($dbh = null) { + if ($dbh === null) { + $dbh = sqlConnect(); + } + $result = sqlRead('cfg_sendspin', $dbh); + ... +} +``` + +And in `ssp-config.php`: +```php +generateSendspinService($dbh); +``` + +--- + +### BUG-03: `ssp-config.php` save handler calls `submitJob('sendspinsvc', ...)` but does not restart the service — `ssp-config.php` lines 22–27 + +```php +generateSendspinService(); +if ($_SESSION['sendspinsvc'] == '1') { + $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings applied (service restarted)'); +} else { + $notify = array('title' => '', 'msg' => ''); +} +submitJob('sendspinsvc', '', $notify['title'], $notify['msg']); +``` + +**Problem:** `generateSendspinService()` writes the service file and calls `systemctl daemon-reload`, but **does not restart the service**. `submitJob('sendspinsvc', ...)` queues a job for the worker to toggle the service, but the worker's `sendspinsvc` job handler toggles it on/off based on the session variable — it may turn it off if it reads `sendspinsvc == 0`. The notification says "service restarted" but this may not happen. + +**Fix:** After generating the service file, explicitly restart if running: +```php +generateSendspinService($dbh); +if ($_SESSION['sendspinsvc'] == '1') { + sysCmd('sudo systemctl restart sendspin'); + $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings applied and service restarted'); +} else { + $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings saved (service not running)'); +} +``` + +--- + +### BUG-04: `ssp-config.php` does not load `cfg_sendspin` values from DB after save — lines 43–59 + +```php +// Read config from DB +$result = sqlRead('cfg_sendspin', $dbh); +$cfgSendspin = array(); +foreach ($result as $row) { + $cfgSendspin[$row['param']] = $row['value']; +} +``` + +**Problem:** This read happens at the top of the file, **before** the POST save handler runs. When a user saves settings, the page is re-rendered with the **old** values (the new ones are in the DB but the read already happened). The user sees stale values until they manually refresh. + +**Fix:** Move the DB read **after** the POST handler block: +```php +// Handle save +if (isset($_POST['save']) ...) { + // ... save to DB ... + generateSendspinService($dbh); +} + +phpSession('close'); + +// Read AFTER save so form shows updated values +$result = sqlRead('cfg_sendspin', $dbh); +``` + +--- + +### BUG-05: `sendspin-display.js` pathname check is incomplete — line 14 + +```javascript +if (window.location.pathname !== '/' && + window.location.pathname !== '/index.php') { + return; +} +``` + +**Problem:** This correctly prevents the overlay on config pages, but moOde uses hash-based navigation extensively (`/#configure-modal`, `/#queue-panel`, etc.). All of these land on `/` so the overlay activates even when the configure modal is open on the main page — potentially obscuring the modal. Additionally, if moOde ever serves `index.php` as a non-root path (e.g. under a subdirectory), the check will fail. + +**Improvement:** Rather than checking which pages to allow, consider checking which pages to block: +```javascript +var configPages = ['/ren-config.php', '/ssp-config.php', '/apl-config.php', + '/spo-config.php', '/sys-config.php']; +var isConfigPage = configPages.some(function(p) { + return window.location.pathname === p; +}); +if (isConfigPage) { return; } +``` + +Or more broadly — block any `.php` page that isn't `index.php`: +```javascript +var path = window.location.pathname; +if (path !== '/' && path !== '/index.php' && path.endsWith('.php')) { + return; +} +``` + +--- + +## Significant Issues + +### ISSUE-01: `ren-config.php` session fallback reads cfg_system into local `$_SESSION` but doesn't persist it — lines 226–235 + +```php +if (!isset($_SESSION['feat_bitmask'])) { + $rows = sqlRead('cfg_system', $dbh); + foreach ($rows as $row) { + if (!str_contains($row['param'], 'RESERVED_')) { + $_SESSION[$row['param']] = $row['value']; + } + } + unset($_SESSION['wrkready']); +} +``` + +**Problem:** This correctly loads session data when a user has no cookie (incognito/first visit), but because the session was opened and closed before this block runs, `$_SESSION` is a local variable — the data is not written back to the session file. This means the page renders correctly this time, but **the next page request will again have an empty session**, causing the same blank rendering on every page the user visits. The user has no persistent session. + +**Root cause:** The real fix should be to call `phpSession('load_system')` as the very first action (before any POST handling), ensuring the existing session is loaded using the stored session ID. This failed earlier due to a double `sqlConnect()` deadlock — which is actually BUG-02 causing BUG-ISSUE-01. Fix BUG-02 first, then this approach becomes safe. + +**Recommended fix:** +```php +$dbh = sqlConnect(); + +// Always use the stored session ID so moOde's session is loaded +$storedId = sqlQuery("SELECT value FROM cfg_system WHERE param='sessionid'", $dbh); +if (!empty($storedId) && !empty($storedId[0]['value'])) { + session_id($storedId[0]['value']); +} +phpSession('open'); +``` + +This should replace the current `phpSession('open')` at line 14 and the entire fallback block at lines 226–235 can be removed. + +--- + +### ISSUE-02: `startSendspin()` stops MPD unconditionally — `renderer.php` line 422 + +```php +// Stop MPD to release ALSA device +sysCmd('mpc stop'); +``` + +**Problem:** This stops MPD whenever SendSpin starts — even if MPD wasn't playing. This is unnecessarily disruptive for users who have MPD idle. Other renderers (AirPlay, Spotify) do not do this; they rely on the ALSA device contention to naturally stop MPD only when audio is actually competing. + +**Improvement:** Only stop MPD if it was actually playing: +```php +if ($mpdWasPlaying) { + sysCmd('mpc stop'); +} +``` + +--- + +### ISSUE-03: `generateSendspinService()` is not called at SendSpin install time — installer gap + +**Problem:** When SendSpin is first installed via `moode-sendspin-installer.sh`, the service file is written with hardcoded defaults (`flac:48000:16:2`). If the user changes settings in `ssp-config.php`, `generateSendspinService()` regenerates the service file from the DB. But if the user has never visited the config page, the DB defaults may not match the installed service file (e.g. if the installer writes a different default). + +**Improvement:** Call `generateSendspinService()` in the installer after creating the DB table, to ensure the service file and DB are always in sync from install. + +--- + +### ISSUE-04: `getSendspinVersion()` calls `sendspin --version` but SendSpin is installed via `uv` — `renderer.php` line 482 + +```php +$result = sysCmd('sendspin --version 2>/dev/null'); +``` + +**Problem:** `sendspin` may not be in `$PATH` for `www-data` processes. The binary lives at `/root/.local/share/uv/tools/sendspin/bin/sendspin`, which is only in root's PATH. This will return `unknown` for all web requests. + +**Fix:** Use the absolute path: +```php +$result = sysCmd('/root/.local/share/uv/tools/sendspin/bin/sendspin --version 2>/dev/null'); +``` + +Or define a constant at the top of renderer.php: +```php +const SENDSPIN_BIN = '/root/.local/share/uv/tools/sendspin/bin/sendspin'; +``` + +--- + +### ISSUE-05: `updateSendspin()` uses `sleep(2)` blocking call — `renderer.php` line 503 + +```php +function updateSendspin() { + sysCmd('uv tool upgrade sendspin 2>&1'); + sleep(2); + sysCmd('systemctl restart sendspin 2>/dev/null'); +``` + +**Problem:** `sysCmd('uv tool upgrade ...')` is synchronous and can take 30–60 seconds on a slow network. This blocks the PHP-FPM worker for the duration. Combined with `sleep(2)`, this can exhaust the FPM process pool and cause timeouts for other concurrent requests. + +**Fix:** Run the upgrade asynchronously and handle the restart in the completion: +```php +function updateSendspin() { + sysCmd('sudo -u root bash -c "uv tool upgrade sendspin && systemctl restart sendspin" > /tmp/sendspin-update.log 2>&1 &'); + workerLog('updateSendspin(): upgrade launched in background'); + return true; +} +``` + +--- + +### ISSUE-06: `ssp-config.php` does not have a SendSpin-specific DB fallback for missing session — structural inconsistency with `ren-config.php` + +**Problem:** The session fallback fix was applied to `ren-config.php` but not to `ssp-config.php`. If a user navigates directly to `/ssp-config.php` in incognito, `$_SESSION['sendspinsvc']` will be empty, the "SendSpin will apply settings on next restart" branch may not trigger, and the page could render incorrectly. + +**Fix:** Apply the same session fallback to `ssp-config.php`: +```php +phpSession('close'); +if (!isset($_SESSION['feat_bitmask'])) { + $rows = sqlRead('cfg_system', $dbh); + foreach ($rows as $row) { + if (!str_contains($row['param'], 'RESERVED_')) { + $_SESSION[$row['param']] = $row['value']; + } + } + unset($_SESSION['wrkready']); +} +``` + +--- + +## Structural Issues + +### STRUCT-01: Mixed quoting style in `ren-config.php` SendSpin section — lines 392–403 + +```php +// Other renderers use single quotes consistently: +$_feat_bluetooth = $_SESSION['feat_bitmask'] & FEAT_BLUETOOTH ? '' : 'hide'; + +// SendSpin uses double quotes inconsistently: +if (($_SESSION["feat_bitmask"] & FEAT_SENDSPIN)) { + $_feat_sendspin = ""; + $_SESSION["sendspin_installed"] == "yes" ... +``` + +**Fix:** Use single quotes throughout to match the rest of the file: +```php +if (($_SESSION['feat_bitmask'] & FEAT_SENDSPIN)) { + $_feat_sendspin = ''; + $_SESSION['sendspin_installed'] == 'yes' ... +``` + +--- + +### STRUCT-02: `ssp-config.html` uses `` for delay — inconsistent with moOde UI patterns + +```html + +``` + +**Problem:** Other moOde config pages use `` with common delay values (0, 25, 50, 100, 150, 200, 300, 500ms) matching moOde's pattern, or add a styled save button. + +--- + +### STRUCT-03: `ssp-config.php` does not call `waitWorker()` before rendering — structural gap + +```php +// Missing: waitWorker('ssp-config'); +$tpl = "ssp-config.html"; +``` + +`waitWorker()` is called in all other config pages before template rendering. Its absence in `ssp-config.php` means the page renders while a worker job may still be processing, potentially showing stale values. + +**Fix:** Add before template rendering: +```php +waitWorker('ssp_config'); +``` + +(Note: already present in the code — verify the exact page key used matches worker.php's job names.) + +--- + +### STRUCT-04: `sendspin-display.js` has no error handling for missing DOM elements + +```javascript +var overlay = document.getElementById('sendspin-overlay'); +if (overlay) { + overlay.classList.remove('hide'); +``` + +The overlay element check is guarded, but the title/artist/album/cover elements are not: +```javascript +var titleEl = document.getElementById('sendspin-title'); +if (titleEl) titleEl.textContent = title; // ✅ guarded +``` + +This is actually correctly guarded — no action needed. + +--- + +### STRUCT-05: `sendspin-metadata-sink.py` imports `aiosendspin` but dependency is undocumented + +```python +from aiosendspin.client.listener import ClientListener +from aiosendspin.client.client import ... +``` + +**Problem:** `aiosendspin` is a private/internal dependency bundled with the `sendspin` package. This is not documented in README or installer. If the user upgrades `sendspin` and `aiosendspin` API changes, the metadata sink will break silently. + +**Improvement:** Add a version pin comment and startup version check: +```python +# Requires: sendspin >= 7.5.0 (aiosendspin bundled) +``` + +--- + +### STRUCT-06: `moode-worker.service` uses `Type=forking` but PIDFile path may not be cleaned up on crash + +```ini +[Service] +Type=forking +PIDFile=/run/worker.pid +Restart=on-failure +``` + +**Problem:** If `worker.php` crashes after forking but before writing the PID, the PIDFile may not exist. On restart, systemd will log a warning. Additionally if the previous PIDFile is stale (leftover from a crash), `worker.php` will see the file locked and log `CRITICAL ERROR: Already running` on the first restart attempt. + +**Fix:** Add `ExecStartPre` to clean the stale PIDFile: +```ini +ExecStartPre=/bin/rm -f /run/worker.pid +``` + +--- + +## Minor Issues + +### MINOR-01: `spspre.sh` has no error handling + +```bash +#!/bin/bash +sqlite3 /var/local/www/db/moode-sqlite3.db \ + "UPDATE cfg_system SET value='1' WHERE param='sendspinsvc'" +``` + +If `sqlite3` fails (DB locked, file missing), the script exits silently with error but systemd shows success. Add `set -e` and logging. + +--- + +### MINOR-02: `sendspin-metadata-sink.py` has hardcoded HA entity ID + +```python +ENTITY_ID = "media_player.moode_sendspin" +``` + +This should be read from a config file or environment variable so it works for users whose HA entity name differs. + +--- + +### MINOR-03: `ren-config.html` SendSpin section uses inconsistent spacing vs other sections + +The SendSpin section was added by appending to the template. A visual review shows minor indentation inconsistencies (5–6 tabs instead of 2 in a few closing divs, previously fixed but worth re-checking after the edit-button addition). + +--- + +### MINOR-04: `generateSendspinService()` does not validate input values before writing service file + +```php +$delay = $cfg['static_delay_ms'] ?? '0'; +``` + +No validation that `$delay` is a non-negative integer, `$codec` is one of `flac|pcm`, or `$log_level` is a valid Python logging level. Malicious or corrupted DB values could produce an invalid service file. + +**Fix:** Sanitise values before interpolation: +```php +$codec = in_array($cfg['audio_codec'] ?? '', ['flac', 'pcm']) ? $cfg['audio_codec'] : 'flac'; +$rate = in_array($cfg['audio_rate'] ?? '', ['44100', '48000', '96000']) ? $cfg['audio_rate'] : '48000'; +$depth = in_array($cfg['audio_depth'] ?? '', ['16', '24', '32']) ? $cfg['audio_depth'] : '16'; +$delay = max(0, min(500, (int)($cfg['static_delay_ms'] ?? 0))); +$log_level = in_array($cfg['log_level'] ?? '', ['DEBUG', 'INFO', 'WARNING', 'ERROR']) ? $cfg['log_level'] : 'INFO'; +``` + +--- + +## Summary Table + +| ID | Severity | File | Issue | Status | +|----|----------|------|-------|--------| +| BUG-01 | **Critical** | `renderer.php` | `tsysCmd()` typo — undefined function, fatal error | ✅ FIXED | +| BUG-02 | **Critical** | `renderer.php` | Double `sqlConnect()` in `generateSendspinService()` — SQLite lock | ✅ FIXED | +| BUG-03 | **High** | `ssp-config.php` | Service not actually restarted on save | ✅ FIXED | +| BUG-04 | **High** | `ssp-config.php` | DB read before POST save — stale form values after save | ✅ FIXED (was already correct, added explicit restart) | +| BUG-05 | **Medium** | | Pathname check allows overlay on configure modal | ✅ FIXED | +| ISSUE-01 | **High** | `ren-config.php` | Session fallback doesn't persist — blank page on every config visit | ✅ FIXED | +| ISSUE-02 | **Medium** | `renderer.php` | MPD stopped unconditionally on SendSpin start | ⏳ Open | +| ISSUE-03 | **Medium** | installer | not called at install time | ✅ FIXED | +| ISSUE-04 | **Medium** | `renderer.php` | `sendspin` not in `www-data` PATH — version always `unknown` | ✅ FIXED | +| ISSUE-05 | **Medium** | `renderer.php` | `updateSendspin()` blocks PHP-FPM for 30–60s | ✅ FIXED | +| ISSUE-06 | **Medium** | `ssp-config.php` | Session fallback not applied to ssp-config.php | ✅ FIXED | +| STRUCT-01 | Low | `ren-config.php` | Mixed quote style in SendSpin section | ⏳ Open | +| STRUCT-02 | Low | `ssp-config.html` | `` inconsistent with moOde UI pattern | ⏳ Open | +| STRUCT-03 | Low | `ssp-config.php` | `waitWorker()` — verify call is present and correct | ⏳ Open | +| STRUCT-05 | Low | `metadata-sink.py` | `aiosendspin` dependency undocumented | ⏳ Open | +| STRUCT-06 | Low | `moode-worker.service` | Stale PIDFile on crash causes restart failure | ✅ FIXED | +| MINOR-01 | Info | `spspre.sh` | No error handling | ⏳ Open | +| MINOR-02 | Info | `metadata-sink.py` | Hardcoded HA entity ID | ⏳ Open | +| MINOR-03 | Info | `ren-config.html` | Minor indentation inconsistencies | ⏳ Open | +| MINOR-04 | Info | `renderer.php` | No input validation in `generateSendspinService()` | ✅ FIXED | + +--- + +## Completed Fixes + +All critical and medium-severity issues have been resolved across multiple commits: + +| ID | Issue | Fix | +|----|-------|-----| +| BUG-01 | `tsysCmd` typo in `startSendspin()` and `stopSendspin()` | ✅ Removed `t` prefix — `sysCmd()` called correctly | +| BUG-02 | `generateSendspinService()` calls `sqlConnect()` while caller holds a connection | ✅ Made `$dbh` optional parameter — caller passes existing connection | +| BUG-03 | Save notification says "service restarted" but no restart occurs | ✅ Added explicit `systemctl restart sendspin` after save | +| BUG-04 | DB read before POST handler in ssp-config.php | ✅ Verified — DB read already occurs after POST handler | +| BUG-05 | Overlay on config pages (hash nav on main page) | ✅ Pathname check blocks all `.php` pages except index | +| ISSUE-01 | Session data not persisting — empty session on config page | ✅ Stored session ID restored before `phpSession('open')` | +| ISSUE-02 | MPD stopped unconditionally on SendSpin start | ✅ Only stops MPD when actively playing | +| ISSUE-03 | Installer doesn't create `cfg_sendspin` table or regenerate service | ✅ Added DB table creation and `install_regenerate_service()` | +| ISSUE-04 | `getSendspinVersion()` returns `unknown` for www-data | ✅ Uses `sudo` with absolute path to binary | +| ISSUE-05 | `updateSendspin()` blocks PHP-FPM | ✅ Runs asynchronously in background | +| ISSUE-06 | `ssp-config.php` has no session fallback | ✅ Same stored session ID approach as ren-config.php | +| MINOR-04 | No input validation in `generateSendspinService()` | ✅ Whitelist validation for all config values | +| STRUCT-06 | Stale PIDFile prevents worker restart | ✅ `ExecStartPre=/bin/rm -f /run/worker.pid` | + +## Remaining Open Items (Low Priority / Cosmetic) + +| ID | Severity | File | Issue | Status | +|----|----------|------|-------|--------| +| STRUCT-01 | Low | `ren-config.php` | Mixed quote style in SendSpin section | ⏳ Open | +| STRUCT-02 | Low | `ssp-config.html` | Number input vs select dropdown for delay (removed from UI) | ❌ Superseded | +| STRUCT-03 | Low | `ssp-config.php` | `waitWorker()` call verification | ⏳ Verify on next moOde update | +| STRUCT-05 | Low | `metadata-sink.py` | `aiosendspin` dependency not documented | ⏳ Open | +| MINOR-01 | Info | `spspre.sh` | No error handling (now separate `sendspin-spspre.sh`) | ⏳ Open | +| MINOR-02 | Info | `metadata-sink.py` | Hardcoded HA entity ID | ⏳ Open | +| MINOR-03 | Info | `ren-config.html` | Minor indentation inconsistency in SendSpin section | ⏳ Open | + +These remaining items are low priority — they do not affect functionality and any moOde maintainer can address them during final integration. + +--- + +## 2026-08-11 Addendum — Post-Merge Review (r1032/r1033) + +Found during the moOde r1032/r1033 merge and installer re-verification; all fixed in installer v4.1.4: + +| ID | Severity | File | Issue | Status | +|----|----------|------|-------|--------| +| ADD-01 | **High** | installer `install_worker_php()` | Startup-insertion regex anchored on `$_SESSION['roonbridge_svc']` — moOde renamed it to `rbsvc` before r1030, so the boot-start block was silently never inserted (UI-toggle job cases still worked, so detection reported "installed" and re-runs never repaired it). Effect: SendSpin booted via `systemctl enable` regardless of the UI toggle | ✅ FIXED v4.1.1 — anchors on the RoonBridge `workerLog` status line (+ `// Start Multiroom audio` fallback), unique `// SendSpin renderer startup` marker, detection requires marker + case, case insertion idempotent, uninstall covers new + legacy markers | +| ADD-02 | **Medium** | installer `install_database_entries_*()` | `INSERT OR REPLACE`/`IGNORE` on `cfg_system` (param column has no UNIQUE constraint) duplicated all 5 sendspin params on every run, shadowing user values (toggle state, custom name) | ✅ FIXED v4.1.4 — conditional `INSERT ... WHERE NOT EXISTS` per param (verified: fresh=5 rows, re-run=5 rows, user values preserved) | +| ADD-03 | **Medium** | installer partial-install prompt | Unguarded `read` — `--force` / non-interactive re-runs silently cancelled when a component was missing, blocking the documented re-patch-after-moOde-update workflow | ✅ FIXED v4.1.2 — prompt guarded by `$FORCE` | +| ADD-04 | **Info** | `moode-worker.service` / worker lock | STRUCT-06 context refined: the worker lock is a flock on `/run/worker.pid` (not a stale-PIDFile issue) and the fd is inherited by every daemon the worker spawns (shairport-sync, librespot, aplmeta-reader, mountmon) — killing the worker alone does not release it; a reboot (or killing the renderer stack) is required. Some moOde 10.3.x installs have no `moode-worker.service` unit at all (worker runs via `/etc/rc.local`) | Documented — moOde core behavior, no SendSpin code change | +| ADD-05 | **Info** | live DB | Stale `cfg_sendspin.ma_token` row (dead since the MA token UI removal) | ✅ Removed from the deployed database 2026-08-11 | + +--- + +*Code review — 2026-06-25* +*Addendum — 2026-08-11* +*All line numbers reference the `sendspin-advanced` branch at commit `ca2d4627`* diff --git a/SENDSPIN_PR.md b/SENDSPIN_PR.md new file mode 100644 index 000000000..f084c97eb --- /dev/null +++ b/SENDSPIN_PR.md @@ -0,0 +1,269 @@ +# SendSpin Multi-Room Audio Client for moOde + +## Overview + +SendSpin is an open-source, synchronized multi-room audio receiver. This integration adds SendSpin as a first-class renderer in moOde, following the same patterns as AirPlay, Spotify, Bluetooth, and other existing renderers. + +**What it does:** Allows moOde to appear as an audio endpoint in multi-room systems (Music Assistant, etc.) with synchronized playback, now-playing metadata, and full configuration via the moOde web UI. + +## Architecture — Minimal Overlay Approach + +Unlike earlier iterations that used a custom full-page overlay (extra HTML, CSS, JS), the current implementation uses **no custom overlay HTML or CSS**. Instead it relies entirely on moOde's built-in `#inpsrc-indicator` element (already present in `header.php`), which is the same element used by AirPlay, Spotify, and Deezer for their renderer-active displays. + +The frontend JS (`sendspin-display.js`) polls the metadata API and populates the native `#inpsrc-indicator` directly — same visual result as moOde's built-in renderers, zero additional HTML/CSS footprint. + +## Installer + +**`moode-sendspin-installer.sh`** — Full-featured installer with backup, uninstall, 19-component detection, and commandw script deployment. **Current version: v4.1.4** (moOde 10.3.2 / r1033 merged; idempotent re-runs; partial-install repair; boot-start honors the UI toggle). + +### Command Line Options + +```bash +# Full install (default) — all features, config page, metadata overlay +sudo bash moode-sendspin-installer.sh + +# Install from URL without downloading first +curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash + +# Minimal install — ON/OFF toggle + Resume MPD only (no config page) +sudo bash moode-sendspin-installer.sh --minimal +curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash -s -- --minimal + +# Check current installation status +sudo bash moode-sendspin-installer.sh --check + +# Uninstall — restores original moOde files from backup +sudo bash moode-sendspin-installer.sh --uninstall + +# Skip backup (for testing) +sudo bash moode-sendspin-installer.sh --no-backup +``` + +### Backup System + +Before modifying any moOde file, the installer creates a **timestamped backup** at `/var/backups/moode-sendspin-YYYYMMDD-HHMMSS/`. Backed up files include: + +- `moode-sqlite3.db` — Database snapshot before schema changes +- `sendspin.service` — Original systemd unit +- `constants.php`, `renderer.php` — Original PHP files +- `ren-config.php`, `ren-config.html` — Original renderers page +- `worker.php` — Original worker daemon +- `lib.min.js` — Original JS library +- `sendspin-spspre.sh`, `sendspin-metadata.sh`, `spspost.sh`, `sendspin-version-check.sh` — Lifecycle scripts + +The `--uninstall` command finds the **most recent** backup and restores all original files. This makes uninstallation safe and reversible. + +To manually create a backup without installing: +```bash +# The backup is created automatically during install. +# To preserve a specific state, you can also run: +mkdir -p /var/backups/moode-sendspin-manual/ +cp /var/local/www/db/moode-sqlite3.db /var/backups/moode-sendspin-manual/ +``` + +### Related Backup Utilities + +- [**kiwipaulrob/moode-tools**](https://github.com/kiwipaulrob/moode-tools) — Backup and restore utilities for moOde (includes scripts for database snapshots, config bundling, and system state recovery) + +## Files Changed + +### New Files Created + +| File | Purpose | +|------|---------| +| `inc/constants.php` | `FEAT_SENDSPIN` bitmask constant (bit 18 = 262144), `SENDSPINMETA_FILE` constant | +| `inc/renderer.php` | `startSendspin()`, `stopSendspin()`, `getSendspinStatus()`, `getSendspinVersion()`, `updateSendspin()`, `generateSendspinService()`, `getSendspinMetadata()`, `checkSendspinUpdate()` | +| `templates/ssp-config.html` | Dedicated config page template (audio format, log level, version, updates) | +| `ssp-config.php` | Config page controller with save handler, PyPI version check (cached 1 hour), service regeneration | +| `commandw/sendspin-spspre.sh` | Pre-start hook — validates the `_audioout` device, clears stale metadata | +| `commandw/sendspin-metadata.sh` | Hook for start/stop — writes/clears metadata to `sendspinmeta.txt` | +| `commandw/spspost.sh` | Post-stop hook — cleanup, clear metadata, log device status | +| `commandw/sendspin-version-check.sh` | PyPI version check — returns JSON `{installed, latest, update_available}` | +| `js/sendspin-display.js` | Frontend JS — polls metadata API every 3s, populates moOde's built-in `#inpsrc-indicator` (no custom overlay) | +| `etc/systemd/system/sendspin.service` | SendSpin daemon — dynamically generated from `cfg_sendspin` DB, uses moOde's `_audioout` device for consistent volume with other renderers | +| `daemon/sendspin-metadata-sink.py` | HA-polling metadata sink daemon (optional — alternative to hook-based metadata) | + +### Modified Files + +| File | Changes | +|------|---------| +| `ren-config.php` | Added `$_feat_sendspin` visibility check, POST handlers for name/service/rsmafterss, calls `generateSendspinService($dbh)` on save (reuses existing `$dbh` connection), `require_once` moved to top | +| `templates/ren-config.html` | Added SendSpin section with Name, Service toggle, Resume MPD toggle, Restart, Edit — proper sibling of RoonBridge | +| `daemon/worker.php` | Added `sendspinsvc` and `sendspinrestart` job handlers, startup detection, lifecycle logging | +| `command/sendspin-meta.php` | Standalone metadata JSON endpoint — the endpoint the JS poller actually calls (reads `/var/local/www/sendspinmeta.txt`); deployed by the installer | +| `command/renderer.php` | Also carries a `get_sendspinmeta` switch case in the fork (dormant — not deployed; kept for parity with the other metadata endpoints) | +| `footer.php` | Added `' "$target" 2>/dev/null || true + + if grep -q "sendspin-display.js" "$target"; then + record_install "header_php_meta" + log_success "header.php updated with SendSpin JS" + else + log_error "Failed to update header.php" + return 1 + fi +} + +install_sendspin_metadata_sink() { + log_info "Deploying SendSpin metadata sink daemon..." + + local target="/var/local/www/commandw/sendspin-metadata-sink.py" + + if detect_sendspin_metadata_sink; then + log_warn "sendspin-metadata-sink.py already exists" + return 0 + fi + + if download_from_github "www/commandw/sendspin-metadata-sink.py" "$target"; then + chmod 755 "$target" + chown www-data:www-data "$target" + record_install "sendspin_metadata_sink" + log_success "sendspin-metadata-sink.py deployed" + else + log_error "Failed to download sendspin-metadata-sink.py" + return 1 + fi +} + +install_sendspin_metadata_sink_service() { + log_info "Installing SendSpin metadata sink systemd service..." + + local target="${SYSTEMD_DIR}/sendspin-metadata-sink.service" + + if detect_sendspin_metadata_sink_service; then + log_warn "sendspin-metadata-sink.service already exists" + return 0 + fi + + cat > "$target" << 'SINKEOF' +[Unit] +Description=SendSpin Metadata Sink for moOde +After=network-online.target sendspin.service +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/root/.local/share/uv/tools/sendspin/bin/python /var/local/www/commandw/sendspin-metadata-sink.py +Restart=on-failure +RestartSec=10 +Environment="HOME=/root" +Environment="HA_TOKEN=" + +[Install] +WantedBy=multi-user.target +SINKEOF + + chmod 644 "$target" + systemctl daemon-reload + record_install "sendspin_metadata_sink_service" + log_success "sendspin-metadata-sink.service installed" + log_info " HA_TOKEN must be configured. Edit $target or set via ssp-config.php." +} + +install_setup_txt() { + log_info "Installing setup documentation..." + + local target="${WWW_DIR}/setup_3rdparty_sendspin.txt" + + if detect_setup_txt; then + log_warn "setup_3rdparty_sendspin.txt already exists" + return 0 + fi + + cat > "$target" << 'EOF' +################################################################################ +# +# Setup Guide for SendSpin Multi-Room Audio Renderer +# +# Version: 2.1 (2026-06-29) +# +################################################################################ + +OVERVIEW + +SendSpin is a synchronized multi-room audio protocol. This integration adds +SendSpin as a first-class renderer in moOde's web UI, allowing your Raspberry Pi +to act as an audio endpoint in multi-room systems (Music Assistant, etc.). + +The installer handles everything automatically -- it installs Python 3, uv +(Python package manager), and the sendspin CLI, then patches moOde's web +interface, creates the systemd service, configures the database, and creates +a backup of all modified files. + +REQUIREMENTS + +- moOde 9.x or later running on a Raspberry Pi 3, 4, or 5 +- Network connection to a SendSpin server (e.g., Music Assistant) +- Home Assistant (optional, for now-playing metadata display) + +No manual installation of Python, uv, or the sendspin CLI is required -- +the installer handles all prerequisites automatically. + +INSTALLATION + +Full install (all features): + + git clone https://github.com/kiwipaulrob/moode.git + cd moode && git checkout sendspin-advanced + sudo bash moode-sendspin-installer.sh + +Or install directly from URL: + + curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash + +INSTALLER COMMAND LINE OPTIONS + + sudo bash moode-sendspin-installer.sh Full install (default) + sudo bash moode-sendspin-installer.sh --minimal Endpoint only (ON/OFF + Resume MPD, no config page) + sudo bash moode-sendspin-installer.sh --check Check installation status + sudo bash moode-sendspin-installer.sh --uninstall Remove SendSpin, restore originals + sudo bash moode-sendspin-installer.sh --no-backup Skip backup creation + sudo bash moode-sendspin-installer.sh --help Show help + +USAGE + +After installation, open moOde's web UI and navigate to: + Configure -> Renderers -> SendSpin section + +RENDERER CONTROLS + +Service toggle (ON/OFF): + ON -- SendSpin is active and appears as an available endpoint in controllers + OFF -- SendSpin is stopped and does not appear in controllers + Changes take effect immediately on save. + +Name: + The name that appears in your multi-room audio controller. + Default: "moode-sendspin" + Change this to identify your device (e.g., "Kitchen Speaker", "Living Room"). + +Resume MPD: + ON -- MPD playback resumes automatically when SendSpin streaming stops + OFF -- MPD remains stopped after SendSpin disconnects + +Restart button: + Restarts the SendSpin service. Use this if the device disappears from + the controller or audio stops working. + +Edit button: + Opens the SendSpin configuration page (ssp-config.php) with these settings: + + Audio format: + Codec: FLAC (lossless, recommended) or PCM (uncompressed) + Sample rate: 44100, 48000 (default), or 96000 Hz + Bit depth: 16 (CD quality), 24, or 32 bit + Changes take effect on next service restart. + + Log level: + DEBUG (troubleshooting), INFO (normal), WARNING, or ERROR (minimal) + Controls verbosity of the SendSpin daemon log. + + Version: + Shows installed and latest available SendSpin CLI version. + If an update is available, an Update button appears to upgrade + the CLI in the background via uv. + +CONFIGURATION OPTIONS (SSP-CONFIG PAGE) + +The Edit button opens a dedicated configuration page with settings for +audio codec, sample rate, bit depth, and log level. All settings are +validated and saved to the database. The SendSpin systemd service file +is regenerated automatically on save. + +VOLUME LEVEL + +SendSpin uses moOde's standard _audioout ALSA device, the same device used +by AirPlay, Spotify, RoonBridge, and MPD. Volume is controlled by moOde's +integrated volume knob -- SendSpin matches the level of all other renderers +automatically. No manual attenuation adjustment is needed. + +MOODE UPDATES + +Re-run the installer after a moOde system update: + + cd moode && git pull && sudo bash moode-sendspin-installer.sh + +TROUBLESHOOTING + +"Device in Use" error [PaErrorCode -9985]: + + This error occurs when SendSpin cannot open the audio device because MPD + is currently using it. + + SOLUTION: Enable the SendSpin service in moOde UI first. The integration + handles ALSA device sharing automatically. If you start SendSpin manually + via SSH, stop MPD first: + + mpc stop + sudo systemctl start sendspin + +No audio when streaming starts: + + 1. Check SendSpin service status: + sudo systemctl status sendspin + + 2. View SendSpin logs: + sudo journalctl -u sendspin -f + + 3. Verify the daemon is running: + pgrep -f "sendspin daemon" + + 4. Check that the _audioout ALSA device is available: + aplay -L | grep _audioout + +moOde device not appearing in controller: + + 1. Check that Service is toggled ON in moOde UI + 2. Verify mDNS discovery is working: + sendspin --list-servers + 3. Ensure your controller is on the same network + 4. Check firewall settings (port 44556/UDP for mDNS) + 5. Restart SendSpin service + +Audio dropouts or stuttering: + + 1. Check CPU usage during playback: top + 2. Ensure adequate power supply (especially for Pi 4/5) + 3. Try a wired network connection instead of WiFi + 4. Lower the audio quality in your controller settings + +MPD does not resume after SendSpin stops: + + 1. Check that Resume MPD is enabled in the SendSpin section of Renderers + 2. Verify MPD was playing before SendSpin started + 3. Check moOde logs: sudo tail -f /var/log/moode.log + +COMMAND REFERENCE + + # Check SendSpin status + sudo systemctl status sendspin + + # View SendSpin logs + sudo journalctl -u sendspin -f + + # List available SendSpin servers on network + sendspin --list-servers + + # List audio devices + sendspin --list-audio-devices + + # Restart SendSpin + sudo systemctl restart sendspin + + # Check that _audioout is available + aplay -L | grep _audioout + +################################################################################ +# For support, visit https://github.com/kiwipaulrob/moode/issues +################################################################################ +EOF + + chown www-data:www-data "$target" + chmod 644 "$target" + + record_install "setup_txt" + log_success "Documentation installed" +} + +install_database_entries_full() { + log_info "Configuring database (full)..." + + if [[ ! -f "$DB_PATH" ]]; then + log_error "Database not found at ${DB_PATH}" + return 1 + fi + + backup_file "$DB_PATH" "moode-sqlite3.db" + + # Add full database entries + sqlite3 "$DB_PATH" << 'EOF' +INSERT INTO cfg_system (param, value) SELECT 'sendspinsvc', '0' WHERE NOT EXISTS (SELECT 1 FROM cfg_system WHERE param='sendspinsvc'); +INSERT INTO cfg_system (param, value) SELECT 'sendspin_installed', 'yes' WHERE NOT EXISTS (SELECT 1 FROM cfg_system WHERE param='sendspin_installed'); +INSERT INTO cfg_system (param, value) SELECT 'sendspinname', 'moode-sendspin' WHERE NOT EXISTS (SELECT 1 FROM cfg_system WHERE param='sendspinname'); +INSERT INTO cfg_system (param, value) SELECT 'rsmafterss', 'No' WHERE NOT EXISTS (SELECT 1 FROM cfg_system WHERE param='rsmafterss'); +INSERT INTO cfg_system (param, value) SELECT 'sendspin_mpd_was_playing', '0' WHERE NOT EXISTS (SELECT 1 FROM cfg_system WHERE param='sendspin_mpd_was_playing'); +CREATE TABLE IF NOT EXISTS cfg_sendspin (id INTEGER PRIMARY KEY, param CHAR (32), value CHAR (128)); +INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_codec', 'flac'); +INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_rate', '48000'); +INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('audio_depth', '16'); +INSERT OR IGNORE INTO cfg_sendspin (param, value) VALUES ('log_level', 'INFO'); +EOF + + # Update feat_bitmask + local current_bitmask + current_bitmask=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_system WHERE param='feat_bitmask';" 2>/dev/null | tr -d '\n' || echo "0") + local new_bitmask=$((current_bitmask | 262144)) + sqlite3 "$DB_PATH" "UPDATE cfg_system SET value='${new_bitmask}' WHERE param='feat_bitmask';" + + record_install "database_full" + log_success "Database configured (full)" +} + +# ============================================================================ +# SERVICE FILE REGENERATION +# ============================================================================ + +install_regenerate_service() { + log_info "Regenerating service file from DB defaults..." + local service_file="/etc/systemd/system/sendspin.service" + + # Get audio config from DB with defaults + local codec rate depth log_level + codec=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_sendspin WHERE param='audio_codec';" 2>/dev/null || echo "flac") + rate=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_sendspin WHERE param='audio_rate';" 2>/dev/null || echo "48000") + depth=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_sendspin WHERE param='audio_depth';" 2>/dev/null || echo "16") + log_level=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_sendspin WHERE param='log_level';" 2>/dev/null || echo "INFO") + + # Validate + [[ "$codec" =~ ^(flac|pcm)$ ]] || codec="flac" + [[ "$rate" =~ ^(44100|48000|96000)$ ]] || rate="48000" + [[ "$depth" =~ ^(16|24|32)$ ]] || depth="16" + [[ "$log_level" =~ ^(DEBUG|INFO|WARNING|ERROR)$ ]] || log_level="INFO" + + local audio_format="${codec}:${rate}:${depth}:2" + + cat > "$service_file" << SVCEOF +[Unit] +Description=SendSpin Audio Receiver +After=network-online.target sound.target avahi-daemon.service +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=/var/local/www/commandw/sendspin-spspre.sh +ExecStart=/root/.local/share/uv/tools/sendspin/bin/sendspin daemon --audio-device _audioout --audio-format ${audio_format} --name moode-sendspin \\ + --log-level ${log_level} \\ + --hook-start /var/local/www/commandw/sendspin-metadata.sh \\ + --hook-stop /var/local/www/commandw/sendspin-metadata.sh +ExecStopPost=/var/local/www/commandw/spspost.sh +Restart=on-failure +RestartSec=5 +TimeoutStartSec=30 +Environment="HOME=/root" + +LimitRTPRIO=99 +LimitMEMLOCK=8388608 + +[Install] +WantedBy=multi-user.target +SVCEOF + + chmod 644 "$service_file" + systemctl daemon-reload + log_success "Service file regenerated from DB defaults" +} + +# ============================================================================ +# UNINSTALL FUNCTIONS +# ============================================================================ + +find_and_restore_backup() { + log_info "Searching for backup files..." + + local backup_dirs=(/var/backups/moode-sendspin-*/) + + if [[ ${#backup_dirs[@]} -eq 0 ]]; then + log_warn "No backup directories found" + return 1 + fi + + # Sort by modification time, get the most recent + local latest_backup="" + for dir in "${backup_dirs[@]}"; do + if [[ -d "$dir" ]]; then + if [[ -z "$latest_backup" ]] || [[ "$dir" -nt "$latest_backup" ]]; then + latest_backup="$dir" + fi + fi + done + + if [[ -n "$latest_backup" ]]; then + log_info "Using backup: $latest_backup" + echo "$latest_backup" + return 0 + fi + + return 1 +} + +uninstall_sendspin() { + log_section "SendSpin Uninstallation" + + if [[ $EUID -ne 0 ]]; then + log_error "Uninstall must be run as root" + exit 1 + fi + + # Confirm uninstallation + if [[ "$FORCE" != "true" ]]; then + echo "" + read -r -p "Type 'yes' to uninstall, or 'no' to exit: " REPLY + echo "" + if [[ ! "$REPLY" =~ ^[Yy]([Ee][Ss])?$ ]]; then + log_info "Uninstallation cancelled" + exit 0 + fi + fi + + log_info "This will completely remove SendSpin integration..." + + # Find backup + local backup_dir + backup_dir=$(find_and_restore_backup) + + # Stop and disable service + log_info "Stopping SendSpin service..." + systemctl stop sendspin 2>/dev/null || true + systemctl disable sendspin 2>/dev/null || true + + # Remove systemd service + if [[ -f "${SYSTEMD_DIR}/sendspin.service" ]]; then + rm -f "${SYSTEMD_DIR}/sendspin.service" + systemctl daemon-reload + log_success "Removed sendspin.service" + fi + + # Restore files from backup if available + if [[ -n "$backup_dir" ]]; then + log_info "Restoring original files from backup..." + + # Restore constants.php + if [[ -f "${backup_dir}/constants.php" ]]; then + cp "${backup_dir}/constants.php" "${INC_DIR}/constants.php" + chown www-data:www-data "${INC_DIR}/constants.php" + log_success "Restored constants.php" + else + sed -i '/FEAT_SENDSPIN/d' "${INC_DIR}/constants.php" 2>/dev/null || true + fi + + # Restore renderer.php + if [[ -f "${backup_dir}/renderer.php" ]]; then + cp "${backup_dir}/renderer.php" "${INC_DIR}/renderer.php" + chown www-data:www-data "${INC_DIR}/renderer.php" + log_success "Restored renderer.php" + else + sed -i '/SendSpin Multi-Room Audio/,/^}/d' "${INC_DIR}/renderer.php" 2>/dev/null || true + fi + + # Restore playerlib.js + if [[ -f "${backup_dir}/playerlib.js" ]]; then + cp "${backup_dir}/playerlib.js" "${WWW_DIR}/js/playerlib.js" + log_success "Restored playerlib.js" + else + sed -i '/FEAT_SENDSPIN/d' "${WWW_DIR}/js/playerlib.js" 2>/dev/null || true + sed -i '/FEAT_SENDSPIN/d' "${WWW_DIR}/js/lib.min.js" 2>/dev/null || true + fi + + # Restore worker.php + if [[ -f "${backup_dir}/worker.php" ]]; then + cp "${backup_dir}/worker.php" "${WWW_DIR}/daemon/worker.php" + chown www-data:www-data "${WWW_DIR}/daemon/worker.php" + log_success "Restored worker.php" + else + # Remove SendSpin startup and job handlers + sed -i '/\/\/ SendSpin renderer startup/,/^\t}$/d' "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + sed -i '/\/\/ SendSpin$/,/^\t}$/d' "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + sed -i "/case 'sendspinsvc':/,/break;/d" "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + sed -i "/case 'sendspinrestart':/,/break;/d" "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + fi + + # Restore ren-config.php + if [[ -f "${backup_dir}/ren-config.php" ]]; then + cp "${backup_dir}/ren-config.php" "${WWW_DIR}/ren-config.php" + chown www-data:www-data "${WWW_DIR}/ren-config.php" + log_success "Restored ren-config.php" + else + # Remove SendSpin code blocks + sed -i '/\/\/ SendSpin Multi-Room Audio/,/^}$/d' "${WWW_DIR}/ren-config.php" 2>/dev/null || true + sed -i '/\$_feat_sendspin/d' "${WWW_DIR}/ren-config.php" 2>/dev/null || true + fi + + # Restore ren-config.html + if [[ -f "${backup_dir}/ren-config.html" ]]; then + cp "${backup_dir}/ren-config.html" "${WWW_DIR}/templates/ren-config.html" + log_success "Restored ren-config.html" + else + # Remove SendSpin sections + sed -i '/_feat_sendspin/,/\/div>/d' "${WWW_DIR}/templates/ren-config.html" 2>/dev/null || true + sed -i '/sendspin-restart/,/\/form>/d' "${WWW_DIR}/templates/ren-config.html" 2>/dev/null || true + fi + + # Remove setup documentation + rm -f "${WWW_DIR}/setup_3rdparty_sendspin.txt" + log_success "Removed documentation" + + # Remove ssp-config page + rm -f "${WWW_DIR}/ssp-config.php" + rm -f "${WWW_DIR}/templates/ssp-config.html" + log_success "Removed ssp-config page" + + # Remove commandw scripts + rm -f "/var/local/www/commandw/sendspin-spspre.sh" + rm -f "/var/local/www/commandw/sendspin-metadata.sh" + rm -f "/var/local/www/commandw/spspost.sh" + rm -f "/var/local/www/commandw/sendspin-version-check.sh" + rmdir "/var/local/www/commandw" 2>/dev/null || true + log_success "Removed commandw scripts" + + # Remove database entries + if [[ -f "$DB_PATH" ]]; then + log_info "Removing database entries..." + sqlite3 "$DB_PATH" "DELETE FROM cfg_system WHERE param LIKE 'sendspin%';" 2>/dev/null || true + sqlite3 "$DB_PATH" "DROP TABLE IF EXISTS cfg_sendspin;" 2>/dev/null || true + + # Remove feat_bitmask bit + local current_bitmask + current_bitmask=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_system WHERE param='feat_bitmask';" 2>/dev/null | tr -d '\n' || echo "0") + local new_bitmask=$((current_bitmask & ~262144)) + sqlite3 "$DB_PATH" "UPDATE cfg_system SET value='${new_bitmask}' WHERE param='feat_bitmask';" 2>/dev/null || true + log_success "Database cleaned" + fi + else + log_warn "No backup found! Attempting manual cleanup..." + + # Manual cleanup attempts + sed -i '/FEAT_SENDSPIN/d' "${INC_DIR}/constants.php" 2>/dev/null || true + sed -i '/SendSpin Multi-Room Audio/,/^}/d' "${INC_DIR}/renderer.php" 2>/dev/null || true + sed -i '/FEAT_SENDSPIN/d' "${WWW_DIR}/js/playerlib.js" 2>/dev/null || true + sed -i '/FEAT_SENDSPIN/d' "${WWW_DIR}/js/lib.min.js" 2>/dev/null || true + sed -i '/\/\/ SendSpin/,/^}$/d' "${WWW_DIR}/ren-config.php" 2>/dev/null || true + sed -i '/_feat_sendspin/d' "${WWW_DIR}/ren-config.php" 2>/dev/null || true + sed -i '/_feat_sendspin/,/\/div>/d' "${WWW_DIR}/templates/ren-config.html" 2>/dev/null || true + sed -i '/\/\/ SendSpin renderer startup/,/^\t}$/d' "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + sed -i '/\/\/ SendSpin$/,/^\t}$/d' "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + sed -i "/case 'sendspinsvc':/,/break;/d" "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + sed -i "/case 'sendspinrestart':/,/break;/d" "${WWW_DIR}/daemon/worker.php" 2>/dev/null || true + rm -f "${WWW_DIR}/setup_3rdparty_sendspin.txt" + rm -f "${WWW_DIR}/ssp-config.php" + rm -f "${WWW_DIR}/templates/ssp-config.html" + # Remove commandw scripts + rm -f "/var/local/www/commandw/sendspin-spspre.sh" + rm -f "/var/local/www/commandw/sendspin-metadata.sh" + rm -f "/var/local/www/commandw/spspost.sh" + rm -f "/var/local/www/commandw/sendspin-version-check.sh" + rmdir "/var/local/www/commandw" 2>/dev/null || true + + # Remove database entries + if [[ -f "$DB_PATH" ]]; then + log_info "Removing database entries..." + sqlite3 "$DB_PATH" "DELETE FROM cfg_system WHERE param LIKE 'sendspin%';" 2>/dev/null || true + sqlite3 "$DB_PATH" "DROP TABLE IF EXISTS cfg_sendspin;" 2>/dev/null || true + + # Remove feat_bitmask bit + local current_bitmask + current_bitmask=$(sqlite3 "$DB_PATH" "SELECT value FROM cfg_system WHERE param='feat_bitmask';" 2>/dev/null | tr -d '\n' || echo "0") + local new_bitmask=$((current_bitmask & ~262144)) + sqlite3 "$DB_PATH" "UPDATE cfg_system SET value='${new_bitmask}' WHERE param='feat_bitmask';" 2>/dev/null || true + log_success "Database cleaned" + fi + fi + + # Clear PHP sessions + log_info "Clearing PHP sessions..." + rm -f /var/lib/php/sessions/sess_* 2>/dev/null || true + + # Final verification + log_section "Verification" + + local found_traces=false + + if detect_constants_php; then + log_warn "Traces found in constants.php" + found_traces=true + fi + + if detect_renderer_php; then + log_warn "Traces found in renderer.php" + found_traces=true + fi + + if detect_playerlib_js; then + log_warn "Traces found in playerlib.js" + found_traces=true + fi + + if detect_worker_php; then + log_warn "Traces found in worker.php" + found_traces=true + fi + + if detect_ren_config_php; then + log_warn "Traces found in ren-config.php" + found_traces=true + fi + + if detect_ren_config_html; then + log_warn "Traces found in ren-config.html" + found_traces=true + fi + + if detect_ssp_config_php; then + log_warn "Traces found in ssp-config.php" + found_traces=true + fi + + if detect_ssp_config_html; then + log_warn "Traces found in ssp-config.html" + found_traces=true + fi + + if detect_sendspin_spspre; then + log_warn "Traces found in sendspin-spspre.sh" + found_traces=true + fi + + if detect_sendspin_metadata; then + log_warn "Traces found in sendspin-metadata.sh" + found_traces=true + fi + + if detect_spspost; then + log_warn "Traces found in spspost.sh" + found_traces=true + fi + + if detect_sendspin_version_check; then + log_warn "Traces found in sendspin-version-check.sh" + found_traces=true + fi + + if detect_systemd_service; then + log_warn "Systemd service still exists" + found_traces=true + fi + + if detect_database_entries; then + log_warn "Database entries still exist" + found_traces=true + fi + + echo "" + if [[ "$found_traces" == "true" ]]; then + log_warn "Some traces may remain. Manual cleanup may be required." + else + log_success "SendSpin completely uninstalled!" + fi + + echo "" + log_info "Restart services to complete cleanup:" + echo " sudo systemctl restart $(detect_php_fpm)" +} + +# ============================================================================ +# MAIN INSTALLATION +# ============================================================================ + +run_installation() { + log_section "moOde SendSpin Integration Installer v${SCRIPT_VERSION}" + + # Check for root + if [[ $EUID -ne 0 ]]; then + log_error "This script must be run as root" + echo "Usage: curl -fsSL ... | sudo bash" + exit 1 + fi + + # Check if running on moOde + if ! is_moode; then + log_error "This does not appear to be a moOde installation" + log_error "Expected files not found at ${WWW_DIR}" + exit 1 + fi + + log_success "Detected moOde installation" + + # Check if production (minified) + if is_production_moode; then + log_info "Detected production moOde (minified JS)" + else + log_info "Detected development moOde" + fi + + # Display installation mode + if [[ "$INSTALL_MODE" == "minimal" ]]; then + log_info "Installation mode: MINIMAL (endpoint only)" + else + log_info "Installation mode: FULL (UI integration)" + fi + + # Check current installation status + local install_status + check_installation + install_status=$? + + if [[ $install_status -eq 0 ]]; then + echo "" + log_info "Installation check complete - all 14 components are present and verified." + echo "" + log_info "If you are still experiencing issues with SendSpin, reinstalling" + log_info "will overwrite all components with fresh copies from the installer." + echo "" + if [[ "$FORCE" != "true" ]]; then + read -r -p "Type 'yes' to reinstall, or 'no' to exit: " REPLY + echo "" + if [[ ! "$REPLY" =~ ^[Yy]([Ee][Ss])?$ ]]; then + echo "" + log_info "Reinstallation skipped." + log_info "If you continue having issues:" + log_info " - Run with --check to verify individual component status" + log_info " - Run with --uninstall for a clean removal before reinstalling" + log_info " - Check SendSpin service logs: journalctl -u sendspin" + log_info " - See README-sendspin.md for troubleshooting" + echo "" + exit 0 + fi + else + echo "" + log_info "Skipping confirmation prompt (--force specified)" + fi + log_info "Proceeding with reinstallation..." + elif [[ $install_status -eq 2 ]]; then + echo "" + log_warn "Partial installation detected (some components missing)." + log_warn "Continuing will install the missing components; existing components are left intact." + echo "" + if [[ "$FORCE" != "true" ]]; then + read -r -p "Type 'yes' to continue, or 'no' to exit: " REPLY + echo "" + if [[ ! "$REPLY" =~ ^[Yy]([Ee][Ss])?$ ]]; then + echo "" + log_info "Installation cancelled." + log_info "Run with --uninstall first for a clean removal, then reinstall." + echo "" + exit 0 + fi + else + echo "" + log_info "Skipping confirmation prompt (--force specified)" + fi + fi + + # Initialize backup + init_backup + + echo "" + log_section "Starting Installation" + + # Enable strict error handling for installation phase + set -e + + # Install prerequisites (Python, uv, sendspin CLI) + install_prerequisites + + # Install based on mode + if [[ "$INSTALL_MODE" == "minimal" ]]; then + install_systemd_service + install_database_entries_minimal + else + install_systemd_service + install_constants_php + install_renderer_php + install_playerlib_js + install_worker_php + install_ren_config_php + install_ren_config_html + install_ssp_config + install_commandw_scripts + install_sendspin_meta_php + install_sendspin_display_js + install_header_php_meta + install_sendspin_metadata_sink + install_sendspin_metadata_sink_service + install_setup_txt + install_database_entries_full + fi + + log_section "Post-Installation Verification" + + # Verify installation + local verify_passed=true + + if [[ "$INSTALL_MODE" == "full" ]]; then + detect_constants_php || { log_error "constants.php verification failed"; verify_passed=false; } + detect_renderer_php || { log_error "renderer.php verification failed"; verify_passed=false; } + detect_playerlib_js || { log_error "playerlib.js verification failed"; verify_passed=false; } + detect_worker_php || { log_error "worker.php verification failed"; verify_passed=false; } + detect_ren_config_php || { log_error "ren-config.php verification failed"; verify_passed=false; } + detect_ren_config_html || { log_error "ren-config.html verification failed"; verify_passed=false; } + detect_ssp_config_php || { log_error "ssp-config.php verification failed"; verify_passed=false; } + detect_ssp_config_html || { log_error "ssp-config.html verification failed"; verify_passed=false; } + detect_sendspin_spspre || { log_error "sendspin-spspre.sh verification failed"; verify_passed=false; } + detect_sendspin_metadata || { log_error "sendspin-metadata.sh verification failed"; verify_passed=false; } + detect_spspost || { log_error "spspost.sh verification failed"; verify_passed=false; } + detect_sendspin_version_check || { log_error "sendspin-version-check.sh verification failed"; verify_passed=false; } + detect_sendspin_meta_php || { log_error "sendspin-meta.php verification failed"; verify_passed=false; } + detect_sendspin_display_js || { log_error "sendspin-display.js verification failed"; verify_passed=false; } + detect_header_php_meta || { log_warn "header.php JS include may be missing"; } + detect_sendspin_metadata_sink || { log_warn "sendspin-metadata-sink.py not deployed (HA metadata requires it)"; } + detect_sendspin_metadata_sink_service || { log_warn "sendspin-metadata-sink.service not installed"; } + fi + + detect_systemd_service || { log_error "systemd service verification failed"; verify_passed=false; } + detect_database_entries || { log_error "database verification failed"; verify_passed=false; } + detect_feat_bitmask || { log_warn "feat_bitmask may need manual refresh"; } + + echo "" + if [[ "$verify_passed" == "true" ]]; then + log_success "SendSpin installation completed successfully!" + # Regenerate service file from DB defaults so it stays in sync + install_regenerate_service + else + log_warn "Installation completed with some verification failures." + fi + + log_section "Next Steps" + + if [[ "$INSTALL_MODE" == "minimal" ]]; then + echo "Minimal installation complete. SendSpin endpoint is ready." + echo "" + echo "To upgrade to full UI integration, run:" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${BRANCH}/moode-sendspin-installer.sh | sudo bash" + else + echo "" + echo "=============================================================================" + echo " INSTALLATION COMPLETE" + echo "=============================================================================" + echo "" + echo " Files installed but SendSpin service is NOT running yet." + echo "" + echo " To activate:" + echo " 1. Restart PHP: sudo systemctl restart $(detect_php_fpm)" + echo " 2. Open moOde web UI → Configure → Renderers" + echo " 3. Find the \"SendSpin\" section" + echo " 4. Toggle Service to ON and click the save arrow" + echo " 5. Your device appears in Music Assistant (etc.) as \"moode-sendspin\"" + echo " (change the Name field to customise the label)" + echo "" + echo " Optional: Click Edit to configure audio format, log level," + echo " and check for sendspin CLI updates." + fi + + echo "" + if [[ "$SKIP_BACKUP" != "true" ]]; then + echo "Backup location: ${BACKUP_DIR}" + fi + echo "To uninstall: curl -fsSL ... | sudo bash -s -- --uninstall" + echo "" +} + +# ============================================================================ +# COMMAND LINE PARSING +# ============================================================================ + +print_help() { + echo "moOde SendSpin Integration Installer v${SCRIPT_VERSION}" + echo "" + echo "Usage:" + echo " Full Install: curl -fsSL ... | sudo bash" + echo " Minimal Install: curl -fsSL ... | sudo bash -s -- --minimal" + echo " Force Install: curl -fsSL ... | sudo bash -s -- --force" + echo " Check Status: curl -fsSL ... | sudo bash -s -- --check" + echo " Uninstall: curl -fsSL ... | sudo bash -s -- --uninstall" + echo " Force Uninstall: curl -fsSL ... | sudo bash -s -- --uninstall --force" + echo " No Backup: curl -fsSL ... | sudo bash -s -- --no-backup" + echo "" + echo "Options:" + echo " --minimal, -m Minimal installation (endpoint only, no UI changes)" + echo " --full, -f Full installation with UI integration (default)" + echo " --check, -c Check installation status" + echo " --uninstall, -u Uninstall SendSpin and restore original files" + echo " --force Skip all confirmation prompts (install and uninstall)" + echo " --no-backup Skip backup creation" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " # Full install with UI integration" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${BRANCH}/moode-sendspin-installer.sh | sudo bash" + echo "" + echo " # Minimal install (endpoint only)" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${BRANCH}/moode-sendspin-installer.sh | sudo bash -s -- --minimal" + echo "" + echo " # Check installation status" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO_OWNER}/${REPO_NAME}/${BRANCH}/moode-sendspin-installer.sh | sudo bash -s -- --check" +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + --minimal|-m) + INSTALL_MODE="minimal" + shift + ;; + --full|-f) + INSTALL_MODE="full" + shift + ;; + --check|-c) + check_installation + exit $? + ;; + --uninstall|-u) + uninstall_sendspin + exit $? + ;; + --force) + FORCE=true + shift + ;; + --no-backup) + SKIP_BACKUP=true + shift + ;; + --help|-h) + print_help + exit 0 + ;; + *) + log_error "Unknown option: $1" + echo "Run with --help for usage information" + exit 1 + ;; + esac +done + +# Run installation +run_installation \ No newline at end of file diff --git a/usr/local/bin/devutl b/usr/local/bin/devutl new file mode 100755 index 000000000..bf2966c15 --- /dev/null +++ b/usr/local/bin/devutl @@ -0,0 +1,100 @@ +#!/bin/bash +# +# moOde audio player (C) 2014 Tim Curtis +# http://moodeaudio.org +# +function chk_sudo() { + [[ $EUID -ne 0 ]] && { echo "Use sudo to run the script" ; exit 1 ; } ; +} + +function readYnInput () { + while true; do + read -p "$1" YN + case $YN in + [y] ) break;; + [n] ) break;; + * ) echo "** Valid entries are y|n";; + esac + done +} + +function set_variables() { + IP_ADDRESS="lt5" + REMOTE_DIR="Software/moode-player" + MOUNT_POINT="/mnt/moode-player" + USER_NAME="guest" + PASSWORD='' + OPTIONS="vers=3.0,rw,noserverino,dir_mode=0777,file_mode=0777" + REPO="GitHub/moode" +} + +function mount_share() { + RESULT=$(ls $MOUNT_POINT/$REPO >/dev/null 2>&1) + if [ $? -ne 0 ]; then + echo "Mounting: "$IP_ADDRESS"/"$REMOTE_DIR + mkdir $MOUNT_POINT > /dev/null 2>&1 + mount -t cifs //$IP_ADDRESS/$REMOTE_DIR -o username=$USER_NAME,password=$PASSWORD,$OPTIONS $MOUNT_POINT > /dev/null 2>&1 + if [ $? -ne 0 ]; then + echo "Mount failed" + else + echo "Mount successful" + fi + else + echo "Mount successful" + fi +} + +function watch_for_mount() { + watch "devutl -m" +} + +function remove_dotfiles() { + echo Cleaning /media + cd /media + find . -name "._*" -exec rm -rf {} \; + find . -name ".Trashes" -exec rm -rf {} \; + find . -name "._.Trashes" -exec rm -rf {} \; + find . -name ".Spotlight*" -exec rm -rf {} \; + find . -name ".DS_Store" -exec rm -rf {} \; + find . -name "._.DS_Store" -exec rm -rf {} \; + find . -name ".fseventsd*" -exec rm -rf {} \; + find . -name "._.com.apple.timemachine.donotpresent" -exec rm -rf {} \; + find . -name ".com.apple.timemachine.donotpresent" -exec rm -rf {} \; + find . -name ".TemporaryItems" -exec rm -rf {} \; + find . -name "._.TemporaryItems" -exec rm -rf {} \; + find . -name "._moodecfg.txt" -exec rm -rf {} \; + find . -name "__MACOSX" -exec rm -rf {} \; +} + +function print_help() { + echo -e "Usage: devutl.sh [OPTION] [PI USER]... +Developer utility + +PI USER + Defaults to pi if not specified +OPTIONS + -m\t\tMount software share + -w\t\tWatch for mount success + -r\t\tRemove MacOS dot files in /media + --help\t\tPrint help text +" +} + +# +# Main +# + +chk_sudo +set_variables + +if [ -z "$1" ] || [ $1 = "--help" ]; then + print_help +elif [ $1 = "-m" ]; then + mount_share +elif [ $1 = "-w" ]; then + watch_for_mount $2 +elif [ $1 = "-r" ]; then + remove_dotfiles +else + echo "Unknown option" +fi diff --git a/usr/share/camilladsp/configs/V4-Revox B251 With Haas Effect.yml b/usr/share/camilladsp/configs/V4-Revox B251 With Haas Effect.yml new file mode 100644 index 000000000..69a37a4ae --- /dev/null +++ b/usr/share/camilladsp/configs/V4-Revox B251 With Haas Effect.yml @@ -0,0 +1,226 @@ +description: Revox B251 Tone Control With Haas effect. This pipeline is modeled after the hardware circuit topology of the Revox B251 amplifier. It consists of two parametric equalizers with fixed center frequencies and Q-factors, where only the boost/cut level is variable. Its main advantage is that it leaves the midrange completely untouched, which is critical for preserving the definition of the stereo soundstage. To ensure highly accurate emulation of this tone control use the Selective Resampling option in MPD Config to resample the input to either 88.2kHz or 96kHz depending on the input base frequency. Set "Sample rate (kHz)" to 96 and "Selective resampling" to "Resample (adhere to base freq)". +devices: + adjust_period: 10 + capture: + type: Stdin + channels: 2 + format: S32_LE + chunksize: 4096 + enable_rate_adjust: false + multithreaded: true + playback: + type: Alsa + channels: 2 + device: plughw:2,0 + format: S32_LE + queuelimit: 1 + rate_measure_interval: ~ + samplerate: 44100 + silence_threshold: 0 + silence_timeout: 0 + stop_on_rate_change: false + target_level: 0 + volume_limit: null + volume_ramp_time: 150 + worker_threads: 2 +filters: + haas_high: + description: null + parameters: + freq: 4000 + q: 0.5 + type: Lowpass + type: Biquad + haass_low: + description: null + parameters: + freq: 250 + q: 0.5 + type: Highpass + type: Biquad + hass_delay: + description: null + parameters: + delay: 20 + subsample: true + unit: ms + type: Delay + hass_wet_gain: + description: null + parameters: + gain: 3 + inverted: true + mute: false + scale: dB + type: Gain + revox-b251_bass: + description: null + parameters: + freq: 33 + gain: 8 + q: 0.8 + type: Peaking + type: Biquad + revox-b251_trebble: + description: null + parameters: + freq: 13000 + gain: 6 + q: 0.35 + type: Peaking + type: Biquad +mixers: + haas_input: + channels: + in: 2 + out: 4 + description: null + labels: + - left_dry + - right_dry + - left_wet + - right_wet + mapping: + - dest: 0 + mute: false + sources: + - channel: 0 + gain: 0 + inverted: false + mute: false + scale: dB + - dest: 1 + mute: false + sources: + - channel: 1 + gain: 0 + inverted: false + mute: false + scale: dB + - dest: 2 + mute: false + sources: + - channel: 0 + gain: 0 + inverted: false + mute: false + scale: dB + - dest: 3 + mute: false + sources: + - channel: 1 + gain: 0 + inverted: false + mute: false + scale: dB + haas_ouput: + channels: + in: 4 + out: 2 + description: null + labels: + - left + - right + mapping: + - dest: 0 + mute: false + sources: + - channel: 0 + gain: 0 + inverted: false + mute: false + scale: dB + - channel: 3 + gain: 0 + inverted: false + mute: false + scale: dB + - dest: 1 + mute: false + sources: + - channel: 1 + gain: 0 + inverted: false + mute: false + scale: dB + - channel: 2 + gain: 0 + inverted: false + mute: false + scale: dB + stereo: + channels: + in: 2 + out: 2 + description: null + labels: + - left + - right + mapping: + - dest: 0 + mute: null + sources: + - channel: 0 + gain: 0 + inverted: false + mute: null + scale: null + - dest: 1 + mute: null + sources: + - channel: 1 + gain: 0 + inverted: false + mute: null + scale: null +pipeline: +- bypassed: false + description: null + name: stereo + type: Mixer +- bypassed: null + channels: + - 0 + description: null + names: + - revox-b251_bass + - revox-b251_trebble + type: Filter +- bypassed: null + channels: + - 1 + description: null + names: + - revox-b251_bass + - revox-b251_trebble + type: Filter +- bypassed: null + description: null + name: haas_input + type: Mixer +- bypassed: null + channels: + - 2 + description: null + names: + - haass_low + - haas_high + - hass_delay + - hass_wet_gain + type: Filter +- bypassed: null + channels: + - 3 + description: null + names: + - haass_low + - haas_high + - hass_delay + - hass_wet_gain + type: Filter +- bypassed: null + description: null + name: haas_ouput + type: Mixer +processors: null +title: Revox B251 With Haas effect diff --git a/var/lib/mpd/playlists/Default Playlist.m3u b/var/lib/mpd/playlists/Default Playlist.m3u index 611eb59d5..d8f8f986c 100644 --- a/var/lib/mpd/playlists/Default Playlist.m3u +++ b/var/lib/mpd/playlists/Default Playlist.m3u @@ -4,6 +4,8 @@ OSDISK/Stereo Test/LRMonoPhase4.flac http://ice1.somafm.com/beatblender-128-aac http://ice1.somafm.com/groovesalad-128-aac http://ice2.somafm.com/gsclassic-128-aac +http://ice1.somafm.com/defcon-128-aac +http://ice1.somafm.com/dronezone-128-aac http://ice1.somafm.com/missioncontrol-128-aac http://ice1.somafm.com/deepspaceone-128-aac http://west-aac-64.streamthejazzgroove.com/stream diff --git a/var/local/www/commandw/spotevent.sh b/var/local/www/commandw/spotevent.sh index 36efebbb0..96d8a9d0d 100755 --- a/var/local/www/commandw/spotevent.sh +++ b/var/local/www/commandw/spotevent.sh @@ -93,6 +93,9 @@ if [[ $PLAYER_EVENT == "session_disconnected" ]]; then # Worker picks this up and sends spotactive0 to front-end $(sqlite3 $SQLDB "UPDATE cfg_system SET value='0' WHERE param='spotactive'") + # Truncate metadata file + truncate /var/local/www/spotmeta.json --size 0 + # Local /var/www/util/vol.sh -restore diff --git a/var/local/www/commandw/spspost.sh b/var/local/www/commandw/spspost.sh index ac28b7682..4c1cb81ec 100755 --- a/var/local/www/commandw/spspost.sh +++ b/var/local/www/commandw/spspost.sh @@ -21,6 +21,10 @@ debug_log "Event: Run spspost.sh" # Worker picks this up and sends aplactive0 to front-end $(sqlite3 $SQLDB "UPDATE cfg_system SET value='0' WHERE param='aplactive'") + +# Truncate metadata file +truncate /var/local/www/aplmeta.json --size 0 + # cfg_system RESULT=$(sqlite3 $SQLDB "SELECT value FROM cfg_system WHERE param IN ('alsavolume_max','alsavolume','amixname','mpdmixer','rsmafterapl','camilladsp_volume_sync','inpactive','volknob_mpd','multiroom_tx')") readarray -t arr <<<"$RESULT" diff --git a/var/local/www/db/moode-sqlite3.db.sql b/var/local/www/db/moode-sqlite3.db.sql index aa6f724b0..4d1f796e7 100644 --- a/var/local/www/db/moode-sqlite3.db.sql +++ b/var/local/www/db/moode-sqlite3.db.sql @@ -1,5 +1,5 @@ -- --- File generated with SQLiteStudio v3.4.4 on Sun May 17 11:23:17 2026 +-- File generated with SQLiteStudio v3.4.4 on Sun Aug 2 17:28:41 2026 -- -- Text encoding used: UTF-8 -- @@ -251,7 +251,7 @@ CREATE TABLE cfg_outputdev (id INTEGER PRIMARY KEY, device_name CHAR (32), mpd_v CREATE TABLE cfg_plugin (id INTEGER PRIMARY KEY, component CHAR (32), type CHAR (32), plugin CHAR (32), version CHAR (32)); INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (1, 'camilladsp', 'sample-configs', 'v4-sample-configs', '4.0.0'); INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (2, 'peppydisplay', 'moode-meters', 'v4-moode-meters', '4.0.0'); -INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (3, 'renderer', 'airplay', 'v5-shairport-sync', '5.0.4-1moode1'); +INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (3, 'renderer', 'airplay', 'v5-shairport-sync', '5.2.1-1moode1'); INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (4, 'renderer', 'spotify-connect', 'v8-librespot', '0.8.0-1moode1'); INSERT INTO cfg_plugin (id, component, type, plugin, version) VALUES (5, 'system', 'nqptp', 'v1-nqptp', '1.2.6-1moode1'); @@ -493,6 +493,289 @@ INSERT INTO cfg_radio (id, station, name, type, logo, genre, broadcaster, langua INSERT INTO cfg_radio (id, station, name, type, logo, genre, broadcaster, language, country, region, bitrate, format, geo_fenced, home_page, monitor) VALUES (234, 'https://25583.live.streamtheworld.com/TOPZEN_SC', 'Zen FM', 'r', 'local', 'Lounge', 'Zen FM', 'Dutch', 'Belgium', 'Europe', '192', 'MP3', 'No', '', 'No'); INSERT INTO cfg_radio (id, station, name, type, logo, genre, broadcaster, language, country, region, bitrate, format, geo_fenced, home_page, monitor) VALUES (499, 'zx reserved 499', 'zx reserved 499', 'r', 'zx reserved 499', '', '', '', '', '', '', '', '', '', ''); +-- Table: cfg_rbgenres +CREATE TABLE cfg_rbgenres (id INTEGER PRIMARY KEY, name CHAR (32), genre CHAR (32)); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (1, 'African', 'african'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (2, 'African - Afro House', 'afro house'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (3, 'African - Afro Soul', 'afro soul'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (4, 'African - Afrobeats', 'afrobeats'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (5, 'African - Afropop', 'afropop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (6, 'African - Benga', 'benga'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (7, 'African - Bongo-Flava', 'bongo-flava'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (8, 'African - Coupé-Décalé', 'coupé-décalé'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (9, 'African - Gqom', 'gqom'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (10, 'African - Highlife', 'highlife'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (11, 'African - Kizomba', 'kizomba'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (12, 'African - Kuduro', 'kuduro'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (13, 'African - Kwaito', 'kwaito'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (14, 'African - Maskandi', 'maskandi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (15, 'African - Mbalax', 'mbalax'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (16, 'African - Ndombolo', 'ndombolo'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (17, 'African - Shangaan Electro', 'shangaan electro'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (18, 'African - Soukous', 'soukous'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (19, 'African - Taarab', 'taarab'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (20, 'African - Zouglou', 'zouglou'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (21, 'Alternative', 'alternative'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (22, 'Alternative - EMO', 'emo'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (23, 'Alternative - Goth Rock', 'goth rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (24, 'Alternative - Grunge', 'grunge'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (25, 'Alternative - Indie Pop', 'indie pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (26, 'Alternative - Indie Rock', 'indie rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (27, 'Alternative - New Wave', 'new wave'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (28, 'Alternative - Pop Punk', 'pop punk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (29, 'Alternative - Punk', 'punk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (30, 'Anime', 'anime'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (31, 'Arabic', 'arabic'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (32, 'Blues', 'blues'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (33, 'Blues - Chicago Blues', 'chicago blues'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (34, 'Blues - Country Blues', 'country blues'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (35, 'Blues - Delta Blues', 'delta blues'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (36, 'Brazilian', 'brazilian'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (37, 'Brazilian - Axé', 'axé'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (38, 'Brazilian - Bossa Nova', 'bossa nova'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (39, 'Brazilian - Choro', 'choro'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (40, 'Brazilian - Forró', 'forró'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (41, 'Brazilian - Frevo', 'frevo'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (42, 'Brazilian - MPB', 'mpb'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (43, 'Brazilian - Pagode', 'pagode'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (44, 'Brazilian - Samba', 'samba'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (45, 'Brazilian - Sertanejo', 'sertanejo'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (46, 'Chinese', 'chinese'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (47, 'Classical', 'classical'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (48, 'Classical - Avant-Garde', 'avant-garde'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (49, 'Classical - Baroque', 'baroque'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (50, 'Classical - Cello', 'cello'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (51, 'Classical - Chamber Music', 'chamber music'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (52, 'Classical - Chant', 'chant'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (53, 'Classical - Choral', 'choral'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (54, 'Classical - Contemporary', 'contemporary classical'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (55, 'Classical - Early Music', 'early music'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (56, 'Classical - Guitar', 'classical guitar'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (57, 'Classical - Medieval', 'medieval classical'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (58, 'Classical - Minimalism', 'minimalism'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (59, 'Classical - Opera', 'opera'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (60, 'Classical - Orchestral', 'orchestral'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (61, 'Classical - Piano', 'piano'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (62, 'Classical - Renaissance', 'renaissance'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (63, 'Classical - Sacred', 'sacred'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (64, 'Comedy', 'comedy'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (65, 'Country', 'country'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (66, 'Country - Alternative Country', 'alternative country'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (67, 'Country - Bluegrass', 'bluegrass'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (68, 'Country - Contemporary Country', 'contemporary country'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (69, 'Country - Country Gospel', 'country gospel'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (70, 'Country - Honky Tonk', 'honky tonk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (71, 'Country - Outlaw Country', 'outlaw country'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (72, 'Country - Traditional Country', 'traditional country'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (73, 'Cuban', 'cuban'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (74, 'Cuban - Bolero', 'bolero'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (75, 'Cuban - Chachacha', 'chachacha'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (76, 'Cuban - Guajira', 'guajira'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (77, 'Cuban - Guaracha', 'guaracha'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (78, 'Cuban - Mambo', 'mambo'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (79, 'Cuban - Son', 'son'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (80, 'Cuban - Timba', 'timba'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (81, 'Dance', 'dance'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (82, 'Dance - Breakbeat', 'breakbeat'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (83, 'Dance - Drum & Bass', 'drum & bass'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (84, 'Dance - EDM', 'edm'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (85, 'Dance - Garage', 'garage'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (86, 'Dance - Hardcore', 'hardcore'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (87, 'Dance - House', 'house'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (88, 'Dance - Techno', 'techno'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (89, 'Dance - Trance', 'trance'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (90, 'Decades - 2000''s', '2000''s'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (91, 'Decades - 50''s', '50''s'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (92, 'Decades - 60''s', '60''s'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (93, 'Decades - 70''s', '70''s'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (94, 'Decades - 80''s', '80''s'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (95, 'Decades - 90''s', '90''s'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (96, 'Disney', 'disney'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (97, 'Easy Listening', 'easy listening'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (98, 'Easy Listening - Lounge', 'lounge'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (99, 'Easy Listening - Swing', 'swing'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (100, 'Electronic', 'electronic'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (101, 'Electronic - Ambient', 'ambient'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (102, 'Electronic - Bass', 'bass'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (103, 'Electronic - Downtempo', 'downtempo'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (104, 'Electronic - Dubstep', 'dubstep'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (105, 'Electronic - EDM', 'edm'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (106, 'Electronic - Electronica', 'electronica'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (107, 'Electronic - IDM', 'idm'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (108, 'Electronic - Industrial', 'industrial'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (109, 'Fitness', 'Fitness'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (110, 'Folk', 'folk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (111, 'Gospel', 'gospel'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (112, 'Gospel - Praise & Worship', 'praise & worship'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (113, 'Gospel - Southern Gospel', 'southern gospel'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (114, 'Hip Hop/Rap - East Coast Rap', 'east coast rap'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (115, 'Hip Hop/Rap - Gangsta Rap', 'gangsta rap'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (116, 'Hip Hop/Rap - Hip-Hop', 'hip-hop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (117, 'Hip Hop/Rap - Old School Rap', 'old school rap'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (118, 'Hip Hop/Rap - Rap', 'rap'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (119, 'Hip Hop/Rap - West Coast Rap', 'west coast rap'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (120, 'Holiday', 'holiday'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (121, 'Indian', 'indian'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (122, 'Indian - Bollywood', 'bollywood'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (123, 'Indian - Ghazals', 'ghazals'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (124, 'Indian - Sufi', 'sufi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (125, 'Indian - Tamil', 'tamil'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (126, 'Indian - Telugu', 'telugu'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (127, 'Inspirational', 'inspirational'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (128, 'Instrumental', 'instrumental'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (129, 'Jazz', 'jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (130, 'Jazz - Avant-Garde Jazz', 'avant-garde jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (131, 'Jazz - Bebop', 'bebop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (132, 'Jazz - Big Band', 'big band'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (133, 'Jazz - Contemporary Jazz', 'contemporary jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (134, 'Jazz - Cool', 'cool'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (135, 'Jazz - Crossover Jazz', 'crossover jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (136, 'Jazz - Dixieland', 'dixieland'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (137, 'Jazz - Fusion', 'fusion'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (138, 'Jazz - Hard Bop', 'hard bop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (139, 'Jazz - Latin Jazz', 'latin jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (140, 'Jazz - Mainstream Jazz', 'mainstream jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (141, 'Jazz - Ragtime', 'ragtime'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (142, 'Jazz - Smooth Jazz', 'smooth jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (143, 'Korean', 'korean'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (144, 'Korean - K-Pop', 'k-pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (145, 'Latin', 'latin'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (146, 'Latin - Baladas', 'baladas'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (147, 'Latin - Boleros', 'boleros'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (148, 'Latin - Latin Jazz', 'latin jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (149, 'Latin - Raices', 'raices'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (150, 'Latin - Reggaeton', 'reggaeton'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (151, 'Latin - Regional Mexicano', 'regional mexicano'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (152, 'Latin - Salsa', 'salsa'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (153, 'Latin - Urban', 'latin urban'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (154, 'New Age', 'new age'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (155, 'New Age - Healing', 'healing'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (156, 'New Age - Meditation', 'meditation'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (157, 'New Age - Nature', 'nature'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (158, 'New Age - Relaxation', 'relaxation'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (159, 'New Age - Travel', 'travel'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (160, 'New Age - Yoga', 'yoga'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (161, 'Opera', 'opera'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (162, 'Orchestral', 'orchestral'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (163, 'Pop', 'pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (164, 'Pop - Britpop', 'britpop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (165, 'Pop - French Pop', 'french pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (166, 'Pop - German Pop', 'german pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (167, 'Pop - J-Pop', 'j-pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (168, 'Pop - K-Pop', 'K-pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (169, 'Pop - Kayokyoku', 'kayokyoku'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (170, 'Pop - Malaysian Pop', 'malaysian pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (171, 'Pop - Mandopop', 'mandopop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (172, 'Pop - Oldies', 'oldies'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (173, 'Pop - Pop/Rock', 'pop/rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (174, 'Pop - Shows', 'shows'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (175, 'Pop - Soft Rock', 'soft rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (176, 'Pop - Teen Pop', 'teen pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (177, 'R&B/Soul - Contemporary R&B', 'contemporary r&b'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (178, 'R&B/Soul - Disco', 'disco'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (179, 'R&B/Soul - Funk', 'funk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (180, 'R&B/Soul - Motown', 'motown'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (181, 'R&B/Soul - Neo-Soul', 'neo-soul'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (182, 'R&B/Soul - R&B', 'r&b'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (183, 'R&B/Soul - Soul', 'soul'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (184, 'Reggae', 'reggae'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (185, 'Reggae - Roots Reggae', 'roots reggae'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (186, 'Reggae - Ska', 'ska'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (187, 'Regional Indian - Assamese', 'assamese'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (188, 'Regional Indian - Bengali', 'bengali'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (189, 'Regional Indian - Bhojpuri', 'bhojpuri'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (190, 'Regional Indian - Gujarati', 'gujarati'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (191, 'Regional Indian - Haryanvi', 'haryanvi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (192, 'Regional Indian - Kannada', 'kannada'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (193, 'Regional Indian - Malayalam', 'malayalam'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (194, 'Regional Indian - Marathi', 'marathi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (195, 'Regional Indian - Odia', 'odia'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (196, 'Regional Indian - Punjabi', 'punjabi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (197, 'Regional Indian - Rajasthani', 'rajasthani'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (198, 'Regional Indian - Urdu', 'urdu'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (199, 'Rock', 'rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (200, 'Rock - Adult Alternative', 'adult alternative'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (201, 'Rock - Arena Rock', 'arena rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (202, 'Rock - Art Rock', 'art rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (203, 'Rock - Blues-Rock', 'blues-rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (204, 'Rock - British Invasion', 'british invasion'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (205, 'Rock - Classic Rock', 'classic rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (206, 'Rock - Death Metal', 'death metal'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (207, 'Rock - Folk Rock', 'folk rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (208, 'Rock - Glam Rock', 'glam rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (209, 'Rock - Hair Metal', 'hair metal'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (210, 'Rock - Hard Rock', 'hard rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (211, 'Rock - Heavy Metal', 'heavy metal'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (212, 'Rock - Jam Bands', 'jam bands'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (213, 'Rock - Metal', 'metal'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (214, 'Rock - Prog Rock', 'prog rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (215, 'Rock - Psychedelic', 'psychedelic'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (216, 'Rock - Rock & Roll', 'rock & roll'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (217, 'Rock - Rockabilly', 'rockabilly'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (218, 'Rock - Roots Rock', 'roots rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (219, 'Rock - Southern Rock', 'southern rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (220, 'Rock - Surf', 'surf'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (221, 'Singer/Songwriter', 'singer/songwriter'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (222, 'Singer/Songwriter - Alternative Folk', 'alternative folk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (223, 'Singer/Songwriter - Contemporary Folk', 'contemporary folk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (224, 'Singer/Songwriter - Folk Rock', 'folk rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (225, 'Singer/Songwriter - New Acoustic', 'new acoustic'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (226, 'Singer/Songwriter - Traditional Folk', 'traditional folk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (227, 'Soundtrack', 'soundtrack'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (228, 'Soundtrack - Musicals', 'musicals'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (229, 'Soundtrack - Original Score', 'original score'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (230, 'Soundtrack - Video Game', 'video game'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (231, 'Spoken Word', 'spoken word'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (232, 'Turkish', 'turkish'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (233, 'Turkish - Fantezi', 'fantezi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (234, 'Turkish - Özgün', 'özgün'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (235, 'Turkish - Turkish Alternative', 'turkish alternative'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (236, 'Turkish - Turkish Pop', 'turkish pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (237, 'Turkish - Turkish Rock', 'turkish rock'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (238, 'Vocal', 'vocal'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (239, 'Vocal - Jazz', 'vocal jazz'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (240, 'Vocal - Pop', 'vocal pop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (241, 'Vocal - Standards', 'vocal standards'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (242, 'World', 'world'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (243, 'World - Afrikaans', 'afrikaans'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (244, 'World - Afro-Beat', 'afrobeat'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (245, 'World - Afro-Pop', 'afropop'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (246, 'World - Arabesque', 'arabesque'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (247, 'World - Asia', 'asia'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (248, 'World - Australia', 'australia'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (249, 'World - Cajun', 'cajun'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (250, 'World - Calypso', 'calypso'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (251, 'World - Caribbean', 'caribbean'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (252, 'World - Celtic', 'celtic'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (253, 'World - Celtic', 'celtic'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (254, 'World - Dangdut', 'dangdut'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (255, 'World - Dini', 'dini'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (256, 'World - Enka', 'enka'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (257, 'World - Europe', 'europe'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (258, 'World - Fado', 'fado'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (259, 'World - Farsi', 'farsi'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (260, 'World - Flamenco', 'flamenco'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (261, 'World - France', 'france'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (262, 'World - German', 'german'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (263, 'World - Halk', 'halk'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (264, 'World - Hawaii', 'hawaii'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (265, 'World - Iberia', 'iberia'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (266, 'World - Israeli', 'israeli'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (267, 'World - Japan', 'japan'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (268, 'World - Klezmer', 'klezmer'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (269, 'World - Polka', 'polka'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (270, 'World - Russian', 'russian'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (271, 'World - Russian Chanson', 'russian chanson'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (272, 'World - Sanat', 'sanat'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (273, 'World - Soca', 'soca'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (274, 'World - South Africa', 'south africa'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (275, 'World - Tango', 'tango'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (276, 'World - Worldbeat', 'worldbeat'); +INSERT INTO cfg_rbgenres (id, name, genre) VALUES (277, 'World - Zydeco', 'zydeco'); + +-- Table: cfg_rcucache +CREATE TABLE cfg_rcucache (id INTEGER PRIMARY KEY, title CHAR (32), cover_url CHAR (32)); + -- Table: cfg_sl CREATE TABLE cfg_sl (id INTEGER PRIMARY KEY, param CHAR (20), value CHAR (64)); INSERT INTO cfg_sl (id, param, value) VALUES (1, 'PLAYERNAME', 'Moode'); @@ -609,7 +892,7 @@ INSERT INTO cfg_system (id, param, value) VALUES (63, 'cpugov', 'ondemand'); INSERT INTO cfg_system (id, param, value) VALUES (64, 'pasvc', '0'); INSERT INTO cfg_system (id, param, value) VALUES (65, 'pkgid_suffix', ''); INSERT INTO cfg_system (id, param, value) VALUES (66, 'lib_pos', '-1,-1,-1'); -INSERT INTO cfg_system (id, param, value) VALUES (67, 'radio_track_covers', 'Yes'); +INSERT INTO cfg_system (id, param, value) VALUES (67, 'radio_covers', 'Radio Cover+'); INSERT INTO cfg_system (id, param, value) VALUES (68, 'deezactive', '0'); INSERT INTO cfg_system (id, param, value) VALUES (69, 'peppy_scn_blank_active', '0'); INSERT INTO cfg_system (id, param, value) VALUES (70, 'rsmafterbt', '0'); @@ -687,7 +970,7 @@ INSERT INTO cfg_system (id, param, value) VALUES (141, 'playlist_art', 'Yes'); INSERT INTO cfg_system (id, param, value) VALUES (142, 'library_onetouch_ralbum', 'No action'); INSERT INTO cfg_system (id, param, value) VALUES (143, 'radioview_sort_group', 'Name,No grouping'); INSERT INTO cfg_system (id, param, value) VALUES (144, 'radioview_show_hide', 'No action,No action'); -INSERT INTO cfg_system (id, param, value) VALUES (145, 'renderer_backdrop', 'Yes'); +INSERT INTO cfg_system (id, param, value) VALUES (145, 'RESERVED_145', ''); INSERT INTO cfg_system (id, param, value) VALUES (146, 'library_flatlist_filter', 'full_lib'); INSERT INTO cfg_system (id, param, value) VALUES (147, 'library_flatlist_filter_str', ''); INSERT INTO cfg_system (id, param, value) VALUES (148, 'library_misc_options', 'No,Album@Artist (Default)'); diff --git a/var/local/www/imagesw/radio-logos/Soma FM - DEF CON Radio.jpg b/var/local/www/imagesw/radio-logos/Soma FM - DEF CON Radio.jpg index 2ec6f7553..b308a9e74 100644 Binary files a/var/local/www/imagesw/radio-logos/Soma FM - DEF CON Radio.jpg and b/var/local/www/imagesw/radio-logos/Soma FM - DEF CON Radio.jpg differ diff --git a/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio.jpg b/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio.jpg index 2ec6f7553..1b1d3d392 100644 Binary files a/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio.jpg and b/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio.jpg differ diff --git a/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio_sm.jpg b/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio_sm.jpg index 4b3c3c71d..50217bbca 100644 Binary files a/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio_sm.jpg and b/var/local/www/imagesw/radio-logos/thumbs/Soma FM - DEF CON Radio_sm.jpg differ diff --git a/www/audioinfo.php b/www/audioinfo.php index 7c4732d60..0247fdede 100644 --- a/www/audioinfo.php +++ b/www/audioinfo.php @@ -47,7 +47,11 @@ if ($btActive === true && $_SESSION['audioout'] == 'Local') { $_file = 'Bluetooth stream'; $_encoded_at = sysCmd("bluealsa-cli -v list-pcms | awk -F\": \" '/Selected codec/ {print $2}' | cut -d\":\" -f1")[0]; - $_decoded_to = 'PCM 16 bit 44.1 kHz, Stereo'; + $pcms = sysCmd('bluealsa-cli list-pcms')[0]; + $info = sysCmd('bluealsa-cli info ' . $pcms . " | grep \"Sampling\|Format\" | awk -F\" \" '{print $2}'"); + $bits = substr($info[0], 1, 2); + $rate = formatRate($info[1]); + $_decoded_to = 'PCM ' . $bits . ' bit ' . $rate . ' kHz, Stereo'; $_decode_rate = ''; } else if ($aplActive == '1') { $_file = 'AirPlay stream' . ($disableSync == 'yes' ? ' (sync disabled)' : ''); @@ -210,20 +214,7 @@ $outputMode = $_SESSION['alsa_output_mode']; } -// Bluetooth overrides -if ($btActive === true) { - // Bluetooth inbound - if ($_SESSION['alsa_output_mode'] == 'iec958') { - $outputModeName = ALSA_OUTPUT_MODE_NAME[$_SESSION['alsa_output_mode']]; - } else { - $outputModeName = ALSA_OUTPUT_MODE_BT_NAME[$_SESSION['alsa_output_mode_bt']]; - $outputMode = $_SESSION['alsa_output_mode_bt'] == '_audioout' ? - $_SESSION['alsa_output_mode'] : - 'plughw'; - } -} else { - $outputModeName = ALSA_OUTPUT_MODE_NAME[$_SESSION['alsa_output_mode']]; -} +$outputModeName = ALSA_OUTPUT_MODE_NAME[$_SESSION['alsa_output_mode']]; // Peppy ALSA $peppyAlsa = ($_SESSION['peppy_display'] == '1' || $_SESSION['enable_peppyalsa'] == '1') ? 'PeppyALSA → ' : ''; diff --git a/www/blu-config.php b/www/blu-config.php index 9a495058f..3e565617b 100644 --- a/www/blu-config.php +++ b/www/blu-config.php @@ -117,19 +117,6 @@ $_SESSION['notify']['title'] = NOTIFY_TITLE_INFO; $_SESSION['notify']['msg'] = NOTIFY_MSG_BLUETOOTH_RECONNECT; } -// ALSA output mode: Standard = _audioout, Compatibility = plughw -// NOTE: conflicts with peppy -if (isset($_POST['update_alsa_output_mode_bt']) && $_POST['update_alsa_output_mode_bt'] == '1') { - $_SESSION['alsa_output_mode_bt'] = $_POST['alsa_output_mode_bt']; - if ($_POST['alsa_output_mode_bt'] == 'plughw') { - $alsaDevice = $_SESSION['alsa_output_mode'] == 'iec958' ? getAlsaIEC958Device() : 'plughw' . ':' . $_SESSION['cardnum'] . ',0'; - } else { // _audioout - $alsaDevice = $_POST['alsa_output_mode_bt']; - } - sysCmd("sed -i '/AUDIODEV/c\AUDIODEV=" . $alsaDevice . "' /etc/bluealsaaplay.conf"); - $_SESSION['notify']['title'] = NOTIFY_TITLE_INFO; - $_SESSION['notify']['msg'] = NOTIFY_MSG_BLUETOOTH_RECONNECT; -} // Controller mode if (isset($_POST['update_bluez_controller_mode']) && $_POST['update_bluez_controller_mode'] == '1') { $_SESSION['bluez_controller_mode'] = $_POST['bluez_controller_mode']; @@ -218,10 +205,6 @@ $_select['sbc_quality'] .= "\n"; $_select['sbc_quality'] .= "\n"; -// ALSA output mode -$_select['alsa_output_mode_bt'] .= "\n"; -$_select['alsa_output_mode_bt'] .= "\n"; - // Controller mode $_select['bluez_controller_mode'] .= "\n"; $_select['bluez_controller_mode'] .= "\n"; diff --git a/www/command/audioinfo.php b/www/command/audioinfo.php index 962edb9a1..1b3ed81f9 100644 --- a/www/command/audioinfo.php +++ b/www/command/audioinfo.php @@ -19,7 +19,7 @@ break; case 'track_info': $sock = getMpdSock('command/audioinfo.php'); - sendMpdCmd($sock,'lsinfo "' . $_GET['path'] .'"'); + sendMpdCmd($sock,'lsinfo "' . escapeDblQuotes($_GET['path']) .'"'); echo json_encode(parseTrackInfo(readMpdResp($sock))); break; default: diff --git a/www/command/music-library.php b/www/command/music-library.php index 29865a564..018c94e78 100644 --- a/www/command/music-library.php +++ b/www/command/music-library.php @@ -29,11 +29,11 @@ //sleep(10); // To simulate a long library load echo loadLibrary($sock); break; - case 'get_dbupdate_status': - $stats = getLibraryStats($sock); - $status = ($_SESSION['mpd_dbupdate_status'] == '0' || isset($_GET['lib_stats'])) ? $stats : - 'Files indexed: ' . $_SESSION['mpd_dbupdate_status']; - echo json_encode($status); + case 'get_dbupdate_count': + echo json_encode($_SESSION['mpd_dbupdate_count']); + break; + case 'get_db_stats': + echo json_encode($_SESSION['mpd_db_stats']); break; case 'lsinfo': $path = isset($_GET['path']) && $_GET['path'] != '' ? $_GET['path'] : ''; @@ -105,12 +105,13 @@ } function searchMpdDb($sock, $querytype, $query = '') { + // DEBUG: //workerLog($querytype . ', ' . $query); switch ($querytype) { // List a database path case 'lsinfo': if (!empty($query)){ - sendMpdCmd($sock, 'lsinfo "' . html_entity_decode($query) . '"'); + sendMpdCmd($sock, 'lsinfo "' . escapeDblQuotes(html_entity_decode($query)) . '"'); break; } else { diff --git a/www/command/playlist.php b/www/command/playlist.php index 7775815e9..71f732320 100755 --- a/www/command/playlist.php +++ b/www/command/playlist.php @@ -16,6 +16,144 @@ chkVariables($_POST, array('path')); switch ($_GET['cmd']) { + case 'export_playlist': + // Stream a playlist's .m3u file as a download + $plName = isset($_GET['name']) ? basename(html_entity_decode($_GET['name'])) : ''; + $plFile = MPD_PLAYLIST_ROOT . $plName . '.m3u'; + if ($plName === '' || strpos($plName, '..') !== false || !file_exists($plFile)) { + http_response_code(404); + exit(); + } + header('Content-Description: File Transfer'); + header('Content-Type: audio/x-mpegurl'); + header('Content-Disposition: attachment; filename="' . $plName . '.m3u"'); + header('Content-Length: ' . filesize($plFile)); + header('Pragma: no-cache'); + header('Expires: 0'); + readfile($plFile); + exit(); + case 'analyze_import': + // Classify the entries of an uploaded .m3u so unknown local paths can be remapped. + // When a 'remap' is supplied, it is applied before validating, so the same endpoint + // also serves the modal's "Test" button (re-check paths after remapping). + $content = isset($_POST['content']) ? $_POST['content'] : ''; + if ($content === '' || strlen($content) > 5 * 1024 * 1024) { + echo json_encode(array('status' => 'error', 'msg' => 'File is empty or too large')); + break; + } + $knownDirs = knownPlaylistDirs(); + $remap = isset($_POST['remap']) ? json_decode($_POST['remap'], true) : array(); + if (!is_array($remap)) { + $remap = array(); + } + foreach ($remap as $old => $new) { + if (!in_array($new, $knownDirs, true)) { + unset($remap[$old]); + } + } + $total = 0; + $okLocal = 0; + $urlCount = 0; + $remapped = 0; + $groups = array(); // unknown prefix => count + sample + foreach (preg_split('/\r\n|\r|\n/', $content) as $line) { + $line = trim($line); + if ($line === '' || isMetaEntry($line)) { + continue; + } + $total++; + if (isUrlEntry($line)) { + $urlCount++; + continue; + } + $line = applyPathRemap($line, $remap, $remapped); + $prefix = unknownPathPrefix($line); + if ($prefix === '') { + $okLocal++; + } else { + if (!isset($groups[$prefix])) { + $groups[$prefix] = array('prefix' => $prefix, 'count' => 0, 'sample' => $line); + } + $groups[$prefix]['count']++; + } + } + $unknown = array(); + foreach ($groups as $g) { + $g['suggested'] = suggestKnownDir($g['prefix'], $knownDirs); + $unknown[] = $g; + } + echo json_encode(array('status' => 'ok', 'total' => $total, 'ok_local' => $okLocal, + 'url_count' => $urlCount, 'remapped' => $remapped, 'unknown' => $unknown, 'known_dirs' => $knownDirs)); + break; + case 'import_playlist': + // Write an uploaded .m3u as a new playlist, applying optional path remapping + $content = isset($_POST['content']) ? $_POST['content'] : ''; + if ($content === '' || strlen($content) > 5 * 1024 * 1024) { + echo json_encode(array('status' => 'error', 'msg' => 'File is empty or too large')); + break; + } + $plName = isset($_POST['name']) ? basename(html_entity_decode($_POST['name'])) : ''; + if ($plName === '' || preg_match('/["\\\\$`\/]/', $plName) || strpos($plName, '..') !== false) { + echo json_encode(array('status' => 'error', 'msg' => 'Invalid playlist name')); + break; + } + $plFile = MPD_PLAYLIST_ROOT . $plName . '.m3u'; + if (file_exists($plFile)) { + echo json_encode(array('status' => 'error', 'msg' => 'A playlist with this name already exists')); + break; + } + // Accept only remap targets that are real known dirs (defends against crafted POSTs) + $remap = isset($_POST['remap']) ? json_decode($_POST['remap'], true) : array(); + if (!is_array($remap)) { + $remap = array(); + } + $knownDirs = knownPlaylistDirs(); + foreach ($remap as $old => $new) { + if (!in_array($new, $knownDirs, true)) { + unset($remap[$old]); + } + } + $dropInvalid = isset($_POST['drop_invalid']) && $_POST['drop_invalid'] == '1'; + + $out = array(); + $imported = 0; + $remapped = 0; + $dropped = 0; + foreach (preg_split('/\r\n|\r|\n/', $content) as $line) { + $line = trim($line); + if ($line === '') { + continue; + } + if (isMetaEntry($line) || isUrlEntry($line)) { + $out[] = $line; + if (isUrlEntry($line)) { + $imported++; + } + continue; + } + $line = applyPathRemap($line, $remap, $remapped); + if (file_exists(MPD_MUSICROOT . $line)) { + $out[] = $line; + $imported++; + } else if ($dropInvalid) { + $dropped++; + } else { + $out[] = $line; + $imported++; + } + } + + // MPD_PLAYLIST_ROOT is root-owned so stage in a www-data temp then copy via + // sysCmd (root), matching the owner/perms convention used elsewhere here + $tmpFile = tempnam(sys_get_temp_dir(), 'plimport'); + file_put_contents($tmpFile, implode("\n", $out) . "\n"); + sysCmd('cp "' . $tmpFile . '" "' . $plFile . '"'); + sysCmd('chmod 0777 "' . $plFile . '"'); + sysCmd('chown root:root "' . $plFile . '"'); + unlink($tmpFile); + echo json_encode(array('status' => 'ok', 'name' => $plName, + 'imported' => $imported, 'remapped' => $remapped, 'dropped' => $dropped)); + break; case 'set_plcover_image': if (submitJob($_GET['cmd'], $_POST['name'] . ',' . $_POST['blob'], '', '')) { echo json_encode('job submitted'); @@ -159,7 +297,7 @@ break; case 'get_playlist_contents': $playlist = getPlaylistContents($_POST['path']); - $array = array('name' => $playlist['name'], 'genre' => $playlist['genre'], 'items' => $playlist['items']); + $array = array('name' => $playlist['name'], 'genre' => $playlist['genre'], 'cover' => $playlist['cover'], 'items' => $playlist['items']); echo json_encode($array); break; default: @@ -234,7 +372,7 @@ function getPlaylistContents($plName) { $line2 = 'Radio Station'; } else { // Song file - sendMpdCmd($sock, 'lsinfo "' . $item . '"'); + sendMpdCmd($sock, 'lsinfo "' . escapeDblQuotes($item) . '"'); $tags = parseDelimFile(readMpdResp($sock), ': '); $name = $tags['Title'] ? $tags['Title'] : 'Unknown title'; $line2 = ($tags['Album'] ? $tags['Album'] : 'Unknown album') . ' - ' . @@ -360,3 +498,80 @@ function markItemAsFavorite($item) { return $msg; } + +// Playlist import helpers (path validation and remapping) + +// A metadata line (#EXTGENRE, #EXTIMG, #EXTM3U, #EXTINF, ...) +function isMetaEntry($line) { + return $line !== '' && $line[0] == '#'; +} +// A remote stream / radio station (never path-rewritten) +function isUrlEntry($line) { + return preg_match('#^https?://#i', $line) == 1; +} +// Longest existing prefix of a local entry + the first missing segment, or '' if +// the whole path resolves under the MPD music root (= present in the Folder view) +function unknownPathPrefix($path) { + $accum = ''; + foreach (explode('/', $path) as $seg) { + $test = $accum == '' ? $seg : $accum . '/' . $seg; + if (file_exists(MPD_MUSICROOT . $test)) { + $accum = $test; + } else { + return $test; + } + } + return ''; +} +// Known roots and their immediate subdirs (the Folder view roots/shares), depth <= 2 +function knownPlaylistDirs() { + $dirs = array(); + foreach (ROOT_DIRECTORIES as $root) { + if ($root == 'RADIO') { + continue; + } + $rootPath = MPD_MUSICROOT . $root; + if (is_dir($rootPath)) { + $dirs[] = $root; + foreach (scandir($rootPath) as $sub) { + if ($sub != '.' && $sub != '..' && is_dir($rootPath . '/' . $sub)) { + $dirs[] = $root . '/' . $sub; + } + } + } + } + return $dirs; +} +// Best replacement guess for an unknown prefix: a single same-root share, else '' +function suggestKnownDir($prefix, $knownDirs) { + $root = explode('/', $prefix)[0]; + $shares = array(); + $bareRoot = array(); + foreach ($knownDirs as $dir) { + if ($dir === $root) { + $bareRoot[] = $dir; + } else if (strpos($dir, $root . '/') === 0) { + $shares[] = $dir; + } + } + if (count($shares) == 1) { + return $shares[0]; + } + if (count($shares) == 0 && count($bareRoot) == 1) { + return $bareRoot[0]; + } + return ''; +} +// Apply the longest matching prefix remap rule to a local entry +function applyPathRemap($path, $remap, &$remapped) { + foreach ($remap as $old => $new) { + if ($old === '' || $new === '') { + continue; + } + if ($path === $old || strpos($path, $old . '/') === 0) { + $remapped++; + return $new . substr($path, strlen($old)); + } + } + return $path; +} diff --git a/www/command/queue.php b/www/command/queue.php index 56157ee3f..7eca595de 100755 --- a/www/command/queue.php +++ b/www/command/queue.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/../inc/mpd.php'; require_once __DIR__ . '/../inc/music-library.php'; require_once __DIR__ . '/../inc/queue.php'; +require_once __DIR__ . '/../inc/radio-browser.php'; require_once __DIR__ . '/../inc/session.php'; require_once __DIR__ . '/../inc/sql.php'; @@ -46,6 +47,10 @@ case 'delete_playqueue_item': sendMpdCmd($sock, 'delete ' . $_GET['range']); $resp = readMpdResp($sock); + // A transient radio-browser station (cfg_radio type='rb') exists only while its + // stream is queued; once removed from the queue, prune its row from the DB. + // Favorites (type='f' or type='fb') and native stations (type='r') are kept untouched. + rbPruneOrphanStations(); break; case 'move_playqueue_item': sendMpdCmd($sock, 'move ' . $_GET['range'] . ' ' . $_GET['newpos']); @@ -123,7 +128,7 @@ case 'play_group': case 'play_group_next': // Search the Queue for the group - sendMpdCmd($sock, 'lsinfo "' . $_POST['path'][0] . '"'); + sendMpdCmd($sock, 'lsinfo "' . escapeDblQuotes($_POST['path'][0]) . '"'); $album = parseDelimFile(readMpdResp($sock), ': ')['Album']; $result = findInQueue($sock, 'album', $album); $last = count($_POST['path']) - 1; @@ -151,7 +156,7 @@ } putToggleSongId($pos); break; - /*case 'clear_add_group':*/ + // case 'clear_add_group': case 'clear_play_group': $cmds = array_merge(array('clear'), addGroupToQueue($_POST['path'])); updLibRecentPlaylistVar('None'); diff --git a/www/command/radio-browser.php b/www/command/radio-browser.php new file mode 100644 index 000000000..ac527618b --- /dev/null +++ b/www/command/radio-browser.php @@ -0,0 +1,317 @@ + false, 'message' => 'Unknown command'); + +switch ($cmd) { + case 'logo': + rbServeLogo($_REQUEST['url'] ?? ''); + // rbServeLogo() streams the image (or 302s) and exit()s. + break; + + case 'search': + $params = array( + 'name' => $_REQUEST['name'] ?? '', + 'countrycode' => $_REQUEST['countrycode'] ?? '', + 'tag' => $_REQUEST['tag'] ?? '', + 'offset' => (int)($_REQUEST['offset'] ?? 0), + 'limit' => (int)($_REQUEST['limit'] ?? RADIOBROWSER_LIMIT), + 'order' => 'clickcount', + 'reverse' => 'true', + 'hidebroken' => 'true' + ); + $params = array_filter($params, function ($v) { + return $v !== '' && $v !== null; + }); + $key = 'search_' . md5(json_encode($params)); + $data = rbCacheGet($key, RADIOBROWSER_CACHE_TTL); + if ($data === false) { + $data = rbApi('/json/stations/search', $params); + if ($data !== false) { + rbCacheSet($key, $data); + } else { + $data = rbCacheGet($key, 0); // Stale fallback if API is down + } + } + if ($data !== false) { + $response = array('success' => true, 'stations' => rbShapeResults($data, sqlConnect()), 'batch' => count($data)); + } else { + $response = array('success' => false, 'message' => 'No results or API error'); + } + break; + + case 'countries': + $data = rbCacheGet('countries', RADIOBROWSER_CACHE_TTL_STATIC); + if ($data === false) { + $data = rbApi('/json/countries', array('hidebroken' => 'true')); + array_walk_recursive($data, function(&$item) { + $item = str_replace('The ', '', $item); + }); + usort($data, function($a, $b) { + return $a['name'] <=> $b['name']; + }); + if ($data !== false) { + rbCacheSet('countries', $data); + } + } + $response = $data !== false ? + array('success' => true, 'countries' => $data) : + array('success' => false, 'message' => 'API error'); + break; + + case 'genres': + $data = rbCacheGet('genres', RADIOBROWSER_CACHE_TTL_STATIC); + if ($data === false) { + $result = sqlQuery("SELECT name, genre FROM cfg_rbgenres", sqlConnect()); + usort($result, function($a, $b) { + return $a['name'] <=> $b['name']; + }); + $data = array(); + foreach ($result as $row) { + array_push($data, array('name' => $row['name'], 'genre' => $row['genre'])); + } + if ($data !== false) { + rbCacheSet('genres', $data); + } + } + $response = $data !== false ? + array('success' => true, 'genres' => $data) : + array('success' => false, 'message' => 'API error'); + break; + + case 'recently_played': + $favUrls = rbFavoriteUrls(sqlConnect()); + $stations = array(); + foreach (rbGetRecent() as $s) { + $s['added'] = isset($favUrls[rbNormalizeUrl($s['url'])]); + $stations[] = $s; + } + $response = array('success' => true, 'stations' => $stations); + break; + + case 'add': + $station = rbInputStation(); + $url = trim($station['url'] ?? ''); + if ($url === '' || !preg_match('#^https?://#i', $url) || str_contains($url, '"')) { + $response = array('success' => false, 'message' => 'Invalid station URL'); + break; + } + $name = rbSafeName($station['name'] ?? DEFAULT_STATION_NAME); + $dbh = sqlConnect(); + + // Already in cfg_radio? Promote to favorite (idempotent), never duplicate the stream + $existing = sqlQuery("SELECT id, type, name FROM cfg_radio WHERE station='" . SQLite3::escapeString($url) . "' LIMIT 1", $dbh); + if (is_array($existing)) { + if ($existing[0]['type'] == 'fb') { + $response = array('success' => true, 'message' => 'Station already in Favorites'); + } else { + sqlQuery("UPDATE cfg_radio SET type='fb' WHERE id='" . $existing[0]['id'] . "'", $dbh); + // The native Radio grid plays RADIO/.pls; a promoted 'rb' has none, so + // create it (+ ensure the logo). Stock 'r' stations already ship theirs — don't overwrite. + $exName = $existing[0]['name']; + if (!file_exists(MPD_MUSICROOT . 'RADIO/' . $exName . '.pls')) { + rbEnsureLogo($exName, trim($station['favicon'] ?? '')); + rbWritePls($exName, $url); + } + $response = array('success' => true, 'message' => 'Station added to Favorites'); + } + break; + } + + // New station: create the local logo files (favicon → convert, else default cover) + rbEnsureLogo($name, trim($station['favicon'] ?? '')); + + rbWriteStation(array( + 'url' => $url, + 'name' => $name, + 'genre' => trim($station['tags'] ?? ''), + 'language' => trim($station['language'] ?? ''), + 'country' => trim($station['country'] ?? ''), + 'region' => trim($station['state'] ?? ''), + 'bitrate' => (string)(int)($station['bitrate'] ?? 0), + 'format' => trim($station['codec'] ?? ''), + 'home_page' => trim($station['homepage'] ?? '') + )); + $response = array('success' => true, 'message' => 'Station added to Favorites'); + break; + + case 'remove': + $station = rbInputStation(); + $url = trim($station['url'] ?? ''); + if ($url === '') { + $response = array('success' => false, 'message' => 'No station URL'); + break; + } + $dbh = sqlConnect(); + $row = sqlQuery("SELECT id, name FROM cfg_radio WHERE station='" . SQLite3::escapeString($url) . "' AND type='fb' LIMIT 1", $dbh); + if (!is_array($row)) { + $response = array('success' => false, 'message' => 'Station not in Favorites'); + break; + } + // TODO: Doesn't rb only add stations with id's > 499 ?? + //Core moOde stations (id < 499): just un-favorite (f -> r), keep them in the list. + + // User/imported RB stations (id >= 499): if the stream is STILL in the play queue, + // demote to transient 'rb' so now-playing/thumb keep resolving (the queue-prune deletes + // it once it leaves the queue); only fully delete it when it's not queued anymore. + if ((int)$row[0]['id'] < 499) { + sqlQuery("UPDATE cfg_radio SET type='r' WHERE id='" . $row[0]['id'] . "'", $dbh); + } else { + $queued = rbQueuedUrls(); + if (isset($queued[rbNormalizeUrl($url)])) { + sqlQuery("UPDATE cfg_radio SET type='rb' WHERE id='" . $row[0]['id'] . "'", $dbh); + } else { + rbDeleteStation($row[0]['name']); + } + } + $response = array('success' => true, 'message' => 'Station has been removed'); + break; + + case 'remove_recent': + $station = rbInputStation(); + $url = trim($station['url'] ?? ''); + if ($url === '') { + $response = array('success' => false, 'message' => 'No station URL'); + break; + } + rbRemoveRecent($url); + $response = array('success' => true, 'message' => 'Station has been removed'); + break; + + case 'check_registered': + $result = sqlQuery("SELECT name, type FROM cfg_radio WHERE station='" . SQLite3::escapeString($_REQUEST['url']) . "'", sqlConnect()); + // DEBUG: + //workerLog('URL=' . $_REQUEST['url']); + //workerLog(print_r($result, true)); + if (!empty($result[0]['name'])) { + if ($result[0]['type'] == 'f' || $result[0]['type'] == 'r') { + $response = array('success' => true, 'message' => 'Station exists in Radio view'); + } else { + $response = array('success' => true, 'message' => 'Station already registered'); + } + } else { + $response = array('success' => false, 'message' => 'Station not registered'); + } + break; + + case 'register': + // Called when a Radio Browser tile's context menu opens: make the native queue + // actions (Add/Play/Add next/…) resolve a not-yet-added station (logo + type='rb'). + $station = rbInputStation(); + $url = trim($station['url'] ?? ''); + if ($url === '' || !preg_match('#^https?://#i', $url) || str_contains($url, '"')) { + $response = array('success' => false, 'message' => 'Invalid station URL'); + break; + } + + $reg = rbRegisterStation($station); + + rbAddRecent(array( + 'name' => $reg['name'], + 'url' => $url, + 'favicon' => trim($station['favicon'] ?? ''), + 'country' => trim($station['country'] ?? ''), + 'tags' => trim($station['tags'] ?? ''), + 'bitrate' => (int)$reg['bitrate'], + 'codec' => $reg['format'], + 'stationuuid' => trim($station['stationuuid'] ?? ''), + 'registered_at' => time() + )); + + rbPruneOrphanStations($url); // drop transient 'rb' rows that have left the queue + + $response = array('success' => true, 'message' => 'Registered'); + break; + + // NOTE: Instant play disabled + // Lacks registration checks and time delay needed to ensure Queue thumb shows up + case 'play': + $station = rbInputStation(); + $url = trim($station['url'] ?? ''); + if ($url === '' || !preg_match('#^https?://#i', $url) || str_contains($url, '"')) { + $response = array('success' => false, 'message' => 'Invalid station URL'); + break; + } + $favicon = trim($station['favicon'] ?? ''); + // Ensure logo + cfg_radio type='rb' + session var (so now-playing resolves the stream) + $reg = rbRegisterStation($station); + $name = $reg['name']; + $format = $reg['format']; + $bitrate = $reg['bitrate']; + $homepage = $reg['homepage']; + + $sock = getMpdSock('command/radio-browser.php'); + sendMpdCmd($sock, 'addid "' . $url . '"'); + $resp = readMpdResp($sock); + if (preg_match('/Id:\s*(\d+)/', $resp, $m)) { + sendMpdCmd($sock, 'playid ' . $m[1]); + readMpdResp($sock); + $response = array('success' => true, 'message' => 'Playing: ' . $name, 'name' => $name, 'url' => $url, 'format' => $format, 'bitrate' => $bitrate, 'home_page' => $homepage); + } else { + $response = array('success' => false, 'message' => 'MPD addid failed'); + } + closeMpdSock($sock); + + rbAddRecent(array( + 'name' => $name, + 'url' => $url, + 'favicon' => $favicon, + 'country' => trim($station['country'] ?? ''), + 'tags' => trim($station['tags'] ?? ''), + 'bitrate' => (int)$bitrate, + 'codec' => $format, + 'stationuuid' => trim($station['stationuuid'] ?? ''), + 'played_at' => time() + )); + + rbPruneOrphanStations($url); // drop transient 'rb' rows that have left the queue + + // Click tracking (fire-and-forget) — radio-browser.info best practice + $uuid = trim($station['stationuuid'] ?? ''); + if ($uuid !== '' && preg_match('/^[0-9a-f\-]{36}$/i', $uuid)) { + $options = array('http' => array( + 'method' => 'POST', + 'timeout' => 3.0, + 'header' => "User-Agent: " . RADIOBROWSER_UA . "\r\n" + )); + @file_get_contents('https://' . RADIOBROWSER_API_PRIMARY . '/json/url/' . $uuid, false, stream_context_create($options)); + } + break; + + case 'clear_recents': + case 'clear_caches': + case 'check_servers': + $cmdMap = array( + 'clear_recents' => '--clear-recents', + 'clear_caches' => '--clear-caches', + 'check_servers' => '--check-servers' + ); + $response = sysCmd('/var/www/util/radio-browser.sh ' . $cmdMap[$cmd])[0]; + break; + + default: + $response = array('success' => false, 'message' => 'Unknown command'); + break; +} + +echo json_encode($response); diff --git a/www/command/radio.php b/www/command/radio.php index 40ee1131e..2ead02138 100644 --- a/www/command/radio.php +++ b/www/command/radio.php @@ -6,10 +6,11 @@ require_once __DIR__ . '/../inc/common.php'; require_once __DIR__ . '/../inc/mpd.php'; +require_once __DIR__ . '/../inc/radio.php'; require_once __DIR__ . '/../inc/session.php'; require_once __DIR__ . '/../inc/sql.php'; -chkVariables($_GET, array('track_title')); +chkVariables($_GET, array('title', 'station')); chkVariables($_POST, array('path')); switch ($_GET['cmd']) { @@ -62,9 +63,11 @@ sysCmd('/var/www/daemon/mpdmon.php "' . $_POST['opt'] . '" > /dev/null 2>&1 &'); } break; - case 'get_track_cover_url': - //workerLog('get_track_cover_url: track_title: (' . $_GET['track_title'] . ')'); - echo json_encode(getTrackCoverUrl($_GET['track_title'])); + case 'get_radiocover_url': + echo json_encode(getRadioCoverUrl($_GET['title'], $_GET['station'])); + break; + case 'clear_radiocover_url_cache': + clearRadioCoverUrlCache(); break; default: echo 'Unknown command'; @@ -281,7 +284,7 @@ function putRadioViewShowHide($stBlock, $stType) { } else if ($stBlock == 'Moode geo-fenced') { $whereClause = "WHERE id < '499' AND type != 'f' AND geo_fenced = 'Yes'"; } else if ($stBlock == 'Other') { - $whereClause = "WHERE id > '499' AND type != 'f'"; + $whereClause = "WHERE id > '499' AND substr(type,1 ,1) != 'f' AND type != 'rb'"; } $result = sqlQuery("UPDATE cfg_radio SET type='" . $stType . "' " . $whereClause, $dbh); diff --git a/www/command/renderer.php b/www/command/renderer.php index 967d9c1f6..73fecf270 100644 --- a/www/command/renderer.php +++ b/www/command/renderer.php @@ -52,6 +52,35 @@ case 'get_spotmeta': echo trim(file_get_contents(SPOTMETA_CACHE_FILE)); break; + // Relay the user's answer to a Bluetooth pairing confirmation (see the modal in + // footer.php and bt-pairing-agent.py). $_POST: id, accepted ('1'/'0'), code (optional). + case 'bt_pair_response': + $code = isset($_POST['code']) ? $_POST['code'] : ''; + $ok = sendBtAgentResponse($_POST['id'], $_POST['accepted'], $code); + echo json_encode($ok ? 'ok' : 'failed'); + break; + case 'get_sendspinmeta': + $sspFile = '/var/local/www/sendspinmeta.txt'; + if (file_exists($sspFile)) { + $raw = trim(file_get_contents($sspFile)); + // Try JSON format (from metadata sink/hook) + $json = json_decode($raw, true); + if ($json && isset($json['title'])) { + echo implode('~~~', [ + $json['title'] ?? '', + $json['artist'] ?? '', + $json['album'] ?? '', + '', // track number placeholder + $json['artwork_url'] ?? '' + ]); + } else { + // Plain text fallback (~~~ separated) + echo $raw; + } + } else { + echo ''; + } + break; default: echo 'Unknown command'; break; diff --git a/www/command/sendspin-meta.php b/www/command/sendspin-meta.php new file mode 100644 index 000000000..019875844 --- /dev/null +++ b/www/command/sendspin-meta.php @@ -0,0 +1,31 @@ + None: + """Handle incoming server connection.""" + logger.info("Server connecting...") + + client = SendspinClient( + client_id=CLIENT_ID, + client_name=CLIENT_NAME, + roles=[Roles.METADATA], + ) + + client.add_metadata_listener(on_metadata) + client.add_disconnect_listener(on_disconnect) + client.add_stream_start_listener(on_stream_start) + client.add_stream_end_listener(on_stream_end) + + try: + await client.attach_websocket(ws) + logger.info("Server connected, monitoring stream state...") + disconnect_event = asyncio.Event() + client.add_disconnect_listener(disconnect_event.set) + await disconnect_event.wait() + except Exception as e: + logger.error("Connection error: %s", e) + finally: + clear_meta_file() + + +async def main(): + ensure_dirs() + clear_meta_file() + + if not HA_TOKEN: + logger.error("HA_TOKEN environment variable not set!") + logger.error("Set it in the systemd service file: Environment=\"HA_TOKEN=your_token\"") + sys.exit(1) + + logger.info("Starting SendSpin Metadata Sink (HA polling mode) on port %d", LISTEN_PORT) + logger.info("HA URL: %s, Entity: %s", HA_URL, HA_ENTITY) + + listener = ClientListener( + client_id=CLIENT_ID, + on_connection=handle_connection, + port=LISTEN_PORT, + client_name=CLIENT_NAME, + ) + + stop_event = asyncio.Event() + + def signal_handler(sig, frame): + logger.info("Signal %d received, shutting down", sig) + stop_event.set() + + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + await listener.start() + logger.info("Listening on port %d, advertising as '%s'", LISTEN_PORT, CLIENT_NAME) + + # Start HA polling loop + poll_task = asyncio.create_task(ha_poll_loop()) + + await stop_event.wait() + + logger.info("Stopping...") + poll_task.cancel() + await listener.stop() + clear_meta_file() + logger.info("Stopped") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/www/commandw/sendspin-metadata.sh b/www/commandw/sendspin-metadata.sh new file mode 100755 index 000000000..044f43ce2 --- /dev/null +++ b/www/commandw/sendspin-metadata.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# SendSpin Metadata Hook — lightweight stream state marker +# Called by SendSpin daemon via --hook-start / --hook-stop. +# Defers to metadata sink daemon if it''s running. + +SENDSPINMETA_FILE="/var/local/www/sendspinmeta.txt" + +# If sink daemon is active, let it handle metadata — skip writing +if systemctl -q is-active sendspin-metadata-sink 2>/dev/null; then + logger -t sendspin-metadata "Metadata sink daemon active — skipping hook write" + exit 0 +fi + +# Fallback: write basic streaming status (JSON format, light footprint) +echo '{"status":"streaming"}' > "$SENDSPINMETA_FILE" +logger -t sendspin-metadata "Hook: streaming status written (no daemon)" +exit 0 \ No newline at end of file diff --git a/www/commandw/sendspin-spspre.sh b/www/commandw/sendspin-spspre.sh new file mode 100755 index 000000000..2db5ef8bf --- /dev/null +++ b/www/commandw/sendspin-spspre.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# SendSpin Pre-Start Hook +# Runs before SendSpin daemon starts via systemd ExecStartPre +# Validates audio environment -- uses moOde's standard _audioout device + +SENDSPINMETA_FILE="/var/local/www/sendspinmeta.txt" + +# Clear any stale metadata file +rm -f "$SENDSPINMETA_FILE" + +# Validate the _audioout ALSA device is available +if ! aplay -L 2>/dev/null | grep -q "^_audioout$"; then + echo "WARNING: ALSA device '_audioout' not found in aplay -L" >&2 +fi + +# Log pre-start +logger -t sendspin-spspre "Pre-start hook executed, using moOde _audioout device" +exit 0 \ No newline at end of file diff --git a/www/commandw/sendspin-version-check.sh b/www/commandw/sendspin-version-check.sh new file mode 100755 index 000000000..fefbf61ae --- /dev/null +++ b/www/commandw/sendspin-version-check.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# SendSpin Version Check Script +# Checks PyPI for latest sendspin version +# Outputs JSON: {"installed": "x.y.z", "latest": "a.b.c", "update_available": true/false} + +set -e + +# Get installed version +INSTALLED_VERSION="unknown" +if command -v sendspin >/dev/null 2>&1; then + INSTALLED_VERSION=$(sendspin --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") +fi + +# Get latest version from PyPI +LATEST_VERSION="unknown" +UPDATE_AVAILABLE=false + +PYPI_JSON=$(curl -fsSL --max-time 5 "https://pypi.org/pypi/sendspin/json" 2>/dev/null || echo "{}") + +if echo "$PYPI_JSON" | grep -q '"version"'; then + LATEST_VERSION=$(echo "$PYPI_JSON" | python3 -c " +import sys, json +data = json.load(sys.stdin) +print(data.get('info', {}).get('version', 'unknown')) +" 2>/dev/null || echo "unknown") + + # Compare versions + if [[ "$INSTALLED_VERSION" != "unknown" && "$LATEST_VERSION" != "unknown" ]]; then + # Use Python for version comparison + UPDATE_AVAILABLE=$(python3 -c " +import sys +from packaging import version +try: + installed = version.parse('$INSTALLED_VERSION') + latest = version.parse('$LATEST_VERSION') + print('true' if latest > installed else 'false') +except: + print('false') +" 2>/dev/null || echo "false") + fi +fi + +# Output JSON +cat </dev/null 2>&1; then + fuser /dev/snd/pcmC0D0p 2>/dev/null && logger -t sendspin-spspost "Audio device still in use after stop" || logger -t sendspin-spspost "Audio device released" +fi + +exit 0 \ No newline at end of file diff --git a/www/css/configs.css b/www/css/configs.css index dddb1f190..07213c4d0 100644 --- a/www/css/configs.css +++ b/www/css/configs.css @@ -50,6 +50,7 @@ input[type=password] {display:inline-block;height:22px;vertical-align:top;margin .config-input-mini {width:6vw;max-width:60px;} .config-input-large {width:20.75vw;max-width:210px;} .config-input-xlarge {width:28.75vw;max-width:310px;} +.config-input-xxlarge {width:90%;max-width:750px;} .config-select-large:not([class*=span]) {width:22vw;max-width:220px;margin:0 var(--config-ctl-margin-right) 0 0;} .config-select-xxlarge:not([class*=span]) {width:90%;max-width:750px;margin:0 var(--config-ctl-margin-right) var(--config-ctl-margin-bottom) 0;} .config-select-yn:not([class*=span]) {width:8vw;max-width:80px;margin:0 var(--config-ctl-margin-right) var(--config-ctl-margin-bottom) 0;} diff --git a/www/css/main.min.css b/www/css/main.min.css new file mode 100644 index 000000000..cefa0840c --- /dev/null +++ b/www/css/main.min.css @@ -0,0 +1,25 @@ +/** + * moOde audio player (C) 2014 Tim Curtis + * http://moodeaudio.org + * + * This Program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3, or (at your option) + * any later version. + * + * This Program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * @version 8.1.1 + * @build Sun, Jun 21, 2026 9:13 AM ET + * + */ +/*! jQuery Countdown styles 1.6.2. */.countdown_rtl{direction:rtl}.countdown_holding span{color:#888}.countdown_row{clear:both;width:100%;padding:0 2px;text-align:center}.countdown_show1 .countdown_section{width:98%}.countdown_show2 .countdown_section{width:48%}.countdown_show3 .countdown_section{width:32.5%}.countdown_show4 .countdown_section{width:24.5%}.countdown_show5 .countdown_section{width:19.5%}.countdown_show6 .countdown_section{width:16.25%}.countdown_show7 .countdown_section{width:14%}.countdown_section{display:block;float:left;font-size:75%;text-align:center}.countdown_amount{font-size:200%}.countdown_descr{display:block;width:100%} + + +/*# sourceMappingURL=../maps/css/main.min.css.map */ diff --git a/www/css/media.css b/www/css/media.css index af28d65b1..d83c55f3d 100644 --- a/www/css/media.css +++ b/www/css/media.css @@ -245,6 +245,10 @@ body { #top-columns.nogenre #lib-album {left:100%;width:50%;} #top-columns.nogenre #index-artists {right:calc(0% + var(--sbw));} + /* Radio Browser */ + #rb-filter {width:40vw;} + .rb-select:not([class*=span]) {width:35vw;} + /* Menus */ #panel-header .dropdown-menu, .viewswitch .dropdown-menu {min-width:16rem;font-size:1.3rem;line-height:3.75rem;} diff --git a/www/css/moode.css b/www/css/moode.css index e8a5b09d0..621ba029a 100644 --- a/www/css/moode.css +++ b/www/css/moode.css @@ -203,3 +203,8 @@ ol.playhistory {margin-left:3em;} #playhistory-search {width:84%;margin:auto;} #ph-filter-results {line-height:28px;font-size:14px;font-style:italic;margin-right:10px;} #search-reset-ph {padding:3px 10px;} + +/* Bluetooth pairing confirmation modal */ +.btpair-text {font-size:16px;text-align:center;margin-bottom:4px;} +.btpair-name {font-size:20px;font-weight:bold;text-align:center;margin-bottom:12px;} +.btpair-code {font-size:40px;font-weight:bold;letter-spacing:6px;text-align:center;margin:10px 0;} diff --git a/www/css/panels.css b/www/css/panels.css index 547e0b104..c709577d9 100644 --- a/www/css/panels.css +++ b/www/css/panels.css @@ -130,7 +130,7 @@ html {background-color:inherit;} .configure-renderer, .disconnect-renderer, .turnoff-renderer, -.turnoff-receiver {border-radius:5rem;background-color:var(--btnshade4);margin:0.2em 1vw;padding:0.5rem 0;font-size:.7em;width:10em;color:inherit;} +.turnoff-receiver {border-radius:5rem;background-color:var(--btnshade4);margin:0.2em 1vw;padding:0.65rem 0;font-size:.7em;width:10em;color:inherit;} .renderer-btn {border-radius:5rem;background-color:transparent;padding:1em 0;font-size:.6em;width:3em;color:inherit;} /* Mask hover effect */ @@ -288,23 +288,24 @@ img.coverart { #coverart-url {position:relative;} #container-browse {width:100%;padding:0;top:2.75rem;top:calc(2.75rem + env(safe-area-inset-top));overflow:hidden;position:absolute;} #container-radio, #container-playlist {width:100%;padding:0;top:2.75rem;top:calc(2.75rem + env(safe-area-inset-top));overflow:hidden;position:absolute;} -#database, #database-radio, #database-playlist {overflow:auto;position:relative;width:100%;top:.5em;bottom:0px;height:calc(100vh - 12.25rem);-webkit-overflow-scrolling:touch;} +#database, #database-radio, #database-playlist, .rb-covers-container {overflow:auto;position:relative;width:100%;top:.5em;bottom:0px;height:calc(100vh - 12.25rem);-webkit-overflow-scrolling:touch;} .input-append input::placeholder {color:var(--textvariant);} #playqueue-filter::placeholder {color:var(--textvariant);} #lib-album-filter::placeholder {color:var(--textvariant);font-size:.9em;} -#playqueue, #database, #database-radio, #database-playlist {padding:0;background:none;} +#playqueue, #database, #database-radio, #database-playlist, .rb-covers-container {padding:0;background:none;} .playqueue, .cv-playqueue, .database, .database-radio, .database-playlist {display:block;margin:0;padding:0;list-style:none;counter-reset:item;word-break: break-word;} .database {padding:0 0 12rem 0;width:100%;overflow-x:hidden;margin:0;top:.5em;} .playqueue .cv-playqueue {padding:0 1em 4em 0;} #database-radio, #database-playlist {height:calc(100vh - 2.75em);} +.rb-covers-container {height:calc(100vh - 5.25em);} .database-radio, .database-playlist {text-align:center;padding:0 0 14rem 0;} .playqueue li, .cv-playqueue li, .database li, .database-radio li, .database-playlist li {display:block;position:relative;margin:0;cursor:pointer;text-align:left;padding-left:.75em;} .database li:first-child .db-entry {border-top:1px solid var(--btnshade);} .playqueue li:before, .cv-playqueue li:before {float:left;width:2.9em;letter-spacing:-1px;text-align:right;line-height:normal;counter-increment:item;content:counter(item) ' ';font-size:1em;margin-top:2px;} .database-radio li, .database-playlist li {width:var(--thumbcols);text-align:center;font-size:.95em;margin:0 .1em;display:inline-block;vertical-align:top;height:auto;padding:0 0 .5em 0;} .station-name {font-weight:600;} -.database li {padding:0 2rem 0 1.35rem;} +.database li {padding:0 3.75rem 0 1.35rem;} .database span {display:block;font-weight:normal;color:var(--textvariant);} .playqueue span, .cv-playqueue span {line-height:normal;margin-left:calc(3em + 1vmin);display:block;} .pll1 {font-size:1em;} @@ -380,13 +381,15 @@ img.coverart { .database .db-entry div {padding-left:.25em;} .playqueue .playqueue-action, .playqueue .db-action a {position:relative;width:4.5em;margin-left:1em;font-weight:normal;text-decoration:none;z-index:3;font-size:.9em;line-height:.625em;padding:.318em 0;float:right;} .cv-playqueue .playqueue-action, .cv-playqueue .db-action a {position:relative;width:4.5em;margin-left:1em!important;font-weight:normal;text-decoration:none;z-index:3;font-size:.9em;line-height:.625em;padding:.318em 0;float:right;} +.playqueue .playqueue-action {position:static;} +.playqueue .playqueue-action::after {content:'';position:absolute;top:0;bottom:0;right:0;width:4.5em;z-index:3;} #db-search-results {font-size:1em;font-style:italic;padding:1em;display:none;cursor:pointer;} #playlist-save-name, #playlist-favorites-name {width:85%;} .input-append, .input-prepend {margin-bottom:0em;font-size:1em;} .btnlist {position:fixed;left:0;right:0;display:block;width:auto;height:2.5em;padding:0;background:none;-webkit-border-radius:0px;-moz-border-radius:0px;border-radius:0px;z-index:999;} .btnlist:focus {outline:none;} .btnlist.playqueue-prevPage, .btnlist.db-prevPage, .btnlist.playqueue-firstPage, .btnlist.db-firstPage {padding:0 .3em;} -.btnlist-top-db, .btnlist-top-ra, .btnlist-top-pl { +.btnlist-top-db, .btnlist-top-ra, .btnlist-top-pl, .btnlist-top-rb { position:relative; background-color:var(--btnshade3); top:.25em; @@ -423,7 +426,7 @@ img.coverart { #radio-panel, #playlist-panel {animation: fadeIn var(--fadein-rate);width:100%;height:100vh;position:fixed;top:0;overflow:hidden;} #library-panel {animation: fadeIn var(--fadein-rate);height:100vh;} -.btnlist-top-db button, .btnlist-top-ra button, .btnlist-top-pl button {margin:0;line-height:normal;height:2.5rem;width:2.5rem;font-size:1.1rem;border-radius:0;float:left;display:inline-block;padding:.25rem .5rem;border-right: 1px solid var(--btnshade);} +.btnlist-top-db button, .btnlist-top-ra button, .btnlist-top-pl button, .btnlist-top-rb button {margin:0;line-height:normal;height:2.5rem;width:2.5rem;font-size:1.1rem;border-radius:0;float:left;display:inline-block;padding:.25rem .5rem;border-right: 1px solid var(--btnshade);} #db-back {font-size:1.5rem;} #reconnect, @@ -590,7 +593,7 @@ img.lib-artistart { #lib-content #lib-file li:nth-child(odd) {background:rgba(128,128,128,0.1);} #lib-content #lib-file li:nth-child(odd).active {background:var(--accentxta);} #lib-content li div.active {color:var(--accentxts);} -#lib-content li.active, .albumslist .active, #radio-covers li.active, #playlist-covers li.active {background-color:var(--accentxta);color:#eee;} +#lib-content li.active, .albumslist .active, #radio-covers li.active, #rb-covers-search li.active, #playlist-covers li.active {background-color:var(--accentxta);color:#eee;} .lib-entry {padding:0 .5em;margin:0 .1rem;} .lib-entry-song {margin-left:4px;padding-top:4px;line-height:normal;margin-right:26px;} .lib-track {cursor:pointer;} @@ -615,15 +618,16 @@ img.lib-artistart { #albumcovers .lib-entry, .database-radio .lib-entry, .database-playlist .lib-entry {width:var(--thumbcols);text-align:center;display:inline-block;vertical-align:top;height:auto;font-size:.95em;padding:0 0 .4em 0;} #albumcovers .lib-entry img, .database-radio img, .database-playlist img { position:absolute; - left:var(--thumbmargin); - bottom:0; + top:50%; + left:calc(50% + var(--thumbmargin)); + transform:translate(-50%, -50%); object-fit:contain; width:var(--thumbimagesize); max-height:var(--thumbimagesize); border-style:none; border-radius:var(--thm-border-radius); } -#albumcovers .thumbHW, #radio-covers .thumbHW, #playlist-covers .thumbHW {height:var(--thumbimagesize);width:var(--thumbimagesize);position:relative;margin:.75em 0 .5em 0;} +#albumcovers .thumbHW, #radio-covers .thumbHW, #playlist-covers .thumbHW, .rb-covers-container .thumbHW {height:var(--thumbimagesize);width:var(--thumbimagesize);position:relative;margin:.75em 0 .5em 0;} #albumcovers .artyear {color:var(--textvariant);font-weight:500;} #albumcovers .artist-name { margin:0 .15em; @@ -728,7 +732,8 @@ body.cvwide #playback-controls {display:none!important;} #splash div {position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);font-size:10vw;font-weight:300;letter-spacing:-.06em;opacity:0;transition:1s;} .cover-menu {position:absolute;float:right;height:5rem;width:5rem;background-size:2rem 2rem;background-repeat:no-repeat;background-position:.75rem 2.4rem;transform:translate(.75rem, -5.5rem);background-image:url('../images/dots.png')} body.no-touch .cover-menu {opacity:0;} -#radio-covers li:hover .cover-menu, #playlist-covers li:hover .cover-menu, #albumcovers li:hover .cover-menu {opacity:1;} +#radio-covers li:hover .cover-menu, #playlist-covers li:hover .cover-menu, #albumcovers li:hover .cover-menu, +#rb-covers-search li:hover .cover-menu, #rb-covers-recent li:hover .cover-menu, #rb-covers-fav li:hover .cover-menu {opacity:1;} #playback-toolbar {display:none;z-index:1003;transform:translate(-50%, -50%);top:2%;top:calc(env(safe-area-inset-top) + 2%);left:50%;position:fixed;color:var(--adapttext);border-radius:0 0 .5em .5em;background-color:inherit;backdrop-filter:blur(5px);-webkit-backdrop-filter:blur(5px);box-shadow:0px 0px 10px rgba(0, 0, 0, 0.40);} /* Equalizers */ @@ -1001,12 +1006,19 @@ body.cv .timeline-thm input[type='range']::-webkit-slider-thumb { .busy-spinner-btn-saved-search svg {stroke:var(--adapttext);height:1.5rem;width:1.5rem;} #tagview-text-cover {position:relative;font-size:1.8rem;line-height:1.1em;height:calc(20vw - 1rem);width:calc(20vw - 1rem);background:rgba(64,64,64,.2);box-shadow: 0px 0px .2em rgba(0,0,0,0.2);word-break:break-word;} .plview-text-cover-div {margin:0 .5em;} -.plview-text-cover {position:relative; +.plview-text-cover { + position:relative; font-size:var(--thumbtextcoverfontsize); top:1rem; width:100%; left:var(--thumbmargin); - text-align:center;} + text-align:center; +} +.plview-edit-thumb { + position:absolute; + top:.5rem; + left:var(--thumbmargin); +} #station-path {text-align:center;} #track-info-text {text-align:left;} .no-tagview-covers {padding-top:.4em;padding-bottom:.4em;} @@ -1133,3 +1145,24 @@ body.cv .timeline-thm input[type='range']::-webkit-slider-thumb { #multiroom-sender {position:relative;display:none;top:env(safe-area-inset-top);transform:translate(-55%);left:50%;} #multiroom-sender a {font-size:1.25rem;color:var(--textvariant);/*color:var(--adapttext);*/} #menu-cdsp {color:var(--adapttext);} + +/* Radio Browser view (radio-browser.info) */ +.btnlist-top-rb button.rb-tab {width:auto;padding:.25rem .75rem;font-size:.95rem;} +.btnlist-top-rb button.active {background-color:var(--accentxta);color:var(--adapttext);} +.btnlist-top-rb button:first-child.active {border-top-left-radius:var(--btn-border-radius);border-bottom-left-radius:var(--btn-border-radius);} +.btnlist-top-rb .rb-toggle-btn.active {border-top-right-radius:var(--btn-border-radius);border-bottom-right-radius:var(--btn-border-radius);} +#rb-search {display:block;float:left;margin:0;z-index:1001;position:relative;} +#rb-search input {margin-left:.5em;padding:0;height:2.5rem;border:none;font-size:inherit;width:20vw;} +#rb-filters {float:left;} +#rb-country-menu, #rb-genre-menu {height:70vh;overflow:scroll;background-color:var(--adaptmbg);} +#rb-country-list, #rb-genre-list {color:var(--adapttext);} +.rb-select:not([class*=span]) {width:15vw;margin:0 .85em 0 0;} +.btnlist-top-ra .rb-toggle-btn, .btnlist-top-rb .rb-toggle-btn {float:right;} +.rb-tab-pane {position:relative;} +.rb-empty {font-size:1.25em!important;padding:2rem 2em!important;width:50%!important;display:block!important;text-align:left!important;color:var(--textvariant);} +.rb-fav-toggle {position:absolute;top:.4rem;right:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center;border-radius:50%;background-color:var(--btnshade2);font-size:1.2rem;z-index:2;cursor:pointer;} +.rb-fav-toggle.added {color: var(--accentxts);} +.rb-fav-toggle .fa-heart {margin-top:4px;} +#rb-covers-search #rb-showmore {display:block;width:100%;text-align:center;padding:.5rem 0 0 0;margin:0;cursor:default;} +#rb-covers-search #rb-showmore button {font-size:.9em;background:var(--btnshade2);border-radius:3em;margin:1em auto;padding:.5em 1.25em .6em 1.25em;line-height:normal;height:auto;width:auto;} +#rb-covers-search #rb-showmore button:hover {background:var(--btnshade2);} /* Mask hover effect */ diff --git a/www/daemon/bt-pairing-agent.py b/www/daemon/bt-pairing-agent.py new file mode 100644 index 000000000..9021a9c17 --- /dev/null +++ b/www/daemon/bt-pairing-agent.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +# Copyright 2014 The moOde audio player project / Tim Curtis +# +# Bluetooth pairing agent. +# +# Replaces `bt-agent` (bluez-tools). Registered with the DisplayYesNo capability +# it drives Secure Simple Pairing "Numeric Comparison": on a pairing request bluez +# hands us the 6-digit code, we show it in the moOde UI and wait for the user to +# confirm it matches the code on their device. Confirming yields an authenticated +# (MITM-protected) link key - the legacy PIN it replaces could not. +# +# The agent is deliberately unaware of the UI: it pushes a small message to the +# front-end (via send-fecmd.php) and waits for a yes/no on a local socket. Any +# other front-end speaking that message contract would work unchanged. +# +# Front-end contract (see command/renderer.php + playerlib.js): +# push -> pairreq,,,,, (method: confirm|display|input|authorize) +# reply <- pairresp,,<1|0>[,] (on RESPONSE_SOCK) +# push -> paircancel, (timed out or device gave up) + +import base64 +import os +import pwd +import socket +import subprocess +import sys +import uuid + +import dbus +import dbus.mainloop.glib +import dbus.service +from gi.repository import GLib + +AGENT_PATH = '/org/bluez/moode_agent' +# DisplayYesNo drives Numeric Comparison (authenticated). NoInputNoOutput falls +# back to Just Works, i.e. today's behaviour with no modal. argv wins for testing, +# then the unit's environment, then the default. +CAPABILITY = (len(sys.argv) > 1 and sys.argv[1]) \ + or os.environ.get('BT_AGENT_CAPABILITY') or 'DisplayYesNo' +SEND_FECMD = '/var/www/util/send-fecmd.php' +RESPONSE_SOCK = '/tmp/moode-btagent.sock' +RESPONSE_USER = 'www-data' # front-end (php-fpm) writes the reply here +# Safety net only. Normal closure is driven by bluez calling Cancel() when the device +# gives up, which keeps the modal in sync with what the phone shows. This long timeout +# just prevents a stuck modal if Cancel() never arrives. +CONFIRM_TIMEOUT = 60 + + +def log(msg): + print(msg, flush=True) + + +def push_fe(cmd): + # Fire-and-forget notify to every connected UI; never let it block the agent. + try: + subprocess.Popen(['php', SEND_FECMD, cmd], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception as e: + log('push_fe failed: %s' % e) + + +def device_props(path): + bus = dbus.SystemBus() + props = dbus.Interface(bus.get_object('org.bluez', path), + 'org.freedesktop.DBus.Properties') + def get(name, default=''): + try: + return str(props.Get('org.bluez.Device1', name)) + except dbus.DBusException: + return default + return get('Name', 'Bluetooth device'), get('Icon', 'bluetooth') + + +class Rejected(dbus.DBusException): + _dbus_error_name = 'org.bluez.Error.Rejected' + + +class PairingAgent(dbus.service.Object): + def __init__(self, bus, path): + super().__init__(bus, path) + self.pending = {} # id -> {'reply', 'error', 'timeout', 'code'} + + # --- request lifecycle ------------------------------------------------ + def _open(self, method, device, code, reply, error): + req_id = uuid.uuid4().hex[:8] + name, icon = device_props(device) + name_b64 = base64.b64encode(name.encode()).decode() + timeout = GLib.timeout_add_seconds(CONFIRM_TIMEOUT, self._expire, req_id) + self.pending[req_id] = {'reply': reply, 'error': error, + 'timeout': timeout, 'code': code} + push_fe('pairreq,%s,%s,%s,%s,%s' % (req_id, method, code, name_b64, icon)) + log('%s(%s) code=%s -> req %s' % (method, device, code, req_id)) + return req_id + + def _resolve(self, req_id, accepted, code=None): + req = self.pending.pop(req_id, None) + if req is None: + return # already resolved (duplicate/late reply): ignore silently + log('resolve req %s accepted=%s' % (req_id, accepted)) + GLib.source_remove(req['timeout']) + if accepted: + if req['code'] == '__input__': + req['reply'](dbus.UInt32(code)) + else: + req['reply']() + else: + req['error'](Rejected('Rejected by user')) + # Close the dialog on any other UI that also popped it (browser + local + # display): the client that answered has already closed its own. + push_fe('paircancel,%s' % req_id) + + def _expire(self, req_id): + req = self.pending.pop(req_id, None) + if req is not None: + req['error'](Rejected('Timed out')) + push_fe('paircancel,%s' % req_id) + log('req %s timed out' % req_id) + return False + + def _cancel_all(self): + for req_id in list(self.pending): + req = self.pending.pop(req_id) + GLib.source_remove(req['timeout']) + req['error'](Rejected('Cancelled')) + push_fe('paircancel,%s' % req_id) + + # --- org.bluez.Agent1 ------------------------------------------------- + @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') + def Release(self): + log('Release') + + @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='', + async_callbacks=('reply', 'error')) + def AuthorizeService(self, device, uuid_, reply, error): + # A2DP/AVRCP on an already-paired device: accept silently, like today. + log('AuthorizeService(%s, %s) -> accept' % (device, uuid_)) + reply() + + @dbus.service.method('org.bluez.Agent1', in_signature='ou', out_signature='', + async_callbacks=('reply', 'error')) + def RequestConfirmation(self, device, passkey, reply, error): + self._open('confirm', device, '%06u' % passkey, reply, error) + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='', + async_callbacks=('reply', 'error')) + def RequestAuthorization(self, device, reply, error): + self._open('authorize', device, '', reply, error) + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='u', + async_callbacks=('reply', 'error')) + def RequestPasskey(self, device, reply, error): + self._open('input', device, '__input__', reply, error) + + @dbus.service.method('org.bluez.Agent1', in_signature='ouq', out_signature='') + def DisplayPasskey(self, device, passkey, entered): + # Informational: show the code the user must type on their device. + name, icon = device_props(device) + name_b64 = base64.b64encode(name.encode()).decode() + push_fe('pairreq,%s,display,%06u,%s,%s' % (uuid.uuid4().hex[:8], passkey, name_b64, icon)) + log('DisplayPasskey(%s, %06u)' % (device, passkey)) + + @dbus.service.method('org.bluez.Agent1', in_signature='os', out_signature='') + def DisplayPinCode(self, device, pincode): + log('DisplayPinCode(%s, %s) -> ignored (legacy)' % (device, pincode)) + + @dbus.service.method('org.bluez.Agent1', in_signature='o', out_signature='s') + def RequestPinCode(self, device): + # Legacy PIN pairing is not offered; reject so bluez does not fall back to it. + log('RequestPinCode(%s) -> reject (legacy not supported)' % device) + raise Rejected('Legacy PIN not supported') + + @dbus.service.method('org.bluez.Agent1', in_signature='', out_signature='') + def Cancel(self): + log('Cancel') + self._cancel_all() + + +def on_response(sock, _cond, agent): + try: + data = sock.recv(256).decode().strip() + except OSError: + return True + for line in data.splitlines(): + parts = line.split(',') + if parts[0] == 'pairresp' and len(parts) >= 3: + req_id, accepted = parts[1], parts[2] == '1' + code = int(parts[3]) if accepted and len(parts) >= 4 and parts[3].isdigit() else None + agent._resolve(req_id, accepted, code) + return True + + +def make_response_socket(): + if os.path.exists(RESPONSE_SOCK): + os.unlink(RESPONSE_SOCK) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + sock.bind(RESPONSE_SOCK) + # php-fpm (www-data) must be able to write the user's answer here. + ent = pwd.getpwnam(RESPONSE_USER) + os.chown(RESPONSE_SOCK, ent.pw_uid, ent.pw_gid) + os.chmod(RESPONSE_SOCK, 0o660) + return sock + + +def main(): + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SystemBus() + + agent = PairingAgent(bus, AGENT_PATH) + manager = dbus.Interface(bus.get_object('org.bluez', '/org/bluez'), + 'org.bluez.AgentManager1') + manager.RegisterAgent(AGENT_PATH, CAPABILITY) + manager.RequestDefaultAgent(AGENT_PATH) + log('registered as default agent, capability=%s' % CAPABILITY) + + sock = make_response_socket() + GLib.io_add_watch(sock, GLib.IO_IN, lambda s, c: on_response(s, c, agent)) + + loop = GLib.MainLoop() + try: + loop.run() + except KeyboardInterrupt: + pass + finally: + try: + manager.UnregisterAgent(AGENT_PATH) + except dbus.DBusException: + pass + if os.path.exists(RESPONSE_SOCK): + os.unlink(RESPONSE_SOCK) + + +if __name__ == '__main__': + main() diff --git a/www/daemon/peppy-gain.php b/www/daemon/peppy-gain.php new file mode 100755 index 000000000..b5bc12572 --- /dev/null +++ b/www/daemon/peppy-gain.php @@ -0,0 +1,70 @@ +#!/usr/bin/php +/dev/null', 'r'); + if ($monitor !== false) { + while (fgets($monitor) !== false) { + publishGainDb($dbh); + } + pclose($monitor); + } + // Only reached if the card went away (USB DAC unplugged). The worker restarts us + // on a card change; keep retrying so a replug alone is enough to recover. + sleep(PEPPY_GAIN_MON_RETRY); +} diff --git a/www/daemon/touchmon.php b/www/daemon/touchmon.php index fcb5cd8f0..d1ff219bb 100644 --- a/www/daemon/touchmon.php +++ b/www/daemon/touchmon.php @@ -18,6 +18,7 @@ //debugLog('touchmon: Started'); $timeoutArg = !isset($argv[1]) ? TOUCHMON_TIMEOUT_DEFAULT : $argv[1]; $timeout = $timeoutArg; +$closedCount = 0; $dbh = sqlConnect(); sysCmd('rm ' . TOUCHMON_LOG . ' > /dev/null'); sysCmd('killall -s9 xinput > /dev/null'); @@ -80,9 +81,16 @@ } } // Switch to WebUI + // MPD closes the ALSA device between tracks, so a single closed reading is + // not proof that playback stopped. Require a few in a row. if (isPeppyOn($dbh) === true && isAudioPlaying() === false) { - //debugLog('touchmon: - switch to webui'); - exec('sudo moodeutl --setdisplay webui'); + if (++$closedCount >= TOUCHMON_CLOSED_COUNT) { + //debugLog('touchmon: - switch to webui'); + exec('sudo moodeutl --setdisplay webui'); + $closedCount = 0; + } + } else { + $closedCount = 0; } } else { //debugLog('touchmon: - WARNING: peppyalsa is not enabled'); diff --git a/www/daemon/worker.php b/www/daemon/worker.php index a88582785..38e5a8fc7 100755 --- a/www/daemon/worker.php +++ b/www/daemon/worker.php @@ -18,6 +18,7 @@ require_once __DIR__ . '/../inc/music-source.php'; require_once __DIR__ . '/../inc/network.php'; require_once __DIR__ . '/../inc/peripheral.php'; +require_once __DIR__ . '/../inc/radio-browser.php'; require_once __DIR__ . '/../inc/renderer.php'; require_once __DIR__ . '/../inc/session.php'; require_once __DIR__ . '/../inc/sql.php'; @@ -144,6 +145,9 @@ } // - Delete session vars that have been removed or renamed $sessionVars = array( + 'mpd_dbupdate_status', + 'trackcover_url_cache', + 'radio_track_covers' ); foreach ($sessionVars as $var) { sysCmd('moodeutl -D ' . $var); @@ -188,6 +192,8 @@ sysCmd('touch ' . SLPOWER_LOG); sysCmd('truncate ' . MOUNTMON_LOG . ' --size 0'); sysCmd('mkdir ' . THMCACHE_DIR . ' > /dev/null 2>&1'); +// Radio Browser caches are written synchronously by www-data (php-fpm), so unlike moOde's +sysCmd('/var/www/util/radio-browser.sh --fix-permissions > /dev/null 2>&1'); // Delete any tmp files left over from New/Edit station or playlist sysCmd('rm /var/local/www/imagesw/radio-logos/' . TMP_IMAGE_PREFIX . '* > /dev/null 2>&1'); sysCmd('rm /var/local/www/imagesw/radio-logos/thumbs/' . TMP_IMAGE_PREFIX . '* > /dev/null 2>&1'); @@ -211,7 +217,6 @@ sysCmd('chmod 0666 ' . SLPOWER_LOG); sysCmd('chmod 0666 ' . MOODE_LOG); sysCmd('chmod 0666 ' . MOUNTMON_LOG); -sysCmd('chmod 0600 ' . BT_PINCODE_CONF); if (!file_exists(ETC_MACHINE_INFO)) { sysCmd('cp /usr/share/moode-player' . ETC_MACHINE_INFO . ' /etc/'); workerLog('worker: File check: created default /etc/machine-info'); @@ -739,6 +744,13 @@ // ALSA mixer phpSession('write', 'amixname', getAlsaMixerName($_SESSION['adevname'])); workerLog('worker: ALSA mixer: ' . ($_SESSION['amixname'] == 'none' ? 'none exists' : $_SESSION['amixname'])); +// Drop a stray softvol control left under the simple mixer name by an earlier release. +// ALSA restores it at boot, where it shadows the hardware control. Only application +// created controls are removed, so a hardware element is never touched. +if ($_SESSION['amixname'] != 'none') { + sysCmd('alsactl clean ' . $_SESSION['cardnum'] . ' "name=\'' . $_SESSION['amixname'] . '\'"'); + sysCmd('alsactl store ' . $_SESSION['cardnum']); +} // HDMI mixer initialize (after first boot a test signal needs to be sent to "register" the mixer with ALSA) if ($_SESSION['alsa_output_mode'] == 'iec958') { $result = getAlsaVolume($_SESSION['amixname']); @@ -912,9 +924,13 @@ } } -// Database update item count -if (!isset($_SESSION['mpd_dbupdate_status'])) { - $_SESSION['mpd_dbupdate_status'] = 0; +// Database update file count +if (!isset($_SESSION['mpd_dbupdate_count'])) { + $_SESSION['mpd_dbupdate_count'] = 0; +} +// Database stats (artists/albums/tracks) +if (!isset($_SESSION['mpd_db_stats'])) { + $_SESSION['mpd_db_stats'] = 'none'; } // Start MPD @@ -979,9 +995,9 @@ workerLog('worker: MPD CDSP volsync: ' . lcfirst($_SESSION['camilladsp_volume_sync'])); $serviceCmd = CamillaDSP::isMPD2CamillaDSPVolSyncEnabled() ? 'start' : 'stop'; sysCmd('systemctl ' . $serviceCmd .' mpd2cdspvolume'); -// Library stats -$stats = getLibraryStats($sock); -workerLog('worker: Library stats: ' . $stats); +workerLog('worker: Database stats: ' . + ($_SESSION['mpd_db_stats'] == 'none' ? 'Analyze has not been run' : $_SESSION['mpd_db_stats']) +); //----------------------------------------------------------------------------// workerLog('worker: --'); @@ -1058,10 +1074,12 @@ // Bluetooth session vars $status = 'session vars ok'; -if (!isset($_SESSION['bt_pin_code'])) { +if (!isset($_SESSION['bt_pairing_confirm'])) { $status = 'session vars created'; - $_SESSION['bt_pin_code'] = ''; + $_SESSION['bt_pairing_confirm'] = '1'; } +// Keep the agent's capability file in step with the setting before it is started. +applyBtPairingConfirm($_SESSION['bt_pairing_confirm']); // ALSA/CDSP max volumes if (!isset($_SESSION['alsavolume_max_bt'])) { $_SESSION['alsavolume_max_bt'] = $_SESSION['alsavolume_max']; @@ -1073,10 +1091,6 @@ if (!isset($_SESSION['bluez_sbc_quality'])) { $_SESSION['bluez_sbc_quality'] = 'xq+'; } -// ALSA output mode -if (!isset($_SESSION['alsa_output_mode_bt'])) { - $_SESSION['alsa_output_mode_bt'] = '_audioout'; -} // Controller mode if (!isset($_SESSION['bluez_controller_mode'])) { $_SESSION['bluez_controller_mode'] = 'dual'; @@ -1092,20 +1106,23 @@ } else { $status = 'n/a'; } -$status .= ', PIN: ' . (empty($_SESSION['bt_pin_code']) ? 'None' : 'Set'); +$status .= ', Pair confirm: ' . ($_SESSION['bt_pairing_confirm'] == '1' ? 'On' : 'Off'); $status .= ', ALSA/CDSP max: ' . $_SESSION['alsavolume_max_bt'] . '%/' . $_SESSION['cdspvolume_max_bt'] . 'dB'; -$status .= ', ALSA out: ' . ALSA_OUTPUT_MODE_BT_NAME[$_SESSION['alsa_output_mode_bt']]; $status .= ', Transport: ' . $_SESSION['bluez_controller_mode']; workerLog('worker: Bluetooth: ' . $status); // Start airplay renderer if ($_SESSION['feat_bitmask'] & FEAT_AIRPLAY) { + if (!isset($_SESSION['airplaysvc_type'])) { + $_SESSION['airplaysvc_type'] = '2'; + } if (isset($_SESSION['airplaysvc']) && $_SESSION['airplaysvc'] == 1) { $status = 'started'; startAirPlay(); } else { $status = 'available'; } + $status = $status . ', protocol: ' . $_SESSION['airplaysvc_type']; } else { $status = 'n/a'; } @@ -1422,6 +1439,12 @@ } startLocalDisplay(); } +// Not gated on peppy_display: that only says which screen touchmon is showing right now +// (it swaps to the WebUI whenever playback stops), while the meter can come back at the +// next track. Follow the ALSA chain instead, like updAudioOutAndBtOutConfs() does. +if ($_SESSION['peppy_display'] == '1' || $_SESSION['enable_peppyalsa'] == '1') { + startPeppyGainMon(); +} // WebUI display workerLog('worker: WebUI display: ' . ($_SESSION['local_display'] == '1' ? 'on' : 'off')); @@ -1521,9 +1544,9 @@ // NOTE: updaterAutoCheck() logs status $_SESSION['updater_available_update'] = updaterAutoCheck($validIPAddress); -// Radio track covers -workerLog('worker: Radio track covers: ' . lcfirst($_SESSION['radio_track_covers'])); -workerLog('worker: iTunes query timeout: ' . $_SESSION['itunes_query_timeout'] . ' sec(s)'); +// Radio cover search provider +workerLog('worker: Radio covers: ' . $_SESSION['radio_covers']); +workerLog('worker: iTunes timeout: ' . $_SESSION['itunes_query_timeout'] . ' secs'); // Automatic CoverView (Preferences) workerLog('worker: Auto-CoverView: ' . ($_SESSION['auto_coverview'] == '-on' ? 'on' : 'off')); @@ -1647,13 +1670,6 @@ $_SESSION['lib_fv_only'] = 'off'; } -// Radio track cover URL cache -if (!isset($_SESSION['trackcover_url_cache'])) { - $_SESSION['trackcover_url_cache'] = ''; -} -// Empty cache -$_SESSION['trackcover_url_cache'] = array('' => ''); // trackTitle => URL - // Metadata file if (!isset($_SESSION['extmeta'])) { $_SESSION['extmeta'] = '0'; @@ -1949,6 +1965,10 @@ //debugLog('** chkPeppyScnBlank'); chkPeppyScnBlank(); } + if ($_SESSION['peppy_display'] == '1' || $_SESSION['enable_peppyalsa'] == '1') { + //debugLog('** chkPeppyGainMon'); + chkPeppyGainMon(); + } // CoverView (as screen saver) if ($_SESSION['scnsaver_timeout'] != 'Never') { //debugLog('** chkScnSaver'); @@ -2280,6 +2300,16 @@ function chkAttachedDisplayOnOff() { sendFECmd('local_display_onoff,' . $currentOnOff); } } +// Tracks Hardware volume and updates peppy +function chkPeppyGainMon() { + // The meter gain is only as good as the daemon that publishes it: if it dies the + // needles keep displaying the last dB and silently stop following the volume. + // The [.] keeps the pattern from matching the shell that runs pgrep itself. + if (sysCmd('pgrep -c -f "peppy-gain[.]php"')[0] == '0') { + workerLog('worker: Peppy gain monitor: not running, restarted'); + startPeppyGainMon(); + } +} // Peppy screen blank // - Timeout is set // - Peppy is on @@ -2359,18 +2389,17 @@ function chkLibraryUpdate() { workerLog('worker: CRITICAL ERROR: chkLibraryUpdate(): Connection to MPD failed'); } else { $status = getMpdStatus($sock); - $stats = getLibraryStats($sock); closeMpdSock($sock); - $_SESSION['mpd_dbupdate_status'] = countMpdLogLines(); - if ($_SESSION['mpd_dbupdate_status'] != 0) { - debugLog('mpdindex: File count ' . $_SESSION['mpd_dbupdate_status']); + $_SESSION['mpd_dbupdate_count'] = countMpdLogLines(); + if ($_SESSION['mpd_dbupdate_count'] != 0) { + debugLog('mpdindex: File count ' . $_SESSION['mpd_dbupdate_count']); } if (!isset($status['updating_db'])) { sendFECmd('libupd_done'); $GLOBALS['check_library_update'] = '0'; - workerLog('mpdindex: Done: indexed ' . $stats); + workerLog('mpdindex: Done: updated ' . $_SESSION['mpd_dbupdate_count'] . ' files'); workerLog('worker: Job update_library done'); } } @@ -2382,18 +2411,17 @@ function chkLibraryRegen() { workerLog('worker: CRITICAL ERROR: chkLibraryRegen(): Connection to MPD failed'); } else { $status = getMpdStatus($sock); - $stats = getLibraryStats($sock); closeMpdSock($sock); - $_SESSION['mpd_dbupdate_status'] = countMpdLogLines(); - if ($_SESSION['mpd_dbupdate_status'] != 0) { - debugLog('mpdindex: File count ' . $_SESSION['mpd_dbupdate_status']); + $_SESSION['mpd_dbupdate_count'] = countMpdLogLines(); + if ($_SESSION['mpd_dbupdate_count'] != 0) { + debugLog('mpdindex: File count ' . $_SESSION['mpd_dbupdate_count']); } if (!isset($status['updating_db'])) { sendFECmd('libregen_done'); $GLOBALS['check_library_regen'] = '0'; - workerLog('mpdindex: Done: indexed ' . $stats); + workerLog('mpdindex: Done: indexed ' . $_SESSION['mpd_dbupdate_count'] . ' files'); workerLog('worker: Job regen_library done'); } } @@ -2425,13 +2453,12 @@ function chkClockRadio() { $mpdCmd = 'play ' . parseMpdRespAsArray($resp)['Pos']; } + // Set volume + sysCmd('/var/www/util/vol.sh ' . $_SESSION['clkradio_volume']); // Send play cmd sendMpdCmd($sock, $mpdCmd); $resp = readMpdResp($sock); closeMpdSock($sock); - - // Set volume - sysCmd('/var/www/util/vol.sh ' . $_SESSION['clkradio_volume']); } } else if ($currentTime == $GLOBALS['clkradio_stop_time'] && $GLOBALS['clkradio_stop_days'][$currentDay] == '1') { //workerLog('chkClockRadio(): stoptime=(' . $GLOBALS['clkradio_stop_time'] . ')'); @@ -2809,7 +2836,7 @@ function runQueuedJob() { workerLog('worker: Truncate MPD log'); truncateMpdLog(); // Update library - $cmd = empty($_SESSION['w_queueargs']) ? 'update' : 'update "' . html_entity_decode($_SESSION['w_queueargs']) . '"'; + $cmd = empty($_SESSION['w_queueargs']) ? 'update' : 'update "' . escapeDblQuotes(html_entity_decode($_SESSION['w_queueargs'])) . '"'; workerLog('mpdindex: Cmd (' . $cmd . ')'); workerLog('mpdindex: Scanning'); if (false === ($sock = openMpdSock('localhost', 6600))) { @@ -3146,6 +3173,9 @@ function runQueuedJob() { break; } + // Regenerate the Bluetooth A2DP sink device (AUDIODEV) for the new DSP head + updDspAndBtInConfs($_SESSION['cardnum'], $_SESSION['alsa_output_mode']); + // Restart MPD // NOTE: Don't restart if already done in the camillaDSP section if ($_SESSION['w_queue'] != 'camilladsp' || ($_SESSION['w_queue'] == 'camilladsp' && empty($queueArgs[1]))) { @@ -3199,17 +3229,10 @@ function runQueuedJob() { } } break; - case 'bt_pin_code': - if (empty($_SESSION['w_queueargs'])) { - sysCmd('echo "* ' . '" > ' . BT_PINCODE_CONF); - sysCmd("sed -i s'|ExecStart=/usr/bin/bt-agent.*|ExecStart=/usr/bin/bt-agent -c NoInputNoOutput|' /etc/systemd/system/bt-agent.service"); - sysCmd("sed -i s'|ExecStartPost=/bin/hciconfig.*|ExecStartPost=/bin/hciconfig hci0 sspmode 1|' /etc/systemd/system/bt-agent.service"); - } else { - sysCmd('echo "* ' . $_SESSION['w_queueargs'] . '" > ' . BT_PINCODE_CONF); - sysCmd("sed -i s'|ExecStart=/usr/bin/bt-agent.*|ExecStart=/usr/bin/bt-agent -c NoInputNoOutput -p " . BT_PINCODE_CONF . "|' /etc/systemd/system/bt-agent.service"); - sysCmd("sed -i s'|ExecStartPost=/bin/hciconfig.*|ExecStartPost=/bin/hciconfig hci0 sspmode 0|' /etc/systemd/system/bt-agent.service"); - } - sysCmd('systemctl daemon-reload'); + case 'bt_pairing_confirm': + // On: the pairing agent asks the user to confirm the code (DisplayYesNo, + // Numeric Comparison). Off: Just Works, no confirmation. + applyBtPairingConfirm($_SESSION['bt_pairing_confirm']); sysCmd('systemctl restart bt-agent'); break; case 'reset_bt_auto_disconnect': @@ -3824,7 +3847,7 @@ function runQueuedJob() { setAudioOut($_SESSION['w_queueargs']); break; - // command jobs + // From Prefs > Appearance case 'set_bg_image': $imgdata = base64_decode($_SESSION['w_queueargs'], true); if ($imgdata === false) { @@ -3835,6 +3858,8 @@ function runQueuedJob() { fclose($fh); } break; + + // Radio and Playlist view cover images case 'set_ralogo_image': case 'set_plcover_image': $job = $_SESSION['w_queue']; @@ -3935,6 +3960,38 @@ function runQueuedJob() { sysCmd('chmod 0777 "' . $imgDir . $thmDir . TMP_IMAGE_PREFIX . '"*'); break; + // Radio Browser favorite to Radio view + case 'set_rblogo_image': + $queueArgs = explode('~~~', $_SESSION['w_queueargs'], 2); + $name = $queueArgs[0]; + $imageData = $queueArgs[1]; + + $image = @imagecreatefromstring($imageData); + if (!$image) { + workerLog('worker: '. $job .' ERROR: imagecreatefromstring() failed for ' . $name); + break; + } + + $w = imagesx($image); + $h = imagesy($image); + $ok1 = rbResizeAndSave($image, $w, $h, 400, RADIO_LOGOS_ROOT . $name . '.jpg'); + $ok2 = rbResizeAndSave($image, $w, $h, 200, RADIO_LOGOS_ROOT . 'thumbs/' . $name . '.jpg'); + $ok3 = rbResizeAndSave($image, $w, $h, 80, RADIO_LOGOS_ROOT . 'thumbs/' . $name . '_sm.jpg'); + + if ($ok1 && $ok2 && $ok3) { + if (imagedestroy($image) === false) { + workerLog('worker: '. $job .' ERROR: imagedestroy() failed for ' . $name); + break; + } + } else { + workerLog('worker: '. $job .' ERROR: rbResizeAndSave() failed for ' . $name); + break; + } + + sysCmd('chmod 0777 "' . RADIO_LOGOS_ROOT . $name . '"*'); + sysCmd('chmod 0777 "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '"*'); + break; + // Other jobs case 'reboot': case 'poweroff': @@ -3983,7 +4040,8 @@ function runQueuedJob() { // Clear MPD log function truncateMpdLog() { sysCmd('truncate ' . MPD_LOG . ' --size 0'); - $_SESSION['mpd_dbupdate_status'] = 0; + $_SESSION['mpd_dbupdate_count'] = 0; + $_SESSION['mpd_db_stats'] = 'none'; } // Count number of lines in MPD log for database update or regen function countMpdLogLines() { diff --git a/www/engine-mpd.php b/www/engine-mpd.php index 20f713914..e9ee21dcd 100644 --- a/www/engine-mpd.php +++ b/www/engine-mpd.php @@ -53,6 +53,7 @@ $event = explode("\n", $resp)[0]; $status = getMpdStatus($sock); $status['idle_timeout_event'] = $event; + $status['idle_mixer_changed'] = (strpos($resp, 'changed: mixer') !== false) ? '1' : '0'; $status['empd_socket_timeout'] = $sockTimeout; scriptLog('Event (' . $event . ')'); } diff --git a/www/footer.php b/www/footer.php index 8bd286828..9b3edc800 100644 --- a/www/footer.php +++ b/www/footer.php @@ -19,11 +19,11 @@
Your Privacy

- We want you to know that our audio player does not serve Ads, nag for subscriptions, use cookies, analytics/tracking or any other such technology. Player preference, configuration and operational data is stored on the local boot media and is not provided to any 3rd parties. + We want you to know that our audio player does not serve Ads, nag for subscriptions, use cookies, analytics/tracking or any other such technology. Player preference, configuration and operational data is stored on the local boot media and is under your complete control.

Release Information
    -
  • Release: 10.2.3 2026-06-15
  • +
  • Release: 10.3.3 2026-MM-DD
  • Maintainer: Tim Curtis © 2014
  • Documentation: View release notes, View setup guide
  • Contributors: View contributors
  • @@ -69,6 +69,7 @@

  • Input select
  • +

  • Radio Cover+
@@ -89,6 +90,22 @@ + + + + "; +//workerLog('-- footer.php'); +$return_val = session_write_close(); +//workerLog('session_write_close=' . (($return_val) ? 'TRUE' : 'FALSE')); +echo ""; ?> diff --git a/www/header.php b/www/header.php index ff9689c62..c0a3dac2e 100644 --- a/www/header.php +++ b/www/header.php @@ -87,6 +87,7 @@ + diff --git a/www/images/default-album-cover.jpg b/www/images/default-album-cover.jpg new file mode 100644 index 000000000..59e7a0c69 Binary files /dev/null and b/www/images/default-album-cover.jpg differ diff --git a/www/images/default-album-cover.png b/www/images/default-album-cover.png deleted file mode 100644 index f5ca0479e..000000000 Binary files a/www/images/default-album-cover.png and /dev/null differ diff --git a/www/images/default-notfound-cover.jpg b/www/images/default-notfound-cover.jpg index 9b7f38552..59e7a0c69 100644 Binary files a/www/images/default-notfound-cover.jpg and b/www/images/default-notfound-cover.jpg differ diff --git a/www/images/default-playlist-cover.jpg b/www/images/default-playlist-cover.jpg index 0fb9e5079..e6ff7354a 100644 Binary files a/www/images/default-playlist-cover.jpg and b/www/images/default-playlist-cover.jpg differ diff --git a/www/images/default-radio-cover.jpg b/www/images/default-radio-cover.jpg new file mode 100644 index 000000000..59e7a0c69 Binary files /dev/null and b/www/images/default-radio-cover.jpg differ diff --git a/www/images/default-rx-cover.jpg b/www/images/default-rx-cover.jpg index 1cb223867..59e7a0c69 100644 Binary files a/www/images/default-rx-cover.jpg and b/www/images/default-rx-cover.jpg differ diff --git a/www/images/default-upnp-cover.jpg b/www/images/default-upnp-cover.jpg index fe9f6e611..a043d6428 100644 Binary files a/www/images/default-upnp-cover.jpg and b/www/images/default-upnp-cover.jpg differ diff --git a/www/inc/alsa.php b/www/inc/alsa.php index e0b26b580..0ca143934 100644 --- a/www/inc/alsa.php +++ b/www/inc/alsa.php @@ -68,6 +68,18 @@ function getAlsaMixerName($deviceName) { return $mixerName; } +// Get the raw ALSA control element name for a simple mixer control +function getAlsaCtlElemName($mixerName, $cardNum) { + $elemName = $mixerName . ' Playback Volume'; + foreach (sysCmd('amixer -c ' . $cardNum . ' controls') as $control) { + if (str_contains($control, "name='" . $elemName . "'")) { + return $elemName; + } + } + + return $mixerName; +} + function getAlsaVolume($mixerName) { $maxLoops = 3; $sleepTime = 1; diff --git a/www/inc/audio.php b/www/inc/audio.php index 4a7e72845..faa38a9d5 100644 --- a/www/inc/audio.php +++ b/www/inc/audio.php @@ -7,6 +7,7 @@ require_once __DIR__ . '/alsa.php'; require_once __DIR__ . '/common.php'; require_once __DIR__ . '/mpd.php'; +require_once __DIR__ . '/peripheral.php'; require_once __DIR__ . '/renderer.php'; require_once __DIR__ . '/session.php'; require_once __DIR__ . '/sql.php'; @@ -273,11 +274,18 @@ function updDspAndBtInConfs($cardNum, $outputMode) { } else { $alsaDevice = 'peppy'; } - // AUDIODEV=_audioout or plughw depending on Bluetooth Config, ALSA output mode - } else if ($_SESSION['alsa_output_mode_bt'] == 'plughw') { - $alsaDevice = $outputMode == 'iec958' ? getAlsaIEC958Device() : 'plughw' . ':' . $cardNum . ',0'; + // LADSPA heads (alsaequal/crossfeed/eqfa12p) can't be opened via plug:_audioout; open directly + } else if ($_SESSION['alsaequal'] != 'Off') { + $alsaDevice = 'alsaequal'; + } else if ($_SESSION['camilladsp'] != 'off') { + $alsaDevice = 'plug:_audioout'; + } else if ($_SESSION['crossfeed'] != 'Off') { + $alsaDevice = 'crossfeed'; + } else if ($_SESSION['eqfa12p'] != 'Off') { + $alsaDevice = 'eqfa12p'; } else { - $alsaDevice = $_SESSION['alsa_output_mode_bt']; // _audioout + // A2DP sink: convert the fixed-format decoded PCM at the top of the chain + $alsaDevice = 'plug:_audioout'; } sysCmd("sed -i 's/^AUDIODEV.*/AUDIODEV=" . $alsaDevice . "/' /etc/bluealsaaplay.conf"); @@ -296,28 +304,43 @@ function updPeppyConfs($cardNum, $outputMode) { } sysCmd("sed -i 's/^slave.pcm.*/slave.pcm \"" . $alsaDevice . "\"/' " . ALSA_PLUGIN_PATH . '/_peppyout.conf'); // ALSA mixer - $alsaMixer = $_SESSION['amixname'] == 'none' ? 'PCM' : $_SESSION['amixname']; + // The softvol control{} block takes a raw control element name. A simple mixer name + // never matches, which makes softvol create its own 0-255 control alongside the + // hardware one instead of binding to it. + $alsaMixer = $_SESSION['amixname'] == 'none' ? + 'PCM' : getAlsaCtlElemName($_SESSION['amixname'], $cardNum); $peppyConfFile = file_exists(ALSA_PLUGIN_PATH . '/peppy.conf.hide') ? '/peppy.conf.hide' : '/peppy.conf'; sysCmd("sed -i 's/^name.*/name \"" . $alsaMixer . "\"/' " . ALSA_PLUGIN_PATH . $peppyConfFile); sysCmd("sed -i 's/^card.*/card " . $cardNum . "/' " . ALSA_PLUGIN_PATH . $peppyConfFile); + // Follow the ALSA chain, not the display: touchmon flips peppy_display straight in the + // database whenever it swaps the screen between the WebUI and Peppy, so peppy_display + // says what is on screen right now, not whether peppyalsa is in the chain. Bounce the + // monitor rather than leave it: it watches a fixed card, and the output device (hw:N), + // output mode or volume type may just have changed. + if ($_SESSION['peppy_display'] == '1' || $_SESSION['enable_peppyalsa'] == '1') { + startPeppyGainMon(); + } else { + stopPeppyGainMon(); + } } // Read output device cache function readOutputDeviceCache($deviceName) { $dbh = sqlConnect(); + $deviceName = SQLite3::escapeString($deviceName); - $result = sqlRead('cfg_outputdev', $dbh, $deviceName); - if ($result === true) { - // Not in table + $result = sqlRead('cfg_outputdev', $dbh, $deviceName); + if ($result === true) { + // Not in table $values = 'device not found'; - } else { + } else { // In table $values = array( 'device_name' => $result[0]['device_name'], 'mpd_volume_type' => $result[0]['mpd_volume_type'], 'alsa_output_mode' => $result[0]['alsa_output_mode'], 'alsa_max_volume' => $result[0]['alsa_max_volume']); - } + } return $values; } @@ -325,23 +348,24 @@ function readOutputDeviceCache($deviceName) { // Update output device cache function updOutputDeviceCache($deviceName) { $dbh = sqlConnect(); + $deviceName = SQLite3::escapeString($deviceName); - $result = sqlRead('cfg_outputdev', $dbh, $deviceName); - if ($result === true) { - // Not in table so add new - $values = + $result = sqlRead('cfg_outputdev', $dbh, $deviceName); + if ($result === true) { + // Not in table so add new + $values = "'" . $deviceName . "'," . "'" . $_SESSION['mpdmixer'] . "'," . "'" . $_SESSION['alsa_output_mode'] . "'," . "'" . $_SESSION['alsavolume_max'] . "'"; - $result = sqlInsert('cfg_outputdev', $dbh, $values); - } else { + $result = sqlInsert('cfg_outputdev', $dbh, $values); + } else { $value = array( 'mpd_volume_type' => $_SESSION['mpdmixer'], 'alsa_output_mode' => $_SESSION['alsa_output_mode'], 'alsa_max_volume' => $_SESSION['alsavolume_max']); $result = sqlUpdate('cfg_outputdev', $dbh, $deviceName, $value); - } + } } function checkOutputDeviceCache($deviceName, $cardNum) { diff --git a/www/inc/autocfg.php b/www/inc/autocfg.php index 955fa74b3..934d352d7 100644 --- a/www/inc/autocfg.php +++ b/www/inc/autocfg.php @@ -472,7 +472,7 @@ function getCfgTableParams($table, $values, $prefix = '') { ['requires' => ['rbsvc'], 'handler' => 'setSessVarSql'], ['requires' => ['rsmafterrb'], 'handler' => 'setSessVarSql'], 'Bluetooth', - ['requires' => ['bt_pin_code'], 'handler' => 'setSessVarOnly'], + ['requires' => ['bt_pairing_confirm'], 'handler' => 'setSessVarOnly'], ['requires' => ['alsavolume_max_bt'], 'handler' => 'setSessVarOnly'], ['requires' => ['cdspvolume_max_bt'], 'handler' => 'setSessVarOnly'], ['requires' => ['audioout'], 'handler' => function($values) { @@ -486,10 +486,6 @@ function getCfgTableParams($table, $values, $prefix = '') { $_SESSION['bluez_sbc_quality'] = $values['bluez_sbc_quality']; sysCmd("sed -i 's/--sbc-quality.*/--sbc-quality=" . $values['bluez_sbc_quality'] . "/' /etc/systemd/system/bluealsa.service"); }], - ['requires' => ['alsa_output_mode_bt'], 'handler' => function($values) { - $_SESSION['alsa_output_mode_bt'] = '_audioout'; // Reset to Standard (_audioout) - sysCmd("sed -i '/AUDIODEV/c\AUDIODEV=_audioout" . "' /etc/bluealsaaplay.conf"); - }], ['requires' => ['bluez_controller_mode'], 'handler' => function($values) { $_SESSION['bluez_controller_mode'] = $values['bluez_controller_mode']; sysCmd("sed -i 's/ControllerMode.*/ControllerMode = " . $_SESSION['bluez_controller_mode'] . "/' /etc/bluetooth/main.conf"); @@ -603,7 +599,6 @@ function getCfgTableParams($table, $values, $prefix = '') { ['requires' => ['cover_backdrop'], 'handler' => 'setSessVarSql'], ['requires' => ['cover_blur'], 'handler' => 'setSessVarSql'], ['requires' => ['cover_scale'], 'handler' => 'setSessVarSql'], - ['requires' => ['renderer_backdrop'], 'handler' => 'setSessVarSql'], ['requires' => ['font_size'], 'handler' => 'setSessVarSql'], ['requires' => ['native_lazyload'], 'handler' => 'setSessVarSql'], 'Playback', @@ -626,7 +621,7 @@ function getCfgTableParams($table, $values, $prefix = '') { ['requires' => ['library_hiresthm'], 'handler' => 'setSessVarSql'], ['requires' => ['playlist_art'], 'handler' => 'setSessVarSql'], ['requires' => ['library_tagview_covers'], 'handler' => 'setSessVarSql'], - ['requires' => ['radio_track_covers'], 'handler' => 'setSessVarSql'], + ['requires' => ['radio_covers'], 'handler' => 'setSessVarSql'], ['requires' => ['itunes_query_timeout'], 'handler' => 'setSessVarSql'], 'Library', ['requires' => ['library_onetouch_album'], 'handler' => 'setSessVarSql'], diff --git a/www/inc/common.php b/www/inc/common.php index 618bb120b..8ab8dc064 100755 --- a/www/inc/common.php +++ b/www/inc/common.php @@ -507,6 +507,25 @@ function sendFECmd ($cmd) { } } +// Send a pairing decision to the Bluetooth agent (bt-pairing-agent.py) over its +// local socket. $accepted is '1'/'0'; $code is only used for Passkey Entry input. +function sendBtAgentResponse($id, $accepted, $code = '') { + if (preg_match('/^[0-9a-f]{8}$/', $id) !== 1) { + return false; + } + $msg = 'pairresp,' . $id . ',' . ($accepted == '1' ? '1' : '0'); + if ($code !== '' && ctype_digit((string)$code)) { + $msg .= ',' . $code; + } + if (false === ($sock = socket_create(AF_UNIX, SOCK_DGRAM, 0))) { + workerLog('sendBtAgentResponse(): Socket create failed'); + return false; + } + $result = @socket_sendto($sock, $msg, strlen($msg), 0, BT_AGENT_SOCK); + socket_close($sock); + return $result !== false; +} + function sockWrite($sock, $msg) { $length = strlen($msg); $retryCount = 4; @@ -727,6 +746,5 @@ function formatFanTemp0Params($params) { //----------------------------------------------------------------------------// function updDSIScnBrightness($screenType, $brightnessValue) { - // Write brightness to i2c bus 10 device 0045 (both Touch1 and Touch2 use this) - sysCmd('/bin/su -c "echo '. $brightnessValue . ' > /sys/class/backlight/10-0045/brightness"'); + sysCmd('/bin/su -c "echo '. $brightnessValue . ' > /sys/class/backlight/*/brightness"'); } diff --git a/www/inc/constants.php b/www/inc/constants.php index 9a2b77b05..d2eeeea93 100755 --- a/www/inc/constants.php +++ b/www/inc/constants.php @@ -34,28 +34,45 @@ // Currentsong / Now playing const CURRENTSONG_TXT = '/var/local/www/currentsong.txt'; const CURRENTSONG_TXT_TMP = '/tmp/currentsong.txt'; +// Radiocover plus +const RADIOCOVER_PLUS_CFG = '/etc/radiocover-plus/config.txt'; +const RADIOCOVER_PLUS_LOG = '/var/log/moode_radiocover_plus.log'; +// Radio Browser (radio-browser.info) +const RADIOBROWSER_API_PRIMARY = 'all.api.radio-browser.info'; // round-robin alias: bootstrap + last resort +const RADIOBROWSER_API_SRV = '_api._tcp.radio-browser.info'; // DNS SRV record for server discovery +const RADIOBROWSER_UA = 'moode-radio-browser/1.0'; +const RADIOBROWSER_CACHE = '/var/local/www/rb-cache'; +const RADIOBROWSER_RECENT_FILE = '/var/local/www/rb-cache/recently_played.json'; +const RADIOBROWSER_IMAGE_CACHE = '/var/local/www/imagesw/rb-logos'; +const RADIOBROWSER_IMAGE_MIN_SIZE = 100; // bytes +const RADIOBROWSER_IMAGE_MAX_SIZE = 1000000; // bytes +const RADIOBROWSER_CACHE_TTL = 1800; // Search results (30 min) +const RADIOBROWSER_CACHE_TTL_STATIC = 43200; // Countries/genres/topclick (12 hr) +const RADIOBROWSER_RECENT_MAX = 50; +const RADIOBROWSER_LIMIT = 28; // Fixed search/page size // AirPlay, Deezer Connect and Spotify Connect const APLMETA_CACHE_FILE = '/var/local/www/aplmeta.json'; const DEEZMETA_CACHE_FILE = '/var/local/www/deezmeta.json'; const DEEZ_CREDENTIALS_FILE = '/etc/deezer/deezer.toml'; const SPOTMETA_CACHE_FILE = '/var/local/www/spotmeta.json'; +const SENDSPINMETA_FILE = '/var/local/www/sendspinmeta.txt'; const ITUNES_API_BASE_URL = 'https://itunes.apple.com/search'; // SQLite const SQLDB = 'sqlite:/var/local/www/db/moode-sqlite3.db'; const SQLDB_PATH = '/var/local/www/db/moode-sqlite3.db'; // Dashboard const DASHBOARD_CACHE_FILE = '/var/local/www/dashboard.txt'; -// Library/Playback -const LIBCACHE_BASE = '/var/local/www/libcache'; -const ROOT_DIRECTORIES = array('NAS', 'NVME', 'OSDISK', 'RADIO', 'SATA', 'USB'); +// Default titles and covers const DEFAULT_STATION_NAME = 'Radio station'; -const DEFAULT_RADIO_COVER = 'images/default-album-cover.png'; -const DEFAULT_ALBUM_COVER = 'images/default-album-cover.png'; -const DEFAULT_UPNP_COVER = 'images/default-upnp-cover.jpg'; -const DEFAULT_RX_COVER = 'images/default-rx-cover.jpg'; +const DEFAULT_ALBUM_COVER = 'images/default-album-cover.jpg'; +const DEFAULT_RADIO_COVER = 'images/default-radio-cover.jpg'; const DEFAULT_PLAYLIST_COVER = '/var/www/images/default-playlist-cover.jpg'; const DEFAULT_NOTFOUND_COVER = '/var/www/images/default-notfound-cover.jpg'; -const DEFAULT_WEBUI_DISPLAY_URL = 'http://localhost/'; +const DEFAULT_UPNP_COVER = 'images/default-upnp-cover.jpg'; +const DEFAULT_RX_COVER = 'images/default-rx-cover.jpg'; // DEPRECATED +// Library/Playback +const LIBCACHE_BASE = '/var/local/www/libcache'; +const ROOT_DIRECTORIES = array('NAS', 'NVME', 'OSDISK', 'RADIO', 'SATA', 'USB'); const AIRPLAY_COVERS_ROOT = '/var/local/www/imagesw/airplay-covers/'; const PLAYLIST_COVERS_ROOT = '/var/local/www/imagesw/playlist-covers/'; const RADIO_LOGOS_ROOT = '/var/local/www/imagesw/radio-logos/'; @@ -94,7 +111,8 @@ const BOOT_CMDLINE_TXT = BOOT_DIR . '/cmdline.txt'; const BOOT_MOODEBACKUP_ZIP = '/boot/moodebackup.zip'; const BOOT_MOODECFG_INI = '/boot/moodecfg.ini'; -const BT_PINCODE_CONF = '/etc/bluetooth/pin.conf'; +const BT_AGENT_SOCK = '/tmp/moode-btagent.sock'; // bt-pairing-agent.py response socket +const BT_AGENT_ENV = '/var/local/www/btagent.env'; // bt-agent.service capability (BT_AGENT_CAPABILITY) const ETC_MACHINE_INFO = '/etc/machine-info'; const CHROMIUM_DOWNGRADE_VER = '126.0.6478.164-rpt1'; const NO_USERID_DEFINED = 'userid does not exist'; @@ -103,14 +121,24 @@ const READY_CHIME_TITLE = 'moOde audio - Ready Chime'; // File sharing const FS_SMB_CONF = '/etc/samba/smb.conf'; +// WebUI display +const DEFAULT_WEBUI_DISPLAY_URL = 'http://localhost/'; // Peppy display const PEPPY_METER_ETC_DIR = '/etc/peppymeter'; const PEPPY_METER_OPT_DIR = '/opt/peppymeter'; const PEPPY_SPECTRUM_ETC_DIR = '/etc/peppyspectrum'; const PEPPY_SPECTRUM_OPT_DIR = '/opt/peppyspectrum'; +// Live meter gain source: peppy-gain.php writes the current hardware attenuation (dB) +// here and PeppyMeter (volume.gain.db.source) scales its needles by 10^(dB/20). /tmp, +// alongside the existing /tmp/peppymeter FIFO. +const PEPPY_GAIN_DB_FILE = '/tmp/peppy_gain_db'; +// Seconds before retrying the ALSA monitor after the card goes away (DAC unplugged) +const PEPPY_GAIN_MON_RETRY = 5; +const PEPPY_GAIN_MON_LOG = '/tmp/moode_peppy_gain.log'; // Peppy touch monitor const TOUCHMON_LOG = '/tmp/moode_touchmon.log'; const TOUCHMON_TIMEOUT_DEFAULT = 15; +const TOUCHMON_CLOSED_COUNT = 3; // Notifications const NOTIFY_TITLE_INFO = ' Info'; const NOTIFY_TITLE_ALERT = ' Alert'; @@ -215,6 +243,7 @@ const FEAT_DEVTWEAKS = 32768; // Developer tweaks const FEAT_MULTIROOM = 65536; // y Multiroom audio const FEAT_PEPPYDISPLAY = 131072; // y Peppy display +const FEAT_SENDSPIN = 262144; // x SendSpin multi-room audio // ------- // 228279 @@ -268,7 +297,6 @@ const ALSA_DEFAULT_MIXER_NAME_INTEGRATED = 'PCM'; // ALSA output mode names const ALSA_OUTPUT_MODE_NAME = array('plughw' => 'Default', 'hw' => 'Direct', 'iec958' => 'IEC958'); -const ALSA_OUTPUT_MODE_BT_NAME = array('_audioout' => 'Standard', 'plughw' => 'Compatibility'); // ALSA HDMI IEC958 const ALSA_IEC958_DEVICE = 'default:vc4hdmi'; const ALSA_IEC958_FORMAT = 'IEC958_SUBFRAME_LE'; diff --git a/www/inc/mpd.php b/www/inc/mpd.php index f46337366..012b57d16 100755 --- a/www/inc/mpd.php +++ b/www/inc/mpd.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/audio.php'; require_once __DIR__ . '/cdsp.php'; require_once __DIR__ . '/music-library.php'; +require_once __DIR__ . '/radio.php'; require_once __DIR__ . '/session.php'; require_once __DIR__ . '/sql.php'; @@ -723,7 +724,7 @@ function enhanceMetadata($current, $sock, $caller = '') { if ($caller == 'engine_mpd_php') { // Both these functions perform phpSession('open_ro'); $current['cover_art_hash'] = getCoverHash($current['file']); - $current['mapped_db_vol'] = getMappedDbVol(); + $current['mapped_db_vol'] = getMappedDbVol($current['file']); //debugLog('enhanceMetadata(): ' . $caller . ' OPEN_RO session'); } @@ -798,14 +799,14 @@ function enhanceMetadata($current, $sock, $caller = '') { // URL logo image $current['coverurl'] = rawurlencode($_SESSION[$song['file']]['logo']); } - // Track cover from Apple Music (iTunes API) + // Get radio cover if ($current['title'] != DEFAULT_STATION_NAME) { - if ($_SESSION['radio_track_covers'] == 'Yes') { + if ($_SESSION['radio_covers'] != 'No') { if ($current['state'] == 'play') { - // NOTE: This function performs phpSession('open_ro') or phpSession(open) / phpSession(close) - $trackCoverUrl = getTrackCoverUrl($current['title']); - if (str_contains($trackCoverUrl, 'https://')) { - $current['coverurl'] = $trackCoverUrl; + // NOTE: getRadioCoverUrl() opens the session + $coverUrl = getRadioCoverUrl($current['title'], $current['album']); // title, station + if (substr($coverUrl, 0, 4) == 'http') { // URL and not 'None' or '' + $current['coverurl'] = $coverUrl; } } } @@ -864,7 +865,7 @@ function enhanceMetadata($current, $sock, $caller = '') { $format[1] = $result[0]; // bits $format[2] = $result[2]; // channels } else { // Song file - sendMpdCmd($sock, 'lsinfo "' . $song['file'] . '"'); + sendMpdCmd($sock, 'lsinfo "' . escapeDblQuotes($song['file']) . '"'); $songData = parseDelimFile(readMpdResp($sock), ': '); // [0] rate, [1] bits, [2] channels $format = explode(':', $songData['Format']); @@ -902,57 +903,6 @@ function enhanceMetadata($current, $sock, $caller = '') { return $current; } -function getTrackCoverUrl($trackTitle) { - $trackTitle = html_entity_decode($trackTitle); - - phpSession('open_ro'); - $coverUrl = $_SESSION['trackcover_url_cache'][$trackTitle]; - if (!empty($coverUrl)) { - // DEBUG: - //workerLog('getTrackCoverUrl(): Return cached cover URL for: ' . $trackTitle); - return $coverUrl; - } - - // DEBUG: - //workerLog('getTrackCoverUrl(): Query iTunes repository for: ' . $trackTitle); - // Create query - $parts = explode(' - ', $trackTitle); // $parts[0]=Artist name, $parts[1]=Track title - $query = '?term=' . urlencode($parts[0] . ' ' . $parts[1]) . '&media=music&entity=musicTrack&limit=1'; - $apiUrl = ITUNES_API_BASE_URL . $query; - // Get stream timeout, same for both connect and readdata - $timeout = $_SESSION['itunes_query_timeout'] . '.0'; - $options = array( - 'http' => array( - 'protocol_version' => (float)'1.1', - 'timeout' => (float)$timeout - ) - ); - // Submit query to iTunes repo - $result = file_get_contents($apiUrl, false, stream_context_create($options)); - if ($result === false) { - $msg = 'Query failed for: ' . $trackTitle; - $coverUrl = ''; - } else { - $resultArray = json_decode($result, true); - if ($resultArray['resultCount'] == '0') { - $msg = 'Query return 0 results for: ' . $trackTitle; - $coverUrl = ''; - } else { - $msg = 'Query successful for: ' . $trackTitle . "\n" . 'Cover URL= ' . $coverUrl; - $coverUrl = str_replace('100x100', '1000x1000', $resultArray['results'][0]['artworkUrl100']); - } - } - - // DEBUG: - //workerLog('getTrackCoverUrl(): ' . $msg . "\n" . $coverUrl . (!empty($coverUrl) ? "\n" : '') . $apiUrl); - - phpSession('open'); - $_SESSION['trackcover_url_cache'][$trackTitle] = $coverUrl; - phpSession('close'); - - return $coverUrl; -} - function getUpnpCoverUrl() { $mode = sqlQuery("SELECT value FROM cfg_upnp WHERE param='upnpav'", sqlConnect())[0]['value'] == 1 ? 'upnpav' : 'openhome'; $result = sysCmd('/var/www/util/upnp_albumart.py "' . $_SESSION['upnpname'] . '" '. $mode); @@ -960,7 +910,7 @@ function getUpnpCoverUrl() { return explode(',', $result[0])[0]; } -function getMappedDbVol() { +function getMappedDbVol($file = '') { phpSession('open_ro'); if (CamillaDsp::isMPD2CamillaDSPVolSyncEnabled()) { @@ -976,25 +926,51 @@ function getMappedDbVol() { } else { $mappedDbVol = str_contains($mappedDbVol, '.') ? $mappedDbVol . 'dB' : $mappedDbVol . '.0dB'; } + } else if ($_SESSION['mpdmixer'] == 'software') { + // MPD software volume, no ALSA control is driven so the dB value is computed. + // MPD maps the volume with gain = (exp(volume / 25) - 1) / (e^4 - 1), see + // PercentVolumeToSoftwareVolume() in MPD's SoftwareMixerPlugin.cxx. + $ext = getSongFileExt($file); + $volKnob = (int)sqlRead('cfg_system', sqlConnect(), 'volknob')[0]['value']; + if ($ext == 'dsf' || $ext == 'dff') { + // MPD cannot attenuate DSD + $mappedDbVol = '0dB'; + } else if ($volKnob <= 0) { + $mappedDbVol = '-120dB'; + } else if ($volKnob >= 100) { + $mappedDbVol = '0dB'; + } else { + $gain = (exp($volKnob / 25) - 1) / (exp(4) - 1); + $mappedDbVol = number_format(20 * log10($gain), 1) . 'dB'; + } } else { // MPD volume - $mappedDbVol = sysCmd('amixer -c ' . $_SESSION['cardnum'] . ' sget "' . $_SESSION['amixname'] . '" | ' . - "awk -F\"[][]\" '/dB/ {print $4; count++; if (count==1) exit}'")[0]; - if (empty($mappedDbVol) || $_SESSION['mpdmixer'] == 'software' || $_SESSION['mpdmixer'] == 'null') { - $mappedDbVol = ''; + $mappedDbVol = getAlsaMappedDbVol($_SESSION['cardnum'], $_SESSION['amixname'], $_SESSION['mpdmixer']); + } + + return $mappedDbVol; +} + +// Read the ALSA mixer attenuation +// NOTE: args, not session vars: peppy-gain.php runs sessionless +function getAlsaMappedDbVol($cardNum, $amixName, $mixerType) { + $mappedDbVol = sysCmd('amixer -c ' . $cardNum . ' sget "' . $amixName . '" | ' . + "awk -F\"[][]\" '/dB/ {print $4; count++; if (count==1) exit}'")[0]; + if (empty($mappedDbVol) || $mixerType == 'null') { + $mappedDbVol = ''; + } else { + $mappedDbVol = number_format(rtrim($mappedDbVol, 'dB'), 1); + if ($mappedDbVol == 0) { + $mappedDbVol = '0dB'; } else { - $mappedDbVol = number_format(rtrim($mappedDbVol, 'dB'), 1); - if ($mappedDbVol == 0) { - $mappedDbVol = '0dB'; - } else { - $mappedDbVol = ($mappedDbVol <= -127 ? '-127' : $mappedDbVol) . 'dB'; - } + $mappedDbVol = ($mappedDbVol <= -127 ? '-127' : $mappedDbVol) . 'dB'; } } return $mappedDbVol; } + function getCoverHash($file) { $ext = getSongFileExt($file); @@ -1157,3 +1133,7 @@ function parseDir($path) { return $result; } + +function escapeDblQuotes($path) { + return str_replace('"', '\"', $path); +} diff --git a/www/inc/music-library.php b/www/inc/music-library.php index a2fb863e9..53b328928 100755 --- a/www/inc/music-library.php +++ b/www/inc/music-library.php @@ -577,52 +577,58 @@ function getAlbumYear($trackData) { return $albumYear; } +// Analyze the MPD database and produce artist/album/track counts function getLibraryStats($sock) { // Generate the file list $fileList = sysCmd("mpc search '(Title !=\"\")'"); - // Scan the list and generate counts + // Initialize counts $trackCount = 0; $albumCount = 0; $artistCount = 0; $artists = array(); $albumKeys = array(); + + // Scan the file list and generate the counts foreach ($fileList as $file) { - // Albums - sendMpdCmd($sock, 'lsinfo "' . $file . '"'); + sendMpdCmd($sock, 'lsinfo "' . escapeDblQuotes($file) . '"'); $tags = parseLsinfoAsArray(readMpdResp($sock)); - // get the track path (the albums might be differentiated by that) - also by MUSICBRAINZ tags, so mybe there is room for improvement... - $apath = explode("/", $file); - $removeFromHere = -1; // remove the filename - if (str_ends_with($apath[count($apath) - 2], ".cue") == true) { - $removeFromHere = -2; // remove the cue filename - } - array_splice($apath, $removeFromHere); - $albumPath = join("/", $apath); + // ALBUMS: Accumulate unique album keys + // AlbumPath (to accurately differentiate albums) + $aPath = explode("/", $file); + $removeFromHere = -1; // Remove the filename + if (str_ends_with($aPath[count($aPath) - 2], ".cue") == true) { + $removeFromHere = -2; // Remove the cue filename + } + array_splice($aPath, $removeFromHere); + $albumPath = join("/", $aPath); + // Album and AlbumArtist $album = $tags['Album'] ? $tags['Album'] : 'Unknown Album'; $albumartist = $tags['AlbumArtist'] ? $tags['AlbumArtist'] : ($tags['Artist'] ? (count($tags['Artist']) == 1 ? $tags['Artist'][0] : 'Unknown AlbumArtist') : 'Unknown AlbumArtist'); + // Create unique album keys + $albumKey = $album . '@' . $albumartist . '@' . $albumPath; + if (!in_array($albumKey, $albumKeys)) { + array_push($albumKeys, $albumKey); + } - // Accumulate artists (unique) + // ARTISTS: Accumulate unique artists foreach($tags['Artist'] as $artist) { if (!in_array($artist, $artists)) { array_push($artists, $artist); } } - // Create unique album keys - $albumKey = $album . '@' . $albumartist . '@' . $albumPath; - if (!in_array($albumKey, $albumKeys)) { - array_push($albumKeys, $albumKey); - } - // Tracks - $trackCount++; + // TRACKS: File count + $tCount++; } + // Final counts $artistCount = count($artists); $albumCount = count($albumKeys); + $trackCount = $tCount; // Return counts as a formatted string return 'Artists:' . $artistCount . ' Albums:' . $albumCount . ' Tracks:' . $trackCount; @@ -866,7 +872,7 @@ function getMpdFormatTag($file) { workerLog('CRITICAL ERROR: getMpdFormatTag(): Connection to MPD failed'); return 'CRITICAL ERROR'; } else { - sendMpdCmd($sock, 'lsinfo "' . $file . '"'); + sendMpdCmd($sock, 'lsinfo "' . escapeDblQuotes($file) . '"'); $trackData = parseDelimFile(readMpdResp($sock), ': '); closeMpdSock($sock); return $trackData['Format']; diff --git a/www/inc/peripheral.php b/www/inc/peripheral.php index 297711cbb..426481c4e 100755 --- a/www/inc/peripheral.php +++ b/www/inc/peripheral.php @@ -167,6 +167,22 @@ function startLcdUpdater() { sysCmd('/var/www/daemon/lcd-updater.sh'); } +// Peppy meter gain monitor +function startPeppyGainMon() { + stopPeppyGainMon(); + // Keep stderr: a daemon dying silently leaves the meter on a stale gain with nothing + // to show for it + sysCmd('/var/www/daemon/peppy-gain.php >> ' . PEPPY_GAIN_MON_LOG . ' 2>&1 &'); +} +function stopPeppyGainMon() { + // Match the path, not the command: the process is "php", killall php would take the web + // app down. The [.] keeps the pattern from matching the shell that runs pkill itself. + sysCmd('pkill -f "peppy-gain[.]php" > /dev/null 2>&1'); + // The monitor is a child of the above and its command line carries none of that path, + // so it outlives the kill and leaks one process per bounce + sysCmd('pkill -f "alsactl[ ]monitor hw:" > /dev/null 2>&1'); +} + // GPIO button handler function startGpioBtnHandler() { sysCmd('/var/www/daemon/gpio_buttons.py ' . GPIOBUTTONS_SLEEP . ' > /dev/null &'); diff --git a/www/inc/queue.php b/www/inc/queue.php index 19ee96401..7477d88be 100644 --- a/www/inc/queue.php +++ b/www/inc/queue.php @@ -84,7 +84,7 @@ function addItemToQueue($path) { $cmd = 'add'; } - return $cmd . ' "' . html_entity_decode($path) . '"'; + return $cmd . ' "' . escapeDblQuotes(html_entity_decode($path)) . '"'; } function isSavedPlaylist($path) { @@ -110,7 +110,7 @@ function addGroupToQueue($songs) { $cmds = array(); foreach ($songs as $song) { - array_push($cmds, 'add "' . html_entity_decode($song) . '"'); + array_push($cmds, 'add "' . escapeDblQuotes(html_entity_decode($song)) . '"'); } return $cmds; diff --git a/www/inc/radio-browser.php b/www/inc/radio-browser.php new file mode 100644 index 000000000..90824ff1f --- /dev/null +++ b/www/inc/radio-browser.php @@ -0,0 +1,539 @@ + array( + 'method' => 'GET', + 'protocol_version' => (float)'1.1', + 'timeout' => (float)$timeout, + 'header' => $header, + 'follow_location' => 1, + 'max_redirects' => 3 + )); + // The context 'timeout' only bounds the READ; the http:// wrapper uses + // default_socket_timeout for the CONNECT. Without bounding it, a favicon on a + // dead/silent host hangs up to 60s and search (30 fetches) stalls for minutes. + // Cap both so each fetch is capped at ~$timeout, like the plugin's cURL TIMEOUT. + $prevSocketTimeout = ini_set('default_socket_timeout', (string)(int)ceil($timeout)); + $data = @file_get_contents($url, false, stream_context_create($options)); + if ($prevSocketTimeout !== false) { + ini_set('default_socket_timeout', $prevSocketTimeout); + } + return $data; +} + +// Discover the current API server list, per radio-browser.info guidance ("get a list +// of the servers", "names may change"): DNS SRV, then HTTP /json/servers, then the +// round-robin alias as last resort. Cached 12h; shuffled each call to spread load. +function rbGetServers() { + $servers = rbCacheGet('servers', RADIOBROWSER_CACHE_TTL_STATIC); + if (!is_array($servers) || !count($servers)) { + $servers = array(); + $records = @dns_get_record(RADIOBROWSER_API_SRV, DNS_SRV); + if (is_array($records)) { + foreach ($records as $r) { + if (!empty($r['target'])) { $servers[] = $r['target']; } + } + } + if (!count($servers)) { + $resp = rbHttpGet('https://' . RADIOBROWSER_API_PRIMARY . '/json/servers', 10, true); + $list = ($resp !== false && $resp !== '') ? json_decode($resp, true) : null; + if (is_array($list)) { + foreach ($list as $s) { + if (!empty($s['name'])) { $servers[] = $s['name']; } + } + } + } + $servers = array_values(array_unique($servers)); + if (!count($servers)) { + return array(RADIOBROWSER_API_PRIMARY); // last resort, not cached + } + rbCacheSet('servers', $servers); + } + shuffle($servers); + return $servers; +} + +// Call the radio-browser.info JSON API with automatic mirror failover +function rbApi($endpoint, $params = array(), $timeout = 10) { + $query = http_build_query($params); + // Always try the round-robin alias first (fast, health-balanced), then the + // dynamically discovered individual servers as failover. + $servers = array_values(array_unique(array_merge(array(RADIOBROWSER_API_PRIMARY), rbGetServers()))); + foreach ($servers as $srv) { + $url = 'https://' . $srv . $endpoint . ($query ? '?' . $query : ''); + $resp = rbHttpGet($url, $timeout, true); + if ($resp !== false && $resp !== '') { + $data = json_decode($resp, true); + if ($data !== null) { + return $data; + } + } + } + return false; +} + +// Normalise a stream URL for identity comparison (scheme/trailing slash/case insensitive) +function rbNormalizeUrl($url) { + $u = trim(strtolower($url)); + $u = preg_replace('#^https?://#', '', $u); + $u = rtrim($u, '/'); + return $u; +} + +// Make a station name safe for use as a filesystem/shell path component. +// moOde keeps station names verbatim as the logo/.pls filename (validateInput only +// SQL-escapes; putStationCover uses the raw name), and the now-playing renderer looks +// the logo up by that exact name — so we must NOT strip characters moOde keeps (notably +// '&', which is literal inside the double-quoted sysCmd paths). Only remove path +// separators and the few chars that break a double-quoted shell arg or a file write. +function rbSafeName($name) { + $safe = preg_replace('#[/\\\\"`$\x00-\x1f]#', '', (string)$name); + $safe = preg_replace('/--|>|/', '', (string)$safe); // -- is sql injection, > is shell hack + $safe = trim(preg_replace('/\s+/', ' ', $safe)); + $safe = substr($safe, 0, 128); + return $safe !== '' ? $safe : DEFAULT_STATION_NAME; +} + +// Read a station object from the JSON request body (fallback to POST fields) +function rbInputStation() { + $st = json_decode(file_get_contents('php://input'), true); + if (!is_array($st)) { + $st = $_POST; + } + return is_array($st) ? $st : array(); +} + +// Simple file-based JSON response cache +function rbCacheGet($key, $ttl) { + $file = RADIOBROWSER_CACHE . '/' . $key . '.json'; + if (file_exists($file) && ($ttl === 0 || (time() - filemtime($file) < $ttl))) { + $data = json_decode(file_get_contents($file), true); + return $data === null ? false : $data; + } + return false; +} +function rbCacheSet($key, $data) { + @file_put_contents(RADIOBROWSER_CACHE . '/' . $key . '.json', json_encode($data)); +} + +// --- Station logo handling (ported from RubaTron's Radio Browser api.php) -------------- +// Creates the three JPGs moOde expects for a radio station logo, synchronously (so they +// exist before the stream plays / the tile renders): .jpg (400), thumbs/.jpg +// (200), thumbs/_sm.jpg (80). This is the plugin's rb_save_permanent_logo mechanism. + +// Fetch raw image bytes from an http(s) URL or a local /var/local/www cache path +function rbFetchImageData($favicon) { + if ($favicon === '') { + return false; + } + if (preg_match('#^https?://#i', $favicon)) { + $data = rbHttpGet($favicon, 8); + return ($data !== false && strlen($data) > 100) ? $data : false; + } + // Local same-origin path (e.g. a cached favicon under imagesw/rb-logos/) + $local = '/var/local/www/' . ltrim($favicon, '/'); + $real = realpath($local); + if ($real !== false && str_starts_with($real, '/var/local/www/imagesw/') && is_file($real)) { + $data = file_get_contents($real); + return ($data !== false && strlen($data) > 100) ? $data : false; + } + return false; +} + +// Resize a GD image to a square (white background), save as JPG +function rbResizeAndSave($src, $srcW, $srcH, $size, $outPath, $quality = 85) { + $canvas = imagecreatetruecolor($size, $size); + $white = imagecolorallocate($canvas, 255, 255, 255); + imagefill($canvas, 0, 0, $white); + $scale = min($size / $srcW, $size / $srcH); + $newW = (int)($srcW * $scale); + $newH = (int)($srcH * $scale); + $x = (int)(($size - $newW) / 2); + $y = (int)(($size - $newH) / 2); + imagecopyresampled($canvas, $src, $x, $y, 0, 0, $newW, $newH, $srcW, $srcH); + $result = imagejpeg($canvas, $outPath, $quality); + $saved = @imagejpeg($canvas, $outPath, $quality); + imagedestroy($canvas); + return $saved; +} + +// Save station logo (400/200/80) to moOde's radio-logos folder. Returns true if all saved. +function rbSaveLogo($name, $imageData) { + // The RADIO_LOGOS_ROOT dirs are owned by root so use worker.php job processor which runs as root + phpSession('open'); + submitJob('set_rblogo_image', $name . '~~~' . $imageData); + phpSession('close'); + waitWorker('rbSaveLogo'); + return true; +} + +// Ensure the station has local logo files: download+convert the favicon, else copy the +// moOde default cover. No-op if the small thumb already exists. Runs before play/add so +// moOde's native now-playing/playqueue renderer never requests a missing logo (no 404). +function rbEnsureLogo($name, $favicon) { + if (file_exists(RADIO_LOGOS_ROOT . 'thumbs/' . $name . '_sm.jpg')) { + return; + } + + $saved = false; + if ($favicon !== '' && !str_contains($favicon, 'encrypted-tbn0.gstatic.com')) { + $data = rbFetchImageData($favicon); + if ($data !== false) { + $saved = rbSaveLogo($name, $data); + } + } + + if (!$saved) { + sysCmd('cp "' . DEFAULT_NOTFOUND_COVER . '" "' . RADIO_LOGOS_ROOT . $name . '.jpg"'); + sysCmd('cp "' . DEFAULT_NOTFOUND_COVER . '" "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '.jpg"'); + sysCmd('cp "' . DEFAULT_NOTFOUND_COVER . '" "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '_sm.jpg"'); + } +} + +// Recently played history (file-based, most-recent-first) +function rbGetRecent() { + if (!file_exists(RADIOBROWSER_RECENT_FILE)) { + return array(); + } + $data = json_decode(@file_get_contents(RADIOBROWSER_RECENT_FILE), true); + return is_array($data) ? $data : array(); +} +function rbAddRecent($station) { + $fp = @fopen(RADIOBROWSER_RECENT_FILE, 'c+'); + if (!$fp) { + return; + } + if (!flock($fp, LOCK_EX)) { + fclose($fp); + return; + } + $content = stream_get_contents($fp); + $list = ($content !== '' && ($d = json_decode($content, true)) && is_array($d)) ? $d : array(); + $url = trim($station['url']); + $list = array_values(array_filter($list, function ($item) use ($url) { + return $item['url'] !== $url; + })); + array_unshift($list, $station); + $list = array_slice($list, 0, RADIOBROWSER_RECENT_MAX); + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($list, JSON_PRETTY_PRINT)); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); +} +function rbRemoveRecent($url) { + $fp = @fopen(RADIOBROWSER_RECENT_FILE, 'c+'); + if (!$fp) { + return; + } + if (!flock($fp, LOCK_EX)) { + fclose($fp); + return; + } + $content = stream_get_contents($fp); + $list = ($content !== '' && ($d = json_decode($content, true)) && is_array($d)) ? $d : array(); + $url = trim($url); + $list = array_values(array_filter($list, function ($item) use ($url) { + return $item['url'] !== $url; + })); + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($list, JSON_PRETTY_PRINT)); + fflush($fp); + flock($fp, LOCK_UN); + fclose($fp); +} + +// Normalised URL set of the user's favorites (cfg_radio type='fb') +function rbFavoriteUrls($dbh) { + $urls = array(); + $rows = sqlQuery("SELECT station FROM cfg_radio WHERE type='fb'", $dbh); + if (is_array($rows)) { + foreach ($rows as $r) { + $urls[rbNormalizeUrl($r['station'])] = true; + } + } + return $urls; +} + +// Map a radio-browser.info result to the fields the UI needs, dedup and mark favorites +function rbShapeResults($data, $dbh) { + $favUrls = rbFavoriteUrls($dbh); + $deduped = array(); + $index = array(); + foreach ($data as $s) { + $url = trim($s['url_resolved'] ?? $s['url'] ?? ''); + if ($url === '') { + continue; + } + $key = rbNormalizeUrl($url); + // Return the raw favicon URL — do NOT cache inline here. Caching 30 external + // images synchronously in one request stalls search for tens of seconds (a + // slow/dead host blocks the whole loop). The tile instead points at the + // same-origin 'logo' proxy below (rbServeLogo), so images are fetched+cached + // per-image and in parallel by the browser, on demand. + $favicon = trim($s['favicon'] ?? ''); + $station = array( + 'name' => trim($s['name'] ?? ''), + 'url' => $url, + 'favicon' => $favicon, + 'homepage' => trim($s['homepage'] ?? ''), + 'country' => trim($s['country'] ?? ''), + 'countrycode' => trim($s['countrycode'] ?? ''), + 'state' => trim($s['state'] ?? ''), + 'language' => trim($s['language'] ?? ''), + 'tags' => trim($s['tags'] ?? ''), + 'codec' => trim($s['codec'] ?? ''), + 'bitrate' => (int)($s['bitrate'] ?? 0), + 'stationuuid' => trim($s['stationuuid'] ?? ''), + 'added' => isset($favUrls[$key]) + ); + if (!isset($index[$key])) { + $index[$key] = count($deduped); + $deduped[] = $station; + } else if ($deduped[$index[$key]]['favicon'] === '' && $favicon !== '') { + $deduped[$index[$key]] = $station; + } + } + return $deduped; +} + +// Insert a favorite station into cfg_radio + session var + .pls, then update MPD. +// NOTE: mirrors the native new_station path in command/radio.php (kept self-contained +// so this feature stays purely additive and does not modify radio.php). +function rbWriteStation($s) { + $dbh = sqlConnect(); + $values = + 'NULL,' . + "'" . SQLite3::escapeString($s['url']) . "'," . + "'" . SQLite3::escapeString($s['name']) . "'," . + "'fb'," . + "'local'," . + "\"" . SQLite3::escapeString($s['genre']) . "\"," . + "''," . + "'" . SQLite3::escapeString($s['language']) . "'," . + "'" . SQLite3::escapeString($s['country']) . "'," . + "'" . SQLite3::escapeString($s['region']) . "'," . + "'" . SQLite3::escapeString($s['bitrate']) . "'," . + "'" . SQLite3::escapeString($s['format']) . "'," . + "'No'," . + "'" . SQLite3::escapeString($s['home_page']) . "'," . + "'No'"; + sqlQuery('INSERT INTO cfg_radio VALUES (' . $values . ')', $dbh); + + phpSession('open'); + $_SESSION[$s['url']] = array( + 'name' => $s['name'], + 'type' => 'fb', + 'logo' => 'local', + 'bitrate' => $s['bitrate'], + 'format' => $s['format'], + 'home_page' => $s['home_page'], + 'monitor' => 'No' + ); + phpSession('close'); + + rbWritePls($s['name'], $s['url']); +} + +// Create the RADIO/.pls that the native Radio view plays (data-path="RADIO/.pls"). +// Factored out of rbWriteStation so promoting a played 'rb' station to favorite can also +// create it (a 'rb' has a logo but no .pls, so without this the promoted favorite won't play). +function rbWritePls($name, $url) { + $plsFile = MPD_MUSICROOT . 'RADIO/' . $name . '.pls'; + $contents = "[playlist]\nFile1=" . $url . "\nTitle1=" . $name . "\nLength1=-1\nNumberOfEntries=1\nVersion=2\n"; + file_put_contents($plsFile, $contents); + sysCmd('chmod 0777 "' . $plsFile . '"'); + sysCmd('chown root:root "' . $plsFile . '"'); + sysCmd('find ' . MPD_MUSICROOT . 'RADIO -name *.pls -exec touch {} \+'); + rbMpdUpdateRadio(); +} + +// Delete a station row + its .pls + logo files, then update MPD +function rbDeleteStation($name) { + $dbh = sqlConnect(); + $row = sqlQuery("SELECT station FROM cfg_radio WHERE name='" . SQLite3::escapeString($name) . "'", $dbh); + if (is_array($row)) { + phpSession('open'); + unset($_SESSION[$row[0]['station']]); + phpSession('close'); + } + sqlQuery("DELETE FROM cfg_radio WHERE name='" . SQLite3::escapeString($name) . "'", $dbh); + sysCmd('rm -f "' . MPD_MUSICROOT . 'RADIO/' . $name . '.pls"'); + sysCmd('rm -f "' . RADIO_LOGOS_ROOT . $name . '.jpg"'); + sysCmd('rm -f "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '.jpg"'); + sysCmd('rm -f "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '_sm.jpg"'); + sysCmd('find ' . MPD_MUSICROOT . 'RADIO -name *.pls -exec touch {} \+'); + rbMpdUpdateRadio(); +} + +function rbMpdUpdateRadio() { + $sock = getMpdSock('command/radio-browser.php'); + sendMpdCmd($sock, 'update RADIO'); + readMpdResp($sock); + closeMpdSock($sock); +} + +// Normalised set of the stream URLs currently in the MPD play queue (for orphan pruning). +// Uses playlistinfo and reads the canonical `file: ` lines (moOde's own convention, +// cf. getPlayqueue()/findInQueue) — the legacy `playlist` command's `pos:uri` format did +// NOT match cfg_radio.station here, so prune wrongly deleted still-queued 'rb' rows. +function rbQueuedUrls() { + $urls = array(); + $sock = getMpdSock('command/radio-browser.php'); + sendMpdCmd($sock, 'playlistinfo'); + $resp = readMpdResp($sock); + closeMpdSock($sock); + if (is_string($resp)) { + foreach (explode("\n", $resp) as $line) { + if (strncmp($line, 'file: ', 6) === 0) { + $urls[rbNormalizeUrl(trim(substr($line, 6)))] = true; + } + } + } + return $urls; +} + +// Prune transient (type='rb') radio-browser stations that are no longer in the play queue. +// A 'rb' row exists ONLY so moOde's native now-playing/playqueue renderer can resolve a +// played-but-unsaved stream's name/logo (via cfg_radio → session/RADIO.json); once the +// stream leaves the queue the row is dead weight, so we delete it (row + local logo files +// + session var). Keeps cfg_radio authoritative and self-cleaning without any temporary +// JSON. $keepUrl protects the station currently being registered/played (it may not be in +// the queue yet). Favorites (type='fb') and core/native stations are never touched. +function rbPruneOrphanStations($keepUrl = '') { + $dbh = sqlConnect(); + $rows = sqlQuery("SELECT station, name FROM cfg_radio WHERE type='rb'", $dbh); + if (!is_array($rows)) { + return; + } + $queued = rbQueuedUrls(); + $keep = rbNormalizeUrl($keepUrl); + phpSession('open'); + foreach ($rows as $r) { + $norm = rbNormalizeUrl($r['station']); + if ($norm === $keep || isset($queued[$norm])) { + continue; + } + unset($_SESSION[$r['station']]); + sqlQuery("DELETE FROM cfg_radio WHERE station='" . SQLite3::escapeString($r['station']) . "' AND type='rb'", $dbh); + $name = $r['name']; + sysCmd('rm -f "' . RADIO_LOGOS_ROOT . $name . '.jpg"'); + sysCmd('rm -f "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '.jpg"'); + sysCmd('rm -f "' . RADIO_LOGOS_ROOT . 'thumbs/' . $name . '_sm.jpg"'); + // A demoted favorite (fb -> rb) keeps its RADIO/.pls; remove it too so it doesn't + // orphan in the RADIO folder. A play-only 'rb' has none (rm -f is then a harmless no-op). + sysCmd('rm -f "' . MPD_MUSICROOT . 'RADIO/' . $name . '.pls"'); + } + phpSession('close'); +} + +// Same-origin logo proxy: fetch+cache ONE favicon on demand and stream it. Search AND +// Recent both return raw favicon URLs, so every tile's points here — a single +// render path. The browser loads logos in parallel and a slow/dead host only delays its +// own tile. Content-Type is set from the bytes (getimagesize), not the cache filename, +// so JPG/PNG are served correctly; 302s to the default cover on miss. +function rbServeLogo($url) { + if (session_status() === PHP_SESSION_ACTIVE) { + session_write_close(); // release the session lock so parallel image requests don't serialise + } + $url = trim($url); + $file = ''; + if ($url !== '' && preg_match('#^https?://#i', $url) && !str_contains($url, 'encrypted-tbn0.gstatic.com')) { + $hash = md5($url); + $path = RADIOBROWSER_IMAGE_CACHE . '/' . $hash . '.png'; + if (file_exists($path) && (time() - filemtime($path) < RADIOBROWSER_CACHE_TTL_STATIC)) { + $file = $path; + } else { + $data = rbHttpGet($url, 4); + if ($data !== false && strlen($data) > RADIOBROWSER_IMAGE_MIN_SIZE && strlen($data) < RADIOBROWSER_IMAGE_MAX_SIZE) { + if (@file_put_contents($path, $data)) { + $file = $path; + } + } + } + } + if ($file === '') { + header('Location: /' . DEFAULT_RADIO_COVER); + exit; + } + $info = @getimagesize($file); + $type = ($info && !empty($info['mime'])) ? $info['mime'] : 'image/png'; + header('Content-Type: ' . $type); + header('Cache-Control: public, max-age=86400'); + header('Content-Length: ' . filesize($file)); + readfile($file); + exit; +} + +// Register a radio-browser station locally WITHOUT playing it: ensure the 3 logo files +// exist, persist it as cfg_radio type='rb' (played/history — not shown in the Radio view) +// if new, and set the session var so moOde's native now-playing/playqueue renderer +// resolves name/format/logo. Shared by 'play' and by 'register' (the latter is fired +// when a Radio Browser tile's context menu opens, so the native queue actions resolve a +// not-yet-added station instead of crashing on an unknown stream). Returns normalised fields. +function rbRegisterStation($station) { + $url = trim($station['url'] ?? ''); + $name = rbSafeName($station['name'] ?? DEFAULT_STATION_NAME); + $favicon = trim($station['favicon'] ?? ''); + $bitrate = (string)(int)($station['bitrate'] ?? 0); + $format = trim($station['codec'] ?? ''); + $homepage = trim($station['homepage'] ?? ''); + + // Update cfg_radio + $dbh = sqlConnect(); + $exists = sqlQuery("SELECT id FROM cfg_radio WHERE station='" . SQLite3::escapeString($url) . "' LIMIT 1", $dbh); + if (!is_array($exists)) { + $vals = 'NULL,' . + "'" . SQLite3::escapeString($url) . "'," . + "'" . SQLite3::escapeString($name) . "'," . + "'rb','local'," . + "\"" . SQLite3::escapeString(trim($station['tags'] ?? '')) . "\"," . + "''," . + "'" . SQLite3::escapeString(trim($station['language'] ?? '')) . "'," . + "'" . SQLite3::escapeString(trim($station['country'] ?? '')) . "'," . + "'" . SQLite3::escapeString(trim($station['state'] ?? '')) . "'," . + "'" . SQLite3::escapeString($bitrate) . "'," . + "'" . SQLite3::escapeString($format) . "'," . + "'No'," . + "'" . SQLite3::escapeString($homepage) . "'," . + "'No'"; + sqlQuery('INSERT INTO cfg_radio VALUES (' . $vals . ')', $dbh); + } + // Update session + phpSession('open'); + $_SESSION[$url] = array( + 'name' => $name, 'type' => 'rb', 'logo' => 'local', + 'bitrate' => $bitrate, 'format' => $format, + 'home_page' => $homepage, 'monitor' => 'No' + ); + phpSession('close'); + + // For new logo 'set_rblogo_image' job is submitted to worker + rbEnsureLogo($name, $favicon); + + return array('name' => $name, 'format' => $format, 'bitrate' => $bitrate, 'homepage' => $homepage); +} diff --git a/www/inc/radio.php b/www/inc/radio.php new file mode 100755 index 000000000..ce0ffd7b7 --- /dev/null +++ b/www/inc/radio.php @@ -0,0 +1,146 @@ + array( + 'protocol_version' => (float)'1.1', + 'timeout' => (float)$timeout + ) + ); + + // Submit query to iTunes + $result = file_get_contents($apiUrl, false, stream_context_create($options)); + if ($result === false) { + $msg = 'Search failed for: ' . $title; + $coverUrl = 'None'; + } else { + $resultArray = json_decode($result, true); + if ($resultArray['resultCount'] == '0') { + $msg = 'Search returned 0 results for: ' . $title; + $coverUrl = 'None'; + } else { + // DEBUG: Report result count and/or full results + //workerLog('searchItunes(): - Returned ' . $resultArray['resultCount'] . ' results'); + //workerLog('searchItunes(): - Full results:' . "\n" . print_r($resultArray['results'] ,true)); + $coverUrl = 'None'; + $i = 0; + foreach ($resultArray['results'] as $result) { + // DEBUG: Find artist match in results + //workerLog('searchItunes(): - Checking result[' . $i . '] album: ' . $result['collectionName']); + $itunesArtist = strtolower(str_replace($result['artistName'], ' ', '')); + $titleArtist = strtolower(str_replace($titleParts[0], ' ', '')); + if ($titleArtist == $itunesArtist) { + $coverUrl = str_replace('100x100', '1000x1000', $resultArray['results'][$i]['artworkUrl100']); + $msg = 'Search successful for: ' . $title . "\n" . + 'Cover: ' . $coverUrl . "\n" . + 'Query: ' . $apiUrl; + // DEBUG: Report artist match + //workerLog('searchItunes(): - Artist match found'); + break; + } + } + } + } + + // DEBUG: Report result + //workerLog('searchItunes(): ' . $msg); + + return $coverUrl; // URL or 'None' +} + +function getRadioCoverUrlCacheCount() { + sqlQuery("SELECT count() FROM cfg_rcucache", sqlConnect()); +} + +function clearRadioCoverUrlCache() { + sqlQuery("DELETE FROM cfg_rcucache", sqlConnect()); +} diff --git a/www/inc/renderer.php b/www/inc/renderer.php index 6d4907745..3d0a80ffa 100644 --- a/www/inc/renderer.php +++ b/www/inc/renderer.php @@ -11,6 +11,13 @@ require_once __DIR__ . '/sql.php'; // Bluetooth +// Write the pairing agent's capability file. On ('1') -> DisplayYesNo (the agent asks +// the user to confirm the pairing code); off -> NoInputNoOutput (Just Works). Read by +// bt-agent.service via EnvironmentFile; the caller restarts bt-agent to apply it. +function applyBtPairingConfirm($confirm) { + $capability = $confirm == '1' ? 'DisplayYesNo' : 'NoInputNoOutput'; + file_put_contents(BT_AGENT_ENV, 'BT_AGENT_CAPABILITY=' . $capability . "\n"); +} function startBluetooth() { sysCmd('systemctl start hciuart'); sysCmd('systemctl start bluetooth'); @@ -53,7 +60,9 @@ function stopBluetooth() { // AirPlay function startAirPlay() { - sysCmd('systemctl start nqptp'); + if ($_SESSION['airplaysvc_type'] == '2') { + sysCmd('systemctl start nqptp'); + } // Verbose logging if ($_SESSION['debuglog'] == '1') { @@ -95,6 +104,9 @@ function startAirPlay() { $cmd = '/var/www/daemon/aplmeta-reader.sh > /dev/null 2>&1 &'; debugLog('startAirPlay(): (' . $cmd . ')'); sysCmd($cmd); + + // Truncate metadata file + sysCmd('truncate ' . APLMETA_CACHE_FILE . ' --size 0'); } function stopAirPlay() { $maxRetries = 3; @@ -125,8 +137,11 @@ function stopAirPlay() { } // Stop shairport-sync for ($i = 0; $i < $maxRetries; $i++) { - sysCmd('pkill -f -9 shairport-sync'); - $result = sysCmd('pgrep -c -f "LC_ALL=C /usr/bin/shairport-sync"')[0]; + $result = sysCmd('pkill -c -f -9 "[s]hairport-sync"'); + //workerLog(print_r($result, true)); + + $result = sysCmd('pgrep -c -f "[L]C_ALL=C /usr/bin/shairport-sync"')[0]; + //workerLog(print_r($result, true)); if ($result == 0) { break; } @@ -215,6 +230,9 @@ function startSpotify() { debugLog('startSpotify(): (' . $cmd . ')'); sysCmd($cmd); + + // Truncate metadata file + sysCmd('truncate ' . SPOTMETA_CACHE_FILE . ' --size 0'); } function stopSpotify() { sysCmd('killall -s9 librespot'); @@ -404,3 +422,184 @@ function stopAllRenderers() { } } } + +// SendSpin Multi-Room Audio renderer functions + +function getSendspinStatus() { + // Check systemd service status safely + $result = sysCmd('systemctl is-active sendspin 2>/dev/null'); + $status = (!empty($result) && isset($result[0])) ? $result[0] : 'inactive'; + if ($status === 'active') { + // Check if actually streaming (process using audio) + $sndResult = sysCmd('fuser /dev/snd/pcmC0D0p 2>/dev/null'); + if (!empty($sndResult)) { + // Check if sendspin is using the device + $sendspinPids = sysCmd('pgrep -f sendspin 2>/dev/null'); + foreach ($sendspinPids as $pid) { + if (strpos($sndResult[0], $pid) !== false) { + return 'streaming'; + } + } + } + return 'ready'; + } + return 'inactive'; +} + +function startSendspin() { + // Save MPD state before starting + $mpdStatus = sysCmd('mpc status')[0]; + $mpdWasPlaying = strpos($mpdStatus, 'playing') !== false; + + // Persist in database (survives PHP-FPM restarts) + $dbh = sqlConnect(); + sqlUpdate('cfg_system', $dbh, 'sendspin_mpd_was_playing', $mpdWasPlaying ? '1' : '0'); + + // Also write to session for immediate access + phpSession('write', 'mpd_was_playing', $mpdWasPlaying ? '1' : '0'); + + // Stop MPD to release ALSA device + sysCmd('mpc stop'); + + // Start SendSpin daemon + sysCmd('systemctl start sendspin'); + sysCmd('systemctl enable sendspin'); + + // Set active state + phpSession('write', 'sspactive', '1'); + $GLOBALS['sspactive'] = '1'; + sendFECmd('sspactive1'); + + workerLog('startSendspin(): daemon started (MPD was playing: ' . ($mpdWasPlaying ? 'yes' : 'no') . ')'); +} + +function stopSendspin() { + // Stop SendSpin daemon + sysCmd('systemctl stop sendspin'); + sysCmd('systemctl disable sendspin'); + + // Optionally resume MPD if it was playing AND rsmafterss is enabled + $dbh = sqlConnect(); + $result = sqlQuery("SELECT value FROM cfg_system WHERE param='rsmafterss'", $dbh); + $rsmafterss = (!empty($result)) ? $result[0]['value'] : 'No'; + + $mpdWasPlaying = $_SESSION['mpd_was_playing'] ?? '0'; + // Also check database as fallback + if ($mpdWasPlaying == '0') { + $result = sqlQuery("SELECT value FROM cfg_system WHERE param='sendspin_mpd_was_playing'", $dbh); + $mpdWasPlaying = (!empty($result)) ? $result[0]['value'] : '0'; + } + + if ($mpdWasPlaying == '1' && $rsmafterss == 'Yes') { + sleep(1); // Allow SendSpin to release device + sysCmd('mpc play'); + phpSession('write', 'mpd_was_playing', '0'); + sqlUpdate('cfg_system', $dbh, 'sendspin_mpd_was_playing', '0'); + workerLog('stopSendspin(): MPD playback resumed (rsmafterss=Yes)'); + } elseif ($mpdWasPlaying == '1') { + // Clear the flag even if not resuming + phpSession('write', 'mpd_was_playing', '0'); + sqlUpdate('cfg_system', $dbh, 'sendspin_mpd_was_playing', '0'); + workerLog('stopSendspin(): MPD was playing but rsmafterss=No, not resuming'); + } + + workerLog('stopSendspin(): daemon stopped'); + + // Local: restore volume knob + sysCmd('/var/www/util/vol.sh -restore'); + if (CamillaDSP::isMPD2CamillaDSPVolSyncEnabled()) { + sysCmd('systemctl restart mpd2cdspvolume'); + } + + // Clear active state + phpSession('write', 'sspactive', '0'); + $GLOBALS['sspactive'] = '0'; + sendFECmd('sspactive0'); +} + +// === SendSpin Advanced Functions (Release 2) === + +function getSendspinVersion() { + $result = sysCmd('sudo /root/.local/share/uv/tools/sendspin/bin/sendspin --version 2>/dev/null'); + $version = (!empty($result) && isset($result[0])) ? trim($result[0]) : 'unknown'; + return $version; +} + +function getSendspinMetadata() { + if (file_exists(SENDSPINMETA_FILE)) { + $meta = file_get_contents(SENDSPINMETA_FILE); + return $meta; + } + return ''; +} + +function checkSendspinUpdate() { + $result = sysCmd('sendspin-version-check.sh 2>/dev/null'); + $json = (!empty($result) && isset($result[0])) ? $result[0] : '{}'; + return $json; +} + +function updateSendspin() { + sysCmd('sudo -u root bash -c "/root/.local/share/uv/tools/sendspin/bin/python -m uv tool upgrade sendspin 2>&1 && systemctl restart sendspin" > /tmp/sendspin-update.log 2>&1 &'); + workerLog('updateSendspin(): upgrade launched in background'); + return true; +} + +function generateSendspinService($dbh = null) { + if ($dbh === null) { + $dbh = sqlConnect(); + } + $result = sqlRead('cfg_sendspin', $dbh); + $cfg = array(); + foreach ($result as $row) { + $cfg[$row['param']] = $row['value']; + } + + $codec = in_array($cfg['audio_codec'] ?? '', ['flac', 'pcm']) ? $cfg['audio_codec'] : 'flac'; + $rate = in_array($cfg['audio_rate'] ?? '', ['44100', '48000', '96000']) ? $cfg['audio_rate'] : '48000'; + $depth = in_array($cfg['audio_depth'] ?? '', ['16', '24', '32']) ? $cfg['audio_depth'] : '16'; + $log_level = in_array($cfg['log_level'] ?? '', ['DEBUG', 'INFO', 'WARNING', 'ERROR']) ? $cfg['log_level'] : 'INFO'; + + $audio_format = "{$codec}:{$rate}:{$depth}:2"; + + $service = <<', // Library - update_library: 'Library is being updated...

Click the progress spinner for status.', + update_library: 'Library is being updated...

Click the busy spinner for status.', library_updating: 'Library update is already in progress. ', library_loading: 'Library is loading... ', dbupdate_status: '', @@ -50,6 +50,8 @@ function notify(title, tag, arg3, arg4 = '') { new_playlist: 'Playlist has been created. ', upd_playlist: 'Playlist has been updated. ', del_playlist: 'Playlist has been deleted. ', + import_playlist: 'Playlist has been imported. ', + import_playlist_error: 'Import failed. ', // Radio view validation_check: 'Validation check. ', creating_station: 'Creating new station... ', @@ -58,6 +60,8 @@ function notify(title, tag, arg3, arg4 = '') { upd_station: 'Station has been updated. ', del_station: 'Station has been deleted. ', blank_entries: 'Name or URL is blank. ', + // Radio Browser + rb_message: '', // Multiroom trx_querying_receivers: 'Querying receivers... ', trx_no_receivers_found: 'No receivers were found. Run receiver Discovery. ', @@ -118,7 +122,7 @@ function notify(title, tag, arg3, arg4 = '') { // Queue playqueue_info: '
', // Library - update_library: 'Library is being updated...

Click the progress spinner for status.', + update_library: 'Library is being updated...

Click the busy spinner for status.', library_updating: 'Library update is already in progress. ', library_loading: 'Library is loading... ', dbupdate_status: '', @@ -130,7 +134,8 @@ function notify(title, tag, arg3, arg4 = '') { // Playback play_here_config_error: 'HTTP streaming must be ON and encoder set to LAME (MP3).', // Playlist view - // no_tags + import_playlist: 'Playlist has been imported. ', + import_playlist_error: 'Import failed. ', // Radio view validation_check: 'Validation check. ', blank_entries: 'Name or URL is blank. ', diff --git a/www/js/playerlib.js b/www/js/playerlib.js index 8ef62ca12..04a06ea5a 100755 --- a/www/js/playerlib.js +++ b/www/js/playerlib.js @@ -26,6 +26,8 @@ const FEAT_PEPPYDISPLAY = 131072; // y Peppy display // ------- // 228279 +const VOL_KNOB_DEBOUNCE = 150; // ms, coalesce a knob drag's intermediate values + // Notifications const NOTIFY_TITLE_INFO = ' Info'; const NOTIFY_TITLE_ALERT = ' Alert'; @@ -88,12 +90,15 @@ const LIB_MOUNT_TYPE_NVME = 'nvme'; // Default titles and covers const DEFAULT_STATION_NAME = 'Radio station'; -const DEFAULT_RADIO_COVER = 'images/default-album-cover.png'; -const DEFAULT_ALBUM_COVER = 'images/default-album-cover.png'; +const DEFAULT_ALBUM_COVER = 'images/default-album-cover.jpg'; +const DEFAULT_RADIO_COVER = 'images/default-radio-cover.jpg'; +const DEFAULT_PLAYLIST_COVER = 'images/default-playlist-cover.jpg'; +const DEFAULT_NOTFOUND_COVER = 'images/default-notfound-cover.jpg'; const DEFAULT_UPNP_COVER = 'images/default-upnp-cover.jpg'; -const DEFAULT_RX_COVER = 'images/default-rx-cover.jpg'; -const DEFAULT_PLAYLIST_COVER = '/var/www/images/default-playlist-cover.jpg'; -const DEFAULT_NOTFOUND_COVER = '/var/www/images/default-notfound-cover.jpg'; +const DEFAULT_RX_COVER = 'images/default-rx-cover.jpg'; // DEPRECATED + +// Radio Browser +const RB_API = 'command/radio-browser.php'; var UI = { knob: null, @@ -349,6 +354,10 @@ function engineMpd() { if (MPD.json['date']) MPD.json['date'] = MPD.json['date'].slice(0,4); // should fix in php but... renderUI(); } + + if (MPD.json['idle_mixer_changed'] == '1' && MPD.json['idle_timeout_event'] != 'changed: mixer') { + renderUIVol(); + } } engineMpd(); @@ -690,6 +699,14 @@ function engineCmd() { // causing engine-cmd.php to start releasing idle connections console.log(cmd[0]); break; + case 'pairreq': + // cmd: pairreq,,,,, + btPairRequest(cmd[1], cmd[2], cmd[3], cmd[4]); + break; + case 'paircancel': + // cmd: paircancel, (timed out or the device gave up) + btPairCancel(cmd[1]); + break; default: console.log('engineCmd(): ' + cmd[0]); break; @@ -771,6 +788,14 @@ function engineCmdLite() { // causing engine-cmd.php to start releasing idle connections console.log(cmd[0]); break; + case 'pairreq': + // cmd: pairreq,,,,, + btPairRequest(cmd[1], cmd[2], cmd[3], cmd[4]); + break; + case 'paircancel': + // cmd: paircancel, (timed out or the device gave up) + btPairCancel(cmd[1]); + break; default: console.log('engineCmdLite(): ' + cmd[0]); break; @@ -788,32 +813,67 @@ function engineCmdLite() { }); } +// Bluetooth pairing confirmation modal. bt-pairing-agent.py pushes a pairreq; the +// user confirms the code matches the one on their device, and the answer is POSTed +// back to the agent. See command/renderer.php (bt_pair_response) and footer.php. +var btPairCurrentId = null; + +function btPairRequest(id, method, code, nameB64) { + var name; + try { name = decodeURIComponent(escape(window.atob(nameB64))); } catch (e) { name = 'Bluetooth device'; } + btPairCurrentId = id; + + var text, showCode = true, showConfirm = true; + if (method === 'confirm') { + text = 'Confirm this code matches the one shown on'; + } else if (method === 'display') { + text = 'Enter this code on'; + showConfirm = false; // informational; the device does the entering + } else if (method === 'authorize') { + text = 'Allow pairing with'; + showCode = false; + } else { + text = 'Pairing request from'; + showCode = false; + } + + $('#btpair-modal-text').text(text); + $('#btpair-modal-name').text(name); + $('#btpair-modal-code').text(showCode ? code : '').toggle(showCode); + $('#btpair-confirm-btn').toggle(showConfirm); + $('#btpair-cancel-btn').text(showConfirm ? 'Reject' : 'Close'); + $('#btpair-modal').modal('show'); +} + +function btPairRespond(accepted) { + if (btPairCurrentId === null) { + return; + } + $.post('command/renderer.php?cmd=bt_pair_response', {id: btPairCurrentId, accepted: accepted}); + btPairCurrentId = null; + $('#btpair-modal').modal('hide'); +} + +function btPairCancel(id) { + if (btPairCurrentId === id) { + btPairCurrentId = null; + $('#btpair-modal').modal('hide'); + } +} + function inpSrcIndicator(cmd, msgText) { // DEBUG: //console.log('inpSrcIndicator(): ' + cmd + ' | ' + msgText); + + // Reset UI.currentFile = 'blank'; $('#inpsrc-msg').removeClass('inpsrc-msg-metadata'); $('#inpsrc-msg').addClass('inpsrc-msg-default'); $('#inpsrc-msg').css({width:'100%', top:'50%', bottom:'unset'}); $('#inpsrc-metadata').hide(); $('#inpsrc-cover').html(''); - - // Set custom backdrop (if any) - if (cmd == 'rxactive1') { - $('#inpsrc-backdrop').html(''); - $('#inpsrc-backdrop').css('filter', 'blur(1.25px)'); - $('#inpsrc-backdrop').css('transform', 'scale(1.0)'); - } else if (SESSION.json['renderer_backdrop'] == 'Yes') { - if (SESSION.json['cover_backdrop'] == 'Yes' && MPD.json['coverurl'].indexOf(DEFAULT_ALBUM_COVER) === -1) { - $('#inpsrc-backdrop').html(''); - $('#inpsrc-backdrop').css('filter', 'blur(' + SESSION.json['cover_blur'] + ')'); - $('#inpsrc-backdrop').css('transform', 'scale(' + SESSION.json['cover_scale'] + ')'); - } else if (SESSION.json['bgimage'] != '') { - $('#inpsrc-backdrop').html(''); - $('#inpsrc-backdrop').css('filter', 'blur(0px)'); - $('#inpsrc-backdrop').css('transform', 'scale(1.0)'); - } - } + $('#inpsrc-backdrop').html(''); + $('#inpsrc-style').css('display', 'none'); // Set the button and preamp volume // NOTE: Preamp volume #id will only exist if audioin != Local @@ -871,8 +931,11 @@ function updateInpsrcMeta(cmd, data) { //console.log('metadata', metadata); } catch (e) { - console.log('updateInpsrcMeta(): JSON parse error:', e.message); - console.log('updateInpsrcMeta(): data=(' + (data ? data : 'empty') + ')'); + if (data) { + console.log('updateInpsrcMeta(): JSON parse error:', e.message); + console.log('updateInpsrcMeta(): data=(' + data + ')'); + } + // Empty data: metadata file gets truncated when service disconnects or is started/restarted. return; } @@ -1081,11 +1144,17 @@ function renderUIVol() { // Load session vars (required for multi-client) $.getJSON('command/cfg-table.php?cmd=get_cfg_system', function(data) { + var localVol = SESSION.json['volknob']; + var localMute = SESSION.json['volmute']; if (data === false) { console.log('renderUIVol(): No data returned from get_cfg_system'); } else { SESSION.json = data; } + if (Date.now() - (GLOBAL.volLastChange || 0) < 1500) { + SESSION.json['volknob'] = localVol; + SESSION.json['volmute'] = localMute; + } // Volume type if (SESSION.json['mpdmixer'] == 'none') { @@ -1137,11 +1206,17 @@ function renderUI() { // Load session vars (required for multi-client) $.getJSON('command/cfg-table.php?cmd=get_cfg_system', function(data) { + var localVol = SESSION.json['volknob']; + var localMute = SESSION.json['volmute']; if (data === false) { console.log('renderUI(): No data returned from get_cfg_system'); } else { SESSION.json = data; } + if (Date.now() - (GLOBAL.volLastChange || 0) < 1500) { + SESSION.json['volknob'] = localVol; + SESSION.json['volmute'] = localMute; + } // Debug notification (appears above cover art) // var debugText = GLOBAL.userAgent + '
' + (GLOBAL.chromium ? 'chromium=true' : 'chromium=false'); @@ -1218,7 +1293,7 @@ function renderUI() { // Thumbnail cover for Playbar if (MPD.json['file'] && MPD.json['coverurl']) { if (MPD.json['artist'] == DEFAULT_STATION_NAME) { - if (MPD.json['coverurl'].includes('https://')) { + if (MPD.json['coverurl'].substring(0, 4) == 'http') { // Use substr for 'http' // Track cover var image_url = MPD.json['coverurl']; } else { @@ -1719,9 +1794,9 @@ function updateActivePlayqueueItem() { $('#pq-' + (parseInt(MPD.json['song']) + 1).toString() + ' .pll1').html(data[i].Title); // Update in case MPD did not get Title tag at initial play $('#currentsong').html(data[i].Title); - if (SESSION.json['radio_track_covers'] == 'Yes' && MPD.json['state'] == 'play') { + if (SESSION.json['radio_covers'] != 'No' && MPD.json['state'] == 'play') { if (!data[i].Title.toLowerCase().includes('advert')) { - updateTrackCover(data[i].Title); + updateRadioCover(data[i].Title, data[i].Name); } } // Add search URL, see corresponding code in renderUI() @@ -1763,7 +1838,7 @@ function renderPlayqueue(state) { //console.log('renderPlayqueue(' + seqNum++ + '): GLOBAL.playQueueLength: ' + GLOBAL.playQueueLength); var showPlayqueueThumb = SESSION.json['playlist_art'] == 'Yes' ? true : false; - // Format playlist items + // Format Queue items if (data) { for (i = 0; i < data.length; i++) { // Item highlight @@ -1810,9 +1885,9 @@ function renderPlayqueue(state) { if (i == parseInt(MPD.json['song'])) { // active // Update in case MPD did not get Title tag at initial play $('#currentsong').html(data[i].Title); - if (SESSION.json['radio_track_covers'] == 'Yes' && MPD.json['state'] == 'play') { + if (SESSION.json['radio_covers'] != 'No' && MPD.json['state'] == 'play') { if (!data[i].Title.toLowerCase().includes('advert')) { - updateTrackCover(data[i].Title); + updateRadioCover(data[i].Title, data[i].Name); } } // Add search URL, see corresponding code in renderUI() @@ -1903,15 +1978,15 @@ function renderPlayqueue(state) { }); } -// Update track cover -function updateTrackCover(trackTitle) { - $.getJSON('command/radio.php?cmd=get_track_cover_url', {'track_title': trackTitle}, function(coverURL) { +// Update radio cover +function updateRadioCover(title, station) { + $.getJSON('command/radio.php?cmd=get_radiocover_url', {'title': title, 'station': station}, function(coverURL) { // DEBUG: - //console.log('updateTrackCover(): ' + trackTitle); + //console.log('updateRadioCover(): ' + title + '|' + station); //console.log(coverURL); - if (coverURL.includes('https://') && MPD.json['coverurl'] != coverURL) { + if (coverURL.substring(0, 4) == 'http' && MPD.json['coverurl'] != coverURL) { MPD.json['coverurl'] = coverURL; - MPD.json['title'] = trackTitle; + MPD.json['title'] = title; // Playback/Playbar cover $('#coverart-url').html(''); $('#playbar-cover').html(''); @@ -1944,6 +2019,8 @@ function sendQueueCmd(cmd, path) { function renderFolderView(data, path, searchstr) { UI.path = path; $('#db-path').text(path); + // Import targets the playlists at the root, so only offer it there + $('#btn-db-import').toggle(path == ''); // Separate out dirs, playlists, files, exclude the RADIO folder var dirs = []; @@ -2016,7 +2093,7 @@ function renderFolderView(data, path, searchstr) { rootFolderIcon = getKeyOrValue('value', data[i].directory); } var cueVirtualDir = false; - output += '
  • '; + output += '
  • '; output += '' : @@ -2033,7 +2110,7 @@ function renderFolderView(data, path, searchstr) { else if (data[i].playlist && !cueVirtualDir) { // NOTE: Skip wavpack since it may contain embedded playlist and they are not supported yet in Folder view if (data[i].playlist.substr(data[i].playlist.lastIndexOf('.') + 1).toLowerCase() != 'wv') { - output += '
  • '; + output += '
  • '; output += '
    '; output += ''; output += '
    '; @@ -2044,7 +2121,7 @@ function renderFolderView(data, path, searchstr) { else if (data[i].file && !cueVirtualDir) { if (data[(i > 1 ? i - 1 : 0)].Album != data[i].Album || (i == 0 && data[i].Album)) { // Album header - output += '
  • '; + output += '
  • '; output += '
  • '; + output += '
  • '; output += '
    '; // Hack to enable entire line click for context menu output += ''; output += (data[i].Track ? data[i].Track : "•") + ''; @@ -2072,7 +2149,7 @@ function renderFolderView(data, path, searchstr) { } else { // Playlist item - output += '
  • '; + output += '
  • '; if (data[i].file.substr(data[i].file.lastIndexOf('.') + 1).toLowerCase() == 'cue') { var itemType = 'CUE sheet'; output += '
    '; @@ -2179,7 +2256,7 @@ function renderRadioView(lazyLoad = true) { if (showHideOtherStations == 'Hide all' || showHideOtherStations == 'Un-hide all') { var newStationType = showHideOtherStations == 'Hide all' ? 'h' : 'r'; for (var i = 0; i < data.length; i++) { - if (parseInt(data[i].id) > 499 && data[i].type != 'f') { + if (parseInt(data[i].id) > 499 && data[i].type.substring(0, 1) != 'f' && data[i].type != 'rb') { data[i].type = newStationType; } } @@ -2206,6 +2283,7 @@ function renderRadioView(lazyLoad = true) { k = k + 1; break; case 'f': + case 'fb': allNonHiddenStations[j] = data[i]; j = j + 1; favoriteStations[l] = data[i]; @@ -2275,7 +2353,7 @@ function renderRadioView(lazyLoad = true) { data = allNonHiddenStations; } else if (showHideOtherStations == 'Edit hidden') { data = hiddenOtherStations; - } else if (groupMethod == 'Favorites first') { + } else if (groupMethod.includes('Favorites')) { data = favoriteStations.concat(regularStations); } else if (groupMethod == 'Sort tag' || groupMethod == 'No grouping') { data = allNonHiddenStations; @@ -2293,7 +2371,7 @@ function renderRadioView(lazyLoad = true) { // Favorites header (if any) and end flag var output = ''; var endOfFavs = true; - if (groupMethod == 'Favorites first' && favoriteStations.length > 0) { + if (groupMethod.includes('Favorites') && favoriteStations.length > 0) { output = '
  • Favorites
  • '; endOfFavs = false; } @@ -2330,19 +2408,22 @@ function renderRadioView(lazyLoad = true) { var genreDiv = sortTag == 'genre' ? '' : ''; // Output Favorites first - if (groupMethod == 'Favorites first' && data[i].type == 'f') { + if (groupMethod.includes('Favorites') && data[i].type.substring(0, 1) == 'f') { //NOP } - // Change to Sort tag grouping unless method is No grouping - else if (groupMethod != 'No grouping') { + // Change to Sort tag grouping unless method is No grouping or Favorites first + else if (groupMethod != 'No grouping' && groupMethod != 'Favorites first') { groupMethod = 'Sort tag'; } // Mark the end of Favorites - if (configuredGroupMethod == 'Favorites first') { - if (endOfFavs === false && data[i].type != 'f' && lastSortTagValue != '') { + if (configuredGroupMethod.includes('Favorites')) { + if (endOfFavs === false && data[i].type.substring(0, 1) != 'f' && lastSortTagValue != '') { lastSortTagValue = ''; endOfFavs = true; + if (configuredGroupMethod == 'Favorites first') { // no group remaining + output += '
  • '; + } } } @@ -2369,11 +2450,18 @@ function renderRadioView(lazyLoad = true) { // Construct station entries var imgUrl = data[i].logo == 'local' ? 'imagesw/radio-logos/thumbs/' + data[i].name + '.jpg' : data[i].logo; - output += '
  • ' + radioViewLazy + encodeURIComponent(imgUrl) + '">
    '; + // Favorite heart (mirrors the Radio Browser explorer tile) — toggles cfg_radio type f<->r ?? + if (data[i].type == 'fb') { + var favToggle = '
    '; + } else { + var favToggle = ''; + } + output += '
  • ' + radioViewLazy + encodeURIComponent(imgUrl) + '">' + favToggle + '
    '; output += radioViewHdDiv; output += radioViewBgDiv; output += '' + data[i].name + ''; + output += '' + data[i].type + ''; output += broadcasterDiv; output += countryDiv; output += languageDiv; @@ -2403,7 +2491,6 @@ function renderRadioView(lazyLoad = true) { function renderPlaylistView () { var playlists = ''; $.getJSON('command/playlist.php?cmd=get_playlists', function(playlists) { - //console.log(playlists); // Lazyload method var plViewLazy = GLOBAL.nativeLazyLoad ? '
    '; - output += '
    ' + plViewLazy + encodeURIComponent(imgUrl) + '">'; - output += playlists[i].cover == 'default' ? '
    ' + playlists[i].name + '
    ' : ''; + output += '
    ' + plViewLazy + encodeURIComponent(imgUrl) + '">'; output += '
    '; output += '' + playlists[i].name + ''; output += genreDiv; @@ -2711,6 +2803,8 @@ function setVolume(level, event) { level = level > GLOBAL.mpdMaxVolume ? GLOBAL.mpdMaxVolume : level; level = level < 0 ? 0 : level; + GLOBAL.volLastChange = Date.now(); + var async = true; /*console.log('setVolume(): ' + @@ -2723,7 +2817,19 @@ function setVolume(level, event) { if (SESSION.json['volmute'] == '0') { //console.log('sendVolCmd(): unmute (volknob ' + SESSION.json['volknob'] + ')'); SESSION.json['volknob'] = level.toString(); - sendVolCmd('POST', 'upd_volume', {'volknob': SESSION.json['volknob'], 'event': event}, async); + if (event != 'knob_change') { + $('#volume, #volume-2').val(level).trigger('change'); + } + $('.volume-display div, .mpd-volume-level').text(level); + if (event == 'knob_change') { + clearTimeout(GLOBAL.volKnobTimer); + GLOBAL.volKnobTimer = setTimeout(function() { + sendVolCmd('POST', 'upd_volume', {'volknob': SESSION.json['volknob'], 'event': event}, async); + }, VOL_KNOB_DEBOUNCE); + } else { + clearTimeout(GLOBAL.volKnobTimer); + sendVolCmd('POST', 'upd_volume', {'volknob': SESSION.json['volknob'], 'event': event}, async); + } } else { // Muted if (level == 0 && event == 'mute') { @@ -2889,6 +2995,9 @@ $(document).on('click', '.context-menu a', function(e) { // // Context menu items // + case 'export_playlist': + window.location = 'command/playlist.php?cmd=export_playlist&name=' + encodeURIComponent(path); + break; case 'add_item': case 'play_item': case 'clear_play_item': @@ -3037,9 +3146,13 @@ $(document).on('click', '.context-menu a', function(e) { } break; case 'player_info': - $.getJSON('command/music-library.php?cmd=get_dbupdate_status', {'lib_stats': ''}, function(status) { - var stats = status.split(' '); - var networkIface = SESSION.json['wlanssid'] == '' ? 'Ethernet' : 'Wireless (' + SESSION.json['wlanssid'] + ')'; + $.getJSON('command/music-library.php?cmd=get_db_stats', function(results) { + var stats = results == 'none' ? + ['Artists:Analyze has not been run', 'Albums: ', 'Tracks: '] : + results.split(' '); + var networkIface = SESSION.json['wlanssid'] == '' ? + 'Ethernet' : + 'Wireless (' + SESSION.json['wlanssid'] + ')'; notify(NOTIFY_TITLE_INFO, 'player_info', 'moOde:   ' + SESSION.json['moode_release'] + '
    ' + 'Host:    ' + SESSION.json['hostname'] + '
    ' + @@ -3069,6 +3182,7 @@ $(document).on('click', '.context-menu a', function(e) { $('#preview-edit-logoimage').html(''); $('#edit-station-tags').css('margin-top', '20px'); $('#edit-station-type span').text(getKeyOrValue('key', data['type'])); + data['type'] == 'fb' ? $('#edit-station-type-fb').show() : $('#edit-station-type-fb').hide(); $('#edit-station-genre').val(data['genre']); $('#edit-station-broadcaster').val(data['broadcaster']); $('#edit-station-home-page').val(data['home_page']); @@ -3096,7 +3210,15 @@ $(document).on('click', '.context-menu a', function(e) { $('#edit-playlist-name').val(path); $('#edit-plcoverimage').val(''); $('#info-toggle-edit-plcoverimage').css('margin-left','60px'); - $('#preview-edit-plcoverimage').html(''); + var icon = ''; + if (data.cover == 'local') { + var imgUrl = '../imagesw/playlist-covers/' + path + '.jpg'; + } else if (data.cover == 'default') { + var imgUrl = DEFAULT_PLAYLIST_COVER; + } else { // Manually entered URL for #EXTIMG tag (rare) + var imgUrl = data.cover; + } + $('#preview-edit-plcoverimage').html(''); $('#edit-playlist-tags').css('margin-top', '20px'); $('#edit-playlist-genre').val(data['genre']); @@ -3401,7 +3523,6 @@ $(document).on('click', '.context-menu a', function(e) { $('#cover-backdrop-enabled span').text(SESSION.json['cover_backdrop']); $('#cover-blur span').text(SESSION.json['cover_blur']); $('#cover-scale span').text(SESSION.json['cover_scale']); - $('#renderer-backdrop span').text(SESSION.json['renderer_backdrop']); $('#font-size span').text(SESSION.json['font_size']); $('#native-lazyload span').text(SESSION.json['native_lazyload']); @@ -3417,7 +3538,7 @@ $(document).on('click', '.context-menu a', function(e) { $('#hires-thumbnails span').text(getKeyOrValue('key', SESSION.json['library_hiresthm'])); $('#playqueue-art-enabled span').text(SESSION.json['playlist_art']); $('#show-tagview-covers span').text(SESSION.json['library_tagview_covers']); - $('#show-radio-track-covers span').text(SESSION.json['radio_track_covers']); + $('#show-radio-covers span').text(SESSION.json['radio_covers']); $('#itunes-query-timeout span').text(SESSION.json['itunes_query_timeout']); // Library @@ -3594,7 +3715,7 @@ $('#btn-preferences-update').click(function(e){ var fontSizeChange = false; var lazyLoadChange = false; var playqueueArtChange = false; - var radioTrackCoversChange = false; + var radioCoversChange = false; var showNpIconChange = false; var thumbSizeChange = false; @@ -3633,7 +3754,7 @@ $('#btn-preferences-update').click(function(e){ if (SESSION.json['library_hiresthm'] != getKeyOrValue('value', $('#hires-thumbnails span').text())) {regenThumbsReqd = true;} if (SESSION.json['playlist_art'] != $('#playqueue-art-enabled span').text()) {playqueueArtChange = true;} if (SESSION.json['library_tagview_covers'] != $('#show-tagview-covers span').text()) {libraryOptionsChange = true;} - if (SESSION.json['radio_track_covers'] != $('#show-radio-track-covers span').text()) {radioTrackCoversChange = true;} + if (SESSION.json['radio_covers'] != $('#show-radio-covers span').text()) {radioCoversChange = true;} // Library // One-touch actions @@ -3680,7 +3801,6 @@ $('#btn-preferences-update').click(function(e){ SESSION.json['cover_backdrop'] = $('#cover-backdrop-enabled span').text(); SESSION.json['cover_blur'] = $('#cover-blur span').text(); SESSION.json['cover_scale'] = $('#cover-scale span').text(); - SESSION.json['renderer_backdrop'] = $('#renderer-backdrop span').text(); SESSION.json['font_size'] = $('#font-size span').text(); SESSION.json['native_lazyload'] = $('#native-lazyload span').text(); @@ -3696,9 +3816,8 @@ $('#btn-preferences-update').click(function(e){ SESSION.json['library_hiresthm'] = getKeyOrValue('value', $('#hires-thumbnails span').text()); SESSION.json['playlist_art'] = $('#playqueue-art-enabled span').text(); SESSION.json['library_tagview_covers'] = $('#show-tagview-covers span').text(); - SESSION.json['radio_track_covers'] = $('#show-radio-track-covers span').text(); + SESSION.json['radio_covers'] = $('#show-radio-covers span').text(); SESSION.json['itunes_query_timeout'] = $('#itunes-query-timeout span').text(); - // Library // One-touch actions SESSION.json['library_onetouch_album'] = $('#onetouch_album span').text(); @@ -3752,6 +3871,10 @@ $('#btn-preferences-update').click(function(e){ $.post('command/music-library.php?cmd=clear_libcache_all'); } + if (radioCoversChange == true) { + $.post('command/radio.php?cmd=clear_radiocover_url_cache'); + } + if (accentColorChange == true) { accentColor = themeToColors(SESSION.json['accent_color']); $('.playbackknob').trigger('configure',{"fgColor":accentColor}); @@ -3803,7 +3926,6 @@ $('#btn-preferences-update').click(function(e){ 'cover_backdrop': SESSION.json['cover_backdrop'], 'cover_blur': SESSION.json['cover_blur'], 'cover_scale': SESSION.json['cover_scale'], - 'renderer_backdrop': SESSION.json['renderer_backdrop'], 'font_size': SESSION.json['font_size'], 'native_lazyload': SESSION.json['native_lazyload'], @@ -3819,7 +3941,7 @@ $('#btn-preferences-update').click(function(e){ 'library_hiresthm': SESSION.json['library_hiresthm'], 'playlist_art': SESSION.json['playlist_art'], 'library_tagview_covers': SESSION.json['library_tagview_covers'], - 'radio_track_covers': SESSION.json['radio_track_covers'], + 'radio_covers': SESSION.json['radio_covers'], 'itunes_query_timeout': SESSION.json['itunes_query_timeout'], // Library @@ -3861,8 +3983,8 @@ $('#btn-preferences-update').click(function(e){ function() { if (extraTagsChange || scnSaverStyleChange || scnSaverModeChange || scnSaverLayoutChange || playHistoryChange || libraryOptionsChange || clearLibcacheAllReqd || lazyLoadChange || - radioTrackCoversChange || - (SESSION.json['bgimage'] != '' && SESSION.json['cover_backdrop'] == 'No') || UI.bgImgChange == true) { + (SESSION.json['bgimage'] != '' && SESSION.json['cover_backdrop'] == 'No') || UI.bgImgChange == true + ) { notify(NOTIFY_TITLE_INFO, 'settings_updated_with_msg', ' The page will automatically refresh to make the settings effective.'); setTimeout(function() { location.reload(true); @@ -4042,8 +4164,14 @@ function setClkRadioCtls(ctlValue) { // Custom select controls $('body').on('click', '.dropdown-menu .custom-select a', function(e) { var selector = '#' + $(this).data('cmd').substr(0, $(this).data('cmd').indexOf('-sel')); - $(selector + ' span').text($(this).text()); + // Default: update the main span element + $(selector + ' span:not(.data-value)').text($(this).text()); + // Radio Browser + if ($(this).data('cmd') == 'rb-country-sel' || $(this).data('cmd') == 'rb-genre-sel') { + $(selector + ' span.data-value').text($(this).data('value')); + } + // Clock radio if ($(this).data('cmd') == 'clockradio-mode-sel') { setClkRadioCtls($(this).text()); } @@ -4494,7 +4622,14 @@ $('#index-browse li').on('click', function(e) { listLook('database li', 'folder', $(this).text()); }); $('#index-radio li').on('click', function(e) { - list = SESSION.json['radioview_sort_group'].split(',')[1] == 'No grouping' ? 'radio' : 'radio_headers'; + var sortGroup = SESSION.json['radioview_sort_group'].split(',')[1]; + if (sortGroup == 'No grouping') { + list = 'radio'; + } else if (sortGroup == 'Favorites first') { + list = 'radio_exclude_favorites'; + } else { + list = 'radio_headers'; + } listLook('radio-covers li', list, $(this).text()); }); $('#index-playlist li').on('click', function(e) { @@ -4509,10 +4644,21 @@ function listLook(selector, list, searchText) { if (searchText != '#') { if (list == 'radio') { $('#' + selector).each(function() { - var text = removeArticles($(this).children('span').text().toLowerCase()); + var text = removeArticles($(this).children('.station-name').text().toLowerCase()); if (text.substr(0, 1) == searchText) {return false;} itemNum++; }); + } + else if (list == 'radio_exclude_favorites') { + list = 'radio'; + $('#' + selector).each(function() { + var type = $(this).children('.station-type').text(); + if (!type.includes('f')) { + var text = removeArticles($(this).children('.station-name').text().toLowerCase()); + if (text.substr(0, 1) == searchText) {return false;} + itemNum++; + } + }); } else if (list == 'radio_headers') { $('#' + selector).each(function() { @@ -5362,7 +5508,7 @@ function getKeyOrValue (type, item) { // Font size factors ['Smaller',.35],['Small',.40],['Normal',.45],['Large',.55],['Larger',.65],['X-Large',.75], // Radioview station types - ['Regular','r'],['Favorite','f'],['Hidden','h'], + ['Regular','r'],['Favorite','f'],['Favorite (Radio Browser)','fb'],['Hidden','h'], // Thumbnail resolutions ['Auto','Auto'],['400px','400px,75'],['500px','500px,60'],['600px','600px,60'], // Dashboard commands diff --git a/www/js/radio-browser.js b/www/js/radio-browser.js new file mode 100644 index 000000000..64293f0fb --- /dev/null +++ b/www/js/radio-browser.js @@ -0,0 +1,470 @@ +/*! + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 The moOde audio player project / Tim Curtis + * Copyright 2026 RadioBrowser extension / @rubatron + * https://github.com/rubatron/RadioBrowser/tree/main + * Copyright 2026 RadioBrowser integration / @Gjuju + * https://github.com/moode-player/moode/commit/910bee751a1f65fa80b1cd44383bc9450cacba19 + * + * Radio Browser view. + * Derived from @rubatron's RadioBrowser extension for moOde and re-implemented + * using moOde's native front-end style and reusing the Radio view markup and CSS. + */ + +var RB = { + tab: 'search', // search | recent + offset: 0, // Search pagination offset + limit: 28, // Page size (fixed) + listsLoaded: {recent: false}, + countriesLoaded: false, + menuUrl: '' // URL of the tile whose context menu is open (Remove-from-recent target) +}; + +// Build a station object from a tile's data-* attributes +function rbStationFromTile(li) { + var $li = $(li); + return { + name: $li.data('name') || '', + url: $li.data('url') || '', + favicon: $li.data('favicon') || '', + homepage: $li.data('homepage') || '', + country: $li.data('country') || '', + tags: $li.data('tags') || '', + bitrate: parseInt($li.data('bitrate')) || 0, + codec: $li.data('codec') || '', + stationuuid: $li.data('uuid') || '' + }; +} + +// Logo URL from the favicon (external ones proxied same-origin, cached on demand) +function rbLogoUrl(s) { + if (s.favicon) { + if (/^https?:\/\//i.test(s.favicon)) { + return RB_API + '?cmd=logo&url=' + encodeURIComponent(s.favicon); + } + return s.favicon; + } + return DEFAULT_RADIO_COVER; +} + +// Build a single station tile (mirrors renderRadioView() markup so Radio CSS applies) +function rbBuildTile(s, i) { + var logo = rbLogoUrl(s); + var meta = []; + if (s.country) meta.push(rbEscapeHtml(s.country)); + if (s.codec) meta.push(rbEscapeHtml(s.codec)); + if (s.bitrate) meta.push(s.bitrate + ' kbps'); + var favClass = s.added ? 'rb-fav-toggle added' : 'rb-fav-toggle'; + var favIcon = s.added ? 'fa-solid' : 'fa-regular'; + var hires = (s.bitrate && s.bitrate >= 320) ? '
    ' + RADIO_HIRES_BADGE_TEXT + '
    ' : ''; + var favToggle = '
    '; + var coverMenu = '
    '; + + return '
  • ' + + '
    ' + + '
    ' + + '' + + favToggle + + '
    ' + + '
    ' + + coverMenu + + hires + + '' + rbEscapeHtml(s.name) + '' + + (meta.length ? '' : '') + + '
  • '; +} + +function rbRenderTiles(stations, ulId) { + RB.seen = {}; // normalized url set (dedup across appended pages) + RB.tileIndex = 0; // running
  • id counter + if (!stations || stations.length === 0) { + $('#' + ulId).html('
  • No stations
  • '); + return; + } + document.getElementById(ulId).innerHTML = ''; + rbAppendTiles(stations, ulId); +} + +// Append a batch, skipping stations already shown (client-side cross-page dedup) +function rbAppendTiles(stations, ulId) { + var sm = document.getElementById('rb-showmore'); + if (sm) { sm.remove(); } // keep "Show more" as the last
  • + var html = ''; + for (var i = 0; i < stations.length; i++) { + var key = (stations[i].url || '').trim().toLowerCase(); + if (key && RB.seen[key]) { continue; } + if (key) { RB.seen[key] = true; } + html += rbBuildTile(stations[i], RB.tileIndex++); + } + document.getElementById(ulId).insertAdjacentHTML('beforeend', html); +} + +// Inject/remove the "Show more" button as the last
  • of the search grid +function rbSetShowMore(show) { + var old = document.getElementById('rb-showmore'); + if (old) { old.remove(); } + if (show) { + document.getElementById('rb-covers-search').insertAdjacentHTML('beforeend', + '
  • '); + } +} + +function rbEscapeHtml(text) { + return $('
    ').text(text == null ? '' : text).html().replace(/"/g, '"'); +} + +function rbLoading(ulId) { + $('#' + ulId).html('
  •  Loading…
  • '); +} + +// --- Search --------------------------------------------------------------- + +function rbSearch(offset, append) { + RB.offset = offset || 0; + var params = { + name: $('#rb-filter').val().trim(), + countrycode: $('#rb-country span.data-value').text(), + tag: $('#rb-genre span.data-value').text(), + offset: RB.offset, + limit: RB.limit + }; + + if (!append) { rbLoading('rb-covers-search'); } + // No filters = top stations by clickcount (search paginates via offset) + $.getJSON(RB_API + '?cmd=search', params, function(data) { + if (data && data.success) { + if (append) { rbAppendTiles(data.stations, 'rb-covers-search'); } + else { rbRenderTiles(data.stations, 'rb-covers-search'); } + // API returns no total: show "more" while the raw batch was full + var batch = (typeof data.batch === 'number') ? data.batch : data.stations.length; + rbSetShowMore(batch >= RB.limit); + } else { + if (!append) { $('#rb-covers-search').html('
  • No results
  • '); } + rbSetShowMore(false); + } + }).fail(function() { + if (!append) { $('#rb-covers-search').html('
  • radio-browser.info unavailable
  • '); } + rbSetShowMore(false); + }); +} + +// --- Recently played ------------------------------------------------------ + +function rbLoadRecent() { + rbLoading('rb-covers-recent'); + $.getJSON(RB_API + '?cmd=recently_played', function(data) { + rbRenderTiles(data.success ? data.stations : [], 'rb-covers-recent'); + RB.listsLoaded.recent = true; + }); +} + + +// NOTE: Instant play disabled +// Lacks registration checks and time delay needed to ensure Queue thumb shows up +// A play just updated the recent list server-side (cmd=play → rbAddRecent). Reload it now if +// the Recent tab is showing, else force a reload the next time it's opened. +function rbMarkRecentStale() { + RB.listsLoaded.recent = false; + if (RB.tab === 'recent') { rbLoadRecent(); } +} + +// Native-style client-side filter over the loaded Recent tiles (mirrors the #ra-filter handler) +function rbFilterRecent(filter) { + filter = (filter || '').trim(); + $('#rb-covers-recent li').each(function() { + $(this).toggle($(this).text().search(new RegExp(filter, 'i')) >= 0); + }); +} + +// --- Actions -------------------------------------------------------------- + +// Pre-register the stream in RADIO.json so the native now-playing renderer resolves it +function rbRegisterInRadioJson(station) { + if (typeof RADIO === 'object' && RADIO.json && station.url && !RADIO.json[station.url]) { + RADIO.json[station.url] = { + name: station.name, type: 'rb', logo: 'local', + bitrate: String(station.bitrate || ''), format: station.codec || '', + home_page: station.homepage || '', monitor: 'No' + }; + } +} + +// NOTE: Instant play disabled +// Lacks registration checks and time delay needed to ensure Queue thumb shows up +function rbPlay(li) { + var station = rbStationFromTile(li); + if (!station.url) return; + $('#container-radio-browser .database-radio li').removeClass('active'); + $(li).addClass('active'); + rbRegisterInRadioJson(station); + + $.ajax({ + url: RB_API + '?cmd=play', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(station), + dataType: 'json', + success: function(data) { + notify(data && data.success ? NOTIFY_TITLE_INFO : NOTIFY_TITLE_ALERT, + 'rb_message', data ? data.message + '. ' : 'Play failed. ', NOTIFY_DURATION_SHORT); + rbMarkRecentStale(); // the play was recorded server-side; refresh the Recent tab + } + }); +} + +function rbToggleFavorite(li) { + var $li = $(li); + var station = rbStationFromTile(li); + if (!station.url) return; + var isAdded = $li.find('.rb-fav-toggle').hasClass('added'); + var cmd = isAdded ? 'remove' : 'add'; + + $.ajax({ + url: RB_API + '?cmd=' + cmd, + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(station), + dataType: 'json', + success: function(data) { + if (data && data.success) { + rbSetFavoriteState(station.url, !isAdded); + RB.favoritesDirty = true; // refresh the native Radio grid when we return to it + if (cmd == 'add') { + notify(NOTIFY_TITLE_INFO, 'rb_message', data.message + '. ', NOTIFY_DURATION_SHORT); + } + } else { + notify(NOTIFY_TITLE_ALERT, 'rb_message', 'Action failed. ', NOTIFY_DURATION_SHORT); + } + // Clear the busy spinner + setTimeout(function() { $('.busy-spinner').hide(); }, ONE_SEC_TIMEOUT); + } + }); +} + +// Sync the heart state of every tile that shares this stream URL +function rbSetFavoriteState(url, added) { + $('#container-radio-browser .database-radio li').each(function() { + if ($(this).data('url') === url) { + var $t = $(this).find('.rb-fav-toggle'); + $t.toggleClass('added', added); + $t.find('i').toggleClass('fa-solid', added).toggleClass('fa-regular', !added); + } + }); +} + +// --- Tabs / view activation ---------------------------------------------- + +function rbShowTab(tab) { + RB.tab = tab; + $('.rb-tab').removeClass('active'); + $('#btn-rb-tab-' + tab).addClass('active'); + $('.rb-tab-pane').addClass('hide'); + $('#rb-tab-' + tab).removeClass('hide'); + // Country/genre are radio-browser.info API params — no meaning on the client-filtered + // Recent tab. The search box stays, but switches to a native-style live filter (below). + $('#rb-filters').toggleClass('hide', tab === 'recent'); + $('#rb-filter').attr('placeholder', tab === 'recent' ? 'search' : 'search radio-browser.info'); + // The search box is shared by both tabs and means different things per tab — reset it and + // clear any leftover Recent filter on every switch so each tab starts clean. + $('#rb-filter').val(''); + $('#btn-rb-search-reset').addClass('hide'); + $('#rb-covers-recent li').show(); + + if (tab === 'search' && $('#rb-covers-search li').length === 0) { + rbSearch(0); + } else if (tab === 'recent' && !RB.listsLoaded.recent) { + rbLoadRecent(); + } +} + +// Called from makeActive() when the Radio Browser view becomes active +function rbOnViewActive() { + if (!RB.countriesLoaded) { + rbLoadCountriesAndGenres(); + } + rbShowTab(RB.tab); +} +function rbLoadCountriesAndGenres() { + RB.countriesLoaded = true; + $.getJSON(RB_API + '?cmd=countries', function(data) { + if (data && data.success) { + var lines = '
  • All Countries
  • '; + data.countries.forEach(function(item) { + if (item.iso_3166_1 && item.name) { + lines += '
  • ' + + rbEscapeHtml(item.name) + + '
  • '; + } + }); + $('#rb-country-list').html(lines); + } + }); + $.getJSON(RB_API + '?cmd=genres', function(data) { + if (data && data.success) { + var lines = '
  • All Genres
  • '; + data.genres.forEach(function(item) { + if (item.name && item.genre) { + lines += '
  • ' + + item.name + + '
  • '; + } + }); + $('#rb-genre-list').html(lines); + } + }); +} + +// --- Event bindings ------------------------------------------------------- + +$(document).ready(function() { + $('#btn-rb-tab-search').click(function() { rbShowTab('search'); }); + $('#btn-rb-tab-recent').click(function() { rbShowTab('recent'); }); + + $('#btn-rb-refresh').click(function() { + if (RB.tab === 'search') { rbSearch(0); } + else { rbLoadRecent(); } + }); + + $('#rb-filter').on('keyup', function(e) { + $('#btn-rb-search-reset').toggleClass('hide', $(this).val() === ''); + if (RB.tab === 'recent') { + // Native-style client-side filter of the already-loaded Recent tiles (debounced) + clearTimeout(searchTimer); + var val = $(this).val(); + searchTimer = setTimeout(function() { rbFilterRecent(val); }, SEARCH_TIMEOUT); + } else if (e.which === 13) { + rbSearch(0); + } + }); + + $('#btn-rb-search-reset').click(function() { + $('#rb-filter').val(''); + $(this).addClass('hide'); + if (RB.tab === 'recent') { rbFilterRecent(''); } + else { rbSearch(0); } + }); + + var target = document.querySelector('#rb-country span'); + var observer = new MutationObserver(mutate); + var config = {characterData: true, attributes: false, childList: true, subtree: false}; + observer.observe(target, config); + function mutate(mutations) { + rbSearch(0); + } + var target = document.querySelector('#rb-genre span'); + var observer = new MutationObserver(mutate); + var config = {characterData: true, attributes: false, childList: true, subtree: false}; + observer.observe(target, config); + function mutate(mutations) { + rbSearch(0); + } + + $('#rb-covers-search').on('click', '#btn-rb-showmore', function() { + rbSearch(RB.offset + RB.limit, true); + }); + + // Tile interactions (event delegation across the search/recent tabs) + $('#container-radio-browser').on('click', '.database-radio img', function() { + li = $(this).closest('li'); + $('#container-radio-browser .database-radio li').removeClass('active'); + $(li).addClass('active'); + + // NOTE: Disable instant play + // Lacks registration checks and time delay needed to ensure Queue thumb shows up + //rbPlay(li); + }); + + $('#container-radio-browser').on('click', '.rb-fav-toggle', function(e) { + e.stopPropagation(); + var station = rbStationFromTile($(this).closest('li')); + if (!station.url) { + notify(NOTIFY_TITLE_ALERT,'rb_message', 'Action failed: URL missing. ', NOTIFY_DURATION_SHORT); + return false; + } else { + var li = $(this).closest('li') + $.getJSON(RB_API + '?cmd=check_registered', {'url': station.url}, function(data) { + if (data.success && data.message == 'Station exists in Radio view') { + notify(NOTIFY_TITLE_INFO,'rb_message', data.message + '. ', NOTIFY_DURATION_SHORT); + } else { + rbToggleFavorite(li); + } + }); + } + }); + + // Register the station for now-playing; the native .cover-menu handler queues data-path + $('#container-radio-browser').on('click', '.cover-menu', function() { + // 'Remove from recent' only makes sense on the Recent tab + $('#rb-ctx-remove-recent').toggleClass('hide', RB.tab !== 'recent'); + var station = rbStationFromTile($(this).closest('li')); + if (!station.url) { + notify(NOTIFY_TITLE_ALERT,'rb_message', 'Action failed: URL missing. ', NOTIFY_DURATION_SHORT); + return false; + } else { + RB.menuUrl = station.url; // target for the Remove-from-recent action + $.getJSON(RB_API + '?cmd=check_registered', {'url': station.url}, function(data) { + //console.log(data.message); + if (data.success) { + if (data.message == 'Station exists in Radio view') { + notify(NOTIFY_TITLE_INFO,'rb_message', data.message + '. ', NOTIFY_DURATION_SHORT); + } + $('#context-menu-radio-browser-item').show(); + } else { + // not in cfg_radio, pruned due to being removed from the Queue + rbRegisterInRadioJson(station); + notify(NOTIFY_TITLE_INFO,'rb_message', 'Registering station for playback... ', NOTIFY_DURATION_INFINITE); + $.ajax({ + url: RB_API + '?cmd=register', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify(station), + dataType: 'json', + success: function(data) { + if (data.success) { + $('.ui-pnotify-closer').click(); + $('#context-menu-radio-browser-item').show(); + rbMarkRecentStale(); // the register was recorded server-side; refresh the Recent tab + } else { + notify(NOTIFY_TITLE_ALERT, 'rb_message', 'Action failed. ', NOTIFY_DURATION_SHORT); + } + } + }); + } + }); + } + }); + + $('#context-menu-radio-browser-item a[data-cmd="rb_remove_recent"]').click(function() { + if (!RB.menuUrl) { + notify(NOTIFY_TITLE_ALERT,'rb_message', 'Action failed: URL missing. ', NOTIFY_DURATION_SHORT); + return false; + } else { + $.ajax({ + url: RB_API + '?cmd=remove_recent', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({url: RB.menuUrl}), + dataType: 'json', + success: function(data) { + if (data && data.success) { rbLoadRecent(); } + notify(data && data.success ? NOTIFY_TITLE_INFO : NOTIFY_TITLE_ALERT, + 'rb_message', data ? data.message + '. ' : 'Action failed. ', NOTIFY_DURATION_SHORT); + } + }); + } + }); +}); diff --git a/www/js/scripts-configs.js b/www/js/scripts-configs.js index 285aa5326..c0d281d6c 100644 --- a/www/js/scripts-configs.js +++ b/www/js/scripts-configs.js @@ -282,10 +282,10 @@ jQuery(document).ready(function($){ 'use strict'; $('#manualserver').val($('#address').val().trim()); }); - // View MPD db update status + // View MPD db regen status $('#view-dbupdate-status').click(function(e) { - $.getJSON('command/music-library.php?cmd=get_dbupdate_status', function(status) { - $('#dbupdate-status').html(status); + $.getJSON('command/music-library.php?cmd=get_dbupdate_count', function(count) { + $('#dbupdate-status').html('Files indexed: ' + count); }); }); diff --git a/www/js/scripts-library.js b/www/js/scripts-library.js index cbace93cd..9550d6080 100755 --- a/www/js/scripts-library.js +++ b/www/js/scripts-library.js @@ -1309,6 +1309,39 @@ $('#database-radio').on('click', 'img', function(e) { }, DEFAULT_TIMEOUT); }); +// This removes the station from Radio view Favorites and refreshes the list +// Favorite heart will only be on RB favorite stations +$('#database-radio').on('click', '.rb-fav-toggle', function(e) { + e.stopPropagation(); + var $toggle = $(this); + var url = $toggle.closest('li').data('url'); + var name = $toggle.closest('li').data('name'); + if (!url) {return;} + var isAdded = $toggle.hasClass('added'); + $.ajax({ + url: RB_API + '?cmd=remove', + type: 'POST', + contentType: 'application/json', + data: JSON.stringify({url: url, name: name}), + dataType: 'json', + success: function(data) { + if (data && data.success) { + $toggle.toggleClass('added', !isAdded); + $toggle.find('i').toggleClass('fa-solid', !isAdded).toggleClass('fa-regular', isAdded); + } else { + notify(NOTIFY_TITLE_ALERT, 'rb_message', 'Action failed. ', NOTIFY_DURATION_SHORT); + } + // Clear the busy spinner + setTimeout(function() { $('.busy-spinner').hide(); }, ONE_SEC_TIMEOUT); + // Refresh list + setTimeout(function() { + $('#btn-ra-refresh').click(); + $('#btn-rb-refresh').click(); + }, DEFAULT_TIMEOUT); + } + }); +}); + // Radio manager $('#btn-ra-manager').click(function(e) { var sortGroup = SESSION.json['radioview_sort_group'].split(','); @@ -1508,6 +1541,28 @@ $('#btn-upd-radio-manager').click(function(e) { ); }); +// Radio Browser manager +$('#btn-rb-manager').click(function(e) { + $('#rb-clear-recents-msg, #rb-clear-caches-msg, #rb-check-servers-msg').text('').hide(); + $('#radio-browser-manager-modal').modal(); +}); +$('#btn-rb-clear-recents').click(function(e) { + $.getJSON(RB_API + '?cmd=clear_recents', function(result) { + $('#rb-clear-recents-msg').text(result).show(); + }); +}); +$('#btn-rb-clear-caches').click(function(e) { + $.getJSON(RB_API + '?cmd=clear_caches', function(result) { + $('#rb-clear-caches-msg').text(result).show(); + }); +}); +$('#btn-rb-check-servers').click(function(e) { + $('#rb-check-servers-msg').text('Checking...').show(); + $.getJSON(RB_API + '?cmd=check_servers', function(result) { + $('#rb-check-servers-msg').text(result); + }); +}); + // Click playlist entry $('#database-playlist').on('click', 'img', function(e) { var pos = $(this).parents('li').index(); diff --git a/www/js/scripts-panels.js b/www/js/scripts-panels.js index d470cc83c..1be46b753 100644 --- a/www/js/scripts-panels.js +++ b/www/js/scripts-panels.js @@ -57,18 +57,16 @@ jQuery(document).ready(function($) { 'use strict'; } // Only show Prefs transparency options if alphablend != 1.00 - var target = document.querySelector('#alpha-blend span') + var target = document.querySelector('#alpha-blend span'); var observer = new MutationObserver(mutate); var config = {characterData: true, attributes: false, childList: true, subtree: false}; observer.observe(target, config); function mutate(mutations) { - mutations.forEach(function(mutation) { - if ($('#alpha-blend span').text() != '1.00') { - $('#cover-options').css('display', 'block'); - } else { - $('#cover-options').css('display', ''); - } - }); + if ($('#alpha-blend span').text() != '1.00') { + $('#cover-options').css('display', 'block'); + } else { + $('#cover-options').css('display', ''); + } } // Load current cfg @@ -423,10 +421,32 @@ jQuery(document).ready(function($) { 'use strict'; // EVENT HANDLERS // + // Swap Radio view native and Radio Browser (each wrapper has its own button bar) + function setRadioBrowser(active) { + if (active) { + $('.container-radio-native').addClass('hide'); + $('#container-radio-browser').removeClass('hide'); + rbOnViewActive(); + } + else { + $('#container-radio-browser').addClass('hide'); + $('.container-radio-native').removeClass('hide'); + // Refresh the now-visible native grid if a favorite changed while RB was on + if (typeof RB === 'object' && RB.favoritesDirty && typeof renderRadioView === 'function') { + RB.favoritesDirty = false; + renderRadioView(); + } + } + } + // Radio view $('.radio-view-btn').click(function(e){ makeActive('.radio-view-btn','#radio-panel','radio'); }); + // Radio Browser toggle (inside Radio view) — one copy per wrapper + $('.rb-toggle-btn').click(function(e){ + setRadioBrowser($('#container-radio-browser').hasClass('hide')); + }); // Playlist view $('.playlist-view-btn').click(function(e){ makeActive('.playlist-view-btn','#playlist-panel','playlist'); @@ -734,6 +754,7 @@ jQuery(document).ready(function($) { 'use strict'; $.get('command/playlist.php?cmd=save_queue_to_playlist&name=' + plName, function() { notify(NOTIFY_TITLE_INFO, 'queue_saved', NOTIFY_DURATION_SHORT); $('#btn-pl-refresh').click(); + $('#db-refresh').click(); }); } } else { @@ -928,10 +949,140 @@ jQuery(document).ready(function($) { 'use strict'; }); $('#db-refresh').click(function(e) { UI.dbPos[UI.dbPos[10]] = 0; - $.getJSON('command/music-library.php?cmd=lsinfo', {'path': UI.path}, function(data) { - renderFolderView(data, UI.path); - }); + if (UI.dbCmd == 'get_pl_items_fv') { + $.getJSON('command/playlist.php?cmd=get_pl_items_fv', {'path': UI.path}, function(data) { + renderFolderView(data, UI.path); + }); + } else { + $.getJSON('command/music-library.php?cmd=lsinfo', {'path': UI.path}, function(data) { + renderFolderView(data, UI.path); + }); + } }); + $('#btn-db-import, #btn-pl-import').click(function(e) { + $('#db-import-file').val(''); + $('#db-import-file').click(); + }); + var importPlaylistContent = ''; // holds the .m3u text between analyze and commit + $('#db-import-file').change(function(e) { + if (this.files.length == 0) { + return; + } + var file = this.files[0]; + var defaultName = file.name.replace(/\.m3u8?$/i, ''); + var reader = new FileReader(); + reader.onload = function(ev) { + importPlaylistContent = ev.target.result; + // Phase A: ask the server which local paths it can't resolve + $.post('command/playlist.php?cmd=analyze_import', {'content': importPlaylistContent}, function(data) { + if (data.status != 'ok') { + notify(NOTIFY_TITLE_ALERT, 'import_playlist_error', data.msg); + } else if (data.unknown.length == 0) { + // All paths known (or URLs) -> import straight away + commitImportPlaylist(defaultName, {}, false); + } else { + openImportModal(defaultName, data); + } + }, 'json'); + }; + reader.readAsText(file); + }); + // custom_radio.js (the moOde toggle wiring) is only in the config bundle, not the + // main index, so wire this modal's ON/OFF toggle ourselves. + function wireImportToggle() { + var $toggle = $('#import-playlist-modal .toggle'); + var $radios = $toggle.find('input'); + // Colour the ON knob with the theme accent like the config pages do. The index's + // setColors() sets the accentColor global but doesn't paint the toggle knob (it + // normally has no toggles), so replicate scripts-configs.js here. + if (typeof accentColor === 'string' && accentColor.charAt(0) == '#') { + var knob = "data:image/svg+xml;utf8,"; + document.body.style.setProperty('--toggleon', 'url("' + knob + '")'); + } + $toggle.toggleClass('toggle-off', !$radios.eq(0).is(':checked')); + $radios.off('click.imptoggle').on('click.imptoggle', function() { + $toggle.toggleClass('toggle-off'); + }); + } + function openImportModal(name, data) { + $('#import-pl-name').val(name); + // default OFF = keep unresolved entries + $('#toggle-import-drop-1').prop('checked', false); + $('#toggle-import-drop-2').prop('checked', true); + wireImportToggle(); + var html = ''; + for (var i = 0; i < data.unknown.length; i++) { + var u = data.unknown[i]; + var opts = ''; + for (var j = 0; j < data.known_dirs.length; j++) { + var d = encodeHTMLEntities(data.known_dirs[j]); + opts += ''; + } + html += '
    ' + + '' + + '
    ' + + '(' + u.count + ' track' + (u.count > 1 ? 's' : '') + ')
    ' + + '
    '; + } + $('#import-pl-remap').html(html); + $('#import-pl-remap .selectpicker').selectpicker(); // moOde-styled dropdowns + renderImportReport(data); + $('#import-playlist-modal').modal(); + } + function renderImportReport(data) { + var unknownTotal = 0; + var prefixes = []; + for (var k = 0; k < data.unknown.length; k++) { + unknownTotal += data.unknown[k].count; + prefixes.push(data.unknown[k].prefix); + } + var msg = (data.ok_local + data.url_count) + ' of ' + data.total + ' entries resolve'; + msg += unknownTotal > 0 ? ' · ' + unknownTotal + ' still unresolved (' + prefixes.join(', ') + ')' + : ' · all good'; + $('#import-pl-report').text(msg); + } + function currentImportRemap() { + var remap = {}; + $('#import-pl-remap .import-pl-row').each(function() { + var target = $(this).find('.import-pl-select').val(); + if (target) { + remap[$(this).attr('data-prefix')] = target; + } + }); + return remap; + } + // Re-validate the entries against the box with the current remap choices + $('#btn-test-import').click(function(e) { + $.post('command/playlist.php?cmd=analyze_import', + {'content': importPlaylistContent, 'remap': JSON.stringify(currentImportRemap())}, + function(data) { + if (data.status == 'ok') { + renderImportReport(data); + } + }, 'json'); + }); + $('#btn-import-playlist').click(function(e) { + var dropInvalid = !$('#import-playlist-modal .toggle').hasClass('toggle-off'); + commitImportPlaylist($('#import-pl-name').val(), currentImportRemap(), dropInvalid); + }); + function commitImportPlaylist(name, remap, dropInvalid) { + $.post('command/playlist.php?cmd=import_playlist', { + 'name': name, + 'content': importPlaylistContent, + 'remap': JSON.stringify(remap), + 'drop_invalid': dropInvalid ? '1' : '0' + }, function(data) { + if (data.status != 'ok') { + notify(NOTIFY_TITLE_ALERT, 'import_playlist_error', data.msg); + return; + } + var extra = data.imported + ' track' + (data.imported > 1 ? 's' : '') + + (data.dropped > 0 ? ', ' + data.dropped + ' removed' : ''); + notify(NOTIFY_TITLE_INFO, 'import_playlist', extra, NOTIFY_DURATION_SHORT); + $('#btn-pl-refresh').click(); + $('#db-refresh').click(); + }, 'json'); + } $('#db-search-results').click(function(e) { $('.database li').removeClass('active'); $('#db-search-results').css('font-weight', 'bold'); @@ -1041,10 +1192,13 @@ jQuery(document).ready(function($) { 'use strict'; var path = $(this).parents('li').data('path'); // DEBUG: //console.log('click .cover-menu: pos|path: ' + pos + '|' + path); + //console.log('click .cover-menu: ul: ' + $(this).parents('ul').attr('id')); UI.dbEntry[0] = path; - UI.radioPos = pos; - storeRadioPos(UI.radioPos) + if ($(this).parents('ul').attr('id') != 'rb-covers-search') { // dont store for Radio Browser view + UI.radioPos = pos; + storeRadioPos(UI.radioPos) + } $('#' + UI.dbEntry[3]).removeClass('active'); UI.dbEntry[3] = $(this).parents('li').attr('id'); @@ -1255,6 +1409,7 @@ jQuery(document).ready(function($) { 'use strict'; $.post('command/playlist.php?cmd=del_playlist', {'path': UI.dbEntry[0]}, function() { notify(NOTIFY_TITLE_INFO, 'del_playlist', NOTIFY_DURATION_SHORT); $('#btn-pl-refresh').click(); + $('#db-refresh').click(); }); }); // Delete/Move playlist items(s) @@ -1936,11 +2091,11 @@ jQuery(document).ready(function($) { 'use strict'; $('#dropdown-cdsp-menu').scrollTo(0, 200); }); - // Display MPD update status + // Display MPD db update status $('.busy-spinner').click(function(e) { if (GLOBAL.libLoading == true) { - $.getJSON('command/music-library.php?cmd=get_dbupdate_status', function(status) { - notify(NOTIFY_TITLE_INFO, 'dbupdate_status', status); + $.getJSON('command/music-library.php?cmd=get_dbupdate_count', function(count) { + notify(NOTIFY_TITLE_INFO, 'dbupdate_status', 'Files updated: ' + count); }); } }); diff --git a/www/js/sendspin-display.js b/www/js/sendspin-display.js new file mode 100644 index 000000000..6284f9413 --- /dev/null +++ b/www/js/sendspin-display.js @@ -0,0 +1,140 @@ +(function() { + 'use strict'; + + var path = window.location.pathname; + if (path !== '/' && path !== '/index.php') { + return; + } + + var pollTimer = null; + + function showIndicator(title, artist, album, coverUrl) { + var indicator = document.getElementById('inpsrc-indicator'); + var msg = document.getElementById('inpsrc-msg'); + var cover = document.getElementById('inpsrc-cover'); + var metadata = document.getElementById('inpsrc-metadata'); + var backdrop = document.getElementById('inpsrc-backdrop'); + + if (!indicator || !msg) return; + + // Switch msg class to metadata mode (matching moOde pattern) + msg.classList.remove('inpsrc-msg-default'); + msg.classList.add('inpsrc-msg-metadata'); + msg.style.width = '100%'; + + // Message area: Turn Off button only (metadata shown below) + // moOde clears msg-text when metadata is available + msg.innerHTML = '' + + ''; + + // Cover art image + if (cover) { + if (coverUrl) { + cover.innerHTML = ''; + } else { + cover.innerHTML = ''; + } + } + + // Backdrop (blurred background) + if (backdrop) { + if (coverUrl) { + backdrop.innerHTML = ''; + } else { + backdrop.innerHTML = ''; + } + } + + // Metadata text: Artist - Title / Album + if (metadata) { + if (artist && title) { + metadata.innerHTML = '' + artist + ' - ' + title + '
    ' + (album || '') + ''; + } else { + metadata.innerHTML = ''; + } + metadata.style.display = ''; + } + + // Style indicator (shows the backdrop color overlay) + var styleEl = document.getElementById('inpsrc-style'); + if (styleEl) styleEl.style.display = 'block'; + + // Show the indicator + indicator.classList.remove('hide'); + indicator.style.display = 'block'; + } + + function hideIndicator() { + var indicator = document.getElementById('inpsrc-indicator'); + var msg = document.getElementById('inpsrc-msg'); + var metadata = document.getElementById('inpsrc-metadata'); + var cover = document.getElementById('inpsrc-cover'); + var backdrop = document.getElementById('inpsrc-backdrop'); + + if (indicator) { + indicator.style.display = ''; + indicator.classList.add('hide'); + } + if (msg) { + msg.innerHTML = ''; + msg.classList.remove('inpsrc-msg-metadata'); + msg.classList.add('inpsrc-msg-default'); + } + if (metadata) { + metadata.innerHTML = ''; + metadata.style.display = 'none'; + } + if (cover) cover.innerHTML = ''; + if (backdrop) backdrop.innerHTML = ''; + } + + function fetchMetadata() { + fetch('command/sendspin-meta.php') + .then(function(r) { return r.text(); }) + .then(function(data) { + if (!data || data === '') { + hideIndicator(); + return; + } + var parts = data.split('~~~'); + var title = (parts[0] || '').trim(); + var artist = (parts[1] || '').trim(); + var album = (parts[2] || '').trim(); + var coverUrl = (parts[4] || '').trim(); + + if (title === '' || title === 'SendSpin') { + hideIndicator(); + return; + } + + showIndicator(title, artist, album, coverUrl); + }) + .catch(function() {}); + } + + // Turn Off button + document.addEventListener('click', function(e) { + var btn = (e.target.closest && e.target.closest('[data-job="sendspinsvc"]')); + if (btn || (e.target.classList && e.target.classList.contains('turnoff-renderer') && e.target.getAttribute('data-job') === 'sendspinsvc')) { + e.preventDefault(); + hideIndicator(); + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + fetch('command/renderer.php?cmd=disconnect_renderer', { + method: 'POST', + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: 'job=sendspinsvc' + }); + } + }); + + function start() { + fetchMetadata(); + pollTimer = setInterval(fetchMetadata, 3000); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); + } else { + start(); + } +})(); diff --git a/www/lib-config.php b/www/lib-config.php index 65ce4b4d8..e84f43072 100644 --- a/www/lib-config.php +++ b/www/lib-config.php @@ -18,8 +18,12 @@ chkVariables($_GET); chkVariables($_POST, array('password')); -// For save, remove actions +// For "save/remove" actions $initiateLibraryUpd = false; +// For "Analyze music database" +$_mpd_db_stats = '' . + ($_SESSION['mpd_db_stats'] == 'none' ? 'Analyze has not been run' : $_SESSION['mpd_db_stats']) . + ''; //----------------------------------------------------------------------------// // Library Config @@ -54,13 +58,26 @@ } $_SESSION['lib_fv_only'] = $_POST['lib_fv_only']; } -// Regenerate MPD database +// Regenerate MPD database and library tag cache if (isset($_POST['regen_library'])) { unset($_GET['cmd']); submitJob('regen_library', '', NOTIFY_TITLE_INFO, 'Regenerating the database. Stay on this screen until the progress spinner disappears.

    Click VIEW STATUS for progress.', NOTIFY_DURATION_INFINITE); } +// Analyze MPD database +if (isset($_POST['analyze_mpd_db'])) { + unset($_GET['cmd']); + if (false === ($sock = openMpdSock('localhost', 6600))) { + $msg = 'CRITICAL ERROR: lib-config.php: Connection to MPD failed'; + workerLog($msg); + } else { + $msg = getLibraryStats($sock); + closeMpdSock($sock); + $_SESSION['mpd_db_stats'] = $msg; + } + $_mpd_db_stats = '' . $msg . ''; +} // Clear library cache if (isset($_POST['clear_libcache'])) { unset($_GET['cmd']); @@ -448,14 +465,7 @@ $_select['moodefiles_ignore_off'] = "\n"; // DB update status - if (false === ($sock = openMpdSock('localhost', 6600))) { - $msg = 'CRITICAL ERROR: lib-config.php: Connection to MPD failed'; - workerLog($msg); - } else { - $msg = getLibraryStats($sock); - closeMpdSock($sock); - } - $_dbupdate_status = $_SESSION['mpd_dbupdate_status'] == '0' ? $msg : 'Files indexed: ' . $_SESSION['mpd_dbupdate_status']; + $_dbupdate_status = 'Files indexed: ' . $_SESSION['mpd_dbupdate_count']; // Thumbcache status $_thmcache_status = $_SESSION['thmcache_status']; diff --git a/www/quickhelp.html b/www/quickhelp.html index 5f2d70580..81d4c5b76 100644 --- a/www/quickhelp.html +++ b/www/quickhelp.html @@ -23,6 +23,9 @@
    Library

    For details on Library generation and metadata tags view the Music Database and Music Metadata documents.

      +
    • Counts +

      The ANALYZE button in Library Config scans the Music database to produce accurate Artist/Album/Track counts that are subsequently displayed in the Library Config screen and in the Menu > Player name popup. Note: This can take a while if there are a large number of number of tracks.

      +
    • Cover art

      The Cover Art section of Preferences contains settings for specifying cover art resolution, whether to search first for embedded image or cover image file and which audio formats are included.

      Note that some Albums for example "Nine Inch Nails - The Slip" have a main cover image file and also embedded covers in each track. To display the covers correctly first add the cover image file named so its the last file in the album directory, then set Prefs > "Cover search priority" to "Embedded" and Prefs > "Audio formats" to "Default+"

      @@ -82,7 +85,24 @@
      Searching the Library

      Searches are not case sensitive and a progressive Typedown search method is used on the displayed items.

    • Clearing the search -

      Click the Browse by header or the x icon in the "search" field to clear the search.

      +

      Click the Browse by header or the icon in the "search" field to clear the search.

      +
    • +
    +
    +

    Radio Browser

    +
    +
      +
    • General +

      Searches are not case sensitive.

      +
    • +
    • Resetting the main search +

      Click the icon to reset the search.

      +
    • +
    • Resetting the Countries and Genres searches +

      Select "All Countries" or "All Genres" to reset the search.

      +
    • +
    • Manage Settings +

      Click the icon to open the Radio Browser Manager.

    diff --git a/www/rcp-config.php b/www/rcp-config.php new file mode 100644 index 000000000..45951786d --- /dev/null +++ b/www/rcp-config.php @@ -0,0 +1,136 @@ + 'iTunes', + 'search_provider_deezer' => 'Deezer', + 'search_provider_musicbrainz' => 'MusicBrainz', + 'search_provider_spotify' => 'Spotify', + 'spotify_client_id' => 'SPOTIFY_CLIENT_ID', + 'spotify_client_secret' => 'SPOTIFY_CLIENT_SECRET', + 'search_provider_lastfm' => 'LastFM', + 'lastfm_api_key' => 'LASTFM_API_KEY', + 'search_provider_discogs' => 'Discogs', + 'discogs_token' => 'DISCOGS_TOKEN', + 'search_provider_theaudiodb' => 'TheAudioDB', + 'theaudiodb_api_key' => 'THEAUDIODB_API_KEY', + 'search_request_timeout' => 'REQUEST_TIMEOUT', + 'search_min_similarity' => 'MIN_SIMILARITY', + 'search_min_similarity_itunes' => 'MIN_SIMILARITY_ITUNES', + 'search_fast_deadline' => 'FAST_DEADLINE_S', + 'search_total_deadline' => 'TOTAL_DEADLINE_S', + 'search_early_stop_score' => 'EARLY_STOP_SCORE', + 'search_cover_max_size' => 'MAX_SIZE_PX', + 'search_cover_quality' => 'COVER_QUALITY', + 'log_level' => 'LOG_LEVEL', + 'sse_debounce_ms' => 'DEBOUNCE_MS', + 'sse_cache_enabled' => 'CACHE_ENABLED', + 'sse_last_event_send_delay' => 'LAST_EVENT_SEND_DELAY', + 'sse_health_check_interval' => 'HEALTH_CHECK_INTERVAL', + 'sse_segment_cover_weather' => 'SEGMENT_COVER_METEO', + 'sse_segment_cover_traffic' => 'SEGMENT_COVER_TRAFFIC', + 'sse_segment_cover_news' => 'SEGMENT_COVER_NEWS', + 'sse_segment_cover_advert' => 'SEGMENT_COVER_ADVERTISING' + ); + + // Update settings + foreach ($_POST['config'] as $key => $value) { + chkValue($key, $value); + $param = $mappingTable[$key]; + if ($param == 'LOG_LEVEL') { + $value = strtoupper($value); + } + sysCmd("sed -i 's|^" . $param . '=.*|' . $param . '=' . $value . "|' " . RADIOCOVER_PLUS_CFG); + } +} + +if (isset($_POST['update_clear_rcucache'])) { + clearRadioCoverUrlCache(); + $_SESSION['notify']['title'] = NOTIFY_TITLE_INFO; + $_SESSION['notify']['msg'] = 'Cover URL cache has been cleared.'; +} + +phpSession('close'); +// Load config +$config = parseDelimFile(file_get_contents(RADIOCOVER_PLUS_CFG), '='); + +// Search providers (Free) +// iTunes +$_config['search_provider_itunes'] .= "\n"; +$_config['search_provider_itunes'] .= "\n"; +// Deezer +$_config['search_provider_deezer'] .= "\n"; +$_config['search_provider_deezer'] .= "\n"; +// MusicBrainz +$_config['search_provider_musicbrainz'] .= "\n"; +$_config['search_provider_musicbrainz'] .= "\n"; + +// Search providers (Non-Free) +// Spotify +$_config['search_provider_spotify'] .= "\n"; +$_config['search_provider_spotify'] .= "\n"; +$_config['spotify_client_id'] = $config['SPOTIFY_CLIENT_ID']; +$_config['spotify_client_secret'] = $config['SPOTIFY_CLIENT_SECRET']; +// LastFM +$_config['search_provider_lastfm'] .= "\n"; +$_config['search_provider_lastfm'] .= "\n"; +$_config['lastfm_api_key'] = $config['LASTFM_API_KEY']; +// Discogs +$_config['search_provider_discogs'] .= "\n"; +$_config['search_provider_discogs'] .= "\n"; +$_config['discogs_token'] = $config['DISCOGS_TOKEN']; +// TheAudioDB +$_config['search_provider_theaudiodb'] .= "\n"; +$_config['search_provider_theaudiodb'] .= "\n"; +$_config['theaudiodb_api_key'] = $config['THEAUDIODB_API_KEY']; + +// Search settings +$_config['search_request_timeout'] = $config['REQUEST_TIMEOUT']; +$_config['search_min_similarity'] = $config['MIN_SIMILARITY']; +$_config['search_min_similarity_itunes'] = $config['MIN_SIMILARITY_ITUNES']; +$_config['search_fast_deadline'] = $config['FAST_DEADLINE_S']; +$_config['search_total_deadline'] = $config['TOTAL_DEADLINE_S']; +$_config['search_early_stop_score'] = $config['EARLY_STOP_SCORE']; +$_config['search_cover_max_size'] = $config['MAX_SIZE_PX']; +$_config['search_cover_quality'] = $config['COVER_QUALITY']; +$_rcucache_count = sqlQuery("SELECT count() FROM cfg_rcucache",sqlConnect())[0]['count()']; + +// Logging +$_config['log_level'] .= "\n"; +$_config['log_level'] .= "\n"; +$_config['log_level'] .= "\n"; +$_config['log_level'] .= "\n"; +$_config['log_level'] .= "\n"; + +// Daemon mode settings (SSE server) +$_config['sse_debounce_ms'] = $config['DEBOUNCE_MS']; +$_config['sse_cache_enabled'] .= "\n"; +$_config['sse_cache_enabled'] .= "\n"; +$_config['sse_last_event_send_delay'] = $config['LAST_EVENT_SEND_DELAY']; +$_config['sse_health_check_interval'] = $config['HEALTH_CHECK_INTERVAL']; +$_config['sse_segment_cover_weather'] = $config['SEGMENT_COVER_METEO']; +$_config['sse_segment_cover_traffic'] = $config['SEGMENT_COVER_TRAFFIC']; +$_config['sse_segment_cover_news'] = $config['SEGMENT_COVER_NEWS']; +$_config['sse_segment_cover_advert'] = $config['SEGMENT_COVER_ADVERTISING']; + +waitWorker('rcp-config'); + +$tpl = "rcp-config.html"; +$section = basename(__FILE__, '.php'); +storeBackLink($section, $tpl); + +include('header.php'); +eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");"); +include('footer.php'); diff --git a/www/relnotes.txt b/www/relnotes.txt index 39b719413..7d4401118 100644 --- a/www/relnotes.txt +++ b/www/relnotes.txt @@ -9,6 +9,115 @@ # ################################################################################ +################################################################################ +# +# 2026-MM-DD moOde 10.3.3 (Trixie) +# +################################################################################ + +Updates: +- UPD: Radio Browser improve notification messages, reduce messages + +Bug fixes +- FIX: Duplicate entries in Radio Cover+ cache table +- FIX: Bluetooth pairing modal not displayed when on Config screens +- FIX: Brightness adjustment not working for Pi Touch 2 DSI screen +- FIX: Radio Browser recents not showing + +################################################################################ +# +# 2026-08-03 moOde 10.3.2 (Trixie) +# +################################################################################ + +Packages +- NEW: alsa-lib: 1.2.14-1+rpt1moode1 +- UPD: peppy-alsa: 2026.07.26-1moode1 +- UPD: shairport-sync: 5.2.1-1moode1 +- UPD: shairport-sync-metadata-reader: 2.0.0~git20260724.a4a29f3-1moode1 +- UPD: mpd: 0.24.13-1moode1 +- UPD: linux-image: 1:6.18.39-1+rpt1 + +Updates +- UPD: Support native DSD levels in Peppy (pkgbuild PR #24/25 by @Gjuju) +- UPD: Support DoP levels in Peppy (pkgbuild PR #23 by @Gjuju) +- UPD: Get Bluetooth decoded-to using bluealsa-cli instead of hard coding it +- UPD: Add AirPlay protocol (1|2) option to Renderer Config +- UPD: New logo for Soma FM - DEF CON Radio + +Bug fixes +- FIX: Bluetooth PIN pairing: use Confirm Code pairing (PR #784 by @Gjuju) +- FIX: MPD socket error when native DSD and Peppy (pkgbuild PR #24 by @Gjuju) +- FIX: Previous AirPlay or Spotify covers shows after disconnect, start/restart +- FIX: RB station names with -- and > cause issues in Radio view +- FIX: Shairport-sync stop/restart pkill command string + +################################################################################ +# +# 2026-07-22 moOde 10.3.1 (Trixie) +# +################################################################################ + +Packages +- UPD: peppy-meter 2026.7.20-1moode1 + +Updates +- UPD: Increase hit area of the ... button for Queue items (PR #771 @Gjuju) +- UPD: Provide dB values for MPD software volume (PR #770 @Gjuju) + +Radio Browser +- FIX: Image create in radio-logos/ directory fails +- FIX: Queue thumb not showing up due to timing issue +- FIX: List position in Radio view being updated by Radio Browser +- FIX: Highlight is missing when tile is clicked +- FIX: Duplicate station URL's can be added +- FIX: Countries sorted by country code instead of name +- FIX: Remove duplicate genre "Jazz - Bebop" entry +- FIX: Radio Manager hide/unhide actions set wrong station type for RB stations + +Bug fixes +- FIX: Premature auto-switch from Peppy to WebUI on track change (PR #777 @Gjuju) +- FIX: Fix no Bluetooth audio when a GEQ/PEQ/Crossfeed active (PR #774 @Gjuju) +- FIX: Hardware volume not being tracked when Peppy is on (PR #773 @Gjuju) +- FIX: Volume/attenuation when Peppy is on (PR #769 @Gjuju) +- FIX: Typo in Library Config help for the Analyze button +- FIX: Index search broken in Radio view when group by "Favorites first" + +################################################################################ +# +# 2026-07-14 moOde 10.3.0 (Trixie) +# +################################################################################ + +New features +- NEW: Radio Cover+ adv cover search (based on MR+ by Ivo Scagliola & Marco Mosca) +- NEW: view (based on RadioBrowser by @rubatron, integrated by @Gjuju) + +Updates +- UPD: Add Playlist upload/download: both Playlist and Folder view (PR #755 by @Gjuju) +- UPD: Populate CPU governor list from the kernel (PR #757 by @Gjuju) +- UPD: Add "Revox B251 With Haas Effect" to CamillaDSP sample configs (New image) +- UPD: Improve iTunes cover art lookup + +Bug fixes +- FIX: Volume knob responsiveness: freeze/jump, drag flooding (PR #753 by @Gjuju) +- FIX: Pirev.py crashes on unrecognized revision codes (PR #754 by @Gjuju) +- FIX: Bluetooth no sound when ALSA output mode is not "Default" (PR #758 by @Gjuju) +- FIX: Spotify Connect missing covers and metadata +- FIX: Spotify and AirPlay cover backdrop not being reset after renderer ends +- FIX: Text/button visibility with Renderer backdrop (revert to no-cover backdrop) + +################################################################################ +# +# 2026-06-21 moOde 10.2.4 (Trixie) +# +################################################################################ + +Bug fixes +- FIX: Library stats process causing performance issues +- FIX: Folder view not handling directory names with embedded quotes +- FIX: Clock radio sends play command before setting volume + ################################################################################ # # 2026-06-15 moOde 10.2.3 (Trixie) diff --git a/www/ren-config.php b/www/ren-config.php index 802ddfc6f..b480a71b9 100644 --- a/www/ren-config.php +++ b/www/ren-config.php @@ -35,12 +35,11 @@ if (isset($_POST['btrestart']) && $_POST['btrestart'] == 1 && $_SESSION['btsvc'] == '1') { submitJob('btsvc', '', NOTIFY_TITLE_INFO, NAME_BLUETOOTH . NOTIFY_MSG_SVC_MANUAL_RESTART); } -if (isset($_POST['update_bt_pin_code']) && $_POST['update_bt_pin_code'] != 'Pincode set') { - phpSession('write', 'bt_pin_code', $_POST['bt_pin_code']); - $notify = $_SESSION['btsvc'] == '1' ? - array('title' => NOTIFY_TITLE_INFO, 'msg' => NAME_BLUETOOTH_PAIRING_AGENT . NOTIFY_MSG_SVC_RESTARTED) : - array('title' => '', 'msg' => ''); - submitJob('bt_pin_code', $_SESSION['bt_pin_code'], $notify['title'], $notify['msg']); +if (isset($_POST['update_bt_pairing_confirm']) && isset($_POST['bt_pairing_confirm']) + && $_POST['bt_pairing_confirm'] != $_SESSION['bt_pairing_confirm']) { + $_SESSION['bt_pairing_confirm'] = $_POST['bt_pairing_confirm']; + $msg = 'Pairing confirmation ' . ($_POST['bt_pairing_confirm'] == '1' ? 'enabled' : 'disabled'); + submitJob('bt_pairing_confirm', '', NOTIFY_TITLE_INFO, $msg); } if (isset($_POST['update_alsavolume_max_bt'])) { $_SESSION['alsavolume_max_bt'] = $_POST['alsavolume_max_bt']; @@ -75,6 +74,17 @@ submitJob('airplaysvc'); } } +if (isset($_POST['update_airplaysvc_type'])) { + $_SESSION['airplaysvc_type'] = $_POST['airplaysvc_type']; + if ($_SESSION['airplaysvc'] == '1') { + $title = NOTIFY_TITLE_INFO; + $msg = NAME_AIRPLAY . NOTIFY_MSG_SVC_RESTARTED; + } else { + $title = ''; + $msg = ''; + } + submitJob('airplaysvc', '', $title, $msg); +} if (isset($_POST['update_rsmafterapl'])) { phpSession('write', 'rsmafterapl', $_POST['rsmafterapl']); } @@ -208,6 +218,37 @@ submitJob('rbrestart', '', NOTIFY_TITLE_INFO, NAME_ROONBRIDGE . NOTIFY_MSG_SVC_MANUAL_RESTART); } +// SendSpin Multi-Room Audio +if (isset($_POST['update_sendspin_settings'])) { + if (isset($_POST['sendspinsvc']) && $_POST['sendspinsvc'] != $_SESSION['sendspinsvc']) { + $update = true; + phpSession('write', 'sendspinsvc', $_POST['sendspinsvc']); + } + if (isset($_POST['sendspinname']) && $_POST['sendspinname'] != $_SESSION['sendspinname']) { + $update = true; + phpSession('write', 'sendspinname', $_POST['sendspinname']); + } + if (isset($_POST['rsmafterss']) && $_POST['rsmafterss'] != $_SESSION['rsmafterss']) { + $update = true; + phpSession('write', 'rsmafterss', $_POST['rsmafterss']); + } + if (isset($update)) { + // Worker handles service regeneration + start/stop via submitJob + submitJob('sendspinsvc'); + } +} +if (isset($_POST['sendspinrestart']) && $_POST['sendspinrestart'] == 1 && $_SESSION['sendspinsvc'] == '1') { + submitJob('sendspinrestart', '', NOTIFY_TITLE_INFO, 'SendSpin' . NOTIFY_MSG_SVC_MANUAL_RESTART); +} + +// Preserve cfg_system params (feat_bitmask) before closing shared session. +// header.php opens the worker's shared session. phpSession('close') +// writes $_SESSION back to disk. If a page doesn't load feat_bitmask, +// it gets wiped from the session file, hiding all renderer sections. +if (!isset($_SESSION['feat_bitmask'])) { + $stmt = $dbh->query("SELECT value FROM cfg_system WHERE param='feat_bitmask'"); + $_SESSION['feat_bitmask'] = $stmt ? $stmt->fetchColumn() : '0'; +} phpSession('close'); // Bluetooth @@ -218,13 +259,9 @@ $_select['btsvc_on'] .= "\n"; $_select['btsvc_off'] .= "\n"; $_select['btname'] = $_SESSION['btname']; -if (empty($_SESSION['bt_pin_code'])) { - $_bt_pin_code = ''; - $_pwd_input_format = 'password'; -} else { - $_bt_pin_code = 'Pincode set'; - $_pwd_input_format = 'text'; -} +$autoClick = " onchange=\"autoClick('#btn-set-btpairconfirm');\""; +$_select['bt_pairing_confirm_on'] = "\n"; +$_select['bt_pairing_confirm_off'] = "\n"; if ($_SESSION['alsavolume'] == 'none') { $_alsavolume_max_bt = ''; @@ -276,6 +313,8 @@ $_select['airplaysvc_on'] .= "\n"; $_select['airplaysvc_off'] .= "\n"; $_select['airplayname'] = $_SESSION['airplayname']; +$_select['airplaysvc_type'] .= "\n"; +$_select['airplaysvc_type'] .= "\n"; $autoClick = " onchange=\"autoClick('#btn-set-rsmafterapl');\" " . $_airplay_btn_disable; $_select['rsmafterapl_on'] .= "\n"; $_select['rsmafterapl_off'] .= "\n"; @@ -412,6 +451,23 @@ $_feat_roonbridge = 'hide'; } + +if (($_SESSION['feat_bitmask'] & FEAT_SENDSPIN)) { + $_feat_sendspin = ''; + $_SESSION['sendspin_installed'] == 'yes' ? $_sendspin_svcbtn_disable = '' : $_sendspin_svcbtn_disable = 'disabled'; + $_SESSION['sendspinsvc'] == '1' ? $_sendspin_btn_disable = '' : $_sendspin_btn_disable = 'disabled'; + $_SESSION['sendspinsvc'] == '1' ? $_sendspin_link_disable = '' : $_sendspin_link_disable = 'onclick="return false;"'; + $autoClick = " onchange=\"autoClick('#btn-set-sendspinsvc');\""; + $_select['sendspinsvc_on'] = "\n"; + $_select['sendspinsvc_off'] = "\n"; + $_select['sendspinname'] = $_SESSION['sendspinname']; + $autoClick = " onchange=\"autoClick('#btn-set-rsmafterss');\" " . $_sendspin_btn_disable; + $_select['rsmafterss_on'] = "\n"; + $_select['rsmafterss_off'] = "\n"; +} else { + $_feat_sendspin = 'hide'; +} + waitWorker('ren-config'); $tpl = "ren-config.html"; @@ -420,4 +476,4 @@ include('header.php'); eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");"); -include('footer.php'); +include('footer.min.php'); diff --git a/www/setup_3rdparty_sendspin.txt b/www/setup_3rdparty_sendspin.txt new file mode 100644 index 000000000..1c0c736b0 --- /dev/null +++ b/www/setup_3rdparty_sendspin.txt @@ -0,0 +1,254 @@ +################################################################################ +# +# Setup Guide for SendSpin Multi-Room Audio Renderer +# +# Version: 3.0 (2026-08-12) +# +################################################################################ + +OVERVIEW + +SendSpin is a synchronized multi-room audio protocol. This integration adds +SendSpin as a first-class renderer in moOde's web UI, allowing your Raspberry Pi +to act as an audio endpoint in multi-room systems (Music Assistant, etc.). + +The installer handles everything automatically -- it installs Python 3, uv +(Python package manager), and the sendspin CLI, then patches moOde's web +interface, creates the systemd service, configures the database, and creates +a backup of all modified files. + +Metadata (now-playing track info, artist, album, cover art) is displayed on +moOde's homepage via the built-in input source indicator. The metadata sink +daemon runs alongside the audio daemon to provide rich track information. + +REQUIREMENTS + +- moOde 9.x or later running on a Raspberry Pi 3, 4, or 5 +- Network connection to a SendSpin server (e.g., Music Assistant) +- Home Assistant long-lived access token (for rich metadata display -- optional) + +No manual installation of Python, uv, or the sendspin CLI is required -- +the installer handles all prerequisites automatically. + +INSTALLATION + +Full install (all features): + + git clone https://github.com/kiwipaulrob/moode.git + cd moode && git checkout sendspin-advanced + sudo bash moode-sendspin-installer.sh + +Or install directly from URL: + + curl -fsSL https://raw.githubusercontent.com/kiwipaulrob/moode/sendspin-advanced/moode-sendspin-installer.sh | sudo bash + +INSTALLER COMMAND LINE OPTIONS + + sudo bash moode-sendspin-installer.sh Full install (default) + sudo bash moode-sendspin-installer.sh --minimal Endpoint only (ON/OFF + Resume MPD, no config page) + sudo bash moode-sendspin-installer.sh --check Check installation status + sudo bash moode-sendspin-installer.sh --uninstall Remove SendSpin, restore originals + sudo bash moode-sendspin-installer.sh --no-backup Skip backup creation + sudo bash moode-sendspin-installer.sh --help Show help + +USAGE + +After installation, open moOde's web UI and navigate to: + Configure -> Renderers -> SendSpin section + +RENDERER CONTROLS + +Service toggle (ON/OFF): + ON -- SendSpin is active and appears as an available endpoint in controllers + OFF -- SendSpin is stopped and does not appear in controllers + Changes take effect immediately on save. + +Name: + The name that appears in your multi-room audio controller. + Default: "moode-sendspin" + Change this to identify your device (e.g., "Kitchen Speaker", "Living Room"). + +Resume MPD: + ON -- MPD playback resumes automatically when SendSpin streaming stops + OFF -- MPD remains stopped after SendSpin disconnects + +Restart button: + Restarts the SendSpin service. Use this if the device disappears from + the controller or audio stops working. + +Edit button: + Opens the SendSpin configuration page (ssp-config.php) with these settings: + + Audio format: + Codec: FLAC (lossless, recommended) or PCM (uncompressed) + Sample rate: 44100, 48000 (default), or 96000 Hz + Bit depth: 16 (CD quality), 24, or 32 bit + Changes take effect on next service restart. + + Log level: + DEBUG (troubleshooting), INFO (normal), WARNING, or ERROR (minimal) + Controls verbosity of the SendSpin daemon log. + + Audio device: + Read-only display of the ALSA output chain SendSpin uses. + Shows moOde's standard `_audioout` device (same as AirPlay/Spotify). + + Version: + Shows installed and latest available SendSpin CLI version. + If an update is available, an Update button appears to upgrade + the CLI in the background via uv. + +METADATA DISPLAY + +SendSpin includes a metadata sink daemon that displays now-playing information +on moOde's homepage using the built-in input source indicator. + +Sources (in priority order): + 1. SendSpin protocol metadata@v1 -- real-time push from the sender + 2. Home Assistant REST API -- polls media_player.moode_sendspin every 3s (requires HA token) + 3. Streaming status -- shows "Now Playing" when audio is active + +To enable rich metadata (artist, title, album, cover art): + 1. Create a long-lived access token in HA Settings + 2. Set it in the metadata sink service: + sudo sed -i 's|Environment="HA_TOKEN="|Environment="HA_TOKEN=YOUR_TOKEN"|' /etc/systemd/system/sendspin-metadata-sink.service + sudo systemctl daemon-reload + sudo systemctl restart sendspin-metadata-sink + 3. Album art + track info appears within 10 seconds + +VOLUME LEVEL + +SendSpin uses moOde's standard _audioout ALSA device, the same device used +by AirPlay, Spotify, RoonBridge, and MPD. Volume is controlled by moOde's +integrated volume knob -- SendSpin matches the level of all other renderers +automatically. No manual attenuation adjustment is needed. + +MOODE UPDATES + +Re-run the installer after a moOde system update: + + cd moode && git pull && sudo bash moode-sendspin-installer.sh + +TROUBLESHOOTING + +"Device in Use" error [PaErrorCode -9985]: + + This error occurs when SendSpin cannot open the audio device because MPD + is currently using it. + + SOLUTION: Enable the SendSpin service in moOde UI first. The integration + handles ALSA device sharing automatically. If you start SendSpin manually + via SSH, stop MPD first: + + mpc stop + sudo systemctl start sendspin + +No audio when streaming starts: + + 1. Check SendSpin service status: + sudo systemctl status sendspin + + 2. View SendSpin logs: + sudo journalctl -u sendspin -f + + 3. Verify the daemon is running: + pgrep -f "sendspin daemon" + + 4. Check that the _audioout ALSA device is available: + aplay -L | grep _audioout + +moOde device not appearing in controller: + + 1. Check that Service is toggled ON in moOde UI + 2. Verify mDNS discovery is working: + sendspin --list-servers + 3. Ensure your controller is on the same network + 4. Check firewall settings (port 44556/UDP for mDNS) + 5. Restart SendSpin service + +No metadata or album art displaying: + + 1. Verify the metadata sink daemon is running: + sudo systemctl status sendspin-metadata-sink + + 2. Check that HA_TOKEN is configured in the systemd service: + grep HA_TOKEN /etc/systemd/system/sendspin-metadata-sink.service + + 3. View metadata sink logs: + sudo journalctl -u sendspin-metadata-sink -f + + 4. Check current metadata file: + cat /var/local/www/sendspinmeta.txt + (~~~ format means metadata is flowing; empty means daemon issue) + + 5. Verify HA is reachable from the Pi: + curl http://192.168.214.159:8123/api/ + +Audio dropouts or stuttering: + + 1. Check CPU usage during playback: top + 2. Ensure adequate power supply (especially for Pi 4/5) + 3. Try a wired network connection instead of WiFi + 4. Lower the audio quality in your controller settings + +MPD does not resume after SendSpin stops: + + 1. Check that Resume MPD is enabled in the SendSpin section of Renderers + 2. Verify MPD was playing before SendSpin started + 3. Check moOde logs: sudo tail -f /var/log/moode.log + +Renderers not appearing on Configure -> Renderers page: + + moOde uses a shared session between the worker daemon and all web pages. + If renderer sections disappear (only top header visible), the shared + session has lost its cfg_system params. + + 1. Visit the moOde homepage first: http:/// + (This triggers the backend to reload configuration.) + + 2. If the problem persists, restart PHP-FPM: + sudo systemctl restart php8.4-fpm + + 3. If still not working, reboot the Pi: sudo reboot + +SendSpin config page hangs after saving: + + 1. This can happen if the worker daemon is stuck. + 2. Restart PHP-FPM: sudo systemctl restart php8.4-fpm + 3. If persistent, reboot the Pi: sudo reboot + +COMMAND REFERENCE + + # Check SendSpin status + sudo systemctl status sendspin + + # Check metadata sink status + sudo systemctl status sendspin-metadata-sink + + # View SendSpin logs + sudo journalctl -u sendspin -f + + # View metadata sink logs + sudo journalctl -u sendspin-metadata-sink -f + + # List available SendSpin servers on network + sendspin --list-servers + + # List audio devices + sendspin --list-audio-devices + + # Restart SendSpin + sudo systemctl restart sendspin + + # Restart metadata sink + sudo systemctl restart sendspin-metadata-sink + + # Check that _audioout is available + aplay -L | grep _audioout + + # Check current metadata + cat /var/local/www/sendspinmeta.txt + +################################################################################ +# For support, visit https://github.com/kiwipaulrob/moode/issues +################################################################################ \ No newline at end of file diff --git a/www/snd-config.php b/www/snd-config.php index 41b59b9d3..3a455193b 100644 --- a/www/snd-config.php +++ b/www/snd-config.php @@ -145,8 +145,14 @@ // Output mode if (isset($_POST['update_alsa_output_mode'])) { if (isset($_POST['alsa_output_mode']) && $_POST['alsa_output_mode'] != $_SESSION['alsa_output_mode']) { - phpSession('write', 'alsa_output_mode', $_POST['alsa_output_mode']); - submitJob('alsa_output_mode', $_POST['alsa_output_mode']); + if ($_POST['alsa_output_mode'] == 'plughw' && $_SESSION['peppy_display'] == '1' && + ($_SESSION['crossfeed'] != 'Off' || $_SESSION['alsaequal'] != 'Off' || $_SESSION['eqfa12p'] != 'Off')) { + $_SESSION['notify']['title'] = NOTIFY_TITLE_ALERT; + $_SESSION['notify']['msg'] = 'To run Peppy when Crossfeed, Graphic or Parametric EQ is on, set ALSA output mode to Direct or IEC958.'; + } else { + phpSession('write', 'alsa_output_mode', $_POST['alsa_output_mode']); + submitJob('alsa_output_mode', $_POST['alsa_output_mode']); + } } } // Loopback diff --git a/www/ssp-config.php b/www/ssp-config.php new file mode 100644 index 000000000..e28b5318e --- /dev/null +++ b/www/ssp-config.php @@ -0,0 +1,165 @@ + $value) { + chkValue($key, $value); + sqlUpdate('cfg_sendspin', $dbh, $key, $value); + } + // Regenerate service file from updated config + generateSendspinService($dbh); + + // Restart service if running + if ($_SESSION['sendspinsvc'] == '1') { + sysCmd('sudo systemctl restart sendspin'); + $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings applied and service restarted'); + } else { + $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin settings saved (service not running)'); + } + submitJob('sendspinsvc', '', $notify['title'], $notify['msg']); +} + +// Handle update request +if (isset($_POST['update_sendspin']) && $_POST['update_sendspin'] == '1') { + if ($_SESSION['sendspinsvc'] == '1') { + updateSendspin(); + $notify = array('title' => NOTIFY_TITLE_INFO, 'msg' => 'SendSpin updated and restarted'); + } else { + $notify = array('title' => '', 'msg' => ''); + } + submitJob('sendspinsvc', '', $notify['title'], $notify['msg']); +} + +phpSession('close'); + +// If session is empty (no cookie or incognito), load all cfg_system into session +if (!isset($_SESSION['feat_bitmask'])) { + $rows = sqlRead('cfg_system', $dbh); + foreach ($rows as $row) { + if (!str_contains($row['param'], 'RESERVED_')) { + $_SESSION[$row['param']] = $row['value']; + } + } + unset($_SESSION['wrkready']); +} + +// Read config from DB +$result = sqlRead('cfg_sendspin', $dbh); +$cfgSendspin = array(); +foreach ($result as $row) { + $cfgSendspin[$row['param']] = $row['value']; +} + +// Get installed version (cached — getSendspinVersion() takes ~2s) +$versionCacheFile = '/tmp/sendspin_local_version.txt'; +$versionCacheAge = 86400; // 24 hours +if (file_exists($versionCacheFile) && (time() - filemtime($versionCacheFile)) < $versionCacheAge) { + $_installed_version = trim(@file_get_contents($versionCacheFile)); +} else { + $_installed_version = getSendspinVersion(); + if ($_installed_version) { + @file_put_contents($versionCacheFile, $_installed_version); + } +} +$_installed_version_display = htmlspecialchars($_installed_version ?: 'Not installed'); + +// Check for latest version via PyPI JSON API (cached hourly) +$_latest_version_display = ''; +$_update_available = false; +$_can_update = false; + +$cacheFile = '/tmp/sendspin_version_cache.json'; +$cacheAge = 3600; // 1 hour + +if (file_exists($cacheFile) && (time() - filemtime($cacheFile)) < $cacheAge) { + $pypi_json = @file_get_contents($cacheFile); +} else { + $pypi_url = 'https://pypi.org/pypi/sendspin/json'; + $pypi_json = @file_get_contents($pypi_url, false, stream_context_create(array( + 'http' => array( + 'timeout' => 3, + 'method' => 'GET', + 'header' => "Accept: application/json\r\n" + ) + ))); + if ($pypi_json !== false) { + @file_put_contents($cacheFile, $pypi_json); + } +} + +if ($pypi_json !== false) { + $pypi_data = json_decode($pypi_json, true); + if (isset($pypi_data['info']['version'])) { + $_latest_version = $pypi_data['info']['version']; + $_latest_version_display = htmlspecialchars($_latest_version); + $_can_update = true; + if ($_installed_version && version_compare($_installed_version, $_latest_version, '<')) { + $_update_available = true; + } + } +} else { + $_latest_version_display = 'Unable to check'; +} + +// Build selects +$_select['sendspin_update_btn'] = $_SESSION['sendspinsvc'] == '1' && $_update_available ? + '' : + ''; + +$_select['installed_version'] = $_installed_version_display; +$_select['latest_version'] = $_latest_version_display; +$_select['update_available'] = $_update_available ? 'yes' : 'no'; + +// Audio codec +$codec = $cfgSendspin['audio_codec'] ?? 'flac'; +$_select['audio_codec'] .= "\n"; +$_select['audio_codec'] .= "\n"; + +// Sample rate +$rate = $cfgSendspin['audio_rate'] ?? '48000'; +$_select['audio_rate'] .= "\n"; +$_select['audio_rate'] .= "\n"; +$_select['audio_rate'] .= "\n"; + +// Bit depth +$depth = $cfgSendspin['audio_depth'] ?? '16'; +$_select['audio_depth'] .= "\n"; +$_select['audio_depth'] .= "\n"; +$_select['audio_depth'] .= "\n"; + +// Log level +$log_level = $cfgSendspin['log_level'] ?? 'INFO'; +$_select['log_level'] .= "\n"; +$_select['log_level'] .= "\n"; +$_select['log_level'] .= "\n"; +$_select['log_level'] .= "\n"; + + + +// ALSA card info +$cardResult = sysCmd("sqlite3 /var/local/www/db/moode-sqlite3.db \"SELECT value FROM cfg_system WHERE param='cardnum'\" 2>/dev/null"); +$_select['alsa_cardnum'] = (!empty($cardResult) && isset($cardResult[0])) ? trim($cardResult[0]) : '?'; +$nameResult = sysCmd("sqlite3 /var/local/www/db/moode-sqlite3.db \"SELECT value FROM cfg_system WHERE param='devname'\" 2>/dev/null"); +$_select['alsa_devname'] = (!empty($nameResult) && isset($nameResult[0])) ? trim($nameResult[0]) : 'unknown'; + +waitWorker('ssp_config'); + +$tpl = "ssp-config.html"; +$section = basename(__FILE__, '.php'); +storeBackLink($section, $tpl); + +include('header.php'); +eval("echoTemplate(\"" . getTemplate("templates/$tpl") . "\");"); +include('footer.min.php'); diff --git a/www/sys-config.php b/www/sys-config.php index 2f437d7e2..bad4804e1 100644 --- a/www/sys-config.php +++ b/www/sys-config.php @@ -430,8 +430,26 @@ // Performance $_select['worker_responsiveness'] .= "\n"; $_select['worker_responsiveness'] .= "\n"; -$_select['cpugov'] .= "\n"; -$_select['cpugov'] .= "\n"; +// Offer the governors the kernel actually exposes, not a hardcoded pair. +$availGovs = trim(@file_get_contents('/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors')); +$govList = $availGovs !== '' ? preg_split('/\s+/', $availGovs) : array('performance'); +$liveGov = trim(@file_get_contents('/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor')); +$selGov = in_array($_SESSION['cpugov'], $govList) ? $_SESSION['cpugov'] : $liveGov; +$govDescriptions = array( + 'conservative' => 'Scale CPU frequency gradually based on load.', + 'ondemand' => 'Scale CPU frequency from min to max based on load.', + 'userspace' => 'Let a user-space program set the CPU frequency.', + 'powersave' => 'Run the CPU at min frequency.', + 'performance' => 'Run the CPU at max frequency.', + 'schedutil' => 'Scale CPU frequency using the kernel scheduler load estimates.' +); +$govHelp = array(); +foreach ($govList as $gov) { + $label = $gov == 'ondemand' ? 'On-demand' : ucfirst($gov); + $_select['cpugov'] .= "\n"; + $govHelp[] = "" . $label . ":" . (isset($govDescriptions[$gov]) ? ' ' . $govDescriptions[$gov] : ''); +} +$_cpugov_help = implode("
    \n", $govHelp); $_select['pci_express'] .= "\n"; $_select['pci_express'] .= "\n"; $_select['pci_express'] .= "\n"; diff --git a/www/templates/blu-config.html b/www/templates/blu-config.html index 906ebb515..16e6f5e77 100644 --- a/www/templates/blu-config.html +++ b/www/templates/blu-config.html @@ -98,19 +98,6 @@

    Bluetooth Control

    -
    - - - ALSA output mode - - - Standard: Use the Output mode setting from Audio Config. DSP and Peppy are supported.
    - Compatibility: Does not support DSP or Peppy but may help if there is no sound output. -
    -
    -
    @@ -252,23 +254,85 @@
    -
    - - - - -
    - -
    - - -
    -
    -
      + +
      +
      + + + + + +
      + +
      +
        +
        +
        +
        • #
        • a
        • b
        • c
        • d
        • e
        • f
        • g
        • h
        • i
        • j
        • k
        • l
        • m
        • n
        • o
        • p
        • q
        • r
        • s
        • t
        • u
        • v
        • w
        • x
        • y
        • z
        +
        -
        -
        • #
        • a
        • b
        • c
        • d
        • e
        • f
        • g
        • h
        • i
        • j
        • k
        • l
        • m
        • n
        • o
        • p
        • q
        • r
        • s
        • t
        • u
        • v
        • w
        • x
        • y
        • z
        + + + +
        +
        + + + + + +
        + +
        + + +
        + +
        + + +
        +
        + +
        + + + +
        +
        +
          +
          +
          +
          @@ -279,6 +343,7 @@ +
          +
          + + + +