Merge pull request #381 from Nezreka/dev

Release v2.4.0
This commit is contained in:
BoulderBadgeDad 2026-04-26 11:11:34 -07:00 committed by GitHub
commit 42971a17e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
94 changed files with 89614 additions and 78970 deletions

View file

@ -1,5 +1,8 @@
# Docker ignore file for SoulSync WebUI
# Hidden folders and files
.*
# Git
.git
.gitignore

View file

@ -8,6 +8,7 @@ on:
jobs:
cleanup:
if: github.repository == 'Nezreka/SoulSync'
runs-on: ubuntu-latest
permissions:
packages: write

View file

@ -13,6 +13,7 @@ on:
jobs:
nightly:
if: github.repository == 'Nezreka/SoulSync'
runs-on: ubuntu-latest
# Skip scheduled runs if dev branch has no new commits in the last 24h
# (pushes and manual triggers always run)

View file

@ -15,6 +15,7 @@ on:
jobs:
build-and-push:
if: github.repository == 'Nezreka/SoulSync'
runs-on: ubuntu-latest
permissions:

103
README.md
View file

@ -84,6 +84,7 @@ SoulSync bridges streaming services to your music library with automated discove
- Hi-Res FLAC downsampling to 16-bit/44.1kHz CD quality
- Blasphemy Mode — delete original FLAC after conversion
- Synchronized lyrics (LRC) via LRClib
- ReplayGain analysis — optional track-level loudness tagging via ffmpeg, runs before lossy copy so both files get tagged
- Picard-style album consistency — pre-flight MusicBrainz release lookup ensures all tracks get the same release ID
### Listening Stats & Scrobbling
@ -117,8 +118,13 @@ SoulSync bridges streaming services to your music library with automated discove
- Spotify auth still enables playlists, followed artists, and enrichment
- MusicBrainz enrichment with Picard-style album consistency
**Hydrabase** (optional P2P metadata network) — replaces iTunes as the metadata source when connected. Federated lookup with community-matched results, falls back automatically if disconnected. Dev-mode feature, enable in Settings → Connections.
**Genre Whitelist** — filter junk genre tags (artist names, radio show names, playlist names) from all 10 enrichment sources. 272 curated default genres, fully customizable. Off by default for backward compatibility.
**Post-Processing Tag Embedding**
- Granular per-service tag toggles (18+ MusicBrainz tags, Spotify/iTunes/Deezer IDs, AudioDB mood/style, Tidal/Qobuz ISRCs, Last.fm tags, Genius URLs)
- Multi-artist tagging options: configurable separator (comma/semicolon/slash), multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking, optional "move featured artists to title" mode
- Album art embedding, cover.jpg download
- Spotify rate limit protection across all API calls
@ -134,12 +140,13 @@ SoulSync bridges streaming services to your music library with automated discove
### Automation
**Automation Engine** — Visual drag-and-drop builder for custom workflows
- **Triggers**: Schedule, Daily/Weekly Time, Track Downloaded, Batch Complete, Playlist Changed, Discovery Complete, Signal Received, and 10+ more
- **Actions**: Process Wishlist, Scan Watchlist, Refresh Mirrored, Discover Playlist, Sync Playlist, Scan Library, Database Update, Quality Scan, Full Cleanup, and more
- **Then Actions**: Fire Signal (chain automations), Discord, Telegram, Pushbullet notifications
- **Playlist Pipeline**: Single automation for full playlist lifecycle — refresh → discover → sync → download missing. No signal wiring needed.
- **Pipelines**: Pre-built one-click pipeline deployments (New Music, Nightly Operations, etc.)
- **Signal Chains**: playlist_id forwarded from events to action handlers for proper chain execution
- **Triggers**: Schedule, Daily/Weekly Time, Track Downloaded, Batch Complete, Playlist Changed, Discovery Complete, Signal Received, Library Scan Complete, Watchlist Match, Wishlist Item Added, and more
- **Actions**: Process Wishlist, Scan Watchlist, Refresh Mirrored, Discover Playlist, Sync Playlist, Scan Library, Database Update, Quality Scan, Full Cleanup, and 10+ more
- **Then Actions** (up to 3 per automation): Fire Signal (chain to other automations), Discord/Telegram/Pushbullet notifications, audible chimes
- **Signal Chains** — One automation fires `signal:foo`, another listens for it. Cycle detection + chain depth limit + cooldown prevent runaway chains.
- **Playlist Pipeline** — Single automation for full playlist lifecycle: refresh → discover → sync → download missing. No manual signal wiring.
- **Pipelines** — Pre-built one-click deployments (New Music, Nightly Operations, Full Library Maintenance, etc.) that install a linked group of automations at once
- **Automation Groups** — Drag-and-drop organization, bulk enable/disable, rename, right-click context menus
**Watchlist** — Monitor unlimited artists with per-artist configuration
- Release type filters: Albums, EPs, Singles
@ -149,8 +156,11 @@ SoulSync bridges streaming services to your music library with automated discove
**Wishlist** — Failed downloads automatically queued for retry with auto-processing
**Mirrored Playlists** — Mirror from Spotify, Tidal, YouTube, Deezer and keep synced
- Automatic refresh detects changes, discovery pipeline matches metadata
- Followed Spotify playlists with 403 errors fall back to public embed scraper
- Auto-refresh detects source changes via URL/ID tracking in playlist metadata
- Discovery pipeline matches source tracks to user's primary metadata source (Spotify/iTunes/Deezer/Discogs)
- Auto Wing It fallback — tracks that fail all metadata APIs get stub metadata from the raw source title and flow through the normal download pipeline anyway
- Followed Spotify playlists that hit 403 errors fall back to public embed scraper
- Unmatch button on found tracks with DB persistence for mirrored playlists
**Local Profiles** — Multiple configuration profiles with isolated settings, watchlists, and playlists
@ -176,6 +186,8 @@ SoulSync bridges streaming services to your music library with automated discove
**Database Storage Visualization** — Donut chart showing per-table storage breakdown
**Live Log Viewer** — Real-time terminal-style log viewer on Settings → Logs. Color-coded levels (DEBUG/INFO/WARNING/ERROR), live filter + search, switch between log files (app, post-processing, AcoustID, source reuse). Auto-scroll, copy, clear. Updates via WebSocket every 0.5s.
**Import System** — Tag-first matching, auto-grouped album cards, staging folder workflow
- Auto-Import worker: recursive scan, single file support, AcoustID fingerprinting fallback
- Confidence-gated: 90%+ auto-imports, 70-90% queued for review
@ -213,6 +225,43 @@ docker-compose up -d
# Access at http://localhost:8008
```
### Release Channels
SoulSync publishes two Docker image tracks so you can choose your level of stability.
**Stable — `:latest`** (recommended for most users). Hand-promoted from the `dev` branch to `main` when a batch of changes is ready for release. Published to Docker Hub. Your `docker-compose.yml` pulls this by default — no changes needed.
```bash
docker pull boulderbadgedad/soulsync:latest
```
**Nightly — `:dev`**. Rebuilt every night from the `dev` branch (and on every push to dev). Published to GitHub Container Registry. Gets new features and bug fixes before they reach `:latest`, at the cost of occasional instability as changes settle. Good for early adopters, contributors validating their own merges, and anyone helping shake out bugs on Discord before a stable release.
To switch, edit `docker-compose.yml`:
```yaml
image: ghcr.io/nezreka/soulsync:dev
```
Then run `docker-compose pull && docker-compose up -d`.
Pinned dev builds are also published as `ghcr.io/nezreka/soulsync:dev-YYYYMMDD-<sha>` if you want to stick with an exact known-good snapshot.
**Version-tagged releases** (e.g. `:2.3`, `:2.4`) are permanent tags published on both registries when a stable release is promoted:
```bash
docker pull boulderbadgedad/soulsync:2.4
# or
docker pull ghcr.io/nezreka/soulsync:2.4
```
| You are... | Use |
|---|---|
| A typical user who wants things to work | `:latest` |
| Pinning to a specific version for stability | `:2.3`, `:2.4`, etc. |
| An early adopter who wants new features early and is OK reporting bugs | `:dev` |
| A contributor testing post-merge behavior | `:dev` or a pinned dev build |
### Unraid
SoulSync is available as an Unraid template. Install from Community Applications or manually add the template from:
@ -222,6 +271,8 @@ https://raw.githubusercontent.com/Nezreka/SoulSync/main/templates/soulsync.xml
PUID/PGID are exposed in the template — set them to match your Unraid permissions (default: 99/100 for nobody/users).
The template points at `boulderbadgedad/soulsync:latest` (stable) by default. To use the nightly `:dev` channel on Unraid, edit the container's **Repository** field to `ghcr.io/nezreka/soulsync:dev` after installing from the template.
### Python (No Docker)
```bash
@ -343,3 +394,39 @@ Open SoulSync at `http://localhost:8008` and go to Settings.
- **Album Consistency** — pre-flight MusicBrainz release lookup before album downloads
- **Automation Engine** — event-driven workflows with signal chains and pipeline deployment
- **SoulID System** — deterministic cross-instance artist/album/track identifiers via track-verified API lookup
---
## Contributing
### Branch workflow
SoulSync uses a `dev``main` flow:
- **`main`** — release branch. `:latest` images auto-build from this. Only receives merges from `dev`.
- **`dev`** — integration branch. Nightly `:dev` images build from here. PRs land here first for validation before being promoted to `main`.
- **Feature branches** — branched from `dev`. PRs target `dev`.
### Opening a PR
1. Fork and clone the repo
2. Branch off `dev`: `git checkout -b fix/your-change dev`
3. Make your changes and commit
4. Push and open a PR against **`dev`** (not `main`)
5. CI (`build-and-test.yml`) runs ruff lint + compile + pytest on your branch — wait for green
6. A maintainer reviews and merges
### Running locally
```bash
pip install -r requirements-dev.txt
python -m ruff check . # must be 0 errors
python -m pytest # all tests must pass
gunicorn -c gunicorn.dev.conf.py wsgi:application
```
Ruff config lives in `pyproject.toml`. The ruleset is intentionally lenient — it catches real bugs (undefined names, import shadowing, closure-in-loop) without style nits.
### Reporting bugs / requesting features
Open an issue on GitHub. For user-side support, the Discord community is the fastest place to ask.

View file

@ -1,5 +1,15 @@
# SoulSync WebUI - Docker Deployment Guide
## Release Channels
SoulSync publishes two Docker image tracks:
- **Stable — `boulderbadgedad/soulsync:latest`** (Docker Hub). Hand-promoted from the `dev` branch when a batch of changes is ready. Default in `docker-compose.yml`.
- **Nightly — `ghcr.io/nezreka/soulsync:dev`** (GHCR). Rebuilt every night and on every push to `dev`. Faster access to new features at the cost of occasional instability.
- **Version-tagged — `:2.3`, `:2.4`, etc.** on both registries for pinning to a specific release.
To switch a running install to the nightly channel, edit the `image:` line in `docker-compose.yml` to `ghcr.io/nezreka/soulsync:dev` and run `docker-compose pull && docker-compose up -d`. See the [main README](../README.md#release-channels) for the full channel guide.
## 🐳 Quick Start
### Prerequisites

View file

@ -1666,7 +1666,7 @@ class BeatportUnifiedScraper:
release_urls = []
urls_found = 0
for i, row in enumerate(table_rows):
for _i, row in enumerate(table_rows):
# Look for release link in this row
link_elem = row.select_one('a[href*="/release/"]')
if link_elem and link_elem.get('href'):
@ -1694,7 +1694,7 @@ class BeatportUnifiedScraper:
_beatport_log(f" Found {len(tracks)} individual tracks")
all_individual_tracks.extend(tracks)
else:
_beatport_log(f" No tracks found")
_beatport_log(" No tracks found")
# Add delay between requests to be respectful
if i < len(release_urls) - 1:
@ -1877,7 +1877,7 @@ class BeatportUnifiedScraper:
# Convert to our standard format (with Hype Picks branding)
converted_tracks = []
for i, track_data in enumerate(release_tracks):
for _i, track_data in enumerate(release_tracks):
track = self.convert_hype_picks_json_to_track_format(track_data, release_url, len(converted_tracks) + 1)
if track:
converted_tracks.append(track)
@ -2631,7 +2631,7 @@ class BeatportUnifiedScraper:
# Example: "Gods window, Pt. 1Thakzin,Thandazo,Xelimpilo"
lines = [line.strip() for line in text.split('\n') if line.strip()]
for i, line in enumerate(lines):
for _i, line in enumerate(lines):
# Look for lines that might contain title and artists
if len(line) > 5 and '$' not in line and 'Music' in line:
# This might be a title line
@ -2861,7 +2861,7 @@ class BeatportUnifiedScraper:
_beatport_log(f" Found {len(tracks)} individual tracks")
all_individual_tracks.extend(tracks)
else:
_beatport_log(f" No tracks found")
_beatport_log(" No tracks found")
# Add delay between requests to be respectful
if i < len(release_urls) - 1:
@ -2983,7 +2983,7 @@ class BeatportUnifiedScraper:
# If no dedicated hype page found, try main genre page for hype content
if not tracks:
_beatport_log(f" No dedicated hype page found, looking for hype content on main page...")
_beatport_log(" No dedicated hype page found, looking for hype content on main page...")
genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}"
soup = self.get_page(genre_url)
if soup:
@ -3048,7 +3048,7 @@ class BeatportUnifiedScraper:
seen_urls = set()
# Process ALL links but stop when we reach the limit of unique URLs (same as Latest Releases)
for i, link in enumerate(release_links):
for _i, link in enumerate(release_links):
href = link.get('href')
if href:
# Ensure full URL (same as Latest Releases)
@ -3228,7 +3228,7 @@ class BeatportUnifiedScraper:
if not soup:
return tracks
_beatport_log(f" Looking for HYPE labeled tracks on page...")
_beatport_log(" Looking for HYPE labeled tracks on page...")
# Look for elements containing "HYPE" text
hype_elements = soup.find_all(text=re.compile(r'HYPE', re.I))
@ -3243,7 +3243,7 @@ class BeatportUnifiedScraper:
track_container = None
# Walk up the DOM tree to find a suitable container
for level in range(5):
for _level in range(5):
if parent:
# Look for track links in this container
track_links = parent.find_all('a', href=re.compile(r'/track/'))
@ -3314,7 +3314,7 @@ class BeatportUnifiedScraper:
if not soup:
return tracks
_beatport_log(f" Extracting hype tracks from Beatport page...")
_beatport_log(" Extracting hype tracks from Beatport page...")
# Method 1: Extract from Hype Picks carousel (release cards with HYPE badges)
hype_picks_tracks = self.extract_hype_picks_from_carousel(soup, list_name, limit)
@ -3551,7 +3551,7 @@ class BeatportUnifiedScraper:
seen_urls = set()
# Process ALL links but stop when we reach the limit of unique URLs (same as Latest Releases)
for i, link in enumerate(release_links):
for _i, link in enumerate(release_links):
href = link.get('href')
if href:
# Ensure full URL (same as Latest Releases)
@ -3627,7 +3627,7 @@ class BeatportUnifiedScraper:
seen_urls = set()
# Process ALL links but stop when we reach the limit of unique URLs (same as homepage)
for i, link in enumerate(release_links):
for _i, link in enumerate(release_links):
href = link.get('href')
if href:
# Ensure full URL (same as homepage)
@ -3724,12 +3724,12 @@ class BeatportUnifiedScraper:
"""Extract tracks from Beatport chart table structure (tracks-table class)"""
tracks = []
_beatport_log(f" DEBUG: Looking for tracks-table container...")
_beatport_log(" DEBUG: Looking for tracks-table container...")
# Look for the tracks table container
tracks_table = soup.find(class_=re.compile(r'tracks-table'))
if not tracks_table:
_beatport_log(f" No tracks-table container found")
_beatport_log(" No tracks-table container found")
# Debug: Let's see what table classes ARE available
all_tables = soup.find_all(['table', 'div'], class_=re.compile(r'table|Table', re.I))
_beatport_log(f" DEBUG: Found {len(all_tables)} table-like elements")
@ -3745,7 +3745,7 @@ class BeatportUnifiedScraper:
track_rows_class = tracks_table.find_all(class_=re.compile(r'Table.*Row.*tracks-table'))
track_rows_generic = tracks_table.find_all(class_=re.compile(r'Table.*Row'))
_beatport_log(f" DEBUG: Track rows found:")
_beatport_log(" DEBUG: Track rows found:")
_beatport_log(f" - By data-testid='tracks-table-row': {len(track_rows_testid)}")
_beatport_log(f" - By class pattern 'Table.*Row.*tracks-table': {len(track_rows_class)}")
_beatport_log(f" - By generic 'Table.*Row': {len(track_rows_generic)}")
@ -3754,7 +3754,7 @@ class BeatportUnifiedScraper:
track_rows = track_rows_testid or track_rows_class or track_rows_generic
if not track_rows:
_beatport_log(f" No track rows found in any format")
_beatport_log(" No track rows found in any format")
return tracks
_beatport_log(f" Using {len(track_rows)} track rows for extraction")
@ -3836,7 +3836,7 @@ class BeatportUnifiedScraper:
_beatport_log(f" Found {len(table_rows)} potential table rows")
for i, row in enumerate(table_rows[:limit]):
for _i, row in enumerate(table_rows[:limit]):
try:
# Skip header rows
if row.name == 'tr' and row.find('th'):
@ -4361,7 +4361,7 @@ def test_dynamic_genre_discovery():
_beatport_log("\nTEST 2: Genre Discovery with Images (Sample)")
genres_with_images = scraper.discover_genres_with_images(include_images=True)
_beatport_log(f"\nSample genres with images:")
_beatport_log("\nSample genres with images:")
for genre in genres_with_images[:3]:
_beatport_log(f"{genre['name']}: {genre.get('image_url', 'No image')}")
@ -4377,7 +4377,7 @@ def test_dynamic_genre_discovery():
for track in tracks:
_beatport_log(f"{track['artist']} - {track['title']}")
else:
_beatport_log(f" No tracks found")
_beatport_log(" No tracks found")
return genres
@ -4392,7 +4392,7 @@ def test_improved_chart_sections():
_beatport_log("\nTEST 1: Chart Section Discovery")
chart_discovery = scraper.discover_chart_sections()
_beatport_log(f"\nDiscovery Results:")
_beatport_log("\nDiscovery Results:")
summary = chart_discovery.get('summary', {})
_beatport_log(f" • Top Charts sections: {summary.get('top_charts_sections', 0)}")
_beatport_log(f" • Staff Picks sections: {summary.get('staff_picks_sections', 0)}")
@ -4497,7 +4497,7 @@ def main():
top_100 = scraper.scrape_top_100(limit=10) # Test with 10 for now
if top_100:
_beatport_log(f"\nTop 100 Sample (showing first 5):")
_beatport_log("\nTop 100 Sample (showing first 5):")
for track in top_100[:5]:
_beatport_log(f" {track['position']}. {track['artist']} - {track['title']}")
@ -4548,7 +4548,7 @@ def main():
all_tracks = (top_100 or []) + [track for tracks in all_genre_results.values() for track in tracks]
if all_tracks:
overall_quality = scraper.test_data_quality(all_tracks)
_beatport_log(f"\nOVERALL DATA QUALITY")
_beatport_log("\nOVERALL DATA QUALITY")
_beatport_log(f"• Quality Score: {overall_quality['quality_score']:.1f}%")
_beatport_log(f"• Valid Tracks: {overall_quality['valid_tracks']}/{overall_quality['total_tracks']}")
@ -4571,27 +4571,27 @@ def main():
try:
with open('beatport_unified_results.json', 'w', encoding='utf-8') as f:
json.dump(results, f, indent=2, ensure_ascii=False)
_beatport_log(f"\nResults saved to beatport_unified_results.json")
_beatport_log("\nResults saved to beatport_unified_results.json")
except Exception as e:
_beatport_log(f"Failed to save results: {e}")
# Virtual playlist possibilities
if overall_quality['quality_score'] > 70:
_beatport_log(f"\nSUCCESS! Ready for virtual playlist creation")
_beatport_log(f"You can now create playlists for:")
_beatport_log(f" • Beatport Top 100")
_beatport_log("\nSUCCESS! Ready for virtual playlist creation")
_beatport_log("You can now create playlists for:")
_beatport_log(" • Beatport Top 100")
for genre_name in list(all_genre_results.keys())[:5]:
_beatport_log(f"{genre_name} Top 100")
if len(all_genre_results) > 5:
_beatport_log(f" • ...and {len(all_genre_results) - 5} more genres!")
_beatport_log(f"\nIntegration Notes:")
_beatport_log(f" • Artist and title data is clean and ready")
_beatport_log("\nIntegration Notes:")
_beatport_log(" • Artist and title data is clean and ready")
_beatport_log(f"{total_genres} genres confirmed working")
_beatport_log(f" • Data quality: {overall_quality['quality_score']:.1f}%")
else:
_beatport_log(f"\nData quality needs improvement ({overall_quality['quality_score']:.1f}%)")
_beatport_log(f"Consider refining extraction methods")
_beatport_log("Consider refining extraction methods")
if __name__ == "__main__":

View file

@ -274,7 +274,7 @@ class AcoustIDClient:
if not search_dir.exists():
continue
# Walk up to 2 levels deep to find an audio file quickly
for depth, pattern in enumerate(['*', '*/*']):
for _depth, pattern in enumerate(['*', '*/*']):
for f in search_dir.glob(pattern):
if f.is_file() and f.suffix.lower() in audio_extensions:
return str(f)

View file

@ -296,9 +296,14 @@ class AcoustIDVerification:
logger.info(f"AcoustID verification PASSED - {msg}")
return VerificationResult.PASS, msg
# Title matches but artist doesn't — could be a cover or collab, skip
# Title matches but artist doesn't — could be a cover/collab OR a
# genuinely different track with the same name. Distinguish the
# two by checking whether the expected artist appears anywhere in
# AcoustID's returned recordings.
if title_sim >= TITLE_MATCH_THRESHOLD and artist_sim < ARTIST_MATCH_THRESHOLD:
# Check if the expected artist appears anywhere in the AcoustID results
# First: if the expected artist is present in ANY recording's
# metadata for this fingerprint, it's likely the right track
# (AcoustID's "best" match just picked the wrong variant).
for rec in recordings:
if _similarity(expected_artist_name, rec.get('artist', '')) >= ARTIST_MATCH_THRESHOLD:
msg = (
@ -308,10 +313,30 @@ class AcoustIDVerification:
logger.info(f"AcoustID verification PASSED (secondary match) - {msg}")
return VerificationResult.PASS, msg
# Expected artist wasn't found anywhere. Decide between:
# - FAIL: clear mismatch, e.g. "Tom Walker" (sim ~0.2) when
# expecting "Maduk" — different song with same name
# - SKIP: ambiguous, e.g. collab / alt credit / formatting
# difference (sim 0.3-0.6)
#
# The 0.3 cutoff catches hard mismatches while preserving the
# benefit of the doubt for borderline artist formatting.
CLEAR_MISMATCH_THRESHOLD = 0.3
if artist_sim < CLEAR_MISMATCH_THRESHOLD:
msg = (
f"Audio mismatch: file identified as '{matched_title}' by '{matched_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(title={title_sim:.0%}, artist={artist_sim:.0%}) — "
f"expected artist not found in any AcoustID recording"
)
logger.warning(f"AcoustID verification FAILED (clear artist mismatch) - {msg}")
return VerificationResult.FAIL, msg
msg = (
f"Title matches but artist unclear: "
f"AcoustID='{matched_title}' by '{matched_artist}', "
f"expected '{expected_track_name}' by '{expected_artist_name}'"
f"expected '{expected_track_name}' by '{expected_artist_name}' "
f"(artist_sim={artist_sim:.0%} — ambiguous, could be cover/collab)"
)
logger.info(f"AcoustID verification SKIPPED - {msg}")
return VerificationResult.SKIP, msg

View file

@ -267,7 +267,12 @@ class ApiCallTracker:
def save(self):
"""Persist 24h minute history to disk. Call on shutdown."""
"""Persist 24h minute history to disk. Call on shutdown.
Uses an atomic write (write to a sibling .tmp file, fsync, rename) so
a SIGINT/SIGTERM/crash mid-write can't leave the JSON file truncated.
Without this, an interrupted shutdown corrupts the history file and
the next startup loses 24h of metrics."""
try:
now = time.time()
cutoff = now - 86400
@ -283,10 +288,22 @@ class ApiCallTracker:
if entries:
data[key] = entries
events = [dict(e) for e in self._events if e['ts'] >= cutoff]
with open(_PERSIST_PATH, 'w') as f:
json.dump({'ts': now, 'history': data, 'events': events}, f)
payload = {'ts': now, 'history': data, 'events': events}
tmp_path = _PERSIST_PATH + '.tmp'
with open(tmp_path, 'w') as f:
json.dump(payload, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, _PERSIST_PATH)
except Exception as e:
logger.error(f"[ApiCallTracker] Failed to save history: {e}")
# Best-effort cleanup of stale tmp file from a failed write.
try:
if os.path.exists(_PERSIST_PATH + '.tmp'):
os.remove(_PERSIST_PATH + '.tmp')
except Exception:
pass
def _load(self):
"""Restore 24h minute history from disk. Called on init."""

View file

@ -0,0 +1,186 @@
"""Synthesize an artist-detail response for an artist that isn't in the library.
Extracted from ``web_server.py`` so the logic is importable at test time.
The route handler in ``web_server.py`` is now a thin wrapper that builds the
per-source clients (which live as module globals there), calls this function,
and wraps the return value in ``jsonify``.
Used by ``/api/artist-detail/<id>`` when the URL is called with a ``source``
query parameter and the library DB lookup misses. Enriches the response with
whatever metadata we can pull on demand:
* Image URL (via ``metadata_service.get_artist_image_url``)
* Source-specific artist info genres + follower count from the named
source's ``get_artist`` / ``get_artist_info`` helper
* Last.fm bio + listeners + playcount + URL (by artist name)
* Discography from the named source, with variant dedup disabled so every
release surfaces
All per-source clients are passed in explicitly. Callers that can't or don't
want to provide a given client pass ``None`` the corresponding enrichment
branch becomes a no-op.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
from core.artist_source_lookup import SOURCE_ID_FIELD
logger = logging.getLogger("artist_source_detail")
def build_source_only_artist_detail(
artist_id: str,
artist_name: str,
source: str,
*,
spotify_client: Optional[Any] = None,
deezer_client: Optional[Any] = None,
itunes_client: Optional[Any] = None,
discogs_client: Optional[Any] = None,
lastfm_api_key: Optional[str] = None,
) -> Tuple[Dict[str, Any], int]:
"""Build the artist-detail payload for a source-only artist.
Returns ``(payload_dict, http_status)``. Callers wrap the dict in
``jsonify`` or equivalent. Status is 200 on success, 404 when the
source's discography lookup returned no releases.
"""
# Deferred import — keeps the top-level module importable in test rigs
# that stub out only what they need (same pattern `_find_library_artist`
# uses).
from core.metadata_service import (
MetadataLookupOptions,
get_artist_detail_discography,
get_artist_image_url,
)
resolved_name = (artist_name or artist_id or "").strip()
# 1. Image URL via the same helper /api/artist/<id>/image uses.
image_url: Optional[str] = None
try:
image_url = get_artist_image_url(artist_id, source_override=source)
except Exception as e:
logger.debug(f"Artist image lookup failed for {source}:{artist_id}: {e}")
# 2. Source-side artist info (image, genres, followers depending on source).
source_genres: list = []
source_followers: Optional[int] = None
try:
if source == "spotify" and spotify_client is not None:
sp_artist = spotify_client.get_artist(artist_id, allow_fallback=False)
if sp_artist:
source_genres = sp_artist.get("genres") or []
source_followers = (sp_artist.get("followers") or {}).get("total")
if not image_url and sp_artist.get("images"):
image_url = sp_artist["images"][0].get("url")
elif source == "deezer" and deezer_client is not None:
dz_artist = deezer_client.get_artist_info(artist_id)
if dz_artist:
source_genres = dz_artist.get("genres") or []
source_followers = (dz_artist.get("followers") or {}).get("total")
elif source == "itunes" and itunes_client is not None:
it_artist = itunes_client.get_artist(artist_id)
if it_artist:
source_genres = it_artist.get("genres") or []
elif source == "discogs" and discogs_client is not None:
dc_artist = discogs_client.get_artist(artist_id)
if dc_artist:
source_genres = dc_artist.get("genres") or []
except Exception as e:
logger.debug(f"Source-side artist info lookup failed for {source}:{artist_id}: {e}")
# 3. Last.fm enrichment by artist name.
lastfm_bio: Optional[str] = None
lastfm_listeners: Optional[int] = None
lastfm_playcount: Optional[int] = None
lastfm_url: Optional[str] = None
if resolved_name and lastfm_api_key:
try:
from core.lastfm_client import LastFMClient
lastfm = LastFMClient(api_key=lastfm_api_key)
lf_info = lastfm.get_artist_info(resolved_name)
if lf_info:
bio_obj = lf_info.get("bio") or {}
lastfm_bio = bio_obj.get("content") or bio_obj.get("summary")
stats_obj = lf_info.get("stats") or {}
if stats_obj.get("listeners"):
try:
lastfm_listeners = int(stats_obj["listeners"])
except (ValueError, TypeError):
pass
if stats_obj.get("playcount"):
try:
lastfm_playcount = int(stats_obj["playcount"])
except (ValueError, TypeError):
pass
lastfm_url = lf_info.get("url")
except Exception as e:
logger.debug(f"Last.fm enrichment failed for {resolved_name}: {e}")
# 4. Discography from the specified source. Skip variant dedup so the
# page shows every release the source returns — matches the inline
# Artists-page behaviour that this view was modelled after.
discography_result = get_artist_detail_discography(
artist_id,
artist_name=resolved_name or artist_id,
options=MetadataLookupOptions(
source_override=source,
allow_fallback=True,
skip_cache=False,
max_pages=0,
limit=50,
artist_source_ids={source: artist_id},
dedup_variants=False,
),
)
if not discography_result.get("success"):
return {
"success": False,
"error": discography_result.get("error", "Could not load discography"),
"source": source,
}, 404
artist_info: Dict[str, Any] = {
"id": artist_id,
"name": resolved_name or artist_id,
"image_url": image_url,
"server_source": None, # not in library
"genres": source_genres,
}
# Stamp the source-specific ID so the correct service badge renders on the
# hero (e.g. source=deezer -> deezer_id; source=spotify -> spotify_artist_id).
source_id_field = SOURCE_ID_FIELD.get(source)
if source_id_field:
artist_info[source_id_field] = artist_id
if source_followers is not None:
artist_info["followers"] = source_followers
if lastfm_bio:
artist_info["lastfm_bio"] = lastfm_bio
if lastfm_listeners is not None:
artist_info["lastfm_listeners"] = lastfm_listeners
if lastfm_playcount is not None:
artist_info["lastfm_playcount"] = lastfm_playcount
if lastfm_url:
artist_info["lastfm_url"] = lastfm_url
logger.info(
f"Source-only artist-detail: {artist_info['name']} from {source}"
f"albums={len(discography_result.get('albums', []))}, "
f"eps={len(discography_result.get('eps', []))}, "
f"singles={len(discography_result.get('singles', []))}, "
f"genres={len(source_genres)}, lastfm_bio={'yes' if lastfm_bio else 'no'}"
)
return {
"success": True,
"artist": artist_info,
"discography": discography_result,
"enrichment_coverage": {},
}, 200

View file

@ -0,0 +1,88 @@
"""Source-artist → library lookup helpers.
Extracted from `web_server.py` so the logic can be imported and unit-tested
without booting the Flask app, Spotify client, Soulseek connection, etc.
Two concepts live here:
* ``SOURCE_ID_FIELD`` the per-source column on the ``artists`` table that
stores the external service ID (Spotify track ID, Deezer artist ID, ).
This map is what ties a result clicked in the source-aware Search results
back to a library record so we can serve the richer library view.
* ``find_library_artist_for_source`` given a source-aware click (e.g.
``deezer:525046``), try to locate a matching library artist. First by
direct column match against the source's ID column, then by case-
insensitive name match scoped to the active media server.
"""
from __future__ import annotations
import logging
from typing import Optional
logger = logging.getLogger("artist_source_lookup")
SOURCE_ONLY_ARTIST_SOURCES = frozenset({
"spotify", "itunes", "deezer", "discogs", "hydrabase", "musicbrainz",
})
SOURCE_ID_FIELD = {
"spotify": "spotify_artist_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"discogs": "discogs_id",
"hydrabase": "soul_id",
"musicbrainz": "musicbrainz_id",
}
def find_library_artist_for_source(
database,
source: str,
source_artist_id: str,
artist_name: Optional[str] = None,
active_server: Optional[str] = None,
) -> Optional[str]:
"""Return the library PK of an artist matching the source-aware click.
Lookup order:
1. Direct match on the source-specific ID column (server-agnostic any
library record with the right external ID is a hit).
2. Case-insensitive name match within ``active_server`` (defaults to the
active media server when not provided), so we don't jump the user
across server contexts on a name collision.
Returns ``None`` on miss or on any database error.
"""
column = SOURCE_ID_FIELD.get(source)
if not column:
return None
try:
with database._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
f"SELECT id, name FROM artists WHERE {column} = ? LIMIT 1",
(str(source_artist_id),),
)
row = cursor.fetchone()
if row:
return row[0]
if artist_name and active_server:
cursor.execute(
"SELECT id FROM artists "
"WHERE LOWER(name) = LOWER(?) AND server_source = ? LIMIT 1",
(artist_name, active_server),
)
row = cursor.fetchone()
if row:
return row[0]
except Exception as e:
logger.debug(
f"Library upgrade lookup failed for {source}:{source_artist_id}: {e}"
)
return None

View file

@ -270,7 +270,7 @@ class AutomationEngine:
"""Cancel all timers on shutdown."""
self._running = False
with self._lock:
for aid, timer in self._timers.items():
for _aid, timer in self._timers.items():
timer.cancel()
count = len(self._timers)
self._timers.clear()

View file

@ -348,7 +348,7 @@ class DatabaseUpdateWorker:
total_artists = len(artists)
logger.info(f"Deep scan: Processing {total_artists} artists (sequential, skip-existing mode)")
for i, artist in enumerate(artists):
for _i, artist in enumerate(artists):
if self.should_stop:
break
@ -599,7 +599,7 @@ class DatabaseUpdateWorker:
result_msg = f"Smart incremental scan result: {len(artists_to_process)} artists to process from {albums_with_new_content} albums with new content"
if stopped_early:
result_msg += f" (stopped early after finding 25 consecutive complete albums)"
result_msg += " (stopped early after finding 25 consecutive complete albums)"
else:
result_msg += f" (checked all {total_tracks_checked} tracks from {len(recent_albums)} recent albums)"
@ -1224,7 +1224,7 @@ class DatabaseUpdateWorker:
# Process artists sequentially when requested (the web server uses this path).
if self.force_sequential:
# Sequential processing for web server mode
for i, artist in enumerate(artists):
for _i, artist in enumerate(artists):
if self.should_stop:
break

View file

@ -72,11 +72,11 @@ def _decrypt_chunk(chunk: bytes, key: bytes) -> bytes:
cipher = Cipher(algorithms.Blowfish(key), modes.CBC(iv))
decryptor = cipher.decryptor()
return decryptor.update(chunk) + decryptor.finalize()
except ImportError:
except ImportError as exc:
raise ImportError(
"Deezer downloads require pycryptodome or cryptography package. "
"Install with: pip install pycryptodome"
)
) from exc
class DeezerDownloadClient:

View file

@ -8,7 +8,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.deezer_client import DeezerClient
from core.worker_utils import interruptible_sleep
from core.worker_utils import interruptible_sleep, set_album_api_track_count
logger = get_logger("deezer_worker")
@ -579,6 +579,17 @@ class DeezerWorker:
WHERE id = ? AND (genres IS NULL OR genres = '' OR genres = '[]')
""", (json.dumps(genre_names), album_id))
# Cache the authoritative expected track count for the Album
# Completeness repair job. Deezer's field is `nb_tracks`; prefer
# full_data over search_data so we pick up the richer count when
# the full album lookup ran. Helper handles the int conversion
# and skip-on-missing semantics.
set_album_api_track_count(
cursor,
album_id,
(full_data.get('nb_tracks') if full_data else None) or search_data.get('nb_tracks'),
)
conn.commit()
except Exception as e:

View file

@ -18,11 +18,34 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.discogs_client import DiscogsClient
from core.worker_utils import interruptible_sleep
from core.worker_utils import interruptible_sleep, set_album_api_track_count
logger = get_logger("discogs_worker")
def count_discogs_real_tracks(tracklist) -> int:
"""Count actual songs in a Discogs tracklist response.
Discogs tracklists interleave real tracks with section headings
(``type_=='heading'``), index markers (``type_=='index'``),
and sub-tracks (``type_=='sub_track'``) that aren't themselves
songs. We count anything that's explicitly typed as ``'track'`` OR
has an empty/missing ``type_`` field matching exactly what
:meth:`core.discogs_client.DiscogsClient.get_album_tracks` itself
treats as a real track (`type_ in ('track', '')`). Counting any
narrower set silently disagrees with the repair job's fallback
`_get_expected_total` path, which calls `get_album_tracks_for_source`
under the hood and therefore uses the client's count.
Reported by kettui on PR #374 — original filter only counted
``type_=='track'`` and undercounted releases where the discogs
response left ``type_`` empty for some real tracks.
"""
if not tracklist:
return 0
return sum(1 for t in tracklist if (t.get('type_') or '') in ('track', ''))
class DiscogsWorker:
"""Background worker for enriching library artists and albums with Discogs metadata."""
@ -454,6 +477,16 @@ class DiscogsWorker:
WHERE id = ? AND (thumb_url IS NULL OR thumb_url = '')
""", (image_url, album_id))
# Cache the authoritative expected track count for the Album
# Completeness repair job. See `count_discogs_real_tracks`
# for why we accept both `type_ == 'track'` and empty `type_`
# (kettui's PR #374 review — narrower filter undercounted).
set_album_api_track_count(
cursor,
album_id,
count_discogs_real_tracks(data.get('tracklist')),
)
conn.commit()
except Exception as e:

View file

@ -87,7 +87,7 @@ class DownloadOrchestrator:
# Reload underlying client configs (SLSKD URL, API key, etc.)
if self.soulseek:
self.soulseek._setup_client()
logger.info(f"Soulseek client config reloaded")
logger.info("Soulseek client config reloaded")
# Reconnect Deezer if ARL changed
deezer_arl = config_manager.get('deezer_download.arl', '')

View file

@ -668,7 +668,7 @@ class HiFiClient:
"""Get all active downloads (Soulseek-compatible)."""
statuses = []
with self._download_lock:
for dl_id, info in self.active_downloads.items():
for _dl_id, info in self.active_downloads.items():
statuses.append(DownloadStatus(
id=info['id'],
filename=info['filename'],

View file

@ -8,7 +8,7 @@ from datetime import datetime, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.itunes_client import iTunesClient
from core.worker_utils import interruptible_sleep
from core.worker_utils import interruptible_sleep, set_album_api_track_count
logger = get_logger("itunes_worker")
@ -669,6 +669,10 @@ class iTunesWorker:
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
""", (year, album_id))
# Cache the authoritative expected track count for the Album
# Completeness repair job (see set_album_api_track_count docstring).
set_album_api_track_count(cursor, album_id, getattr(album_obj, 'total_tracks', 0))
conn.commit()
except Exception as e:
logger.error(f"Error updating album #{album_id} with iTunes data: {e}")

View file

@ -1555,9 +1555,9 @@ class JellyfinClient:
logger.info(f"Creating backup playlist '{backup_name}' before sync")
if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully")
logger.info("Backup created successfully")
else:
logger.warning(f"Failed to create backup, continuing with sync")
logger.warning("Failed to create backup, continuing with sync")
if existing_playlist:
# Delete existing playlist using DELETE request

1496
core/library_reorganize.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -311,7 +311,7 @@ class LidarrDownloadClient:
}
# Check if album already exists
existing = self._api_get(f'album', params={'foreignAlbumId': album.get('foreignAlbumId', '')})
existing = self._api_get('album', params={'foreignAlbumId': album.get('foreignAlbumId', '')})
if existing and isinstance(existing, list) and len(existing) > 0:
lidarr_album_id = existing[0].get('id')
# Trigger search for existing album

View file

@ -36,6 +36,10 @@ class MetadataLookupOptions:
max_pages: int = 0
limit: int = 50
artist_source_ids: Optional[Dict[str, str]] = None
dedup_variants: bool = True # Collapse "Deluxe Edition" / "Remastered" etc.
# into a single canonical release card. Off
# gives the inline-Artists-page behaviour of
# showing every variant the source returns.
# =============================================================================
@ -633,9 +637,10 @@ def get_artist_detail_discography(
else:
albums.append(card)
albums = _dedup_variant_releases(albums)
eps = _dedup_variant_releases(eps)
singles = _dedup_variant_releases(singles)
if options is None or options.dedup_variants:
albums = _dedup_variant_releases(albums)
eps = _dedup_variant_releases(eps)
singles = _dedup_variant_releases(singles)
albums = _sort_discography_releases(albums)
eps = _sort_discography_releases(eps)
@ -1778,8 +1783,14 @@ def get_artist_image_url(
artist_id: str,
source_override: Optional[str] = None,
plugin: Optional[str] = None,
artist_name: Optional[str] = None,
) -> Optional[str]:
"""Resolve an artist image URL using the configured source priority."""
"""Resolve an artist image URL using the configured source priority.
`artist_name` is used when the source-of-record doesn't store artist
images (MusicBrainz) the resolver then searches fallback sources
(iTunes/Deezer) by name for a matching artist and returns their image.
"""
if not artist_id:
return None
@ -1796,6 +1807,14 @@ def get_artist_image_url(
return _get_artist_image_from_source('itunes', artist_id)
return None
# MusicBrainz doesn't store artist images directly — use the artist
# name (passed by the frontend) to look up the image on a fallback
# source that does. Without a name we can't resolve.
if source_override == 'musicbrainz':
if not artist_name:
return None
return _lookup_artist_image_by_name(artist_name)
if source_override:
return _get_artist_image_from_source(source_override, artist_id)
@ -1807,6 +1826,41 @@ def get_artist_image_url(
return None
def _lookup_artist_image_by_name(name: str) -> Optional[str]:
"""Look up an artist image by NAME (not MBID) across fallback sources.
Used when the primary source doesn't store artist images (MusicBrainz).
Tries configured sources in priority order, searches each for the
artist name, and returns the first matching result's image URL.
"""
name = (name or '').strip()
if not name:
return None
# Skip sources that don't do artist-name search or don't have images.
_SKIP_SOURCES = {'musicbrainz', 'soulseek', 'youtube_videos', 'hydrabase'}
for source in get_source_priority(get_primary_source()):
if source in _SKIP_SOURCES:
continue
client = get_client_for_source(source)
if not client or not hasattr(client, 'search_artists'):
continue
try:
results = client.search_artists(name, limit=1) or []
if results:
top = results[0]
img = getattr(top, 'image_url', None) or (
top.get('image_url') if isinstance(top, dict) else None
)
if img:
return img
except Exception as exc:
logger.debug("Artist image lookup by name failed on %s for %r: %s",
source, name, exc)
continue
return None
def get_deezer_client():
"""Get cached Deezer client.

View file

@ -44,65 +44,85 @@ def rate_limited(func):
class MusicBrainzClient:
"""Client for interacting with MusicBrainz API"""
BASE_URL = "https://musicbrainz.org/ws/2"
# MusicBrainz mandates a meaningful User-Agent with contact info. Falling back
# to a bare name/version risks IP blocking under load — include the project
# URL so MB operators have a way to reach us if we misbehave.
DEFAULT_CONTACT = "https://github.com/Nezreka/SoulSync"
def __init__(self, app_name: str = "SoulSync", app_version: str = "1.0", contact_email: str = ""):
"""
Initialize MusicBrainz client
Args:
app_name: Name of the application
app_version: Version of the application
contact_email: Contact email (optional but recommended)
contact_email: Contact email or URL (defaults to project URL when empty)
"""
self.user_agent = f"{app_name}/{app_version}"
if contact_email:
self.user_agent += f" ( {contact_email} )"
contact = contact_email or self.DEFAULT_CONTACT
self.user_agent = f"{app_name}/{app_version} ( {contact} )"
self.session = requests.Session()
self.session.headers.update({
'User-Agent': self.user_agent,
'Accept': 'application/json'
})
logger.info(f"MusicBrainz client initialized with user agent: {self.user_agent}")
@rate_limited
def search_artist(self, artist_name: str, limit: int = 10) -> List[Dict[str, Any]]:
def search_artist(self, artist_name: str, limit: int = 10, strict: bool = True) -> List[Dict[str, Any]]:
"""
Search for artists by name
Search for artists by name.
Args:
artist_name: Name of the artist to search for
limit: Maximum number of results to return
strict: When True (default), builds a phrase-match query against
the `artist` field only correct for enrichment flows that
already know the exact name. When False, sends a bare query
which MusicBrainz matches against the alias, artist, AND
sortname indexes the right behavior for user-facing fuzzy
search (finds "Metallica" from typing "metalica", matches
aliased names, etc.).
Returns:
List of artist results with id, name, score, etc.
List of artist results with id, name, score, etc. MusicBrainz
assigns each result a `score` 0-100; the list is pre-sorted
score-descending by the server.
"""
try:
# Escape quotes and backslashes for Lucene query
safe_name = artist_name.replace('\\', '\\\\').replace('"', '\\"')
if strict:
query = f'artist:"{safe_name}"'
else:
# Bare query hits alias/artist/sortname indexes — much better
# recall for user typing. Still Lucene-escaped via the API's
# query parser.
query = safe_name
params = {
'query': f'artist:"{safe_name}"',
'query': query,
'fmt': 'json',
'limit': limit
}
response = self.session.get(
f"{self.BASE_URL}/artist",
params=params,
timeout=10
)
response.raise_for_status()
data = response.json()
artists = data.get('artists', [])
logger.debug(f"Found {len(artists)} artists for query: {artist_name}")
return artists
except Exception as e:
logger.error(f"Error searching for artist '{artist_name}': {e}")
return []
@ -197,6 +217,98 @@ class MusicBrainzClient:
logger.error(f"Error searching for recording '{track_name}': {e}")
return []
@rate_limited
def browse_artist_release_groups(self, artist_mbid: str,
release_types: Optional[List[str]] = None,
limit: int = 100,
offset: int = 0) -> List[Dict[str, Any]]:
"""Browse release-groups linked to an artist MBID.
This is the correct MusicBrainz pattern for "give me this artist's
discography" — text-based `/release?query=...` search would look at
release TITLES (matching unrelated releases literally titled after
the artist name), while browse walks the artistrelease-group link
directly.
Args:
artist_mbid: Artist's MusicBrainz ID
release_types: Filter by primary type any of 'album', 'single',
'ep', 'compilation', 'soundtrack', 'live', etc. Combined with
`|` per MB spec, e.g. `['album', 'ep']` `type=album|ep`.
None returns all types.
limit: 1-100 (MB hard cap)
offset: Pagination offset
Returns:
List of release-group dicts. Each has `id`, `title`, `primary-type`,
`secondary-types`, `first-release-date`, `disambiguation`.
"""
try:
params = {'artist': artist_mbid, 'fmt': 'json', 'limit': min(limit, 100), 'offset': offset}
if release_types:
params['type'] = '|'.join(release_types)
response = self.session.get(
f"{self.BASE_URL}/release-group",
params=params,
timeout=10
)
response.raise_for_status()
data = response.json()
rgs = data.get('release-groups', [])
logger.debug(f"Browsed {len(rgs)} release-groups for artist {artist_mbid}")
return rgs
except Exception as e:
logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}")
return []
@rate_limited
def search_recordings_by_artist_mbid(self, artist_mbid: str,
limit: int = 100) -> List[Dict[str, Any]]:
"""Search for recordings linked to an artist via Lucene `arid:` query.
This is the counterpart to `browse_artist_release_groups` for tracks.
The proper "browse" endpoint (`/recording?artist=<mbid>`) rejects
`inc=releases`, so we can't get album context per recording from
browse only the track title/length/MBID. Without release info the
user would see tracks with no album, which is useless.
The search endpoint with a fielded `arid:<mbid>` query returns
recordings with the `releases` array already embedded (including
release-group, date, and media info), which is what the search-tab
UI needs.
Args:
artist_mbid: Artist's MusicBrainz ID
limit: 1-100 (MB hard cap)
Returns:
List of recording dicts with `id`, `title`, `length`, `score`,
`artist-credit`, and `releases` (each with release-group + date).
"""
try:
params = {
'query': f'arid:{artist_mbid}',
'fmt': 'json',
'limit': min(limit, 100),
}
response = self.session.get(
f"{self.BASE_URL}/recording",
params=params,
timeout=10
)
response.raise_for_status()
data = response.json()
recs = data.get('recordings', [])
logger.debug(f"Found {len(recs)} recordings for artist {artist_mbid}")
return recs
except Exception as e:
logger.error(f"Error searching recordings for artist {artist_mbid}: {e}")
return []
@rate_limited
def get_artist(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""
@ -257,6 +369,38 @@ class MusicBrainzClient:
logger.error(f"Error fetching release {mbid}: {e}")
return None
@rate_limited
def get_release_group(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""Get full release-group details by MBID.
Release-groups are the 'canonical album' entity in MusicBrainz
they group every edition/reissue/region-specific release of the
same logical album under one MBID. Use `inc=releases` to list the
individual releases this group contains (each with its own
tracklist); use `inc=artist-credits` for artist info.
Args:
mbid: Release-group's MusicBrainz ID
includes: Optional list, e.g. ['releases', 'artist-credits']
Returns:
Release-group data or None if not found.
"""
try:
params = {'fmt': 'json'}
if includes:
params['inc'] = '+'.join(includes)
response = self.session.get(
f"{self.BASE_URL}/release-group/{mbid}",
params=params,
timeout=10
)
response.raise_for_status()
return response.json()
except Exception as e:
logger.error(f"Error fetching release-group {mbid}: {e}")
return None
@rate_limited
def get_recording(self, mbid: str, includes: Optional[List[str]] = None) -> Optional[Dict[str, Any]]:
"""

View file

@ -6,7 +6,7 @@ enabling MusicBrainz as a search tab in enhanced and global search.
Album art is fetched from Cover Art Archive (free, linked by release MBID).
"""
import requests
import threading
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
@ -59,29 +59,24 @@ class Album:
external_urls: Optional[Dict[str, str]] = None
def _get_cover_art_url(release_mbid: str) -> Optional[str]:
"""Fetch album art URL from Cover Art Archive. Returns None if not available."""
try:
# CAA redirects to the actual image URL — just get the front image URL
url = f"{COVER_ART_ARCHIVE_URL}/release/{release_mbid}/front-250"
resp = requests.head(url, timeout=3, allow_redirects=True)
if resp.status_code == 200:
return resp.url # The redirect target is the actual image
return None
except Exception:
return None
def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]:
"""Build a Cover Art Archive URL without hitting the network.
CAA URLs are deterministic from the MBID: the endpoint either 307-redirects
to the image or returns 404. Previously we fired `requests.head(timeout=3)`
per result during search 10 results × 3s worst-case = up to 30s of
blocking HEAD calls before a search returned. The frontend's <img> tag
handles the 404 case via onerror fallback, so the HEAD round-trip was
pure overhead.
def _get_release_group_art(release_group_mbid: str) -> Optional[str]:
"""Fetch album art from release group (covers all editions)."""
try:
url = f"{COVER_ART_ARCHIVE_URL}/release-group/{release_group_mbid}/front-250"
resp = requests.head(url, timeout=3, allow_redirects=True)
if resp.status_code == 200:
return resp.url
return None
except Exception:
`scope` is 'release' (most specific) or 'release-group' (covers all
editions better hit rate).
"""
if not mbid:
return None
if scope not in ('release', 'release-group'):
scope = 'release'
return f"{COVER_ART_ARCHIVE_URL}/{scope}/{mbid}/front-250"
def _extract_artist_credit(artist_credit) -> List[str]:
@ -97,6 +92,28 @@ def _extract_artist_credit(artist_credit) -> List[str]:
return [n for n in names if n]
def _extract_title_hint(query: str, artist_name: str) -> Optional[str]:
"""If `query` starts with `artist_name` followed by more words, return
the trailing portion. Used to pick out the album/track title the user
typed after the artist name (e.g. "The Beatles Abbey Road" "Abbey
Road"). Returns None when the query is just the artist name.
Case-insensitive prefix match on whitespace-normalized versions of
both strings, so "the beatles abbey road" "abbey road" and
"The Beatles" None.
"""
if not query or not artist_name:
return None
q_norm = ' '.join(query.split()).lower()
a_norm = ' '.join(artist_name.split()).lower()
if q_norm == a_norm:
return None
# Require a word boundary between the artist name and the trailing bit.
if q_norm.startswith(a_norm + ' '):
return query[len(artist_name):].strip() or None
return None
def _map_release_type(primary_type: str, secondary_types: List[str] = None) -> str:
"""Map MusicBrainz release group type to standard album_type."""
pt = (primary_type or '').lower()
@ -116,49 +133,304 @@ class MusicBrainzSearchClient:
def __init__(self):
from core.musicbrainz_client import MusicBrainzClient
self._client = MusicBrainzClient("SoulSync", "2.3")
self._art_cache: Dict[str, Optional[str]] = {} # mbid -> url
# Client defaults to the project URL as its User-Agent contact,
# which is what MusicBrainz wants. Version stays generic ("2") —
# the exact UI minor version would add noise to every request.
self._client = MusicBrainzClient("SoulSync", "2")
# Per-instance cache for "top artist MBID for this query". The
# backend fires artists/albums/tracks searches in parallel against
# one client instance, and albums+tracks both need the same artist
# lookup. Without this cache, we'd fire 3 identical artist-search
# HTTP calls (each serialized by the 1-rps rate limit = 3 wasted
# seconds). The _Sentinel marks "we already looked and found
# nothing" to prevent repeat no-hit lookups.
self._artist_mbid_cache: Dict[str, Optional[Dict[str, Any]]] = {}
self._artist_mbid_lock = threading.Lock()
def _cached_art(self, release_mbid: str, release_group_mbid: str = '') -> Optional[str]:
"""Get cover art with caching. Tries release first, then release group."""
if release_mbid in self._art_cache:
return self._art_cache[release_mbid]
"""Build a Cover Art Archive URL for a release / release-group MBID.
url = _get_cover_art_url(release_mbid)
if not url and release_group_mbid:
url = _get_release_group_art(release_group_mbid)
self._art_cache[release_mbid] = url
return url
Prefers release-group scope when provided better hit rate because
it covers all editions of the same album. No network call; the
frontend's <img onerror> fallback handles 404s.
"""
preferred = release_group_mbid or release_mbid
if not preferred:
return None
scope = 'release-group' if release_group_mbid else 'release'
return _cover_art_url(preferred, scope=scope)
# Score threshold for user-facing search results. MusicBrainz returns a
# Lucene score 0-100 on every match; exact name/alias hits score 100,
# partial/typo matches trend lower, and tribute bands / random
# lookalikes score 40-65. 80 is the cutoff that keeps the true artist
# and close variants while dropping unrelated noise.
_MIN_SCORE = 80
def search_artists(self, query: str, limit: int = 10) -> List[Artist]:
"""MusicBrainz search tab doesn't show artists — only albums and tracks."""
return []
"""Search MusicBrainz for artists by name.
Uses a bare Lucene query (no field prefix) so MusicBrainz searches
the alias, artist, AND sortname indexes together much better
recall than strict `artist:"..."` phrase matching. Results are
filtered by score (>= 80) to drop tribute bands and unrelated
lookalikes.
"""
try:
# Fetch extra so dedup below has enough to pick from. For
# common names (Michael Jackson, John Williams, etc.) MB returns
# many same-named people; without a larger pool, capping at
# `limit` before dedup can leave us with fewer results than
# requested.
raw = self._client.search_artist(query, limit=max(limit * 3, 10), strict=False)
# Dedupe by normalized name. MusicBrainz has many different
# people with the same canonical name (7 entries for "Michael
# Jackson" — the singer + poet + photographer + didgeridoo
# player + ...), all scoring 80+ on exact-name match. Rendered
# as identical cards since the fallback image lookup hits the
# same fallback-source result for each. Keep the highest-
# scoring entry per normalized name so the user sees one card
# per distinct artist.
seen = {}
for a in raw:
score = a.get('score', 0) or 0
if score < self._MIN_SCORE:
continue
mbid = a.get('id', '')
name = a.get('name', '')
if not mbid or not name:
continue
key = name.lower().strip()
if key not in seen or (seen[key].get('score', 0) or 0) < score:
seen[key] = a
# Sort the survivors score-descending and cap at the caller's
# limit. `seen` only holds top-per-name, so ordering is stable.
top = sorted(seen.values(), key=lambda r: -(r.get('score', 0) or 0))[:limit]
artists = []
for a in top:
mbid = a.get('id', '')
name = a.get('name', '')
# Genres from MB tags (user-applied categorical labels). Each
# tag has {name, count}; keep the top-weighted ones.
tags = a.get('tags', []) or []
genres = [t.get('name') for t in tags if t.get('name')][:5]
external_urls = {
'musicbrainz': f'https://musicbrainz.org/artist/{mbid}'
}
artists.append(Artist(
id=mbid,
name=name,
popularity=a.get('score', 0) or 0, # Reuse score as popularity (0-100)
genres=genres,
followers=0, # MusicBrainz doesn't track followers
image_url=None, # MB doesn't store artist images directly
external_urls=external_urls,
))
return artists
except Exception as e:
logger.warning(f"MusicBrainz artist search failed: {e}")
return []
def _split_structured_query(self, query: str):
"""Split 'Artist - Title' / 'Artist Title' / 'Artist — Title' if
a separator is present. Returns (artist_name, title) or (None, query)."""
for sep in [' - ', ' ', '']:
if sep in query:
parts = query.split(sep, 1)
return parts[0].strip(), parts[1].strip()
return None, query
def _resolve_top_artist(self, query: str) -> Optional[Dict[str, Any]]:
"""Return the top-scoring artist for a bare-name query, or None if
nothing scores above threshold. Cached per instance so parallel
album/track searches don't each refetch."""
if not query:
return None
key = query.strip().lower()
with self._artist_mbid_lock:
if key in self._artist_mbid_cache:
return self._artist_mbid_cache[key]
# Do the HTTP call OUTSIDE the lock so other threads can still
# check the cache while we wait on the network.
raw = self._client.search_artist(query, limit=1, strict=False)
top = None
if raw and (raw[0].get('score', 0) or 0) >= self._MIN_SCORE:
top = raw[0]
with self._artist_mbid_lock:
self._artist_mbid_cache[key] = top
return top
# Secondary-type tags on MB release-groups that indicate NOT a studio
# release. Used by both the album browse (filter out) and the track
# browse (prefer studio release for album context).
_NON_STUDIO_SECONDARY_TYPES = {
'Live', 'Compilation', 'Soundtrack', 'Remix', 'Demo',
'Mixtape/Street', 'Interview', 'Audiobook', 'Audio drama',
}
def _release_preference_key(self, rel: Dict[str, Any]):
"""Sort key: studio releases first, then by date ASC.
Recordings in MB often have 10+ releases (studio album, live, best-of,
reissues, anniversary editions). The first one in the API response is
arbitrary it's often a recent live bootleg because MB users add new
live recordings all the time. Re-sorting before `_recording_to_track`
reads the first release means tracks show their canonical studio
album, not a random live compilation.
"""
rg = rel.get('release-group') or {}
secs = set(rg.get('secondary-types') or [])
is_studio = 0 if not (secs & self._NON_STUDIO_SECONDARY_TYPES) else 1
date = (rel.get('date') or '')[:4]
year = int(date) if date.isdigit() else 9999
return (is_studio, year)
def _has_studio_release(self, recording: Dict[str, Any]) -> bool:
"""True when at least one of the recording's releases is on a
release-group with no non-studio secondary type."""
for rel in (recording.get('releases') or []):
rg = rel.get('release-group') or {}
secs = set(rg.get('secondary-types') or [])
if not (secs & self._NON_STUDIO_SECONDARY_TYPES):
return True
return False
def _release_group_to_album(self, rg: Dict[str, Any], artist_name: str) -> Album:
"""Project a MusicBrainz release-group into our Album dataclass."""
rg_mbid = rg.get('id', '')
title = rg.get('title', '') or ''
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
release_date = rg.get('first-release-date', '') or ''
# Release-group browse doesn't link directly to a single release,
# so we can't get per-release track counts cheaply. Leave 0 — the
# frontend treats it as "unknown" gracefully.
image_url = self._cached_art(rg_mbid, rg_mbid)
return Album(
id=rg_mbid,
name=title,
artists=[artist_name] if artist_name else ['Unknown Artist'],
release_date=release_date,
total_tracks=0,
album_type=album_type,
image_url=image_url,
external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {},
)
def search_albums(self, query: str, limit: int = 10) -> List[Album]:
"""Search MusicBrainz for releases (albums)."""
"""Search MusicBrainz for releases (albums).
Primary path: when the query looks like a bare artist name, resolve
it to an artist MBID and BROWSE that artist's release-groups. This
returns the artist's actual discography instead of unrelated
releases that happen to be titled after them.
Fallback path: when the query is structured as "Artist - Album" or
the artist lookup fails, drop back to text search with the
existing Lucene strategy.
"""
try:
# Try to split "Artist Album" for better matching
artist_name = None
album_name = query
for sep in [' - ', ' ', '']:
if sep in query:
parts = query.split(sep, 1)
artist_name = parts[0].strip()
album_name = parts[1].strip()
break
artist_name, title = self._split_structured_query(query)
# Structured "Artist - Album" query → respect user's intent;
# text-search with both terms is more precise than browsing all
# of that artist's discography.
if artist_name:
return self._search_albums_text(title, artist_name, limit)
# Bare name query → try artist-first → browse path.
top = self._resolve_top_artist(query)
if top:
mbid = top.get('id', '')
tname = top.get('name', '') or query
# If the query has words beyond the artist name (e.g. "The
# Beatles Abbey Road"), extract the leftover as a title hint.
# We'll use it below to narrow browse results to the specific
# album the user typed rather than dumping the full back
# catalogue. kettui flagged the regression — bare-name browse
# was burying a specific-album query inside a discography list.
title_hint = _extract_title_hint(query, tname)
rgs = self._client.browse_artist_release_groups(
mbid,
# 'compilation' is a SECONDARY type, not a primary type
# — including it in the OR filter causes MB to return
# only 82 matches instead of the actual 1076 because
# the filter silently breaks. Actual compilations
# (primary-type=Album with secondary-types=[Compilation])
# are handled by the studio-preference filter below.
release_types=['album', 'ep', 'single'],
limit=100,
)
# Prefer studio releases — MusicBrainz tags live bootlegs
# and best-of compilations with secondary-types. For mega-
# artists like Metallica, 83 of 100 browse results are live
# broadcast bootlegs; the 12 studio albums are buried. A
# release-group with no secondary-types (or an explicit
# studio-only type) is the "original studio" shape users
# expect to see first.
def _is_studio(rg):
secs = set((rg.get('secondary-types') or []))
return not (secs & {'Live', 'Compilation', 'Soundtrack',
'Remix', 'Demo', 'Mixtape/Street',
'Interview', 'Audiobook', 'Audio drama'})
studio = [rg for rg in rgs if _is_studio(rg)]
# If filtering leaves us empty (niche live-only artist),
# fall back to the unfiltered list — better than no results.
rgs = studio or rgs
# Narrow to the title-hint if the user gave one ("The Beatles
# Abbey Road" → filter to RGs whose title contains "abbey
# road"). If no RG matches, fall back to text-search so the
# user finds the specific album instead of either seeing the
# full discography or getting zero results. (kettui flagged
# this regression — artist-first alone was burying specific-
# album queries inside the unfiltered discography list.)
if title_hint:
hint_lower = title_hint.lower()
matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()]
if matched:
rgs = matched
else:
fallback = self._search_albums_text(title_hint, tname, limit)
if fallback:
return fallback
# Text-search also missed — fall through and show the
# full (unfiltered) discography rather than nothing.
# Sort by primary-type priority first (album > ep > single >
# compilation), then chronologically ASC — the standard way
# discographies are listed ("their debut was X, then Y, then Z").
type_priority = {'album': 0, 'ep': 1, 'single': 2, 'compilation': 3}
def _sort_key(rg):
pt = (rg.get('primary-type') or '').lower()
date = rg.get('first-release-date') or ''
year = int(date[:4]) if date[:4].isdigit() else 9999
return (type_priority.get(pt, 9), year)
rgs.sort(key=_sort_key)
albums = [self._release_group_to_album(rg, tname) for rg in rgs[:limit]]
return albums
# No artist match → text search on the whole query.
return self._search_albums_text(query, None, limit)
except Exception as e:
logger.warning(f"MusicBrainz album search failed: {e}")
return []
def _search_albums_text(self, album_name: str, artist_name: Optional[str], limit: int) -> List[Album]:
"""Fallback text-search path for structured/fuzzy album queries."""
try:
results = self._client.search_release(album_name, artist_name=artist_name, limit=limit)
# If no separator, try word-boundary splitting
if not results and not artist_name:
words = query.split()
for i in range(1, len(words)):
possible_artist = ' '.join(words[:i])
possible_album = ' '.join(words[i:])
if len(possible_album) >= 2:
results = self._client.search_release(possible_album, artist_name=possible_artist, limit=limit)
if results:
break
# Score filter — same threshold as artists. Drops garbage
# title-match hits from unrelated releases.
results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
albums = []
for r in results:
@ -223,165 +495,300 @@ class MusicBrainzSearchClient:
logger.warning(f"MusicBrainz album search failed: {e}")
return []
def _recording_to_track(self, r: Dict[str, Any], fallback_artist_name: str) -> Optional[Track]:
"""Project a MusicBrainz recording into our Track dataclass. Returns
None when the recording lacks required fields."""
mbid = r.get('id', '')
title = r.get('title', '')
if not title:
return None
artists = _extract_artist_credit(r.get('artist-credit', []))
if not artists and fallback_artist_name:
artists = [fallback_artist_name]
duration_ms = r.get('length', 0) or 0
album_name = ''
album_id = ''
release_date = ''
image_url = None
album_type = 'single'
# Initialized to 0 and summed from the release's media track-counts.
# Previously initialized to 1, which made every track-with-release
# report one more than the album actually has (kettui caught this).
total_tracks = 0
releases = r.get('releases', []) or []
if releases:
rel = releases[0]
album_name = rel.get('title', '') or ''
album_id = rel.get('id', '') or ''
release_date = rel.get('date', '') or ''
rg = rel.get('release-group', {}) or {}
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
for m in rel.get('media', []) or []:
total_tracks += m.get('track-count', 0)
rg_mbid = rg.get('id', '') or ''
image_url = self._cached_art(album_id, rg_mbid) if album_id else None
# Tracks with no release info are standalone recordings — give them
# total_tracks=1 (the track itself). Keeps the old shape for that
# edge case but fixes the off-by-one for every normal case.
if not releases:
total_tracks = 1
return Track(
id=mbid,
name=title,
artists=artists if artists else ['Unknown Artist'],
album=album_name or title,
duration_ms=duration_ms,
popularity=r.get('score', 0) or 0,
image_url=image_url,
release_date=release_date,
external_urls={'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {},
album_type=album_type,
total_tracks=total_tracks,
album_id=album_id,
)
def search_tracks(self, query: str, limit: int = 10) -> List[Track]:
"""Search MusicBrainz for recordings (tracks)."""
"""Search MusicBrainz for recordings (tracks).
Same strategy as `search_albums`: bare name artist-first browse
recordings; structured "Artist - Title" stays on text search so the
user's explicit title intent is respected.
"""
try:
# Try to split "Artist - Title" for better matching
artist_name = None
track_name = query
for sep in [' - ', ' ', '']:
if sep in query:
parts = query.split(sep, 1)
artist_name = parts[0].strip()
track_name = parts[1].strip()
break
artist_name, title = self._split_structured_query(query)
# Structured query → text search with both fields.
if artist_name:
return self._search_tracks_text(title, artist_name, limit)
# Bare name → artist-first → arid: search.
top = self._resolve_top_artist(query)
if top:
mbid = top.get('id', '')
tname = top.get('name', '') or query
# /recording?artist=<mbid> (browse) rejects inc=releases,
# so we use the fielded Lucene search arid:<mbid> instead —
# that returns recordings with release context inline.
recs = self._client.search_recordings_by_artist_mbid(mbid, limit=100)
# Re-order each recording's releases to prefer studio over
# live/compilation. Without this, the first release (which
# the adapter uses for album info + date) is often a random
# live bootleg — Metallica has 10+ live versions of "One"
# ranked ahead of the studio release. Mutates in place so
# `_recording_to_track` sees the preferred release first.
for r in recs:
rels = r.get('releases') or []
if not rels:
continue
rels.sort(key=self._release_preference_key)
r['releases'] = rels
# Prefer recordings that have at least one studio release.
# Falls back to the full set if the artist is live-only.
studio = [r for r in recs if self._has_studio_release(r)]
recs = studio or recs
# Dedupe by normalized title (MB has many versions of the
# same song — live, remaster, re-recording, etc.). Because
# we sorted releases above, `_recording_to_track` will pick
# the studio release for album info on the first keeper.
seen = set()
deduped = []
for r in recs:
key = (r.get('title') or '').lower().strip()
if not key or key in seen:
continue
seen.add(key)
deduped.append(r)
# Sort by studio-release year ASC so classic tracks surface
# first. For a user typing "metallica", this means "Seek
# and Destroy" (1983) before "Atlas, Rise!" (2016) — which
# matches how most discography views order by release.
def _track_sort_key(r):
rels = r.get('releases') or []
for rel in rels:
date = (rel.get('date') or '')[:4]
if date.isdigit():
return int(date)
return 9999
deduped.sort(key=_track_sort_key)
tracks = []
for r in deduped[:limit]:
t = self._recording_to_track(r, tname)
if t:
tracks.append(t)
return tracks
# No artist match → fall back to text search on whole query.
return self._search_tracks_text(query, None, limit)
except Exception as e:
logger.warning(f"MusicBrainz track search failed: {e}")
return []
def _search_tracks_text(self, track_name: str, artist_name: Optional[str], limit: int) -> List[Track]:
"""Fallback text-search path for structured/fuzzy track queries."""
try:
results = self._client.search_recording(track_name, artist_name=artist_name, limit=limit)
# Score filter matches the artist/album logic — cuts garbage
# title collisions from unrelated recordings.
results = [r for r in results if (r.get('score', 0) or 0) >= self._MIN_SCORE]
# If no separator found or structured search failed, try the full query
# as both a recording search and an artist+recording combined search
if not results and not artist_name:
# Try each word split as potential artist/title boundary
words = query.split()
for i in range(1, len(words)):
possible_artist = ' '.join(words[:i])
possible_track = ' '.join(words[i:])
if len(possible_track) >= 2:
results = self._client.search_recording(possible_track, artist_name=possible_artist, limit=limit)
if results:
break
tracks = []
for r in results:
mbid = r.get('id', '')
title = r.get('title', '')
if not title:
continue
artists = _extract_artist_credit(r.get('artist-credit', []))
duration_ms = r.get('length', 0) or 0
# Get album from first release
album_name = ''
album_id = ''
release_date = ''
image_url = None
album_type = 'single'
total_tracks = 1
track_number = None
releases = r.get('releases', [])
if releases:
rel = releases[0]
album_name = rel.get('title', '')
album_id = rel.get('id', '')
release_date = rel.get('date', '') or ''
rg = rel.get('release-group', {})
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
media = rel.get('media', [])
for m in media:
total_tracks += m.get('track-count', 0)
# Find track number
for t in m.get('tracks', []):
if t.get('id') == mbid or t.get('recording', {}).get('id') == mbid:
try:
track_number = int(t.get('number', t.get('position', 0)))
except (ValueError, TypeError):
pass
# Cover art
rg_mbid = rg.get('id', '')
image_url = self._cached_art(album_id, rg_mbid) if album_id else None
external_urls = {'musicbrainz': f'https://musicbrainz.org/recording/{mbid}'} if mbid else {}
tracks.append(Track(
id=mbid,
name=title,
artists=artists if artists else ['Unknown Artist'],
album=album_name or title,
duration_ms=duration_ms,
popularity=r.get('score', 0),
image_url=image_url,
release_date=release_date,
external_urls=external_urls,
track_number=track_number,
album_type=album_type,
total_tracks=total_tracks,
album_id=album_id,
))
t = self._recording_to_track(r, artist_name or '')
if t:
tracks.append(t)
return tracks
except Exception as e:
logger.warning(f"MusicBrainz track search failed: {e}")
return []
def get_album(self, release_mbid: str) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal."""
try:
release = self._client.get_release(release_mbid, includes=['recordings', 'artist-credits', 'release-groups'])
if not release:
return None
def _pick_representative_release(self, releases: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
"""Pick the best release out of a release-group's editions.
title = release.get('title', '')
artists_raw = _extract_artist_credit(release.get('artist-credit', []))
release_date = release.get('date', '') or ''
rg = release.get('release-group', {})
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
# Cover art
rg_mbid = rg.get('id', '')
image_url = self._cached_art(release_mbid, rg_mbid)
# Build tracks from media
tracks = []
total_tracks = 0
media_list = release.get('media', [])
for media_idx, media in enumerate(media_list):
disc_number = media.get('position', media_idx + 1)
for track in media.get('tracks', []):
total_tracks += 1
recording = track.get('recording', {})
track_artists = _extract_artist_credit(recording.get('artist-credit', []))
if not track_artists:
track_artists = artists_raw
try:
track_num = int(track.get('number', track.get('position', total_tracks)))
except (ValueError, TypeError):
track_num = total_tracks
tracks.append({
'id': recording.get('id', track.get('id', '')),
'name': recording.get('title', track.get('title', '')),
'artists': [{'name': a} for a in track_artists],
'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0,
'track_number': track_num,
'disc_number': disc_number,
})
images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else []
return {
'id': release_mbid,
'name': title,
'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])],
'release_date': release_date,
'total_tracks': total_tracks,
'album_type': album_type,
'images': images,
'tracks': tracks,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
}
except Exception as e:
logger.error(f"MusicBrainz album detail failed for {release_mbid}: {e}")
Release-groups often contain 5-20+ releases (original, reissues,
remasters, regional editions, bonus-track editions). We want a
single canonical version to show the user as 'the album.' Prefer:
1. Official releases (not promo/bootleg)
2. Earliest date (the original)
3. Any release with media (skip entries that are just stubs)
"""
if not releases:
return None
def _key(r):
status = (r.get('status') or '').lower()
status_rank = 0 if status == 'official' else 1 # Official first
has_media = 0 if r.get('media') else 1 # Real tracklists first
date = (r.get('date') or '9999-99-99')[:10]
return (has_media, status_rank, date)
return sorted(releases, key=_key)[0]
def get_album(self, album_mbid: str) -> Optional[Dict[str, Any]]:
"""Get full album details with track listing for download modal.
The MBID passed in could be either:
- A release-group MBID (from `search_albums` browse path the
common case now that bare-name searches route artist-first
browse), or
- A release MBID (from the text-search fallback path).
Try release-group first since that's the majority; if it 404s,
fall back to direct release lookup. Release-group resolution adds
one extra API call (~1s at the 1-rps rate limit) to pick a
representative release and then fetch its tracklist.
"""
try:
# Path A: release-group MBID (new browse-based search default)
rg = self._client.get_release_group(
album_mbid, includes=['releases', 'artist-credits']
)
if rg:
releases = rg.get('releases') or []
rep = self._pick_representative_release(releases)
if rep and rep.get('id'):
album = self._render_release_as_album(
rep['id'],
rg_fallback=rg,
)
if album:
# Keep the release-group MBID as the canonical
# Album.id so downstream code can re-fetch with
# the same URL.
album['id'] = album_mbid
album['external_urls'] = {
'musicbrainz': f'https://musicbrainz.org/release-group/{album_mbid}'
}
return album
# Path B: release MBID (text-search fallback path)
return self._render_release_as_album(album_mbid)
except Exception as e:
logger.error(f"MusicBrainz album detail failed for {album_mbid}: {e}")
return None
def _render_release_as_album(self, release_mbid: str,
rg_fallback: Optional[Dict[str, Any]] = None) -> Optional[Dict[str, Any]]:
"""Fetch a specific release and project it to the album-detail dict
shape the download modal expects. `rg_fallback` supplies release-group
metadata (type, artist credits) when resolving from a release-group
whose releases may be lightly populated."""
release = self._client.get_release(
release_mbid, includes=['recordings', 'artist-credits', 'release-groups']
)
if not release:
return None
title = release.get('title', '')
artists_raw = _extract_artist_credit(release.get('artist-credit', []))
if not artists_raw and rg_fallback:
artists_raw = _extract_artist_credit(rg_fallback.get('artist-credit', []))
release_date = release.get('date', '') or ''
if not release_date and rg_fallback:
release_date = rg_fallback.get('first-release-date', '') or ''
rg = release.get('release-group', rg_fallback or {}) or {}
primary_type = rg.get('primary-type', '') or ''
secondary_types = rg.get('secondary-types', []) or []
album_type = _map_release_type(primary_type, secondary_types)
rg_mbid = rg.get('id', '')
image_url = self._cached_art(release_mbid, rg_mbid)
tracks = []
total_tracks = 0
media_list = release.get('media', [])
for media_idx, media in enumerate(media_list):
disc_number = media.get('position', media_idx + 1)
for track in media.get('tracks', []):
total_tracks += 1
recording = track.get('recording', {})
track_artists = _extract_artist_credit(recording.get('artist-credit', []))
if not track_artists:
track_artists = artists_raw
try:
track_num = int(track.get('number', track.get('position', total_tracks)))
except (ValueError, TypeError):
track_num = total_tracks
tracks.append({
'id': recording.get('id', track.get('id', '')),
'name': recording.get('title', track.get('title', '')),
'artists': [{'name': a} for a in track_artists],
'duration_ms': recording.get('length', 0) or track.get('length', 0) or 0,
'track_number': track_num,
'disc_number': disc_number,
})
images = [{'url': image_url, 'height': 250, 'width': 250}] if image_url else []
return {
'id': release_mbid,
'name': title,
'artists': [{'name': a, 'id': ''} for a in (artists_raw or ['Unknown Artist'])],
'release_date': release_date,
'total_tracks': total_tracks,
'album_type': album_type,
'images': images,
'tracks': tracks,
'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'},
}
def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single') -> List:
"""Get artist's releases for discography view."""
try:

View file

@ -997,9 +997,9 @@ class NavidromeClient:
# We only need to backup once, even if duplicates exist
if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully")
logger.info("Backup created successfully")
else:
logger.warning(f"Failed to create backup, continuing with sync")
logger.warning("Failed to create backup, continuing with sync")
# STRATEGY: Update the first match, delete the rest
if existing_playlists:

View file

@ -342,7 +342,7 @@ class PlexClient:
if valid_tracks:
# Debug the track objects before creating playlist
logger.debug(f"About to create playlist with tracks:")
logger.debug("About to create playlist with tracks:")
for i, track in enumerate(valid_tracks):
logger.debug(f" Track {i+1}: {track.title} (type: {type(track)}, ratingKey: {track.ratingKey})")
@ -378,7 +378,7 @@ class PlexClient:
return True
except Exception as final_error:
logger.error(f"Final playlist creation attempt failed: {final_error}")
raise create_error
raise create_error from final_error
else:
logger.error(f"No valid tracks with ratingKeys for playlist '{name}'")
return False
@ -464,9 +464,9 @@ class PlexClient:
logger.info(f"Creating backup playlist '{backup_name}' before sync")
if self.copy_playlist(playlist_name, backup_name):
logger.info(f"Backup created successfully")
logger.info("Backup created successfully")
else:
logger.warning(f"Failed to create backup, continuing with sync")
logger.warning("Failed to create backup, continuing with sync")
# Delete original and recreate
existing_playlist.delete()

View file

@ -361,7 +361,7 @@ class QobuzClient:
if is_valid:
logger.debug(f"Secret test passed (HTTP {resp.status_code})")
else:
logger.debug(f"Secret test failed (HTTP 400 — invalid signature)")
logger.debug("Secret test failed (HTTP 400 — invalid signature)")
return is_valid
except Exception as e:
@ -1105,7 +1105,7 @@ class QobuzClient:
download_statuses = []
with self._download_lock:
for download_id, info in self.active_downloads.items():
for _download_id, info in self.active_downloads.items():
status = DownloadStatus(
id=info['id'],
filename=info['filename'],

453
core/reorganize_queue.py Normal file
View file

@ -0,0 +1,453 @@
"""FIFO queue for library album reorganize requests.
Replaces the single-slot "one reorganize at a time, return 409 on
collision" model with a queue: clicks always succeed (or surface
"already queued" on dedupe), the user can fan-out clicks across
albums or hit "Reorganize All", and a single background worker
chews through the queue in submission order.
Design rules:
- **Single global queue**, single worker thread. Reorganize is
I/O-heavy (file copy, mutagen tagging, AcoustID, possibly ffmpeg)
and post-process is not designed for cross-album concurrency.
In-album track parallelism still happens inside `reorganize_album`
(3 worker threads see `_REORGANIZE_MAX_WORKERS`).
- **Dedupe on enqueue**: an album that's already queued or currently
running is rejected silently. Stops the user from spamming the
same album N times by clicking the button repeatedly.
- **Per-item source**: each queued item carries its own `source`
string (the user's per-album modal pick). Worker passes it
through to `reorganize_album(primary_source=..., strict_source=...)`.
- **Continue on failure**: a failed item doesn't stop the queue.
Worker logs the failure, marks the item `failed`, moves on.
- **Cancel queued items**: items in `queued` state can be cancelled
(drop from queue). The currently-running item can NOT be cancelled
mid-flight Python threads aren't cleanly killable, and post-
process spawns subprocesses we can't safely interrupt. Cancel
changes the item's status to `cancelled` and removes it from the
active queue.
- **In-memory only**: queue state lives in a module-level singleton.
A server restart loses the queue (in-flight item likely also lost
half-way through post-process). DB persistence is a follow-up if
this turns out to matter operationally.
"""
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from utils.logging_config import get_logger
logger = get_logger("reorganize_queue")
# How many recently-completed items to retain for the snapshot endpoint.
# The status panel uses these to show "just-finished" cards briefly so
# the user sees outcomes scroll past instead of items vanishing.
_RECENT_HISTORY_CAP = 30
@dataclass
class QueueItem:
"""One album waiting (or being processed) in the reorganize queue."""
queue_id: str # uuid; how the API references this item
album_id: str
album_title: str # captured at enqueue time for UI display
artist_id: Optional[str]
artist_name: str # captured at enqueue time for UI display
source: Optional[str] # the user's per-modal pick (None = auto)
enqueued_at: float
status: str = 'queued' # queued | running | done | failed | cancelled
started_at: Optional[float] = None
finished_at: Optional[float] = None
# Populated by the worker after each item finishes — surfaced to the
# status panel so users see counts + per-item error messages.
result_status: Optional[str] = None # mirrors `reorganize_album` summary['status']
result_source: Optional[str] = None # which source the orchestrator actually used
moved: int = 0
skipped: int = 0
failed: int = 0
error: Optional[str] = None # shorthand for the first error, for the toast
# Live-progress fields for the currently-running item; cleared when
# the worker moves on so the snapshot stays small.
current_track: Optional[str] = None
progress_total: int = 0
progress_processed: int = 0
def to_snapshot(self) -> dict:
return {
'queue_id': self.queue_id,
'album_id': self.album_id,
'album_title': self.album_title,
'artist_id': self.artist_id,
'artist_name': self.artist_name,
'source': self.source,
'enqueued_at': self.enqueued_at,
'started_at': self.started_at,
'finished_at': self.finished_at,
'status': self.status,
'result_status': self.result_status,
'result_source': self.result_source,
'moved': self.moved,
'skipped': self.skipped,
'failed': self.failed,
'error': self.error,
'current_track': self.current_track,
'progress_total': self.progress_total,
'progress_processed': self.progress_processed,
}
class ReorganizeQueue:
"""Module-level singleton that owns the queue + worker thread.
Use the module-level :func:`get_queue` accessor don't construct
directly. The class is documented public-style so tests can spin
up isolated instances.
"""
def __init__(self, *, runner: Optional[Callable[[QueueItem], dict]] = None):
"""
Args:
runner: Callable that takes a `QueueItem` and runs the
actual reorganize, returning a summary dict with
``status``, ``source``, ``moved``, ``skipped``,
``failed``, ``errors`` keys (the shape
``reorganize_album`` already returns). Tests inject
a fake runner; production wires the real one in
via :func:`set_runner`.
"""
# Single Condition variable owns both mutual exclusion and the
# idle-worker wait. Using a Condition (vs Lock + Event) closes a
# race where the worker could clear an event right after enqueue
# set it, causing the new item to sleep for the timeout window.
# cond.wait() releases the lock and re-acquires on notify, so
# state checks and waits are properly interleaved.
self._cond = threading.Condition()
self._items: List[QueueItem] = [] # everything ever submitted (active + recent)
self._runner = runner
self._worker: Optional[threading.Thread] = None
self._stopped = False
# -- public API --------------------------------------------------
def set_runner(self, runner: Callable[[QueueItem], dict]) -> None:
"""Inject the function that does the actual reorganize work.
Web_server calls this once at startup with a closure over the
injected dependencies (post-process fn, db, etc.)."""
with self._cond:
self._runner = runner
def enqueue(
self,
*,
album_id: str,
album_title: str,
artist_id: Optional[str],
artist_name: str,
source: Optional[str] = None,
) -> dict:
"""Add an album to the queue. Returns a result dict:
{'queued': True, 'queue_id': '...', 'position': N}
{'queued': False, 'reason': 'already_queued', 'queue_id': '...'}
Dedupe: if this album is already in `queued` or `running`
status, returns the existing entry's queue_id rather than
adding a duplicate. ``cancelled`` / ``done`` / ``failed``
items don't block re-enqueue (user retried after a failure).
"""
with self._cond:
for existing in self._items:
if existing.album_id == album_id and existing.status in ('queued', 'running'):
return {
'queued': False,
'reason': 'already_queued',
'queue_id': existing.queue_id,
}
item = QueueItem(
queue_id=uuid.uuid4().hex[:12],
album_id=album_id,
album_title=album_title,
artist_id=artist_id,
artist_name=artist_name,
source=source,
enqueued_at=time.time(),
)
self._items.append(item)
position = sum(1 for i in self._items if i.status == 'queued')
self._ensure_worker()
self._cond.notify_all()
logger.info(
f"[Queue] Enqueued '{album_title}' (album_id={album_id}, "
f"queue_id={item.queue_id}, position={position}, source={source or 'auto'})"
)
return {
'queued': True,
'queue_id': item.queue_id,
'position': position,
}
def enqueue_many(self, items: List[Dict[str, Any]]) -> Dict[str, int]:
"""Bulk-enqueue a list of items. Each ``item`` is a dict with
the same keys :meth:`enqueue` accepts (``album_id``,
``album_title``, ``artist_id``, ``artist_name``, ``source``).
Dedupe still applies per-album-id.
Holds the queue lock for the entire batch so two things hold:
(1) the worker can't start draining mid-batch, and (2) duplicate
album_ids inside the same batch get deduped against each other,
not just against pre-existing items. Without (2), a fast runner
could finish the first copy before the loop reached the second
and both would enqueue.
Returns a tally dict ``{'enqueued': N, 'already_queued': M,
'total': len(items)}`` so the caller can report bulk results
without doing the counting themselves. Used by the bulk
Reorganize-All endpoint and any future maintenance jobs that
enqueue at scale.
"""
enqueued = 0
already = 0
seen_in_batch: set = set()
with self._cond:
# Snapshot album_ids that already block re-enqueue so we don't
# rescan self._items per row.
blocked = {
i.album_id for i in self._items if i.status in ('queued', 'running')
}
for raw in items:
album_id = str(raw['album_id'])
if album_id in blocked or album_id in seen_in_batch:
already += 1
continue
seen_in_batch.add(album_id)
item = QueueItem(
queue_id=uuid.uuid4().hex[:12],
album_id=album_id,
album_title=raw.get('album_title') or 'Unknown Album',
artist_id=str(raw['artist_id']) if raw.get('artist_id') is not None else None,
artist_name=raw.get('artist_name') or 'Unknown Artist',
source=raw.get('source'),
enqueued_at=time.time(),
)
self._items.append(item)
enqueued += 1
logger.info(
f"[Queue] Bulk-enqueued '{item.album_title}' (album_id={album_id}, "
f"queue_id={item.queue_id}, source={item.source or 'auto'})"
)
if enqueued:
self._ensure_worker()
self._cond.notify_all()
return {'enqueued': enqueued, 'already_queued': already, 'total': len(items)}
def cancel(self, queue_id: str) -> dict:
"""Cancel a queued item. The currently-running item cannot be
cancelled (Python threads aren't cleanly killable; post-process
may have spawned ffmpeg)."""
with self._cond:
for item in self._items:
if item.queue_id != queue_id:
continue
if item.status == 'queued':
item.status = 'cancelled'
item.finished_at = time.time()
logger.info(f"[Queue] Cancelled queued item {queue_id} ('{item.album_title}')")
return {'cancelled': True}
if item.status == 'running':
return {'cancelled': False, 'reason': 'running_cant_cancel'}
return {'cancelled': False, 'reason': 'not_active'}
return {'cancelled': False, 'reason': 'not_found'}
def clear_queued(self) -> int:
"""Cancel ALL queued items (running item continues). Returns
the count of items cancelled."""
cancelled = 0
with self._cond:
now = time.time()
for item in self._items:
if item.status == 'queued':
item.status = 'cancelled'
item.finished_at = now
cancelled += 1
if cancelled:
logger.info(f"[Queue] Bulk-cancelled {cancelled} queued items")
return cancelled
def snapshot(self) -> dict:
"""Current queue state for the status panel. Returns:
{
'active': item dict | None,
'queued': [item dicts in FIFO order],
'recent': [item dicts in finish order, newest first, capped],
'totals': {'queued': N, 'running': M, 'done_today': K, ...},
}
"""
with self._cond:
active = next((i for i in self._items if i.status == 'running'), None)
queued = [i for i in self._items if i.status == 'queued']
recent = [i for i in self._items if i.status in ('done', 'failed', 'cancelled')]
recent.sort(key=lambda i: i.finished_at or 0, reverse=True)
recent = recent[:_RECENT_HISTORY_CAP]
return {
'active': active.to_snapshot() if active else None,
'queued': [i.to_snapshot() for i in queued],
'recent': [i.to_snapshot() for i in recent],
'totals': {
'queued': len(queued),
'running': 1 if active else 0,
'done': sum(1 for i in self._items if i.status == 'done'),
'failed': sum(1 for i in self._items if i.status == 'failed'),
'cancelled': sum(1 for i in self._items if i.status == 'cancelled'),
},
}
def stop(self) -> None:
"""Stop the worker (called on server shutdown)."""
with self._cond:
self._stopped = True
self._cond.notify_all()
# -- internals ---------------------------------------------------
def _ensure_worker(self) -> None:
"""Lazy worker start — only spawn the thread when there's
actually something to process. Caller MUST hold ``_cond``."""
if self._worker is not None and self._worker.is_alive():
return
self._worker = threading.Thread(
target=self._run, daemon=True, name='ReorganizeQueueWorker'
)
self._worker.start()
def _claim_next_or_wait(self) -> Optional[QueueItem]:
"""Atomically pick the next queued item AND flip it to 'running'
under a single lock acquisition. If the queue is empty, block
on ``_cond.wait()`` (which releases the lock while sleeping)
and return None when we're notified or timeout. Returning the
item already-marked-running closes the cancel-vs-run race: a
cancel() call now sees status='running' and is rejected."""
with self._cond:
while not self._stopped:
for item in self._items:
if item.status == 'queued':
item.status = 'running'
item.started_at = time.time()
return item
# No queued items — wait for an enqueue or shutdown.
# 60s timeout so a stuck notify (shouldn't happen, but
# defensive) doesn't park the worker forever.
self._cond.wait(timeout=60)
return None
def _run(self) -> None:
"""Worker loop: pull next queued, run it, mark done, repeat.
Idles on `_cond.wait()` when queue is empty."""
logger.info("[Queue] Worker thread started")
while not self._stopped:
item = self._claim_next_or_wait()
if item is None:
# Only happens on shutdown — `_claim_next_or_wait` only
# returns None once `_stopped` is True. Loop back to the
# `while not self._stopped` check, which exits.
continue
logger.info(f"[Queue] Starting '{item.album_title}' (queue_id={item.queue_id})")
try:
runner = self._runner
if runner is None:
raise RuntimeError("Queue has no runner configured — call set_runner() at startup")
summary = runner(item)
except Exception as e:
logger.error(
f"[Queue] Runner raised for '{item.album_title}': {e}",
exc_info=True,
)
with self._cond:
item.status = 'failed'
item.error = str(e)
item.finished_at = time.time()
continue
with self._cond:
item.moved = int(summary.get('moved', 0))
item.skipped = int(summary.get('skipped', 0))
item.failed = int(summary.get('failed', 0))
item.result_status = summary.get('status')
item.result_source = summary.get('source')
errors = summary.get('errors') or []
if errors:
first_err = errors[0] if isinstance(errors[0], dict) else {'error': str(errors[0])}
item.error = first_err.get('error') or first_err.get('reason')
# 'failed' status only when the run produced concrete failed tracks
# OR ended in a non-completed state (no_source_id / no_album / etc).
item.status = 'failed' if (item.failed > 0 or item.result_status not in (None, 'completed')) else 'done'
item.finished_at = time.time()
# Clear live-progress fields — done items don't need them.
item.current_track = None
item.progress_total = 0
item.progress_processed = 0
logger.info(
f"[Queue] Finished '{item.album_title}' — status={item.status}, "
f"moved={item.moved}, skipped={item.skipped}, failed={item.failed}"
)
logger.info("[Queue] Worker thread exiting")
# Called by the runner (or test) to push live progress onto the
# currently-running item. Safe to call from worker thread inside
# reorganize_album's on_progress callback.
def update_active_progress(self, *, queue_id: str, **fields) -> None:
with self._cond:
for item in self._items:
if item.queue_id == queue_id and item.status == 'running':
if 'current_track' in fields:
item.current_track = fields['current_track']
if 'total' in fields:
item.progress_total = int(fields['total'])
if 'processed' in fields:
item.progress_processed = int(fields['processed'])
if 'moved' in fields:
item.moved = int(fields['moved'])
if 'skipped' in fields:
item.skipped = int(fields['skipped'])
if 'failed' in fields:
item.failed = int(fields['failed'])
return
# Module-level singleton accessor ---------------------------------------------
_singleton: Optional[ReorganizeQueue] = None
_singleton_lock = threading.Lock()
def get_queue() -> ReorganizeQueue:
global _singleton
with _singleton_lock:
if _singleton is None:
_singleton = ReorganizeQueue()
return _singleton
def reset_queue_for_tests() -> None:
"""Test-only: drop the singleton so the next get_queue() returns
a fresh instance. Production code never calls this."""
global _singleton
with _singleton_lock:
if _singleton is not None:
_singleton.stop()
_singleton = None

123
core/reorganize_runner.py Normal file
View file

@ -0,0 +1,123 @@
"""Builds the per-item runner closure that the reorganize queue worker
invokes. Lives outside ``web_server`` so the wiring is unit-testable
and the monolith stays small.
The runner ties three subsystems together:
* :func:`core.library_reorganize.reorganize_album` the orchestrator
that copies files to staging, matches them against the metadata
source, and routes each through the post-process pipeline.
* :func:`core.reorganize_queue.get_queue` the queue this runner is
registered with; we forward live progress updates back into the
active queue item so the status panel can show per-track state.
* The dependency callbacks injected by ``web_server`` (DB accessor,
resolve-file-path, post-process function, empty-dir cleanup,
shutdown signal). These are passed in rather than imported so the
module stays testable in isolation.
Config (download path / transfer path) is read **per run**, not at
module load. That way a user changing their download path in settings
takes effect on the next reorganize without needing a server restart.
"""
import os
from typing import Callable, Optional
from utils.logging_config import get_logger
logger = get_logger("reorganize_runner")
def build_runner(
*,
get_database: Callable[[], object],
resolve_file_path_fn: Callable[[Optional[str]], Optional[str]],
post_process_fn: Callable[[str, dict, str], None],
cleanup_empty_directories_fn: Callable[[str, str], None],
is_shutting_down_fn: Callable[[], bool],
get_download_path: Callable[[], str],
get_transfer_path: Callable[[], str],
) -> Callable[[object], dict]:
"""Return the closure the queue worker invokes per item.
Args:
get_database: Returns the live MusicDatabase singleton.
resolve_file_path_fn: Resolves a DB-stored file path to the
actual on-disk path (or ``None`` if missing).
post_process_fn: ``_post_process_matched_download``. Must set
``context['_final_processed_path']`` on success.
cleanup_empty_directories_fn: Called as
``cleanup_empty_directories_fn(transfer_dir, marker_path)``
to prune empty source dirs after a track is moved.
is_shutting_down_fn: Returns True when the server is shutting
down so the orchestrator can abort early.
get_download_path: Resolves the user's configured download
path *at call time* (so config changes apply live).
get_transfer_path: Same, for the transfer path.
Returns:
A callable ``runner(item)`` suitable for
:meth:`core.reorganize_queue.ReorganizeQueue.set_runner`.
"""
from core.library_reorganize import reorganize_album
from core.reorganize_queue import get_queue
def _update_track_path(track_id, new_path):
try:
db = get_database()
with db._get_connection() as conn:
conn.execute(
"UPDATE tracks SET file_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(new_path, str(track_id)),
)
conn.commit()
except Exception as db_err:
logger.warning(f"[Reorganize] DB path update failed for {track_id}: {db_err}")
def runner(item):
# Read config per-run so the user changing their download path
# in Settings takes effect on the next reorganize without a
# server restart.
download_dir = get_download_path()
transfer_dir = get_transfer_path()
staging_root = os.path.join(download_dir, 'ssync_staging')
try:
os.makedirs(staging_root, exist_ok=True)
except OSError as mk_err:
logger.error(f"[Reorganize] Cannot create staging dir {staging_root}: {mk_err}")
return {
'status': 'setup_failed',
'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0,
'errors': [{'error': f'Could not create staging dir: {mk_err}'}],
}
def _cleanup_empty(src_dir):
try:
cleanup_empty_directories_fn(transfer_dir, os.path.join(src_dir, '_'))
except Exception:
pass
def _on_progress(updates):
try:
get_queue().update_active_progress(queue_id=item.queue_id, **updates)
except Exception:
# Progress fan-out failures must never break a run.
pass
return reorganize_album(
album_id=item.album_id,
db=get_database(),
staging_root=staging_root,
resolve_file_path_fn=resolve_file_path_fn,
post_process_fn=post_process_fn,
update_track_path_fn=_update_track_path,
cleanup_empty_dir_fn=_cleanup_empty,
transfer_dir=transfer_dir,
on_progress=_on_progress,
primary_source=item.source,
strict_source=bool(item.source),
stop_check=is_shutting_down_fn,
)
return runner

View file

@ -7,6 +7,7 @@ from core.metadata_service import (
)
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from core.worker_utils import set_album_api_track_count
from utils.logging_config import get_logger
logger = get_logger("repair_job.album_complete")
@ -19,9 +20,10 @@ class AlbumCompletenessJob(RepairJob):
description = 'Checks if all tracks from albums are present'
help_text = (
'Compares the number of tracks you have for each album against the expected total '
'from the active metadata provider first, then other supported sources if needed. '
'Albums where tracks are missing get flagged as findings with details about which '
'tracks are absent.\n\n'
'from your configured metadata sources. Counts cached during normal enrichment are '
'used when available; otherwise the job queries a metadata source directly. Albums '
'where tracks are missing get flagged as findings with details about which tracks '
'are absent.\n\n'
'Useful for catching partial downloads or albums where some tracks failed to download. '
'You can use the Download Missing feature from the album page to fill gaps.\n\n'
'Settings:\n'
@ -53,6 +55,7 @@ class AlbumCompletenessJob(RepairJob):
conn = None
has_itunes = False
has_deezer = False
has_api_track_count = False
try:
conn = context.db._get_connection()
cursor = conn.cursor()
@ -65,17 +68,31 @@ class AlbumCompletenessJob(RepairJob):
has_discogs = 'discogs_id' in columns
has_hydrabase = 'soul_id' in columns
# Build SELECT with available source ID columns
# Detect the `api_track_count` column — older DBs may not have it
# yet (migration runs on app start, but repair-job code mustn't
# assume it's present). When absent, fall back to the pre-column
# behavior: look up expected total via API every scan, don't try
# to persist it.
has_api_track_count = 'api_track_count' in columns
# Build SELECT with available source ID columns.
# NOTE: `al.track_count` is deliberately NOT selected. That
# column holds the OBSERVED track count written by server syncs
# (Plex leafCount, SoulSync standalone len(tracks)) — always
# equal to COUNT(t.id), so it's worthless for completeness.
# The expected total comes from `al.api_track_count` (cached
# from metadata-source enrichment) or a live API lookup.
select_cols = [
('al.id', 'album_id'),
('al.title', 'album_title'),
('ar.name', 'artist_name'),
('al.spotify_album_id', 'spotify_album_id'),
('al.track_count', 'track_count'),
('COUNT(t.id)', 'actual_count'),
('al.thumb_url', 'album_thumb_url'),
('ar.thumb_url', 'artist_thumb_url'),
]
if has_api_track_count:
select_cols.append(('al.api_track_count', 'api_track_count'))
if has_itunes:
select_cols.append(('al.itunes_album_id', 'itunes_album_id'))
if has_deezer:
@ -135,7 +152,6 @@ class AlbumCompletenessJob(RepairJob):
title = row[column_index['album_title']]
artist_name = row[column_index['artist_name']]
spotify_album_id = row[column_index['spotify_album_id']]
db_track_count = row[column_index['track_count']]
actual_count = row[column_index['actual_count']]
album_thumb = row[column_index['album_thumb_url']]
artist_thumb = row[column_index['artist_thumb_url']]
@ -143,6 +159,9 @@ class AlbumCompletenessJob(RepairJob):
deezer_album_id = row[column_index['deezer_album_id']] if 'deezer_album_id' in column_index else None
discogs_album_id = row[column_index['discogs_album_id']] if 'discogs_album_id' in column_index else None
hydrabase_album_id = row[column_index['hydrabase_album_id']] if 'hydrabase_album_id' in column_index else None
# Cached authoritative track count from a prior API lookup (NULL
# on unscanned albums and on DBs predating the column migration).
cached_api_count = row[column_index['api_track_count']] if 'api_track_count' in column_index else None
result.scanned += 1
@ -154,9 +173,6 @@ class AlbumCompletenessJob(RepairJob):
log_type='info'
)
# If we don't know the expected track count, try to get it from an API
expected_total = db_track_count
album_ids = {
'spotify': spotify_album_id or '',
'itunes': itunes_album_id or '',
@ -165,8 +181,20 @@ class AlbumCompletenessJob(RepairJob):
'hydrabase': hydrabase_album_id or '',
}
# Expected total comes from the metadata provider, NOT from
# al.track_count — that column holds the observed count from
# server syncs (Plex leafCount, SoulSync standalone len(tracks))
# which by definition always equals actual_count and made the
# job skip every album. Use the cached api_track_count if a
# prior scan already looked it up; otherwise hit the API and
# persist the answer for next time.
expected_total = cached_api_count
if not expected_total:
expected_total = self._get_expected_total(context, primary_source, album_ids)
# Only persist positive results. Zero/None would keep
# re-triggering the lookup on every scan.
if expected_total and expected_total > 0 and has_api_track_count:
self._save_api_track_count(context, album_id, expected_total)
# Skip singles/EPs based on expected track count (not local count)
if expected_total and expected_total < min_tracks:
@ -251,6 +279,27 @@ class AlbumCompletenessJob(RepairJob):
result.scanned, result.findings_created)
return result
def _save_api_track_count(self, context, album_id, count):
"""Persist a metadata-API track count via the shared worker helper.
Enrichment workers call `set_album_api_track_count` inside their own
`_update_album` transaction. Here we're in the repair job's fallback
path (the album wasn't enriched yet), so we own the connection +
commit ourselves. A cache-write failure must never break the scan,
so all errors are swallowed into the debug log.
"""
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
set_album_api_track_count(cursor, album_id, count)
conn.commit()
except Exception as e:
logger.debug("Failed to cache api_track_count for album %s: %s", album_id, e)
finally:
if conn:
conn.close()
def _get_expected_total(self, context, primary_source, album_ids):
"""Try to get the expected track count from the active metadata provider first."""
for source in get_source_priority(primary_source):

View file

@ -27,28 +27,40 @@ class DiscographyBackfillJob(RepairJob):
description = 'Finds missing albums and tracks for artists in your library'
help_text = (
'Scans each artist in your library, fetches their full discography from '
'the configured metadata source, and adds any tracks you don\'t already '
'own to the wishlist for automatic download.\n\n'
'the configured metadata source, and creates findings for any tracks '
'you don\'t already own. Click Fix on a finding to add it to the '
'wishlist for automatic download.\n\n'
'Respects content filters: live versions, remixes, acoustic versions, '
'instrumentals, and compilations are excluded by default.\n\n'
'Settings:\n'
'- Include Albums/EPs/Singles: Which release types to check\n'
'- Include Live/Remixes/Acoustic/Compilations/Instrumentals: Content type filters\n'
'- Max Artists Per Run: Limit how many artists to process per scan (default: 50)'
'- Max Artists Per Run: Limit how many artists to process per scan (default: 50)\n'
'- Auto Add To Wishlist: When on, missing tracks are pushed to the wishlist during the scan as well as logged as findings\n'
'- Include Albums / EPs / Singles: Which release types to check\n'
'- Include Live / Remixes / Acoustic / Compilations / Instrumentals: Content type filters'
)
icon = 'repair-icon-backfill'
default_enabled = False
default_interval_hours = 168 # Weekly
default_interval_hours = 24 # Daily — the scan is rate-limited at 50 artists per run
# Order matters: the UI renders these in dict-insertion order. Keys beginning
# with `_section_` are rendered as group headers (not settings rows) and are
# stripped from the saved config.
default_settings = {
'_section_core': 'Core',
'max_artists_per_run': 50,
# When on, missing tracks are added to the wishlist during the scan in
# addition to creating findings. When off (default), only findings are
# created; the user reviews them and decides per-track in the repair UI.
'auto_add_to_wishlist': False,
'_section_release_types': 'Release Types',
'include_albums': True,
'include_eps': True,
'include_singles': False,
'include_singles': True,
'_section_content_filters': 'Content Filters',
'include_live': False,
'include_remixes': False,
'include_acoustic': False,
'include_compilations': False,
'include_instrumentals': False,
'max_artists_per_run': 50,
}
auto_fix = False
@ -93,12 +105,15 @@ class DiscographyBackfillJob(RepairJob):
log_type='info',
)
logger.info("[%d/%d] Scanning %s", i + 1, total, artist_name)
try:
missing_count = self._scan_artist(context, artist, settings, primary_source, result)
if missing_count > 0:
logger.info("Found %d missing tracks for %s", missing_count, artist_name)
logger.info("[%d/%d] Found %d missing tracks for %s", i + 1, total, missing_count, artist_name)
else:
logger.info("[%d/%d] %s — no missing tracks", i + 1, total, artist_name)
except Exception as e:
logger.warning("Error scanning discography for %s: %s", artist_name, e)
logger.warning("[%d/%d] Error scanning discography for %s: %s", i + 1, total, artist_name, e)
result.errors += 1
if context.update_progress and (i + 1) % 3 == 0:
@ -118,18 +133,25 @@ class DiscographyBackfillJob(RepairJob):
return result
def _scan_artist(self, context, artist, settings, primary_source, result):
"""Scan one artist's discography and create findings for missing tracks."""
"""Scan one artist's discography and create findings for missing tracks.
Uses the same batched in-memory matching the Library and Artists pages
use (get_candidate_albums_for_artist + get_candidate_tracks_for_albums)
so one artist with a big library doesn't trigger thousands of per-track
SQL queries.
"""
artist_name = artist['name']
result.scanned += 1
# Build source ID map for more accurate lookups
# Build source ID map for more accurate lookups. Primary fallback
# relies on artist-name search when a source ID is missing.
source_ids = {}
if artist.get('spotify_artist_id'):
source_ids['spotify'] = artist['spotify_artist_id']
if artist.get('itunes_artist_id'):
source_ids['itunes'] = artist['itunes_artist_id']
if artist.get('deezer_artist_id'):
source_ids['deezer'] = artist['deezer_artist_id']
if artist.get('deezer_id'):
source_ids['deezer'] = artist['deezer_id']
# Fetch full discography
discography = get_artist_discography(
@ -154,6 +176,24 @@ class DiscographyBackfillJob(RepairJob):
if context.config_manager:
active_server = context.config_manager.get_active_media_server()
auto_add = settings.get('auto_add_to_wishlist', False)
# Pre-fetch the artist's library albums + tracks ONCE per artist for
# fast in-memory matching (same pattern as the Library/Artists page
# completion check). Avoids thousands of per-track SQL calls.
candidate_tracks = None
try:
cand_albums = context.db.get_candidate_albums_for_artist(
artist_name, server_source=active_server
)
if cand_albums:
candidate_tracks = context.db.get_candidate_tracks_for_albums(
[a.id for a in cand_albums]
)
except Exception as exc:
logger.debug("Could not pre-fetch candidates for %s: %s", artist_name, exc)
candidate_tracks = None
# Process albums and singles
for release in albums + singles:
if context.check_stop():
@ -163,7 +203,8 @@ class DiscographyBackfillJob(RepairJob):
release_id = release.get('id', '')
total_tracks = release.get('total_tracks', 0) or 0
album_type = release.get('album_type', 'album')
release_image = release.get('image_url', '')
release_image = release.get('image_url', '') or ''
release_date = release.get('release_date', '') or ''
# Filter by release type
if not self._should_include_release(total_tracks, album_type, settings):
@ -193,6 +234,20 @@ class DiscographyBackfillJob(RepairJob):
if not items:
continue
# Build the full album context once per release so every finding
# created for this release carries the same wishlist-ready dict.
# Matches the shape add_to_wishlist / download pipeline expects.
album_context = {
'id': str(release_id),
'name': release_name,
'album_type': album_type,
'release_date': release_date,
'images': [{'url': release_image}] if release_image else [],
'image_url': release_image,
'artists': [{'name': artist_name}],
'total_tracks': total_tracks,
}
for track_item in items:
if context.check_stop():
return missing_count
@ -201,7 +256,7 @@ class DiscographyBackfillJob(RepairJob):
if not track_name:
continue
# Extract artist name from track
# Extract artist name from track (fall back to the discography artist)
track_artists = track_item.get('artists', [])
if track_artists:
first_artist = track_artists[0]
@ -226,12 +281,15 @@ class DiscographyBackfillJob(RepairJob):
if is_instrumental_version(track_name, release_name):
continue
# Check if track already exists in library
# Check if track already exists in library — batched in-memory
# match when candidates were pre-fetched (fast path). Falls back
# to the legacy SQL path if pre-fetch failed.
db_track, confidence = context.db.check_track_exists(
track_name, track_artist,
confidence_threshold=0.7,
server_source=active_server,
album=release_name,
candidate_tracks=candidate_tracks,
)
if db_track and confidence >= 0.7:
continue # Already owned
@ -244,21 +302,19 @@ class DiscographyBackfillJob(RepairJob):
except Exception:
pass
# Build track data for wishlist
# Build wishlist-ready track data. album is a dict (required by
# add_to_wishlist and by the download pipeline's cover-art
# extraction). Every finding carries enough context that the
# fix handler can hand it straight to the wishlist.
track_data = {
'id': track_item.get('id', f'backfill_{hash(f"{track_artist}_{track_name}") % 100000}'),
'name': track_name,
'artists': [{'name': track_artist}],
'album': {
'name': release_name,
'id': str(release_id),
'images': [{'url': release_image}] if release_image else [],
'album_type': album_type,
'release_date': release.get('release_date', ''),
},
'album': dict(album_context), # copy so per-track mutations don't bleed
'duration_ms': track_item.get('duration_ms', 0),
'track_number': track_item.get('track_number', 0),
'disc_number': track_item.get('disc_number', 1),
'image_url': release_image,
}
# Create finding
@ -286,6 +342,24 @@ class DiscographyBackfillJob(RepairJob):
)
result.findings_created += 1
missing_count += 1
# Auto-wishlist mode: also push to wishlist now. The
# finding still gets created so the user has a log of
# what the backfill picked up.
if auto_add:
try:
context.db.add_to_wishlist(
spotify_track_data=track_data,
failure_reason='Discography backfill — missing from library (auto-added)',
source_type='repair',
source_info={
'job': 'discography_backfill',
'artist': artist_name,
'auto_added': True,
},
)
except Exception as wl_err:
logger.debug("Auto-add to wishlist failed for '%s': %s", track_name, wl_err)
except Exception as e:
logger.debug("Error creating finding for %s: %s", track_name, e)
result.errors += 1
@ -294,21 +368,29 @@ class DiscographyBackfillJob(RepairJob):
@staticmethod
def _should_include_release(total_tracks, album_type, settings):
"""Check if a release should be included based on type settings."""
# Use album_type from metadata source when available
"""Check if a release should be included based on type settings.
Spotify lumps both EPs and true singles under album_type='single', so
only an explicit 'album' / 'ep' / 'compilation' is trusted outright.
Anything else (including 'single' or missing type) falls through to a
track-count disambiguation matching the download pipeline:
- 1-3 tracks -> true single
- 4-6 tracks -> EP
- 7+ tracks -> album
"""
normalized = (album_type or '').lower()
if normalized == 'compilation':
return settings.get('include_compilations', False)
if normalized in ('single',):
return settings.get('include_singles', False)
if normalized in ('ep',):
if normalized == 'album':
return settings.get('include_albums', True)
if normalized == 'ep':
return settings.get('include_eps', True)
# Fall back to track count heuristic
# 'single' or missing: disambiguate by track count
if total_tracks >= 7:
return settings.get('include_albums', True)
elif total_tracks >= 4:
if total_tracks >= 4:
return settings.get('include_eps', True)
elif total_tracks >= 1:
if total_tracks >= 1:
return settings.get('include_singles', False)
return settings.get('include_albums', True)
@ -342,8 +424,8 @@ class DiscographyBackfillJob(RepairJob):
select.append("spotify_artist_id")
if 'itunes_artist_id' in columns:
select.append("itunes_artist_id")
if 'deezer_artist_id' in columns:
select.append("deezer_artist_id")
if 'deezer_id' in columns:
select.append("deezer_id")
cursor.execute(f"""
SELECT {', '.join(select)}

View file

@ -111,7 +111,7 @@ class DuplicateDetectorJob(RepairJob):
if context.report_progress:
context.report_progress(phase=f'Comparing {total} tracks...', total=total)
for bucket_key, bucket_tracks in buckets.items():
for _bucket_key, bucket_tracks in buckets.items():
if context.check_stop():
return result

View file

@ -75,7 +75,14 @@ def _sanitize_context_values(context: dict) -> dict:
def _apply_path_template(template: str, context: dict) -> str:
"""Apply template variables to build a path string."""
clean = _sanitize_context_values(context)
# $cdnum — smart CD label. "CD01"/"CD02" only when multi-disc, empty for
# single-disc (collapses cleanly via the double-dash regex at the end).
_total_discs = int(clean.get('total_discs', 1) or 1)
_disc_number = int(clean.get('disc_number', 1) or 1)
cdnum_value = f"CD{_disc_number:02d}" if _total_discs > 1 else ''
result = template
result = result.replace('${cdnum}', cdnum_value)
result = result.replace('$albumartist', clean.get('albumartist', clean.get('artist', 'Unknown Artist')))
result = result.replace('$albumtype', clean.get('albumtype', 'Album'))
result = result.replace('$playlist', clean.get('playlist_name', ''))
@ -83,6 +90,7 @@ def _apply_path_template(template: str, context: dict) -> str:
result = result.replace('$artist', clean.get('artist', 'Unknown Artist'))
result = result.replace('$album', clean.get('album', 'Unknown Album'))
result = result.replace('$title', clean.get('title', 'Unknown Track'))
result = result.replace('$cdnum', cdnum_value)
result = result.replace('$track', f"{clean.get('track_number', 1):02d}")
result = result.replace('$year', str(clean.get('year', '')))
result = re.sub(r'\s+', ' ', result)
@ -120,6 +128,9 @@ def _build_path_from_template(template: str, context: dict) -> tuple:
filename_base = re.sub(r'\s*\(\s*\)', '', filename_base)
filename_base = re.sub(r'\s*\{\s*\}', '', filename_base)
filename_base = re.sub(r'\s*-\s*$', '', filename_base)
# Leading dash cleanup — lets $cdnum at the start of a filename
# cleanly disappear on single-disc albums (empty-value case).
filename_base = re.sub(r'^\s*-\s*', '', filename_base)
filename_base = re.sub(r'\s+', ' ', filename_base).strip()
sanitized_folders = [_sanitize_filename(p) for p in cleaned_folders]
@ -345,7 +356,7 @@ class LibraryReorganizeJob(RepairJob):
# API fallback: find (artist, album) pairs still missing year, batch-lookup
if needs_year and db_album_years is not None:
missing_pairs = set()
for fpath, tags in file_tags.items():
for _fpath, tags in file_tags.items():
year = tags.get('year', '')
if year:
continue
@ -419,6 +430,9 @@ class LibraryReorganizeJob(RepairJob):
'title': title,
'track_number': track_number,
'disc_number': disc_number,
# total_discs lets $cdnum decide whether to emit "CDxx" or
# stay empty (single-disc albums).
'total_discs': total_discs,
'year': year,
'quality': quality,
'albumtype': 'Album',

View file

@ -11,13 +11,18 @@ logger = get_logger("repair_job.live_commentary_cleaner")
# Keywords that indicate unwanted content types
# Each tuple: (keyword, content_type_label)
#
# Live patterns require clear recording context — the bare `\blive\b` was
# too loose and falsely flagged verb uses like "What We Live For" by
# American Authors or "Live Forever" by Oasis.
_CONTENT_PATTERNS = [
# Live
(r'\blive\b', 'live'),
(r'\blive at\b', 'live'),
(r'\blive from\b', 'live'),
(r'\blive in\b', 'live'),
(r'[\(\[]live\b', 'live'), # (Live), [Live at ...]
(r'-\s*live\b', 'live'), # Song - Live
(r'\blive (at|from|in|on|version|session|recording|performance|album|show|tour|concert|edit|cut|take)\b', 'live'),
(r'\bin concert\b', 'live'),
(r'\bconcert\b', 'live'),
(r'\bon stage\b', 'live'),
(r'\bunplugged\b', 'live'),
# Commentary
(r'\bcommentary\b', 'commentary'),

View file

@ -282,7 +282,7 @@ class TrackNumberRepairJob(RepairJob):
album_name = None
artist_name = None
for fpath, fname, _ in file_track_data:
for fpath, _fname, _ in file_track_data:
if 'spotify' not in source_album_ids or 'itunes' not in source_album_ids:
aid, source = _read_album_id_from_file(fpath)
if aid and source in ('spotify', 'itunes') and source not in source_album_ids:

View file

@ -434,7 +434,7 @@ class RepairWorker:
forced_job = self._force_run_queue.pop(0)
if forced_job:
self._run_job(forced_job)
self._run_job(forced_job, forced=True)
if self._sleep_or_stop(2):
break
continue
@ -483,7 +483,7 @@ class RepairWorker:
best_job_id = None
best_staleness = -1
for job_id, job in self._jobs.items():
for job_id, _job in self._jobs.items():
config = self.get_job_config(job_id)
if not config['enabled']:
continue
@ -518,8 +518,13 @@ class RepairWorker:
return best_job_id
def _run_job(self, job_id: str):
"""Execute a single job and record the run."""
def _run_job(self, job_id: str, forced: bool = False):
"""Execute a single job and record the run.
When forced=True, the user explicitly triggered this via "Run Now"
the job runs even if the master worker is paused, and wait_if_paused()
does not block.
"""
job = self._jobs.get(job_id)
if not job:
return
@ -568,7 +573,7 @@ class RepairWorker:
create_finding=self._create_finding,
should_stop=lambda: self.should_stop,
stop_event=self._stop_event,
is_paused=lambda: not self.enabled,
is_paused=(lambda: False) if forced else (lambda: not self.enabled),
update_progress=self._update_progress,
report_progress=_report_progress,
)
@ -1047,7 +1052,7 @@ class RepairWorker:
self._cleanup_empty_parents(resolved)
return {'success': True, 'action': 'moved_to_staging',
'message': f'Moved to staging folder for import'}
'message': 'Moved to staging folder for import'}
elif fix_action == 'delete':
os.remove(resolved)
@ -2691,7 +2696,8 @@ class RepairWorker:
'single_album_redundant', 'mbid_mismatch',
'album_tag_inconsistency',
'incomplete_album', 'path_mismatch',
'missing_lossy_copy')
'missing_lossy_copy',
'missing_discography_track', 'acoustid_mismatch')
placeholders = ','.join(['?'] * len(fixable_types))
where_parts = [f"finding_type IN ({placeholders})", "status = 'pending'"]
params = list(fixable_types)

View file

@ -68,10 +68,10 @@ def analyze_track(file_path: str) -> Tuple[float, float]:
text=True,
timeout=120
)
except FileNotFoundError:
raise FileNotFoundError("ffmpeg not found on PATH")
except subprocess.TimeoutExpired:
raise RuntimeError("ffmpeg timed out analyzing track")
except FileNotFoundError as exc:
raise FileNotFoundError("ffmpeg not found on PATH") from exc
except subprocess.TimeoutExpired as exc:
raise RuntimeError("ffmpeg timed out analyzing track") from exc
stderr = result.stderr

View file

@ -380,8 +380,8 @@ class SeasonalDiscoveryService:
cursor = conn.cursor()
# Build keyword search query
keyword_conditions = " OR ".join([f"LOWER(track_name) LIKE ?" for _ in keywords])
keyword_conditions += " OR " + " OR ".join([f"LOWER(album_name) LIKE ?" for _ in keywords])
keyword_conditions = " OR ".join(["LOWER(track_name) LIKE ?" for _ in keywords])
keyword_conditions += " OR " + " OR ".join(["LOWER(album_name) LIKE ?" for _ in keywords])
keyword_params = [f"%{kw}%" for kw in keywords] + [f"%{kw}%" for kw in keywords]
@ -840,7 +840,7 @@ class SeasonalDiscoveryService:
tracks_by_artist[artist].append(track)
balanced_tracks = []
for artist, artist_tracks in tracks_by_artist.items():
for _artist, artist_tracks in tracks_by_artist.items():
# Sort by popularity and take top 3
sorted_tracks = sorted(artist_tracks, key=lambda t: t.get('popularity', 50), reverse=True)
balanced_tracks.extend(sorted_tracks[:3])

View file

@ -369,7 +369,7 @@ class SoulseekClient:
logger.debug(f"API request returned 404 (Not Found) for {url}")
elif response.status == 401:
if not getattr(self, '_last_401_logged', False):
logger.warning(f"slskd authentication failed (401) — check API key. Suppressing further 401 errors.")
logger.warning("slskd authentication failed (401) — check API key. Suppressing further 401 errors.")
self._last_401_logged = True
logger.debug(f"API request 401 for {url}")
else:
@ -823,7 +823,7 @@ class SoulseekClient:
logger.debug(f"No ID in response, using filename as fallback: {response}")
return filename
else:
logger.debug(f"Web interface endpoint returned no response")
logger.debug("Web interface endpoint returned no response")
except Exception as e:
logger.debug(f"Web interface endpoint failed: {e}")
@ -1044,7 +1044,7 @@ class SoulseekClient:
# Fallback: if download_id looks like a filename (contains path separators),
# list all transfers, find by filename, and cancel with the real transfer ID
if '\\' in download_id or '/' in download_id:
logger.debug(f"Download ID looks like a filename, trying filename-based lookup fallback")
logger.debug("Download ID looks like a filename, trying filename-based lookup fallback")
try:
downloads = await self.get_all_downloads()
target_basename = os.path.basename(download_id.replace('\\', '/'))
@ -1056,7 +1056,7 @@ class SoulseekClient:
logger.debug(f"Found matching transfer with real ID, trying: {fallback_endpoint}")
response = await self._make_request('DELETE', fallback_endpoint)
if response is not None:
logger.info(f"Successfully cancelled download via filename fallback")
logger.info("Successfully cancelled download via filename fallback")
return True
except Exception as fallback_error:
logger.debug(f"Filename fallback failed: {fallback_error}")
@ -1646,10 +1646,10 @@ class SoulseekClient:
logger.info(f"Quality Filter: Bit depth 24-bit preference — {len(hi_res)}/{len(quality_buckets['flac'])} FLAC candidates are hi-res")
quality_buckets['flac'] = hi_res
elif not bit_depth_fallback:
logger.info(f"Quality Filter: No 24-bit FLAC found and fallback disabled — rejecting all FLAC")
logger.info("Quality Filter: No 24-bit FLAC found and fallback disabled — rejecting all FLAC")
quality_buckets['flac'] = []
else:
logger.info(f"Quality Filter: No 24-bit FLAC found — falling back to 16-bit")
logger.info("Quality Filter: No 24-bit FLAC found — falling back to 16-bit")
elif bit_depth_pref == '16':
lo_res = [c for c in quality_buckets['flac']
@ -1658,10 +1658,10 @@ class SoulseekClient:
logger.info(f"Quality Filter: Bit depth 16-bit preference — {len(lo_res)}/{len(quality_buckets['flac'])} FLAC candidates are standard")
quality_buckets['flac'] = lo_res
elif not bit_depth_fallback:
logger.info(f"Quality Filter: No 16-bit FLAC found and fallback disabled — rejecting all FLAC")
logger.info("Quality Filter: No 16-bit FLAC found and fallback disabled — rejecting all FLAC")
quality_buckets['flac'] = []
else:
logger.info(f"Quality Filter: No 16-bit FLAC found — falling back to 24-bit")
logger.info("Quality Filter: No 16-bit FLAC found — falling back to 24-bit")
# Debug logging
for quality, bucket in quality_buckets.items():
@ -1688,16 +1688,16 @@ class SoulseekClient:
# If no enabled qualities matched, check if fallback is enabled
if profile.get('fallback_enabled', True):
logger.warning(f"Quality Filter: No enabled qualities matched, falling back to density-filtered candidates")
logger.warning("Quality Filter: No enabled qualities matched, falling back to density-filtered candidates")
if density_filtered_all:
density_filtered_all.sort(key=lambda x: (x.quality_score, self._calculate_effective_kbps(x.size, x.duration) or 0), reverse=True)
logger.info(f"Quality Filter: Returning {len(density_filtered_all)} fallback candidates (bitrate-filtered, any quality)")
return density_filtered_all
else:
logger.warning(f"Quality Filter: All candidates failed bitrate checks, returning empty (respecting constraints)")
logger.warning("Quality Filter: All candidates failed bitrate checks, returning empty (respecting constraints)")
return []
else:
logger.warning(f"Quality Filter: No enabled qualities matched and fallback is disabled, returning empty")
logger.warning("Quality Filter: No enabled qualities matched and fallback is disabled, returning empty")
return []
async def get_session_info(self) -> Optional[Dict[str, Any]]:

View file

@ -260,7 +260,7 @@ class SoulSyncClient:
file_entries = [] # (file_path, tags)
scanned = 0
for root, dirs, files in os.walk(self._transfer_path):
for root, _dirs, files in os.walk(self._transfer_path):
for filename in files:
ext = os.path.splitext(filename)[1].lower()
if ext not in AUDIO_EXTENSIONS:

View file

@ -63,9 +63,15 @@ _rate_limit_first_hit = 0 # Timestamp of the first hit in the current escalat
_LONG_RATE_LIMIT_THRESHOLD = 60 # seconds
# After a ban expires, wait this long before making any auth probe calls.
# This prevents the "immediate re-probe → re-ban" cycle where Spotify's server-side
# cooldown outlasts the Retry-After value they sent us.
_POST_BAN_COOLDOWN = 300 # 5 minutes
# This prevents the "immediate re-probe → re-ban" cycle where Spotify's
# server-side cooldown outlasts the Retry-After (or our default ban
# duration) we used. A user who'd just sat through a 4-hour MAX_RETRIES
# ban had it expire, hit our 5-minute cooldown, made a single
# get_artist_albums call 32 seconds after the cooldown ended, and got
# slapped with another 4-hour ban — the post-ban cooldown was too short
# for Spotify's server to forget the previous offense. 30 minutes is a
# better empirical floor; can be revisited if reports persist.
_POST_BAN_COOLDOWN = 1800 # 30 minutes
# Escalation: if we get rate limited again within this window, increase ban duration
_ESCALATION_WINDOW = 3600 # 1 hour — if re-limited within this, escalate
@ -318,7 +324,7 @@ def rate_limited(func):
# If Retry-After is long, activate global ban instead of sleeping
if delay and delay > _LONG_RATE_LIMIT_THRESHOLD:
_set_global_rate_limit(delay, func.__name__, has_real_header=True)
raise SpotifyRateLimitError(delay, func.__name__)
raise SpotifyRateLimitError(delay, func.__name__) from e
if delay:
delay = delay + 1

View file

@ -8,7 +8,7 @@ from datetime import datetime, date, timedelta
from utils.logging_config import get_logger
from database.music_database import MusicDatabase
from core.spotify_client import SpotifyClient, SpotifyRateLimitError
from core.worker_utils import interruptible_sleep
from core.worker_utils import interruptible_sleep, set_album_api_track_count
logger = get_logger("spotify_worker")
@ -782,6 +782,10 @@ class SpotifyWorker:
WHERE id = ? AND (year IS NULL OR year = '' OR year = '0')
""", (year, album_id))
# Cache the authoritative expected track count for the Album
# Completeness repair job (see set_album_api_track_count docstring).
set_album_api_track_count(cursor, album_id, getattr(album_obj, 'total_tracks', 0))
conn.commit()
except Exception as e:
logger.error(f"Error updating album #{album_id} with Spotify data: {e}")

View file

@ -800,7 +800,7 @@ class TidalClient:
)
if response.status_code == 429:
raise Exception(f"Rate limited (429) on search_artist")
raise Exception("Rate limited (429) on search_artist")
if response.status_code == 200:
data = response.json()
# JSON:API format: included artists in 'artists' or nested in relationships
@ -859,7 +859,7 @@ class TidalClient:
)
if response.status_code == 429:
raise Exception(f"Rate limited (429) on search_album")
raise Exception("Rate limited (429) on search_album")
if response.status_code == 200:
data = response.json()
items = []
@ -925,7 +925,7 @@ class TidalClient:
)
if response.status_code == 429:
raise Exception(f"Rate limited (429) on search_track")
raise Exception("Rate limited (429) on search_track")
if response.status_code == 200:
data = response.json()
items = []
@ -984,7 +984,7 @@ class TidalClient:
)
if response.status_code == 429:
raise Exception(f"Rate limited (429) on get_artist")
raise Exception("Rate limited (429) on get_artist")
if response.status_code == 200:
data = response.json()
# Handle JSON:API format
@ -1018,7 +1018,7 @@ class TidalClient:
)
if response.status_code == 429:
raise Exception(f"Rate limited (429) on get_album")
raise Exception("Rate limited (429) on get_album")
if response.status_code == 200:
data = response.json()
if 'data' in data and 'attributes' in data.get('data', {}):
@ -1051,7 +1051,7 @@ class TidalClient:
)
if response.status_code == 429:
raise Exception(f"Rate limited (429) on get_track")
raise Exception("Rate limited (429) on get_track")
if response.status_code == 200:
data = response.json()
if 'data' in data and 'attributes' in data.get('data', {}):
@ -1132,7 +1132,7 @@ class TidalClient:
break
if not tracks_page or not tracks_page.get("data"):
logger.info(f"No more tracks found, stopping pagination")
logger.info("No more tracks found, stopping pagination")
break
# Reset failure counter on success

View file

@ -76,6 +76,86 @@ if tidalapi is not None:
QUALITY_MAP['hires']['tidal_quality'] = tidalapi.Quality.hi_res_lossless
# Ordering of Tidal's audioQuality values, worst to best. Used to accept
# tier upgrades (Tidal serving higher than the user asked) while still
# rejecting downgrades. Values are the strings tidalapi's `Quality` enum
# exposes — and the strings Tidal's API returns in the `audioQuality`
# field. `HI_RES` (legacy MQA) isn't in the modern `Quality` enum but
# may still come back for old catalog tracks; we rank it below
# `HI_RES_LOSSLESS` so it's treated as a downgrade when the user asked
# for true HiRes lossless.
_QUALITY_RANK = {
'LOW': 1,
'HIGH': 2,
'LOSSLESS': 3,
'HI_RES': 4,
'HI_RES_LOSSLESS': 5,
}
def _verify_stream_tier(stream, q_info: dict, q_key: str) -> Tuple[bool, Optional[str]]:
"""Return ``(True, None)`` when the tier Tidal actually served is
acceptable (same as requested, or a higher tier), ``(False, reason)``
when Tidal silently downgraded.
Tidal's API degrades quality without raising: ask for HI_RES_LOSSLESS
on a track that's only in LOW_320K and you get LOW_320K back with no
error. The downloader used to accept that and write the resulting
AAC file, which defeated "HiRes only" with no fallback and made the
worker's fallback chain ineffective (every tier "succeeded" at the
first one that returned anything).
We accept upgrades because Tidal occasionally serves a higher tier
than requested on tracks flagged as such in its catalog rejecting
a higher quality than asked for would be user-hostile.
Defensive paths:
- No ``audio_quality`` on the stream (older tidalapi builds): pass
through, let the pre-existing codec / file-size guards decide.
- QUALITY_MAP entry without ``tidal_quality`` (tidalapi wasn't
importable at module load): pass through for the same reason.
- Unrecognized served quality value (new Tidal tier we haven't
mapped yet): reject, surfacing a "can't verify" reason so the
next tier gets a chance or the final diagnostic names the
unknown value.
"""
served = getattr(stream, 'audio_quality', None)
expected = q_info.get('tidal_quality')
if served is None or expected is None:
return True, None
# Both sides may be enum instances (str subclass) or plain strings;
# coerce to str to compare values only.
served_str = str(served)
expected_str = str(expected)
if served_str == expected_str:
return True, None
served_rank = _QUALITY_RANK.get(served_str)
expected_rank = _QUALITY_RANK.get(expected_str)
if expected_rank is None:
# Shouldn't happen — every entry in QUALITY_MAP resolves to a
# known tier. If it does, don't reject valid downloads.
return True, None
if served_rank is None:
return False, (
f"{q_key}: Tidal returned unrecognized audioQuality "
f"'{served_str}' — can't verify the tier matches '{expected_str}'"
)
if served_rank >= expected_rank:
return True, None
return False, (
f"{q_key}: Tidal served '{served_str}' instead of "
f"'{expected_str}' — account tier, track licensing, "
f"or region doesn't permit {q_key} for this track"
)
class TidalDownloadClient:
"""
Tidal download client using tidalapi.
@ -185,8 +265,16 @@ class TidalDownloadClient:
login, future = self.session.login_oauth()
self._device_auth_future = future
# tidalapi returns `verification_uri_complete` as a schemeless
# string like `link.tidal.com/ABCDE`. Passing that straight to
# an <a href> makes the browser treat it as a relative URL and
# route it back to the SoulSync origin, so normalize to a
# full https:// URL here.
raw_uri = login.verification_uri_complete or f"link.tidal.com/{login.user_code}"
if not raw_uri.startswith(('http://', 'https://')):
raw_uri = f"https://{raw_uri}"
self._device_auth_link = {
'verification_uri': login.verification_uri_complete or f"https://link.tidal.com/{login.user_code}",
'verification_uri': raw_uri,
'user_code': login.user_code,
}
logger.info(f"Tidal device auth started — code: {login.user_code}")
@ -244,6 +332,98 @@ class TidalDownloadClient:
logger.error(f"Tidal connection check failed: {e}")
return False
# Words that distinguish a specific audio variant from the original track.
# If any of these appear in a query, the fallback-retry results must also
# contain them — otherwise we'd silently downgrade a "(Live)" or
# "(Acoustic)" search to the studio version when shortened queries match
# too broadly.
_QUALIFIER_KEYWORDS = frozenset({
'remix', 'mix', 'edit', 'version', 'dub', 'rmx', 'vip', 'cut',
'rework', 'bootleg', 'flip',
'live', 'concert', 'unplugged', 'acoustic', 'session',
'instrumental', 'karaoke', 'demo', 'bonus',
'extended', 'radio',
})
@classmethod
def _extract_qualifiers(cls, query: str) -> List[str]:
"""Return the qualifier keywords that appear as whole words in the
query (case-insensitive). Word-boundary match prevents false hits like
"edit" matching "edition" or "mix" matching "remix"."""
if not query:
return []
found = []
q_lower = query.lower()
for kw in cls._QUALIFIER_KEYWORDS:
if re.search(r'\b' + re.escape(kw) + r'\b', q_lower):
found.append(kw)
return found
@staticmethod
def _track_name_contains_qualifiers(track_name: str, qualifiers: List[str]) -> bool:
"""True iff the track name contains every qualifier as a whole word."""
if not qualifiers:
return True
if not track_name:
return False
name_lower = track_name.lower()
for kw in qualifiers:
if not re.search(r'\b' + re.escape(kw) + r'\b', name_lower):
return False
return True
@staticmethod
def _generate_shortened_queries(original: str) -> List[str]:
"""Generate progressively-shorter variants of a search query.
Tidal's search engine chokes on long queries with lots of qualifiers
(remix credits, edit labels, bonus-disc markers). When the original
returns 0 results, we retry with shortened variants in order of
conservativeness the first variant that returns results wins.
Variants are returned in priority order. Dedupes against the original
and against previously-added variants so we never issue duplicate
requests.
"""
variants: List[str] = []
seen = {original.strip().lower()}
def _add(candidate: str) -> None:
candidate = candidate.strip()
if candidate and candidate.lower() not in seen:
variants.append(candidate)
seen.add(candidate.lower())
# 1. Strip a trailing parenthetical/bracketed suffix.
# "Song (Radio Edit)" → "Song"
_add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]\s*$', '', original))
# 2. Strip ALL parentheticals/brackets (mid-string too).
# "Song (feat X) [Remix]" → "Song"
_add(re.sub(r'\s*[\(\[][^\)\]]*[\)\]]', ' ', original))
tokens = original.split()
# 3. Drop the last token — covers trailing 1-word modifiers
# (e.g. "… Remix", "… Extended").
if len(tokens) >= 3:
_add(' '.join(tokens[:-1]))
# 4. Drop the last two tokens.
if len(tokens) >= 4:
_add(' '.join(tokens[:-2]))
# 5. Drop the last three tokens — covers "fred v remix" style
# 3-word modifiers common in remix/bonus track names.
if len(tokens) >= 5:
_add(' '.join(tokens[:-3]))
# 6. Aggressive: keep roughly the first half (rounded up).
if len(tokens) >= 7:
_add(' '.join(tokens[:len(tokens) // 2 + 1]))
return variants
async def search(self, query: str, timeout: int = None, progress_callback=None) -> Tuple[List[TrackResult], List[AlbumResult]]:
"""
Search Tidal for tracks (async, Soulseek-compatible interface).
@ -255,21 +435,117 @@ class TidalDownloadClient:
logger.warning("Tidal not available for search (not authenticated)")
return ([], [])
# Defensive guard — None/empty query would blow up the shortener's
# .strip() call. Match the original behaviour (log + empty tuple).
if not query or not isinstance(query, str):
logger.warning(f"Invalid Tidal search query: {query!r}")
return ([], [])
logger.info(f"Searching Tidal for: {query}")
# Outer try/except preserves the original contract: any unexpected
# error returns ([], []) so the caller can fall back to other sources
# instead of crashing. Traceback is logged once, not per-attempt.
try:
# Build the retry ladder: original query, then progressively-shortened
# variants. Capped at 5 total requests to avoid hammering Tidal on
# genuinely-missing tracks, while still allowing enough variants to
# cover multi-word trailing modifiers like remix credits.
queries_to_try = [query] + self._generate_shortened_queries(query)
queries_to_try = queries_to_try[:5]
# Qualifier-aware safety net: if the original query contains variant
# keywords (Live, Remix, Acoustic, Extended, etc.), fallback results
# MUST still contain those qualifiers in their track names. Otherwise
# a shortened query could silently downgrade "Song (Live)" to the
# studio "Song" and the caller would download the wrong variant.
required_qualifiers = self._extract_qualifiers(query)
tidal_tracks: list = []
successful_query: Optional[str] = None
last_error: Optional[Exception] = None
# Tracks whether ANY fallback attempt returned broader matches that
# got rejected by the qualifier filter — used to give an accurate
# "no qualifier-matching variant" log message at the end instead of
# a generic "0 results".
any_fallback_filtered_out = False
loop = asyncio.get_event_loop()
for attempt_idx, attempt_query in enumerate(queries_to_try):
try:
q_copy = attempt_query
def _search():
results = self.session.search(query, models=[tidalapi.media.Track], limit=50)
return results.get('tracks', []) if isinstance(results, dict) else []
def _search(q=q_copy):
results = self.session.search(q, models=[tidalapi.media.Track], limit=50)
return results.get('tracks', []) if isinstance(results, dict) else []
tidal_tracks = await loop.run_in_executor(None, _search)
found = await loop.run_in_executor(None, _search)
if found:
# Fallback attempts get qualifier-filtered. We trust the
# original query to return only appropriate matches, but
# shortened queries are more permissive and can return
# wrong-variant tracks (e.g. studio when Live was asked
# for). Drop any result whose title doesn't carry all
# original qualifier words.
is_fallback = attempt_idx > 0
if is_fallback and required_qualifiers:
filtered = [
t for t in found
if self._track_name_contains_qualifiers(getattr(t, 'name', ''), required_qualifiers)
]
if filtered:
tidal_tracks = filtered
successful_query = attempt_query
logger.info(
f"Tidal fallback kept {len(filtered)}/{len(found)} tracks "
f"after qualifier filter {required_qualifiers} for '{attempt_query}'"
)
break
else:
any_fallback_filtered_out = True
logger.debug(
f"Tidal fallback '{attempt_query}' returned {len(found)} tracks "
f"but none matched original qualifiers {required_qualifiers}"
f"trying next variant"
)
if attempt_idx < len(queries_to_try) - 1:
await asyncio.sleep(0.1)
continue
else:
tidal_tracks = found
successful_query = attempt_query
break
if attempt_idx < len(queries_to_try) - 1:
logger.debug(f"Tidal returned 0 results for '{attempt_query}' — trying shortened variant")
# Small pause so we're not hammering Tidal with rapid retries
await asyncio.sleep(0.1)
except Exception as e:
last_error = e
logger.debug(f"Tidal search attempt {attempt_idx + 1} failed: {e}")
if not tidal_tracks:
logger.warning(f"No Tidal results for: {query}")
if last_error is not None:
import traceback
tb_str = ''.join(traceback.format_exception(
type(last_error), last_error, last_error.__traceback__
))
logger.error(
f"Tidal search failed after {len(queries_to_try)} attempts: {last_error}\n{tb_str}"
)
elif any_fallback_filtered_out:
logger.warning(
f"No Tidal results for '{query}' — fallbacks found broader matches but "
f"none preserved required qualifiers {required_qualifiers}"
)
else:
logger.warning(f"No Tidal results for: {query}")
return ([], [])
if successful_query and successful_query != query:
logger.info(f"Tidal fallback query succeeded: '{successful_query}' (original: '{query}')")
# Get configured quality for display
quality_key = config_manager.get('tidal_download.quality', 'lossless')
quality_info = QUALITY_MAP.get(quality_key, QUALITY_MAP['lossless'])
@ -286,7 +562,11 @@ class TidalDownloadClient:
return (track_results, [])
except Exception as e:
logger.error(f"Tidal search failed: {e}")
# Unhandled error in the retry orchestration itself (not in an
# individual attempt, which is already caught above). Preserves
# the original contract of returning ([], []) on any failure so
# the caller's fallback chain isn't broken.
logger.error(f"Tidal search orchestration failed: {e}")
import traceback
traceback.print_exc()
return ([], [])
@ -462,6 +742,13 @@ class TidalDownloadClient:
logger.warning(f"Quality {q_key} returned no stream, trying next")
quality_error_reasons.append(reason)
continue
ok, reason = _verify_stream_tier(stream, q_info, q_key)
if not ok:
logger.warning(reason)
quality_error_reasons.append(reason)
continue
logger.info(f"Got Tidal stream at quality: {q_key}")
except Exception as e:
reason = f"{q_key}: {type(e).__name__}: {e}"
@ -480,7 +767,8 @@ class TidalDownloadClient:
download_url = urls[0]
# Determine file extension from manifest
# Determine file extension from manifest codec (HiRes FLAC
# can arrive wrapped in MP4 — unwrapped at Step 4).
codec = manifest.get_codecs()
if codec and 'flac' in codec.lower():
extension = 'flac'
@ -491,20 +779,6 @@ class TidalDownloadClient:
else:
extension = q_info.get('extension', 'flac')
# Verify quality wasn't silently downgraded: if HiRes was requested but the
# codec/manifest points to standard FLAC, log a clear warning.
if q_key == 'hires' and codec:
codec_lower = codec.lower()
if 'flac' in codec_lower or 'alac' in codec_lower:
# HiRes should be 24-bit — we can't confirm bit-depth from the codec
# string alone, but we log the received codec so users can diagnose.
logger.info(f"HiRes stream codec: {codec} (verify file bit-depth after download)")
elif 'mp4a' in codec_lower or 'aac' in codec_lower:
logger.warning(
f"HiRes requested but received AAC stream (codec: {codec}) — "
f"account may not have HiRes subscription or track isn't available in HiRes"
)
# Build output filename
safe_name = re.sub(r'[<>:"/\\|?*]', '_', display_name)
out_filename = f"{safe_name}.{extension}"
@ -675,7 +949,7 @@ class TidalDownloadClient:
download_statuses = []
with self._download_lock:
for download_id, info in self.active_downloads.items():
for _download_id, info in self.active_downloads.items():
status = DownloadStatus(
id=info['id'],
filename=info['filename'],

View file

@ -85,6 +85,11 @@ def is_live_version(track_name: str, album_name: str = "") -> bool:
"""
Detect if a track or album is a live version.
Uses patterns that require a clear live-recording context (parenthesized
"(Live)", dash-suffixed "- Live", or "live" with a location/format
modifier). The bare `\\blive\\b` pattern was too loose it falsely
flagged verb uses like "What We Live For" or "Live Forever".
Args:
track_name: Track name to check
album_name: Album name to check (optional)
@ -98,17 +103,18 @@ def is_live_version(track_name: str, album_name: str = "") -> bool:
# Combine track and album names for comprehensive checking
text_to_check = f"{track_name} {album_name}".lower()
# Live version patterns
# Live-recording patterns — each one requires clear context so verbs
# like "What We Live For" / "Live Forever" / "Living on a Prayer" don't
# get swept up.
live_patterns = [
r'\blive\b', # (Live), Live at, etc.
r'\blive at\b', # Live at Madison Square Garden
r'\bconcert\b', # Concert, Live Concert
r'[\(\[]live\b', # (Live), (Live at ...), [Live Version]
r'-\s*live\b', # Song - Live, Song - Live at ...
# "live" followed by a recording-context word
r'\blive (at|from|in|on|version|session|recording|performance|album|show|tour|concert|edit|cut|take)\b',
r'\bin concert\b', # In Concert
r'\bunplugged\b', # MTV Unplugged (usually live)
r'\blive session\b', # Live Session
r'\blive from\b', # Live from...
r'\blive recording\b', # Live Recording
r'\bconcert\b', # Concert (album name)
r'\bon stage\b', # On Stage
r'\bunplugged\b', # MTV Unplugged
]
for pattern in live_patterns:
@ -939,9 +945,8 @@ class WatchlistScanner:
if watchlist_artists is None:
watchlist_artists = self.database.get_watchlist_artists(profile_id=profile_id)
if apply_global_overrides:
self._apply_global_watchlist_overrides(watchlist_artists)
# scan_watchlist_artists applies overrides itself now — pass the flag
# through instead of applying here (prevents double-application).
return self.scan_watchlist_artists(
watchlist_artists,
profile_id=profile_id,
@ -950,6 +955,7 @@ class WatchlistScanner:
cancel_check=cancel_check,
artist_index_offset=artist_index_offset,
total_artists_override=total_artists_override,
apply_global_overrides=apply_global_overrides,
)
def scan_watchlist_artists(
@ -962,8 +968,19 @@ class WatchlistScanner:
cancel_check: Optional[Callable[[], bool]] = None,
artist_index_offset: int = 0,
total_artists_override: Optional[int] = None,
apply_global_overrides: bool = True,
) -> List[ScanResult]:
"""Scan a list of watchlist artists using the shared web watchlist scan flow."""
"""Scan a list of watchlist artists using the shared web watchlist scan flow.
apply_global_overrides: when True (default), per-artist include_*
flags are overwritten with the global values if
`watchlist.global_override_enabled` is set. This matches the
behaviour of `scan_watchlist_profile` so every entry point respects
the user's Global Override toggle.
"""
if apply_global_overrides:
self._apply_global_watchlist_overrides(watchlist_artists)
scan_results: List[ScanResult] = []
if not watchlist_artists:
if scan_state is not None:
@ -1117,7 +1134,7 @@ class WatchlistScanner:
albums = discography_result.albums
source_artist_id = discography_result.artist_id
artist_image_url = discography_result.image_url or self.get_artist_image_url(artist) or ''
album_fetcher = lambda album_id, album_name='': self._get_album_data_for_source(source, album_id, album_name)
album_fetcher = lambda album_id, album_name='', source=source: self._get_album_data_for_source(source, album_id, album_name)
absolute_index = artist_index_offset + i + 1
if scan_state is not None:
@ -1379,7 +1396,7 @@ class WatchlistScanner:
rescan_cutoff = self._get_rescan_cutoff()
if rescan_cutoff == 'all':
if self._rescan_cutoff_log_marker != 'all':
logger.info(f"Lookback period changed to 'all' — returning full discography")
logger.info("Lookback period changed to 'all' — returning full discography")
self._rescan_cutoff_log_marker = 'all'
cutoff_timestamp = None
needs_full_discog = True
@ -1605,7 +1622,7 @@ class WatchlistScanner:
if hasattr(self, '_metadata_service') and self._metadata_service:
results = self._metadata_service.itunes.search_artists(artist_name, limit=5)
else:
logger.warning(f"Cannot match to iTunes - MetadataService not available")
logger.warning("Cannot match to iTunes - MetadataService not available")
return None
return self._best_artist_match(results, artist_name)
@ -2856,11 +2873,11 @@ class WatchlistScanner:
cache_callback = None
if source == 'spotify':
cache_callback = lambda found_id, watchlist_id=artist.id: self._cache_watchlist_artist_source_id(artist, 'spotify', found_id)
cache_callback = lambda found_id, watchlist_id=artist.id, artist=artist: self._cache_watchlist_artist_source_id(artist, 'spotify', found_id)
elif source == 'itunes':
cache_callback = lambda found_id, watchlist_id=artist.id: self._cache_watchlist_artist_source_id(artist, 'itunes', found_id)
cache_callback = lambda found_id, watchlist_id=artist.id, artist=artist: self._cache_watchlist_artist_source_id(artist, 'itunes', found_id)
elif source == 'deezer':
cache_callback = lambda found_id, watchlist_id=artist.id: self._cache_watchlist_artist_source_id(artist, 'deezer', found_id)
cache_callback = lambda found_id, watchlist_id=artist.id, artist=artist: self._cache_watchlist_artist_source_id(artist, 'deezer', found_id)
artist_id = self._resolve_artist_id_for_source(
source,
@ -3100,11 +3117,11 @@ class WatchlistScanner:
stored_id = getattr(artist, source_attr, None) if source_attr else None
cache_callback = None
if source == 'spotify':
cache_callback = lambda found_id, watchlist_id=artist.id: self._cache_watchlist_artist_source_id(artist, 'spotify', found_id)
cache_callback = lambda found_id, watchlist_id=artist.id, artist=artist: self._cache_watchlist_artist_source_id(artist, 'spotify', found_id)
elif source == 'itunes':
cache_callback = lambda found_id, watchlist_id=artist.id: self._cache_watchlist_artist_source_id(artist, 'itunes', found_id)
cache_callback = lambda found_id, watchlist_id=artist.id, artist=artist: self._cache_watchlist_artist_source_id(artist, 'itunes', found_id)
elif source == 'deezer':
cache_callback = lambda found_id, watchlist_id=artist.id: self._cache_watchlist_artist_source_id(artist, 'deezer', found_id)
cache_callback = lambda found_id, watchlist_id=artist.id, artist=artist: self._cache_watchlist_artist_source_id(artist, 'deezer', found_id)
artist_id = self._resolve_artist_id_for_source(
source,
@ -3428,7 +3445,7 @@ class WatchlistScanner:
# Balance by artist - max 6 tracks per artist
balanced_track_data = []
for artist, tracks in artist_track_data.items():
for _artist, tracks in artist_track_data.items():
sorted_tracks = sorted(tracks, key=lambda t: t['score'], reverse=True)
balanced_track_data.extend(sorted_tracks[:6])

View file

@ -39,7 +39,7 @@ class WishlistService:
# Extract Spotify track data from the track_info structure
spotify_track = self._extract_spotify_track_from_modal_info(track_info)
if not spotify_track:
logger.error(f"Could not extract Spotify track data from modal info")
logger.error("Could not extract Spotify track data from modal info")
return False
# Get failure reason from track_info if available

View file

@ -1,7 +1,10 @@
"""Shared helpers for background workers."""
import logging
import threading
logger = logging.getLogger(__name__)
def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float = 0.5) -> bool:
"""Sleep in chunks so shutdown can interrupt long waits."""
@ -15,3 +18,52 @@ def interruptible_sleep(stop_event: threading.Event, seconds: float, step: float
break
remaining -= wait_for
return stop_event.is_set()
def set_album_api_track_count(cursor, album_id, count):
"""Cache an album's authoritative track count from a metadata source.
Called by enrichment workers (Spotify / iTunes / Deezer / Discogs) after
they fetch album metadata. The count is the EXPECTED total tracks
according to that source distinct from `albums.track_count`, which
server syncs (Plex `leafCount`, SoulSync standalone `len(tracks)`)
populate with the OBSERVED count SoulSync already has indexed. The
Album Completeness repair job reads `albums.api_track_count` as the
expected total; populating it here during enrichment avoids a second
round of API calls during the repair scan.
Skips the write when the source didn't supply a positive numeric count
(None, 0, negative, or non-numeric) that way a source lacking track
info doesn't overwrite a good value another source already wrote. If
multiple sources report different counts (rare, usually deluxe vs.
standard edition), last-write-wins across enrichment cycles; that's
fine since any metadata-source count is strictly better than the
observed-count fallback that the repair job used before this column
existed.
Caller owns the cursor (and its connection / transaction) this
helper does not commit. Integrates with each worker's existing
`_update_album` method, which already batches several UPDATEs into
one transaction.
"""
try:
count = int(count or 0)
except (TypeError, ValueError):
return
if count <= 0:
return
# Swallow SQL errors — each worker batches several album UPDATEs into
# one transaction, and we don't want a failure here (e.g., the
# migration somehow hasn't run yet and the column is missing) to
# rollback the worker's other writes (spotify_album_id, thumb_url,
# etc.). The repair job's fallback path will eventually populate the
# column via its own save path once the column exists.
try:
cursor.execute(
"UPDATE albums SET api_track_count = ? WHERE id = ?",
(count, album_id),
)
except Exception as e:
logger.warning(
"Failed to cache api_track_count for album %s: %s", album_id, e
)

View file

@ -26,8 +26,8 @@ from enum import Enum
try:
import yt_dlp
except ImportError:
raise ImportError("yt-dlp is required. Install with: pip install yt-dlp")
except ImportError as exc:
raise ImportError("yt-dlp is required. Install with: pip install yt-dlp") from exc
from utils.logging_config import get_logger
from core.matching_engine import MusicMatchingEngine
@ -382,7 +382,7 @@ class YouTubeClient:
# If we already have both locally, use them
if ffmpeg_path.exists() and ffprobe_path.exists():
logger.info(f"Found ffmpeg and ffprobe in tools folder")
logger.info("Found ffmpeg and ffprobe in tools folder")
# Add to PATH so yt-dlp can find them
tools_dir_str = str(tools_dir.absolute())
os.environ['PATH'] = tools_dir_str + os.pathsep + os.environ.get('PATH', '')
@ -397,10 +397,10 @@ class YouTubeClient:
url = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip'
zip_path = tools_dir / 'ffmpeg.zip'
logger.info(f" Downloading from GitHub (this may take a minute)...")
logger.info(" Downloading from GitHub (this may take a minute)...")
urllib.request.urlretrieve(url, zip_path)
logger.info(f" Extracting ffmpeg.exe and ffprobe.exe...")
logger.info(" Extracting ffmpeg.exe and ffprobe.exe...")
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
# Extract ffmpeg.exe and ffprobe.exe from the bin folder
for file in zip_ref.namelist():
@ -418,10 +418,10 @@ class YouTubeClient:
url = 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz'
tar_path = tools_dir / 'ffmpeg.tar.xz'
logger.info(f" Downloading from GitHub (this may take a minute)...")
logger.info(" Downloading from GitHub (this may take a minute)...")
urllib.request.urlretrieve(url, tar_path)
logger.info(f" Extracting ffmpeg and ffprobe...")
logger.info(" Extracting ffmpeg and ffprobe...")
with tarfile.open(tar_path, 'r:xz') as tar_ref:
for member in tar_ref.getmembers():
if member.name.endswith('bin/ffmpeg'):
@ -437,17 +437,17 @@ class YouTubeClient:
elif system == 'darwin':
# Download Mac ffmpeg and ffprobe (static builds)
logger.info(f" Downloading ffmpeg from evermeet.cx...")
logger.info(" Downloading ffmpeg from evermeet.cx...")
ffmpeg_url = 'https://evermeet.cx/ffmpeg/getrelease/zip'
ffmpeg_zip = tools_dir / 'ffmpeg.zip'
urllib.request.urlretrieve(ffmpeg_url, ffmpeg_zip)
logger.info(f" Downloading ffprobe from evermeet.cx...")
logger.info(" Downloading ffprobe from evermeet.cx...")
ffprobe_url = 'https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip'
ffprobe_zip = tools_dir / 'ffprobe.zip'
urllib.request.urlretrieve(ffprobe_url, ffprobe_zip)
logger.info(f" Extracting ffmpeg and ffprobe...")
logger.info(" Extracting ffmpeg and ffprobe...")
with zipfile.ZipFile(ffmpeg_zip, 'r') as zip_ref:
zip_ref.extract('ffmpeg', tools_dir)
with zipfile.ZipFile(ffprobe_zip, 'r') as zip_ref:
@ -473,10 +473,10 @@ class YouTubeClient:
except Exception as e:
logger.error(f"Failed to download ffmpeg: {e}")
logger.error(f" Please install manually:")
logger.error(f" Windows: scoop install ffmpeg")
logger.error(f" Linux: sudo apt install ffmpeg")
logger.error(f" Mac: brew install ffmpeg")
logger.error(" Please install manually:")
logger.error(" Windows: scoop install ffmpeg")
logger.error(" Linux: sudo apt install ffmpeg")
logger.error(" Mac: brew install ffmpeg")
return False
def _youtube_to_track_result(self, entry: dict, best_audio: Optional[dict] = None) -> TrackResult:
@ -1057,7 +1057,7 @@ class YouTubeClient:
# Check if it's a 403 error
if '403' in error_msg or 'Forbidden' in error_msg:
if attempt < max_retries - 1:
logger.info(f"Waiting 2 seconds before retry...")
logger.info("Waiting 2 seconds before retry...")
import time
time.sleep(2)
continue # Retry on 403
@ -1148,7 +1148,7 @@ class YouTubeClient:
download_statuses = []
with self._download_lock:
for download_id, download_info in self.active_downloads.items():
for _download_id, download_info in self.active_downloads.items():
status = DownloadStatus(
id=download_info['id'],
filename=download_info['filename'],
@ -1276,11 +1276,11 @@ class YouTubeClient:
if audio.tags is not None:
# Delete ALL existing frames
audio.tags.clear()
logger.debug(f" Cleared all existing tag frames")
logger.debug(" Cleared all existing tag frames")
else:
# No tags exist, add them
audio.add_tags()
logger.debug(f" Added new tag structure")
logger.debug(" Added new tag structure")
if spotify_track:
# Use Spotify metadata
@ -1304,7 +1304,7 @@ class YouTubeClient:
except:
pass
logger.debug(f" Setting metadata tags...")
logger.debug(" Setting metadata tags...")
# Set ID3 tags (using setall to ensure they're set)
audio.tags.setall('TIT2', [TIT2(encoding=3, text=title)])
@ -1337,7 +1337,7 @@ class YouTubeClient:
logger.debug(f" Year: {year}")
# Fetch and embed album art from Spotify (via search)
logger.debug(f" Fetching album art from Spotify...")
logger.debug(" Fetching album art from Spotify...")
album_art_url = self._get_spotify_album_art(spotify_track)
if album_art_url:
@ -1367,11 +1367,11 @@ class YouTubeClient:
except Exception as art_error:
logger.warning(f" Could not embed album art: {art_error}")
else:
logger.warning(f" No album art found on Spotify")
logger.warning(" No album art found on Spotify")
# Save all tags
audio.save()
logger.info(f"Metadata enhanced successfully")
logger.info("Metadata enhanced successfully")
# Return album art URL for cover.jpg creation
return album_art_url
@ -1415,10 +1415,10 @@ class YouTubeClient:
# Don't overwrite existing cover art
if cover_path.exists():
logger.debug(f" cover.jpg already exists, skipping")
logger.debug(" cover.jpg already exists, skipping")
return
logger.debug(f" Downloading cover.jpg...")
logger.debug(" Downloading cover.jpg...")
response = requests.get(album_art_url, timeout=10)
response.raise_for_status()
@ -1440,10 +1440,10 @@ class YouTubeClient:
from core.lyrics_client import lyrics_client
if not lyrics_client.api:
logger.debug(f" LRClib API not available - skipping lyrics")
logger.debug(" LRClib API not available - skipping lyrics")
return
logger.debug(f" Fetching lyrics from LRClib...")
logger.debug(" Fetching lyrics from LRClib...")
# Get track metadata
artist_name = spotify_track.artists[0] if spotify_track.artists else "Unknown Artist"
@ -1461,12 +1461,12 @@ class YouTubeClient:
)
if success:
logger.debug(f" Created .lrc lyrics file")
logger.debug(" Created .lrc lyrics file")
else:
logger.debug(f" No lyrics found on LRClib")
logger.debug(" No lyrics found on LRClib")
except ImportError:
logger.debug(f" lyrics_client not available - skipping lyrics")
logger.debug(" lyrics_client not available - skipping lyrics")
except Exception as e:
logger.warning(f" Could not create lyrics file: {e}")

View file

@ -163,7 +163,6 @@ class MusicDatabase:
"""SQLite database manager for SoulSync music library data"""
def __init__(self, database_path: str = None):
import os
# Use env var if path is None OR if it's the default path
# This ensures Docker containers use the correct mounted volume location
if database_path is None or database_path == "database/music_library.db":
@ -1204,7 +1203,7 @@ class MusicDatabase:
cursor.execute(f"INSERT OR IGNORE INTO discovery_recent_albums_new ({cols_str}) SELECT {cols_str} FROM discovery_recent_albums")
cursor.execute("DROP TABLE discovery_recent_albums")
cursor.execute("ALTER TABLE discovery_recent_albums_new RENAME TO discovery_recent_albums")
conn.commit()
cursor.connection.commit()
logger.info("Successfully migrated discovery_recent_albums table for iTunes support")
# Migration: Add UNIQUE constraint to similar_artists table
@ -3123,6 +3122,19 @@ class MusicDatabase:
cursor.execute("ALTER TABLE albums ADD COLUMN soul_id TEXT DEFAULT NULL")
logger.info("Added soul_id column to albums table")
# Albums: api_track_count — cached expected track count from the
# metadata provider, separate from track_count which is the
# OBSERVED count written by server syncs (Plex leafCount,
# SoulSync standalone len(tracks)). Without a separate column,
# the Album Completeness job can't tell apart "you have all the
# tracks" from "Plex says this album has N tracks and you have
# N tracks" — the latter looks complete but might be missing
# material the metadata source knows about. NULL = not yet
# looked up; the repair job fills it as it runs.
if 'api_track_count' not in album_cols:
cursor.execute("ALTER TABLE albums ADD COLUMN api_track_count INTEGER DEFAULT NULL")
logger.info("Added api_track_count column to albums table")
# Tracks: soul_id (song-level) + album_soul_id (release-specific)
cursor.execute("PRAGMA table_info(tracks)")
track_cols = [c[1] for c in cursor.fetchall()]
@ -3526,7 +3538,6 @@ class MusicDatabase:
def get_db_storage_stats(self):
"""Get database storage breakdown by table."""
import os
conn = None
try:
# Total file size
@ -3989,7 +4000,7 @@ class MusicDatabase:
conn.commit()
return cursor.rowcount > 0
except sqlite3.IntegrityError:
logger.warning(f"Profile update failed (duplicate name?)")
logger.warning("Profile update failed (duplicate name?)")
return False
except Exception as e:
logger.error(f"Error updating profile {profile_id}: {e}")
@ -4717,6 +4728,10 @@ class MusicDatabase:
'audiodb_id', 'audiodb_match_status', 'audiodb_last_attempted',
'style', 'mood', 'label', 'explicit', 'record_type',
'deezer_id', 'deezer_match_status', 'deezer_last_attempted',
# api_track_count is metadata-source-derived enrichment cache;
# losing it on a ratingKey rekey would force the next
# completeness scan back to live API lookups (kettui PR #374).
'api_track_count',
]
# Read enrichment data from old album
@ -4780,6 +4795,63 @@ class MusicDatabase:
logger.error(f"Error inserting/updating {server_source} album {getattr(album_obj, 'title', 'Unknown')}: {e}")
return False
def get_album_display_meta(self, album_id) -> Optional[Dict[str, Any]]:
"""Return ``{album_title, artist_id, artist_name}`` for an album row.
Used by the reorganize queue enqueue endpoint to capture display
strings at submission time so the status panel can render
without a DB lookup per poll. Returns None when the album row
does not exist; lets DB errors bubble up so callers can surface
a real failure instead of swallowing it as "album not found".
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT al.title AS album_title,
ar.id AS artist_id,
ar.name AS artist_name
FROM albums al
JOIN artists ar ON al.artist_id = ar.id
WHERE al.id = ?
""",
(str(album_id),),
)
row = cursor.fetchone()
if not row:
return None
return {
'album_title': row['album_title'] or 'Unknown Album',
'artist_id': str(row['artist_id']) if row['artist_id'] is not None else None,
'artist_name': row['artist_name'] or 'Unknown Artist',
}
def get_artist_albums_for_reorganize(self, artist_id) -> List[Dict[str, Any]]:
"""Return ``[{album_id, album_title, artist_id, artist_name}, ...]``
for every album owned by ``artist_id``, ordered by year then
title. Used by the bulk Reorganize-All endpoint to pull the
full tracklist server-side instead of trusting whatever the
frontend cached. Returns an empty list when the artist has no
albums; lets DB errors bubble so a real failure surfaces as a
500 rather than masquerading as "no albums found".
"""
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT al.id AS album_id,
al.title AS album_title,
ar.id AS artist_id,
ar.name AS artist_name
FROM albums al
JOIN artists ar ON al.artist_id = ar.id
WHERE ar.id = ?
ORDER BY al.year ASC, al.title ASC
""",
(str(artist_id),),
)
return [dict(r) for r in cursor.fetchall()]
def get_albums_by_artist(self, artist_id: int) -> List[DatabaseAlbum]:
"""Get all albums by artist ID"""
try:
@ -5547,7 +5619,7 @@ class MusicDatabase:
u_words = uncensored.lower().split()
if len(c_words) == len(u_words):
all_match = True
for cw, uw in zip(c_words, u_words):
for cw, uw in zip(c_words, u_words, strict=False):
if '*' in cw:
# Strip asterisks to get the visible prefix/suffix
# "b*****t" → prefix "b", suffix "t"
@ -5675,7 +5747,6 @@ class MusicDatabase:
def _get_album_formats(self, cursor, sibling_ids: list) -> List[str]:
"""Get distinct format strings for tracks in the given album IDs."""
import os
try:
placeholders = ','.join('?' for _ in sibling_ids)
cursor.execute(f"""
@ -6178,7 +6249,7 @@ class MusicDatabase:
# Debug logging for Unicode normalization
if search_title != search_title_norm or search_artist != search_artist_norm or \
db_track.title != db_title_norm or db_track.artist_name != db_artist_norm:
logger.debug(f"Unicode normalization:")
logger.debug("Unicode normalization:")
logger.debug(f" Search: '{search_title}''{search_title_norm}' | '{search_artist}''{search_artist_norm}'")
logger.debug(f" Database: '{db_track.title}''{db_title_norm}' | '{db_track.artist_name}''{db_artist_norm}'")
@ -10026,7 +10097,7 @@ class MusicDatabase:
ORDER BY g.artist_name ASC, g.created_at DESC
""")
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting retag groups: {e}")
return []
@ -10042,7 +10113,7 @@ class MusicDatabase:
ORDER BY disc_number ASC, track_number ASC
""", (group_id,))
columns = [desc[0] for desc in cursor.description]
return [dict(zip(columns, row)) for row in cursor.fetchall()]
return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()]
except Exception as e:
logger.error(f"Error getting retag tracks: {e}")
return []
@ -11165,7 +11236,7 @@ class MusicDatabase:
LIMIT ? OFFSET ?
""", (automation_id, limit, offset))
cols = [d[0] for d in cursor.description]
rows = [dict(zip(cols, row)) for row in cursor.fetchall()]
rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()]
return {'history': rows, 'total': total}
except Exception as e:
logger.error(f"Error getting automation run history for {automation_id}: {e}")
@ -11534,7 +11605,6 @@ def get_database(database_path: str = None) -> MusicDatabase:
database_path: Path to database file. If None or default path, uses DATABASE_PATH env var
or defaults to "database/music_library.db". Custom paths are used as-is.
"""
import os
# Use env var if path is None OR if it's the default path
# This ensures Docker containers use the correct mounted volume location
if database_path is None or database_path == "database/music_library.db":
@ -11553,7 +11623,7 @@ def close_database():
with _database_lock:
# Close all database instances
for thread_id, db_instance in list(_database_instances.items()):
for _thread_id, db_instance in list(_database_instances.items()):
try:
db_instance.close()
except Exception as e:

View file

@ -294,7 +294,7 @@ class PlaylistSyncService:
# Use active media server for playlist sync
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error(f"No active media client available for playlist sync")
logger.error("No active media client available for playlist sync")
sync_success = False
else:
logger.info(f"Syncing playlist '{playlist.name}' to {server_type.upper()} server")
@ -615,7 +615,7 @@ class PlaylistSyncService:
try:
media_client, server_type = self._get_active_media_client()
if not media_client:
logger.error(f"No active media client available")
logger.error("No active media client available")
return []
if hasattr(media_client, 'search_tracks'):
@ -714,7 +714,7 @@ class PlaylistSyncService:
media_client, server_type = self._get_active_media_client()
if not media_client:
return {"error": f"No active media client available"}
return {"error": "No active media client available"}
media_playlists = media_client.get_all_playlists() if hasattr(media_client, 'get_all_playlists') else []
media_stats = media_client.get_library_stats() if hasattr(media_client, 'get_library_stats') else {}

View file

@ -306,3 +306,311 @@ def test_album_completeness_supports_hydrabase_primary(monkeypatch):
assert [track["track_number"] for track in missing_tracks] == [2, 3, 4]
assert missing_tracks[0]["source"] == "hydrabase"
assert missing_tracks[0]["source_track_id"] == "hy-2"
# ---------------------------------------------------------------------------
# api_track_count caching — the fix for the "0.1s / 0 findings" bug
# ---------------------------------------------------------------------------
class _ApiCountCursor:
"""Records UPDATE statements so we can verify the cache write."""
def __init__(self):
self.updates = []
def execute(self, query, params=None):
if query.strip().startswith("UPDATE albums"):
self.updates.append((query, params))
return self
def fetchall(self):
return []
class _ApiCountConnection:
def __init__(self, cursor):
self._cursor = cursor
self.commits = 0
def cursor(self):
return self._cursor
def commit(self):
self.commits += 1
def close(self):
return None
class _ApiCountDB:
def __init__(self):
self.cursor = _ApiCountCursor()
def _get_connection(self):
return _ApiCountConnection(self.cursor)
def test_save_api_track_count_writes_update_to_db():
"""The helper persists the resolved count so subsequent scans don't
refetch the expected total from the API."""
job = AlbumCompletenessJob()
db = _ApiCountDB()
context = types.SimpleNamespace(db=db)
job._save_api_track_count(context, "album-42", 12)
assert len(db.cursor.updates) == 1
query, params = db.cursor.updates[0]
assert "UPDATE albums" in query
assert "api_track_count = ?" in query
assert params == (12, "album-42")
def test_save_api_track_count_swallows_errors():
"""A cache-write failure must not break the scan — the job falls back
to the pre-cache behavior (API call next time)."""
job = AlbumCompletenessJob()
class _Boom:
def _get_connection(self):
raise RuntimeError("db is gone")
context = types.SimpleNamespace(db=_Boom())
# Should not raise.
job._save_api_track_count(context, "album-x", 10)
# ---------------------------------------------------------------------------
# Integration tests — run the full scan loop against a real sqlite in-memory
# DB so SELECT/PRAGMA/UPDATE go through actual SQL. Catches wiring mistakes
# between the SELECT, column_index, loop, and finding creation that the
# isolated helper tests wouldn't surface.
# ---------------------------------------------------------------------------
import sqlite3
import uuid
class _SharedMemoryDB:
"""Tiny shim matching MusicDatabase's `_get_connection()` contract,
backed by a shared-cache sqlite in-memory DB so `close()` on a
per-call connection doesn't destroy the data."""
def __init__(self):
self.uri = f"file:testdb_{uuid.uuid4().hex}?mode=memory&cache=shared"
# Keepalive conn holds the in-memory DB alive while other conns
# open/close. Without this, the DB is garbage-collected when the
# last conn closes.
self._keepalive = sqlite3.connect(self.uri, uri=True)
self._keepalive.executescript(
"""
CREATE TABLE artists (
id TEXT PRIMARY KEY,
name TEXT,
thumb_url TEXT
);
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
thumb_url TEXT,
spotify_album_id TEXT,
itunes_album_id TEXT,
deezer_id TEXT,
discogs_id TEXT,
soul_id TEXT,
track_count INTEGER,
api_track_count INTEGER
);
CREATE TABLE tracks (
id TEXT PRIMARY KEY,
album_id TEXT,
track_number INTEGER
);
"""
)
self._keepalive.commit()
def _get_connection(self):
return sqlite3.connect(self.uri, uri=True)
def insert_artist(self, artist_id, name, thumb=None):
self._keepalive.execute(
"INSERT INTO artists (id, name, thumb_url) VALUES (?, ?, ?)",
(artist_id, name, thumb),
)
self._keepalive.commit()
def insert_album(self, album_id, artist_id, title, *, spotify_id=None,
track_count=None, api_track_count=None):
self._keepalive.execute(
"""INSERT INTO albums
(id, artist_id, title, spotify_album_id, track_count, api_track_count)
VALUES (?, ?, ?, ?, ?, ?)""",
(album_id, artist_id, title, spotify_id, track_count, api_track_count),
)
self._keepalive.commit()
def insert_tracks(self, album_id, count):
rows = [(f"{album_id}-t{i}", album_id, i) for i in range(1, count + 1)]
self._keepalive.executemany(
"INSERT INTO tracks (id, album_id, track_number) VALUES (?, ?, ?)",
rows,
)
self._keepalive.commit()
def fetch_api_track_count(self, album_id):
row = self._keepalive.execute(
"SELECT api_track_count FROM albums WHERE id = ?", (album_id,)
).fetchone()
return row[0] if row else None
def _make_job_context(db, *, create_finding):
"""Minimal JobContext stand-in covering the fields scan() touches."""
return types.SimpleNamespace(
db=db,
transfer_folder='',
config_manager=_DummyConfigManager(),
spotify_client=None,
is_spotify_rate_limited=lambda: False,
stop_event=None,
create_finding=create_finding,
should_stop=None,
is_paused=None,
update_progress=None,
report_progress=None,
check_stop=lambda: False,
wait_if_paused=lambda: False,
)
def test_scan_uses_cached_api_track_count_without_expected_total_lookup(monkeypatch):
"""Integration: when api_track_count is populated, the scan reads the
expected total from the cache. `_get_expected_total` must NOT be called
for albums with a cached value. (The missing-tracks lookup may still
hit the API for incomplete albums that's a separate call path used
only after we've decided the album is incomplete.)"""
db = _SharedMemoryDB()
db.insert_artist('a1', 'Test Artist')
db.insert_album('alb-incomplete', 'a1', 'Incomplete Album',
spotify_id='sp-1', track_count=10, api_track_count=12)
db.insert_tracks('alb-incomplete', 10)
db.insert_album('alb-complete', 'a1', 'Complete Album',
spotify_id='sp-2', track_count=8, api_track_count=8)
db.insert_tracks('alb-complete', 8)
# Stub the track-lookup used by _find_missing_tracks (needed for the
# incomplete album's finding details). We're not asserting on it.
monkeypatch.setattr(
album_completeness_module,
"get_album_tracks_for_source",
lambda source, album_id: {"items": [
{"track_number": i, "name": f"T{i}", "artists": []} for i in range(1, 13)
]},
)
monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "spotify")
monkeypatch.setattr(
album_completeness_module, "get_source_priority",
lambda primary: ["spotify", "itunes", "deezer", "discogs", "hydrabase"],
)
# Spy on _get_expected_total specifically — that's the call path the
# cache is supposed to short-circuit.
job = AlbumCompletenessJob()
expected_total_calls = []
original_get_expected = job._get_expected_total
def spy(context_, primary_, album_ids_):
expected_total_calls.append(album_ids_.get('spotify'))
return original_get_expected(context_, primary_, album_ids_)
job._get_expected_total = spy
findings = []
context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs))
result = job.scan(context)
# _get_expected_total was NOT called — both albums had cached counts.
assert expected_total_calls == []
# Exactly one finding for the incomplete album.
assert result.findings_created == 1
assert len(findings) == 1
finding = findings[0]
assert finding['entity_id'] == 'alb-incomplete'
assert finding['details']['expected_tracks'] == 12
assert finding['details']['actual_tracks'] == 10
def test_scan_falls_back_to_api_and_persists_count_on_cache_miss(monkeypatch):
"""Integration: when api_track_count is NULL, scan calls the API,
gets the expected total, caches it, and creates the finding."""
db = _SharedMemoryDB()
db.insert_artist('a1', 'Test Artist')
# api_track_count is NULL — will need API lookup
db.insert_album('alb-fresh', 'a1', 'Fresh Album',
spotify_id='sp-fresh', track_count=8, api_track_count=None)
db.insert_tracks('alb-fresh', 8)
# API returns 14 tracks (so 8 owned out of 14 → finding)
monkeypatch.setattr(
album_completeness_module,
"get_album_tracks_for_source",
lambda source, album_id: {
"items": [{"track_number": i, "name": f"T{i}", "artists": []} for i in range(1, 15)],
} if source == "spotify" and album_id == "sp-fresh" else None,
)
monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "spotify")
monkeypatch.setattr(
album_completeness_module, "get_source_priority",
lambda primary: ["spotify"],
)
findings = []
context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs))
job = AlbumCompletenessJob()
result = job.scan(context)
# Finding for 8/14.
assert result.findings_created == 1
finding = findings[0]
assert finding['details']['expected_tracks'] == 14
assert finding['details']['actual_tracks'] == 8
# Crucially: the scan persisted the count so the next scan won't refetch.
assert db.fetch_api_track_count('alb-fresh') == 14
def test_scan_ignores_track_count_completely(monkeypatch):
"""Regression: the observed `track_count` (Plex's leafCount) must
NOT influence the expected-total comparison. Before the fix, an
album with track_count=actual_count was skipped as 'complete' even
when the metadata source said it had more tracks."""
db = _SharedMemoryDB()
db.insert_artist('a1', 'Test Artist')
# track_count=10 matches actual=10 (sassmastawillis's bug scenario).
# api_track_count=15 says the album actually has 15 tracks.
db.insert_album('alb-bug', 'a1', 'Bug Reproduction',
spotify_id='sp-bug', track_count=10, api_track_count=15)
db.insert_tracks('alb-bug', 10)
monkeypatch.setattr(
album_completeness_module, "get_album_tracks_for_source",
lambda source, album_id: None, # should NOT be called
)
monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "spotify")
monkeypatch.setattr(
album_completeness_module, "get_source_priority",
lambda primary: ["spotify"],
)
findings = []
context = _make_job_context(db, create_finding=lambda **kwargs: findings.append(kwargs))
job = AlbumCompletenessJob()
result = job.scan(context)
# The album MUST be flagged (10/15), not silently skipped.
assert result.findings_created == 1
assert findings[0]['details']['expected_tracks'] == 15
assert findings[0]['details']['actual_tracks'] == 10

View file

@ -0,0 +1,274 @@
"""Tests for ``core.artist_source_detail.build_source_only_artist_detail``.
The function used to live inline inside ``web_server.py``; a prior version of
these tests AST-parsed the function body to assert on response keys because
``web_server.py`` couldn't be imported at test time. Now that the logic lives
in a side-effect-free core module with dependency-injected clients, the tests
just call it directly with mocks.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from core import artist_source_detail
from core.artist_source_detail import build_source_only_artist_detail
# ---------------------------------------------------------------------------
# Fixtures — stubs for the metadata_service helpers the function calls
# ---------------------------------------------------------------------------
def _success_discography(**overrides):
result = {
"success": True,
"albums": [{"id": "a1", "title": "Album One"}],
"eps": [],
"singles": [{"id": "s1", "title": "Single One"}],
}
result.update(overrides)
return result
def _empty_discography():
return {
"success": False,
"error": "No releases found for artist",
}
@pytest.fixture
def _stub_metadata(monkeypatch):
"""Replace the metadata_service imports with controllable stubs.
The function imports ``get_artist_image_url`` and
``get_artist_detail_discography`` lazily inside its body (deferred import),
so we patch on the metadata_service module directly.
"""
from core import metadata_service
state = {
"image_url": None,
"discography": _success_discography(),
"last_options": None,
"last_discog_call": None,
}
def fake_get_artist_image_url(artist_id, source_override=None):
return state["image_url"]
def fake_get_artist_detail_discography(artist_id, artist_name="", options=None):
state["last_options"] = options
state["last_discog_call"] = (artist_id, artist_name)
return state["discography"]
monkeypatch.setattr(metadata_service, "get_artist_image_url", fake_get_artist_image_url)
monkeypatch.setattr(metadata_service, "get_artist_detail_discography", fake_get_artist_detail_discography)
return state
# ---------------------------------------------------------------------------
# Group A — Success-path response shape + source-specific ID stamping
# ---------------------------------------------------------------------------
class TestResponseShape:
def test_success_returns_expected_envelope(self, _stub_metadata):
payload, status = build_source_only_artist_detail(
"dz-123", "Artist One", "deezer",
)
assert status == 200
assert payload["success"] is True
assert payload["discography"] == _stub_metadata["discography"]
assert payload["enrichment_coverage"] == {}
assert payload["artist"]["id"] == "dz-123"
assert payload["artist"]["name"] == "Artist One"
assert payload["artist"]["server_source"] is None
assert "genres" in payload["artist"]
def test_empty_artist_name_falls_back_to_id(self, _stub_metadata):
payload, status = build_source_only_artist_detail(
"dz-123", "", "deezer",
)
assert status == 200
assert payload["artist"]["name"] == "dz-123"
def test_failure_returns_404(self, _stub_metadata):
_stub_metadata["discography"] = _empty_discography()
payload, status = build_source_only_artist_detail(
"dz-missing", "Unknown Artist", "deezer",
)
assert status == 404
assert payload["success"] is False
assert payload["source"] == "deezer"
assert "error" in payload
@pytest.mark.parametrize("source,expected_field", [
("spotify", "spotify_artist_id"),
("itunes", "itunes_artist_id"),
("deezer", "deezer_id"),
("discogs", "discogs_id"),
("hydrabase", "soul_id"),
("musicbrainz", "musicbrainz_id"),
])
def test_source_specific_id_field_is_stamped(self, _stub_metadata, source, expected_field):
payload, _ = build_source_only_artist_detail("the-id", "Artist", source)
assert payload["artist"][expected_field] == "the-id"
# ---------------------------------------------------------------------------
# Group B — Discography options contract (the bug that motivated the extract)
# ---------------------------------------------------------------------------
class TestDiscographyOptions:
def test_dedup_variants_disabled(self, _stub_metadata):
"""Source-only view must show every release variant, matching the
retired inline Artists page behaviour."""
build_source_only_artist_detail("dz-1", "Artist", "deezer")
opts = _stub_metadata["last_options"]
assert opts is not None
assert opts.dedup_variants is False
def test_passes_source_override_and_artist_source_ids(self, _stub_metadata):
build_source_only_artist_detail("sp-999", "Artist", "spotify")
opts = _stub_metadata["last_options"]
assert opts.source_override == "spotify"
assert opts.artist_source_ids == {"spotify": "sp-999"}
# ---------------------------------------------------------------------------
# Group C — Per-source enrichment
# ---------------------------------------------------------------------------
class TestPerSourceEnrichment:
def test_spotify_extracts_genres_followers_and_image_fallback(self, _stub_metadata):
spotify = SimpleNamespace(
get_artist=lambda aid, allow_fallback=False: {
"genres": ["alt rock", "emo"],
"followers": {"total": 12345},
"images": [{"url": "https://sp/img.jpg"}],
}
)
payload, _ = build_source_only_artist_detail(
"sp-1", "Artist", "spotify", spotify_client=spotify,
)
assert payload["artist"]["genres"] == ["alt rock", "emo"]
assert payload["artist"]["followers"] == 12345
# image_url falls back to Spotify's image when metadata_service returned None
assert payload["artist"]["image_url"] == "https://sp/img.jpg"
def test_deezer_extracts_genres_and_followers(self, _stub_metadata):
deezer = SimpleNamespace(
get_artist_info=lambda aid: {
"genres": ["pop"],
"followers": {"total": 500},
}
)
payload, _ = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", deezer_client=deezer,
)
assert payload["artist"]["genres"] == ["pop"]
assert payload["artist"]["followers"] == 500
def test_itunes_extracts_genres_only(self, _stub_metadata):
itunes = SimpleNamespace(get_artist=lambda aid: {"genres": ["rock"]})
payload, _ = build_source_only_artist_detail(
"it-1", "Artist", "itunes", itunes_client=itunes,
)
assert payload["artist"]["genres"] == ["rock"]
assert "followers" not in payload["artist"]
def test_discogs_extracts_genres_only(self, _stub_metadata):
discogs = SimpleNamespace(get_artist=lambda aid: {"genres": ["jazz"]})
payload, _ = build_source_only_artist_detail(
"dc-1", "Artist", "discogs", discogs_client=discogs,
)
assert payload["artist"]["genres"] == ["jazz"]
def test_client_none_is_safe(self, _stub_metadata):
"""Missing client for the requested source is a no-op, not a crash."""
payload, status = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", deezer_client=None,
)
assert status == 200
assert payload["artist"]["genres"] == []
def test_client_exception_does_not_propagate(self, _stub_metadata):
"""A failing source client should log and move on; the response still builds."""
def _boom(_):
raise RuntimeError("deezer down")
deezer = SimpleNamespace(get_artist_info=_boom)
payload, status = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", deezer_client=deezer,
)
assert status == 200
assert payload["artist"]["genres"] == []
# ---------------------------------------------------------------------------
# Group D — Last.fm enrichment
# ---------------------------------------------------------------------------
class _FakeLastFM:
def __init__(self, api_key=None):
self.api_key = api_key
def get_artist_info(self, name):
return {
"bio": {"content": "Long bio text", "summary": "Short summary"},
"stats": {"listeners": "5000", "playcount": "99999"},
"url": "https://last.fm/artist",
}
class TestLastFmEnrichment:
def _patch_lastfm(self, monkeypatch, cls=_FakeLastFM):
import core.lastfm_client as lastfm_module
monkeypatch.setattr(lastfm_module, "LastFMClient", cls)
def test_lastfm_fields_populated_when_api_key_present(self, _stub_metadata, monkeypatch):
self._patch_lastfm(monkeypatch)
payload, _ = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY",
)
artist = payload["artist"]
assert artist["lastfm_bio"] == "Long bio text"
assert artist["lastfm_listeners"] == 5000
assert artist["lastfm_playcount"] == 99999
assert artist["lastfm_url"] == "https://last.fm/artist"
def test_no_lastfm_when_api_key_missing(self, _stub_metadata, monkeypatch):
self._patch_lastfm(monkeypatch)
payload, _ = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", lastfm_api_key=None,
)
assert "lastfm_bio" not in payload["artist"]
assert "lastfm_listeners" not in payload["artist"]
def test_summary_used_when_bio_content_missing(self, _stub_metadata, monkeypatch):
class _SummaryOnly(_FakeLastFM):
def get_artist_info(self, name):
return {
"bio": {"summary": "Just a summary"},
"stats": {},
"url": "",
}
self._patch_lastfm(monkeypatch, _SummaryOnly)
payload, _ = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY",
)
assert payload["artist"]["lastfm_bio"] == "Just a summary"
def test_lastfm_exception_does_not_propagate(self, _stub_metadata, monkeypatch):
class _Broken(_FakeLastFM):
def get_artist_info(self, name):
raise RuntimeError("last.fm rate limited")
self._patch_lastfm(monkeypatch, _Broken)
payload, status = build_source_only_artist_detail(
"dz-1", "Artist", "deezer", lastfm_api_key="LFM_KEY",
)
assert status == 200
assert "lastfm_bio" not in payload["artist"]

View file

@ -0,0 +1,239 @@
"""Tests for the source-artist → library lookup helpers in
``core/artist_source_lookup.py``.
These exist to catch the class of bug we hit in April 2026 where the
watchlist-config enrichment query referenced a column name (``deezer_artist_id``)
that lived on ``watchlist_artists`` but NOT on ``artists``, producing a
``no such column`` error on every request.
The earlier version of this file AST-parsed ``web_server.py`` because the
logic lived inline there and could not be imported at test time. The logic
has since been extracted to a side-effect-free module, so we can just import
and call it directly.
"""
from __future__ import annotations
import pytest
from core.artist_source_lookup import (
SOURCE_ID_FIELD,
SOURCE_ONLY_ARTIST_SOURCES,
find_library_artist_for_source,
)
from database.music_database import MusicDatabase
EXPECTED_SOURCE_ID_FIELD = {
"spotify": "spotify_artist_id",
"itunes": "itunes_artist_id",
"deezer": "deezer_id",
"discogs": "discogs_id",
"hydrabase": "soul_id",
"musicbrainz": "musicbrainz_id",
}
@pytest.fixture
def db(tmp_path):
"""Fresh MusicDatabase — runs all migrations so source-id columns exist."""
return MusicDatabase(str(tmp_path / "music.db"))
def _insert_artist(db, *, artist_id, name, server_source="plex", **extra):
"""Insert a row into the artists table with the given extra columns."""
cols = ["id", "name", "server_source"] + list(extra.keys())
vals = [artist_id, name, server_source] + list(extra.values())
placeholders = ",".join("?" for _ in cols)
with db._get_connection() as conn:
conn.execute(
f"INSERT INTO artists ({','.join(cols)}) VALUES ({placeholders})",
vals,
)
conn.commit()
# ===========================================================================
# Group A — SOURCE_ID_FIELD constants
# ===========================================================================
class TestSourceIdFieldMapping:
"""The mapping the lookup uses to join source artists back to the library
``artists`` table must stay in sync with this test's expectations AND with
the real column names on the table."""
def test_mapping_matches_expected(self):
assert SOURCE_ID_FIELD == EXPECTED_SOURCE_ID_FIELD, (
"SOURCE_ID_FIELD changed; update EXPECTED_SOURCE_ID_FIELD "
"(and the test body) to match."
)
def test_source_only_set_matches_mapping_keys(self):
"""Sources eligible for the source-only fallback must all have a
column to look them up by otherwise the upgrade path silently
returns None."""
assert SOURCE_ONLY_ARTIST_SOURCES == frozenset(SOURCE_ID_FIELD.keys())
def test_every_mapped_column_exists_on_artists_table(self, db):
"""Regression for the 2026-04 ``deezer_artist_id`` typo: every column
referenced by SOURCE_ID_FIELD must exist on the ``artists`` table."""
with db._get_connection() as conn:
cursor = conn.execute("PRAGMA table_info(artists)")
existing = {row[1] for row in cursor.fetchall()}
missing = {
source: column
for source, column in SOURCE_ID_FIELD.items()
if column not in existing
}
assert not missing, (
"Columns declared in SOURCE_ID_FIELD are missing from the "
f"artists table: {missing}. Available columns: {sorted(existing)}"
)
# ===========================================================================
# Group B — find_library_artist_for_source behaviour
# ===========================================================================
class TestFindLibraryArtistForSource:
"""Behavioural tests against a real (in-memory) MusicDatabase."""
@pytest.mark.parametrize("source,column", list(EXPECTED_SOURCE_ID_FIELD.items()))
def test_lookup_by_source_id_column(self, db, source, column):
source_value = f"{source}-test-artist-123"
_insert_artist(
db,
artist_id=f"pk-{source}",
name=f"{source.title()} Test Artist",
**{column: source_value},
)
result = find_library_artist_for_source(db, source, source_value)
assert result == f"pk-{source}"
def test_unknown_source_returns_none(self, db):
assert find_library_artist_for_source(
db, "made-up-source", "anything", artist_name="Anything"
) is None
def test_lookup_misses_when_source_id_unknown(self, db):
_insert_artist(db, artist_id="pk-real", name="Real Artist", deezer_id="dz-real")
assert find_library_artist_for_source(db, "deezer", "dz-not-real") is None
def test_artist_name_is_optional(self, db):
"""Callers that don't have a name handy should be able to omit it
without falling through to the name-fallback branch."""
_insert_artist(db, artist_id="pk-q", name="Some Artist", server_source="plex")
# No source-id match, no name passed → must return None even when
# active_server is set (otherwise we'd risk matching by None name).
assert find_library_artist_for_source(
db, "deezer", "no-id-match", active_server="plex"
) is None
def test_name_fallback_matches_within_active_server(self, db):
_insert_artist(db, artist_id="pk-a", name="Kendrick Lamar", server_source="plex")
_insert_artist(db, artist_id="pk-b", name="KENDRICK LAMAR", server_source="jellyfin")
result = find_library_artist_for_source(
db, "deezer", "no-id-match", artist_name="kendrick lamar",
active_server="plex",
)
assert result == "pk-a"
def test_name_fallback_skips_other_servers(self, db):
"""Active-server scope is required so we don't jump the user across
server contexts on a name collision."""
_insert_artist(db, artist_id="pk-jelly", name="Taylor Swift", server_source="jellyfin")
result = find_library_artist_for_source(
db, "deezer", "no-id-match", artist_name="Taylor Swift",
active_server="plex",
)
assert result is None
def test_name_fallback_requires_active_server(self, db):
"""Without an active_server we shouldn't fall through to a global
name match too easy to land the user on the wrong record."""
_insert_artist(db, artist_id="pk-x", name="Some Artist", server_source="plex")
result = find_library_artist_for_source(
db, "deezer", "no-id-match", artist_name="Some Artist",
active_server=None,
)
assert result is None
def test_id_match_wins_over_name_match(self, db):
"""If both a source-id match and a name match exist, the id match
should take priority it's the more reliable signal."""
_insert_artist(
db, artist_id="pk-id-match", name="Different Name",
deezer_id="dz-shared", server_source="plex",
)
_insert_artist(
db, artist_id="pk-name-match", name="The Searched Artist",
server_source="plex",
)
result = find_library_artist_for_source(
db, "deezer", "dz-shared", artist_name="The Searched Artist",
active_server="plex",
)
assert result == "pk-id-match"
# ===========================================================================
# Group C — Watchlist-config enrichment query schema contract
# ===========================================================================
class TestWatchlistConfigEnrichmentQueries:
"""The watchlist-config GET (web_server.py ~line 42196) joins
``watchlist_artists`` against ``artists``. Both tables use different
column names for the same external IDs (``deezer_id`` on artists,
``deezer_artist_id`` on watchlist_artists). The queries must use the
correct column per table."""
def test_artists_enrichment_query_executes(self, db):
"""Run the exact SELECT from web_server.py verbatim — must not raise
``no such column``."""
with db._get_connection() as conn:
conn.execute(
"""
SELECT banner_url, summary, style, mood, label, genres
FROM artists
WHERE spotify_artist_id = ?
OR itunes_artist_id = ?
OR deezer_id = ?
OR discogs_id = ?
LIMIT 1
""",
("x", "x", "x", "x"),
)
def test_watchlist_join_query_executes(self, db):
"""The paired query hits ``watchlist_artists`` where the Deezer column
is ``deezer_artist_id`` confirm that shape works too."""
with db._get_connection() as conn:
conn.execute(
"""
SELECT rr.album_name, rr.release_date, rr.album_cover_url, rr.track_count
FROM recent_releases rr
JOIN watchlist_artists wa ON rr.watchlist_artist_id = wa.id
WHERE wa.spotify_artist_id = ?
OR wa.itunes_artist_id = ?
OR wa.deezer_artist_id = ?
ORDER BY rr.release_date DESC
LIMIT 6
""",
("x", "x", "x"),
)
def test_artists_table_does_not_have_watchlist_column_names(self, db):
"""Document the schema split that caused the original bug: these
suffixed names only exist on ``watchlist_artists``, never ``artists``."""
with db._get_connection() as conn:
cursor = conn.execute("PRAGMA table_info(artists)")
artists_cols = {row[1] for row in cursor.fetchall()}
assert "deezer_artist_id" not in artists_cols
assert "discogs_artist_id" not in artists_cols

View file

@ -0,0 +1,106 @@
"""Regression tests for the content-filter regex patterns used by the
watchlist scanner and the Live/Commentary Cleaner repair job.
The bare `\\blive\\b` pattern was too loose it flagged verb uses like
"What We Live For" or "Live Forever" as live recordings. These tests lock
in the tightened behaviour: clear live-recording context required.
"""
import sys
import types
# Minimal stubs so the watchlist_scanner module imports without the full app.
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "plex"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules.setdefault("config", config_pkg)
sys.modules.setdefault("config.settings", settings_mod)
from core.watchlist_scanner import is_live_version # noqa: E402
from core.repair_jobs.live_commentary_cleaner import _detect_content_type # noqa: E402
# ── is_live_version ─────────────────────────────────────────────────────────
def test_is_live_version_catches_live_at_suffix():
# Reported case: Wolfmother 10th Anniversary deluxe bonus tracks
assert is_live_version("Dimension - Live at Big Day Out", "Wolfmother")
assert is_live_version("Minds Eye - Live at Triple J", "Wolfmother")
def test_is_live_version_catches_parenthesized_suffix():
assert is_live_version("Thriller (Live)", "")
assert is_live_version("Song (Live at Wembley)", "")
assert is_live_version("Song [Live Version]", "")
def test_is_live_version_catches_dash_suffix():
assert is_live_version("Song - Live", "")
assert is_live_version("Dimension - Live", "Wolfmother")
def test_is_live_version_catches_modifiers():
assert is_live_version("Song", "Live in Tokyo")
assert is_live_version("Live Recording of Dimension", "")
assert is_live_version("Acoustic Session", "Live Session Vol 1")
def test_is_live_version_catches_other_signals():
assert is_live_version("MTV Unplugged", "MTV Unplugged in New York")
assert is_live_version("The Concert for Bangladesh", "")
assert is_live_version("On Stage", "ABBA")
assert is_live_version("Dead Man's Party (Live in Concert)", "")
def test_is_live_version_does_not_flag_verb_live():
# Reported false positive: "What We Live For" by American Authors
assert not is_live_version("What We Live For", "What We Live For")
assert not is_live_version("Live Forever", "Definitely Maybe")
assert not is_live_version("Live and Let Die", "Band on the Run")
def test_is_live_version_does_not_flag_similar_words():
assert not is_live_version("Living on a Prayer", "Slippery When Wet")
assert not is_live_version("Believe", "Believe")
assert not is_live_version("Symphony No. 5", "")
assert not is_live_version("Alive", "Ten")
def test_is_live_version_handles_empty_input():
assert not is_live_version("", "")
assert not is_live_version("", "Some Album")
# ── live_commentary_cleaner._detect_content_type ────────────────────────────
def test_detect_content_type_flags_live_recordings():
assert _detect_content_type("Dimension - Live at Big Day Out", "Wolfmother") == "live"
assert _detect_content_type("Thriller (Live)", "") == "live"
assert _detect_content_type("Song [Live Version]", "") == "live"
assert _detect_content_type("MTV Unplugged", "Unplugged in NY") == "live"
def test_detect_content_type_does_not_flag_verb_live():
# Reported false positive: "What We Live For" by American Authors
assert _detect_content_type("What We Live For", "What We Live For") is None
assert _detect_content_type("Live Forever", "Definitely Maybe") is None
assert _detect_content_type("Living on a Prayer", "Slippery When Wet") is None
def test_detect_content_type_still_catches_other_categories():
assert _detect_content_type("The Interview", "Press Kit") == "interview"
assert _detect_content_type("Director's Commentary", "Album") == "commentary"
assert _detect_content_type("Spoken Word Poem", "") == "spoken_word"
assert _detect_content_type("A Cappella Version", "") == "acappella"

View file

@ -0,0 +1,129 @@
"""Tests for `discogs_worker.count_discogs_real_tracks` — the filter
that decides which entries in a Discogs tracklist count as real songs
when caching the authoritative track count for the Album Completeness
repair job.
Reported by kettui on PR #374: the original inline filter only kept
``type_ == 'track'`` rows, but `discogs_client.get_album_tracks` itself
keeps both ``type_ == 'track'`` AND rows with an empty/missing
``type_``. The narrower filter would undercount releases whose Discogs
response left ``type_`` blank for some real tracks and the repair
job's fallback path (`_get_expected_total`) would silently disagree
with the cached count.
"""
from core.discogs_worker import count_discogs_real_tracks
# ---------------------------------------------------------------------------
# The kettui case: empty type_ counts as a real track
# ---------------------------------------------------------------------------
def test_empty_type_counts_as_track():
tracklist = [
{'title': 'Track 1', 'type_': 'track'},
{'title': 'Track 2', 'type_': ''}, # <-- the bug
{'title': 'Track 3', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 3
def test_missing_type_field_counts_as_track():
"""Discogs sometimes omits the field entirely rather than sending
an empty string. Both shapes mean 'real track'."""
tracklist = [
{'title': 'Track 1'}, # no type_ key at all
{'title': 'Track 2', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 2
def test_none_type_counts_as_track():
"""And the field may be present-but-None on some clients/versions."""
tracklist = [
{'title': 'Track 1', 'type_': None},
{'title': 'Track 2', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 2
# ---------------------------------------------------------------------------
# Non-track rows are excluded (Discogs's structural markers)
# ---------------------------------------------------------------------------
def test_headings_excluded():
tracklist = [
{'title': 'Disc 1', 'type_': 'heading'},
{'title': 'Track 1', 'type_': 'track'},
{'title': 'Track 2', 'type_': 'track'},
{'title': 'Disc 2', 'type_': 'heading'},
{'title': 'Track 3', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 3
def test_indices_excluded():
tracklist = [
{'title': 'Side A', 'type_': 'index'},
{'title': 'Track 1', 'type_': 'track'},
{'title': 'Side B', 'type_': 'index'},
{'title': 'Track 2', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 2
def test_sub_tracks_excluded():
"""Sub-tracks (parts of a medley) shouldn't double-count against
the parent track."""
tracklist = [
{'title': 'Medley', 'type_': 'track'},
{'title': 'Part A', 'type_': 'sub_track'},
{'title': 'Part B', 'type_': 'sub_track'},
{'title': 'Encore', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 2
def test_unknown_type_excluded():
"""Conservative — if Discogs adds a new structural marker we
haven't seen, don't count it as a track until we explicitly add
it to the allowlist."""
tracklist = [
{'title': 'Track 1', 'type_': 'track'},
{'title': 'Some Future Marker', 'type_': 'experimental_thing'},
]
assert count_discogs_real_tracks(tracklist) == 1
# ---------------------------------------------------------------------------
# Defensive — bad input shouldn't raise
# ---------------------------------------------------------------------------
def test_empty_tracklist_returns_zero():
assert count_discogs_real_tracks([]) == 0
def test_none_tracklist_returns_zero():
assert count_discogs_real_tracks(None) == 0
# ---------------------------------------------------------------------------
# Realistic mixed tracklist (the kettui case in context)
# ---------------------------------------------------------------------------
def test_realistic_multi_disc_tracklist():
"""A 2-disc release with headings, real tracks, AND a few rows
with empty type_ should count all real tracks but no markers."""
tracklist = [
{'title': 'Disc 1', 'type_': 'heading'},
{'title': 'Track 1', 'type_': 'track'},
{'title': 'Track 2', 'type_': 'track'},
{'title': 'Track 3', 'type_': ''}, # the kettui case
{'title': 'Track 4', 'type_': 'track'},
{'title': 'Disc 2', 'type_': 'heading'},
{'title': 'Track 5', 'type_': 'track'},
{'title': 'Track 6'}, # missing type_
{'title': 'Bonus Index', 'type_': 'index'},
{'title': 'Track 7', 'type_': 'track'},
]
assert count_discogs_real_tracks(tracklist) == 7

File diff suppressed because it is too large Load diff

View file

@ -483,6 +483,61 @@ def test_get_artist_detail_discography_dedups_variant_releases(monkeypatch):
assert result["albums"][0]["track_count"] == 10
def test_get_artist_detail_discography_keeps_variants_when_dedup_disabled(monkeypatch):
"""MetadataLookupOptions.dedup_variants=False is the source-only artist
detail code path used so the standalone /artist-detail page can show
every release the source returns (matching the retired inline Artists
page behaviour)."""
monkeypatch.setattr(
metadata_service,
"get_artist_discography",
lambda artist_id, artist_name='', options=None: {
"albums": [
{
"id": "album-standard",
"name": "Variant Album",
"album_type": "album",
"image_url": "https://img.example/standard.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
},
{
"id": "album-swedish",
"name": "Variant Album (Swedish Edition)",
"album_type": "album",
"image_url": "https://img.example/swedish.jpg",
"release_date": "2024-01-05",
"total_tracks": 12,
},
{
"id": "album-remaster",
"name": "Variant Album (2023 Abbey Road Remaster)",
"album_type": "album",
"image_url": "https://img.example/remaster.jpg",
"release_date": "2024-01-05",
"total_tracks": 10,
},
],
"singles": [],
"source": "deezer",
"source_priority": ["deezer", "spotify"],
},
)
result = metadata_service.get_artist_detail_discography(
"artist-1",
"Artist One",
MetadataLookupOptions(dedup_variants=False),
)
assert result["success"] is True
assert [album["id"] for album in result["albums"]] == [
"album-standard",
"album-swedish",
"album-remaster",
]
def test_get_artist_discography_keeps_provider_artist_ids(monkeypatch):
class _SpotifyArtistIdClient(_FakeSourceClient):
def get_artist_albums(self, artist_id, **kwargs):

View file

@ -0,0 +1,767 @@
"""Tests for the MusicBrainz search adapter (core/musicbrainz_search.py).
Covers the behavior changes from the search-overhaul PR:
- Artist search is re-enabled and score-filtered
- Bare name queries route through artist-first browse
- Structured 'Artist - Title' queries stay on text search
- Top-artist resolution is memoized per instance
- Cover Art URLs are constructed, not probed
"""
from unittest.mock import MagicMock, patch
import pytest
from core.musicbrainz_search import (
MusicBrainzSearchClient,
_cover_art_url,
_extract_title_hint,
)
# ---------------------------------------------------------------------------
# Cover art URL construction
# ---------------------------------------------------------------------------
def test_cover_art_url_release_scope():
assert _cover_art_url('abc-123') == 'https://coverartarchive.org/release/abc-123/front-250'
def test_cover_art_url_release_group_scope():
assert _cover_art_url('abc-123', scope='release-group') == \
'https://coverartarchive.org/release-group/abc-123/front-250'
def test_cover_art_url_empty_mbid_returns_none():
assert _cover_art_url('') is None
assert _cover_art_url(None) is None
def test_cover_art_url_unknown_scope_falls_back_to_release():
assert _cover_art_url('abc', scope='garbage') == 'https://coverartarchive.org/release/abc/front-250'
# ---------------------------------------------------------------------------
# Structured query splitting
# ---------------------------------------------------------------------------
def test_split_structured_query_hyphen():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica - Master of Puppets') == ('Metallica', 'Master of Puppets')
def test_split_structured_query_en_dash():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica One') == ('Metallica', 'One')
def test_split_structured_query_em_dash():
client = MusicBrainzSearchClient()
assert client._split_structured_query('Metallica — Battery') == ('Metallica', 'Battery')
def test_split_structured_query_bare_name():
client = MusicBrainzSearchClient()
assert client._split_structured_query('metallica') == (None, 'metallica')
def test_split_structured_query_no_separator_with_hyphens_in_word():
# A hyphen inside a word (no surrounding spaces) should not split.
client = MusicBrainzSearchClient()
assert client._split_structured_query('t-pain') == (None, 't-pain')
# ---------------------------------------------------------------------------
# Artist search — score filtering and shape
# ---------------------------------------------------------------------------
def _mk_artist(name, mbid, score=100, tags=None):
return {
'id': mbid,
'name': name,
'score': score,
'tags': tags or [],
}
def test_search_artists_filters_by_score_threshold():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
_mk_artist('Metallica', 'mb-real', score=100),
_mk_artist('Metallica Tribute', 'mb-tribute', score=60),
_mk_artist('Metallica Jam', 'mb-jam', score=58),
]
results = client.search_artists('metallica', limit=10)
assert len(results) == 1
assert results[0].name == 'Metallica'
assert results[0].id == 'mb-real'
def test_search_artists_uses_strict_false_for_fuzzy_match():
"""The adapter must use strict=False so MusicBrainz searches
alias+artist+sortname together strict mode would miss aliased names.
Adapter fetches `limit * 3` (min 10) so dedup-by-name below has enough
candidates to pick from.
"""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = []
client.search_artists('metallica')
client._client.search_artist.assert_called_once_with('metallica', limit=30, strict=False)
def test_search_artists_dedupes_same_named_homonyms():
"""MusicBrainz has many different PEOPLE sharing a canonical name
(7 Michael Jacksons: singer, poet, photographer, mashup artist, ...).
Since they all render as "Michael Jackson" with the same fallback image,
dedupe to the highest-scoring entry per name."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-king', 'name': 'Michael Jackson', 'score': 100,
'tags': [{'name': 'pop'}]},
{'id': 'mb-poet', 'name': 'Michael Jackson', 'score': 81},
{'id': 'mb-mashup', 'name': 'Michael Jackson', 'score': 80},
{'id': 'mb-photog', 'name': 'Michael Jackson', 'score': 80},
{'id': 'mb-other', 'name': 'Michael Jackson', 'score': 80},
]
results = client.search_artists('michael jackson', limit=10)
# Should collapse to one entry — the highest-scoring one.
assert len(results) == 1
assert results[0].id == 'mb-king'
assert results[0].popularity == 100
def test_search_artists_dedup_normalized_case_and_whitespace():
"""Dedup key is case-insensitive and whitespace-normalized."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'The Band', 'score': 100},
{'id': 'mb-2', 'name': 'THE BAND', 'score': 85},
{'id': 'mb-3', 'name': 'the band', 'score': 82},
]
results = client.search_artists('the band', limit=5)
assert len(results) == 1
assert results[0].id == 'mb-1'
def test_search_artists_keeps_distinct_names():
"""Dedup only collapses identical normalized names, not similar names."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'The Beatles', 'score': 100},
{'id': 'mb-2', 'name': 'The Beatles Revival', 'score': 85},
]
results = client.search_artists('the beatles', limit=5)
assert {r.name for r in results} == {'The Beatles', 'The Beatles Revival'}
def test_search_artists_returns_empty_on_exception():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.side_effect = RuntimeError('network down')
assert client.search_artists('metallica') == []
def test_search_artists_extracts_tags_as_genres():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
_mk_artist('Metallica', 'mb-real', score=100,
tags=[{'name': 'thrash metal', 'count': 20},
{'name': 'heavy metal', 'count': 15}]),
]
results = client.search_artists('metallica')
assert results[0].genres == ['thrash metal', 'heavy metal']
def test_search_artists_skips_entries_without_mbid_or_name():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [
{'id': 'mb-1', 'name': 'Good', 'score': 100},
{'id': '', 'name': 'Missing MBID', 'score': 100},
{'id': 'mb-2', 'name': '', 'score': 100},
]
results = client.search_artists('x')
assert [r.name for r in results] == ['Good']
# ---------------------------------------------------------------------------
# Top-artist resolution — memoization
# ---------------------------------------------------------------------------
def test_resolve_top_artist_memoizes_by_normalized_query():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
first = client._resolve_top_artist('metallica')
second = client._resolve_top_artist(' Metallica ') # Whitespace / case variant
assert first is not None
assert first['id'] == 'mb-1'
assert first is second
# HTTP call happens once despite two resolve calls.
assert client._client.search_artist.call_count == 1
def test_resolve_top_artist_returns_none_below_threshold():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Tribute', 'mb-trib', score=50)]
assert client._resolve_top_artist('obscure') is None
def test_resolve_top_artist_caches_negative_result():
"""After a lookup finds no good match, subsequent calls don't refetch."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = []
first = client._resolve_top_artist('nonexistent band')
second = client._resolve_top_artist('nonexistent band')
assert first is None
assert second is None
assert client._client.search_artist.call_count == 1
def test_resolve_top_artist_empty_query_returns_none_without_http():
client = MusicBrainzSearchClient()
client._client = MagicMock()
assert client._resolve_top_artist('') is None
client._client.search_artist.assert_not_called()
# ---------------------------------------------------------------------------
# Album search — routing
# ---------------------------------------------------------------------------
def test_search_albums_bare_query_uses_browse_path():
"""When a bare name resolves to an artist, we browse their release-groups
instead of text-searching release titles."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-1', 'title': 'Master of Puppets', 'primary-type': 'Album',
'first-release-date': '1986-03-03', 'secondary-types': []},
{'id': 'rg-2', 'title': 'Ride the Lightning', 'primary-type': 'Album',
'first-release-date': '1984-07-27', 'secondary-types': []},
]
albums = client.search_albums('metallica', limit=10)
client._client.browse_artist_release_groups.assert_called_once()
# Text-search path must NOT be taken.
client._client.search_release.assert_not_called()
# Chronological ASC — debut first, so the album list reads like a
# standard discography (Wikipedia-style: earliest release on top).
assert [a.name for a in albums] == ['Ride the Lightning', 'Master of Puppets']
assert all(a.artists == ['Metallica'] for a in albums)
def test_search_albums_structured_query_uses_text_path():
"""'Artist - Title' shape should text-search the title rather than
browsing all of the artist's discography."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_release.return_value = [
{'id': 'rel-1', 'title': 'Master of Puppets', 'score': 100,
'date': '1986', 'media': [{'track-count': 8}],
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Metallica'}]},
]
albums = client.search_albums('Metallica - Master of Puppets', limit=10)
client._client.search_release.assert_called_once()
# Artist-first path must NOT be taken.
client._client.search_artist.assert_not_called()
client._client.browse_artist_release_groups.assert_not_called()
assert len(albums) == 1
assert albums[0].name == 'Master of Puppets'
def test_search_albums_falls_back_to_text_when_no_artist_match():
"""No artist above threshold → text-search the whole query."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Artist lookup returns nothing above threshold.
client._client.search_artist.return_value = [_mk_artist('X', 'mb-x', score=40)]
client._client.search_release.return_value = []
client.search_albums('very obscure band')
client._client.search_release.assert_called_once_with('very obscure band', artist_name=None, limit=10)
client._client.browse_artist_release_groups.assert_not_called()
def test_search_albums_filters_live_and_compilation_secondary_types():
"""Mega-artists' browse results are dominated by live bootlegs and
best-of compilations they should be filtered out so the studio
discography surfaces."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-live-1', 'title': 'Live Bootleg 2019', 'primary-type': 'Album',
'first-release-date': '2019-01-01', 'secondary-types': ['Live']},
{'id': 'rg-studio-1', 'title': 'Kill Em All', 'primary-type': 'Album',
'first-release-date': '1983-07-25', 'secondary-types': []},
{'id': 'rg-comp-1', 'title': 'Greatest Hits', 'primary-type': 'Album',
'first-release-date': '2010-01-01', 'secondary-types': ['Compilation']},
{'id': 'rg-studio-2', 'title': 'Master of Puppets', 'primary-type': 'Album',
'first-release-date': '1986-03-03', 'secondary-types': []},
]
albums = client.search_albums('metallica', limit=10)
titles = [a.name for a in albums]
assert titles == ['Kill Em All', 'Master of Puppets']
assert 'Live Bootleg 2019' not in titles
assert 'Greatest Hits' not in titles
def test_search_albums_falls_back_to_all_when_no_studio():
"""Niche live-only artist: if no studio releases exist, show live ones
rather than returning empty."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('LiveBand', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-live-1', 'title': 'Live at X', 'primary-type': 'Album',
'first-release-date': '2019-01-01', 'secondary-types': ['Live']},
{'id': 'rg-live-2', 'title': 'Live at Y', 'primary-type': 'Album',
'first-release-date': '2020-01-01', 'secondary-types': ['Live']},
]
albums = client.search_albums('liveband', limit=10)
assert len(albums) == 2
def test_search_tracks_prefers_studio_release_in_album_field():
"""When a recording has both a studio release and a live release, the
Track.album should reflect the studio release (canonical album),
regardless of the order MB returned them in."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{
'id': 'rec-master',
'title': 'Master of Puppets',
'length': 516000,
'artist-credit': [{'name': 'Metallica'}],
# Live release first (what MB often returns), studio second.
'releases': [
{'id': 'rel-live', 'title': 'Live Bootleg', 'date': '2023-01-01',
'release-group': {'id': 'rg-live', 'primary-type': 'Album',
'secondary-types': ['Live']}},
{'id': 'rel-studio', 'title': 'Master of Puppets', 'date': '1986-03-03',
'release-group': {'id': 'rg-studio', 'primary-type': 'Album',
'secondary-types': []}},
],
},
]
tracks = client.search_tracks('metallica', limit=10)
assert len(tracks) == 1
# Album must be the studio release, not the live bootleg.
assert tracks[0].album == 'Master of Puppets'
assert tracks[0].release_date == '1986-03-03'
def test_search_tracks_filters_recordings_without_studio_releases():
"""A recording that only exists on live/compilation releases should be
dropped when we have studio alternatives."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-studio', 'title': 'Seek and Destroy', 'length': 390000,
'artist-credit': [{'name': 'Metallica'}],
'releases': [
{'id': 'rel-studio', 'title': 'Kill Em All', 'date': '1983-07-25',
'release-group': {'id': 'rg-studio', 'primary-type': 'Album',
'secondary-types': []}},
]},
{'id': 'rec-live-only', 'title': 'Fight Fire With Fire', 'length': 450000,
'artist-credit': [{'name': 'Metallica'}],
'releases': [
{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993-01-01',
'release-group': {'id': 'rg-live', 'primary-type': 'Album',
'secondary-types': ['Live']}},
]},
]
tracks = client.search_tracks('metallica', limit=10)
titles = [t.name for t in tracks]
assert 'Seek and Destroy' in titles
assert 'Fight Fire With Fire' not in titles
def test_search_albums_text_path_filters_by_score():
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Force text-search path by using a structured query.
client._client.search_release.return_value = [
{'id': 'rel-good', 'title': 'Good', 'score': 95,
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Foo'}]},
{'id': 'rel-bad', 'title': 'Bad', 'score': 40,
'release-group': {'id': 'rg-2', 'primary-type': 'Album'},
'artist-credit': [{'name': 'Foo'}]},
]
albums = client.search_albums('Foo - Good', limit=10)
titles = [a.name for a in albums]
assert 'Good' in titles
assert 'Bad' not in titles
# ---------------------------------------------------------------------------
# Track search — routing
# ---------------------------------------------------------------------------
def test_search_tracks_bare_query_uses_browse_path():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'}}],
'artist-credit': [{'name': 'Metallica'}]},
{'id': 'rec-2', 'title': 'Battery', 'length': 312000,
'releases': [{'id': 'rel-2', 'title': 'Master of Puppets', 'date': '1986',
'release-group': {'id': 'rg-2', 'primary-type': 'Album'}}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('metallica', limit=10)
client._client.search_recordings_by_artist_mbid.assert_called_once()
client._client.search_recording.assert_not_called()
assert len(tracks) == 2
assert {t.name for t in tracks} == {'One', 'Battery'}
def test_search_tracks_dedupes_by_title():
"""MusicBrainz has many live/compilation variants of the same song.
Browse results should be deduped by normalized title so we don't show
'One' three times."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('Metallica', 'mb-1', score=100)]
client._client.search_recordings_by_artist_mbid.return_value = [
{'id': 'rec-1', 'title': 'One', 'length': 446000,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]},
{'id': 'rec-1-live', 'title': 'One', 'length': 490000,
'releases': [{'id': 'rel-live', 'title': 'Live Shit', 'date': '1993'}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('metallica', limit=10)
assert len(tracks) == 1
assert tracks[0].name == 'One'
def test_search_tracks_structured_query_uses_text_path():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-1', 'title': 'One', 'score': 100,
'releases': [{'id': 'rel-1', 'title': '...And Justice for All', 'date': '1988'}],
'artist-credit': [{'name': 'Metallica'}]},
]
tracks = client.search_tracks('Metallica - One', limit=10)
client._client.search_recording.assert_called_once()
client._client.search_artist.assert_not_called()
client._client.search_recordings_by_artist_mbid.assert_not_called()
assert len(tracks) == 1
def test_get_album_resolves_release_group_mbid_to_release():
"""When the album ID is a release-group MBID (from the browse path),
get_album must look up the release-group, pick a canonical release,
and fetch that release's tracklist. Fetching /release/<rg-mbid>
directly 404s."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Release-group lookup returns two editions — an Official release and
# a promo. The Official earlier release should win.
client._client.get_release_group.return_value = {
'id': 'rg-damn',
'title': 'DAMN.',
'primary-type': 'Album',
'secondary-types': [],
'first-release-date': '2017-04-14',
'artist-credit': [{'name': 'Kendrick Lamar'}],
'releases': [
{'id': 'rel-promo', 'status': 'Promotion', 'date': '2017-04-01',
'media': [{'track-count': 14, 'tracks': []}]},
{'id': 'rel-official', 'status': 'Official', 'date': '2017-04-14',
'media': [{'track-count': 14, 'tracks': []}]},
],
}
# Release lookup returns a full release with tracklist.
client._client.get_release.return_value = {
'id': 'rel-official',
'title': 'DAMN.',
'date': '2017-04-14',
'artist-credit': [{'name': 'Kendrick Lamar'}],
'release-group': {'id': 'rg-damn', 'primary-type': 'Album', 'secondary-types': []},
'media': [
{'position': 1, 'tracks': [
{'id': 't1', 'number': '1', 'position': 1, 'length': 50000,
'recording': {'id': 'rec-1', 'title': 'BLOOD.',
'artist-credit': [{'name': 'Kendrick Lamar'}], 'length': 50000}},
]},
],
}
album = client.get_album('rg-damn')
# Must have called release-group first, then release for the picked edition.
client._client.get_release_group.assert_called_once_with(
'rg-damn', includes=['releases', 'artist-credits']
)
client._client.get_release.assert_called_once_with(
'rel-official', includes=['recordings', 'artist-credits', 'release-groups']
)
assert album is not None
assert album['id'] == 'rg-damn' # Canonical ID stays the release-group MBID.
assert album['name'] == 'DAMN.'
assert len(album['tracks']) == 1
assert album['tracks'][0]['name'] == 'BLOOD.'
assert 'release-group' in album['external_urls']['musicbrainz']
def test_get_album_falls_back_to_release_lookup_on_rg_miss():
"""When the MBID is a release (from the text-search fallback path) the
release-group lookup 404s, but the direct release lookup works."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
# Release-group lookup returns None (simulating 404).
client._client.get_release_group.return_value = None
client._client.get_release.return_value = {
'id': 'rel-abc',
'title': 'Some Album',
'date': '2020-01-01',
'artist-credit': [{'name': 'Some Artist'}],
'release-group': {'id': 'rg-abc', 'primary-type': 'Album', 'secondary-types': []},
'media': [{'position': 1, 'tracks': []}],
}
album = client.get_album('rel-abc')
client._client.get_release_group.assert_called_once()
client._client.get_release.assert_called_once()
assert album is not None
assert album['id'] == 'rel-abc' # Falls back to release MBID since rg lookup missed.
# ---------------------------------------------------------------------------
# Title-hint extraction — for "Artist Album Title" bare queries
# ---------------------------------------------------------------------------
def test_extract_title_hint_basic():
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_case_insensitive():
assert _extract_title_hint('the beatles abbey road', 'The Beatles') == 'abbey road'
def test_extract_title_hint_preserves_original_casing():
# Query slicing should return the original casing of the title portion.
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_whitespace_tolerant():
assert _extract_title_hint('The Beatles Abbey Road', 'The Beatles') == 'Abbey Road'
def test_extract_title_hint_bare_artist_returns_none():
assert _extract_title_hint('The Beatles', 'The Beatles') is None
def test_extract_title_hint_artist_not_prefix_returns_none():
# Query where the artist name isn't the prefix — nothing to extract.
assert _extract_title_hint('Abbey Road', 'The Beatles') is None
def test_extract_title_hint_word_boundary_required():
# "Metallicasomething" shouldn't split as artist=Metallica + hint=something
assert _extract_title_hint('Metallicasomething', 'Metallica') is None
def test_search_albums_filters_browse_results_by_title_hint():
"""Regression: 'The Beatles Abbey Road' used to return the whole
discography; should now narrow to Abbey Road specifically."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album',
'first-release-date': '1969-09-26', 'secondary-types': []},
{'id': 'rg-white', 'title': 'The Beatles', 'primary-type': 'Album',
'first-release-date': '1968-11-22', 'secondary-types': []},
{'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album',
'first-release-date': '1966-08-05', 'secondary-types': []},
]
albums = client.search_albums('The Beatles Abbey Road', limit=10)
# Filtered to only the album whose title matches the hint.
assert [a.name for a in albums] == ['Abbey Road']
def test_search_albums_falls_back_to_text_when_hint_matches_nothing():
"""If the title hint doesn't match any browse result, fall back to
text-search rather than returning the full discography or nothing."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
# Browse returns albums that don't match the hint.
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-1', 'title': 'Some Other Album', 'primary-type': 'Album',
'first-release-date': '1965-01-01', 'secondary-types': []},
]
# Text-search fallback (_search_albums_text → search_release) returns the album.
client._client.search_release.return_value = [
{'id': 'rel-abbey', 'title': 'Abbey Road', 'score': 100,
'release-group': {'id': 'rg-abbey', 'primary-type': 'Album'},
'artist-credit': [{'name': 'The Beatles'}]},
]
albums = client.search_albums('The Beatles Totally Fake Album Name', limit=10)
# Browse had no hit for the title hint, then fallback kicks in when
# the filter results are also empty (after studio-pref filter etc.).
# NOTE: in this test the hint filter returns empty, so we fall through
# to search_release.
client._client.search_release.assert_called_once()
assert any(a.name == 'Abbey Road' for a in albums)
def test_search_albums_bare_artist_no_hint_no_filter():
"""Bare artist name (no title hint) returns full discography — the
filter only kicks in when the user types extra words."""
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_artist.return_value = [_mk_artist('The Beatles', 'mb-1', score=100)]
client._client.browse_artist_release_groups.return_value = [
{'id': 'rg-abbey', 'title': 'Abbey Road', 'primary-type': 'Album',
'first-release-date': '1969-09-26', 'secondary-types': []},
{'id': 'rg-revolver', 'title': 'Revolver', 'primary-type': 'Album',
'first-release-date': '1966-08-05', 'secondary-types': []},
]
albums = client.search_albums('the beatles', limit=10)
# No filter — full discography.
titles = {a.name for a in albums}
assert 'Abbey Road' in titles
assert 'Revolver' in titles
def test_recording_to_track_total_tracks_matches_media_count():
"""Regression: total_tracks was initialized at 1 and summed with media
track-counts, producing an off-by-one. An 11-track album reported 12."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Song',
'length': 300000,
'artist-credit': [{'name': 'Band'}],
'releases': [{
'id': 'rel-1',
'title': 'Album',
'date': '2020-01-01',
'release-group': {'id': 'rg-1', 'primary-type': 'Album', 'secondary-types': []},
'media': [{'track-count': 11}],
}],
}
track = client._recording_to_track(recording, 'Band')
assert track is not None
assert track.total_tracks == 11
def test_recording_to_track_multi_disc_sums_media():
"""Two-disc album with 14 tracks total should report 14, not 15 (off by one)
or 3 (missing the sum)."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Song',
'artist-credit': [{'name': 'Band'}],
'releases': [{
'id': 'rel-1', 'title': 'Album',
'release-group': {'id': 'rg-1', 'primary-type': 'Album'},
'media': [{'track-count': 7}, {'track-count': 7}],
}],
}
track = client._recording_to_track(recording, 'Band')
assert track.total_tracks == 14
def test_recording_to_track_no_release_defaults_total_tracks_to_one():
"""A recording with no release info is a standalone track — report 1."""
client = MusicBrainzSearchClient()
recording = {
'id': 'rec-1',
'title': 'Standalone',
'artist-credit': [{'name': 'Band'}],
'releases': [],
}
track = client._recording_to_track(recording, 'Band')
assert track.total_tracks == 1
def test_pick_representative_release_prefers_official_with_media():
"""The release picker should skip stub releases (no media) and pick
Official over Promotion status."""
client = MusicBrainzSearchClient()
releases = [
{'id': 'stub', 'status': 'Official', 'date': '2020-01-01'}, # No media
{'id': 'promo', 'status': 'Promotion', 'date': '2019-12-01',
'media': [{'track-count': 10}]},
{'id': 'official', 'status': 'Official', 'date': '2020-01-05',
'media': [{'track-count': 10}]},
]
picked = client._pick_representative_release(releases)
assert picked['id'] == 'official'
def test_search_tracks_text_path_filters_by_score():
client = MusicBrainzSearchClient()
client._client = MagicMock()
client._client.search_recording.return_value = [
{'id': 'rec-good', 'title': 'Good', 'score': 95,
'releases': [{'id': 'rel-1', 'title': 'X', 'date': '2020'}],
'artist-credit': [{'name': 'Foo'}]},
{'id': 'rec-bad', 'title': 'Bad', 'score': 40,
'releases': [{'id': 'rel-2', 'title': 'Y', 'date': '2021'}],
'artist-credit': [{'name': 'Foo'}]},
]
tracks = client.search_tracks('Foo - Good', limit=10)
titles = [t.name for t in tracks]
assert 'Good' in titles
assert 'Bad' not in titles

View file

@ -0,0 +1,213 @@
"""Tests for the reorganize-queue DB helpers on `MusicDatabase`:
- ``get_album_display_meta(album_id)`` returns the title/artist tuple
the queue uses for status-panel display, or None when not found.
- ``get_artist_albums_for_reorganize(artist_id)`` returns the
bulk-enqueue list ordered by year then title.
These are isolated DB-method tests so the SQL itself is verified
without spinning up Flask, the queue worker, or the orchestrator.
"""
import sqlite3
import sys
import types
import pytest
# ── stubs (same shape used elsewhere in the test suite) ───────────────────
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from database.music_database import MusicDatabase # noqa: E402
# ── helpers ───────────────────────────────────────────────────────────────
class _InMemoryDB(MusicDatabase):
"""MusicDatabase that uses an in-memory sqlite that survives across
`_get_connection()` calls. Lets tests seed rows once and have the
methods under test see them."""
def __init__(self):
# Skip the real __init__ — it would try to migrate a real db.
self._conn = sqlite3.connect(":memory:")
self._conn.row_factory = sqlite3.Row
def _get_connection(self):
return _NonClosingConn(self._conn)
class _NonClosingConn:
"""Wraps the shared sqlite connection so `with db._get_connection()
as conn:` doesn't close the underlying handle between calls."""
def __init__(self, real):
self._real = real
def cursor(self):
return self._real.cursor()
def commit(self):
return self._real.commit()
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *args):
pass
def _seed(db, *, artists=(), albums=()):
cur = db._conn.cursor()
cur.execute("CREATE TABLE artists (id TEXT PRIMARY KEY, name TEXT)")
cur.execute("""
CREATE TABLE albums (
id TEXT PRIMARY KEY,
artist_id TEXT,
title TEXT,
year INTEGER
)
""")
for ar in artists:
cur.execute("INSERT INTO artists VALUES (?, ?)", ar)
for al in albums:
cur.execute(
"INSERT INTO albums (id, artist_id, title, year) VALUES (?, ?, ?, ?)",
al,
)
db._conn.commit()
@pytest.fixture
def db():
return _InMemoryDB()
# ── get_album_display_meta ────────────────────────────────────────────────
def test_get_album_display_meta_returns_dict_for_known_album(db):
_seed(db,
artists=[('ar-1', 'Kendrick Lamar')],
albums=[('alb-1', 'ar-1', 'good kid, m.A.A.d city', 2012)])
meta = db.get_album_display_meta('alb-1')
assert meta == {
'album_title': 'good kid, m.A.A.d city',
'artist_id': 'ar-1',
'artist_name': 'Kendrick Lamar',
}
def test_get_album_display_meta_returns_none_for_missing_album(db):
_seed(db, artists=[('ar-1', 'Aerosmith')])
assert db.get_album_display_meta('does-not-exist') is None
def test_get_album_display_meta_falls_back_for_blank_strings(db):
"""Albums with empty title or artist name in the DB still need a
safe display value the queue UI should never render '(blank)'."""
_seed(db,
artists=[('ar-1', '')],
albums=[('alb-1', 'ar-1', '', 2015)])
meta = db.get_album_display_meta('alb-1')
assert meta['album_title'] == 'Unknown Album'
assert meta['artist_name'] == 'Unknown Artist'
assert meta['artist_id'] == 'ar-1'
# ── get_artist_albums_for_reorganize ──────────────────────────────────────
def test_get_artist_albums_for_reorganize_orders_by_year_then_title(db):
_seed(db,
artists=[('ar-1', 'Aerosmith')],
albums=[
('alb-c', 'ar-1', 'Toys in the Attic', 1975),
('alb-a', 'ar-1', 'Aerosmith', 1973),
('alb-b', 'ar-1', 'Get Your Wings', 1974),
])
rows = db.get_artist_albums_for_reorganize('ar-1')
assert [r['album_id'] for r in rows] == ['alb-a', 'alb-b', 'alb-c']
assert all(r['artist_name'] == 'Aerosmith' for r in rows)
def test_get_artist_albums_for_reorganize_secondary_sorts_by_title(db):
"""Same release year → tiebreak on title alphabetically."""
_seed(db,
artists=[('ar-1', 'X')],
albums=[
('alb-z', 'ar-1', 'Zebra', 1990),
('alb-a', 'ar-1', 'Apple', 1990),
('alb-m', 'ar-1', 'Mango', 1990),
])
rows = db.get_artist_albums_for_reorganize('ar-1')
assert [r['album_title'] for r in rows] == ['Apple', 'Mango', 'Zebra']
def test_get_artist_albums_for_reorganize_returns_empty_for_unknown_artist(db):
_seed(db, artists=[('ar-1', 'Aerosmith')])
assert db.get_artist_albums_for_reorganize('not-a-real-artist') == []
def test_get_artist_albums_for_reorganize_isolates_by_artist(db):
"""Pulling albums for artist A must NOT leak in albums from artist B."""
_seed(db,
artists=[('ar-1', 'A'), ('ar-2', 'B')],
albums=[
('alb-1', 'ar-1', 'A1', 2000),
('alb-2', 'ar-2', 'B1', 2000),
('alb-3', 'ar-1', 'A2', 2001),
])
rows = db.get_artist_albums_for_reorganize('ar-1')
assert {r['album_id'] for r in rows} == {'alb-1', 'alb-3'}
# ── error propagation ────────────────────────────────────────────────────
# Regression for review feedback on the original PR: helpers used to
# swallow every Exception and return None / [], so a real DB outage
# masqueraded as "album not found" / "no albums". Now they let the
# error bubble — the route layer turns it into a 500 — so the user sees
# a real failure instead of a phantom empty state.
def test_get_album_display_meta_propagates_db_errors(db):
"""If the underlying tables don't exist, the helper must raise
rather than swallow it as a missing-album result."""
# Don't seed — the schema is empty, so the SELECT will fail with
# OperationalError ("no such table: albums").
with pytest.raises(sqlite3.OperationalError):
db.get_album_display_meta('alb-1')
def test_get_artist_albums_for_reorganize_propagates_db_errors(db):
with pytest.raises(sqlite3.OperationalError):
db.get_artist_albums_for_reorganize('ar-1')

View file

@ -0,0 +1,479 @@
"""Tests for `core.reorganize_queue.ReorganizeQueue`.
Contract this test file pins:
1. **Dedupe on enqueue** re-submitting an album that's already queued or
running returns ``{'queued': False, 'reason': 'already_queued'}`` and
the existing queue_id, never a duplicate.
2. **FIFO order** the worker drains items in submission order.
3. **Per-item source preserved** the source string the user picked at
enqueue time is what the runner sees, even when multiple items with
different sources are interleaved.
4. **Continue on failure** a runner that raises (or one whose summary
reports a non-completed status) marks that item failed and the
worker moves to the next item, it does not stall.
5. **Cancel queued** items in `queued` state can be dropped before
they reach the runner.
6. **Cancel running rejected** the currently-running item can NOT be
cancelled, the API returns `running_cant_cancel`.
7. **Clear queued** bulk-cancels all `queued` items at once, leaves
the running item alone.
8. **Snapshot shape** `active`, `queued`, `recent`, and `totals` keys
are always present and reflect the current state.
9. **update_active_progress** live progress fields propagate onto the
running item (and only the running item).
10. **Setting runner late** items enqueued before `set_runner()` was
called still get processed once the runner shows up.
"""
import threading
import time
import pytest
from core.reorganize_queue import ReorganizeQueue, QueueItem
# --- helpers ---------------------------------------------------------------
def _make_runner(record, *, raise_on=None, summary_factory=None,
block_event=None, runtime=0.0):
"""Build a runner closure that records what it was called with.
Args:
record: list to append `(queue_id, source)` to per call.
raise_on: queue_id (or set of queue_ids) for which the runner
should raise used to test continue-on-failure.
summary_factory: optional callable `(item) -> summary dict` to
override the default `{'status': 'completed', ...}` shape.
block_event: optional `threading.Event` the runner blocks on
before returning used to keep an item in 'running' state
while the test pokes at it.
runtime: seconds the runner sleeps before returning.
"""
raise_set = set()
if isinstance(raise_on, str):
raise_set = {raise_on}
elif raise_on:
raise_set = set(raise_on)
def runner(item):
record.append((item.queue_id, item.source))
if block_event is not None:
block_event.wait(timeout=2.0)
if runtime:
time.sleep(runtime)
if item.queue_id in raise_set:
raise RuntimeError(f"Simulated failure for {item.queue_id}")
if summary_factory is not None:
return summary_factory(item)
return {
'status': 'completed',
'source': item.source or 'spotify',
'total': 1,
'moved': 1,
'skipped': 0,
'failed': 0,
'errors': [],
}
return runner
def _enqueue(queue, *, album_id, source=None, title=None, artist='Aerosmith'):
return queue.enqueue(
album_id=album_id,
album_title=title or f"Album {album_id}",
artist_id='artist-1',
artist_name=artist,
source=source,
)
def _wait_for(predicate, timeout=2.0, interval=0.02):
"""Poll until predicate() is truthy or timeout elapses."""
deadline = time.time() + timeout
while time.time() < deadline:
if predicate():
return True
time.sleep(interval)
return False
@pytest.fixture
def queue():
q = ReorganizeQueue()
yield q
q.stop()
# --- tests -----------------------------------------------------------------
def test_enqueue_returns_queued_with_position(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
r1 = _enqueue(queue, album_id='alb-1')
# Wait for the worker to actually pick up alb-1 so r2 lands while
# alb-1 is running, not while it's still queued — otherwise the
# position number depends on thread-scheduling timing.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
r2 = _enqueue(queue, album_id='alb-2')
assert r1['queued'] is True
assert r1['position'] == 1
assert r2['queued'] is True
assert r2['position'] == 1
block.set()
def test_enqueue_same_album_dedupes(queue):
queue.set_runner(_make_runner([], block_event=threading.Event()))
r1 = _enqueue(queue, album_id='alb-1', source='spotify')
r2 = _enqueue(queue, album_id='alb-1', source='deezer') # different source
assert r1['queued'] is True
assert r2['queued'] is False
assert r2['reason'] == 'already_queued'
assert r2['queue_id'] == r1['queue_id']
def test_dedupe_releases_after_completion(queue):
"""Once an item finishes (done/failed/cancelled), the same album_id
can be re-enqueued. Otherwise users couldn't retry after a fix."""
record = []
queue.set_runner(_make_runner(record))
r1 = _enqueue(queue, album_id='alb-1')
assert _wait_for(lambda: any(r[0] == r1['queue_id'] for r in record))
# Wait for the item to flip into the recent bucket.
assert _wait_for(lambda: queue.snapshot()['active'] is None)
r2 = _enqueue(queue, album_id='alb-1')
assert r2['queued'] is True
assert r2['queue_id'] != r1['queue_id']
def test_fifo_order(queue):
record = []
queue.set_runner(_make_runner(record))
ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(5)]
assert _wait_for(lambda: len(record) == 5)
assert [r[0] for r in record] == ids
def test_per_item_source_preserved(queue):
record = []
queue.set_runner(_make_runner(record))
sources = ['spotify', 'deezer', 'itunes', None, 'discogs']
for i, src in enumerate(sources):
_enqueue(queue, album_id=f'alb-{i}', source=src)
assert _wait_for(lambda: len(record) == len(sources))
assert [r[1] for r in record] == sources
def test_continue_on_runner_exception(queue):
"""A runner that raises must not stall the queue — the item is
marked failed and the next item runs."""
record = []
# Pre-allocate queue_ids by enqueuing first, then point the runner
# at the middle one. Block the runner so all three sit in the queue
# before any actually run.
block = threading.Event()
raise_target = {}
def runner(item):
record.append((item.queue_id, item.source))
block.wait(timeout=2.0)
if item.queue_id == raise_target.get('id'):
raise RuntimeError(f"Simulated failure for {item.queue_id}")
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
queue.set_runner(runner)
ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(3)]
raise_target['id'] = ids[1]
block.set()
assert _wait_for(lambda: len(record) == 3)
assert [r[0] for r in record] == ids
assert _wait_for(lambda: queue.snapshot()['active'] is None)
snap = queue.snapshot()
recent_by_id = {r['queue_id']: r for r in snap['recent']}
assert recent_by_id[ids[0]]['status'] == 'done'
assert recent_by_id[ids[1]]['status'] == 'failed'
assert recent_by_id[ids[2]]['status'] == 'done'
def test_failed_status_when_runner_reports_failed_tracks(queue):
"""A summary with ``failed > 0`` should mark the queue item as
'failed' even if the runner returned normally."""
queue.set_runner(_make_runner([], summary_factory=lambda item: {
'status': 'completed',
'source': 'spotify',
'total': 5,
'moved': 4,
'skipped': 0,
'failed': 1,
'errors': [{'track_id': 't-1', 'title': 'X', 'error': 'boom'}],
}))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
# Wait for the item to land in `recent` (active is None both before
# the worker picks up the item and after it's done — only the
# presence in recent is unambiguous).
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
item = next(i for i in snap['recent'] if i['queue_id'] == qid)
assert item['status'] == 'failed'
assert item['moved'] == 4
assert item['failed'] == 1
assert item['error'] == 'boom'
def test_failed_status_when_runner_reports_non_completed_status(queue):
"""``status='no_source_id'`` and friends are setup-failures — they
leave failed=0 but the item is still NOT a success."""
queue.set_runner(_make_runner([], summary_factory=lambda item: {
'status': 'no_source_id',
'source': None,
'total': 0,
'moved': 0,
'skipped': 0,
'failed': 0,
'errors': [],
}))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
item = next(r for r in snap['recent'] if r['queue_id'] == qid)
assert item['status'] == 'failed'
assert item['result_status'] == 'no_source_id'
def test_cancel_queued_item(queue):
"""Cancel BEFORE the worker reaches the item drops it cleanly."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
first = _enqueue(queue, album_id='alb-1')['queue_id'] # gets pulled to running, blocks
second = _enqueue(queue, album_id='alb-2')['queue_id'] # sits in queued
# Wait for first to be running so we know the worker is parked on it.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
result = queue.cancel(second)
assert result['cancelled'] is True
snap = queue.snapshot()
assert all(i['queue_id'] != second for i in snap['queued'])
# And the cancelled one shows up in recent with status 'cancelled'.
assert any(i['queue_id'] == second and i['status'] == 'cancelled' for i in snap['recent'])
block.set() # release the running item
def test_cancel_running_rejected(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
result = queue.cancel(qid)
assert result['cancelled'] is False
assert result['reason'] == 'running_cant_cancel'
block.set()
def test_cancel_unknown_id(queue):
result = queue.cancel('does-not-exist')
assert result['cancelled'] is False
assert result['reason'] == 'not_found'
def test_clear_queued_bulk_cancel(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
_enqueue(queue, album_id='alb-1') # running, blocked
queued_ids = [_enqueue(queue, album_id=f'alb-{i}')['queue_id'] for i in range(2, 6)]
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
assert _wait_for(lambda: len(queue.snapshot()['queued']) == 4)
cancelled = queue.clear_queued()
assert cancelled == 4
snap = queue.snapshot()
assert len(snap['queued']) == 0
# Running item is untouched.
assert snap['active'] is not None
cancelled_in_recent = [i for i in snap['recent'] if i['status'] == 'cancelled']
assert {i['queue_id'] for i in cancelled_in_recent} == set(queued_ids)
block.set()
def test_snapshot_shape(queue):
snap = queue.snapshot()
assert set(snap.keys()) == {'active', 'queued', 'recent', 'totals'}
assert set(snap['totals'].keys()) >= {'queued', 'running', 'done', 'failed', 'cancelled'}
assert snap['active'] is None
assert snap['queued'] == []
assert snap['recent'] == []
def test_update_active_progress_only_targets_running(queue):
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
queue.update_active_progress(
queue_id=qid,
current_track='Dream On',
total=8,
processed=3,
moved=3,
skipped=0,
failed=0,
)
snap = queue.snapshot()
assert snap['active']['current_track'] == 'Dream On'
assert snap['active']['progress_total'] == 8
assert snap['active']['progress_processed'] == 3
assert snap['active']['moved'] == 3
block.set()
def test_update_progress_for_unknown_id_is_noop(queue):
"""Calling update_active_progress for an item that isn't running
must not raise, must not corrupt other items."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
qid = _enqueue(queue, album_id='alb-1')['queue_id']
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
queue.update_active_progress(queue_id='not-a-real-id', current_track='X', total=999)
snap = queue.snapshot()
assert snap['active']['queue_id'] == qid
assert snap['active']['progress_total'] == 0 # unchanged
block.set()
def test_enqueue_many_tallies_enqueued_and_dedupes(queue):
"""Bulk enqueue returns ``{enqueued, already_queued, total}`` so
the route handler doesn't have to count itself. Re-enqueuing the
same album-id twice in the same batch dedupes."""
block = threading.Event()
queue.set_runner(_make_runner([], block_event=block))
# Pre-existing item — should appear as already_queued.
queue.enqueue(album_id='alb-existing', album_title='X',
artist_id='ar-1', artist_name='A', source=None)
# Wait for it to be running so the dedupe path triggers.
assert _wait_for(lambda: queue.snapshot()['active'] is not None)
items = [
{'album_id': 'alb-existing', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-new-1', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-new-2', 'album_title': 'Z', 'artist_id': 'ar-1', 'artist_name': 'A'},
]
result = queue.enqueue_many(items)
assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3}
block.set()
def test_enqueue_many_carries_source_per_item(queue):
"""Each dict's ``source`` is honoured independently — the bulk
helper doesn't collapse them to one value."""
record = []
queue.set_runner(_make_runner(record))
items = [
{'album_id': 'a', 'album_title': 'A', 'artist_id': 'x', 'artist_name': 'X', 'source': 'spotify'},
{'album_id': 'b', 'album_title': 'B', 'artist_id': 'x', 'artist_name': 'X', 'source': 'deezer'},
{'album_id': 'c', 'album_title': 'C', 'artist_id': 'x', 'artist_name': 'X', 'source': None},
]
queue.enqueue_many(items)
assert _wait_for(lambda: len(record) == 3)
assert [r[1] for r in record] == ['spotify', 'deezer', None]
def test_enqueue_many_handles_empty_list(queue):
queue.set_runner(_make_runner([]))
assert queue.enqueue_many([]) == {'enqueued': 0, 'already_queued': 0, 'total': 0}
def test_enqueue_many_dedupes_batch_internal_duplicates(queue):
"""Same album_id appearing twice in the same bulk request must be
deduped against each other not just against pre-existing items.
Regression for the race where a fast runner finishes the first copy
before the loop reaches the second, letting both slip through."""
record = []
queue.set_runner(_make_runner(record))
items = [
{'album_id': 'alb-x', 'album_title': 'X', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-y', 'album_title': 'Y', 'artist_id': 'ar-1', 'artist_name': 'A'},
{'album_id': 'alb-x', 'album_title': 'X (dup)', 'artist_id': 'ar-1', 'artist_name': 'A'},
]
result = queue.enqueue_many(items)
assert result == {'enqueued': 2, 'already_queued': 1, 'total': 3}
# Wait for the queue to drain, then give the worker a moment to
# try (and fail) to pick a phantom third item. If the dedupe leaked,
# a third runner call would land here.
assert _wait_for(lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'])
time.sleep(0.05)
assert len(record) == 2
def test_cancel_and_run_are_mutually_exclusive(queue):
"""Regression for kettui's ``_next_queued() → status flip`` race:
a successfully-cancelled item must NEVER have its runner invoked.
With the old non-atomic pick + flip, cancel could land between
the worker's pick and its flip-to-running, leaving the item
marked 'cancelled' but the worker still runs it.
Hammers many enqueue-then-immediately-cancel pairs to exercise the
race window. After draining, every queue_id whose cancel returned
``cancelled: True`` must NOT appear in the runner's record."""
runner_called: set = set()
runner_lock = threading.Lock()
def runner(item):
with runner_lock:
runner_called.add(item.queue_id)
# Slight runtime widens the window where overlapping cancels
# could (incorrectly) fire on a running item.
time.sleep(0.002)
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
queue.set_runner(runner)
successful_cancels: set = set()
for i in range(50):
r = _enqueue(queue, album_id=f'alb-race-{i}')
# Immediately try to cancel — half will land while item is still
# 'queued', half will land after worker has flipped to 'running'.
if queue.cancel(r['queue_id'])['cancelled']:
successful_cancels.add(r['queue_id'])
assert _wait_for(
lambda: queue.snapshot()['active'] is None and not queue.snapshot()['queued'],
timeout=5.0,
)
leaked = successful_cancels & runner_called
assert not leaked, f"Runner ran for cancelled items: {leaked}"
def test_no_runner_marks_item_failed(queue):
"""If the worker pulls an item but no runner has been set, the item
must be marked failed (not silently dropped). In practice
web_server.py wires the runner at module load before any request
can land, so this is a defensive-failure path more than a real
one but the failure mode must be loud."""
queue.set_runner(None)
qid = _enqueue(queue, album_id='alb-orphan')['queue_id']
assert _wait_for(lambda: any(r['queue_id'] == qid for r in queue.snapshot()['recent']))
snap = queue.snapshot()
failed = next(i for i in snap['recent'] if i['queue_id'] == qid)
assert failed['status'] == 'failed'
assert 'runner' in (failed['error'] or '').lower()

View file

@ -0,0 +1,235 @@
"""Tests for `core.reorganize_runner.build_runner`.
Contract this test file pins:
1. **Runner is a closure** calling `build_runner` returns a callable
that takes a queue item and returns a summary dict matching
`reorganize_album`'s shape.
2. **Config is read per-run, not at factory time** changing the
download/transfer path between runs is honoured. Web server config
should never need a restart for this to take effect.
3. **Setup failure surfaces a clean summary** if the staging dir
cannot be created, the runner returns `status='setup_failed'`
instead of raising (so the queue marks the item failed cleanly).
4. **Progress callbacks fan out into the queue** the runner wires
`reorganize_album`'s `on_progress` to `update_active_progress` on
the live singleton queue, so the status panel sees per-track state.
5. **Dependencies are injected, not imported** the factory takes
every external dependency as a callable so tests can run without
spinning up Flask, the DB, or the post-process pipeline.
"""
import sys
import types
from unittest.mock import MagicMock
import pytest
# Stub config.settings so importing core.reorganize_runner -> core.library_reorganize doesn't blow up
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
def get_active_media_server(self):
return "primary"
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
spotipy.Spotify = object
oauth2 = types.ModuleType("spotipy.oauth2")
oauth2.SpotifyOAuth = object
oauth2.SpotifyClientCredentials = object
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
from core.reorganize_runner import build_runner # noqa: E402
@pytest.fixture(autouse=True)
def reset_queue_singleton():
"""Each test gets a fresh queue singleton so update_active_progress
in one test doesn't leak into another."""
from core.reorganize_queue import reset_queue_for_tests
reset_queue_for_tests()
yield
reset_queue_for_tests()
def _make_item(*, queue_id='qid-1', album_id='alb-1', source=None):
"""Mock queue item — only needs the fields the runner reads."""
item = MagicMock()
item.queue_id = queue_id
item.album_id = album_id
item.source = source
return item
def _build(monkeypatch, *, download_path_fn, transfer_path_fn,
reorganize_album_fn, get_database=lambda: object()):
"""Helper: stub out the heavy reorganize_album call so we can test
the wiring without a real DB / post-process pipeline."""
# Patch the import inside reorganize_runner.build_runner.
import core.reorganize_runner as mod
monkeypatch.setattr(
'core.library_reorganize.reorganize_album',
reorganize_album_fn,
raising=True,
)
return build_runner(
get_database=get_database,
resolve_file_path_fn=lambda p: p,
post_process_fn=lambda *a, **k: None,
cleanup_empty_directories_fn=lambda *a, **k: None,
is_shutting_down_fn=lambda: False,
get_download_path=download_path_fn,
get_transfer_path=transfer_path_fn,
)
def test_runner_invokes_reorganize_album_with_injected_deps(monkeypatch, tmp_path):
captured = {}
def fake_reorganize_album(**kwargs):
captured.update(kwargs)
return {
'status': 'completed', 'source': 'spotify',
'total': 1, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
runner = _build(
monkeypatch,
download_path_fn=lambda: str(tmp_path),
transfer_path_fn=lambda: str(tmp_path / 'transfer'),
reorganize_album_fn=fake_reorganize_album,
)
item = _make_item(album_id='alb-X', source='deezer')
summary = runner(item)
assert summary['status'] == 'completed'
assert captured['album_id'] == 'alb-X'
assert captured['primary_source'] == 'deezer'
assert captured['strict_source'] is True
# staging_root is download_path / ssync_staging
assert captured['staging_root'].endswith('ssync_staging')
assert callable(captured['on_progress'])
assert callable(captured['stop_check'])
def test_runner_reads_config_per_call(monkeypatch, tmp_path):
"""Path that the runner sees should reflect the value returned by
the path-resolver lambda AT call time not at build_runner time.
This is the explicit fix for kettui-style "config change requires
server restart" feedback."""
seen_staging_roots = []
def fake_reorganize_album(**kwargs):
seen_staging_roots.append(kwargs['staging_root'])
return {
'status': 'completed', 'source': None,
'total': 0, 'moved': 0, 'skipped': 0, 'failed': 0, 'errors': [],
}
current_path = {'value': str(tmp_path / 'first')}
runner = _build(
monkeypatch,
download_path_fn=lambda: current_path['value'],
transfer_path_fn=lambda: '/tmp/transfer',
reorganize_album_fn=fake_reorganize_album,
)
runner(_make_item())
current_path['value'] = str(tmp_path / 'second')
runner(_make_item())
assert len(seen_staging_roots) == 2
assert 'first' in seen_staging_roots[0]
assert 'second' in seen_staging_roots[1]
def test_runner_returns_setup_failed_on_unwritable_path(monkeypatch, tmp_path):
"""If the staging dir can't be created (permission denied, etc.),
the runner returns a clean ``setup_failed`` summary so the queue
marks the item failed without an unhandled exception."""
def fake_reorganize_album(**kwargs):
pytest.fail("reorganize_album should not run when setup fails")
# Point at a child of an existing FILE — makedirs will raise OSError.
blocking_file = tmp_path / 'blocker'
blocking_file.write_text('x')
runner = _build(
monkeypatch,
download_path_fn=lambda: str(blocking_file), # makedirs fails here
transfer_path_fn=lambda: '/tmp/transfer',
reorganize_album_fn=fake_reorganize_album,
)
summary = runner(_make_item())
assert summary['status'] == 'setup_failed'
assert summary['errors']
def test_runner_progress_callback_forwards_to_queue(monkeypatch, tmp_path):
"""When reorganize_album fires its on_progress callback, the runner
must forward into the live queue's update_active_progress so the
status panel sees per-track updates."""
from core.reorganize_queue import get_queue, ReorganizeQueue
import threading
# Use a real queue that's blocked on a runner — gives us a known
# 'running' item to propagate progress into.
block = threading.Event()
def fake_reorganize_album(*, on_progress, **kwargs):
# Simulate per-track progress emissions like the real
# orchestrator does.
on_progress({'current_track': 'Backseat Freestyle', 'total': 12, 'processed': 1})
on_progress({'moved': 1, 'processed': 1})
return {
'status': 'completed', 'source': 'spotify',
'total': 12, 'moved': 1, 'skipped': 0, 'failed': 0, 'errors': [],
}
runner = _build(
monkeypatch,
download_path_fn=lambda: str(tmp_path),
transfer_path_fn=lambda: str(tmp_path / 'transfer'),
reorganize_album_fn=fake_reorganize_album,
)
# Wire our runner into the singleton queue and enqueue an item, so
# update_active_progress has a 'running' item to write into.
q = get_queue()
q.set_runner(runner)
enq = q.enqueue(album_id='alb-1', album_title='good kid',
artist_id='ar-1', artist_name='Kendrick Lamar', source=None)
# Wait for the worker to finish (fake_reorganize_album is fast).
deadline_passes = 0
import time
while deadline_passes < 50:
snap = q.snapshot()
if any(r['queue_id'] == enq['queue_id'] for r in snap['recent']):
break
time.sleep(0.02)
deadline_passes += 1
snap = q.snapshot()
finished = next(r for r in snap['recent'] if r['queue_id'] == enq['queue_id'])
assert finished['status'] == 'done'
assert finished['moved'] == 1
# The progress fan-out happened *while* the item was running. The
# final snapshot shows the worker-set values — what we're really
# asserting is that progress callbacks didn't raise.

View file

@ -0,0 +1,305 @@
"""Tests for the script.js → module split integrity.
Verifies that:
- The monolithic script.js no longer exists
- All expected split modules are present on disk
- index.html loads all split modules via <script> tags
- core.js loads first and init.js loads last (ordering contract)
- No duplicate top-level function declarations across modules
- Every onclick="fn(…)" in index.html has a matching function
declaration in one of the split modules
- No module references undefined globals at parse time via
window.X = X assignments (the bug pattern we fixed)
"""
import os
import re
from pathlib import Path
from collections import defaultdict
import pytest
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_ROOT = Path(__file__).resolve().parent.parent
_STATIC = _ROOT / "webui" / "static"
_INDEX = _ROOT / "webui" / "index.html"
# The 17 modules that replaced script.js + shared-helpers.js extracted from
# artists.js (order matters for first/last checks)
SPLIT_MODULES = [
"core.js",
"shared-helpers.js",
"media-player.js",
"settings.js",
"search.js",
"sync-spotify.js",
"downloads.js",
"wishlist-tools.js",
"sync-services.js",
"api-monitor.js",
"library.js",
"beatport-ui.js",
"discover.js",
"enrichment.js",
"stats-automations.js",
"pages-extra.js",
"init.js",
]
# Other JS files that exist in static/ but are NOT part of the split
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js"}
# Pre-existing duplicate helper functions that lived in the original monolith.
# In a plain <script> context the last-loaded declaration wins. These are NOT
# regressions from the split — they should be deduplicated in a follow-up.
KNOWN_CROSS_FILE_DUPES = {
"escapeHtml", # downloads.js, shared-helpers.js, discover.js
"formatDuration", # sync-spotify.js, wishlist-tools.js, sync-services.js
"matchedDownloadTrack", # downloads.js, wishlist-tools.js
"matchedDownloadAlbum", # downloads.js, wishlist-tools.js
"matchedDownloadAlbumTrack", # downloads.js, wishlist-tools.js
"_esc", # library.js, stats-automations.js
"_escAttr", # downloads.js, stats-automations.js
"_formatDuration", # stats-automations.js, pages-extra.js
"loadDashboardData", # search.js, wishlist-tools.js
}
# Pre-existing same-file duplicates (two filter UIs reuse the same names).
KNOWN_SAME_FILE_DUPES = {
"applyFiltersAndSort",
"calculateRelevanceScore",
"handleFilterClick",
"initializeFilters",
"resetFilters",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_FUNC_DECL_RE = re.compile(r"^(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE)
_ONCLICK_RE = re.compile(r'onclick="([^"]*)"')
_ONCLICK_FN_RE = re.compile(r"^([A-Za-z_$][A-Za-z0-9_$]*)\s*\(")
_WINDOW_ASSIGN_RE = re.compile(
r"^window\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*;",
re.MULTILINE,
)
_SCRIPT_SRC_RE = re.compile(r"filename='([^']+\.js)'")
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def _all_function_decls(js_text: str) -> list[str]:
"""Return all top-level function declaration names in a JS file."""
return _FUNC_DECL_RE.findall(js_text)
def _script_load_order(html: str) -> list[str]:
"""Return the ordered list of JS filenames loaded from index.html."""
return _SCRIPT_SRC_RE.findall(html)
# =========================================================================
# Group A — File Existence
# =========================================================================
class TestFileExistence:
"""The old monolith is gone and all split modules are present."""
def test_monolith_removed(self):
assert not (_STATIC / "script.js").exists(), "script.js should have been removed"
@pytest.mark.parametrize("module", SPLIT_MODULES)
def test_split_module_exists(self, module):
path = _STATIC / module
assert path.exists(), f"{module} missing from webui/static/"
assert path.stat().st_size > 0, f"{module} is empty"
# =========================================================================
# Group B — index.html Script Loading
# =========================================================================
class TestScriptLoading:
"""index.html references every split module in the correct order."""
@pytest.fixture(autouse=True)
def _load_html(self):
self.html = _read(_INDEX)
self.loaded = _script_load_order(self.html)
@pytest.mark.parametrize("module", SPLIT_MODULES)
def test_module_loaded_in_html(self, module):
assert module in self.loaded, f"{module} not loaded in index.html"
def test_core_loads_first(self):
"""core.js must be the first split module loaded."""
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
assert split_in_html[0] == "core.js", (
f"Expected core.js first, got {split_in_html[0]}"
)
def test_init_loads_last(self):
"""init.js must be the last split module loaded."""
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
assert split_in_html[-1] == "init.js", (
f"Expected init.js last, got {split_in_html[-1]}"
)
def test_no_duplicate_script_tags(self):
"""Each module should only be loaded once."""
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
assert len(split_in_html) == len(set(split_in_html)), (
"Duplicate script tags detected"
)
# =========================================================================
# Group C — No Duplicate Function Declarations
# =========================================================================
class TestNoDuplicateFunctions:
"""No two split modules should declare the same top-level function."""
@pytest.fixture(autouse=True)
def _scan_all(self):
self.func_map: dict[str, list[str]] = defaultdict(list)
for module in SPLIT_MODULES:
text = _read(_STATIC / module)
for fn_name in _all_function_decls(text):
self.func_map[fn_name].append(module)
def test_no_new_cross_file_duplicates(self):
"""Catch NEW duplicate declarations; known pre-existing ones are allowed."""
dupes = {
fn: files
for fn, files in self.func_map.items()
if len(files) > 1
and fn not in KNOWN_CROSS_FILE_DUPES
and fn not in KNOWN_SAME_FILE_DUPES
}
assert not dupes, (
"NEW duplicate function declarations across modules:\n"
+ "\n".join(f" {fn}: {files}" for fn, files in sorted(dupes.items()))
)
def test_known_dupes_still_tracked(self):
"""Ensure the known-dupe set stays current (remove entries when deduped)."""
actual_dupes = {fn for fn, files in self.func_map.items() if len(files) > 1}
stale = (KNOWN_CROSS_FILE_DUPES | KNOWN_SAME_FILE_DUPES) - actual_dupes
assert not stale, (
f"These known duplicates were resolved — remove from KNOWN_*_DUPES:\n"
+ "\n".join(f" {fn}" for fn in sorted(stale))
)
# =========================================================================
# Group D — onclick Handler Coverage
# =========================================================================
class TestOnclickCoverage:
"""Every onclick="fn(…)" in index.html should have a matching
function declaration in the combined split modules."""
@pytest.fixture(autouse=True)
def _scan(self):
# Collect all function declarations from split modules
self.all_fns: set[str] = set()
for module in SPLIT_MODULES:
text = _read(_STATIC / module)
self.all_fns.update(_all_function_decls(text))
# Also include non-split JS files that are loaded
for extra in ("setup-wizard.js", "docs.js", "helper.js"):
path = _STATIC / extra
if path.exists():
self.all_fns.update(_all_function_decls(_read(path)))
# Extract all onclick function references from HTML
html = _read(_INDEX)
self.onclick_fns: set[str] = set()
for onclick_val in _ONCLICK_RE.findall(html):
m = _ONCLICK_FN_RE.match(onclick_val.strip())
if m:
fn_name = m.group(1)
# Skip JS keywords that happen to match (if, return, etc.)
if fn_name not in ("if", "return", "var", "let", "const", "this"):
self.onclick_fns.add(fn_name)
def test_all_onclick_handlers_defined(self):
missing = self.onclick_fns - self.all_fns
assert not missing, (
f"onclick handlers reference undefined functions:\n"
+ "\n".join(f" {fn}" for fn in sorted(missing))
)
def test_onclick_count_sanity(self):
"""Sanity check: there should be a substantial number of onclick handlers."""
assert len(self.onclick_fns) > 50, (
f"Only found {len(self.onclick_fns)} onclick handlers — expected 100+"
)
# =========================================================================
# Group E — No Dangerous Cross-File window.X = X Assignments
# =========================================================================
class TestNoCrossFileWindowAssignments:
"""window.X = X at the top level of a module is only safe if X is
defined in that same module. If X lives in a later-loading module,
this causes a ReferenceError at parse time."""
@pytest.fixture(autouse=True)
def _scan(self):
self.module_fns: dict[str, set[str]] = {}
self.window_assigns: dict[str, list[tuple[str, str]]] = defaultdict(list)
for module in SPLIT_MODULES:
text = _read(_STATIC / module)
self.module_fns[module] = set(_all_function_decls(text))
for prop, value in _WINDOW_ASSIGN_RE.findall(text):
self.window_assigns[module].append((prop, value))
def test_no_cross_file_references(self):
bad = []
for module, assigns in self.window_assigns.items():
local_fns = self.module_fns[module]
for prop, value in assigns:
if value not in local_fns:
bad.append(f" {module}: window.{prop} = {value} "
f"('{value}' not declared in {module})")
assert not bad, (
"Cross-file window.X = X assignments found (will cause ReferenceError):\n"
+ "\n".join(bad)
)
# =========================================================================
# Group F — Module Size Sanity
# =========================================================================
class TestModuleSizes:
"""No single module should be unreasonably large (regression guard)."""
MAX_LINES = 15000 # generous; largest module (wishlist-tools) is ~7200
@pytest.mark.parametrize("module", SPLIT_MODULES)
def test_module_size(self, module):
text = _read(_STATIC / module)
lines = text.count("\n") + 1
assert lines < self.MAX_LINES, (
f"{module} has {lines} lines (max {self.MAX_LINES})"
)
def test_total_lines_reasonable(self):
"""Combined split modules should be in the same ballpark as the original."""
total = 0
for module in SPLIT_MODULES:
total += _read(_STATIC / module).count("\n") + 1
# The original was ~78K lines; allow 60K-100K for flexibility
assert 50000 < total < 120000, f"Total lines: {total}"

View file

@ -68,7 +68,7 @@ class TestSpaRoutes:
"""Deep-link paths for valid client pages should serve index.html."""
@pytest.mark.parametrize("page", [
'dashboard', 'sync', 'downloads', 'discover', 'artists',
'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists',
'automations', 'library', 'import', 'settings', 'help',
'issues', 'stats', 'watchlist', 'wishlist', 'active-downloads',
'artist-detail', 'playlist-explorer', 'hydrabase', 'tools',

View file

@ -0,0 +1,164 @@
"""Regression tests for TidalDownloadClient._generate_shortened_queries.
The shortener's job: when Tidal's search chokes on a long query with
qualifier suffixes ("... (Fred V Remix)"), produce progressively-shorter
variants so the retry loop has a chance of finding results. These tests
pin the expected retry ladder shape for common real-world query patterns.
"""
import sys
import types
# Stub tidalapi before importing the module — it uses tidalapi.Quality at
# import time, and we don't want to require the package for unit tests.
if 'tidalapi' not in sys.modules:
_fake = types.ModuleType('tidalapi')
class _FakeQuality:
# Values mirror the real tidalapi Quality enum (the strings the
# Tidal API returns in `audioQuality`). Keeping these honest
# lets sibling tests that actually compare quality values rely
# on the same stub regardless of pytest collection order.
low_96k = 'LOW'
low_320k = 'HIGH'
high_lossless = 'LOSSLESS'
hi_res = 'HI_RES'
hi_res_lossless = 'HI_RES_LOSSLESS'
_fake.Quality = _FakeQuality
_fake.media = types.SimpleNamespace(Track=object)
sys.modules['tidalapi'] = _fake
from core.tidal_download_client import TidalDownloadClient # noqa: E402
def _ladder(original, cap=5):
"""Return the full retry ladder (original + variants), capped like the
runtime does."""
variants = TidalDownloadClient._generate_shortened_queries(original)
return [original] + variants[: cap - 1]
def test_skowl_reported_query_reaches_working_variant():
"""The user-reported failing query needs to reach
'maduk transformations remixed fire away' within the retry cap."""
original = 'maduk transformations remixed fire away fred v remix'
ladder = _ladder(original)
assert 'maduk transformations remixed fire away' in ladder
# And the original still starts the ladder
assert ladder[0] == original
def test_parenthesized_suffix_is_stripped_first():
"""The cheapest, most obvious shortening should come first for
parenthesized suffixes."""
variants = TidalDownloadClient._generate_shortened_queries('Song (Radio Edit)')
assert variants[0] == 'Song'
def test_bracketed_suffix_is_stripped():
variants = TidalDownloadClient._generate_shortened_queries('Song [Remix]')
assert 'Song' in variants
def test_short_queries_produce_no_variants():
# 1 or 2-word queries have nothing useful to shorten
assert TidalDownloadClient._generate_shortened_queries('one two') == []
assert TidalDownloadClient._generate_shortened_queries('single') == []
def test_variants_are_unique():
# Dedup guard — no variant should duplicate the original or another variant
original = 'Artist Title Club Mix Extended Version'
variants = TidalDownloadClient._generate_shortened_queries(original)
lower = [v.lower() for v in variants]
assert len(lower) == len(set(lower))
assert original.lower() not in lower
def test_progressive_drops_appear_in_ladder():
"""Drop-1, drop-2, drop-3 should all be present (in some order) for a
long query that has no parentheses."""
original = 'a b c d e f g h' # 8 tokens
variants = TidalDownloadClient._generate_shortened_queries(original)
# Drop-1 → 7 tokens; drop-2 → 6; drop-3 → 5
token_counts = [len(v.split()) for v in variants]
assert 7 in token_counts
assert 6 in token_counts
assert 5 in token_counts
def test_empty_query_returns_empty_list():
assert TidalDownloadClient._generate_shortened_queries('') == []
# ── Qualifier guard ────────────────────────────────────────────────────────
#
# When the original query carries a variant marker like "Live", "Remix",
# "Acoustic", fallback results must preserve that marker in the track name —
# otherwise a shortened query would silently downgrade "Song (Live)" to the
# studio "Song" and the caller would download the wrong variant.
def test_extract_qualifiers_finds_whole_word_matches():
# Word boundary: "remix" as a standalone word counts; "remixed" does not
q = TidalDownloadClient._extract_qualifiers(
'maduk transformations remixed fire away fred v remix'
)
assert 'remix' in q
# "mix" is inside "remix/remixed" but not a whole word
assert 'mix' not in q
def test_extract_qualifiers_is_case_insensitive():
q = TidalDownloadClient._extract_qualifiers('Song (LIVE at Wembley)')
assert 'live' in q
def test_extract_qualifiers_no_false_positives():
# "edit" must not match "edition"; "mix" must not match "remixed";
# "live" must not match "olive" / "deliver"
q = TidalDownloadClient._extract_qualifiers('Deluxe Edition Delivering Olive')
assert q == []
def test_extract_qualifiers_empty_query():
assert TidalDownloadClient._extract_qualifiers('') == []
assert TidalDownloadClient._extract_qualifiers(None) == []
def test_track_name_matches_when_all_qualifiers_present():
assert TidalDownloadClient._track_name_contains_qualifiers(
'Fire Away (Fred V Remix)', ['remix']
)
def test_track_name_rejects_when_qualifier_missing():
# Studio version should NOT pass when "remix" is required
assert not TidalDownloadClient._track_name_contains_qualifiers(
'Fire Away', ['remix']
)
def test_track_name_requires_all_qualifiers():
# "Live Acoustic" requires both
assert TidalDownloadClient._track_name_contains_qualifiers(
'Song (Live Acoustic)', ['live', 'acoustic']
)
# Missing one → rejected
assert not TidalDownloadClient._track_name_contains_qualifiers(
'Song (Live)', ['live', 'acoustic']
)
def test_track_name_empty_qualifiers_passes_everything():
# When no qualifiers required, any track passes (original-query behaviour)
assert TidalDownloadClient._track_name_contains_qualifiers('Anything', [])
def test_track_name_qualifier_is_word_bounded():
# "edit" qualifier must match "Edit" but not "Edition"
assert TidalDownloadClient._track_name_contains_qualifiers('Song (Radio Edit)', ['edit'])
assert not TidalDownloadClient._track_name_contains_qualifiers('Deluxe Edition', ['edit'])

View file

@ -0,0 +1,162 @@
"""Tests for `_verify_stream_tier` — the guard that rejects silent Tidal
quality downgrades so the fallback chain (or "HiRes only" with fallback
disabled) behaves the way users configure it to.
Without this check, a user with "HiRes only, no quality fallback" who
asks Tidal for a track that's only available in AAC 320kbps would
receive the 320kbps stream silently Tidal never raises, it just
serves the highest tier available and the downloader would accept
the m4a file and report success. Reported by Netti93.
Tiers ranked worst-to-best:
LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS
Accepting matches and upgrades, rejecting downgrades, rejecting
unrecognized values.
Note on the fake Quality values: tidalapi's real Quality enum has
VALUES that differ from the member names (e.g., `low_320k.value ==
'HIGH'`, `high_lossless.value == 'LOSSLESS'`). The stub mirrors real
values so the tests catch case-sensitivity regressions.
"""
import sys
import types
if 'tidalapi' not in sys.modules:
_fake = types.ModuleType('tidalapi')
class _FakeQuality:
low_96k = 'LOW'
low_320k = 'HIGH'
high_lossless = 'LOSSLESS'
hi_res = 'HI_RES'
hi_res_lossless = 'HI_RES_LOSSLESS'
_fake.Quality = _FakeQuality
_fake.media = types.SimpleNamespace(Track=object)
sys.modules['tidalapi'] = _fake
from core.tidal_download_client import QUALITY_MAP, _verify_stream_tier # noqa: E402
class _FakeStream:
"""Minimal stand-in for tidalapi.media.Stream."""
def __init__(self, audio_quality=None):
if audio_quality is not None:
self.audio_quality = audio_quality
# ---------------------------------------------------------------------------
# Match — served quality is exactly what was requested
# ---------------------------------------------------------------------------
def test_served_quality_matches_request():
stream = _FakeStream(audio_quality='HI_RES_LOSSLESS')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires')
assert ok is True
assert reason is None
# ---------------------------------------------------------------------------
# Upgrades — Tidal serving a higher tier than requested is accepted
# ---------------------------------------------------------------------------
def test_lossless_request_upgraded_to_hires_is_accepted():
"""If Tidal serves HI_RES_LOSSLESS on a LOSSLESS-tier request (rare
but possible on tracks flagged as such in Tidal's catalog), we take
the upgrade rejecting a better-than-asked tier would be user-
hostile."""
stream = _FakeStream(audio_quality='HI_RES_LOSSLESS')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless')
assert ok is True
assert reason is None
def test_lossless_request_upgraded_to_mqa_hires_is_accepted():
stream = _FakeStream(audio_quality='HI_RES')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless')
assert ok is True
assert reason is None
def test_low_request_upgraded_to_any_higher_tier_is_accepted():
stream = _FakeStream(audio_quality='LOSSLESS')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['low'], 'low')
assert ok is True
assert reason is None
# ---------------------------------------------------------------------------
# Downgrades — the reported bug
# ---------------------------------------------------------------------------
def test_hires_downgraded_to_aac_is_rejected():
"""The exact case Netti93 reported: asked HiRes, Tidal served
AAC 320kbps (`'HIGH'` in Tidal's API vocabulary)."""
stream = _FakeStream(audio_quality='HIGH')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires')
assert ok is False
assert 'HIGH' in reason
assert 'HI_RES_LOSSLESS' in reason
def test_hires_lossless_downgraded_to_mqa_hires_is_rejected():
"""User explicitly asked for HI_RES_LOSSLESS (true lossless HiRes).
Getting MQA-encoded HI_RES is a downgrade even though both are
"HiRes tier" marketing-wise MQA is lossy."""
stream = _FakeStream(audio_quality='HI_RES')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires')
assert ok is False
assert 'HI_RES_LOSSLESS' in reason
def test_lossless_downgraded_to_aac_is_rejected():
stream = _FakeStream(audio_quality='HIGH')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['lossless'], 'lossless')
assert ok is False
assert 'LOSSLESS' in reason
# ---------------------------------------------------------------------------
# Unknown quality strings — reject conservatively
# ---------------------------------------------------------------------------
def test_unknown_served_quality_is_rejected():
"""If Tidal introduces a new tier we haven't mapped yet, we can't
prove it's acceptable — reject rather than silently pass through,
so the next fallback tier gets a chance and the final diagnostic
log names the unknown value."""
stream = _FakeStream(audio_quality='SPATIAL_360_DREAM_TIER')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires')
assert ok is False
assert 'SPATIAL_360_DREAM_TIER' in reason
assert 'unrecognized' in reason.lower() or 'can\'t verify' in reason.lower()
# ---------------------------------------------------------------------------
# Defensive — missing attributes must not spuriously fail downloads
# ---------------------------------------------------------------------------
def test_stream_without_audio_quality_attr_is_accepted():
"""Older tidalapi versions may not expose audio_quality — treat as
"can't verify" and let pre-existing codec / file-size guards decide.
Better to miss a downgrade than break every Tidal download after a
library upgrade."""
stream = _FakeStream()
assert not hasattr(stream, 'audio_quality')
ok, reason = _verify_stream_tier(stream, QUALITY_MAP['hires'], 'hires')
assert ok is True
assert reason is None
def test_quality_info_without_tidal_quality_is_accepted():
"""If QUALITY_MAP somehow lacks 'tidal_quality' (tidalapi failed to
import at module load), don't spuriously reject streams."""
stream = _FakeStream(audio_quality='HI_RES_LOSSLESS')
ok, reason = _verify_stream_tier(stream, {'label': 'x'}, 'hires')
assert ok is True
assert reason is None

View file

@ -1,5 +1,9 @@
import sys
import types
from datetime import datetime, timedelta
_RECENT_RELEASE_DATE = (datetime.now() - timedelta(days=2)).strftime("%Y-%m-%d")
if "spotipy" not in sys.modules:
@ -344,25 +348,53 @@ def test_backfill_similar_artists_fallback_ids_uses_provider_priority(monkeypatc
assert [call[0] for call in itunes_client.search_calls] == ["iTunes Artist"]
def test_scan_watchlist_profile_loads_artists_and_applies_overrides(monkeypatch):
def test_scan_watchlist_profile_loads_artists_and_forwards_override_flag(monkeypatch):
# Global-override application moved from scan_watchlist_profile into
# scan_watchlist_artists so every entry point (including the automation
# auto-scan) respects it. scan_watchlist_profile now just forwards the
# apply_global_overrides flag through.
artist = _build_artist()
scanner = _build_scanner({"tracks": {"items": []}}, [artist])
loaded_profiles = []
override_calls = []
scan_calls = []
monkeypatch.setattr(scanner.database, "get_watchlist_artists", lambda profile_id=None: loaded_profiles.append(profile_id) or [artist])
monkeypatch.setattr(scanner, "_apply_global_watchlist_overrides", lambda artists: override_calls.append(list(artists)))
monkeypatch.setattr(scanner, "scan_watchlist_artists", lambda artists, **kwargs: scan_calls.append((list(artists), kwargs)) or ["ok"])
result = scanner.scan_watchlist_profile(42)
assert result == ["ok"]
assert loaded_profiles == [42]
assert override_calls and override_calls[0][0].artist_name == "Artist One"
assert scan_calls and scan_calls[0][0][0].artist_name == "Artist One"
assert scan_calls[0][1]["profile_id"] == 42
assert scan_calls[0][1]["apply_global_overrides"] is True
def test_scan_watchlist_artists_applies_global_overrides(monkeypatch):
# Regression guard: auto-watchlist automation bypassed overrides by
# calling scan_watchlist_artists directly. Overrides now run inside
# scan_watchlist_artists so every caller gets the global filter.
scanner = _build_scanner({"tracks": {"items": []}}, [])
override_calls = []
monkeypatch.setattr(scanner, "_apply_global_watchlist_overrides", lambda artists: override_calls.append(list(artists)))
# Empty artist list exits early but override must still have been invoked.
scanner.scan_watchlist_artists([])
assert override_calls == [[]]
def test_scan_watchlist_artists_skips_overrides_when_flag_false(monkeypatch):
scanner = _build_scanner({"tracks": {"items": []}}, [])
override_calls = []
monkeypatch.setattr(scanner, "_apply_global_watchlist_overrides", lambda artists: override_calls.append(list(artists)))
scanner.scan_watchlist_artists([], apply_global_overrides=False)
assert override_calls == []
def test_scan_watchlist_artists_scans_tracks_and_updates_state(monkeypatch):
@ -861,7 +893,7 @@ def test_cache_discovery_recent_albums_uses_primary_source_first(monkeypatch):
id="dz-album-1",
name="Recent Deezer Album",
album_type="album",
release_date="2026-04-01",
release_date=_RECENT_RELEASE_DATE,
image_url="https://example.com/deezer-album.jpg",
)
@ -913,7 +945,7 @@ def test_cache_discovery_recent_albums_falls_back_to_spotify_when_primary_has_no
id="sp-album-1",
name="Spotify Recent Album",
album_type="album",
release_date="2026-04-01",
release_date=_RECENT_RELEASE_DATE,
image_url="https://example.com/spotify-album.jpg",
)
spotify_client = _FakeSourceClient(
@ -924,7 +956,7 @@ def test_cache_discovery_recent_albums_falls_back_to_spotify_when_primary_has_no
"id": "sp-album-1",
"name": "Spotify Recent Album",
"images": [{"url": "https://example.com/spotify-album.jpg"}],
"release_date": "2026-04-01",
"release_date": _RECENT_RELEASE_DATE,
"popularity": 50,
"tracks": {"items": [{"id": "sp-track-1", "name": "Spotify Track", "artists": [{"name": "Fallback Artist"}]}]},
"artists": [{"id": "sp-artist"}],
@ -964,7 +996,7 @@ def test_update_discovery_pool_incremental_uses_source_priority(monkeypatch):
release = types.SimpleNamespace(
id="dz-release-1",
name="Incremental Release",
release_date="2026-04-16",
release_date=_RECENT_RELEASE_DATE,
album_type="album",
image_url="https://example.com/deezer-release.jpg",
)
@ -977,7 +1009,7 @@ def test_update_discovery_pool_incremental_uses_source_priority(monkeypatch):
"id": "dz-release-1",
"name": "Incremental Release",
"images": [{"url": "https://example.com/deezer-release.jpg"}],
"release_date": "2026-04-16",
"release_date": _RECENT_RELEASE_DATE,
"popularity": 10,
"tracks": {"items": [{"id": "dz-track-1", "name": "Incremental Track", "artists": [{"name": "Incremental Artist"}], "duration_ms": 180000}]},
"artists": [{"id": "dz-artist"}],
@ -991,7 +1023,7 @@ def test_update_discovery_pool_incremental_uses_source_priority(monkeypatch):
"id": "sp-release-1",
"name": "Spotify Incremental Release",
"images": [{"url": "https://example.com/spotify-release.jpg"}],
"release_date": "2026-04-16",
"release_date": _RECENT_RELEASE_DATE,
"popularity": 50,
"tracks": {"items": [{"id": "sp-track-1", "name": "Spotify Incremental Track", "artists": [{"name": "Incremental Artist"}], "duration_ms": 180000}]},
"artists": [{"id": "sp-artist"}],

View file

@ -2,7 +2,7 @@ from pathlib import Path
def test_websocket_client_prefers_polling_before_websocket():
script_path = Path(__file__).resolve().parents[1] / "webui" / "static" / "script.js"
script_path = Path(__file__).resolve().parents[1] / "webui" / "static" / "core.js"
script = script_path.read_text(encoding="utf-8")
assert "transports: ['polling', 'websocket']" in script

View file

@ -0,0 +1,116 @@
"""Tests for `worker_utils.set_album_api_track_count` — the shared helper
enrichment workers call to cache authoritative track counts."""
from core.worker_utils import set_album_api_track_count
class _RecordingCursor:
"""Minimal cursor stand-in that captures execute() calls."""
def __init__(self):
self.calls = []
def execute(self, query, params=None):
self.calls.append((query, params))
# ---------------------------------------------------------------------------
# Happy-path writes
# ---------------------------------------------------------------------------
def test_writes_positive_int_count():
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-1", 12)
assert len(cursor.calls) == 1
query, params = cursor.calls[0]
assert "UPDATE albums SET api_track_count = ?" in query
assert "WHERE id = ?" in query
assert params == (12, "album-1")
def test_coerces_numeric_string_to_int():
"""Deezer / raw API dicts often have track counts as strings."""
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-2", "14")
assert cursor.calls[0][1] == (14, "album-2")
def test_writes_one_for_single_track_album():
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-single", 1)
assert cursor.calls[0][1] == (1, "album-single")
# ---------------------------------------------------------------------------
# Skip-write cases (don't overwrite good values with bad ones)
# ---------------------------------------------------------------------------
def test_skips_write_when_count_is_zero():
"""A source that doesn't report track counts must not clobber a value
written by another source."""
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-x", 0)
assert cursor.calls == []
def test_skips_write_when_count_is_none():
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-x", None)
assert cursor.calls == []
def test_skips_write_when_count_is_negative():
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-x", -1)
assert cursor.calls == []
def test_skips_write_on_non_numeric_string():
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-x", "not a number")
assert cursor.calls == []
def test_skips_write_on_non_numeric_object():
cursor = _RecordingCursor()
set_album_api_track_count(cursor, "album-x", object())
assert cursor.calls == []
# ---------------------------------------------------------------------------
# Does not commit — caller owns the transaction
# ---------------------------------------------------------------------------
def test_helper_does_not_commit():
"""Workers batch multiple UPDATEs into one transaction. The helper
must not call commit() or it would break that batching."""
class _StrictCursor(_RecordingCursor):
commits = 0
def commit(self): # pragma: no cover — asserts it's never called
_StrictCursor.commits += 1
cursor = _StrictCursor()
set_album_api_track_count(cursor, "album-y", 5)
assert _StrictCursor.commits == 0
# ---------------------------------------------------------------------------
# Error isolation — a cursor.execute failure must not poison the worker's
# other UPDATEs in the same transaction
# ---------------------------------------------------------------------------
def test_swallows_cursor_execute_errors():
"""If the column doesn't exist yet (e.g., migration hasn't run) or
the DB is otherwise unhappy, the helper must not propagate the error.
Otherwise the worker's other UPDATEs (spotify_album_id, thumb_url,
etc.) batched in the same transaction would roll back."""
class _BrokenCursor:
def execute(self, query, params=None):
raise RuntimeError("no such column: api_track_count")
cursor = _BrokenCursor()
# Should not raise.
set_album_api_track_count(cursor, "album-z", 10)

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,10 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0, user-scalable=yes">
<title>SoulSync - Music Sync & Manager</title>
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css') }}">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='favicon.png', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='mobile.css', v=static_v) }}">
<link rel="stylesheet" href="{{ url_for('static', filename='setup-wizard.css', v=static_v) }}">
</head>
<body>
@ -98,9 +98,8 @@
<option value="">Default (Discover)</option>
<option value="dashboard">Dashboard</option>
<option value="sync">Sync</option>
<option value="downloads">Search</option>
<option value="search">Search</option>
<option value="discover">Discover</option>
<option value="artists">Artists</option>
<option value="automations">Automations</option>
<option value="active-downloads">Downloads</option>
<option value="library">Library</option>
@ -113,9 +112,8 @@
<div id="new-profile-allowed-pages" class="profile-page-checkboxes">
<label><input type="checkbox" value="dashboard" checked> Dashboard</label>
<label><input type="checkbox" value="sync" checked> Sync</label>
<label><input type="checkbox" value="downloads" checked> Search</label>
<label><input type="checkbox" value="search" checked> Search</label>
<label><input type="checkbox" value="discover" checked> Discover</label>
<label><input type="checkbox" value="artists" checked> Artists</label>
<label><input type="checkbox" value="automations" checked> Automations</label>
<label><input type="checkbox" value="active-downloads" checked> Downloads</label>
<label><input type="checkbox" value="library" checked> Library</label>
@ -196,7 +194,7 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg></span>
<span class="nav-text">Sync</span>
</button>
<button class="nav-button" data-page="downloads">
<button class="nav-button" data-page="search">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><polyline points="8 11 11 14 14 11"/></svg></span>
<span class="nav-text">Search</span>
</button>
@ -208,10 +206,6 @@
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="5" r="3"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="12" x2="5" y2="18"/><line x1="12" y1="12" x2="19" y2="18"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="19" r="2"/><line x1="12" y1="12" x2="12" y2="18"/><circle cx="12" cy="19" r="2"/></svg></span>
<span class="nav-text">Explorer</span>
</button>
<button class="nav-button" data-page="artists">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg></span>
<span class="nav-text">Artists</span>
</button>
<button class="nav-button" data-page="watchlist">
<span class="nav-icon"><svg class="nav-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="nav-text">Watchlist</span>
@ -276,7 +270,7 @@
<!-- Version Section -->
<div class="version-section">
<button class="version-button" onclick="showVersionInfo()">v2.3</button>
<button class="version-button" onclick="showVersionInfo()">v2.4.0</button>
</div>
<!-- Status Section -->
@ -1843,15 +1837,15 @@
</div>
<!-- Downloads Page -->
<div class="page" id="downloads-page">
<div class="page" id="search-page">
<!--
This top-level container replicates the QSplitter from downloads.py,
creating the two-panel layout for the page.
-->
<div class="downloads-content manager-hidden">
<div class="downloads-content">
<!-- ======================================================= -->
<!-- == LEFT PANEL: Search, Filters, and Results == -->
<!-- == Search page main panel == -->
<!-- ======================================================= -->
<div class="downloads-main-panel">
@ -1859,30 +1853,16 @@
<div class="downloads-header">
<div class="downloads-header-content">
<div class="downloads-header-text">
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Music Downloads</span></h2>
<p class="downloads-subtitle">Search, discover, and download high-quality music</p>
<h2 class="downloads-title"><img src="/static/search.png" class="page-header-icon" alt=""><span>Search</span></h2>
<p class="downloads-subtitle">Find artists, albums, and tracks from any metadata source</p>
</div>
<button id="toggle-download-manager-btn" class="toggle-manager-btn"
title="Toggle Download Manager">
<span class="toggle-icon"></span>
</button>
</div>
</div>
<!-- Search Mode Toggle -->
<div class="search-mode-toggle-container">
<div class="search-mode-toggle" data-active="enhanced">
<button class="search-mode-btn active" data-mode="enhanced">
<span class="mode-icon"></span>
<span class="mode-label">Enhanced Search</span>
</button>
<button class="search-mode-btn" data-mode="basic">
<span class="mode-icon">🔍</span>
<span class="mode-label">Basic Search</span>
</button>
<div class="toggle-slider"></div>
</div>
</div>
<!-- Search source picker — icon row populated by search.js.
Each icon triggers a single-source fetch (no fan-out);
results are cached per (query, source) pair. -->
<div id="enh-source-row" class="enh-source-row" role="tablist" aria-label="Search source"></div>
<!-- Basic Search Section (Current) -->
<div id="basic-search-section" class="search-section">
@ -1995,10 +1975,6 @@
placeholder="Search for artists, albums, or tracks...">
<button id="enhanced-cancel-btn" class="enhanced-cancel-btn hidden"></button>
</div>
<button id="enhanced-search-btn" class="enhanced-search-btn">
<span class="btn-icon">👁️</span>
<span class="btn-text">Show Results</span>
</button>
</div>
<!-- Enhanced Search Dropdown (Overlay Panel) -->
@ -2024,8 +2000,9 @@
<!-- Results Container -->
<div id="enhanced-results-container" class="enhanced-results-container hidden">
<!-- Source Tabs -->
<div id="enh-source-tabs" class="enh-source-tabs hidden"></div>
<!-- Fallback banner — shown when user clicked Spotify but backend
served Deezer due to rate-limit, etc. Populated by search.js. -->
<div id="enh-fallback-banner" class="enh-fallback-banner hidden"></div>
<!-- Artists Container (Side by Side) -->
<div class="enh-artists-wrapper">
@ -2104,187 +2081,9 @@
<!-- End Enhanced Search Section -->
</div>
<!-- ======================================================= -->
<!-- == RIGHT PANEL: Controls and Download Queue == -->
<!-- ======================================================= -->
<div class="downloads-side-panel">
<!-- Controls Panel: Replicates create_collapsible_controls_panel() -->
<div class="controls-panel">
<h3 class="controls-panel__header">Download Manager</h3>
<div class="controls-panel__stats">
<p id="active-downloads-label">• Active Downloads: 0</p>
<p id="finished-downloads-label">• Finished Downloads: 0</p>
</div>
<div class="controls-panel__actions">
<button class="controls-panel__clear-btn">🗑️ Clear Completed</button>
<button class="controls-panel__cancel-all-btn">⛔ Clear Current</button>
</div>
</div>
<!-- Download Queue: Replicates TabbedDownloadManager -->
<div class="download-manager">
<div class="download-manager__tabs">
<button class="tab-btn active" data-tab="active-queue">Download Queue (0)</button>
<button class="tab-btn" data-tab="finished-queue">Finished (0)</button>
</div>
<div class="download-manager__content">
<!-- Active Queue -->
<div class="download-queue active" id="active-queue">
<div class="download-queue__empty-message">No active downloads.</div>
</div>
<!-- Finished Queue -->
<div class="download-queue" id="finished-queue">
<div class="download-queue__empty-message">No finished downloads.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Artists Page -->
<div class="page" id="artists-page">
<!-- Initial Search State -->
<div class="artists-search-state" id="artists-search-state">
<div class="artists-search-container">
<div class="artists-welcome-section">
<h2 class="artists-welcome-title"><img src="/static/discover.png" class="page-header-icon" alt=""><span>Discover Artists</span></h2>
<p class="artists-welcome-subtitle">Search for your favorite artists and explore their
complete discography</p>
</div>
<div class="artists-search-input-container">
<input type="text" id="artists-search-input" class="artists-search-input"
placeholder="Search for an artist...">
<div class="artists-search-icon">🔍</div>
</div>
<div class="artists-search-status" id="artists-search-status">
Start typing to search for artists
</div>
</div>
</div>
<!-- Search Results State -->
<div class="artists-results-state hidden" id="artists-results-state">
<div class="artists-results-header">
<button class="artists-back-button" id="artists-back-button">
<span class="back-icon"></span>
<span>Back to Search</span>
</button>
<div class="artists-search-header">
<input type="text" id="artists-header-search-input" class="artists-header-search-input"
placeholder="Search for an artist...">
</div>
</div>
<div class="artists-results-content">
<div class="artists-results-title">Search Results</div>
<div class="artists-cards-container" id="artists-cards-container">
<!-- Artist cards will be dynamically populated here -->
</div>
</div>
</div>
<!-- Artist Detail State -->
<div class="artist-detail-state hidden" id="artist-detail-state">
<!-- Hero Section -->
<div class="artists-hero-section" id="artists-hero-section">
<div class="artists-hero-bg" id="artists-hero-bg"></div>
<div class="artists-hero-overlay"></div>
<div class="artists-hero-content">
<button class="artists-hero-back" id="artist-detail-back-button">
<span></span> Back
</button>
<div class="artists-hero-main">
<div class="artists-hero-image" id="artists-hero-image"></div>
<div class="artists-hero-info">
<h1 class="artists-hero-name" id="artists-hero-name">Artist Name</h1>
<div class="artists-hero-badges" id="artists-hero-badges"></div>
<div class="artists-hero-genres" id="artists-hero-genres"></div>
<div class="artists-hero-bio" id="artists-hero-bio"></div>
<div class="artists-hero-stats" id="artists-hero-stats"></div>
<button class="discog-download-btn discog-btn-compact" id="discog-download-btn-artists" onclick="openDiscographyModal()" style="display:none;">
<span class="discog-btn-icon"></span>
<span class="discog-btn-text">Download Discography</span>
<span class="discog-btn-shimmer"></span>
</button>
</div>
</div>
<div class="artists-hero-actions">
<button class="artist-detail-watchlist-btn" id="artist-detail-watchlist-btn">
<span class="watchlist-icon">👁️</span>
<span class="watchlist-text">Add to Watchlist</span>
</button>
<button class="artist-detail-watchlist-settings-btn hidden" id="artist-detail-watchlist-settings-btn" title="Watchlist Settings">
&#9881;
</button>
</div>
</div>
</div>
<!-- Keep old hidden elements for backward compatibility with JS refs -->
<div id="search-artist-detail-image" style="display:none"></div>
<div id="search-artist-detail-name" style="display:none"></div>
<div id="search-artist-detail-genres" style="display:none"></div>
<div class="artist-detail-content">
<div class="artist-detail-tabs">
<button class="artist-tab active" data-tab="albums" id="albums-tab">
<span class="tab-icon">💿</span>
<span>Albums</span>
</button>
<button class="artist-tab" data-tab="singles" id="singles-tab">
<span class="tab-icon">🎵</span>
<span>Singles & EPs</span>
</button>
</div>
<div class="artist-detail-discography">
<div class="tab-content active" id="albums-content">
<div class="album-cards-container" id="album-cards-container">
<!-- Album cards will be populated here -->
</div>
</div>
<div class="tab-content" id="singles-content">
<div class="singles-cards-container" id="singles-cards-container">
<!-- Singles cards will be populated here -->
</div>
</div>
</div>
<!-- Similar Artists Section -->
<div class="similar-artists-section" id="similar-artists-section">
<div class="similar-artists-header">
<h3 class="similar-artists-title">Similar Artists</h3>
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
</div>
<!-- Loading State -->
<div class="similar-artists-loading hidden" id="similar-artists-loading">
<div class="loading-spinner-small"></div>
<span>Finding similar artists...</span>
</div>
<!-- Error State -->
<div class="similar-artists-error hidden" id="similar-artists-error">
<span class="error-icon">⚠️</span>
<span class="error-text">Unable to load similar artists</span>
</div>
<!-- Similar Artists Bubbles Container -->
<div class="similar-artists-bubbles-container" id="similar-artists-bubbles-container">
<!-- Artist bubble cards will be populated here -->
</div>
</div>
</div>
</div>
</div>
<!-- Automations Page -->
<div class="page" id="automations-page">
@ -2355,6 +2154,7 @@
</div>
<div style="display:flex;align-items:center;gap:10px;">
<span class="adl-count" id="adl-count"></span>
<button class="adl-cancel-all-btn" id="adl-cancel-all-btn" onclick="adlCancelAll()" style="display:none" title="Cancel all active and queued downloads">Cancel All</button>
<button class="adl-clear-btn" id="adl-clear-btn" onclick="adlClearCompleted()" style="display:none">Clear Completed</button>
</div>
</div>
@ -2503,11 +2303,19 @@
<!-- Artist cards will be populated here -->
</div>
<!-- Empty State -->
<!-- Empty State — populated by showLibraryEmpty() in library.js.
Shows a generic "no artists" message by default; when the
user's search has no library matches, it switches to a
search-online CTA that hands off to the /search page. -->
<div class="library-empty hidden" id="library-empty">
<div class="empty-icon">🎵</div>
<div class="empty-title">No artists found</div>
<div class="empty-subtitle">Try adjusting your search or filters</div>
<div class="empty-icon" id="library-empty-icon">🎵</div>
<div class="empty-title" id="library-empty-title">No artists found</div>
<div class="empty-subtitle" id="library-empty-subtitle">Try adjusting your search or filters</div>
<button class="library-empty-search-cta hidden" id="library-empty-search-cta">
<span class="library-empty-search-cta-icon">🔍</span>
<span class="library-empty-search-cta-text">Search online for <span id="library-empty-search-cta-query"></span></span>
<span class="library-empty-search-cta-arrow"></span>
</button>
</div>
<!-- Pagination -->
@ -2541,6 +2349,10 @@
<!-- Artist Hero Section -->
<div class="artist-hero-section" id="artist-hero-section">
<!-- Blurred background image + dark overlay (inline-Artists hero treatment) -->
<div class="artist-detail-hero-bg" id="artist-detail-hero-bg"></div>
<div class="artist-detail-hero-overlay"></div>
<div class="artist-hero-content">
<!-- Left: Image -->
<div class="artist-image-container">
@ -2711,6 +2523,27 @@
</div>
</div>
<!-- Similar Artists Section (works for both library and source artists).
Uses its own scoped IDs because the inline Artists page has a section
with the same base IDs and both elements live in the DOM at once. -->
<div class="similar-artists-section" id="ad-similar-artists-section">
<div class="similar-artists-header">
<h3 class="similar-artists-title">Similar Artists</h3>
<p class="similar-artists-subtitle">Discover artists with a similar sound</p>
</div>
<div class="similar-artists-loading hidden" id="ad-similar-artists-loading">
<div class="loading-spinner-small"></div>
<span>Finding similar artists...</span>
</div>
<div class="similar-artists-error hidden" id="ad-similar-artists-error">
<span class="error-icon">⚠️</span>
<span class="error-text">Unable to load similar artists</span>
</div>
<div class="similar-artists-bubbles-container" id="ad-similar-artists-bubbles-container">
<!-- Artist bubble cards will be populated here -->
</div>
</div>
<!-- Enhanced Library Management View -->
<div class="enhanced-view-container hidden" id="enhanced-view-container">
<!-- Populated dynamically by renderEnhancedView() -->
@ -3871,7 +3704,7 @@
</div>
<!-- Spotify Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="spotify">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #1DB954;"></span>
<h4 class="service-title spotify-title">Spotify</h4>
@ -3914,7 +3747,7 @@
</div>
<!-- iTunes Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="itunes">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #fc3c44;"></span>
<h4 class="service-title itunes-title">iTunes / Apple Music</h4>
@ -3949,7 +3782,7 @@
</div>
<!-- Deezer OAuth Auth -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="deezer">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #A238FF;"></span>
<h4 class="service-title deezer-title">Deezer (Favorites & Playlists)</h4>
@ -3995,7 +3828,7 @@
</div>
<!-- Discogs Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="discogs">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #e0d4b8;"></span>
<h4 class="service-title">Discogs</h4>
@ -4017,7 +3850,7 @@
</div>
<!-- Tidal Playlist/Metadata Auth -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="tidal">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #ff6600;"></span>
<h4 class="service-title tidal-title">Tidal (Playlists & Metadata)</h4>
@ -4052,7 +3885,7 @@
</div>
<!-- Qobuz Metadata/Enrichment Auth -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="qobuz">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #4285f4;"></span>
<h4 class="service-title qobuz-title">Qobuz (Metadata & Enrichment)</h4>
@ -4109,7 +3942,7 @@
</div>
<!-- Last.fm Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="lastfm">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #d51007;"></span>
<h4 class="service-title lastfm-title">Last.fm</h4>
@ -4146,7 +3979,7 @@
</div>
<!-- Genius Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="genius">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #ffff64;"></span>
<h4 class="service-title genius-title">Genius</h4>
@ -4168,7 +4001,7 @@
</div>
<!-- AcoustID Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="acoustid">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #ba55d3;"></span>
<h4 class="service-title acoustid-title">AcoustID Verification</h4>
@ -4205,7 +4038,7 @@
</div>
<!-- ListenBrainz Settings -->
<div class="api-service-frame stg-service">
<div class="api-service-frame stg-service" data-service="listenbrainz">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #eb743b;"></span>
<h4 class="service-title listenbrainz-title">ListenBrainz</h4>
@ -4238,7 +4071,7 @@
</div>
<!-- Hydrabase P2P Metadata -->
<div class="api-service-frame stg-service" data-stg="connections">
<div class="api-service-frame stg-service" data-stg="connections" data-service="hydrabase">
<div class="stg-service-header" onclick="toggleStgService(this)">
<span class="stg-service-dot" style="color: #00b4d8;"></span>
<h4 class="service-title">Hydrabase</h4>
@ -5098,7 +4931,7 @@
<input type="text" id="template-album-path"
placeholder="$albumartist/$albumartist - $album/$track - $title">
<small class="settings-hint">Variables: $albumartist, $artist, $artistletter, $album, $albumtype,
$title, $track, $disc (01), $discnum (1), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums</small>
$title, $track, $disc (01), $discnum (1), $cdnum (CD01 on multi-disc, empty on single), $year, $quality (filename only). Use ${var} to append text: ${albumtype}s → Albums</small>
</div>
<div class="form-group">
@ -6979,8 +6812,8 @@
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="rgba(255,255,255,0.15)" stroke-width="1.5"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
</div>
<h3>Your watchlist is empty</h3>
<p>Add artists from the Artists page to monitor them for new releases.</p>
<button class="watchlist-action-btn watchlist-action-primary" onclick="navigateToPage('artists')">Browse Artists</button>
<p>Use Search to find an artist, then add them to your watchlist from the artist page.</p>
<button class="watchlist-action-btn watchlist-action-primary" onclick="navigateToPage('search')">Open Search</button>
</div>
</div>
</div>
@ -8027,11 +7860,33 @@
</div>
</div>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/socket.io.min.js', v=static_v) }}"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
<script src="{{ url_for('static', filename='script.js') }}"></script>
<script src="{{ url_for('static', filename='setup-wizard.js', v=static_v) }}"></script>
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
<script src="{{ url_for('static', filename='core.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='shared-helpers.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='media-player.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='settings.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='search.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-spotify.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='downloads.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='wishlist-tools.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='discover.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='enrichment.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='stats-automations.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='pages-extra.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='init.js', v=static_v) }}"></script>
<!-- Notification bell + floating helper toggle — always accessible above modals -->
<!-- Ambient glow under the global search bar. Radial gradient, brightest
directly under the bar, tapering out toward the window corners.
Purely decorative (pointer-events: none). Visibility follows the bar
via _gsUpdateVisibility(). -->
<div class="gsearch-aura" id="gsearch-aura"></div>
<!-- Global Search Bar — Spotlight-style search from anywhere -->
<div class="gsearch-bar" id="gsearch-bar">
<div class="gsearch-icon">
@ -8108,10 +7963,10 @@
<span>?</span>
</button>
<script src="{{ url_for('static', filename='docs.js') }}"></script>
<script src="{{ url_for('static', filename='helper.js') }}"></script>
<script src="{{ url_for('static', filename='particles.js') }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js') }}"></script>
<script src="{{ url_for('static', filename='docs.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='helper.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='particles.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='worker-orbs.js', v=static_v) }}"></script>
</body>
</html>

3794
webui/static/api-monitor.js Normal file

File diff suppressed because it is too large Load diff

3903
webui/static/beatport-ui.js Normal file

File diff suppressed because it is too large Load diff

879
webui/static/core.js Normal file
View file

@ -0,0 +1,879 @@
// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
// Global state management
let currentPage = 'dashboard';
let currentTrack = null;
let isPlaying = false;
let mediaPlayerExpanded = false;
let searchResults = [];
let currentStream = {
status: 'stopped',
progress: 0,
track: null
};
let currentMusicSourceName = 'Spotify'; // 'Spotify', 'iTunes', or 'Deezer' - updated from status endpoint
// Streaming state management (enhanced functionality)
let streamStatusPoller = null;
let audioPlayer = null;
let streamPollingRetries = 0;
let streamPollingInterval = 1000; // Start with 1-second polling
const maxStreamPollingRetries = 10;
let allSearchResults = [];
let currentFilterType = 'all';
let currentFilterFormat = 'all';
let currentSortBy = 'quality_score';
let isSortReversed = false;
let searchAbortController = null;
let dbStatsInterval = null;
let dbUpdateStatusInterval = null;
let qualityScannerStatusInterval = null;
let duplicateCleanerStatusInterval = null;
let wishlistCountInterval = null;
let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal
let watchlistCountdownInterval = null; // Countdown timer for watchlist overview modal
// Page state for Watchlist & Wishlist sidebar pages
let watchlistPageState = { isInitialized: false, artists: [] };
let wishlistPageState = { isInitialized: false };
// --- Add these globals for the Sync Page ---
let spotifyPlaylists = [];
let selectedPlaylists = new Set();
let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId
// Phase 5: WebSocket sync/discovery/scan state
let _syncProgressCallbacks = {};
let _discoveryProgressCallbacks = {};
let _lastWatchlistScanStatus = null;
let _lastMediaScanStatus = null;
let _lastWishlistStats = null;
let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array
let spotifyPlaylistsLoaded = false;
let activeDownloadProcesses = {};
let sequentialSyncManager = null;
// --- YouTube Playlist State Management ---
let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state
let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId
// --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) ---
let tidalPlaylists = [];
let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
let tidalPlaylistsLoaded = false;
let deezerPlaylists = [];
let deezerPlaylistStates = {};
let deezerArlPlaylists = [];
let deezerArlPlaylistsLoaded = false;
// --- Beatport Chart State Management (Similar to YouTube/Tidal) ---
let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases
let beatportContentState = {
loaded: false,
loadingPromise: null,
abortController: null
};
function getBeatportContentSignal() {
return beatportContentState.abortController ? beatportContentState.abortController.signal : null;
}
function throwIfBeatportLoadAborted() {
if (beatportContentState.abortController && beatportContentState.abortController.signal.aborted) {
throw new DOMException('Beatport load aborted', 'AbortError');
}
}
function stopBeatportDiscoveryAndSyncPolling() {
Object.entries(activeYouTubePollers).forEach(([identifier, poller]) => {
const isBeatportChart = !!youtubePlaylistStates[identifier]?.is_beatport_playlist ||
!!beatportChartStates[identifier];
if (isBeatportChart) {
clearInterval(poller);
delete activeYouTubePollers[identifier];
}
});
Object.entries(_discoveryProgressCallbacks).forEach(([identifier]) => {
const isBeatportChart = !!youtubePlaylistStates[identifier]?.is_beatport_playlist ||
!!beatportChartStates[identifier];
if (isBeatportChart) {
if (socketConnected) socket.emit('discovery:unsubscribe', { ids: [identifier] });
delete _discoveryProgressCallbacks[identifier];
}
});
Object.entries(_syncProgressCallbacks).forEach(([syncPlaylistId]) => {
const beatportState = Object.values(youtubePlaylistStates).find(state =>
state && state.is_beatport_playlist && state.syncPlaylistId === syncPlaylistId
);
if (beatportState) {
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
delete _syncProgressCallbacks[syncPlaylistId];
}
});
}
function resetBeatportSliderInitFlags() {
const rebuildSlider = document.getElementById('beatport-rebuild-slider');
if (rebuildSlider) rebuildSlider.dataset.initialized = 'false';
const releasesSlider = document.getElementById('beatport-releases-slider');
if (releasesSlider) releasesSlider.dataset.initialized = 'false';
beatportReleasesSliderState.isInitialized = false;
beatportHypePicksSliderState.isInitialized = false;
const chartsSlider = document.getElementById('beatport-charts-slider');
if (chartsSlider) chartsSlider.dataset.initialized = 'false';
beatportChartsSliderState.isInitialized = false;
const djSlider = document.getElementById('beatport-dj-slider');
if (djSlider) djSlider.dataset.initialized = 'false';
beatportDJSliderState.isInitialized = false;
}
function cleanupBeatportContent() {
const wasLoaded = beatportContentState.loaded || !!beatportContentState.loadingPromise;
if (!wasLoaded) return;
console.log('🧹 Cleaning up Beatport content...');
if (beatportContentState.abortController) {
beatportContentState.abortController.abort();
beatportContentState.abortController = null;
}
stopBeatportDiscoveryAndSyncPolling();
cleanupBeatportRebuildSlider();
cleanupBeatportReleasesSlider();
cleanupBeatportHypePicksSlider();
cleanupBeatportChartsSlider();
cleanupBeatportDJSlider();
resetBeatportSliderInitFlags();
beatportContentState.loadingPromise = null;
beatportContentState.loaded = false;
console.log('✅ Beatport content cleaned up');
}
// --- ListenBrainz Playlist State Management (Similar to YouTube/Tidal/Beatport) ---
let listenbrainzPlaylistStates = {}; // Key: playlist_mbid, Value: playlist state with phases
let listenbrainzPlaylistsLoaded = false; // Track if playlists have been loaded from backend
// --- Artists Page State Management ---
let artistsPageState = {
currentView: 'search', // 'search', 'results', 'detail'
searchQuery: '',
searchResults: [],
selectedArtist: null,
sourceOverride: null, // Set when navigating from an alternate search tab
artistDiscography: {
albums: [],
singles: []
},
cache: {
searches: {}, // Cache search results by query
discography: {}, // Cache discography by artist ID
colors: {}, // Cache extracted colors by image URL
completionData: {} // Cache completion data by artist ID
},
isInitialized: false // Track if the page has been initialized
};
// --- Artist Downloads Management State ---
let artistDownloadBubbles = {}; // Track artist download bubbles: artistId -> { artist, downloads: [], element }
let artistDownloadModalOpen = false; // Track if artist download modal is open
let downloadsUpdateTimeout = null; // Debounce downloads section updates
// --- Search Downloads Management State ---
let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] }
let searchDownloadModalOpen = false; // Track if search download modal is open
// --- Beatport Downloads Management State ---
let beatportDownloadBubbles = {}; // Track Beatport download bubbles: chartKey -> { chart: { name, image }, downloads: [] }
let beatportDownloadsUpdateTimeout = null; // Debounce Beatport downloads section updates
let artistsSearchTimeout = null;
let artistsSearchController = null;
let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away
let similarArtistsController = null; // Track ongoing similar artists stream to cancel when navigating away
// --- Lazy Background Image Observer ---
// Watches elements with data-bg-src, applies background-image when visible, unobserves after.
const lazyBgObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const el = entry.target;
const src = el.dataset.bgSrc;
if (src) {
el.style.backgroundImage = `url('${src}')`;
delete el.dataset.bgSrc;
}
lazyBgObserver.unobserve(el);
}
});
}, { rootMargin: '200px' });
/**
* Observe all elements with data-bg-src within a container for lazy background loading.
*/
function observeLazyBackgrounds(container) {
if (!container) return;
const elements = container.querySelectorAll('[data-bg-src]');
elements.forEach(el => lazyBgObserver.observe(el));
}
// ===============================
// CONFIRM DIALOG (themed replacement for native confirm())
// ===============================
let _confirmResolver = null;
function showConfirmDialog({ title = 'Confirm', message = '', confirmText = 'Confirm', cancelText = 'Cancel', destructive = false } = {}) {
// Resolve any pending dialog as cancelled before opening a new one
if (_confirmResolver) {
_confirmResolver(false);
_confirmResolver = null;
}
const overlay = document.getElementById('confirm-modal-overlay');
const titleEl = document.getElementById('confirm-modal-title');
const messageEl = document.getElementById('confirm-modal-message');
const confirmBtn = document.getElementById('confirm-modal-confirm');
const cancelBtn = document.getElementById('confirm-modal-cancel');
titleEl.textContent = title;
messageEl.textContent = message;
confirmBtn.textContent = confirmText;
cancelBtn.textContent = cancelText;
// Toggle destructive (red) vs primary (accent) confirm button
confirmBtn.className = destructive
? 'modal-button modal-button--cancel'
: 'modal-button modal-button--primary';
overlay.classList.remove('hidden');
return new Promise(resolve => {
_confirmResolver = resolve;
});
}
function resolveConfirmDialog(result) {
const overlay = document.getElementById('confirm-modal-overlay');
overlay.classList.add('hidden');
if (_confirmResolver) {
_confirmResolver(result);
_confirmResolver = null;
}
}
/**
* Nuclear confirmation dialog for mass-destructive operations.
* User must type an exact phrase to proceed.
*/
function showWitnessMeDialog(orphanCount) {
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'confirm-modal-overlay';
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
overlay.innerHTML = `
<div style="background:var(--bg-secondary, #1e1e2e);border:2px solid #e74c3c;border-radius:12px;padding:28px;max-width:480px;width:90%;color:var(--text-primary, #fff);font-family:inherit;">
<h3 style="margin:0 0 8px;color:#e74c3c;font-size:1.2em;">Mass Deletion Warning</h3>
<p style="margin:0 0 12px;font-size:0.95em;opacity:0.9;">
You are about to <strong>permanently delete ${orphanCount.toLocaleString()} files</strong> from your disk.
</p>
<p style="margin:0 0 12px;font-size:0.9em;opacity:0.75;">
This many orphans usually means a path mismatch between your database and filesystem
not actual orphan files. A previous user lost their entire library this way.
</p>
<p style="margin:0 0 6px;font-size:0.9em;opacity:0.9;">
To confirm you understand the risk, type <strong style="color:#e74c3c;">witness me</strong> below:
</p>
<input type="text" id="witness-me-input" autocomplete="off" spellcheck="false"
placeholder="Type the phrase here..."
style="width:100%;padding:10px;border:1px solid #555;border-radius:6px;background:var(--bg-primary, #111);color:var(--text-primary, #fff);font-size:1em;margin:8px 0 16px;box-sizing:border-box;">
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="witness-cancel" style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:var(--text-primary, #fff);cursor:pointer;font-size:0.9em;">
Cancel
</button>
<button id="witness-confirm" disabled
style="padding:8px 20px;border:none;border-radius:6px;background:#555;color:#888;cursor:not-allowed;font-size:0.9em;font-weight:600;transition:all 0.2s;">
Delete Files
</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#witness-me-input');
const confirmBtn = overlay.querySelector('#witness-confirm');
const cancelBtn = overlay.querySelector('#witness-cancel');
input.addEventListener('input', () => {
const match = input.value.trim().toLowerCase() === 'witness me';
confirmBtn.disabled = !match;
confirmBtn.style.background = match ? '#e74c3c' : '#555';
confirmBtn.style.color = match ? '#fff' : '#888';
confirmBtn.style.cursor = match ? 'pointer' : 'not-allowed';
});
confirmBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(true);
});
cancelBtn.addEventListener('click', () => {
document.body.removeChild(overlay);
resolve(false);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
document.body.removeChild(overlay);
resolve(false);
}
});
setTimeout(() => input.focus(), 100);
});
}
const MASS_ORPHAN_THRESHOLD = 20;
function _isMassOrphanFix(jobId, count) {
if (count <= MASS_ORPHAN_THRESHOLD) return false;
// Only trigger if mass_orphan flag is actually set on visible findings
// (flag is set by backend when >50% of files are orphans — likely path mismatch)
if (jobId === 'orphan_file_detector' || !jobId) {
const massCards = document.querySelectorAll('.repair-finding-card[data-mass-orphan="true"]');
if (massCards.length > 0) return true;
}
return false;
}
// ===============================
// WEBSOCKET CONNECTION MANAGER
// ===============================
let socket = null;
let socketConnected = false;
function initializeWebSocket() {
if (typeof io === 'undefined') {
console.warn('Socket.IO client not loaded — falling back to HTTP polling');
return;
}
socket = io({
transports: ['polling', 'websocket'],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
timeout: 20000
});
socket.on('connect', () => {
console.log('WebSocket connected');
socketConnected = true;
resubscribeDownloadBatches();
// Re-subscribe to any active sync/discovery rooms after reconnect
const activeSyncIds = Object.keys(_syncProgressCallbacks);
if (activeSyncIds.length > 0) {
socket.emit('sync:subscribe', { playlist_ids: activeSyncIds });
console.log('🔄 Re-subscribed to sync rooms:', activeSyncIds);
}
const activeDiscoveryIds = Object.keys(_discoveryProgressCallbacks);
if (activeDiscoveryIds.length > 0) {
socket.emit('discovery:subscribe', { ids: activeDiscoveryIds });
console.log('🔄 Re-subscribed to discovery rooms:', activeDiscoveryIds);
}
// Join profile room for scoped watchlist/wishlist count updates
if (currentProfile) {
socket.emit('profile:join', { profile_id: currentProfile.id });
}
});
socket.on('disconnect', (reason) => {
console.warn('WebSocket disconnected:', reason);
socketConnected = false;
});
socket.on('reconnect', (attemptNumber) => {
console.log(`WebSocket reconnected after ${attemptNumber} attempts`);
// Rejoin profile room for scoped WebSocket emits
if (currentProfile) {
socket.emit('profile:join', { profile_id: currentProfile.id });
}
// Phase 1: Full state refresh on reconnect
fetchAndUpdateServiceStatus();
updateWatchlistButtonCount();
resubscribeDownloadBatches();
// Phase 2: Refresh dashboard data if on dashboard page
if (currentPage === 'dashboard') {
fetchAndUpdateSystemStats();
fetchAndUpdateActivityFeed();
fetchAndUpdateDbStats();
updateWishlistCount();
}
});
// Phase 1 event listeners
socket.on('status:update', handleServiceStatusUpdate);
socket.on('watchlist:count', handleWatchlistCountUpdate);
socket.on('downloads:batch_update', handleDownloadBatchUpdate);
// Phase 2 event listeners (dashboard pollers)
socket.on('rate-monitor:update', _handleRateMonitorUpdate);
socket.on('dashboard:stats', handleDashboardStats);
socket.on('dashboard:activity', handleDashboardActivity);
socket.on('dashboard:toast', handleDashboardToast);
socket.on('dashboard:db_stats', handleDashboardDbStats);
socket.on('dashboard:wishlist_count', handleDashboardWishlistCount);
// Phase 3 event listeners (enrichment sidebar workers)
socket.on('enrichment:musicbrainz', (data) => updateMusicBrainzStatusFromData(data));
socket.on('enrichment:audiodb', (data) => updateAudioDBStatusFromData(data));
socket.on('enrichment:discogs', (data) => updateDiscogsStatusFromData(data));
socket.on('enrichment:deezer', (data) => updateDeezerStatusFromData(data));
socket.on('enrichment:spotify-enrichment', (data) => updateSpotifyEnrichmentStatusFromData(data));
socket.on('enrichment:itunes-enrichment', (data) => updateiTunesEnrichmentStatusFromData(data));
socket.on('enrichment:lastfm-enrichment', (data) => updateLastFMEnrichmentStatusFromData(data));
socket.on('enrichment:genius-enrichment', (data) => updateGeniusEnrichmentStatusFromData(data));
socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data));
socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data));
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
socket.on('enrichment:listening-stats', () => { }); // Status only, no UI update needed
socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data));
// Phase 4 event listeners (tool progress)
socket.on('tool:stream', (data) => updateStreamStatusFromData(data));
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data));
socket.on('tool:retag', (data) => updateRetagStatusFromData(data));
socket.on('tool:db-update', (data) => updateDbProgressFromData(data));
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
socket.on('tool:logs', (data) => updateLogsFromData(data));
// Phase 5 event listeners (sync/discovery progress + scans)
socket.on('sync:progress', (data) => updateSyncProgressFromData(data));
socket.on('discovery:progress', (data) => updateDiscoveryProgressFromData(data));
socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data));
socket.on('scan:media', (data) => updateMediaScanFromData(data));
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
// Phase 6: Automation progress
socket.on('automation:progress', (data) => updateAutomationProgressFromData(data));
}
function handleServiceStatusUpdate(data) {
// Cache for library status card
_lastServiceStatus = data;
// Same logic as fetchAndUpdateServiceStatus response handler
updateServiceStatus('spotify', data.spotify);
updateServiceStatus('media-server', data.media_server);
updateServiceStatus('soulseek', data.soulseek);
updateSidebarServiceStatus('spotify', data.spotify);
updateSidebarServiceStatus('media-server', data.media_server);
updateSidebarServiceStatus('soulseek', data.soulseek);
// Update downloads nav badge from status push
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
// Hide sync buttons (not the page) for standalone mode — playlists still browsable/downloadable
const isSoulsyncStandalone = data.media_server?.type === 'soulsync';
_isSoulsyncStandalone = isSoulsyncStandalone;
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
if (isSoulsyncStandalone) {
btn.dataset.hiddenByStandalone = '1';
btn.style.display = 'none';
} else if (btn.dataset.hiddenByStandalone) {
delete btn.dataset.hiddenByStandalone;
btn.style.display = '';
}
// If not standalone and not previously hidden by standalone, leave display untouched
// (preserves display:none on undiscovered LB/Last.fm playlist sync buttons)
});
// Update enrichment service cards
if (data.enrichment) renderEnrichmentCards(data.enrichment);
// Spotify rate limit / cooldown / recovery
if (data.spotify?.rate_limited && data.spotify.rate_limit) {
handleSpotifyRateLimit(data.spotify.rate_limit);
_spotifyInCooldown = false;
} else if (data.spotify?.post_ban_cooldown > 0) {
if (_spotifyRateLimitShown && !_spotifyInCooldown) {
_spotifyRateLimitShown = false;
_spotifyInCooldown = true;
closeRateLimitModal();
showToast('Spotify ban expired \u2014 recovering shortly', 'info');
}
} else {
if (_spotifyInCooldown) {
_spotifyInCooldown = false;
showToast('Spotify access restored', 'success');
if (currentPage === 'discover') {
loadDiscoverPage();
}
} else if (_spotifyRateLimitShown) {
handleSpotifyRateLimit(null);
}
}
}
function _updateHeroBtnCount(buttonId, badgeId, count) {
const badge = document.getElementById(badgeId);
if (badge) {
badge.textContent = count;
badge.classList.toggle('has-items', count > 0);
}
}
function handleWatchlistCountUpdate(data) {
if (data.success) {
_updateHeroBtnCount('watchlist-button', 'watchlist-badge', data.count);
// Update sidebar nav badge
const wlNavBadge = document.getElementById('watchlist-nav-badge');
if (wlNavBadge) {
wlNavBadge.textContent = data.count;
wlNavBadge.classList.toggle('hidden', data.count === 0);
}
const watchlistButton = document.getElementById('watchlist-button');
if (watchlistButton) {
const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : '';
if (countdownText) {
watchlistButton.title = `Next auto-scan in ${countdownText}`;
}
}
}
}
function handleDownloadBatchUpdate(payload) {
const { batch_id, data } = payload;
// Find which playlistId maps to this batch_id
for (const [playlistId, process] of Object.entries(activeDownloadProcesses)) {
if (process.batchId === batch_id) {
processModalStatusUpdate(playlistId, data);
break;
}
}
}
function resubscribeDownloadBatches() {
if (!socket || !socketConnected) return;
const activeBatchIds = [];
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
if (process.batchId && (process.status === 'running' || process.status === 'complete')) {
activeBatchIds.push(process.batchId);
}
});
if (activeBatchIds.length > 0) {
socket.emit('downloads:subscribe', { batch_ids: activeBatchIds });
console.log(`WebSocket subscribed to ${activeBatchIds.length} download batches`);
}
}
function subscribeToDownloadBatch(batchId) {
if (socket && socketConnected && batchId) {
socket.emit('downloads:subscribe', { batch_ids: [batchId] });
}
}
function unsubscribeFromDownloadBatch(batchId) {
if (socket && socketConnected && batchId) {
socket.emit('downloads:unsubscribe', { batch_ids: [batchId] });
}
}
// --- Phase 2: Dashboard event handlers ---
function handleDashboardStats(data) {
// Same logic as fetchAndUpdateSystemStats response handler
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session');
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
updateStatCard('uptime-card', data.uptime, 'Application runtime');
updateStatCard('memory-card', data.memory_usage, 'Current usage');
}
function handleDashboardActivity(data) {
// Same logic as fetchAndUpdateActivityFeed response handler
updateActivityFeed(data.activities || []);
}
function handleDashboardToast(activity) {
// Same logic as checkForActivityToasts response handler
let toastType = 'info';
if (activity.icon === '\u2705' || activity.title.includes('Complete')) {
toastType = 'success';
} else if (activity.icon === '\u274C' || activity.title.includes('Failed') || activity.title.includes('Error')) {
toastType = 'error';
} else if (activity.icon === '\uD83D\uDEAB' || activity.title.includes('Cancelled')) {
toastType = 'warning';
}
showToast(`${activity.title}: ${activity.subtitle}`, toastType);
}
function handleDashboardDbStats(stats) {
// Same logic as fetchAndUpdateDbStats response handler
updateDashboardStatCards(stats);
updateDbUpdaterCardInfo(stats);
}
function handleDashboardWishlistCount(data) {
const count = data.count || 0;
_updateHeroBtnCount('wishlist-button', 'wishlist-badge', count);
// Update sidebar nav badge
const wlNavBadge = document.getElementById('wishlist-nav-badge');
if (wlNavBadge) {
wlNavBadge.textContent = count;
wlNavBadge.classList.toggle('hidden', count === 0);
}
const wishlistButton = document.getElementById('wishlist-button');
if (wishlistButton) {
if (count === 0) {
wishlistButton.classList.remove('wishlist-active');
wishlistButton.classList.add('wishlist-inactive');
} else {
wishlistButton.classList.remove('wishlist-inactive');
wishlistButton.classList.add('wishlist-active');
}
}
checkForAutoInitiatedWishlistProcess();
}
// ===============================
// END WEBSOCKET CONNECTION MANAGER
// ===============================
// --- Service Integration Logo Constants ---
const MUSICBRAINZ_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png';
const DEEZER_LOGO_URL = 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610';
const SPOTIFY_LOGO_URL = 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png';
const ITUNES_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png';
const LASTFM_LOGO_URL = 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png';
const GENIUS_LOGO_URL = 'https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png';
const TIDAL_LOGO_URL = 'https://www.svgrepo.com/show/519734/tidal.svg';
const QOBUZ_LOGO_URL = 'https://www.svgrepo.com/show/504778/qobuz.svg';
const DISCOGS_LOGO_URL = 'https://www.svgrepo.com/show/305957/discogs.svg';
function getAudioDBLogoURL() { const el = document.querySelector('img.audiodb-logo'); return el ? el.src : null; }
// --- Wishlist Modal Persistence State Management ---
const WishlistModalState = {
// Track if wishlist modal was visible before page refresh
setVisible: function () {
localStorage.setItem('wishlist_modal_visible', 'true');
console.log('📱 [Modal State] Wishlist modal marked as visible in localStorage');
},
setHidden: function () {
localStorage.setItem('wishlist_modal_visible', 'false');
console.log('📱 [Modal State] Wishlist modal marked as hidden in localStorage');
},
wasVisible: function () {
const visible = localStorage.getItem('wishlist_modal_visible') === 'true';
console.log(`📱 [Modal State] Checking if wishlist modal was visible: ${visible}`);
return visible;
},
clear: function () {
localStorage.removeItem('wishlist_modal_visible');
console.log('📱 [Modal State] Cleared wishlist modal visibility state');
},
// Track if user manually closed the modal during auto-processing
setUserClosed: function () {
localStorage.setItem('wishlist_modal_user_closed', 'true');
console.log('📱 [Modal State] User manually closed wishlist modal during auto-processing');
},
clearUserClosed: function () {
localStorage.removeItem('wishlist_modal_user_closed');
console.log('📱 [Modal State] Cleared user closed state');
},
wasUserClosed: function () {
const closed = localStorage.getItem('wishlist_modal_user_closed') === 'true';
console.log(`📱 [Modal State] Checking if user closed modal: ${closed}`);
return closed;
}
};
// Sequential Sync Manager Class
class SequentialSyncManager {
constructor() {
this.queue = [];
this.currentIndex = 0;
this.isRunning = false;
this.startTime = null;
}
start(playlistIds) {
if (this.isRunning) {
console.warn('Sequential sync already running');
return;
}
// Convert playlist IDs to ordered array (maintain display order)
this.queue = Array.from(playlistIds);
this.currentIndex = 0;
this.isRunning = true;
this.startTime = Date.now();
console.log(`🚀 Starting sequential sync for ${this.queue.length} playlists:`, this.queue);
this.updateUI();
this.syncNext();
}
async syncNext() {
if (this.currentIndex >= this.queue.length) {
this.complete();
return;
}
const playlistId = this.queue[this.currentIndex];
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`);
this.updateUI();
try {
// Use existing single sync function
await startPlaylistSync(playlistId);
// Wait for sync to complete by monitoring the poller
await this.waitForSyncCompletion(playlistId);
} catch (error) {
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, error);
showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error');
}
// Move to next playlist
this.currentIndex++;
setTimeout(() => this.syncNext(), 1000); // Small delay between syncs
}
async waitForSyncCompletion(playlistId) {
return new Promise((resolve) => {
// Monitor the existing sync poller for completion
const checkCompletion = () => {
if (!activeSyncPollers[playlistId]) {
// Poller stopped = sync completed
resolve();
return;
}
// Check again in 1 second
setTimeout(checkCompletion, 1000);
};
checkCompletion();
});
}
complete() {
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
const completedCount = this.queue.length;
console.log(`🏁 Sequential sync completed in ${duration}s`);
this.isRunning = false;
this.queue = [];
this.currentIndex = 0;
this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false);
this.updateUI();
updateRefreshButtonState(); // Refresh button state after completion
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
// Hide sidebar after completion
hideSyncSidebar();
}
cancel() {
if (!this.isRunning) return;
console.log('🛑 Cancelling sequential sync');
this.isRunning = false;
this.queue = [];
this.currentIndex = 0;
this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false);
this.updateUI();
updateRefreshButtonState(); // Refresh button state after cancellation
showToast('Sequential sync cancelled', 'info');
// Hide sidebar after cancellation
hideSyncSidebar();
}
updateUI() {
const startSyncBtn = document.getElementById('start-sync-btn');
const selectionInfo = document.getElementById('selection-info');
if (!this.isRunning) {
// Reset to normal state
if (startSyncBtn) {
startSyncBtn.textContent = 'Start Sync';
startSyncBtn.disabled = selectedPlaylists.size === 0;
}
if (selectionInfo) {
const count = selectedPlaylists.size;
selectionInfo.textContent = count === 0
? 'Select playlists to sync'
: `${count} playlist${count > 1 ? 's' : ''} selected`;
}
} else {
// Show sequential sync status
if (startSyncBtn) {
startSyncBtn.textContent = 'Cancel Sequential Sync';
startSyncBtn.disabled = false;
}
if (selectionInfo) {
const current = this.currentIndex + 1;
const total = this.queue.length;
const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]);
selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`;
}
}
}
}
// API endpoints
const API = {
status: '/status',
config: '/config',
settings: '/api/settings',
testConnection: '/api/test-connection',
testDashboardConnection: '/api/test-dashboard-connection',
playlists: '/api/playlists',
sync: '/api/sync',
search: '/api/search',
artists: '/api/artists',
activity: '/api/activity',
stream: {
start: '/api/stream/start',
status: '/api/stream/status',
toggle: '/api/stream/toggle',
stop: '/api/stream/stop'
}
};
// Track last service status for library card (used by websocket handler in core + artists)
let _lastServiceStatus = null;
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
// ===============================

8905
webui/static/discover.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -420,7 +420,7 @@ const DOCS_SECTIONS = [
<h3 class="docs-subsection-title">How to: Set Up Auto-Downloads</h3>
<p class="docs-text"><strong>Goal:</strong> Automatically download new releases from your favorite artists without manual intervention.</p>
<ol class="docs-steps">
<li><strong>Add artists to your Watchlist</strong> &mdash; Search for artists on the Artists page and click the Watch button on each one</li>
<li><strong>Add artists to your Watchlist</strong> &mdash; Find artists via the Search page (or click an artist anywhere in the app), then click the Watch button on the artist detail page</li>
<li><strong>Go to Automations</strong> &mdash; The built-in "Auto-Scan Watchlist" automation checks for new releases every 24 hours</li>
<li><strong>Enable "Auto-Process Wishlist"</strong> &mdash; This automation picks up new releases found by the scan and downloads them every 30 minutes</li>
<li><strong>Done!</strong> &mdash; New releases from watched artists are automatically found, queued, downloaded, tagged, and added to your library</li>

6039
webui/static/downloads.js Normal file

File diff suppressed because it is too large Load diff

3552
webui/static/enrichment.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -917,45 +917,38 @@ const HELPER_CONTENT = {
description: 'Search for music across your configured metadata sources and download from Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer.',
docsId: 'search'
},
'#toggle-download-manager-btn': {
title: 'Toggle Download Manager',
description: 'Show or hide the download manager panel on the right side. The panel shows active downloads, finished downloads, and queue management.',
docsId: 'search-manager'
},
'.search-mode-toggle': {
title: 'Search Mode',
description: 'Switch between Enhanced Search (categorized metadata results with album art) and Basic Search (raw Soulseek results with detailed file info and filters).',
'#enh-source-row': {
title: 'Search Source Icons',
description: 'Each icon is a metadata source. The highlighted one is what your next search will target — defaults to your configured primary source on page load. Click a different icon to search or switch to that source; a small dot on the icon marks sources that already have cached results for the current query.',
tips: [
'Enhanced: shows Artists, Albums, Singles, Tracks from your metadata source',
'Basic: shows raw Soulseek P2P results with format, bitrate, size, uploader info'
'Typing searches only the highlighted source — no more silent fan-out across every provider',
'Switching to an already-cached source is instant, no re-fetch',
'The Soulseek icon routes to the raw-file search (same as the old Basic Search)',
'Music Videos queries YouTube for downloadable music video files',
'An amber border on a source means the backend fell back to a different provider for you (usually because Spotify is rate-limited)'
],
docsId: 'search-enhanced'
},
// Enhanced Search
'.enhanced-search-input-wrapper': {
title: 'Enhanced Search',
description: 'Type an artist, album, or track name. Results appear in categorized sections: Library Artists, Artists, Albums, Singles & EPs, and Tracks. Results come from your active metadata source.',
title: 'Search Bar',
description: 'Type an artist, album, or track name. Results appear in categorized sections: Library Artists, Artists, Albums, Singles & EPs, and Tracks. Only the source highlighted in the icon row above is queried — click another icon to switch.',
tips: [
'Click an album to open the download modal',
'Click a track to search your download source',
'Play button previews tracks from your download source',
'Multi-source tabs compare results across Spotify, iTunes, and Deezer'
'Switch sources via the icon row above — results are cached per query'
],
docsId: 'search-enhanced'
},
'.enh-source-tabs': {
title: 'Source Tabs',
description: 'Switch between metadata sources to see results from Spotify, iTunes, or Deezer. Each source has its own catalog — tracks missing on one may be found on another.',
docsId: 'search-enhanced'
},
'#enh-db-artists-section': {
title: 'Library Artists',
description: 'Artists from your local music library that match the search. Click to view their collection on the Library page.',
},
'#enh-spotify-artists-section': {
title: 'Artists',
description: 'Artists from your metadata source matching the search. Click to view their full discography on the Artists page.',
description: 'Artists from your metadata source matching the search. Click one to open their discography.',
},
'#enh-albums-section': {
title: 'Albums',
@ -1010,33 +1003,7 @@ const HELPER_CONTENT = {
docsId: 'search-basic'
},
// Download Manager Side Panel
'.downloads-side-panel': {
title: 'Download Manager',
description: 'Shows all active and completed downloads. Manage downloads, clear completed items, or cancel active transfers.',
docsId: 'search-manager'
},
'.controls-panel': {
title: 'Download Controls',
description: 'Overview of active and finished download counts. Clear Completed removes finished items from the list. Clear Current cancels all active downloads.',
docsId: 'search-manager'
},
'.controls-panel__clear-btn': {
title: 'Clear Completed',
description: 'Remove all finished downloads from the download manager list. Doesn\'t affect the downloaded files — they\'re already in your library.',
},
'.controls-panel__cancel-all-btn': {
title: 'Clear Current',
description: 'Cancel all active downloads in progress. Files that were partially downloaded will be cleaned up.',
},
'#active-queue': {
title: 'Download Queue',
description: 'Active downloads in progress. Each item shows track name, format, download speed, progress, and a cancel button.',
},
'#finished-queue': {
title: 'Finished Downloads',
description: 'Completed downloads. Shows track name, format, file size, and final status (success or error).',
},
// (Download Manager side-panel was retired — see the dedicated Downloads page)
// ─── DISCOVER PAGE ────────────────────────────────────────────────
@ -1290,139 +1257,30 @@ const HELPER_CONTENT = {
description: 'Personalized mixes generated from your listening patterns. Each mix focuses on a different aspect of your taste — genre clusters, mood, or artist groups.',
},
// ─── ARTISTS PAGE ─────────────────────────────────────────────────
// ─── ARTIST DETAIL PAGE ───────────────────────────────────────────
// (The standalone /artist-detail page is the unified destination for
// both library and metadata-source artists. The inline /artists page
// was retired in the unification project.)
// Search State
'.artists-search-state': {
title: 'Artist Search',
description: 'Search for any artist by name. Results show artist cards with images — click one to view their full discography.',
docsId: 'art-search'
},
'#artists-search-input': {
title: 'Search Input',
description: 'Type an artist name to search across your active metadata source. Results appear as you type with a short debounce delay.',
docsId: 'art-search'
},
'#artists-search-status': {
title: 'Search Status',
description: 'Shows the current search state — ready, searching, or result count.',
},
// Results State
'#artists-results-state': {
title: 'Search Results',
description: 'Artist cards matching your search. Click any artist to view their full discography with albums, singles, and EPs.',
docsId: 'art-search'
},
'#artists-back-button': {
title: 'Back to Search',
description: 'Return to the initial search view to start a new artist search.',
},
'#artists-cards-container': {
title: 'Artist Cards',
description: 'Each card shows an artist from your metadata source. Click to load their full discography.',
},
// Artist Detail — Hero
'#artists-hero-section': {
title: 'Artist Profile',
description: 'Rich artist profile with photo, name, genres, bio, and service links. Data comes from your metadata cache and library enrichment (Last.fm, Spotify, MusicBrainz, etc.).',
tips: [
'Service badges link to the artist on each platform',
'Bio comes from Last.fm enrichment if the artist is in your library',
'Listener and play count stats from Last.fm',
'Genre pills combine metadata source genres with Last.fm tags'
],
docsId: 'art-detail'
},
'.artists-hero-name': {
title: 'Artist Name',
description: 'The artist\'s official name from your metadata source.',
},
'.artists-hero-badges': {
title: 'Service Badges',
description: 'Links to this artist on external services. Click any badge to open the artist\'s page on that platform. Badges appear for services where this artist has been enriched.',
tips: [
'Spotify, MusicBrainz, Deezer, iTunes, Last.fm, Genius, Tidal, Qobuz',
'Badges only appear when the artist has an ID for that service',
'Data comes from library enrichment workers'
]
},
'.artists-hero-genres': {
title: 'Genres',
description: 'Genre tags for this artist. Combines genres from your metadata source with Last.fm tags for comprehensive coverage.',
},
'.artists-hero-bio': {
title: 'Artist Bio',
description: 'Biography from Last.fm. Click "Read more" to expand. Only available for artists in your library that have been enriched by the Last.fm worker.',
},
'.artists-hero-stats': {
title: 'Listener Stats',
description: 'Last.fm listener count and total play count. Shows how popular this artist is globally on Last.fm.',
},
'#artist-detail-watchlist-btn': {
title: 'Watchlist',
description: 'Add or remove this artist from your Watchlist. Watched artists are scanned for new releases which get added to your Wishlist for download.',
docsId: 'art-watchlist'
},
'#artist-detail-watchlist-settings-btn': {
title: 'Watchlist Settings',
description: 'Configure which release types to monitor for this artist — Albums, EPs, Singles, and content filters (live, remixes, acoustic, compilations).',
docsId: 'art-settings'
},
// Discography Tabs
'.artist-detail-tabs': {
title: 'Discography Tabs',
description: 'Switch between Albums and Singles & EPs. Each tab shows the artist\'s releases in that category.',
docsId: 'art-detail'
},
'#albums-tab': {
title: 'Albums',
description: 'Full-length studio albums by this artist. Click any album card to open the download modal.',
},
'#singles-tab': {
title: 'Singles & EPs',
description: 'Singles and extended plays by this artist. Includes single tracks, 2-3 track releases, and EPs.',
},
// Album Cards
'#album-cards-container': {
title: 'Album Grid',
description: 'Album cards with cover art. Click any album to open the download modal where you can select tracks, check library matches, and start downloading.',
tips: [
'Cover art fills the card with album name overlaid at the bottom',
'Completion badges show ownership status (Complete, Partial, Missing)',
'Cards check your library automatically after loading'
],
docsId: 'art-detail'
},
'#singles-cards-container': {
title: 'Singles Grid',
description: 'Single and EP cards. Same behavior as albums — click to open the download modal.',
},
'.album-card': {
title: 'Release Card',
description: 'An album, single, or EP from this artist. Click to open the download modal with track selection, library matching, and download controls.',
tips: [
'Completion overlay shows how many tracks you own',
'Green = complete, Yellow = partial, Red = missing',
'"Checking..." means library match is in progress'
'Big-photo cover art fills the card with title and year overlaid at the bottom',
'Completion badge (top-right) shows ownership status: ✓ Owned / N/M / Missing',
'Library artists check ownership in the background — badge starts as "Checking…" then resolves'
]
},
'.completion-overlay': {
title: 'Completion Status',
description: 'Shows how many tracks from this release are in your library. "Complete" means you have all tracks, "Partial" shows owned/total, "Missing" means none found.',
title: 'Completion Badge',
description: 'Top-right badge showing ownership state for library artists. ✓ Owned = full match, N/M = partial (owned/total tracks), Missing = no match. Source artists don\'t show this badge.',
},
// Similar Artists
'#similar-artists-section': {
'#ad-similar-artists-section': {
title: 'Similar Artists',
description: 'Artists with a similar sound, streamed in real-time from your metadata source. Click any card to view that artist\'s discography.',
description: 'Artists with a similar sound, fetched from MusicMap by name. Works for both library and source artists. Click any bubble to navigate to that artist\'s detail page.',
tips: [
'Cards load progressively as the server finds matches',
'Click a card to navigate to that artist\'s discography',
'Images are lazy-loaded for performance'
'Bubbles load progressively',
'Click navigates to the standalone artist-detail page'
],
docsId: 'art-detail'
},
@ -1430,6 +1288,8 @@ const HELPER_CONTENT = {
title: 'Similar Artist',
description: 'An artist similar to the one you\'re viewing. Click to load their discography and browse their releases.',
},
// (Search source picker annotation lives under `#enh-source-row` above —
// the old `.search-source-picker-container` dropdown is gone.)
// ─── AUTOMATIONS PAGE ─────────────────────────────────────────────
@ -2378,9 +2238,9 @@ function toggleHelperMode() {
const PAGE_TOUR_MAP = {
'dashboard': 'dashboard',
'sync': 'sync-playlist',
'downloads': 'first-download',
'search': 'first-download',
'downloads': 'first-download', // legacy id — the Search page used to be called 'downloads'
'discover': 'discover',
'artists': 'artists-browse',
'automations': 'automations',
'library': 'library',
'stats': 'stats',
@ -2542,15 +2402,11 @@ const HELPER_TOURS = {
description: 'Step-by-step guide to downloading your first album.',
icon: '⬇️',
steps: [
// Search page layout (top-to-bottom, elements visible on load)
{ page: 'downloads', selector: '.search-mode-toggle', title: 'Search Modes', description: 'Two search modes: Enhanced Search (default) shows categorized results from your metadata source. Classic Search queries your download source directly for raw file results.' },
{ page: 'downloads', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' },
{ page: 'downloads', selector: '#toggle-download-manager-btn', title: 'Download Manager', description: 'This button toggles the download manager panel on the right side. It shows active downloads with progress bars, queued items, and completed downloads with their file paths.' },
// What results look like (describe since they appear after searching)
{ page: 'downloads', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear here organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' },
{ page: 'downloads', selector: '.search-mode-toggle', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' },
{ page: 'downloads', selector: '.enhanced-search-input-wrapper', title: 'That\'s It!', description: 'Search, click, download — it\'s that simple. Albums go to your configured download path, get tagged with metadata, and sync to your media server automatically. 🎉' },
{ page: 'search', selector: '#enh-source-row', title: 'Pick a Search Source', description: 'Each icon is a metadata source. The highlighted one is where your next search goes — defaults to your configured primary source. Click a different icon to switch to Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, or Soulseek (raw P2P files). A small dot marks sources you\'ve already searched for the current query.' },
{ page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Search for Music', description: 'Type an artist or album name here. Results appear in categorized sections — Artists, Albums, Singles/EPs, and Tracks. Try searching for your favorite artist now!' },
{ page: 'search', selector: '#enh-results-container', title: 'Search Results', description: 'After searching, results appear organized by type: Artists at the top as cards, then Albums, Singles/EPs, and individual Tracks. "In Library" badges mark items you already own.' },
{ page: 'search', selector: '.enhanced-search-input-wrapper', title: 'Downloading an Album', description: 'Click any album card to open the download modal. You\'ll see the tracklist, quality options, and a big "Download Album" button. Individual tracks have a play button to preview before downloading.' },
{ page: 'search', selector: '.enhanced-search-input-wrapper', title: 'That\'s It!', description: 'Search, click, download. Albums go to your configured download path, get tagged with metadata, and sync to your media server automatically. Active downloads live on the dedicated Downloads page.' },
]
},
'sync-playlist': {
@ -2576,20 +2432,8 @@ const HELPER_TOURS = {
{ page: 'sync', selector: '.sync-sidebar', title: 'Sync Controls', description: 'The command center. Select playlists with checkboxes on the left, then click "Start Sync" here. Progress bars, match counts, and logs update in real-time. That\'s the sync flow! 🎉' },
]
},
'artists-browse': {
title: 'Browse Artists',
description: 'Search for artists and explore their discography.',
icon: '🎤',
steps: [
// Artists list page (visible on load)
{ page: 'artists', selector: '#artists-search-input', title: 'Search for an Artist', description: 'Type any artist name to search your metadata source. Results appear instantly as cards below. Click one to open their full profile and discography.' },
// Artist detail page (describe what they'll see after clicking)
{ page: 'artists', selector: '#artists-search-input', title: 'Artist Profile', description: 'After clicking an artist, you\'ll see a rich hero section with their photo, bio, genres, listening stats from Last.fm, and links to external services like Spotify and MusicBrainz.' },
{ page: 'artists', selector: '#artists-search-input', title: 'Discography & Downloads', description: 'Below the hero, tabs show Albums and Singles/EPs. Click any release to open the download modal. The "Similar Artists" section at the bottom shows recommendations — click any to keep exploring.' },
{ page: 'artists', selector: '#artists-search-input', title: 'Try It Now!', description: 'Search for your favorite artist above to see their full profile. From there you can download albums, explore similar artists, and add them to your watchlist. 🎉' },
]
},
// 'artists-browse' tour retired — the Artists sidebar entry was replaced by the
// unified Search page (see the first-download tour for the new flow).
'automations': {
title: 'Build an Automation',
description: 'Create automated workflows with triggers and actions.',
@ -3111,7 +2955,7 @@ const SETUP_STEPS = [
{ id: 'download-source', label: 'Set Up Download Source', desc: 'Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer', icon: '⬇️', page: 'settings', settingsTab: 'downloads' },
{ id: 'download-paths', label: 'Configure Download Paths', desc: 'Where music is saved and organized', icon: '📁', page: 'settings', settingsTab: 'downloads' },
{ id: 'first-scan', label: 'Run First Library Scan', desc: 'Import your existing collection from media server', icon: '🔍', page: 'dashboard', selector: '#db-updater-card' },
{ id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'downloads' },
{ id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'search' },
{ id: 'watchlist', label: 'Add an Artist to Watchlist', desc: 'Monitor for new releases automatically', icon: '👁️', page: 'library' },
{ id: 'automation', label: 'Create an Automation', desc: 'Schedule tasks and build workflows', icon: '🤖', page: 'automations' },
];
@ -3218,14 +3062,8 @@ async function _checkSetupStatus() {
results['first-download'] = Date.now();
_markSetupComplete('first-download');
}
// Also check the finished queue (if on downloads page)
if (!results['first-download']) {
const fq = document.querySelector('#finished-queue');
if (fq && fq.querySelector('.download-item')) {
results['first-download'] = Date.now();
_markSetupComplete('first-download');
}
}
// (The legacy #finished-queue side-panel was retired; the dashboard stat card
// above is now the single source of truth for the first-download milestone.)
}
return results;
@ -3598,10 +3436,73 @@ function closeHelperSearch() {
// WHAT'S NEW (Phase 6)
// ═══════════════════════════════════════════════════════════════════════════
// Entries tagged with `unreleased: true` are accumulating under a version label
// but won't display until the build version catches up — used for in-progress
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.35': [
'2.4.0': [
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---
{ date: 'April 26, 2026 — 2.4.0 release' },
{ title: 'Reorganize Queue: Race-Condition Hardening (kettui Review)', desc: 'kettui\'s review of PR #377 caught two real concurrency bugs in the new reorganize queue and one input-deduplication gap. (1) Worker race: the worker thread looked up the next queued item, then released the lock, then re-acquired it to flip status to "running". A cancel() landing in that window would mark the item cancelled but the worker still ran it. Now picks and flips atomically under a single lock acquisition. (2) Wakeup race: the worker cleared its wakeup event after observing an empty queue, but enqueue could fire its wakeup.set() between the empty check and the clear, making a freshly-queued album sleep up to 60 seconds before the worker noticed. Replaced the lock + event pair with a single threading.Condition so check-and-wait happen under the same lock atomically. (3) Bulk-enqueue dedupe: enqueue_many called single-item enqueue in a loop, so two copies of the same album_id in one bulk request could both slip through if the worker finished the first copy before the loop reached the second. Now holds the queue lock for the entire batch and tracks a per-batch seen set, so intra-batch duplicates are deduped against each other, not just against pre-existing items. Also fixed two related issues from the same review: the reorganize-preview Apply button could get stuck disabled when an early return / network error skipped the re-enable line (moved into a finally), and the new DB helpers (get_album_display_meta, get_artist_albums_for_reorganize) used to swallow every exception and return None / [], which made a real DB outage look like "album not found" — they now let exceptions bubble so the route layer surfaces a proper 500', page: 'library' },
{ title: 'Reorganize Queue with Live Status Panel', desc: 'Reorganizing albums no longer locks up the page or runs as a JS-driven loop. Each click on the per-album reorganize button — or "Reorganize All" — now enqueues into a single FIFO queue that a backend worker drains one item at a time. Buttons stay clickable: spam-clicking the same album silently dedupes, and you can keep browsing while items run. A status panel mounted at the top of the artist actions bar shows what\'s active (with a progress bar, current track, and live moved/skipped/failed counts), how many items are queued behind it, and recently-finished items with success/warning indicators. The panel expands to show the full queue with per-item cancel buttons (running items can\'t be cancelled mid-flight; queued ones can) and a "Cancel All" button for the queued tail. Items belonging to a different artist than the page you\'re on are flagged with a "(other artist)" hint so you understand what you\'re seeing. Bonus: "Reorganize All" is now one backend call instead of N JS-driven calls — much faster, and the artist context is captured server-side per item so the queue can show cross-artist progress correctly. Also retired the old single-slot status endpoint and the polling loop that depended on it', page: 'library' },
{ title: 'Fix Album Completeness Job Reporting Zero Findings for Everyone', desc: 'sassmastawillis reported the Album Completeness maintenance job was finishing in 0.1s with 0 findings, even for users with obviously-incomplete albums. Root cause: the job used `albums.track_count` as the "expected total" to compare against the library\'s actual count. But `track_count` is populated by server syncs (Plex leafCount, SoulSync standalone len(tracks)) — it\'s always the OBSERVED count, never what the metadata provider says the album should contain. So expected == actual always, and every album looked complete. Fix: new `api_track_count` column on the albums table, written only by metadata-source code paths (Spotify, iTunes, Deezer, and Discogs enrichment workers now populate it whenever they fetch album data, so it piggybacks on existing API calls instead of making new ones). Server syncs never touch this column, so it stays authoritative. The repair job uses it as the expected total; if an album somehow hasn\'t been enriched yet, the job falls back to a live API lookup and caches the result. For users with an already-enriched library, the first completeness scan after the upgrade is fast because the workers will have populated the column during normal enrichment cycles', page: 'library' },
{ title: 'Library Reorganize: Reroute Through the Download Pipeline', desc: 'Reported by winecountrygames — using "Reorganize All" on a 3-disc Aerosmith deluxe collapsed it to a flat 1-disc layout, and on other albums it left half the tracks in their original location with no error or count of what was skipped. Root cause: the reorganize endpoint reinvented several wheels (its own template engine, its own disc-number resolution from file tags, its own sidecar sweep, its own collision detection) and each had drifted from the canonical post-processing path used by downloads. The reorganize-only logic read disc_number from file tags and silently defaulted to 1 on any failure, so a single tag-less file collapsed the whole album to single-disc. Tracks whose file paths didn\'t resolve on disk were silently skipped. Rewrote it to follow the import page\'s pattern: copy each file to a per-album staging folder under your download path, look up the canonical tracklist from your configured metadata source (Deezer / Spotify / iTunes / Discogs / Hydrabase) using the album\'s stored source IDs, then route each file through the same `_post_process_matched_download` function fresh downloads use — same template, same tagging, same multi-disc subfolder logic, same sidecar handling, same AcoustID verification. Albums with no stored source ID are reported back and skipped entirely (degrading silently to file tags is what caused the original bug). Tracks not in the source\'s catalog version (bonus tracks on a deluxe edition) are reported as skipped and left in place rather than force-fed wrong context. Files that don\'t resolve on disk are surfaced with the offending DB path so the UI can show them. The 230-line inline reorganize logic in web_server.py was extracted into core/library_reorganize.py — net -195 lines from the monolith, +13 unit tests for the new orchestrator. Frontend behavior change: the per-call template parameter in the reorganize modal is now ignored — reorganize uses your configured download template, matching the pipeline downloads use', page: 'library' },
{ title: 'Spotify: Longer Post-Ban Cooldown (30 min)', desc: 'A user reported their Spotify rate-limit ban expired after 4 hours, the system ran its 5-minute post-ban cooldown, and then 32 seconds after the cooldown ended a single get_artist_albums call from a background worker was hit with another 4-hour ban. Diagnosis: Spotify\'s server-side memory of the previous offense outlasted our 5-minute cooldown, so the very first call after cooldown got slapped immediately. The cooldown exists specifically to prevent the "ban expires → we probe → re-ban" cycle, but the value was too short. Bumped from 5 minutes to 30 minutes — same mechanism, just enough room for Spotify to actually forget. A more principled follow-up (adaptive cooldown that scales with the previous ban size, plus making the first post-cooldown call a single light probe rather than allowing background workers through) is documented as a future PR if reports persist after this bump', page: 'dashboard' },
{ title: 'Tidal: Reject Silent Quality Downgrades', desc: 'Netti93 reported that with Tidal set to "HiRes only" and quality fallback disabled, tracks were still downloading successfully — as m4a 320kbps files. Root cause: Tidal\'s API silently serves whatever tier your account + the track + your region permits. Ask for HI_RES_LOSSLESS on a track that\'s only in LOW_320K and Tidal returns the AAC stream without raising. The downloader wrote the m4a to disk, the filesize cleared the 100KB stub threshold, and the download reported success. The worker-level fallback chain (hires → lossless → high → low) also never got a chance to advance, because every tier "succeeded" at the first one that returned anything. Fix: after getting the stream, compare stream.audio_quality against what we requested using a rank-based tier comparison (LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS). Same tier or better = accept (so occasional Tidal upgrades don\'t get thrown away). Lower tier = treat this tier as failed, which lets the fallback chain advance when fallback is enabled or fails the whole download honestly when the user has "HiRes only, no fallback" configured. Unrecognized audioQuality values (a new Tidal tier we haven\'t mapped yet) are rejected conservatively so the final diagnostic log can name the unknown value. Older tidalapi builds without the audio_quality attribute fall through to the pre-existing codec / file-size guards so nothing regresses', page: 'downloads' },
{ title: 'Search Source Picker Icon Row', desc: 'The Search page now has a row of source icons above the search bar — one per source (Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, Soulseek). Typing searches only the currently-selected source instead of fanning out to every one by default. Click a different icon to switch; results come back on demand. The default icon on page load is your configured primary metadata source. Replaces the short-lived "Search from" dropdown that preceded this', page: 'search' },
{ title: 'Per-Query Source Cache (No More Re-Fetching)', desc: 'Once you\'ve searched a source for a given query, switching back to it is instant — results are cached for the current query. A small dot on each source icon shows which ones already have cached results this query. Type a new query and the whole cache resets. Same behavior in the sidebar global search popover. Net effect: roughly 6-7x fewer API calls per search compared to the old default fan-out', page: 'search' },
{ title: 'Global Search Widget Source Parity', desc: 'The sidebar Cmd+K / "/" search popover gained the same source icon row as the full Search page. Pick your source up front, see cache dots for already-fetched sources this query, and the rate-limit fallback banner appears if the backend substituted a different source than the one you clicked. Clicking the Soulseek icon hands off to the full Search page (raw file results need more room than the popover provides)', page: 'search' },
{ title: 'Rate-Limit Fallback Banner', desc: 'If you click Spotify but the backend auto-fell back to Deezer because Spotify was rate-limited, the search results now lead with a small amber banner ("Spotify unavailable — showing Deezer.") and the Spotify icon gets an amber border. Previously results just silently showed as the fallback source with no signal that anything unusual happened', page: 'search' },
{ title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search' },
{ title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search' },
{ title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search' },
{ title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search' },
{ title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search' },
{ title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search' },
{ title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help' },
{ title: 'Unified Source-Picker Controller (Search Page + Global Widget)', desc: 'Internal refactor — the source picker state machine (query, active source, per-query cache, fallbacks, loading state, configured-source discovery) is now a single createSearchController factory in shared-helpers.js. Both the full Search page and the sidebar global search popover consume the same controller with per-surface wiring (DOM elements, Soulseek handoff, unconfigured-source click). About 380 lines of near-duplicated state + fetch + render code consolidated into one implementation, so a bug fix or behavior tweak to the picker lands everywhere at once. Zero UX change — every keystroke, icon click, cache hit, rate-limit fallback, and unconfigured-source redirect behaves identically to before', page: 'search' },
{ title: 'Fix Clean Search History Automation Failing with AttributeError', desc: 'The hourly Clean Search History maintenance automation was crashing with "DownloadOrchestrator object has no attribute base_url". Root cause: the check `soulseek_client.base_url` was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client at `.soulseek`. Two other call sites in web_server.py already used the correct `soulseek_client.soulseek.base_url` pattern; this one was missed. Now matches the same getattr-guarded pattern and the hourly cleanup runs again', page: 'stats' },
{ title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search' },
{ title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search' },
{ title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search' },
{ title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search' },
{ title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search' },
{ title: 'Fix Discover Hero "View Discography" 404ing on Source Artists', desc: 'Clicking "View Discography" on the Discover page hero slideshow was calling navigateToArtistDetail without a source, so /api/artist-detail defaulted to a library lookup and returned 404 for artists that don\'t exist in your library (which is nearly every hero artist — they come from discover similar-artists, not the library). Regression from the unification PR that rewrote the click handler to route to /artist-detail but forgot to pass the source. Backend already sends artist.source on each hero entry; we now stash it as data-source on the discography button and thread it through to navigateToArtistDetail so the API call includes source=itunes/deezer/etc. and returns the synthesized discography', page: 'discover' },
{ title: 'MusicBrainz Search Actually Works Now', desc: 'kettui flagged during PR #371 review that the MusicBrainz source tab never returned artists and served garbage tracks/albums. Three things were wrong. First, the artist search was hardcoded to return an empty list — re-enabled with a proper fuzzy query (bare Lucene string against alias/artist/sortname indexes) and score-filtered at 80+ to drop tribute bands. Second, track and album searches used text-search on recording/release TITLES — so typing "metallica" matched random tracks literally named "Metallica" (all scoring 100 because they\'re exact title hits). Now a bare name query resolves to the top-scoring artist, then BROWSES that artist\'s release-groups and recordings directly — the same pattern Plex uses. Structured "Artist - Title" queries still take the text-search path since the user gave an explicit title to match. Third, the adapter was firing synchronous Cover Art Archive HEAD requests (up to 30s of blocking probes per search) — replaced with deterministic URL construction so the browser loads images lazily with <img onerror> fallback. Search completes in ~3 seconds instead of 30+ on cold cache. Also shipped: project URL in User-Agent per MB\'s rate-limit policy recommendations', page: 'search' },
{ title: 'MusicBrainz Search Follow-Ups (Images, Counts, Title Hints)', desc: 'Three fixes from kettui\'s follow-up pass on the MusicBrainz search PR. (1) Artist images were missing because MB doesn\'t store artist art — the lazy-load endpoint now accepts an optional `name` query param and resolves images by searching iTunes/Deezer for that artist name. (2) Track total_tracks was off by one because the counter initialized at 1 before summing release media track-counts — an 11-track album reported 12. Initialized to 0 now, with a special case for standalone recordings that have no release (report 1). (3) Queries like "The Beatles Abbey Road" used to browse The Beatles\' whole discography because the artist-first path resolved the artist and ignored the trailing title. Now extracts the title hint from queries shaped like "Artist Title", filters browse results to match, and falls back to text-search when no browse result matches (so "The Beatles Totally Fake Album" still finds something rather than nothing). 10 new tests covering title-hint extraction, browse-filter behavior, total_tracks edge cases', page: 'search' },
],
'2.39': [
// --- April 22, 2026 ---
{ date: 'April 22, 2026' },
{ title: 'Fix Wrong-Artist Tracks Silently Downloading from Tidal', desc: 'A user reported that searching for "Leave A Light On" by Maduk on Tidal silently downloaded Tom Walker\'s (completely different) song of the same name, embedding Maduk metadata into Tom Walker\'s audio. Two layers of defense were failing: (1) the candidate artist gate used `< 0.4` similarity and "maduk" vs "tom walker" scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`. (2) AcoustID verification correctly identified the mismatch but returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different and the expected artist was absent from every recording. Now returns FAIL when artist similarity < 0.3 (clear mismatch); preserves SKIP for the ambiguous 0.3-0.6 range (covers/collabs/formatting differences)', page: 'sync' },
{ title: 'Tidal Search Falls Back to Shortened Queries on 0 Results', desc: 'Tidal\'s search chokes on long queries with multiple qualifier words (e.g., "maduk transformations remixed fire away fred v remix" returns nothing, but dropping "fred v remix" works). Search now retries with up to 4 progressively-shortened variants when the original returns 0 results. Qualifier-safe: if the original query mentions Live/Remix/Acoustic/etc., fallback results must still contain those keywords in their track names — otherwise a shortened query could silently downgrade "(Live)" to the studio version. Returns ([], []) if no variant preserves the qualifiers, same as before', page: 'sync' },
],
'2.38': [
// --- April 21, 2026 (late) ---
{ date: 'April 21, 2026 (late)' },
{ title: 'Fix Missing Cover Art on Manually Fixed Discovery Tracks', desc: 'The cache matched_data built by the fix modal dropped the image_url and album.images fields when album came back as a bare string (common for Deezer/iTunes search results). Result: re-discovery used the cached match but downloads showed no artwork. Cache writes now carry image_url through to album.images + top-level matched_data, matching what the in-memory state already did. Re-fix the track to refresh its cache entry (INSERT OR REPLACE)', page: 'sync' },
{ title: 'Fix Manual Discovery Fixes Lost After Restart (Non-Spotify Users)', desc: 'When you clicked Fix on a discovery track and picked a manual match, the cache save hardcoded the provider as "spotify" regardless of your configured primary metadata source. On re-scan, the worker queried the cache with your actual primary (Deezer, iTunes, Discogs, Hydrabase) and missed the fix entirely. All 5 save sites (Tidal / Deezer / Spotify Public / YouTube / Discovery Pool) now use the active primary source, matching what the automatic workers already do', page: 'sync' },
],
'2.37': [
// --- April 21, 2026 (evening) ---
{ date: 'April 21, 2026 (evening)' },
{ title: 'Fix Auto-Watchlist Ignoring Global Override Settings', desc: 'The scheduled auto-watchlist scan (not the manual one) called scan_watchlist_artists directly, which bypassed Global Override application. So if you disabled Albums or Live under Watchlist → Global Override, full albums and live tracks still got added to the wishlist during the nightly scan. Override logic now runs inside scan_watchlist_artists so every entry point respects it', page: 'watchlist' },
{ title: 'Fix Live Version Filter False Positives', desc: 'The \\blive\\b regex was too loose — it flagged any title with the word "live" regardless of context, so "What We Live For" by American Authors, "Live Forever" by Oasis, and similar verb uses got treated as live recordings. Tightened to require clear live-recording context: "(Live)", "- Live", "Live at/from/in/on/version/session/etc". Fixes both the watchlist/backfill track filter and the Library Maintenance Live/Commentary Cleaner', page: 'library' },
],
'2.36': [
// --- April 21, 2026 ---
{ date: 'April 21, 2026' },
{ title: 'Fix Metadata Cache Bar Duplicating on Findings Dashboard', desc: 'The "Metadata Cache · View Details" bar under the findings chips could stack into 26 copies if the dashboard refreshed while a cache-health fetch was still in flight. Each resolved fetch appended its own section. Now each fetch clears any existing bar before appending', page: 'library' },
{ title: 'Fix Discography Backfill Stalling When Repair Worker Paused', desc: 'Force-running a job via "Run Now" stalled forever when the master repair worker was paused. The job entered the scan function, logged its starting banner, then blocked on the first wait_if_paused check. Force-run now bypasses the master-pause — scheduled runs still respect it', page: 'library' },
{ title: 'Discography Backfill: 3-Option Fix Dialog', desc: 'Clicking Fix on a missing-track finding now prompts "Add to Wishlist", "Just Clear Finding", or "Cancel" instead of silently adding to wishlist. Bulk Fix shows the same prompt once for all selected backfill findings', page: 'library' },
{ title: 'Discography Backfill: Auto-Add to Wishlist Setting', desc: 'New opt-in setting in the Discography Backfill job config. When enabled, missing tracks are pushed straight to the wishlist during the scan AND a finding is created for the log. Default is off — you review and click Fix', page: 'library' },
{ title: 'Discography Backfill: Faster Batched Matching', desc: 'Each artist scan now pre-fetches the library albums + tracks once and matches in-memory — same fast path the Library and Artists pages use. Avoids thousands of per-track SQL queries on artists with big libraries', page: 'library' },
{ title: 'Discography Backfill: Rich Album Context per Finding', desc: 'Every finding now carries a full album dict (id, name, album_type, release_date, images, artists, total_tracks) matching the wishlist pipeline shape. No more generic "Add to Wishlist" loss of release metadata', page: 'library' },
{ title: 'Discography Backfill: Per-Artist Progress Logs', desc: 'Scan logs now show [N/50] Scanning ArtistName for each artist processed, with found-count or "no missing tracks" afterward. Makes it obvious whether the job is actually progressing' },
// --- April 20, 2026 (part 2) ---
{ date: 'April 20, 2026 (evening)' },
{ title: 'Massively Faster Artist Detail Page Loads', desc: 'Artist discography completion checks used to fire hundreds of SQL queries per page load — 15+ fuzzy title/artist searches per album times 30 albums per artist. Now pre-fetches the artist\'s library albums and tracks ONCE upfront, then matches everything in-memory. Same matching logic and accuracy, roughly 100x fewer SQL round-trips. Applies to both the Library artist page and the Artists search page', page: 'library' },
{ title: 'Fix Reorganize All Ignoring Album Type', desc: 'Reorganize All was sending every album — EPs, singles, and compilations — into the "Albums" folder because the $albumtype template variable silently defaulted to "Album". The variable is now resolved from the album\'s record_type (with track-count fallback) so ${albumtype}s produces the expected Albums/Singles/EPs/Compilations split', page: 'library' },
@ -3770,12 +3671,36 @@ const WHATS_NEW = {
function _getCurrentVersion() {
const btn = document.querySelector('.version-button');
return btn ? btn.textContent.trim().replace('v', '') : '2.35';
return btn ? btn.textContent.trim().replace('v', '') : '2.4.0';
}
// Compare two semver-ish strings ("2.4.0" vs "2.4.1" vs "2.39"). Returns
// negative if a < b, positive if a > b, 0 if equal. Strips any +sha suffix
// before parsing. Missing components are treated as 0 so "2.4" sorts as
// "2.4.0". Replaces the old parseFloat() approach which collapsed any
// 3-part version to its first two components — making 2.4.0 and 2.4.1
// indistinguishable.
function _compareVersions(a, b) {
const parse = (s) => String(s || '0').split('+')[0].split('.').map(n => parseInt(n, 10) || 0);
const pa = parse(a);
const pb = parse(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
function _getLatestWhatsNewVersion() {
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
return versions[0] || '2.35';
// Only surface entries whose version number is <= the current build. Entries
// sitting at higher versions are unreleased work-in-progress and shouldn't
// flag as "new" in the helper badge until the build catches up.
const buildVer = _getCurrentVersion();
const versions = Object.keys(WHATS_NEW)
.filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => _compareVersions(b, a));
return versions[0] || '2.4.0';
}
function openWhatsNew() {
@ -3863,8 +3788,11 @@ function _openFullChangelog() {
}
function _showOlderNotes() {
// Cycle to next older version in the what's new panel
const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a));
// Cycle to next older version in the what's new panel (skip unreleased entries)
const buildVer = _getCurrentVersion();
const versions = Object.keys(WHATS_NEW)
.filter(v => _compareVersions(v, buildVer) <= 0)
.sort((a, b) => _compareVersions(b, a));
const panel = _helperPopover;
if (!panel) return;
const currentTitle = panel.querySelector('.helper-popover-title');

2361
webui/static/init.js Normal file

File diff suppressed because it is too large Load diff

7285
webui/static/library.js Normal file

File diff suppressed because it is too large Load diff

2399
webui/static/media-player.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -329,11 +329,6 @@
width: 100%;
}
.enhanced-search-bar-container button {
width: 100%;
min-height: 44px;
}
.filter-group {
flex-wrap: wrap;
}
@ -3627,4 +3622,119 @@
width: calc(100vw - 16px);
bottom: 54px;
}
}
/*
Source picker row Search page + global widget responsive.
Chips shrink on tablet, labels drop on phone so the full row
of 8 sources remains scannable without hijacking the screen. */
@media (max-width: 768px) {
.enh-source-row {
padding: 8px 8px;
margin: 8px 0 10px;
gap: 6px;
border-radius: 12px;
}
.enh-source-icon {
min-width: 72px;
padding: 8px 10px;
gap: 4px;
font-size: 10.5px;
}
.enh-source-icon-glyph,
.enh-source-icon-glyph img {
width: 24px;
height: 24px;
}
.enh-source-icon-label { font-size: 10.5px; }
.enh-fallback-banner {
font-size: 11px;
padding: 6px 10px;
}
/* Global widget source row (inside the popover) */
.gsearch-source-row {
padding: 10px 10px 6px;
gap: 4px;
}
.gsearch-source-icon {
min-width: 62px;
padding: 6px 8px;
gap: 3px;
}
.gsearch-source-icon-glyph,
.gsearch-source-icon-glyph img {
width: 20px;
height: 20px;
}
.gsearch-fallback-banner {
font-size: 10px;
padding: 5px 10px;
}
/* Ambient glow under the global search trim size so it doesn't
dominate a short mobile viewport. */
.gsearch-aura {
height: 180px;
background:
radial-gradient(ellipse 440px 160px at 50% 100%,
rgba(var(--accent-rgb), 0.20) 0%,
rgba(var(--accent-rgb), 0.08) 35%,
rgba(var(--accent-rgb), 0.02) 65%,
transparent 85%);
}
.gsearch-aura.active {
background:
radial-gradient(ellipse 540px 200px at 50% 100%,
rgba(var(--accent-rgb), 0.34) 0%,
rgba(var(--accent-rgb), 0.14) 28%,
rgba(var(--accent-rgb), 0.04) 58%,
transparent 85%);
}
/* Library empty-state hand-off CTA allow wrap on narrow screens
so long artist queries don't overflow. */
.library-empty-search-cta {
flex-wrap: wrap;
justify-content: center;
padding: 10px 14px;
font-size: 13px;
max-width: 100%;
}
.library-empty-search-cta-text {
white-space: normal;
text-align: center;
}
}
/* Very narrow phones drop the icon labels entirely and show just the
glyphs. Tap target stays big enough, row compacts dramatically. */
@media (max-width: 480px) {
.enh-source-row {
padding: 6px 6px;
gap: 4px;
border-radius: 10px;
}
.enh-source-icon {
min-width: 44px;
padding: 6px 8px;
}
.enh-source-icon-label { display: none; }
.gsearch-source-row {
padding: 8px 8px 5px;
gap: 4px;
}
.gsearch-source-icon {
min-width: 40px;
padding: 5px 7px;
}
.gsearch-source-icon-label { display: none; }
.gsearch-aura { height: 140px; }
.library-empty-search-cta {
font-size: 12px;
padding: 9px 12px;
}
}

2875
webui/static/pages-extra.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1221
webui/static/search.js Normal file

File diff suppressed because it is too large Load diff

3658
webui/static/settings.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

2554
webui/static/sync-spotify.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff