${_esc(j.label)}
From 5d1f3c1b48d2334a945371571012a06090928801 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Fri, 22 May 2026 08:43:57 -0700
Subject: [PATCH 03/26] Fix Picard albumartist orphan false positives
Teach the orphan file detector to match tracked files by both track artist and album artist. This prevents Picard-style albumartist/album (year)/track layouts from being reported as orphans when the DB track artist differs from the album artist.
Also check file albumartist tags during fallback matching and add a regression test for the reported Picard folder layout.
---
core/repair_jobs/orphan_file_detector.py | 47 ++++++++++----
tests/test_orphan_file_detector.py | 81 ++++++++++++++++++++++++
2 files changed, 116 insertions(+), 12 deletions(-)
create mode 100644 tests/test_orphan_file_detector.py
diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py
index 80c5a237..5c6e69cd 100644
--- a/core/repair_jobs/orphan_file_detector.py
+++ b/core/repair_jobs/orphan_file_detector.py
@@ -67,22 +67,28 @@ class OrphanFileDetectorJob(RepairJob):
suffix = '/'.join(parts[-depth:]).lower()
known_suffixes.add(suffix)
- # Build title+artist sets for tag-based fallback matching
+ # Build title+artist sets for fallback matching. Include both
+ # track artist and album artist so Picard-style albumartist paths
+ # don't look orphaned when tracks have featured/guest artists.
cursor.execute("""
- SELECT t.title, ar.name FROM tracks t
- LEFT JOIN artists ar ON ar.id = t.artist_id
+ SELECT t.title, track_ar.name, album_ar.name FROM tracks t
+ LEFT JOIN artists track_ar ON track_ar.id = t.artist_id
+ LEFT JOIN albums al ON al.id = t.album_id
+ LEFT JOIN artists album_ar ON album_ar.id = al.artist_id
WHERE t.title IS NOT NULL AND t.title != ''
""")
for row in cursor.fetchall():
title = (row[0] or '').lower().strip()
- artist = (row[1] or '').lower().strip()
if title:
- known_titles.add((title, artist))
- # Also store normalized version (stripped of feat., parentheticals, etc.)
- clean_t = _strip_extras(title)
- clean_a = _strip_extras(artist)
- if clean_t:
- known_titles_clean.add((clean_t, clean_a))
+ for artist_value in (row[1], row[2]):
+ artist = (artist_value or '').lower().strip()
+ known_titles.add((title, artist))
+ # Also store normalized version (stripped of feat.,
+ # parentheticals, etc.)
+ clean_t = _strip_extras(title)
+ clean_a = _strip_extras(artist)
+ if clean_t:
+ known_titles_clean.add((clean_t, clean_a))
except Exception as e:
logger.error("Error reading known file paths from DB: %s", e, exc_info=True)
result.errors += 1
@@ -144,14 +150,21 @@ class OrphanFileDetectorJob(RepairJob):
if audio:
file_title = ((audio.get('title') or [None])[0] or '').lower().strip()
file_artist = ((audio.get('artist') or [None])[0] or '').lower().strip()
+ file_albumartist = (
+ (audio.get('albumartist') or audio.get('album_artist') or [None])[0]
+ or ''
+ ).lower().strip()
if file_title:
+ file_artists = [a for a in (file_artist, file_albumartist) if a]
+ if not file_artists:
+ file_artists = ['']
# Exact match first (fast path)
- if (file_title, file_artist) in known_titles:
+ if any((file_title, artist) in known_titles for artist in file_artists):
is_known = True
else:
# Normalized match: strip (feat. X), [FLAC 16bit], etc.
clean_title = _strip_extras(file_title)
- clean_artist = _strip_extras(file_artist)
+ clean_artist = _strip_extras(file_artists[0])
# Also try first artist only (handles "Gorillaz, Dennis Hopper" β "Gorillaz")
first_artist = clean_artist.split(',')[0].strip() if clean_artist else ''
if clean_title and (
@@ -159,6 +172,16 @@ class OrphanFileDetectorJob(RepairJob):
(first_artist and (clean_title, first_artist) in known_titles_clean)
):
is_known = True
+ if clean_title and not is_known:
+ for artist in file_artists[1:]:
+ clean_artist = _strip_extras(artist)
+ first_artist = clean_artist.split(',')[0].strip() if clean_artist else ''
+ if (
+ (clean_title, clean_artist) in known_titles_clean or
+ (first_artist and (clean_title, first_artist) in known_titles_clean)
+ ):
+ is_known = True
+ break
except Exception as e:
logger.debug("tag-based orphan check: %s", e)
diff --git a/tests/test_orphan_file_detector.py b/tests/test_orphan_file_detector.py
new file mode 100644
index 00000000..02ba7150
--- /dev/null
+++ b/tests/test_orphan_file_detector.py
@@ -0,0 +1,81 @@
+"""Regression tests for the orphan file detector."""
+
+from __future__ import annotations
+
+import sqlite3
+from pathlib import Path
+
+from core.repair_jobs.base import JobContext
+from core.repair_jobs.orphan_file_detector import OrphanFileDetectorJob
+
+
+class _DB:
+ def __init__(self, path: Path) -> None:
+ self.path = path
+
+ def _get_connection(self):
+ return sqlite3.connect(self.path)
+
+
+def _seed_library(db_path: Path) -> None:
+ conn = sqlite3.connect(db_path)
+ try:
+ conn.executescript(
+ """
+ CREATE TABLE artists (
+ id INTEGER PRIMARY KEY,
+ name TEXT NOT NULL
+ );
+ CREATE TABLE albums (
+ id INTEGER PRIMARY KEY,
+ artist_id INTEGER NOT NULL,
+ title TEXT NOT NULL
+ );
+ CREATE TABLE tracks (
+ id INTEGER PRIMARY KEY,
+ album_id INTEGER NOT NULL,
+ artist_id INTEGER NOT NULL,
+ title TEXT NOT NULL,
+ file_path TEXT
+ );
+ INSERT INTO artists (id, name) VALUES
+ (1, 'Clouddead89'),
+ (2, 'Featured Artist');
+ INSERT INTO albums (id, artist_id, title) VALUES
+ (10, 1, 'Perfect Match Error');
+ INSERT INTO tracks (id, album_id, artist_id, title, file_path) VALUES
+ (100, 10, 2, 'Perfect Match', '/old/prefix/elsewhere.mp3');
+ """
+ )
+ conn.commit()
+ finally:
+ conn.close()
+
+
+def test_orphan_detector_accepts_picard_albumartist_folder_match(tmp_path: Path) -> None:
+ """Picard paths use albumartist/album (year)/track - title.
+
+ Even when the DB track artist is a featured artist, the album artist
+ folder should be enough to recognize the file as tracked.
+ """
+ db_path = tmp_path / "library.sqlite"
+ _seed_library(db_path)
+
+ transfer = tmp_path / "Clouddead89" / "Perfect Match Error (2026)"
+ transfer.mkdir(parents=True)
+ audio_path = transfer / "01 - Perfect Match.mp3"
+ audio_path.write_bytes(b"not a real mp3; filename fallback handles this")
+
+ findings = []
+ context = JobContext(
+ db=_DB(db_path),
+ transfer_folder=str(tmp_path),
+ config_manager=None,
+ create_finding=lambda **kwargs: findings.append(kwargs) or True,
+ )
+
+ result = OrphanFileDetectorJob().scan(context)
+
+ assert result.scanned == 1
+ assert result.findings_created == 0
+ assert findings == []
From 4ebbffb8989f990ff7c3dd0d737140d7c3a031d6 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Fri, 22 May 2026 17:18:06 -0700
Subject: [PATCH 04/26] Fix admin PIN after profile switches
Reset profile PIN dialog controls each time it opens so stale profile-specific event listeners cannot submit an admin PIN against a previously selected profile.
Keep failed PIN attempts retryable and restrict launch-lock verification to the admin profile PIN only, so non-admin profile PINs cannot mark the admin lock as verified.
---
web_server.py | 15 ++++++++++-----
webui/static/init.js | 21 ++++++++++++++++-----
2 files changed, 26 insertions(+), 10 deletions(-)
diff --git a/web_server.py b/web_server.py
index 3966f93c..deea944a 100644
--- a/web_server.py
+++ b/web_server.py
@@ -24315,9 +24315,10 @@ def select_profile():
return jsonify({'success': False, 'error': 'Invalid PIN'}), 401
session['profile_id'] = profile_id
- # If PIN was just validated, also mark launch PIN as verified
- # so the subsequent page reload doesn't ask again
- if pin:
+ # If the admin PIN was just validated, also mark launch PIN as
+ # verified so the subsequent page reload doesn't ask again. A
+ # non-admin profile PIN must not unlock the admin launch lock.
+ if pin and profile_id == 1:
session['launch_pin_verified'] = True
return jsonify({'success': True, 'profile': profile})
except Exception as e:
@@ -24402,12 +24403,16 @@ def reset_pin_via_credential():
# Credential verified β clear PIN for the requested profile (default: admin)
database = get_database()
- target_profile = data.get('profile_id', 1)
+ try:
+ target_profile = int(data.get('profile_id', 1))
+ except (TypeError, ValueError):
+ target_profile = 1
database.update_profile(target_profile, pin_hash=None)
# If clearing admin PIN, also disable launch lock
if target_profile == 1:
config_manager.set('security.require_pin_on_launch', False)
- session['launch_pin_verified'] = True
+ if target_profile == 1:
+ session['launch_pin_verified'] = True
return jsonify({'success': True, 'message': 'PIN cleared. You can set a new PIN in Settings.'})
except Exception as e:
diff --git a/webui/static/init.js b/webui/static/init.js
index 3a0cf496..0465f70d 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -707,8 +707,19 @@ function showPinDialog(profile) {
const dialog = document.getElementById('profile-pin-dialog');
const avatar = document.getElementById('profile-pin-avatar');
const nameEl = document.getElementById('profile-pin-name');
- const input = document.getElementById('profile-pin-input');
const errorEl = document.getElementById('profile-pin-error');
+ const oldInput = document.getElementById('profile-pin-input');
+ const oldSubmit = document.getElementById('profile-pin-submit');
+ const oldCancel = document.getElementById('profile-pin-cancel');
+
+ // Replace controls on every open so stale listeners from a previous
+ // profile cannot submit the new PIN against the old profile id.
+ const input = oldInput.cloneNode(true);
+ const submit = oldSubmit.cloneNode(true);
+ const cancel = oldCancel.cloneNode(true);
+ oldInput.parentNode.replaceChild(input, oldInput);
+ oldSubmit.parentNode.replaceChild(submit, oldSubmit);
+ oldCancel.parentNode.replaceChild(cancel, oldCancel);
renderProfileAvatar(avatar, profile);
nameEl.textContent = profile.name;
@@ -718,13 +729,12 @@ function showPinDialog(profile) {
dialog.style.display = 'flex';
setTimeout(() => input.focus(), 100);
- const submit = document.getElementById('profile-pin-submit');
- const cancel = document.getElementById('profile-pin-cancel');
-
const wasSwitching = !!currentProfile;
const handleSubmit = async () => {
const pin = input.value;
if (!pin) return;
+ submit.disabled = true;
+ submit.textContent = 'Verifying...';
try {
const res = await fetch('/api/profiles/select', {
method: 'POST',
@@ -753,7 +763,8 @@ function showPinDialog(profile) {
errorEl.textContent = 'Connection error';
errorEl.style.display = '';
}
- cleanup();
+ submit.disabled = false;
+ submit.textContent = 'Submit';
};
const handleCancel = () => {
From de8e079a6d43585be066e901497c4821be28a762 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Fri, 22 May 2026 21:19:50 -0700
Subject: [PATCH 05/26] feat(media-player): playable tracks across modals +
lyrics + cleanups
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three related improvements to the now-playing media player and the
"add to wishlist" / "download missing" modals.
1. Play buttons across track-list modals
Every track row in the download-missing modals (Spotify, Tidal,
YouTube, services, artist album, wishlist download-missing) and
the add-to-wishlist modal now carries a play button. Click runs
playTrackFromLibraryOrStream:
- If the track has a local file_path β playLibraryTrack
- Else POST /api/stats/resolve-track to find it in the library
by title + artist β playLibraryTrack
- Else fall back to _gsPlayTrack streaming
Backend ownership response gains track_id / title / file_path so
the wishlist modal's owned tracks can hand the right metadata
to the player without an extra round trip.
The add-to-wishlist modal previously showed the play button only
on owned tracks; now the button is unconditional so the streaming
fallback can take over for unowned ones (matches the standard
pattern from the rest of the app).
2. Clean media-player display titles
YouTube / Tidal / Qobuz / torrent / usenet plugins encode their
source-side identifier into the filename field as
|| so download() can recover it later. The
media player's track-title renderer never knew about this
convention and showed strings like
"wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the
now-playing UI. extractTrackTitle and setTrackInfo now strip the
|| prefix defensively so any path into the player gets a
clean display.
Local library playback also fetches canonical metadata from
/api/stats/resolve-track when track.id is present so title /
artist / album / album art come straight from the SoulSync DB
instead of whatever the caller passed in. Falls back silently
to caller values on any error so playback never blocks on the
metadata fetch.
3. Lyrics panel + View Artist close
New collapsed lyrics panel between the playback controls and
queue panel. POST /api/lyrics/fetch (new backend endpoint)
prefers the local .lrc / .txt sidecar files SoulSync writes
during post-processing so downloaded tracks resolve lyrics with
zero network hits; falls back to LRClib exact-match (when album
+ duration are available) then to LRClib search.
Synced LRC results are parsed (handles multi-stamp lines for
repeated choruses), and the active line highlights + smooth-
scrolls into the middle of the viewport on every audio
timeupdate. Plain-text results render without highlighting.
Per-track cache prevents re-fetching when the user revisits the
same track. Lyrics fetch is fire-and-forget β failure shows
"No lyrics found" without ever blocking playback.
View Artist on the expanded player now calls
closeNowPlayingModal before navigating; the modal was previously
sitting open over the artist page, hiding it. Handler is bound
once and is a no-op when no artist_id is attached.
CSS additions are additive (new .modal-track-play-btn and
.np-lyrics-* rules); no existing styles touched. Backend endpoint
returns 200-with-success-false on any miss so callers can render
"no lyrics" without treating it as an error.
WHATS_NEW updated under 2.5.9 with two entries (lyrics + View
Artist close).
---
web_server.py | 98 +++++++++++++
webui/index.html | 16 +++
webui/static/downloads.js | 87 ++++++++++-
webui/static/helper.js | 2 +
webui/static/library.js | 39 +++++
webui/static/media-player.js | 254 ++++++++++++++++++++++++++++++++-
webui/static/shared-helpers.js | 2 +-
webui/static/style.css | 114 +++++++++++++++
webui/static/sync-services.js | 2 +-
webui/static/sync-spotify.js | 2 +-
webui/static/wishlist-tools.js | 54 +++++++
11 files changed, 661 insertions(+), 9 deletions(-)
diff --git a/web_server.py b/web_server.py
index deea944a..f5b817fb 100644
--- a/web_server.py
+++ b/web_server.py
@@ -8738,6 +8738,9 @@ def library_check_tracks():
file_ext = os.path.splitext(matched_db_track.file_path or '')[1].lstrip('.').upper() or None
owned_map[track_name] = {
"owned": True,
+ "track_id": getattr(matched_db_track, 'id', None),
+ "title": getattr(matched_db_track, 'title', track_name),
+ "file_path": getattr(matched_db_track, 'file_path', None),
"format": file_ext,
"bitrate": matched_db_track.bitrate,
"album": getattr(matched_db_track, 'album_title', None)
@@ -33164,6 +33167,101 @@ def stats_recent():
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
+@app.route('/api/lyrics/fetch', methods=['POST'])
+def fetch_lyrics_endpoint():
+ """Fetch lyrics for the now-playing media player.
+
+ Body: ``{title, artist, album?, duration?}``. Returns
+ ``{success, synced, plain, source}`` where ``synced`` is an LRC
+ string with ``[mm:ss.xx] line`` timestamps (or None) and ``plain``
+ is the untimestamped text (or None). ``source`` is the lookup
+ strategy that hit (``exact`` / ``search`` / ``sidecar``).
+
+ Tries the local ``.lrc`` / ``.txt`` sidecar first when a
+ ``file_path`` is supplied β already-downloaded tracks should not
+ bounce LRClib on every play. Falls through to LRClib's exact-
+ match endpoint when title+artist+album+duration are all available,
+ then to its generic search endpoint.
+ """
+ try:
+ data = request.get_json() or {}
+ title = (data.get('title') or '').strip()
+ artist = (data.get('artist') or '').strip()
+ album = (data.get('album') or '').strip() or None
+ try:
+ duration = int(data.get('duration') or 0) or None
+ except (TypeError, ValueError):
+ duration = None
+ file_path = data.get('file_path') or None
+
+ if not title or not artist:
+ return jsonify({'success': False, 'error': 'title and artist required',
+ 'synced': None, 'plain': None, 'source': None}), 400
+
+ # 1. Sidecar β fastest, no network. The post-processing flow
+ # drops .lrc / .txt next to the audio for every successful
+ # enrichment, so existing downloads almost always have one.
+ if file_path:
+ try:
+ import os as _os
+ stem, _ = _os.path.splitext(file_path)
+ lrc_path = stem + '.lrc'
+ txt_path = stem + '.txt'
+ if _os.path.exists(lrc_path):
+ with open(lrc_path, 'r', encoding='utf-8') as fh:
+ body = fh.read().strip()
+ if body:
+ return jsonify({'success': True, 'synced': body,
+ 'plain': None, 'source': 'sidecar'})
+ if _os.path.exists(txt_path):
+ with open(txt_path, 'r', encoding='utf-8') as fh:
+ body = fh.read().strip()
+ if body:
+ return jsonify({'success': True, 'synced': None,
+ 'plain': body, 'source': 'sidecar'})
+ except Exception as sidecar_err:
+ logger.debug("lyrics sidecar read skipped: %s", sidecar_err)
+
+ # 2. LRClib network lookup via the shared client instance.
+ from core.lyrics_client import lyrics_client as _lyrics_client
+ api = getattr(_lyrics_client, 'api', None)
+ if api is None:
+ return jsonify({'success': False, 'error': 'lrclib unavailable',
+ 'synced': None, 'plain': None, 'source': None}), 200
+
+ result = None
+ # Exact-match endpoint requires all four fields. LRClib's API
+ # will 404 on any miss; treat as soft failure and fall through
+ # to the search endpoint.
+ if album and duration:
+ try:
+ result = api.get_lyrics(track_name=title, artist_name=artist,
+ album_name=album, duration=duration)
+ except Exception as exact_err:
+ logger.debug("lrclib exact lookup failed: %s", exact_err)
+
+ if result is None:
+ try:
+ hits = api.search_lyrics(track_name=title, artist_name=artist)
+ if hits:
+ result = hits[0]
+ except Exception as search_err:
+ logger.debug("lrclib search lookup failed: %s", search_err)
+
+ if result is None:
+ return jsonify({'success': False, 'error': 'no lyrics found',
+ 'synced': None, 'plain': None, 'source': None})
+
+ synced = getattr(result, 'synced_lyrics', None) or None
+ plain = getattr(result, 'plain_lyrics', None) or None
+ return jsonify({'success': bool(synced or plain), 'synced': synced,
+ 'plain': plain, 'source': 'lrclib'})
+ except Exception as e:
+ logger.error("lyrics fetch failed: %s", e)
+ return jsonify({'success': False, 'error': str(e),
+ 'synced': None, 'plain': None, 'source': None}), 500
+
+
@app.route('/api/stats/resolve-track', methods=['POST'])
def stats_resolve_track():
"""Resolve a track by title+artist to get its file_path for playback."""
diff --git a/webui/index.html b/webui/index.html
index 41393284..fac604ed 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -7275,6 +7275,22 @@