commit
42971a17e6
94 changed files with 89614 additions and 78970 deletions
|
|
@ -1,5 +1,8 @@
|
|||
# Docker ignore file for SoulSync WebUI
|
||||
|
||||
# Hidden folders and files
|
||||
.*
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
|
|
|||
1
.github/workflows/cleanup-dev-images.yml
vendored
1
.github/workflows/cleanup-dev-images.yml
vendored
|
|
@ -8,6 +8,7 @@ on:
|
|||
|
||||
jobs:
|
||||
cleanup:
|
||||
if: github.repository == 'Nezreka/SoulSync'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
|
|
|
|||
1
.github/workflows/dev-nightly.yml
vendored
1
.github/workflows/dev-nightly.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
1
.github/workflows/docker-publish.yml
vendored
1
.github/workflows/docker-publish.yml
vendored
|
|
@ -15,6 +15,7 @@ on:
|
|||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: github.repository == 'Nezreka/SoulSync'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
|
|
|
|||
103
README.md
103
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
186
core/artist_source_detail.py
Normal file
186
core/artist_source_detail.py
Normal 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
|
||||
88
core/artist_source_lookup.py
Normal file
88
core/artist_source_lookup.py
Normal 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
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
1496
core/library_reorganize.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 artist→release-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]]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
453
core/reorganize_queue.py
Normal 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
123
core/reorganize_runner.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
274
tests/test_artist_source_detail.py
Normal file
274
tests/test_artist_source_detail.py
Normal 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"]
|
||||
239
tests/test_artist_source_lookup.py
Normal file
239
tests/test_artist_source_lookup.py
Normal 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
|
||||
106
tests/test_content_filter_regex.py
Normal file
106
tests/test_content_filter_regex.py
Normal 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"
|
||||
129
tests/test_discogs_track_count.py
Normal file
129
tests/test_discogs_track_count.py
Normal 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
|
||||
1936
tests/test_library_reorganize_orchestrator.py
Normal file
1936
tests/test_library_reorganize_orchestrator.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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):
|
||||
|
|
|
|||
767
tests/test_musicbrainz_search.py
Normal file
767
tests/test_musicbrainz_search.py
Normal 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
|
||||
213
tests/test_reorganize_db_methods.py
Normal file
213
tests/test_reorganize_db_methods.py
Normal 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')
|
||||
479
tests/test_reorganize_queue.py
Normal file
479
tests/test_reorganize_queue.py
Normal 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()
|
||||
235
tests/test_reorganize_runner.py
Normal file
235
tests/test_reorganize_runner.py
Normal 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.
|
||||
305
tests/test_script_split_integrity.py
Normal file
305
tests/test_script_split_integrity.py
Normal 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}"
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
164
tests/test_tidal_search_shortening.py
Normal file
164
tests/test_tidal_search_shortening.py
Normal 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'])
|
||||
162
tests/test_tidal_stream_tier_verification.py
Normal file
162
tests/test_tidal_stream_tier_verification.py
Normal 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
|
||||
|
|
@ -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"}],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
116
tests/test_worker_utils_album_track_count.py
Normal file
116
tests/test_worker_utils_album_track_count.py
Normal 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)
|
||||
1944
web_server.py
1944
web_server.py
File diff suppressed because it is too large
Load diff
347
webui/index.html
347
webui/index.html
|
|
@ -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">
|
||||
⚙
|
||||
</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
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
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
879
webui/static/core.js
Normal 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
8905
webui/static/discover.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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> — Search for artists on the Artists page and click the Watch button on each one</li>
|
||||
<li><strong>Add artists to your Watchlist</strong> — 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> — The built-in "Auto-Scan Watchlist" automation checks for new releases every 24 hours</li>
|
||||
<li><strong>Enable "Auto-Process Wishlist"</strong> — This automation picks up new releases found by the scan and downloads them every 30 minutes</li>
|
||||
<li><strong>Done!</strong> — New releases from watched artists are automatically found, queued, downloaded, tagged, and added to your library</li>
|
||||
|
|
|
|||
6039
webui/static/downloads.js
Normal file
6039
webui/static/downloads.js
Normal file
File diff suppressed because it is too large
Load diff
3552
webui/static/enrichment.js
Normal file
3552
webui/static/enrichment.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 2–6 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
2361
webui/static/init.js
Normal file
File diff suppressed because it is too large
Load diff
7285
webui/static/library.js
Normal file
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
2399
webui/static/media-player.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
2875
webui/static/pages-extra.js
Normal file
File diff suppressed because it is too large
Load diff
77260
webui/static/script.js
77260
webui/static/script.js
File diff suppressed because it is too large
Load diff
1221
webui/static/search.js
Normal file
1221
webui/static/search.js
Normal file
File diff suppressed because it is too large
Load diff
3658
webui/static/settings.js
Normal file
3658
webui/static/settings.js
Normal file
File diff suppressed because it is too large
Load diff
3773
webui/static/shared-helpers.js
Normal file
3773
webui/static/shared-helpers.js
Normal file
File diff suppressed because it is too large
Load diff
7611
webui/static/stats-automations.js
Normal file
7611
webui/static/stats-automations.js
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
9077
webui/static/sync-services.js
Normal file
9077
webui/static/sync-services.js
Normal file
File diff suppressed because it is too large
Load diff
2554
webui/static/sync-spotify.js
Normal file
2554
webui/static/sync-spotify.js
Normal file
File diff suppressed because it is too large
Load diff
7173
webui/static/wishlist-tools.js
Normal file
7173
webui/static/wishlist-tools.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue