diff --git a/.dockerignore b/.dockerignore index cd63220a..4b77bb74 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,8 @@ # Docker ignore file for SoulSync WebUI +# Hidden folders and files +.* + # Git .git .gitignore diff --git a/.github/workflows/cleanup-dev-images.yml b/.github/workflows/cleanup-dev-images.yml index 19597992..e065c9ce 100644 --- a/.github/workflows/cleanup-dev-images.yml +++ b/.github/workflows/cleanup-dev-images.yml @@ -8,6 +8,7 @@ on: jobs: cleanup: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest permissions: packages: write diff --git a/.github/workflows/dev-nightly.yml b/.github/workflows/dev-nightly.yml index d5312d29..6165350d 100644 --- a/.github/workflows/dev-nightly.yml +++ b/.github/workflows/dev-nightly.yml @@ -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) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index b18d1b26..44e766dd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,6 +15,7 @@ on: jobs: build-and-push: + if: github.repository == 'Nezreka/SoulSync' runs-on: ubuntu-latest permissions: diff --git a/README.md b/README.md index 6d1c30c9..771a0bac 100644 --- a/README.md +++ b/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-` 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. diff --git a/Support/README-Docker.md b/Support/README-Docker.md index 77d0f2d8..ce4a9c54 100644 --- a/Support/README-Docker.md +++ b/Support/README-Docker.md @@ -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 diff --git a/beatport_unified_scraper.py b/beatport_unified_scraper.py index 67daf909..8202981c 100644 --- a/beatport_unified_scraper.py +++ b/beatport_unified_scraper.py @@ -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__": diff --git a/core/acoustid_client.py b/core/acoustid_client.py index a0edb64b..b11c5960 100644 --- a/core/acoustid_client.py +++ b/core/acoustid_client.py @@ -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) diff --git a/core/acoustid_verification.py b/core/acoustid_verification.py index ee4100b5..a7cc3d0d 100644 --- a/core/acoustid_verification.py +++ b/core/acoustid_verification.py @@ -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 diff --git a/core/api_call_tracker.py b/core/api_call_tracker.py index 357ba4e4..f90a6dae 100644 --- a/core/api_call_tracker.py +++ b/core/api_call_tracker.py @@ -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.""" diff --git a/core/artist_source_detail.py b/core/artist_source_detail.py new file mode 100644 index 00000000..c62a5293 --- /dev/null +++ b/core/artist_source_detail.py @@ -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/`` 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//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 diff --git a/core/artist_source_lookup.py b/core/artist_source_lookup.py new file mode 100644 index 00000000..b965d4cf --- /dev/null +++ b/core/artist_source_lookup.py @@ -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 diff --git a/core/automation_engine.py b/core/automation_engine.py index 9964e474..92837476 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -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() diff --git a/core/database_update_worker.py b/core/database_update_worker.py index e0645e2d..3037fa79 100644 --- a/core/database_update_worker.py +++ b/core/database_update_worker.py @@ -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 diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index e34c7f8e..ffd3354b 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -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: diff --git a/core/deezer_worker.py b/core/deezer_worker.py index b5e942fc..4940d16d 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -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: diff --git a/core/discogs_worker.py b/core/discogs_worker.py index 99014882..84270de9 100644 --- a/core/discogs_worker.py +++ b/core/discogs_worker.py @@ -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: diff --git a/core/download_orchestrator.py b/core/download_orchestrator.py index 5f120309..2ec28532 100644 --- a/core/download_orchestrator.py +++ b/core/download_orchestrator.py @@ -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', '') diff --git a/core/hifi_client.py b/core/hifi_client.py index 4eafa51f..6da7ddf0 100644 --- a/core/hifi_client.py +++ b/core/hifi_client.py @@ -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'], diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 9a4769b4..1e1c2e9d 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -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}") diff --git a/core/jellyfin_client.py b/core/jellyfin_client.py index dcd4f14d..4cf1500e 100644 --- a/core/jellyfin_client.py +++ b/core/jellyfin_client.py @@ -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 diff --git a/core/library_reorganize.py b/core/library_reorganize.py new file mode 100644 index 00000000..6d62da98 --- /dev/null +++ b/core/library_reorganize.py @@ -0,0 +1,1496 @@ +"""Re-route a library album's existing files through the same +post-processing pipeline that handles fresh downloads. + +The old 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. Each of those drifted from the +canonical post-processing path over time, producing reorganize-only +bugs (multi-disc deluxe collapsing to single-disc when even one file's +tag was missing; tracks silently skipped when their file paths didn't +resolve on disk; etc.). + +The new design follows the import page's pattern: copy each file to a +staging folder, build the same context dict the download workers +build, then call ``_post_process_matched_download`` for each one. +Post-processing already knows how to pick the right destination, write +the right tags, handle multi-disc subfolders, recreate sidecars (cover +art, lyrics), and run AcoustID verification — there's nothing for +reorganize to add on top. + +Hard requirement: the album must have at least one stored +metadata-source ID (spotify_album_id / itunes_album_id / deezer_id / +discogs_id / soul_id). With no source ID we have nothing authoritative +to ask for the canonical tracklist, and silently degrading to file +tags is exactly the failure mode the old code path produced. Albums +without a source ID are reported back to the caller and skipped +entirely. +""" + +import os +import shutil +import threading +import time +import uuid +from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait +from dataclasses import dataclass, field +from typing import Any, Callable, Dict, List, Optional, Set + +# Per-album track concurrency. Matches the download workers' per-batch +# concurrency (3) so reorganize feels comparable to a fresh download. +# +# Operational note: post-processing can spawn an ffmpeg subprocess per +# track if `lossy_copy.downsample_hires` is enabled. With 3 workers +# that's up to 3 concurrent ffmpeg processes. Acceptable for typical +# album sizes (10-20 tracks); on a giant single-album reorganize +# (50+ tracks) ffmpeg's transient memory could be noticeable but each +# subprocess is short-lived so total RAM doesn't pile up. If we ever +# see resource issues from this, drop to 2 here rather than disabling +# concurrency entirely. +_REORGANIZE_MAX_WORKERS = 3 + +# Watchdog interval — how often the orchestrator checks the worker +# pool while waiting for tasks to finish. Setting this to 30s means +# we log a warning naming any track that's been in flight longer than +# `_HUNG_WORKER_THRESHOLD` (so an operator can investigate) without +# burning CPU on a tight poll. Doesn't kill stuck threads (Python +# can't), just surfaces them. +_WATCHDOG_INTERVAL_SECONDS = 30 +_HUNG_WORKER_THRESHOLD_SECONDS = 300 # 5 min — generous; real worst-case + # is ffmpeg downsampling a long + # hi-res FLAC, ~30-60s typically. + +from core.metadata_service import ( + get_album_for_source, + get_album_tracks_for_source, + get_client_for_source, + get_primary_source, + get_source_priority, +) +from utils.logging_config import get_logger + +logger = get_logger("library_reorganize") + + +def _safe_filename(name: str) -> str: + """Strip path-illegal characters so we can use the value as a + filename component on the staging path.""" + return ''.join(c for c in (name or 'unknown') if c not in '<>:"/\\|?*').strip() or 'unknown' + + +def _normalize_album_tracks(api_tracks): + """Normalize the various provider tracklist shapes (dict-with-`items`, + bare list, ``None``) to a single list of item dicts.""" + if not api_tracks: + return [] + if isinstance(api_tracks, dict): + items = api_tracks.get('items') or [] + return items if items else [] + if isinstance(api_tracks, list): + return api_tracks + return [] + + +SUPPORTED_SOURCES = ('spotify', 'itunes', 'deezer', 'discogs', 'hydrabase') + +# Per-source album-ID column mapping on the `albums` table row. +_ALBUM_ID_COLUMNS = { + 'spotify': 'spotify_album_id', + 'itunes': 'itunes_album_id', + 'deezer': 'deezer_id', + 'discogs': 'discogs_id', + 'hydrabase': 'soul_id', +} + +# Human-facing label for each source. +SOURCE_LABELS = { + 'spotify': 'Spotify', + 'itunes': 'Apple Music (iTunes)', + 'deezer': 'Deezer', + 'discogs': 'Discogs', + 'hydrabase': 'Hydrabase', +} + + +def _extract_source_ids(album_data: dict) -> Dict[str, str]: + """Pull the per-source album-ID strings off an album row.""" + return { + source: (album_data.get(column) or '') + for source, column in _ALBUM_ID_COLUMNS.items() + } + + +def available_sources_for_album(album_data: dict) -> List[dict]: + """Return the list of metadata sources the user can pick for this + album's reorganize. Every entry has both (a) a stored album ID on + the local row AND (b) an authenticated / configured client on this + SoulSync instance. + + Returns entries in source-priority order (preferred source first). + Each entry is ``{'source': str, 'label': str}``. No API calls — + purely local inspection. + """ + source_ids = _extract_source_ids(album_data) + try: + primary = get_primary_source() + except Exception: + primary = 'deezer' + + out = [] + for source in get_source_priority(primary): + if source not in SUPPORTED_SOURCES: + continue + if not source_ids.get(source): + continue + if get_client_for_source(source) is None: + continue + out.append({ + 'source': source, + 'label': SOURCE_LABELS.get(source, source), + }) + return out + + +def authed_sources() -> List[dict]: + """Return all metadata sources the user has authed/configured on + this SoulSync instance. Doesn't require any album-specific stored + ID — used by the bulk "Reorganize All" picker where each album + has its own ID coverage and we just want to know which sources + are reachable. Returned in priority order.""" + try: + primary = get_primary_source() + except Exception: + primary = 'deezer' + + out = [] + for source in get_source_priority(primary): + if source not in SUPPORTED_SOURCES: + continue + if get_client_for_source(source) is None: + continue + out.append({ + 'source': source, + 'label': SOURCE_LABELS.get(source, source), + }) + return out + + +def _resolve_source(album_data: dict, primary_source: str, strict_source: bool = False): + """Walk the configured source priority looking for the first source + we have an ID for AND that returns a usable tracklist. + + When ``strict_source`` is True, only the caller-provided + ``primary_source`` is tried — no fallback. Used when the user has + explicitly picked a source in the reorganize modal: picking Spotify + means "use Spotify or fail", not "use Spotify and silently fall + back to Deezer". + + Returns ``(source_name, album_meta, tracks_list)`` or ``(None, None, None)``. + """ + source_ids = _extract_source_ids(album_data) + + if strict_source: + sources_to_try = [primary_source] if primary_source else [] + else: + sources_to_try = get_source_priority(primary_source) + + for source in sources_to_try: + sid = source_ids.get(source) or '' + if not sid: + continue + try: + api_album = get_album_for_source(source, sid) + api_tracks = get_album_tracks_for_source(source, sid) + except Exception as e: + logger.warning(f"[Reorganize] {source} lookup raised: {e}") + continue + items = _normalize_album_tracks(api_tracks) + if not items or not api_album: + continue + return source, api_album, items + + return None, None, None + + +# Tokens that indicate a *different recording* of a track — when one +# side of a comparison has these and the other doesn't, the two are NOT +# the same track (e.g. "Bitch Don't Kill My Vibe" vs "Bitch Don't Kill +# My Vibe (Remix)" are different recordings; the tier 4 substring match +# would silently merge them otherwise). "Bonus track" is intentionally +# NOT here — it's a marketing annotation, not a recording difference. +_VERSION_DIFFERENTIATORS = frozenset({ + 'remix', 'remixed', + 'live', 'unplugged', 'concert', + 'acoustic', + 'demo', + 'extended', 'edit', + 'instrumental', 'karaoke', + 'remaster', 'remastered', 'remastering', + 'mono', 'stereo', + 'acapella', 'cappella', + 'cover', + 'reprise', + 'alternate', 'alt', + 'rehearsal', +}) + + +def _differentiators_in(norm_title: str) -> frozenset: + """Return the set of version-differentiator tokens present in a + normalized title. Used by the tier-4 matcher to reject substring + matches across different recordings of the same song.""" + if not norm_title: + return frozenset() + return frozenset(t for t in norm_title.split() if t in _VERSION_DIFFERENTIATORS) + + +def _normalize_title(value) -> str: + """Lowercase + strip cosmetic punctuation and treat brackets / dashes + / slashes as word separators so the same track named slightly + differently across providers and user libraries still matches. + + Examples that should normalize equal: + + - ``Bitch, Don't Kill My Vibe - Remix`` ↔ ``Bitch, Don't Kill My Vibe (Remix)`` + - ``Don't Stop Believin'`` ↔ ``Don’t Stop Believin’`` + - ``Swimming Pools (Drank) - Extended Version`` + ↔ ``Swimming Pools (Drank) (Extended Version)`` + """ + if value is None: + return '' + out = str(value).strip().lower() + # Strip characters that don't carry meaning across providers. + for ch in ('"', "'", '‘', '’', '“', '”', '.', ',', '!', '?', + '(', ')', '[', ']', '{', '}'): + out = out.replace(ch, '') + # Treat separators as whitespace so "foo - bar" and "foo (bar)" align. + for ch in ('-', '–', '—', ':', '/', '\\'): + out = out.replace(ch, ' ') + return ' '.join(out.split()) + + +# Title-match scoring grid. Each component's weight was picked to +# satisfy these design rules: +# +# 1. EXACT title alone is enough to win. +# 2. SUBSTRING at the high-confidence floor (≥0.6) is enough to win. +# 3. SUBSTRING at the lower with-tn-match floor (≥0.3) needs the +# track_number bonus to win — track_number provides the missing +# confidence. +# 4. TRACK-NUMBER alone is NOT enough — never falls through to a +# blind track-number lookup on multi-disc albums (that's the +# bug that mis-routed winecountrygames's bonus tracks). +# 5. Different version-differentiator tokens (Remix vs no-remix) +# hard-reject before scoring (see `_score_candidate`). +# +# Worked examples (with threshold = 50): +# +# exact title + tn match 100 + 20 = 120 → match +# exact title alone 100 = 100 → match +# substring ratio 1.0 (no tn match) 50 + 40 = 90 → match +# substring ratio 0.6 (no tn match) 50 + 0 = 50 → match +# substring ratio 0.5 (no tn match) 0 = 0 → no match +# substring ratio 0.45 + tn match 40 + 20 = 60 → match +# substring ratio 0.28 + tn match 0 + 20 = 20 → no match +# (Real vs "Real Real Real") +# track_number alone (no title signal) 0 + 20 = 20 → no match +# different version diffs (any inputs) hard-reject → 0 +# +# Weights are deliberately spaced so each gate is well-clear of the +# threshold; small ratio adjustments don't flip a borderline case +# unexpectedly. + +_MATCH_SCORE_THRESHOLD = 50 + +_W_EXACT_TITLE = 100 +_W_TRACK_NUMBER = 20 + +# Standalone substring (no tn match required): floor + scaled bonus. +# At ratio = floor: contribute base only. At ratio = 1.0: contribute +# base + range. Linear in between. +_W_SUBSTRING_BASE_STANDALONE = 50 +_W_SUBSTRING_RATIO_RANGE = 40 +_SUBSTRING_RATIO_FLOOR_STANDALONE = 0.6 + +# With-tn-match substring: lower floor (0.3) but slightly reduced +# base (40) so this path never beats a standalone high-ratio match +# on equal-tn ties. +_W_SUBSTRING_BASE_WITH_TN = 40 +_SUBSTRING_RATIO_FLOOR_WITH_TN = 0.3 + + +def _score_candidate( + norm_local: str, + local_tn: Optional[int], + local_diffs: frozenset, + api_norm: str, + api_tn: Optional[int], +) -> int: + """Score a single API candidate against the local track. Higher + means more confident match; 0 means no usable signal. The orchestrator + picks the highest-scoring candidate above + :data:`_MATCH_SCORE_THRESHOLD` and treats sub-threshold tracks as + unmatched (the "trust the source — if it doesn't have the track, + skip it" design policy). + + Components: + + - **Exact normalized-title match** is the strongest signal — usually + enough on its own, especially because local titles SoulSync wrote + should already match the source's text after normalization. + - **Substring containment** with a length-ratio guard handles + annotation drift like ``"The Recipe - Bonus Track"`` (local) + matching ``"The Recipe"`` (API). The ratio bonus rewards more + specific matches, so longer common prefixes win over shorter ones. + - **Track-number agreement** is a tiebreaker, never enough alone + (track_number-only would mis-route on multi-disc). + - **Version-differentiator mismatch** is a hard reject — if local + has ``Remix`` and API doesn't (or vice versa), they're different + recordings, not annotation drift. Returns 0 unconditionally. + """ + if not norm_local or not api_norm: + return 0 + + # Hard reject: version differentiators must agree exactly. ``Remix`` + # vs no-remix means different recordings, regardless of how + # otherwise-similar the titles are. + if _differentiators_in(api_norm) != local_diffs: + return 0 + + score = 0 + tn_match = local_tn is not None and api_tn == local_tn + + if api_norm == norm_local: + score += _W_EXACT_TITLE + else: + if api_norm in norm_local: + ratio = len(api_norm) / max(len(norm_local), 1) + elif norm_local in api_norm: + ratio = len(norm_local) / max(len(api_norm), 1) + else: + ratio = 0.0 + if ratio >= _SUBSTRING_RATIO_FLOOR_STANDALONE: + # Strong substring — credit regardless of tn agreement. + normalized = ( + (ratio - _SUBSTRING_RATIO_FLOOR_STANDALONE) + / (1.0 - _SUBSTRING_RATIO_FLOOR_STANDALONE) + ) + score += _W_SUBSTRING_BASE_STANDALONE + int(normalized * _W_SUBSTRING_RATIO_RANGE) + elif tn_match and ratio >= _SUBSTRING_RATIO_FLOOR_WITH_TN: + # Weaker substring (e.g., "the recipe" in "the recipe bonus + # track" at ratio 0.45) — accept ONLY because track_number + # also matches, and at slightly reduced base score. + score += _W_SUBSTRING_BASE_WITH_TN + + if tn_match: + score += _W_TRACK_NUMBER + + return score + + +def _prenormalize_api_tracks(api_tracks: List[dict]) -> List[tuple]: + """Compute ``(item, normalized_title, parsed_track_number)`` once + per API track so the matcher doesn't redo this work on every local + track. Callers that match many local tracks against the same API + list (the orchestrator's per-album loop) should hold this list and + pass it to :func:`_find_api_track`. + + For a 17-track local library matched against a 22-track API list, + avoiding re-normalization saves 17×22 = 374 normalize calls per + album reorganize.""" + out = [] + for item in api_tracks: + api_norm = _normalize_title(item.get('name') or item.get('title')) + try: + api_tn = int(item.get('track_number')) if item.get('track_number') is not None else None + except (TypeError, ValueError): + api_tn = None + out.append((item, api_norm, api_tn)) + return out + + +def _find_api_track(api_tracks, db_title: str, db_track_number) -> Optional[dict]: + """Find the API track that corresponds to a given local track row. + + ``api_tracks`` may be either a raw list of API dicts (will be + normalized internally) OR a list of pre-normalized 3-tuples from + :func:`_prenormalize_api_tracks`. The orchestrator uses the + pre-normalized form to avoid O(n*m) normalization calls; tests + use the raw list for convenience. + + Local rows carry (title, track_number) but NOT disc_number. + Multi-disc albums repeat track_numbers across discs, so a + track_number-only join would collapse the mapping. Title is the + natural disambiguator (each disc's track 1 has a different title), + but local titles drift from API titles in predictable ways: + trailing ``- Bonus Track`` annotations, ``- Remix`` vs ``(Remix)``, + etc. + + Implementation: each candidate is scored by :func:`_score_candidate`; + the highest-scoring one above :data:`_MATCH_SCORE_THRESHOLD` wins. + If nothing clears the threshold the source genuinely doesn't have a + plausible match and we return ``None`` — the orchestrator surfaces + that as ``"not in tracklist, left in place"`` rather than silently + mis-routing. + """ + norm_local = _normalize_title(db_title) + if not norm_local: + return None + try: + tn = int(db_track_number) if db_track_number is not None else None + except (TypeError, ValueError): + tn = None + local_diffs = _differentiators_in(norm_local) + + # Accept either pre-normalized candidates or raw API dicts. + if api_tracks and isinstance(api_tracks[0], tuple): + candidates = api_tracks # type: ignore[assignment] + else: + candidates = _prenormalize_api_tracks(api_tracks) # type: ignore[arg-type] + + best_item: Optional[dict] = None + best_score = 0 + best_tn_match = False + + for item, api_norm, api_tn in candidates: + score = _score_candidate(norm_local, tn, local_diffs, api_norm, api_tn) + if score < _MATCH_SCORE_THRESHOLD: + continue + tn_match = tn is not None and api_tn == tn + if score > best_score or (score == best_score and tn_match and not best_tn_match): + best_item = item + best_score = score + best_tn_match = tn_match + + return best_item + + +def load_album_and_tracks(db, album_id): + """Load the album row + all its track rows from the local DB. + + Returns ``(album_dict | None, tracks_list)``. ``album_dict`` is None + when the album doesn't exist; tracks_list is empty when the album + has no tracks. The caller decides what status to surface for each + state. + """ + conn = None + try: + conn = db._get_connection() + cursor = conn.cursor() + cursor.execute( + """ + SELECT al.*, ar.name as artist_name + FROM albums al + JOIN artists ar ON al.artist_id = ar.id + WHERE al.id = ? + """, + (str(album_id),), + ) + album_row = cursor.fetchone() + if not album_row: + return None, [] + album_data = dict(album_row) + + cursor.execute( + """ + SELECT t.*, ar.name as artist_name + FROM tracks t + JOIN artists ar ON t.artist_id = ar.id + WHERE t.album_id = ? + ORDER BY t.track_number + """, + (str(album_id),), + ) + tracks = [dict(r) for r in cursor.fetchall()] + return album_data, tracks + finally: + if conn is not None: + try: + conn.close() + except Exception: + pass + + +def plan_album_reorganize( + album_data: dict, + tracks: List[dict], + primary_source: Optional[str] = None, + strict_source: bool = False, +) -> dict: + """Compute the per-track plan for an album reorganize without doing + any file IO. Both the actual reorganize orchestrator and the preview + endpoint share this so the preview is guaranteed to match what would + happen on apply. + + Returns: + ``{'status': 'planned' | 'no_source_id' | 'no_tracks', + 'source': str | None, + 'api_album': dict | None, + 'total_discs': int, + 'items': [{'track': dict, 'api_track': dict | None, + 'matched': bool, 'reason': str | None}, ...]}`` + + Per-track behavior matches the orchestrator exactly: + - Match by `(normalized_title, track_number)`, then title alone, then + track_number alone. + - Tracks with no match are reported with `matched=False` and a reason. + - `disc_number` for each track comes from its matched API entry; if + unmatched, `api_track is None` and the caller decides what to do. + """ + if not tracks: + return { + 'status': 'no_tracks', 'source': None, 'api_album': None, + 'total_discs': 1, 'items': [], + } + + if primary_source is None: + try: + primary_source = get_primary_source() + except Exception: + primary_source = 'deezer' + + source, api_album, api_tracks = _resolve_source( + album_data, primary_source, strict_source=strict_source + ) + if not source: + reason = ( + f"Source '{primary_source}' has no usable tracklist for this album" + if strict_source else + "No metadata source ID for this album" + ) + return { + 'status': 'no_source_id', 'source': None, 'api_album': None, + 'total_discs': 1, + 'items': [{ + 'track': t, 'api_track': None, 'matched': False, + 'reason': reason, + } for t in tracks], + } + + total_discs = max( + (int(item.get('disc_number') or 1) for item in api_tracks), + default=1, + ) + + # Pre-normalize once so the matcher doesn't redo the work per track. + prenormalized = _prenormalize_api_tracks(api_tracks) + items = [] + for track in tracks: + api_track = _find_api_track(prenormalized, track.get('title', ''), track.get('track_number')) + if api_track is None: + items.append({ + 'track': track, 'api_track': None, 'matched': False, + 'reason': f"No matching track in {source} tracklist (likely a bonus / non-canonical track)", + }) + else: + items.append({ + 'track': track, 'api_track': api_track, 'matched': True, + 'reason': None, + }) + + return { + 'status': 'planned', + 'source': source, + 'api_album': api_album, + 'total_discs': total_discs, + 'items': items, + } + + +def _build_post_process_context( + api_album: dict, + api_track: dict, + artist_name: str, + album_title: str, + total_discs: int, +) -> dict: + """Build the same shape `import_album_process` builds so post-process + treats this exactly like a fresh download with full Spotify-style + metadata in hand.""" + track_number = int(api_track.get('track_number') or 1) + disc_number = int(api_track.get('disc_number') or 1) + track_artists = api_track.get('artists') or [artist_name] + normalized_artists = [ + ({'name': a} if isinstance(a, str) else a) for a in track_artists + ] + + api_album_id = api_album.get('id') or api_album.get('album_id') or '' + api_album_name = api_album.get('name') or api_album.get('title') or album_title + api_album_release = ( + api_album.get('release_date') + or api_album.get('releaseDate') + or '' + ) + api_album_total_tracks = ( + api_album.get('total_tracks') + or api_album.get('totalTracks') + or 0 + ) + # Spotify shape: {'images': [{'url': ...}, ...]}. + # Deezer shape: {'image_url': '...'}. + api_album_image = api_album.get('image_url') or '' + if not api_album_image: + images = api_album.get('images') + if isinstance(images, list) and images: + first = images[0] + if isinstance(first, dict): + api_album_image = first.get('url') or '' + + track_name = api_track.get('name') or api_track.get('title') or '' + + return { + 'spotify_artist': { + 'name': artist_name, + 'id': '', + 'genres': [], + }, + 'spotify_album': { + 'id': api_album_id, + 'name': api_album_name, + 'release_date': api_album_release, + 'total_tracks': api_album_total_tracks, + 'total_discs': total_discs, + 'image_url': api_album_image, + }, + 'track_info': { + 'name': track_name, + 'id': api_track.get('id', ''), + 'track_number': track_number, + 'disc_number': disc_number, + 'duration_ms': api_track.get('duration_ms', 0), + 'artists': normalized_artists, + 'uri': api_track.get('uri', ''), + }, + 'original_search_result': { + 'title': track_name, + 'artist': artist_name, + 'album': api_album_name, + 'track_number': track_number, + 'disc_number': disc_number, + 'spotify_clean_title': track_name, + 'spotify_clean_album': api_album_name, + 'artists': normalized_artists, + }, + 'is_album_download': True, + 'has_clean_spotify_data': True, + 'has_full_spotify_metadata': True, + } + + +def preview_album_reorganize( + *, + album_id: str, + db, + transfer_dir: str, + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], + build_final_path_fn: Callable, + primary_source: Optional[str] = None, + strict_source: bool = False, +) -> dict: + """Compute the planned destination paths for a reorganize WITHOUT + moving any files. The preview UI uses this to show users what the + "Apply" run would do. + + Critically: the destination per track comes from + ``build_final_path_fn(context, spotify_artist, None, file_ext)`` — + the same shared helper post-processing uses. So the preview is + guaranteed to match what the orchestrator would actually produce. + + Args: + album_id: Library album ID. + db: Database object exposing ``_get_connection()``. + transfer_dir: Configured transfer directory (for trimming the + display-relative current-path string). + resolve_file_path_fn: Resolves a DB-stored file path to the + actual on-disk path (or ``None`` if missing). + build_final_path_fn: ``_build_final_path_for_track`` from + web_server. Signature is + ``(context, spotify_artist, album_info_or_none, file_ext) -> (path, ok)``. + Injected so this module stays Flask-free. + primary_source: Optional override for the configured primary + source. + + Returns: + ``{ + 'success': bool, + 'status': str, # 'planned' | 'no_album' | 'no_tracks' | 'no_source_id' + 'source': str | None, + 'album': str, + 'artist': str, + 'transfer_dir': str, + 'tracks': [ + {'track_id', 'title', 'track_number', 'current_path', + 'new_path', 'file_exists', 'unchanged', 'collision', + 'matched', 'reason', 'disc_number'}, + ... + ], + }`` + """ + album_data, tracks = load_album_and_tracks(db, album_id) + if album_data is None: + return {'success': False, 'status': 'no_album', 'tracks': []} + + if not tracks: + return { + 'success': False, 'status': 'no_tracks', + 'album': album_data.get('title', ''), + 'artist': album_data.get('artist_name', ''), + 'tracks': [], + } + + plan = plan_album_reorganize( + album_data, tracks, + primary_source=primary_source, strict_source=strict_source, + ) + artist_name = album_data.get('artist_name') or 'Unknown Artist' + album_title = album_data.get('title') or 'Unknown Album' + + common = { + 'album': album_title, + 'artist': artist_name, + 'transfer_dir': transfer_dir, + 'source': plan['source'], + } + + if plan['status'] == 'no_source_id': + return { + 'success': False, 'status': 'no_source_id', + **common, + 'tracks': [{ + 'track_id': t.get('id'), + 'title': t.get('title', ''), + 'track_number': t.get('track_number', 0), + 'current_path': t.get('file_path', ''), + 'new_path': '', + 'file_exists': False, 'unchanged': False, 'collision': False, + 'matched': False, + 'reason': 'No metadata source ID — run enrichment first', + 'disc_number': None, + } for t in tracks], + } + + total_discs = plan['total_discs'] + api_album = plan['api_album'] or {} + preview_tracks = [] + + for plan_item in plan['items']: + track = plan_item['track'] + title = track.get('title', '') + db_path = track.get('file_path') + resolved = resolve_file_path_fn(db_path) if db_path else None + file_ext = os.path.splitext(resolved or db_path or '.flac')[1] or '.flac' + + item = { + 'track_id': track.get('id'), + 'title': title, + 'track_number': track.get('track_number', 0), + 'current_path': _trim_to_transfer(db_path, resolved, transfer_dir), + 'new_path': '', + 'file_exists': resolved is not None, + 'unchanged': False, + 'collision': False, + 'matched': plan_item['matched'], + 'reason': plan_item.get('reason'), + 'disc_number': None, + } + + if not plan_item['matched']: + preview_tracks.append(item) + continue + + api_track = plan_item['api_track'] + item['disc_number'] = int(api_track.get('disc_number') or 1) + # Build the same context the orchestrator builds so the path + # builder produces the same destination it would on apply. + context = _build_post_process_context( + api_album, api_track, artist_name, album_title, total_discs + ) + # `_build_final_path_for_track` switches between ALBUM and SINGLE + # modes based on `album_info.get('is_album')` — must be passed, + # not None, otherwise multi-disc deluxes degrade to single-track + # folders (the exact bug winecountrygames hit). + album_info = _build_album_info(context) + try: + spotify_artist = context['spotify_artist'] + new_full, _ok = build_final_path_fn(context, spotify_artist, album_info, file_ext) + item['new_path'] = ( + os.path.relpath(new_full, transfer_dir) + if transfer_dir and new_full and new_full.startswith(transfer_dir) + else new_full or '' + ) + if resolved and new_full and os.path.normpath(resolved) == os.path.normpath(new_full): + item['unchanged'] = True + except Exception as e: + item['reason'] = f"Couldn't compute destination path: {e}" + + preview_tracks.append(item) + + # Collision detection: multiple matched tracks mapping to the same + # destination would overwrite each other on apply. + seen = {} + for it in preview_tracks: + if not it['matched'] or it['unchanged'] or not it['new_path']: + continue + norm = os.path.normpath(it['new_path']) + if norm in seen: + it['collision'] = True + seen[norm]['collision'] = True + else: + seen[norm] = it + + return { + 'success': True, 'status': 'planned', + **common, + 'tracks': preview_tracks, + } + + +def _trim_to_transfer(db_path, resolved, transfer_dir): + """Compose the user-facing 'current path' string — relative to the + transfer dir if the file lives there, else the raw DB value.""" + if resolved and transfer_dir and resolved.startswith(transfer_dir): + return resolved[len(transfer_dir):].lstrip(os.sep).lstrip('/') + return db_path or 'No file' + + +def _build_album_info(context: dict) -> dict: + """Build the ``album_info`` dict that ``_build_final_path_for_track`` + consumes to enter ALBUM MODE. Without this (passing None) the path + builder falls through to SINGLE MODE and produces per-track folders + named after each track title — the exact bug we're fixing. + + Mirrors the shape the download path produces at write time. + """ + spotify_album = context.get('spotify_album', {}) or {} + track_info = context.get('track_info', {}) or {} + return { + 'is_album': True, + 'album_name': spotify_album.get('name') or 'Unknown Album', + 'clean_track_name': track_info.get('name') or 'Unknown Track', + 'track_number': track_info.get('track_number') or 1, + 'disc_number': track_info.get('disc_number') or 1, + 'album_image_url': spotify_album.get('image_url') or '', + 'spotify_album_id': spotify_album.get('id') or '', + } + + +@dataclass +class _RunContext: + """Bundles all state + injected dependencies a single + ``_process_one_track`` call needs. + + Hoisted out of orchestrator-local closures so the per-track + helpers can be unit-tested directly with a fake ctx, and so a + stack trace into a failing helper is intelligible (closures + captured 16+ values, none of which were visible in tracebacks). + + Thread-safety contract — read this before adding new fields: + + - ``state_lock`` MUST be held when mutating any of the + lock-protected fields below. The provided ``record_error`` + method already takes the lock; direct mutation outside that + method is the only place where future contributors might + forget. Add new mutable shared state with the same discipline. + + Lock-protected fields (mutate only inside ``state_lock``): + + summary dict — counts and errors list + src_dirs_touched set — populated by `_finalize_track` + dst_dirs_touched set — populated by `_finalize_track` + + Read-only after construction (safe to read without locking): + + album_id, api_album, artist_name, album_title, total_discs, + staging_album_dir, resolve_file_path_fn, post_process_fn, + update_track_path_fn, on_progress, stop_check, state_lock + + Side-effecting methods that take the lock internally: + + record_error() — records a per-track failure + emit() — fires on_progress callback (no lock; + assumes caller holds it when also + passing summary fields, which the + record_error and orchestrator-success + paths both do) + """ + album_id: str + api_album: dict + artist_name: str + album_title: str + total_discs: int + staging_album_dir: str + state_lock: threading.Lock # required to mutate lock-protected fields + summary: dict # LOCK-PROTECTED + src_dirs_touched: Set[str] # LOCK-PROTECTED + dst_dirs_touched: Set[str] # LOCK-PROTECTED + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]] + post_process_fn: Callable[[str, dict, str], None] + update_track_path_fn: Optional[Callable[[Any, str], None]] = None + on_progress: Optional[Callable[[dict], None]] = None + stop_check: Optional[Callable[[], bool]] = None + + def emit(self, **updates) -> None: + """Fire the progress callback. Caller is responsible for + holding ``state_lock`` when the updates payload includes + snapshots of lock-protected fields (so the snapshot is + coherent). Currently always called from inside the lock by + ``record_error`` and the orchestrator's success path.""" + if self.on_progress is None: + return + try: + self.on_progress(updates) + except Exception: + pass + + def record_error(self, track_id, title, message, kind: str = 'skipped') -> None: + with self.state_lock: + self.summary['errors'].append({ + 'track_id': track_id, + 'title': title, + 'error': message, + }) + self.summary[kind] += 1 + self.emit(**{ + kind: self.summary[kind], + 'errors': list(self.summary['errors']), + 'processed': ( + self.summary['moved'] + + self.summary['skipped'] + + self.summary['failed'] + ), + }) + + +def _stage_track(ctx: _RunContext, track_id, title, resolved_src) -> Optional[str]: + """Stage a copy of ``resolved_src`` into a per-track UUID + subdirectory under ``ctx.staging_album_dir``. + + Per-track subdirs are required for concurrent safety: post-process + calls ``_cleanup_empty_directories`` after each move, which walks + UP from the source file removing empty dirs. With a shared + ``staging_album_dir`` that walk would race with other workers' + in-flight ``makedirs``/``copy2`` calls — worker A finishing could + nuke the dir between worker B's ``makedirs`` and ``copy2``, + causing intermittent ``[WinError 3]`` / ``ENOENT`` failures. + + With per-track subdirs: + + - Worker A's cleanup walks: per-track subdir (empty after move → + removed) → ``staging_album_dir`` (still has other workers' + subdirs → not empty → walk stops). ✓ + - Worker B's stage-in: makedirs its OWN subdir, copies into + it. No interference from worker A. ✓ + """ + worker_dir = os.path.join(ctx.staging_album_dir, uuid.uuid4().hex[:8]) + try: + os.makedirs(worker_dir, exist_ok=True) + except OSError as mk_err: + ctx.record_error(track_id, title, + f"Couldn't create staging subdirectory: {mk_err}", + kind='failed') + return None + staging_file = os.path.join(worker_dir, os.path.basename(resolved_src)) + try: + shutil.copy2(resolved_src, staging_file) + except OSError as copy_err: + ctx.record_error(track_id, title, + f"Couldn't copy to staging: {copy_err}", + kind='failed') + return None + return staging_file + + +def _run_post_process_for_track(ctx: _RunContext, track_id, title, api_track, staging_file) -> Optional[str]: + """Build the per-track context, hand it to post-processing, and + return the final on-disk path it produced. Returns None on any + failure (exception, AcoustID rejection, internal skip); the caller + leaves the original file alone.""" + context = _build_post_process_context( + ctx.api_album, api_track, ctx.artist_name, ctx.album_title, ctx.total_discs + ) + context_key = f"reorganize_{ctx.album_id}_{track_id}_{uuid.uuid4().hex[:8]}" + try: + ctx.post_process_fn(context_key, context, staging_file) + except Exception as pp_err: + ctx.record_error(track_id, title, + f"Post-processing failed: {pp_err}", + kind='failed') + return None + new_path = context.get('_final_processed_path') + if not new_path or not os.path.exists(new_path): + ctx.record_error(track_id, title, + 'Post-processing did not produce a final file ' + '(AcoustID rejection, quarantine, or skip).', + kind='failed') + return None + return new_path + + +def _finalize_track(ctx: _RunContext, track_id, resolved_src, new_path) -> bool: + """Update the DB row, then remove the original (in that order — DB + failure leaves the file at both locations, recoverable by library + scan; the reverse would orphan the row). Records src/dst dirs for + end-of-run cleanup, deletes per-track sidecars. + + Returns ``True`` if the track is fully landed (DB row points to + ``new_path`` AND the original is dealt with), ``False`` if DB + update failed. Caller MUST treat False as a failure for counting + purposes — the file is at both locations, the DB still points to + the old path, and counting it as "moved" overstates how many + tracks the user can actually find via the UI.""" + if ctx.update_track_path_fn: + try: + ctx.update_track_path_fn(track_id, new_path) + except Exception as db_err: + logger.warning( + f"[Reorganize] DB path update failed for {track_id}: {db_err} " + f"— leaving original at {resolved_src} so the library scan can recover." + ) + return False + if os.path.normpath(resolved_src) == os.path.normpath(new_path): + return True # in-place edit; DB already correct, nothing to remove + with ctx.state_lock: + ctx.src_dirs_touched.add(os.path.dirname(resolved_src)) + ctx.dst_dirs_touched.add(os.path.dirname(new_path)) + try: + os.remove(resolved_src) + except OSError as rm_err: + logger.warning(f"[Reorganize] Couldn't remove original {resolved_src}: {rm_err}") + _delete_track_sidecars(resolved_src) + return True + + +def _process_one_track(ctx: _RunContext, plan_item: dict) -> None: + """Process a single plan item end-to-end. Safe to call concurrently + from multiple workers — all shared-state mutations go through + ``ctx.state_lock`` (via ``record_error`` and ``_finalize_track``).""" + if ctx.stop_check and ctx.stop_check(): + return + track = plan_item['track'] + title = track.get('title', 'Unknown') + track_id = track.get('id') + ctx.emit(current_track=title) + + if not plan_item['matched']: + ctx.record_error(track_id, title, + plan_item.get('reason') or 'No matching API track') + return + + db_path = track.get('file_path') + resolved_src = ctx.resolve_file_path_fn(db_path) if db_path else None + if not resolved_src: + ctx.record_error(track_id, title, + f"File not found on disk — DB path: {db_path or '(empty)'}") + return + + staging_file = _stage_track(ctx, track_id, title, resolved_src) + if staging_file is None: + return + + new_path = _run_post_process_for_track(ctx, track_id, title, plan_item['api_track'], staging_file) + if new_path is None: + return + + finalized = _finalize_track(ctx, track_id, resolved_src, new_path) + if not finalized: + # File landed at new_path but DB row + original-removal didn't. + # User can still find the track (library scan will re-index from + # new_path), but we can't honestly count it as "moved" — that + # would overstate how many tracks the UI knows are at their new + # locations. Surfacing as failed lets the user see something + # needs attention (per kettui's PR #377 review). + ctx.record_error( + track_id, title, + 'Track landed at new location but DB update failed — ' + 'file is at both old and new paths until library scan re-indexes.', + kind='failed', + ) + return + + with ctx.state_lock: + ctx.summary['moved'] += 1 + ctx.emit( + moved=ctx.summary['moved'], + processed=ctx.summary['moved'] + ctx.summary['skipped'] + ctx.summary['failed'], + ) + + +def reorganize_album( + *, + album_id: str, + db, + staging_root: str, + resolve_file_path_fn: Callable[[Optional[str]], Optional[str]], + post_process_fn: Callable[[str, dict, str], None], + update_track_path_fn: Optional[Callable[[object, str], None]] = None, + cleanup_empty_dir_fn: Optional[Callable[[str], None]] = None, + transfer_dir: Optional[str] = None, + on_progress: Optional[Callable[[dict], None]] = None, + primary_source: Optional[str] = None, + strict_source: bool = False, + stop_check: Optional[Callable[[], bool]] = None, +) -> dict: + """Run a single album through the post-processing pipeline. + + See module docstring for the rationale. Dependencies (file + resolution, post-processing, DB-path update, empty-dir cleanup) + are injected so the orchestrator stays in ``core/`` and is unit + testable without spinning up the Flask app. + + Args: + album_id: Library album ID. + db: Database object exposing ``_get_connection()``. + staging_root: Root staging directory under the user's download + path. A per-album subfolder is created beneath it; the + whole subfolder is removed at the end of the run. + resolve_file_path_fn: Resolves a DB-stored file path to the + actual on-disk path (or ``None`` if missing). Injected + because the resolution logic lives in ``web_server``. + post_process_fn: ``_post_process_matched_download``. Must set + ``context['_final_processed_path']`` on success. + update_track_path_fn: Called as + ``update_track_path_fn(track_id, new_path)`` after each + successful post-process to update the DB row. ``None`` to + skip (e.g. in tests). + cleanup_empty_dir_fn: Called with each source directory we + emptied so the caller can prune empty parents. ``None`` to + skip. + on_progress: Optional callback for live status updates. + Receives a dict with any subset of the standard reorganize + state keys (``current_track``, ``processed``, ``moved``, + ``skipped``, ``failed``, ``errors``). + primary_source: Override for the configured primary source. + Defaults to ``get_primary_source()``. + stop_check: Returns True when the caller wants the reorganize + to abort early (e.g. server shutdown). + + Returns: + Status summary dict with ``status`` ∈ ``{'completed', + 'no_album', 'no_tracks', 'no_source_id'}`` plus per-track + counters. + """ + summary = { + 'status': 'completed', + 'source': None, + 'total': 0, + 'moved': 0, + 'skipped': 0, + 'failed': 0, + 'errors': [], + } + + state_lock = threading.Lock() + + def _emit(**updates): + if on_progress is None: + return + try: + on_progress(updates) + except Exception: + pass + + # Load album + tracks + album_data, tracks = load_album_and_tracks(db, album_id) + if album_data is None: + summary['status'] = 'no_album' + return summary + + if not tracks: + summary['status'] = 'no_tracks' + return summary + + summary['total'] = len(tracks) + _emit(total=len(tracks)) + + # Build the per-track plan (same logic the preview uses). + plan = plan_album_reorganize( + album_data, tracks, + primary_source=primary_source, strict_source=strict_source, + ) + if plan['status'] == 'no_source_id': + summary['status'] = 'no_source_id' + summary['errors'].append({ + 'error': ( + f"No reachable metadata source ID for '{album_data.get('title', '?')}' — " + "run enrichment first to populate at least one of " + "spotify_album_id / itunes_album_id / deezer_id / discogs_id / soul_id." + ), + }) + return summary + + source = plan['source'] + api_album = plan['api_album'] + total_discs = plan['total_discs'] + summary['source'] = source + logger.info( + f"[Reorganize] Album '{album_data.get('title')}' resolved via {source}: " + f"{len(plan['items'])} item(s) planned" + ) + + # Per-album staging dir under the configured download path. Cleaned + # up (best-effort) at the end of the run regardless of outcome. + artist_name = album_data.get('artist_name') or 'Unknown Artist' + album_title = album_data.get('title') or 'Unknown Album' + staging_album_dir = os.path.join( + staging_root, + f"{_safe_filename(artist_name)} - {_safe_filename(album_title)}_{uuid.uuid4().hex[:8]}", + ) + try: + os.makedirs(staging_album_dir, exist_ok=True) + except OSError as e: + summary['status'] = 'setup_failed' + summary['errors'].append({ + 'error': f"Couldn't create staging directory '{staging_album_dir}': {e}", + }) + return summary + + src_dirs_touched: Set[str] = set() + dst_dirs_touched: Set[str] = set() + + ctx = _RunContext( + album_id=str(album_id), + api_album=api_album or {}, + artist_name=artist_name, + album_title=album_title, + total_discs=total_discs, + staging_album_dir=staging_album_dir, + state_lock=state_lock, + summary=summary, + src_dirs_touched=src_dirs_touched, + dst_dirs_touched=dst_dirs_touched, + resolve_file_path_fn=resolve_file_path_fn, + post_process_fn=post_process_fn, + update_track_path_fn=update_track_path_fn, + on_progress=on_progress, + stop_check=stop_check, + ) + + try: + # 3 concurrent workers per album — matches the download-side + # batch worker count. Post-process has its own per-context-key + # lock so concurrent calls don't race on the same file, and + # all shared-state mutations here are inside `state_lock`. + # + # Wait loop with a periodic watchdog: instead of blocking + # indefinitely on `as_completed`, we wake every + # `_WATCHDOG_INTERVAL_SECONDS` so we can react to stop_check + # promptly AND log a warning if any track has been processing + # for longer than `_HUNG_WORKER_THRESHOLD_SECONDS`. We can't + # kill the thread (Python doesn't allow that cleanly), but + # surfacing it lets operators investigate. + with ThreadPoolExecutor( + max_workers=_REORGANIZE_MAX_WORKERS, + thread_name_prefix='Reorganize', + ) as executor: + future_to_item = { + executor.submit(_process_one_track, ctx, item): item + for item in plan['items'] + } + future_started_at = {f: time.monotonic() for f in future_to_item} + pending = set(future_to_item.keys()) + warned_about: Set[Any] = set() + + while pending: + if stop_check and stop_check(): + for f in pending: + f.cancel() + break + + done, pending = wait( + pending, + timeout=_WATCHDOG_INTERVAL_SECONDS, + return_when=FIRST_COMPLETED, + ) + for finished in done: + try: + finished.result() + except Exception as worker_err: + logger.error( + f"[Reorganize] Worker raised: {worker_err}", + exc_info=True, + ) + + # Watchdog pass — log once per stuck future. + now = time.monotonic() + for f in pending: + if f in warned_about: + continue + elapsed = now - future_started_at[f] + if elapsed >= _HUNG_WORKER_THRESHOLD_SECONDS: + item = future_to_item.get(f, {}) + track_title = (item.get('track') or {}).get('title', 'Unknown') + logger.warning( + f"[Reorganize] Worker stuck for {elapsed:.0f}s on track " + f"'{track_title}' — leaving it running, other workers continuing." + ) + warned_about.add(f) + + finally: + # Best-effort cleanup of the staging dir. + try: + if os.path.isdir(staging_album_dir): + shutil.rmtree(staging_album_dir, ignore_errors=True) + except Exception: + pass + + # Best-effort cleanup of source directories. For each touched dir + # that has no audio files left (i.e. every track in this dir was + # successfully moved), delete album-level sidecars (cover.jpg, + # folder.jpg, etc.) so the dir is empty enough for the empty-dir + # pruner to take it. If audio remains (a track failed to move), + # leave everything alone so the user can see what's still there. + for src_dir in src_dirs_touched: + try: + if _has_remaining_audio(src_dir): + continue + _delete_album_sidecars(src_dir) + except Exception: + pass + + if cleanup_empty_dir_fn: + for src_dir in src_dirs_touched: + try: + cleanup_empty_dir_fn(src_dir) + except Exception: + pass + + # Prune empty *destination* siblings — e.g. when a previous + # failed reorganize attempt left ``Artist/Album-Sibling/`` dirs + # behind that we never end up using, OR when a current-run + # post-process created a destination dir then failed AcoustID + # before landing the file. Walk up from any successful + # destination to the artist folder, then prune one level of + # empty children. Bounded depth = safer than recursive sweep. + if transfer_dir and dst_dirs_touched: + artist_dirs = set() + for dst in dst_dirs_touched: + artist = _find_artist_dir(dst, transfer_dir) + if artist: + artist_dirs.add(artist) + for artist_dir in artist_dirs: + _prune_empty_album_dirs(artist_dir) + + return summary + + +def _find_artist_dir(dest_path: str, transfer_dir: str) -> Optional[str]: + """Walk up from ``dest_path`` until the parent equals ``transfer_dir``; + the directory at that point is the artist folder. Returns None if + ``dest_path`` isn't inside ``transfer_dir`` at all.""" + if not transfer_dir: + return None + transfer_norm = os.path.normpath(transfer_dir) + cur = os.path.normpath(dest_path) + while True: + parent = os.path.dirname(cur) + if parent == cur: + return None # filesystem root + if os.path.normpath(parent) == transfer_norm: + return cur + cur = parent + + +def _prune_empty_album_dirs(artist_dir: str) -> None: + """Remove direct subdirectories of ``artist_dir`` that are empty. + Single-level prune: deliberately doesn't recurse — we want to + catch leftover album-sibling folders without aggressively touching + the user's nested directory tree. + + Also walks one level deeper into each album dir to remove empty + Disc-N subfolders that previous runs may have created.""" + if not os.path.isdir(artist_dir): + return + try: + children = list(os.listdir(artist_dir)) + except OSError: + return + for entry in children: + album_path = os.path.join(artist_dir, entry) + if not os.path.isdir(album_path): + continue + # First pass: prune empty Disc-N subfolders inside this album. + try: + for sub in list(os.listdir(album_path)): + disc_path = os.path.join(album_path, sub) + if os.path.isdir(disc_path): + try: + if not os.listdir(disc_path): + os.rmdir(disc_path) + except OSError: + pass + except OSError: + pass + # Then: if the whole album dir is now empty, prune it. + try: + if not os.listdir(album_path): + os.rmdir(album_path) + logger.info(f"[Reorganize] Pruned empty album dir: {album_path}") + except OSError: + pass + + +# Sidecar / cleanup helpers -------------------------------------------------- + +# Sidecars that live alongside ONE audio file (same filename stem). +_TRACK_SIDECAR_EXTS = ('.lrc', '.nfo', '.txt', '.cue', '.json') + +# Sidecars that live at the ALBUM level (one per directory). +_ALBUM_SIDECARS = ( + 'cover.jpg', 'cover.jpeg', 'cover.png', + 'folder.jpg', 'folder.png', + 'front.jpg', 'front.png', + 'album.jpg', 'album.png', + 'artwork.jpg', 'artwork.png', +) + +# Audio extensions used to decide whether a source directory still has +# tracks the user might care about (i.e. a per-track failure left audio +# behind that we shouldn't strip the cover art from). +_AUDIO_EXTS = frozenset( + {'.flac', '.mp3', '.m4a', '.ogg', '.opus', '.wav', '.aac', '.wma', '.mp4'} +) + + +def _delete_track_sidecars(audio_path: str) -> None: + """Delete per-track sidecars (.lrc / .nfo / .txt / .cue / .json) that + sit alongside `audio_path` and share its filename stem. Best-effort — + individual failures are logged at debug and never raised.""" + src_dir = os.path.dirname(audio_path) + stem = os.path.splitext(os.path.basename(audio_path))[0] + for ext in _TRACK_SIDECAR_EXTS: + sidecar = os.path.join(src_dir, stem + ext) + if os.path.isfile(sidecar): + try: + os.remove(sidecar) + except OSError as e: + logger.debug(f"[Reorganize] Couldn't remove sidecar {sidecar}: {e}") + + +def _delete_album_sidecars(src_dir: str) -> None: + """Delete album-level sidecars (cover.jpg, folder.jpg, etc.) from + `src_dir`. Used during end-of-run cleanup when no audio files remain + in the directory. Best-effort — individual failures are debug-logged.""" + for name in _ALBUM_SIDECARS: + sidecar = os.path.join(src_dir, name) + if os.path.isfile(sidecar): + try: + os.remove(sidecar) + except OSError as e: + logger.debug(f"[Reorganize] Couldn't remove album sidecar {sidecar}: {e}") + + +def _has_remaining_audio(directory: str) -> bool: + """Return True if `directory` contains any audio files. Used as the + safety check before stripping album-level sidecars: if a track + failed to move, leave its cover art and friends in place.""" + if not os.path.isdir(directory): + return False + try: + for name in os.listdir(directory): + full = os.path.join(directory, name) + if not os.path.isfile(full): + continue + if os.path.splitext(name)[1].lower() in _AUDIO_EXTS: + return True + except OSError: + return True # Safer to assume "yes, leave it" if we can't check + return False diff --git a/core/lidarr_download_client.py b/core/lidarr_download_client.py index 72dd3fbc..f6a640f3 100644 --- a/core/lidarr_download_client.py +++ b/core/lidarr_download_client.py @@ -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 diff --git a/core/metadata_service.py b/core/metadata_service.py index 04013a49..e2636d0f 100644 --- a/core/metadata_service.py +++ b/core/metadata_service.py @@ -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. diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py index b7b2725b..b152f28f 100644 --- a/core/musicbrainz_client.py +++ b/core/musicbrainz_client.py @@ -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=`) 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:` 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]]: """ diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index f9d93aab..965317be 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -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 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 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= (browse) rejects inc=releases, + # so we use the fielded Lucene search arid: 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: diff --git a/core/navidrome_client.py b/core/navidrome_client.py index bcba6a17..3730e36c 100644 --- a/core/navidrome_client.py +++ b/core/navidrome_client.py @@ -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: diff --git a/core/plex_client.py b/core/plex_client.py index dc97968b..43bbf8a8 100644 --- a/core/plex_client.py +++ b/core/plex_client.py @@ -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() diff --git a/core/qobuz_client.py b/core/qobuz_client.py index ed5dfaa1..e7dcad9d 100644 --- a/core/qobuz_client.py +++ b/core/qobuz_client.py @@ -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'], diff --git a/core/reorganize_queue.py b/core/reorganize_queue.py new file mode 100644 index 00000000..6b50afb0 --- /dev/null +++ b/core/reorganize_queue.py @@ -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 diff --git a/core/reorganize_runner.py b/core/reorganize_runner.py new file mode 100644 index 00000000..b5fba687 --- /dev/null +++ b/core/reorganize_runner.py @@ -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 diff --git a/core/repair_jobs/album_completeness.py b/core/repair_jobs/album_completeness.py index 999597dd..c92363f3 100644 --- a/core/repair_jobs/album_completeness.py +++ b/core/repair_jobs/album_completeness.py @@ -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): diff --git a/core/repair_jobs/discography_backfill.py b/core/repair_jobs/discography_backfill.py index 6b307293..3170624a 100644 --- a/core/repair_jobs/discography_backfill.py +++ b/core/repair_jobs/discography_backfill.py @@ -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)} diff --git a/core/repair_jobs/duplicate_detector.py b/core/repair_jobs/duplicate_detector.py index 7f61e811..2f1e3045 100644 --- a/core/repair_jobs/duplicate_detector.py +++ b/core/repair_jobs/duplicate_detector.py @@ -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 diff --git a/core/repair_jobs/library_reorganize.py b/core/repair_jobs/library_reorganize.py index 4afa9552..3dc11245 100644 --- a/core/repair_jobs/library_reorganize.py +++ b/core/repair_jobs/library_reorganize.py @@ -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', diff --git a/core/repair_jobs/live_commentary_cleaner.py b/core/repair_jobs/live_commentary_cleaner.py index 5049687b..b3532567 100644 --- a/core/repair_jobs/live_commentary_cleaner.py +++ b/core/repair_jobs/live_commentary_cleaner.py @@ -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'), diff --git a/core/repair_jobs/track_number_repair.py b/core/repair_jobs/track_number_repair.py index 65080101..6246ac2c 100644 --- a/core/repair_jobs/track_number_repair.py +++ b/core/repair_jobs/track_number_repair.py @@ -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: diff --git a/core/repair_worker.py b/core/repair_worker.py index 0810677f..89bacc7c 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -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) diff --git a/core/replaygain.py b/core/replaygain.py index 8f8630d6..4870f741 100644 --- a/core/replaygain.py +++ b/core/replaygain.py @@ -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 diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index 6431218c..0e7dfadc 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -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]) diff --git a/core/soulseek_client.py b/core/soulseek_client.py index 079d3600..9b64ada6 100644 --- a/core/soulseek_client.py +++ b/core/soulseek_client.py @@ -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]]: diff --git a/core/soulsync_client.py b/core/soulsync_client.py index 2a2fabd9..39653768 100644 --- a/core/soulsync_client.py +++ b/core/soulsync_client.py @@ -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: diff --git a/core/spotify_client.py b/core/spotify_client.py index ddcb1d09..322e9021 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -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 diff --git a/core/spotify_worker.py b/core/spotify_worker.py index 89a7935b..dde78781 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -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}") diff --git a/core/tidal_client.py b/core/tidal_client.py index 2dbeafbe..ce6b0ef7 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -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 diff --git a/core/tidal_download_client.py b/core/tidal_download_client.py index 3bc831dd..0823e56d 100644 --- a/core/tidal_download_client.py +++ b/core/tidal_download_client.py @@ -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 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'], diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index bba90c66..a0cdd66b 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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]) diff --git a/core/wishlist_service.py b/core/wishlist_service.py index 0885bf09..3c185ea7 100644 --- a/core/wishlist_service.py +++ b/core/wishlist_service.py @@ -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 diff --git a/core/worker_utils.py b/core/worker_utils.py index 0c61fb0a..d6f42523 100644 --- a/core/worker_utils.py +++ b/core/worker_utils.py @@ -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 + ) diff --git a/core/youtube_client.py b/core/youtube_client.py index 5d158230..55e042ac 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -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}") diff --git a/database/music_database.py b/database/music_database.py index f806ef27..d4f68f0a 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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: diff --git a/services/sync_service.py b/services/sync_service.py index 8b552296..4582a5cf 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -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 {} diff --git a/tests/test_album_completeness_job.py b/tests/test_album_completeness_job.py index f3f8145c..5451839f 100644 --- a/tests/test_album_completeness_job.py +++ b/tests/test_album_completeness_job.py @@ -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 diff --git a/tests/test_artist_source_detail.py b/tests/test_artist_source_detail.py new file mode 100644 index 00000000..400bf7fe --- /dev/null +++ b/tests/test_artist_source_detail.py @@ -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"] diff --git a/tests/test_artist_source_lookup.py b/tests/test_artist_source_lookup.py new file mode 100644 index 00000000..75ca4582 --- /dev/null +++ b/tests/test_artist_source_lookup.py @@ -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 diff --git a/tests/test_content_filter_regex.py b/tests/test_content_filter_regex.py new file mode 100644 index 00000000..29f528d8 --- /dev/null +++ b/tests/test_content_filter_regex.py @@ -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" diff --git a/tests/test_discogs_track_count.py b/tests/test_discogs_track_count.py new file mode 100644 index 00000000..0ad04986 --- /dev/null +++ b/tests/test_discogs_track_count.py @@ -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 diff --git a/tests/test_library_reorganize_orchestrator.py b/tests/test_library_reorganize_orchestrator.py new file mode 100644 index 00000000..62537b3c --- /dev/null +++ b/tests/test_library_reorganize_orchestrator.py @@ -0,0 +1,1936 @@ +"""Tests for `core.library_reorganize.reorganize_album` — the new +post-processing-pipeline approach (the orchestrator that copies files +to staging and routes them through the same code that handles fresh +downloads, instead of doing per-album template work in web_server). + +Contract this test file pins: + +1. Albums without ANY metadata-source ID return ``status='no_source_id'`` + without staging anything, copying anything, or calling post-process. + Silent degradation to file tags is the failure mode the previous + implementation had; the new contract is "we have the source of + truth or we don't touch the album." +2. Source resolution honors the configured primary first, then walks + ``get_source_priority`` until something returns a tracklist. +3. Each library track is matched to the API tracklist by + ``track_number``. Tracks not in the API response (bonus tracks on a + deluxe edition, etc.) are reported as skipped and left in place — + they are NOT force-fed wrong context to post-process. +4. Files that don't resolve on disk are surfaced as skipped errors + with the offending DB path, not silently dropped. +5. After a successful post-process the original file is removed and + the DB row is updated to the new path. A failed post-process leaves + the original alone so the user doesn't lose data. +6. Staging directory is cleaned up regardless of how the run ends. +""" + +import os +import shutil +import sqlite3 +import sys +import types + +import pytest + + +# --- module stubs (same shape used elsewhere in the test suite) ----------- +if "spotipy" not in sys.modules: + spotipy = types.ModuleType("spotipy") + + class _DummySpotify: + def __init__(self, *args, **kwargs): + pass + + oauth2 = types.ModuleType("spotipy.oauth2") + + class _DummyOAuth: + def __init__(self, *args, **kwargs): + pass + + spotipy.Spotify = _DummySpotify + oauth2.SpotifyOAuth = _DummyOAuth + oauth2.SpotifyClientCredentials = _DummyOAuth + 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 core import library_reorganize # noqa: E402 + + +# --- helpers -------------------------------------------------------------- + +class _FakeDB: + """Wraps a sqlite3 in-memory connection that survives `close()` calls + so the tests can reuse it for assertions after the orchestrator runs.""" + + def __init__(self): + self._conn = sqlite3.connect(":memory:") + self._conn.row_factory = sqlite3.Row + + def _get_connection(self): + return _NonClosingConnWrapper(self._conn) + + +class _NonClosingConnWrapper: + def __init__(self, real): + self._real = real + + def cursor(self): + return self._real.cursor() + + def execute(self, *args, **kwargs): + return self._real.execute(*args, **kwargs) + + def commit(self): + return self._real.commit() + + def close(self): + # Underlying connection survives — tests reuse it. + pass + + def __enter__(self): + return self + + def __exit__(self, *args): + pass + + +def _setup_album(db, *, album_id='alb-1', spotify_id='', deezer_id='', + itunes_id='', discogs_id='', soul_id='', tracks=()): + """Build a minimal artists/albums/tracks schema and seed one album. + + `tracks` is a list of `(track_id, track_number, title, file_path)`. + """ + 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, + spotify_album_id TEXT, + deezer_id TEXT, + itunes_album_id TEXT, + discogs_id TEXT, + soul_id TEXT + ) + """) + cur.execute(""" + CREATE TABLE tracks ( + id TEXT PRIMARY KEY, + album_id TEXT, + artist_id TEXT, + title TEXT, + track_number INTEGER, + file_path TEXT, + updated_at TEXT + ) + """) + cur.execute("INSERT INTO artists VALUES (?, ?)", ('artist-1', 'Aerosmith')) + cur.execute( + "INSERT INTO albums (id, artist_id, title, spotify_album_id, deezer_id, " + "itunes_album_id, discogs_id, soul_id) VALUES (?,?,?,?,?,?,?,?)", + (album_id, 'artist-1', 'Aerosmith (1973)', spotify_id, deezer_id, + itunes_id, discogs_id, soul_id), + ) + for tid, tn, title, fp in tracks: + cur.execute( + "INSERT INTO tracks (id, album_id, artist_id, title, track_number, file_path) " + "VALUES (?,?,?,?,?,?)", + (tid, album_id, 'artist-1', title, tn, fp), + ) + db._conn.commit() + + +@pytest.fixture +def tmpdirs(tmp_path): + """Three working directories: original library files, staging root, + transfer destination.""" + library = tmp_path / "library" + staging = tmp_path / "staging" + transfer = tmp_path / "transfer" + library.mkdir() + staging.mkdir() + transfer.mkdir() + return library, staging, transfer + + +def _make_audio_file(library_dir, name='song.flac', content=b'fakeflacdata'): + p = library_dir / name + p.write_bytes(content) + return str(p) + + +# --- tests: source resolution --------------------------------------------- + +def test_returns_no_source_id_when_album_has_none(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, tracks=[ + ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library)), + ]) + + pp_calls = [] + + def pp(key, ctx, fp): + pp_calls.append(key) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes', 'discogs', 'hydrabase']) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', lambda *a: None) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', lambda *a: None) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert summary['status'] == 'no_source_id' + assert summary['moved'] == 0 + assert pp_calls == [] + + +def test_falls_through_to_next_source_when_primary_returns_nothing(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, spotify_id='sp-1', deezer_id='dz-1', tracks=[ + ('t1', 1, 'Same Old Song And Dance', _make_audio_file(library)), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer']) + + def fake_album(src, sid): + return {'id': sid, 'name': 'Aerosmith', 'release_date': '1973-01-01'} \ + if src == 'deezer' else None + + def fake_tracks(src, sid): + return {'items': [{'id': 'dz-t1', 'name': 'Same Old Song And Dance', + 'track_number': 1, 'disc_number': 1}]} \ + if src == 'deezer' else None + + monkeypatch.setattr(library_reorganize, 'get_album_for_source', fake_album) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', fake_tracks) + + def pp(key, ctx, fp): + ctx['_final_processed_path'] = str(library / 'final.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert summary['source'] == 'deezer' + assert summary['moved'] == 1 + + +# --- tests: per-track behavior -------------------------------------------- + +def test_multi_disc_album_disambiguates_by_title(monkeypatch, tmpdirs): + """The whole point of moving from track_number-only to title-based + matching: a 2-disc album has track_number=1 on BOTH discs, but the + titles differ. Each library track must end up routed to the API + entry with the matching title — and therefore to the correct + disc_number in the post-process context.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + # Disc 1 track 1: 'Same Old Song And Dance' + ('t1d1', 1, 'Same Old Song And Dance', _make_audio_file(library, 'd1t1.flac')), + # Disc 2 track 1: 'Dream On' + ('t1d2', 1, 'Dream On', _make_audio_file(library, 'd2t1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'Aerosmith'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'd1t1', 'name': 'Same Old Song And Dance', 'track_number': 1, 'disc_number': 1}, + {'id': 'd2t1', 'name': 'Dream On', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + title_to_disc = {} + + def pp(key, ctx, fp): + # Capture which disc_number landed in the per-track context + title_to_disc[ctx['track_info']['name']] = ctx['track_info']['disc_number'] + # Also record total_discs so we can assert it's correct + title_to_disc.setdefault('_total_discs', ctx['spotify_album']['total_discs']) + ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['disc_number']}_{ctx['track_info']['track_number']}.flac") + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert summary['moved'] == 2 + # The crucial assertion: each track must get the disc_number of + # its title-matched API entry, NOT a collapsed last-write-wins value. + assert title_to_disc['Same Old Song And Dance'] == 1 + assert title_to_disc['Dream On'] == 2 + # And the album-level total_discs must be 2 so post-process inserts the subfolder + assert title_to_disc['_total_discs'] == 2 + + +def test_title_match_tolerates_smart_quotes_and_punctuation(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, "Don't Stop Believin'", _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + # API uses smart quotes — historically a common mismatch source + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Don’t Stop Believin’', 'track_number': 1, 'disc_number': 1}, + ]}, + ) + + pp_calls = [] + + def pp(key, ctx, fp): + pp_calls.append(ctx['track_info']['name']) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert summary['moved'] == 1 + assert len(pp_calls) == 1 + + +def test_bonus_track_routes_to_correct_disc_via_substring_match(monkeypatch, tmpdirs): + """Real-world scenario from winecountrygames's Kendrick Lamar deluxe: + user has ``The Recipe - Bonus Track`` (track 1, disc 2 in his library) + AND ``Sherane`` (track 1, disc 1). The API returns the bonus track as + plain ``The Recipe`` (no suffix). Without substring matching, the + bonus track falls through to track-number-only and lands on disc 1. + With substring matching (gated on track_number), it correctly routes + to disc 2.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + # Disc 1, track 1 + ('t1d1', 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), + # Disc 2, track 1 — local title has " - Bonus Track" suffix + ('t1d2', 1, 'The Recipe - Bonus Track', _make_audio_file(library, 'd2t1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'good kid m.A.A.d city (Deluxe)'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'The Recipe', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + title_to_disc = {} + + def pp(key, ctx, fp): + title_to_disc[ctx['track_info']['name']] = ctx['track_info']['disc_number'] + ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['disc_number']}.flac") + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(_staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + # The local "The Recipe - Bonus Track" must route to the API's + # disc-2 entry (which is named just "The Recipe"), via substring + # match + track_number tiebreaker. + assert title_to_disc['Sherane'] == 1 + assert title_to_disc['The Recipe'] == 2 + + +def test_dash_vs_parens_normalize_equally_for_remix_versions(monkeypatch, tmpdirs): + """Local file has ``Bitch, Don't Kill My Vibe - Remix`` (dash style), + API has the same track as ``Bitch, Don't Kill My Vibe (Remix)`` + (parens style). Both must normalize to the same string so tier 1 + matches without falling to substring or track_number fallbacks.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 5, "Bitch, Don't Kill My Vibe - Remix", _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': "Bitch, Don't Kill My Vibe (Remix)", + 'track_number': 5, 'disc_number': 2}, + ]}, + ) + + matched = [] + + def pp(key, ctx, fp): + matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert matched == [("Bitch, Don't Kill My Vibe (Remix)", 2)] + + +def test_substring_match_handles_track_number_disagreement(monkeypatch, tmpdirs): + """Real-world Kendrick Lamar deluxe case: the user's library has + ``The Recipe (Black Hippy Remix) - Bonus Track`` numbered as track + 4 of disc 2, but Deezer has the same track at disc 2 track 5 (and + has ``Bitch... (Remix)`` at disc 2 track 4). Track_number-gated + containment misses; length-ratio containment must pick the right + one without false-positive risk.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t4', 4, 'The Recipe (Black Hippy Remix) - Bonus Track', + _make_audio_file(library, 't4.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + # API has the bonus tracks in a different order than the user + {'id': 'd1t4', 'name': 'The Art of Peer Pressure', + 'track_number': 4, 'disc_number': 1}, + {'id': 'd2t4', 'name': "Bitch, Don't Kill My Vibe (Remix)", + 'track_number': 4, 'disc_number': 2}, + {'id': 'd2t5', 'name': 'The Recipe (Black Hippy Remix)', + 'track_number': 5, 'disc_number': 2}, + ]}, + ) + + matched = [] + + def pp(key, ctx, fp): + matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + # The local Black Hippy Remix Bonus Track must end up in disc 2, + # NOT collide with disc 1's "Art of Peer Pressure" via track_number. + assert matched == [('The Recipe (Black Hippy Remix)', 2)] + + +def test_remix_does_not_substring_match_to_original_recording(monkeypatch, tmpdirs): + """winecountrygames's iTunes case: iTunes doesn't have the remix, + just the original ``Bitch Don't Kill My Vibe``. Substring + ratio + alone would merge the local remix bonus track into the original + via tier 4 (ratio 0.78). Reject because they have different version + differentiators ('remix' vs none) — they're different recordings.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, itunes_id='it-1', tracks=[ + # Original — should match cleanly via tier 1 to iTunes' entry + ('t2', 2, "Bitch, Don't Kill My Vibe", _make_audio_file(library, 't2.flac')), + # Remix — iTunes doesn't have it; must report unmatched, NOT + # collide with the original via substring + ('t5', 5, "Bitch, Don't Kill My Vibe - Remix", _make_audio_file(library, 't5.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'itunes') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'it-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'it2', 'name': "Bitch, Don't Kill My Vibe", + 'track_number': 2, 'disc_number': 1}, + ]}, + ) + + matched_titles = [] + skipped_titles = [] + + def pp(key, ctx, fp): + matched_titles.append(ctx['track_info']['name']) + ctx['_final_processed_path'] = str(library / f'out_{ctx["track_info"]["name"]}.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + skipped_titles = [e['title'] for e in summary['errors']] + # Only the original should have been processed + assert matched_titles == ["Bitch, Don't Kill My Vibe"] + # The remix should be reported as unmatched, NOT merged with the original + assert "Bitch, Don't Kill My Vibe - Remix" in skipped_titles + assert summary['moved'] == 1 + assert summary['skipped'] == 1 + + +def test_substring_match_does_not_false_positive_across_discs(monkeypatch, tmpdirs): + """Safety: ``Real`` (substring) must not silently map to a longer + track like ``Real Real Real`` on a different disc. Substring match + is gated on matching track_number; if the only API entry whose + title contains the local one has a different track_number, the + matcher must fall through to last-resort track_number-only.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t11', 11, 'Real', _make_audio_file(library, 't11.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + # Real-the-track on disc 1, position 11 — the right answer + {'id': 'a1', 'name': 'Real', 'track_number': 11, 'disc_number': 1}, + # A nearby longer title on disc 2 that contains "real" — must NOT win + {'id': 'a2', 'name': 'Real Real Real', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + matched = [] + + def pp(key, ctx, fp): + matched.append((ctx['track_info']['name'], ctx['track_info']['disc_number'])) + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + # Tier 1 (exact + track_number) wins for the legitimate disc 1 entry + assert matched == [('Real', 1)] + + +def test_skips_track_when_source_tracklist_doesnt_contain_it(monkeypatch, tmpdirs): + """winecountrygames's actual scenario: Deezer's response for the + Kendrick deluxe was missing 'The Recipe (Black Hippy Remix)' — the + user has 17 local tracks, Deezer knows 16. The 17th local track + has no title-based match anywhere in the API tracklist. Per the + design policy 'trust the source', we must NOT fall back to + track_number-only matching (which would falsely route the missing + bonus track to whatever disc-1 entry shares its track_number, + causing a collision with a totally unrelated song).""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + # Local tn=4 — but API doesn't have this track at all; the only + # API entry with track_number=4 is "The Art of Peer Pressure" + # (a completely different song). Old tier-5 fallback would have + # silently routed our bonus track to that entry → collision. + ('t4', 4, 'The Recipe (Black Hippy Remix) - Bonus Track', + _make_audio_file(library, 't4.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + # Same tn=4, completely different title — must NOT capture + # the local track via track_number fallback. + {'id': 'd1t4', 'name': 'The Art of Peer Pressure', + 'track_number': 4, 'disc_number': 1}, + ]}, + ) + + pp_calls = [] + + def pp(*a, **k): + pp_calls.append(a) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + # Track must be skipped, NOT routed to The Art of Peer Pressure. + assert summary['moved'] == 0 + assert summary['skipped'] == 1 + assert pp_calls == [] + assert 'not in' in summary['errors'][0]['error'].lower() \ + or 'bonus' in summary['errors'][0]['error'].lower() \ + or 'non-canonical' in summary['errors'][0]['error'].lower() + + +def test_skips_track_not_in_api_tracklist(monkeypatch, tmpdirs): + """Bonus track scenario: user has 12 tracks, source's catalog version + only has 10. Tracks not in the API response must be skipped, NOT + force-fed wrong context to post-process.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), + ('t11', 11, 'Bonus Track', _make_audio_file(library, 't11.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2, 'disc_number': 1}, + ]}, + ) + + pp_for = [] + + def pp(key, ctx, fp): + pp_for.append(ctx['track_info']['track_number']) + ctx['_final_processed_path'] = str(library / f"out_{ctx['track_info']['track_number']}.flac") + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert sorted(pp_for) == [1, 2] + assert summary['moved'] == 2 + assert summary['skipped'] == 1 + assert any('Bonus Track' in e['title'] for e in summary['errors']) + + +def test_surfaces_unresolved_file_path(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', '/nonexistent/file.flac'), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + pp_calls = [] + + def pp(*a, **k): + pp_calls.append(a) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: None, # nothing resolves + post_process_fn=pp, + ) + + assert summary['skipped'] == 1 + assert summary['moved'] == 0 + assert pp_calls == [] + assert '/nonexistent/file.flac' in summary['errors'][0]['error'] + + +def test_failed_post_process_leaves_original_in_place(monkeypatch, tmpdirs): + """If post-process fails (AcoustID rejection, exception, anything), + the original file must remain at its location and the DB must NOT + be updated. Worst-case the user retries; we don't lose data.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + src_file = _make_audio_file(library, 't1.flac') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src_file), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + # Simulate AcoustID rejection: don't set _final_processed_path + return + + db_updates = [] + + def update_path(track_id, new_path): + db_updates.append((track_id, new_path)) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + update_track_path_fn=update_path, + ) + + assert summary['failed'] == 1 + assert summary['moved'] == 0 + assert os.path.exists(src_file) + assert db_updates == [] + + +def test_post_process_exception_is_caught_and_original_preserved(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + src_file = _make_audio_file(library, 't1.flac') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src_file), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(*a, **k): + raise RuntimeError("boom") + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert summary['failed'] == 1 + assert os.path.exists(src_file) + + +def test_recreates_staging_dir_when_post_process_cleans_it(monkeypatch, tmpdirs): + """Regression test for the "1 moved, 15 failed (path not found)" bug + winecountrygames hit on his first reorganize run. + + Post-processing calls `_cleanup_empty_directories` after each move. + That walks up from the source file removing empties — and since the + only thing in our staging_album_dir is the staged file we just had + post-process consume, the dir is empty after the move and gets + nuked. The next track's `shutil.copy2` then failed with WinError 3 + because the destination directory no longer existed. + + The orchestrator must recreate staging_album_dir before each copy.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), + ('t3', 3, 'Track 3', _make_audio_file(library, 't3.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + {'id': 'a3', 'name': 'Track 3', 'track_number': 3}, + ]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + pp_count = [0] + + def pp_with_aggressive_cleanup(key, ctx, fp): + """Mimic real post-process: move the file, then walk up from + the source directory removing empties (which includes our + staging_album_dir).""" + pp_count[0] += 1 + final = str(final_dir / f"final_{pp_count[0]}.flac") + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + # Walk up from the staged file's old directory, deleting + # any empty dir until we hit the staging root. + dir_to_check = os.path.dirname(fp) + while os.path.normpath(dir_to_check) != os.path.normpath(str(staging)): + try: + os.rmdir(dir_to_check) + except OSError: + break + dir_to_check = os.path.dirname(dir_to_check) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, + post_process_fn=pp_with_aggressive_cleanup, + ) + + # All three tracks must succeed despite the staging dir being + # nuked between each one. + assert summary['moved'] == 3 + assert summary['failed'] == 0 + + +def test_db_update_failure_leaves_original_in_place(monkeypatch, tmpdirs): + """Safety property: a failing DB write must NOT trigger the original + file's deletion. Otherwise we'd have a library row pointing at a + now-deleted path with no easy recovery. Better: leave the file at + BOTH locations (original + new) so the next library scan re-indexes + from the new path and the user doesn't lose data.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + src = _make_audio_file(library, 't1.flac') + final_dir = library / 'final' + final_dir.mkdir() + + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + final_path = str(final_dir / 't1.flac') + + def pp(key, ctx, fp): + shutil.move(fp, final_path) + ctx['_final_processed_path'] = final_path + + def update_path_explodes(track_id, new_path): + raise RuntimeError("simulated DB failure") + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + update_track_path_fn=update_path_explodes, + ) + + assert os.path.exists(src), "Original must still exist when DB update failed" + assert os.path.exists(final_path), "New path file should also exist (post-process succeeded)" + # kettui PR #377 review: a DB-update failure must NOT increment + # `moved` — that would overstate how many tracks the UI knows are + # at their new locations. Track is reported as failed instead. + assert summary['moved'] == 0 + assert summary['failed'] == 1 + assert any('DB update failed' in e['error'] for e in summary['errors']) + + +def test_successful_run_removes_original_and_updates_db(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + src = _make_audio_file(library, 't1.flac') + final_dir = library / 'final' + final_dir.mkdir() + + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + # Pretend post-processing moved the staged file to a final location + final = str(final_dir / 't1.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + db_updates = [] + + def update_path(track_id, new_path): + db_updates.append((track_id, new_path)) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + update_track_path_fn=update_path, + ) + + assert summary['moved'] == 1 + assert summary['failed'] == 0 + assert not os.path.exists(src) + assert os.path.exists(str(final_dir / 't1.flac')) + assert db_updates == [('t1', str(final_dir / 't1.flac'))] + + +# --- tests: cleanup ------------------------------------------------------- + +def test_staging_dir_cleaned_up_on_success(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + final = str(library / 'final.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert os.listdir(str(staging)) == [] + + +def test_staging_dir_cleaned_up_even_on_failure(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + def pp(key, ctx, fp): + raise RuntimeError("simulated post-process explosion") + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert os.listdir(str(staging)) == [] + + +# --- tests: misc ---------------------------------------------------------- + +def test_deletes_per_track_sidecars_after_successful_move(monkeypatch, tmpdirs): + """Real-world Kendrick-Lamar-deluxe shape: each FLAC has a same-stem + `.lrc` sidecar in the source folder. After the audio is moved to its + new location, the original `.lrc` should be removed too — post-process + handles whatever sidecar policy exists at the new destination.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + audio = _make_audio_file(library, '01 - Sherane.flac') + lrc_path = library / '01 - Sherane.lrc' + lrc_path.write_text('lyrics') + nfo_path = library / '01 - Sherane.nfo' + nfo_path.write_text('metadata') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Sherane', audio), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Sherane', 'track_number': 1}]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + + def pp(key, ctx, fp): + final = str(final_dir / 'out.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert not os.path.exists(audio) + assert not lrc_path.exists() + assert not nfo_path.exists() + + +def test_keeps_track_sidecars_when_track_fails_to_move(monkeypatch, tmpdirs): + """If post-process fails (AcoustID rejection), the original audio is + preserved — and so is its sidecar, because the user might want to + investigate or recover the track.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + audio = _make_audio_file(library, '01 - Sherane.flac') + lrc_path = library / '01 - Sherane.lrc' + lrc_path.write_text('lyrics') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Sherane', audio), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Sherane', 'track_number': 1}]}, + ) + + def pp_rejects(key, ctx, fp): + return # don't set _final_processed_path = AcoustID-style rejection + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp_rejects, + ) + + assert os.path.exists(audio) + assert lrc_path.exists() + + +def test_deletes_album_level_sidecars_when_directory_emptied(monkeypatch, tmpdirs): + """After every track in a source dir is successfully moved out, the + leftover album-level sidecars (cover.jpg, folder.jpg, etc.) should be + removed too so the empty-dir pruner can take the dir. If even one + track failed to move, leave them — the user might want the cover.""" + library, staging, _transfer = tmpdirs + disc1_dir = library / 'Disc 1' + disc1_dir.mkdir() + a1 = _make_audio_file(disc1_dir, '01.flac') + a2 = _make_audio_file(disc1_dir, '02.flac') + cover = disc1_dir / 'cover.jpg' + cover.write_bytes(b'JPEGdata') + folder = disc1_dir / 'folder.jpg' + folder.write_bytes(b'JPEGdata') + + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', a1), + ('t2', 2, 'Track 2', a2), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + ]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + + def pp(key, ctx, fp): + final = str(final_dir / f"{ctx['track_info']['track_number']}.flac") + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + assert not cover.exists() + assert not folder.exists() + + +def test_keeps_album_sidecars_when_a_track_failed_to_move(monkeypatch, tmpdirs): + """If even one track in the dir failed to move out, leave the album + art alone — user might still want to look at / recover the album.""" + library, staging, _transfer = tmpdirs + a1 = _make_audio_file(library, '01.flac') + a2 = _make_audio_file(library, '02.flac') + cover = library / 'cover.jpg' + cover.write_bytes(b'JPEGdata') + + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', a1), + ('t2', 2, 'Track 2', a2), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + ]}, + ) + + final_dir = library / 'final' + final_dir.mkdir() + + def pp(key, ctx, fp): + # Track 1 succeeds, track 2 fails (no _final_processed_path set) + if ctx['track_info']['track_number'] == 1: + final = str(final_dir / '1.flac') + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + # Track 2 still in place → cover preserved + assert os.path.exists(a2) + assert cover.exists() + + +# --- preview function (shared planning with the orchestrator) ----------- + +def _fake_path_builder(context, spotify_artist, _album_info, file_ext): + """Stand-in for `_build_final_path_for_track`. Inserts Disc N/ when + total_discs > 1 — same convention the real builder uses.""" + album = context['spotify_album']['name'] + artist = spotify_artist['name'] + track_info = context['track_info'] + title = track_info['name'] + tn = track_info['track_number'] + dn = track_info['disc_number'] + total = context['spotify_album']['total_discs'] + parts = ['/transfer', artist, album] + if total > 1: + parts.append(f'Disc {dn}') + parts.append(f"{tn:02d} - {title}{file_ext}") + return '/'.join(parts), True + + +def _path_builder_album_vs_single(context, spotify_artist, album_info, file_ext): + """Stand-in that emulates the real `_build_final_path_for_track` + branch on `album_info.get('is_album')`. ALBUM mode produces an + album folder with disc subfolder + numbered file; SINGLE mode + produces a per-track folder named after the title (the bug + output).""" + artist = spotify_artist['name'] + if album_info and album_info.get('is_album'): + album = album_info['album_name'] + title = album_info['clean_track_name'] + tn = album_info['track_number'] + dn = album_info['disc_number'] + total = context['spotify_album']['total_discs'] + if total > 1: + return (f'/transfer/{artist}/{artist} - {album}/Disc {dn}/{tn:02d} - {title}{file_ext}', True) + return (f'/transfer/{artist}/{artist} - {album}/{tn:02d} - {title}{file_ext}', True) + title = context['track_info']['name'] + return (f'/transfer/{artist}/{artist} - {title}/{title}{file_ext}', True) + + +def test_preview_uses_album_mode_not_single_mode(monkeypatch, tmpdirs): + """Regression for the bug where every track ended up in its own + track-named folder (SINGLE MODE) because we passed None for + album_info to the path builder. Multi-disc deluxe must produce + one shared album folder, not N single folders.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Sherane', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Bitch Dont Kill My Vibe', _make_audio_file(library, 't2.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'good kid, m.A.A.d city'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'Bitch Dont Kill My Vibe', 'track_number': 2, 'disc_number': 1}, + ]}, + ) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_path_builder_album_vs_single, + ) + + paths = [it['new_path'] for it in result['tracks']] + # Both tracks land under the SAME album folder, not per-track folders + assert all('good kid, m.A.A.d city' in p for p in paths) + # Files use track-number prefix (album mode), not bare title (single mode) + assert any('01 - Sherane' in p for p in paths) + assert any('02 - Bitch Dont Kill My Vibe' in p for p in paths) + # Reject the single-mode shape explicitly + assert not any(p.endswith('/Sherane.flac') for p in paths) + + +def test_preview_emits_disc_subfolders_for_multi_disc_albums(monkeypatch, tmpdirs): + """The bug winecountrygames hit: preview showed all tracks at the + album root with no Disc N/ subfolders, even on a deluxe edition. + Verify the new planner-backed preview produces disc folders.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1d1', 1, 'Sherane', _make_audio_file(library, 'd1t1.flac')), + ('t1d2', 1, 'The Recipe', _make_audio_file(library, 'd2t1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'good kid, m.A.A.d city (Deluxe)'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Sherane', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'The Recipe', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + assert result['success'] is True + assert result['status'] == 'planned' + + by_title = {it['title']: it for it in result['tracks']} + assert 'Disc 1' in by_title['Sherane']['new_path'] + assert 'Disc 2' in by_title['The Recipe']['new_path'] + # And per-track disc_number is propagated for UI display + assert by_title['Sherane']['disc_number'] == 1 + assert by_title['The Recipe']['disc_number'] == 2 + + +def test_preview_status_no_source_id_when_album_lacks_ids(monkeypatch, tmpdirs): + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'spotify', 'itunes', 'discogs', 'hydrabase']) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', lambda *a: None) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', lambda *a: None) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + assert result['status'] == 'no_source_id' + assert result['success'] is False + + +def test_preview_marks_unmatched_tracks(monkeypatch, tmpdirs): + """Tracks with no plausible API match (no exact title, no substring, + no track_number) get reported as unmatched with a reason.""" + library, _staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'A Real Track', _make_audio_file(library, 't1.flac')), + # Use a track_number with no API counterpart and a title that + # has no substring overlap with anything in the API list — so + # no tier matches. + ('t99', 99, 'Completely Unrelated Side Quest', + _make_audio_file(library, 't99.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'A Real Track', 'track_number': 1}]}, + ) + + result = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + by_title = {it['title']: it for it in result['tracks']} + assert by_title['A Real Track']['matched'] is True + assert by_title['A Real Track']['new_path'] + assert by_title['Completely Unrelated Side Quest']['matched'] is False + assert by_title['Completely Unrelated Side Quest']['reason'] + assert by_title['Completely Unrelated Side Quest']['new_path'] == '' + + +def test_preview_uses_same_logic_as_apply(monkeypatch, tmpdirs): + """Sanity check: a multi-disc album previewed and then applied + should show the same destinations. If preview drift creeps in + again, this fails.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1d1', 1, 'D1T1', _make_audio_file(library, 'd1t1.flac')), + ('t1d2', 1, 'D2T1', _make_audio_file(library, 'd2t1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'Test Album'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'D1T1', 'track_number': 1, 'disc_number': 1}, + {'id': 'a2', 'name': 'D2T1', 'track_number': 1, 'disc_number': 2}, + ]}, + ) + + preview = library_reorganize.preview_album_reorganize( + album_id='alb-1', db=db, transfer_dir='/transfer', + resolve_file_path_fn=lambda p: p, + build_final_path_fn=_fake_path_builder, + ) + + # Now apply with the same matching logic; assert apply uses the + # same disc_number per track that the preview reported. + apply_disc_per_title = {} + + def pp(key, ctx, fp): + apply_disc_per_title[ctx['track_info']['name']] = ctx['track_info']['disc_number'] + ctx['_final_processed_path'] = fp + with open(fp, 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + ) + + preview_disc_per_title = {it['title']: it['disc_number'] for it in preview['tracks']} + assert preview_disc_per_title == apply_disc_per_title + + +def test_available_sources_only_lists_authed_sources_with_stored_ids(monkeypatch): + """The reorganize modal needs to know which sources the user can + actually pick. A source is pickable iff: (a) we have an album ID + for that source on the local row, AND (b) the user has the source + authed/configured. Empty-ID sources and unauthed sources are + omitted.""" + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'spotify', 'itunes', 'discogs', 'hydrabase']) + + # Authed: deezer + spotify only. + auth = {'deezer': object(), 'spotify': object()} + monkeypatch.setattr(library_reorganize, 'get_client_for_source', + lambda src: auth.get(src)) + + album = { + 'spotify_album_id': 'sp-1', + 'deezer_id': 'dz-1', + 'itunes_album_id': 'it-1', # has ID but user not authed + 'discogs_id': '', # no ID + 'soul_id': '', # no ID + } + + sources = library_reorganize.available_sources_for_album(album) + names = [s['source'] for s in sources] + + assert names == ['deezer', 'spotify'] + assert all('label' in s for s in sources) + + +def test_authed_sources_lists_all_authed_regardless_of_album_ids(monkeypatch): + """Bulk reorganize uses this — needs the authed sources without + requiring per-album ID coverage. Each album in the bulk run will + do its own per-album ID check at apply time.""" + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes', 'discogs', 'hydrabase']) + + # Authed: spotify + deezer + itunes; discogs + hydrabase NOT authed. + auth = {'spotify': object(), 'deezer': object(), 'itunes': object()} + monkeypatch.setattr(library_reorganize, 'get_client_for_source', + lambda src: auth.get(src)) + + sources = library_reorganize.authed_sources() + names = [s['source'] for s in sources] + + # Primary first, then rest of priority chain — only authed ones + assert names == ['spotify', 'deezer', 'itunes'] + assert all('label' in s for s in sources) + + +def test_strict_source_does_not_fall_back(monkeypatch, tmpdirs): + """When the user picks a specific source in the modal, we must NOT + silently fall back to another source if their pick fails. Picking + Spotify means 'use Spotify or fail' — falling back would defeat + the picker's purpose.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', spotify_id='sp-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes']) + + fetched = [] + + def fake_album(src, sid): + fetched.append(('album', src)) + if src == 'deezer': + return {'id': 'dz-1', 'name': 'Album'} + return None # spotify "fails" + + def fake_tracks(src, sid): + fetched.append(('tracks', src)) + if src == 'deezer': + return {'items': [{'id': 'd1', 'name': 'Track 1', 'track_number': 1}]} + return None + + monkeypatch.setattr(library_reorganize, 'get_album_for_source', fake_album) + monkeypatch.setattr(library_reorganize, 'get_album_tracks_for_source', fake_tracks) + + pp_calls = [] + + def pp(*a, **k): + pp_calls.append(a) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + primary_source='spotify', strict_source=True, + ) + + # Spotify failed; with strict_source we must NOT have queried Deezer. + assert summary['status'] == 'no_source_id' + assert summary['moved'] == 0 + assert pp_calls == [] + assert all(src == 'spotify' for _kind, src in fetched) + + +def test_non_strict_falls_back_when_primary_returns_nothing(monkeypatch, tmpdirs): + """When the user did NOT pick a specific source (default behavior), + the orchestrator walks the priority chain as before.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', spotify_id='sp-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'spotify') + monkeypatch.setattr(library_reorganize, 'get_source_priority', + lambda p: [p, 'deezer', 'itunes']) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda src, sid: ({'id': sid, 'name': 'A'} if src == 'deezer' else None)) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda src, sid: ({'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]} + if src == 'deezer' else None), + ) + + def pp(key, ctx, fp): + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + # No strict_source → uses default fallback chain + ) + assert summary['source'] == 'deezer' + assert summary['moved'] == 1 + + +def test_returns_no_album_when_id_does_not_exist(tmpdirs): + _library, staging, _transfer = tmpdirs + db = _FakeDB() + cur = db._conn.cursor() + cur.execute("CREATE TABLE artists (id TEXT, name TEXT)") + cur.execute( + "CREATE TABLE albums (id TEXT, artist_id TEXT, title TEXT, " + "spotify_album_id TEXT, deezer_id TEXT, itunes_album_id TEXT, " + "discogs_id TEXT, soul_id TEXT)" + ) + cur.execute( + "CREATE TABLE tracks (id TEXT, album_id TEXT, artist_id TEXT, " + "title TEXT, track_number INTEGER, file_path TEXT, updated_at TEXT)" + ) + db._conn.commit() + + summary = library_reorganize.reorganize_album( + album_id='does-not-exist', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=lambda *a: None, + ) + + assert summary['status'] == 'no_album' + + +def test_returns_no_tracks_when_album_has_none(tmpdirs): + _library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[]) + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=lambda *a: None, + ) + + assert summary['status'] == 'no_tracks' + + +def test_processes_tracks_concurrently_with_consistent_state(monkeypatch, tmpdirs): + """Reorganize should run multiple tracks in parallel (matching the + download-side worker count). Verify both the parallelism (we observe + overlapping post-process calls) AND the state consistency (all + tracks are accounted for, no double-counting from races).""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + track_count = 6 + rows = [] + for i in range(1, track_count + 1): + rows.append((f't{i}', i, f'Track {i}', _make_audio_file(library, f't{i}.flac'))) + _setup_album(db, deezer_id='dz-1', tracks=rows) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': f'a{i}', 'name': f'Track {i}', 'track_number': i} + for i in range(1, track_count + 1) + ]}, + ) + + import threading + import time + + in_flight = 0 + max_in_flight = 0 + in_flight_lock = threading.Lock() + final_dir = library / 'final' + final_dir.mkdir() + + def slow_pp(key, ctx, fp): + nonlocal in_flight, max_in_flight + with in_flight_lock: + in_flight += 1 + max_in_flight = max(max_in_flight, in_flight) + # Hold the worker briefly so concurrency is observable + time.sleep(0.05) + with in_flight_lock: + in_flight -= 1 + out = str(final_dir / f"out_{ctx['track_info']['track_number']}.flac") + shutil.move(fp, out) + ctx['_final_processed_path'] = out + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=slow_pp, + ) + + # All 6 tracks processed; counts are consistent (no race-induced duplicates) + assert summary['moved'] == track_count + assert summary['skipped'] == 0 + assert summary['failed'] == 0 + # Should have observed at least 2 workers in flight at once + # (3 is the configured cap; some overlap should always occur with 6 slow tracks) + assert max_in_flight >= 2, f"Expected concurrent workers, only saw {max_in_flight} in flight" + + +def test_prunes_empty_destination_album_dirs(monkeypatch, tmpdirs): + """When transfer_dir is provided, the orchestrator must clean up + empty sibling album folders in the artist directory after the run. + Catches both (a) leftovers from previous failed reorganize attempts + that created standalone single-track folders, and (b) dirs created + by `_build_final_path_for_track` that ended up empty when post- + process failed AcoustID. Uses a single-level prune scoped to the + artist folder — won't touch unrelated user dirs.""" + library, staging, transfer = tmpdirs + db = _FakeDB() + src = _make_audio_file(library, 't1.flac') + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', src), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Track 1', 'track_number': 1}]}, + ) + + # Simulate the user's actual situation: transfer dir already has + # an artist folder with leftover empty single-track album folders + # from previous failed runs, plus an empty Disc-N subfolder. + artist_dir = transfer / 'Artist' + artist_dir.mkdir() + (artist_dir / 'Artist - 2013 Backseat Freestyle').mkdir() + (artist_dir / 'Artist - 2013 Compton').mkdir() + leftover_with_disc = artist_dir / 'Artist - 2012 Old Single-Disc' + leftover_with_disc.mkdir() + (leftover_with_disc / 'Disc 1').mkdir() # empty disc subfolder + + # Successful track lands in the real album folder + real_album = artist_dir / 'Artist - 2013 Real Album' + real_album.mkdir() + + def pp(key, ctx, fp): + final = str(real_album / 'Disc 1' / '01 - Track 1.flac') + os.makedirs(os.path.dirname(final), exist_ok=True) + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + transfer_dir=str(transfer), + ) + + # Empty leftover single-track album folders should be gone + assert not (artist_dir / 'Artist - 2013 Backseat Freestyle').exists() + assert not (artist_dir / 'Artist - 2013 Compton').exists() + # The album with an empty Disc subfolder should also be cleaned + # (Disc 1/ is empty → pruned, then Old Single-Disc/ is empty → pruned) + assert not leftover_with_disc.exists() + # Real album with successful track must still exist + assert real_album.exists() + assert (real_album / 'Disc 1' / '01 - Track 1.flac').exists() + # Artist folder itself (still has the real album) untouched + assert artist_dir.exists() + + +def test_context_dict_satisfies_post_process_contract(monkeypatch, tmpdirs): + """Integration-style test: assert the per-track context dict the + orchestrator hands to post-process contains every key + `_post_process_matched_download` and `_build_final_path_for_track` + actually read in production. If the real post-process starts + requiring a new key in a future refactor, this test catches it + BEFORE the user does — unit-mock tests would not. + + Keys verified are taken from a grep of the real functions in + web_server.py at the time this test was written. The list is the + contract; if it grows, the orchestrator's `_build_post_process_context` + needs to grow too.""" + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: { + 'id': 'dz-1', + 'name': 'Test Album', + 'release_date': '2024-03-15', + 'total_tracks': 12, + 'image_url': 'https://example.com/cover.jpg', + }) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{ + 'id': 'a1', 'name': 'Track 1', + 'track_number': 1, 'disc_number': 1, + 'duration_ms': 240000, + 'artists': [{'name': 'Aerosmith'}], + 'uri': 'spotify:track:abc', + }]}, + ) + + captured_context = {} + + def assert_contract(key, ctx, fp): + captured_context.update(ctx) + # Mimic the bits of real post-process this test cares about + ctx['_final_processed_path'] = str(library / 'out.flac') + with open(ctx['_final_processed_path'], 'wb') as f: + f.write(b'final') + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=assert_contract, + ) + + # Top-level keys the real post-process reads + assert captured_context.get('is_album_download') is True + assert captured_context.get('has_clean_spotify_data') is True + assert captured_context.get('has_full_spotify_metadata') is True + + # spotify_artist (album-level artist context — not per-track) + spotify_artist = captured_context.get('spotify_artist') + assert isinstance(spotify_artist, dict) + assert 'name' in spotify_artist + assert 'id' in spotify_artist + assert 'genres' in spotify_artist + + # spotify_album (used by `_build_final_path_for_track`) + spotify_album = captured_context.get('spotify_album') + assert isinstance(spotify_album, dict) + assert spotify_album.get('id') == 'dz-1' + assert spotify_album.get('name') == 'Test Album' + assert 'release_date' in spotify_album # year extraction + assert 'total_tracks' in spotify_album # ALBUM/EP/Single inference + assert 'total_discs' in spotify_album # Disc N/ subfolder gate + assert 'image_url' in spotify_album # album art + + # track_info (per-track signal — populates filename, tags, disc subfolder) + track_info = captured_context.get('track_info') + assert isinstance(track_info, dict) + assert 'name' in track_info # filename + assert 'id' in track_info # source track id + assert 'track_number' in track_info # filename + tag + assert 'disc_number' in track_info # disc subfolder + tag + assert 'duration_ms' in track_info # tag + assert isinstance(track_info.get('artists'), list) # tag — must be list + assert all(isinstance(a, dict) and 'name' in a for a in track_info['artists']) + + # original_search_result (post-process reads this for fallbacks) + osr = captured_context.get('original_search_result') + assert isinstance(osr, dict) + assert 'title' in osr + assert 'spotify_clean_title' in osr # `_build_final_path_for_track` reads this + assert 'spotify_clean_album' in osr # ditto + assert 'track_number' in osr + assert 'disc_number' in osr + assert 'artists' in osr + + +def test_progress_callback_receives_updates(monkeypatch, tmpdirs): + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Track 1', _make_audio_file(library, 't1.flac')), + ('t2', 2, 'Track 2', _make_audio_file(library, 't2.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': 'a1', 'name': 'Track 1', 'track_number': 1}, + {'id': 'a2', 'name': 'Track 2', 'track_number': 2}, + ]}, + ) + + def pp(key, ctx, fp): + final = str(library / f"final_{ctx['track_info']['track_number']}.flac") + shutil.move(fp, final) + ctx['_final_processed_path'] = final + + progress_log = [] + + def on_progress(updates): + progress_log.append(dict(updates)) + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + on_progress=on_progress, + ) + + assert any('total' in u for u in progress_log) + assert any('current_track' in u for u in progress_log) + assert any(u.get('moved') == 2 for u in progress_log) + + +def test_watchdog_warns_about_stuck_workers(monkeypatch, tmpdirs, caplog): + """When a worker exceeds the hung-threshold, the orchestrator must + log a warning naming the stuck track. Real threshold is 5 minutes; + we monkeypatch it down to ~50ms so the test runs in well under a + second. Watchdog is passive (doesn't kill threads), so the worker + should still complete normally after the warning.""" + import threading + library, staging, _transfer = tmpdirs + + # Tiny watchdog so the test is fast. Interval shorter than threshold + # so the loop checks at least once before the threshold trips. + monkeypatch.setattr(library_reorganize, '_WATCHDOG_INTERVAL_SECONDS', 0.02) + monkeypatch.setattr(library_reorganize, '_HUNG_WORKER_THRESHOLD_SECONDS', 0.05) + + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + ('t1', 1, 'Stuck Track', _make_audio_file(library, 't1.flac')), + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [{'id': 'a1', 'name': 'Stuck Track', 'track_number': 1}]}, + ) + + release = threading.Event() + + def slow_pp(key, ctx, fp): + # Hold long enough for the watchdog to trip the threshold + emit. + # 0.2s vs 0.05s threshold + 0.02s interval = at least one warn pass. + release.wait(timeout=0.25) + ctx['_final_processed_path'] = fp + with open(fp, 'wb') as f: + f.write(b'final') + + caplog.set_level('WARNING', logger='library_reorganize') + + summary = library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=slow_pp, + ) + release.set() + + # Track still completed (watchdog is passive — it doesn't abort) + assert summary['moved'] == 1 + + # And the watchdog warning was logged with the stuck track's title + warnings = [ + r.getMessage() for r in caplog.records + if r.levelname == 'WARNING' and 'Worker stuck' in r.getMessage() + ] + assert any('Stuck Track' in msg for msg in warnings), ( + f"Expected a 'Worker stuck' warning naming the track; got: {warnings}" + ) + + +def test_stop_check_aborts_remaining_tracks(monkeypatch, tmpdirs): + """With concurrent workers, stop_check can't cancel a task that's + already running — but it MUST prevent tasks that haven't started + yet from running. Use enough tracks that the worker pool can't + drain them all before stop_check trips.""" + import threading + library, staging, _transfer = tmpdirs + db = _FakeDB() + _setup_album(db, deezer_id='dz-1', tracks=[ + (f't{i}', i, f'Track {i}', _make_audio_file(library, f't{i}.flac')) + for i in range(1, 11) + ]) + + monkeypatch.setattr(library_reorganize, 'get_primary_source', lambda: 'deezer') + monkeypatch.setattr(library_reorganize, 'get_source_priority', lambda p: [p]) + monkeypatch.setattr(library_reorganize, 'get_album_for_source', + lambda *a: {'id': 'dz-1', 'name': 'A'}) + monkeypatch.setattr( + library_reorganize, 'get_album_tracks_for_source', + lambda *a: {'items': [ + {'id': f'a{i}', 'name': f'Track {i}', 'track_number': i} + for i in range(1, 11) + ]}, + ) + + pp_count = [0] + pp_lock = threading.Lock() + + def pp(key, ctx, fp): + with pp_lock: + pp_count[0] += 1 + ctx['_final_processed_path'] = fp + with open(fp, 'wb') as f: + f.write(b'fake-final') + + stop = [False] + def check_stop(): + with pp_lock: + if pp_count[0] >= 2: + stop[0] = True + return stop[0] + + library_reorganize.reorganize_album( + album_id='alb-1', db=db, staging_root=str(staging), + resolve_file_path_fn=lambda p: p, post_process_fn=pp, + stop_check=check_stop, + ) + + # Some tracks ran (the ones already in flight when stop tripped), + # but not ALL 10 — the stop_check cut off the unstarted ones. + assert pp_count[0] < 10 + assert pp_count[0] >= 2 diff --git a/tests/test_metadata_service_discography.py b/tests/test_metadata_service_discography.py index 48767063..fc9e4bde 100644 --- a/tests/test_metadata_service_discography.py +++ b/tests/test_metadata_service_discography.py @@ -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): diff --git a/tests/test_musicbrainz_search.py b/tests/test_musicbrainz_search.py new file mode 100644 index 00000000..719f02ae --- /dev/null +++ b/tests/test_musicbrainz_search.py @@ -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/ + 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 diff --git a/tests/test_reorganize_db_methods.py b/tests/test_reorganize_db_methods.py new file mode 100644 index 00000000..27e4609d --- /dev/null +++ b/tests/test_reorganize_db_methods.py @@ -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') diff --git a/tests/test_reorganize_queue.py b/tests/test_reorganize_queue.py new file mode 100644 index 00000000..8e2707b4 --- /dev/null +++ b/tests/test_reorganize_queue.py @@ -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() diff --git a/tests/test_reorganize_runner.py b/tests/test_reorganize_runner.py new file mode 100644 index 00000000..f38bffd1 --- /dev/null +++ b/tests/test_reorganize_runner.py @@ -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. diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py new file mode 100644 index 00000000..a801f7d1 --- /dev/null +++ b/tests/test_script_split_integrity.py @@ -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 + - - + + + + + + + + + + + + + + + + + + + + +
@@ -8108,10 +7963,10 @@ ? - - - - + + + + \ No newline at end of file diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js new file mode 100644 index 00000000..c900d824 --- /dev/null +++ b/webui/static/api-monitor.js @@ -0,0 +1,3794 @@ +// == API RATE MONITOR GAUGES == +// =============================== + +const _rateMonitorState = {}; +const _RATE_GAUGE_SERVICES = [ + 'spotify', 'itunes', 'deezer', 'lastfm', 'genius', + 'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', +]; +const _RATE_GAUGE_LABELS = { + spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', + lastfm: 'Last.fm', genius: 'Genius', musicbrainz: 'MusicBrainz', + audiodb: 'AudioDB', tidal: 'Tidal', qobuz: 'Qobuz', discogs: 'Discogs', +}; +const _RATE_GAUGE_COLORS = { + spotify: '#1DB954', itunes: '#FC3C44', deezer: '#A238FF', + lastfm: '#D51007', genius: '#FFFF64', musicbrainz: '#BA478F', + audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35', discogs: '#D4A574', +}; + +// SVG constants — 240° arc, gap at bottom +const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 }; + +function _gPt(angle, radius) { + const rad = (angle - 90) * Math.PI / 180; + const r = radius || _G.r; + return { x: _G.cx + r * Math.cos(rad), y: _G.cy + r * Math.sin(rad) }; +} + +function _gArc(startDeg, endDeg, radius) { + const r = radius || _G.r; + const s = _gPt(startDeg, r), e = _gPt(endDeg, r); + const sweep = ((endDeg - startDeg + 360) % 360); + const large = sweep > 180 ? 1 : 0; + return `M${s.x},${s.y} A${r},${r} 0 ${large} 1 ${e.x},${e.y}`; +} + +function _handleRateMonitorUpdate(data) { + const grid = document.getElementById('rate-monitor-grid'); + if (!grid) return; + + if (!grid.children.length) { + for (const svc of _RATE_GAUGE_SERVICES) { + const div = document.createElement('div'); + div.className = 'rate-gauge-card'; + div.id = `rate-gauge-${svc}`; + div.onclick = () => _openRateModal(svc); + grid.appendChild(div); + } + } + + for (const svc of _RATE_GAUGE_SERVICES) { + const d = data[svc]; + if (!d) continue; + _rateMonitorState[svc] = d; + const container = document.getElementById(`rate-gauge-${svc}`); + if (!container) continue; + + const value = d.cpm || 0; + const max = d.limit || 60; + const pct = Math.min(value / max, 1); + const accent = _RATE_GAUGE_COLORS[svc] || '#888'; + const label = _RATE_GAUGE_LABELS[svc] || svc; + const worker = d.worker || {}; + const wStatus = worker.status || 'stopped'; + const isRateLimited = d.rate_limited === true; + + // Build or update the card content + let gaugeWrap = container.querySelector('.gauge-arc-wrap'); + if (!gaugeWrap) { + // Full rebuild + container.innerHTML = ` +
+ + ${label} + ${_workerStatusLabel(wStatus, worker)} +
+
${_buildGaugeSVG(svc, value, max)}
+
+
${value.toFixed(0)}calls/min
+
${worker.calls_1h || 0}last hour
+
${(worker.calls_24h || 0).toLocaleString()}24h
+
+ ${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''} + ${isRateLimited ? _buildRateLimitBadge(d) : ''} + `; + } else { + // Fast update — only change values + _updateGauge(gaugeWrap, value, max, svc); + + // Update status + const statusEl = container.querySelector('.gauge-card-status'); + if (statusEl) { + statusEl.dataset.status = wStatus; + statusEl.textContent = _workerStatusLabel(wStatus, worker); + } + + // Update stats + const statVals = container.querySelectorAll('.gauge-card-stat-val'); + if (statVals[0]) statVals[0].textContent = value.toFixed(0); + if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0; + if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString(); + + // Budget bar (Spotify) + if (svc === 'spotify' && worker.daily_budget) { + let budgetEl = container.querySelector('.gauge-budget-bar'); + if (!budgetEl) { + const div = document.createElement('div'); + div.innerHTML = _buildBudgetBar(worker.daily_budget); + const statsEl = container.querySelector('.gauge-card-stats'); + if (statsEl) statsEl.after(div.firstElementChild); + } else { + const b = worker.daily_budget; + const pctB = Math.min(100, Math.round((b.used / b.limit) * 100)); + const fill = budgetEl.querySelector('.gauge-budget-fill'); + if (fill) { fill.style.width = pctB + '%'; } + const label = budgetEl.querySelector('.gauge-budget-label'); + if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`; + } + } + + // Rate limit badge + let badge = container.querySelector('.gauge-rl-badge'); + if (isRateLimited) { + if (!badge) { + const div = document.createElement('div'); + div.innerHTML = _buildRateLimitBadge(d); + container.appendChild(div.firstElementChild); + } else { + const mins = Math.ceil((d.rl_remaining || 0) / 60); + const timeEl = badge.querySelector('.gauge-rl-time'); + if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; + } + } else if (badge) { + badge.remove(); + } + } + + container.classList.toggle('danger', pct > 0.8 || isRateLimited); + container.classList.toggle('active', value > 0 || wStatus === 'running'); + container.classList.toggle('rate-limited', isRateLimited); + } +} + +function _workerStatusLabel(status, worker) { + if (status === 'not_configured') return 'Not configured'; + if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; + if (status === 'idle') return 'Idle'; + if (status === 'running') return 'Running'; + return 'Stopped'; +} + +function _buildBudgetBar(budget) { + const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100)); + const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; + return `
+
+ ${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily +
`; +} + +function _buildRateLimitBadge(d) { + const mins = Math.ceil((d.rl_remaining || 0) / 60); + const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; + return `
RATE LIMITED${text}
`; +} + +function _buildGaugeSVG(svc, value, max) { + const { size, cx, cy, r, stroke, startAngle, totalArc } = _G; + const label = _RATE_GAUGE_LABELS[svc] || svc; + const accent = _RATE_GAUGE_COLORS[svc] || '#888'; + const pct = Math.min(value / max, 1); + const endAngle = startAngle + pct * totalArc; + const arcEnd = startAngle + totalArc; + const glowId = `glow-${svc}`; + + // Endpoint dot position + const dot = pct > 0 ? _gPt(endAngle, r) : null; + + // Gradient ID for the colored arc + const gradId = `grad-${svc}`; + + const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; + + return ` + + + + + + + + + ${pct > 0 ? `` : ''} + + + ${dot ? `` : ''} + + + 0 + ${max} + + + ${Math.round(value)} + /min + + + ${label} + + `; +} + +function _updateGauge(container, value, max, svc) { + const { r, stroke, startAngle, totalArc } = _G; + const accent = _RATE_GAUGE_COLORS[svc] || '#888'; + const pct = Math.min(value / max, 1); + const endAngle = startAngle + pct * totalArc; + const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; + + // Update center value + const valText = container.querySelector('.gauge-value'); + if (valText) valText.textContent = Math.round(value); + + // Update active arc + const activeArc = container.querySelector('.gauge-active-arc'); + if (pct > 0) { + const d = _gArc(startAngle, endAngle); + if (activeArc) { + activeArc.setAttribute('d', d); + activeArc.setAttribute('stroke', color); + activeArc.style.filter = `drop-shadow(0 0 6px ${color}60)`; + } else { + // Rebuild the whole gauge when transitioning from 0 to active + container.innerHTML = _buildGaugeSVG(svc, value, max); + return; + } + } else if (activeArc) { + activeArc.remove(); + // Also remove dots + container.querySelectorAll('.gauge-dot').forEach(d => d.remove()); + const innerDot = container.querySelector('.gauge-dot + circle'); + if (innerDot) innerDot.remove(); + return; + } + + // Update endpoint dot + const gaugeDot = container.querySelector('.gauge-dot'); + if (pct > 0 && gaugeDot) { + const dot = _gPt(endAngle, r); + gaugeDot.setAttribute('cx', dot.x); + gaugeDot.setAttribute('cy', dot.y); + gaugeDot.setAttribute('fill', color); + gaugeDot.style.filter = `drop-shadow(0 0 4px ${color}80)`; + const inner = gaugeDot.nextElementSibling; + if (inner && inner.tagName === 'circle') { + inner.setAttribute('cx', dot.x); + inner.setAttribute('cy', dot.y); + } + } +} + +// ── Rate Monitor Detail Modal ── + +let _rateModalService = null; +let _rateModalInterval = null; + +function _openRateModal(serviceKey) { + _rateModalService = serviceKey; + const label = _RATE_GAUGE_LABELS[serviceKey] || serviceKey; + const accent = _RATE_GAUGE_COLORS[serviceKey] || '#888'; + + let overlay = document.getElementById('rate-modal-overlay'); + if (overlay) overlay.remove(); + + overlay = document.createElement('div'); + overlay.id = 'rate-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) _closeRateModal(); }; + + const isSpotify = serviceKey === 'spotify'; + const currentData = _rateMonitorState[serviceKey] || {}; + + overlay.innerHTML = ` +
+
+
+
+
+

${label}

+ ${currentData.cpm || 0} calls/min — limit ${currentData.limit || '?'}/min +
+
+ +
+
+
24-Hour Call History
+
+ +
+
+ ${isSpotify ? '
Per-Endpoint Breakdown
' : ''} +
+
+ `; + document.body.appendChild(overlay); + + // Fetch main history + per-endpoint histories for Spotify + const historyPromises = [ + fetch(`/api/rate-monitor/history/${serviceKey}`).then(r => r.json()) + ]; + if (isSpotify) { + const activeEps = Object.keys(_rateMonitorState.spotify?.endpoints || {}); + for (const ep of activeEps) { + historyPromises.push( + fetch(`/api/rate-monitor/history/spotify:${ep}`).then(r => r.json()).catch(() => null) + ); + } + } + Promise.all(historyPromises).then(results => { + const main = results[0]; + const epHistories = isSpotify ? results.slice(1).filter(Boolean) : []; + _renderRateChart(main.history || [], main.rate_limit || 60, accent, epHistories); + }).catch(() => { }); + + if (isSpotify) { + _updateSpotifyEndpoints(); + _rateModalInterval = setInterval(_updateSpotifyEndpoints, 1000); + } +} + +function _closeRateModal() { + const overlay = document.getElementById('rate-modal-overlay'); + if (overlay) overlay.remove(); + if (_rateModalInterval) { clearInterval(_rateModalInterval); _rateModalInterval = null; } + _rateModalService = null; +} + +function _renderRateChart(history, rateLimit, accent, epHistories = []) { + const canvas = document.getElementById('rate-modal-chart'); + if (!canvas) return; + + // HiDPI support + const dpr = window.devicePixelRatio || 1; + const W = 700, H = 280; + canvas.width = W * dpr; + canvas.height = H * dpr; + canvas.style.width = W + 'px'; + canvas.style.height = H + 'px'; + const ctx = canvas.getContext('2d'); + ctx.scale(dpr, dpr); + + const pad = { top: 24, right: 24, bottom: 36, left: 50 }; + const plotW = W - pad.left - pad.right; + const plotH = H - pad.top - pad.bottom; + + ctx.clearRect(0, 0, W, H); + + // Build data points + const now = Math.floor(Date.now() / 1000); + const start = now - 86400; + const points = []; + + if (history.length > 0) { + const histMap = new Map(history.map(h => [h[0], h[1]])); + for (let t = start; t <= now; t += 300) { + const bucket = Math.floor(t / 60) * 60; + let sum = 0; + for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; + points.push({ t, v: sum / 5 }); + } + } + + const maxVal = Math.max(rateLimit * 1.15, ...points.map(p => p.v), 1); + + // Grid lines (horizontal) + ctx.strokeStyle = 'rgba(255,255,255,0.04)'; + ctx.lineWidth = 1; + for (let i = 1; i <= 4; i++) { + const y = pad.top + plotH * (1 - i / 4); + ctx.beginPath(); + ctx.moveTo(pad.left, y); + ctx.lineTo(pad.left + plotW, y); + ctx.stroke(); + } + + // Danger zone band + const dangerY = pad.top + plotH * (1 - rateLimit / maxVal); + const grad = ctx.createLinearGradient(0, pad.top, 0, dangerY); + grad.addColorStop(0, 'rgba(239, 68, 68, 0.08)'); + grad.addColorStop(1, 'rgba(239, 68, 68, 0.02)'); + ctx.fillStyle = grad; + ctx.fillRect(pad.left, pad.top, plotW, dangerY - pad.top); + + // Rate limit line + ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)'; + ctx.setLineDash([8, 5]); + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(pad.left, dangerY); + ctx.lineTo(pad.left + plotW, dangerY); + ctx.stroke(); + ctx.setLineDash([]); + + ctx.fillStyle = 'rgba(239, 68, 68, 0.6)'; + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(`Rate limit: ${rateLimit}/min`, pad.left + 6, dangerY - 6); + + // Draw area fill + line + if (points.length > 1) { + // Area gradient fill + const areaGrad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH); + // Parse accent to rgba + areaGrad.addColorStop(0, accent + '30'); + areaGrad.addColorStop(1, accent + '05'); + + ctx.beginPath(); + points.forEach((p, i) => { + const x = pad.left + (i / (points.length - 1)) * plotW; + const y = pad.top + plotH * (1 - p.v / maxVal); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.lineTo(pad.left + plotW, pad.top + plotH); + ctx.lineTo(pad.left, pad.top + plotH); + ctx.closePath(); + ctx.fillStyle = areaGrad; + ctx.fill(); + + // Line + ctx.beginPath(); + points.forEach((p, i) => { + const x = pad.left + (i / (points.length - 1)) * plotW; + const y = pad.top + plotH * (1 - p.v / maxVal); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = accent; + ctx.lineWidth = 2; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Glow effect + ctx.shadowColor = accent; + ctx.shadowBlur = 8; + ctx.stroke(); + ctx.shadowBlur = 0; + } + + // Per-endpoint lines (Spotify breakdown) + const legendEl = document.getElementById('rate-modal-chart-legend'); + if (epHistories.length > 0) { + const epColors = ['#1DB954', '#FF6B6B', '#4ECDC4', '#FFE66D', '#A78BFA', '#F97316', '#06B6D4', '#EC4899', '#F472B6', '#34D399']; + const legendItems = []; + + epHistories.forEach((epData, idx) => { + if (!epData || !epData.history || epData.history.length === 0) return; + const epName = (epData.service || '').replace('spotify:', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const color = epColors[idx % epColors.length]; + legendItems.push({ name: epName, color }); + + const histMap = new Map(epData.history.map(h => [h[0], h[1]])); + const epPoints = []; + for (let t = start; t <= now; t += 300) { + const bucket = Math.floor(t / 60) * 60; + let sum = 0; + for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; + epPoints.push({ t, v: sum / 5 }); + } + + if (epPoints.length > 1) { + ctx.beginPath(); + epPoints.forEach((p, i) => { + const x = pad.left + (i / (epPoints.length - 1)) * plotW; + const y = pad.top + plotH * (1 - p.v / maxVal); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.strokeStyle = color + 'BB'; + ctx.lineWidth = 1.5; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + }); + + // HTML legend below chart + if (legendEl && legendItems.length > 0) { + legendEl.innerHTML = legendItems.map(item => + `${item.name}` + ).join(''); + } + } else if (legendEl) { + legendEl.innerHTML = ''; + } + + // X-axis labels + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.font = '10px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + for (let i = 0; i <= 6; i++) { + const t = start + (86400 * i / 6); + const x = pad.left + (i / 6) * plotW; + const d = new Date(t * 1000); + const hr = d.getHours(); + const label = hr === 0 ? '12am' : hr < 12 ? `${hr}am` : hr === 12 ? '12pm' : `${hr - 12}pm`; + ctx.fillText(label, x, H - 10); + // Subtle vertical grid + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, pad.top); + ctx.lineTo(x, pad.top + plotH); + ctx.stroke(); + } + + // Y-axis labels + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.textAlign = 'right'; + ctx.font = '10px -apple-system, sans-serif'; + for (let i = 0; i <= 4; i++) { + const v = maxVal * i / 4; + const y = pad.top + plotH * (1 - i / 4); + ctx.fillText(Math.round(v), pad.left - 8, y + 4); + } + + // Empty state + if (points.length === 0) { + ctx.fillStyle = 'rgba(255,255,255,0.15)'; + ctx.font = '13px -apple-system, sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('No call history yet — data populates as API calls are made', W / 2, H / 2); + } +} + +function _updateSpotifyEndpoints() { + const container = document.getElementById('rate-modal-endpoints'); + if (!container) return; + const endpoints = _rateMonitorState.spotify?.endpoints || {}; + const entries = Object.entries(endpoints).sort((a, b) => b[1] - a[1]); + + if (entries.length === 0) { + container.innerHTML = '
No active Spotify endpoints — start an enrichment worker or search to see activity
'; + return; + } + + const limit = _rateMonitorState.spotify?.limit || 171; + container.innerHTML = entries.map(([ep, cpm]) => { + const pct = Math.min(cpm / limit * 100, 100); + const name = ep.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + const color = pct > 80 ? '#ef4444' : pct > 60 ? '#eab308' : '#1DB954'; + return `
+ ${name} +
+ ${Math.round(cpm)}/min +
`; + }).join(''); +} + +async function fetchAndUpdateSystemStats() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/system/stats'); + if (!response.ok) return; + + const data = await response.json(); + + // Update all stat cards + 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'); + + } catch (error) { + console.warn('Could not fetch system stats:', error); + } +} + +function updateStatCard(cardId, value, subtitle) { + const card = document.getElementById(cardId); + if (card) { + const valueElement = card.querySelector('.stat-card-value'); + const subtitleElement = card.querySelector('.stat-card-subtitle'); + + if (valueElement) { + valueElement.textContent = value; + } + if (subtitleElement) { + subtitleElement.textContent = subtitle; + } + } +} + +async function fetchAndUpdateActivityFeed() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/activity/feed'); + if (!response.ok) { + console.warn('Activity feed response not ok:', response.status, response.statusText); + return; + } + + const data = await response.json(); + console.log('Activity feed data received:', data); + updateActivityFeed(data.activities || []); + + } catch (error) { + console.warn('Could not fetch activity feed:', error); + } +} + +// Cache last feed signature to avoid unnecessary DOM rebuilds (prevents blink) +let _lastActivityFeedSig = ''; + +function updateActivityFeed(activities) { + const feedContainer = document.getElementById('dashboard-activity-feed'); + if (!feedContainer) return; + + if (activities.length === 0) { + if (_lastActivityFeedSig === 'empty') return; + _lastActivityFeedSig = 'empty'; + feedContainer.innerHTML = ` +
+ 📊 +
+

System Started

+

Dashboard initialized successfully

+
+

Just now

+
+ `; + return; + } + + const items = activities.slice(0, 5); + // Build signature from titles+subtitles to detect actual changes + const sig = items.map(a => a.title + a.subtitle).join('|'); + const feedChanged = sig !== _lastActivityFeedSig; + _lastActivityFeedSig = sig; + + if (!feedChanged) { + // Just update timestamps without rebuilding DOM + const timeEls = feedContainer.querySelectorAll('.activity-time'); + items.forEach((activity, i) => { + if (timeEls[i]) timeEls[i].textContent = timeAgo(activity.time); + }); + return; + } + + // Full rebuild only when feed content actually changed + feedContainer.innerHTML = ''; + items.forEach((activity, index) => { + const activityElement = document.createElement('div'); + activityElement.className = 'activity-item'; + activityElement.innerHTML = ` + ${escapeHtml(activity.icon)} +
+

${escapeHtml(activity.title)}

+

${escapeHtml(activity.subtitle)}

+
+

${timeAgo(activity.time)}

+ `; + feedContainer.appendChild(activityElement); + + if (index < items.length - 1) { + const separator = document.createElement('div'); + separator.className = 'activity-separator'; + feedContainer.appendChild(separator); + } + }); +} + +async function checkForActivityToasts() { + if (socketConnected) return; // WebSocket handles this (instant push) + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/activity/toasts'); + if (!response.ok) return; + + const data = await response.json(); + const toasts = data.toasts || []; + + toasts.forEach(activity => { + // Convert activity to toast type based on icon/title + let toastType = 'info'; + if (activity.icon === '✅' || activity.title.includes('Complete')) { + toastType = 'success'; + } else if (activity.icon === '❌' || activity.title.includes('Failed') || activity.title.includes('Error')) { + toastType = 'error'; + } else if (activity.icon === '🚫' || activity.title.includes('Cancelled')) { + toastType = 'warning'; + } + + // Show toast with activity info + showToast(`${activity.title}: ${activity.subtitle}`, toastType); + }); + + } catch (error) { + // Silently fail for toast checking to avoid spam + } +} + +// --- Watchlist Functions --- + +/** + * Toggle an artist's watchlist status + */ +async function toggleWatchlist(event, artistId, artistName) { + // Prevent event bubbling to parent card + event.stopPropagation(); + + const button = event.currentTarget; + const icon = button.querySelector('.watchlist-icon'); + const text = button.querySelector('.watchlist-text'); + + // Show loading state + const originalText = text.textContent; + text.textContent = 'Loading...'; + button.disabled = true; + + try { + // Check current status + const checkResponse = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const checkData = await checkResponse.json(); + if (!checkData.success) { + throw new Error(checkData.error || 'Failed to check watchlist status'); + } + + const isWatching = checkData.is_watching; + + // Toggle watchlist status + const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; + const payload = isWatching ? + { artist_id: artistId } : + { artist_id: artistId, artist_name: artistName }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to update watchlist'); + } + + // Update button appearance + const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn'); + if (isWatching) { + // Was watching, now removed + icon.textContent = '👁️'; + text.textContent = 'Add to Watchlist'; + button.classList.remove('watching'); + if (gearBtn) gearBtn.classList.add('hidden'); + console.log(`❌ Removed ${artistName} from watchlist`); + } else { + // Was not watching, now added + icon.textContent = '👁️'; + text.textContent = 'Watching...'; + button.classList.add('watching'); + if (gearBtn) gearBtn.classList.remove('hidden'); + console.log(`✅ Added ${artistName} to watchlist`); + } + + // Update dashboard watchlist count + updateWatchlistButtonCount(); + + } catch (error) { + console.error('Error toggling watchlist:', error); + text.textContent = originalText; + + // Show error feedback + const originalBackground = button.style.background; + button.style.background = 'rgba(255, 59, 48, 0.3)'; + setTimeout(() => { + button.style.background = originalBackground; + }, 2000); + } finally { + button.disabled = false; + } +} + +/** + * Update the watchlist button count on dashboard + */ +async function updateWatchlistButtonCount() { + if (document.hidden) return; // Skip polling when tab is not visible + if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll + try { + const response = await fetch('/api/watchlist/count'); + const data = await response.json(); + + 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}`; + } + } + } + } catch (error) { + console.error('Error updating watchlist count:', error); + } +} + +/** + * Check and update watchlist status for all visible artist cards + */ +async function updateArtistCardWatchlistStatus() { + const artistCards = document.querySelectorAll('.artist-card'); + const artistIds = []; + for (const card of artistCards) { + const artistId = card.dataset.artistId; + if (artistId) artistIds.push(artistId); + } + if (!artistIds.length) return; + + try { + const response = await fetch('/api/watchlist/check-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_ids: artistIds }) + }); + + const data = await response.json(); + if (data.success && data.results) { + for (const card of artistCards) { + const artistId = card.dataset.artistId; + if (!artistId) continue; + + const button = card.querySelector('.watchlist-toggle-btn'); + if (!button) continue; + const icon = button.querySelector('.watchlist-icon'); + const text = button.querySelector('.watchlist-text'); + + const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn'); + if (data.results[artistId]) { + if (icon) icon.textContent = '👁️'; + if (text) text.textContent = 'Watching...'; + button.classList.add('watching'); + if (gearBtn) gearBtn.classList.remove('hidden'); + } else { + if (icon) icon.textContent = '👁️'; + if (text) text.textContent = 'Add to Watchlist'; + button.classList.remove('watching'); + if (gearBtn) gearBtn.classList.add('hidden'); + } + } + } + } catch (error) { + console.error('Error batch checking watchlist status:', error); + } +} + +/** + * Initialize/refresh the watchlist sidebar page + */ +async function initializeWatchlistPage() { + try { + const emptyEl = document.getElementById('watchlist-page-empty'); + const gridEl = document.getElementById('watchlist-artists-list'); + const countEl = document.getElementById('watchlist-page-count'); + const overrideBanner = document.getElementById('watchlist-page-override-banner'); + + // Fetch count, artists, scan status, global config in parallel + const [countRes, artistsRes, statusRes, globalRes] = await Promise.all([ + fetch('/api/watchlist/count').then(r => r.json()), + fetch('/api/watchlist/artists').then(r => r.json()), + fetch('/api/watchlist/scan/status').then(r => r.json()), + fetch('/api/watchlist/global-config').then(r => r.json()).catch(() => ({ success: false })), + ]); + + const count = countRes.success ? countRes.count : 0; + const artists = artistsRes.success ? artistsRes.artists : []; + const scanStatus = statusRes.success ? statusRes.status : 'idle'; + const globalOverrideActive = globalRes.success && globalRes.config && globalRes.config.global_override_enabled; + + // Update count + if (countEl) countEl.textContent = `${count} artist${count !== 1 ? 's' : ''}`; + + // Update nav badge + const navBadge = document.getElementById('watchlist-nav-badge'); + if (navBadge) { + navBadge.textContent = count; + navBadge.classList.toggle('hidden', count === 0); + } + + // Empty state + if (count === 0) { + if (emptyEl) emptyEl.style.display = ''; + if (gridEl) gridEl.style.display = 'none'; + watchlistPageState.isInitialized = true; + return; + } + if (emptyEl) emptyEl.style.display = 'none'; + if (gridEl) gridEl.style.display = ''; + + // Store artists for sorting + watchlistPageState.artists = artists; + + // Last scan summary strip + const scanStrip = document.getElementById('watchlist-last-scan-strip'); + const scanText = document.getElementById('watchlist-last-scan-text'); + if (scanStrip && scanText && statusRes.completed_at && statusRes.summary) { + const completedDate = new Date(statusRes.completed_at); + const ago = _formatTimeAgo(completedDate); + const found = statusRes.summary.new_tracks_found || 0; + const added = statusRes.summary.tracks_added_to_wishlist || 0; + scanText.textContent = `Last scan: ${ago} — ${found} new track${found !== 1 ? 's' : ''} found, ${added} added to wishlist`; + scanStrip.style.display = ''; + } else if (scanStrip) { + scanStrip.style.display = 'none'; + } + + // Global override banner + if (overrideBanner) overrideBanner.style.display = globalOverrideActive ? '' : 'none'; + const settingsBtn = document.getElementById('watchlist-page-settings-btn'); + if (settingsBtn) { + settingsBtn.classList.toggle('watchlist-global-settings-active', globalOverrideActive); + settingsBtn.innerHTML = ` ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`; + } + + // Render artist cards + if (gridEl) { + gridEl.innerHTML = artists.map(artist => { + const pills = []; + if (artist.include_albums) pills.push('Albums'); + if (artist.include_eps) pills.push('EPs'); + if (artist.include_singles) pills.push('Singles'); + if (artist.include_live) pills.push('Live'); + if (artist.include_remixes) pills.push('Remixes'); + if (artist.include_acoustic) pills.push('Acoustic'); + if (artist.include_compilations) pills.push('Compilations'); + const sourceBadges = []; + if (artist.spotify_artist_id) sourceBadges.push('Spotify'); + if (artist.itunes_artist_id) sourceBadges.push('iTunes'); + if (artist.deezer_artist_id) sourceBadges.push('Deezer'); + if (artist.discogs_artist_id) sourceBadges.push('Discogs'); + const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id; + return ` +
+ + +
+ ${artist.image_url ? `${escapeHtml(artist.artist_name)}` : '
🎤
'} +
+
+ ${escapeHtml(artist.artist_name)} + ${formatRelativeScanTime(artist.last_scan_timestamp)} +
+ ${sourceBadges.length > 0 ? `
${sourceBadges.join('')}
` : ''} + ${pills.length > 0 ? `
${pills.join('')}
` : ''} +
+ `; + }).join(''); + + // Wire up gear buttons + gridEl.querySelectorAll('.watchlist-card-gear').forEach(button => { + button.addEventListener('click', () => { + openWatchlistArtistConfigModal(button.getAttribute('data-artist-id'), button.getAttribute('data-artist-name')); + }); + }); + + // Wire up artist card clicks + gridEl.querySelectorAll('.watchlist-artist-card').forEach(item => { + item.addEventListener('click', (e) => { + if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) return; + const artistId = item.getAttribute('data-artist-id'); + const artistName = item.querySelector('.watchlist-card-name').textContent; + openWatchlistArtistDetailView(artistId, artistName); + }); + }); + } + + // Scan status + const scanStatusEl = document.getElementById('watchlist-scan-status'); + const liveActivityEl = document.getElementById('watchlist-live-activity'); + const scanBtn = document.getElementById('scan-watchlist-btn'); + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + + if (scanStatus === 'scanning') { + if (scanStatusEl) scanStatusEl.style.display = ''; + if (liveActivityEl) liveActivityEl.style.display = 'flex'; + if (scanBtn) { scanBtn.disabled = true; scanBtn.classList.add('btn-processing'); scanBtn.innerHTML = ' Scanning...'; } + if (cancelBtn) cancelBtn.style.display = ''; + pollWatchlistScanStatus(); + } else { + if (scanStatusEl && statusRes.summary) { + scanStatusEl.style.display = ''; + const summaryEl = document.getElementById('watchlist-page-scan-summary'); + if (summaryEl) { + summaryEl.style.display = ''; + summaryEl.innerHTML = `Artists: ${statusRes.summary.total_artists || 0}New tracks: ${statusRes.summary.new_tracks_found || 0}Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0}`; + } + } + } + + // Start countdown timer + const nextRunSeconds = countRes.next_run_in_seconds || 0; + startWatchlistCountdownTimer(nextRunSeconds); + + watchlistPageState.isInitialized = true; + + } catch (error) { + console.error('Error initializing watchlist page:', error); + showToast('Failed to load watchlist', 'error'); + } +} + +/** + * Initialize/refresh the wishlist sidebar page + */ +async function initializeWishlistPage() { + try { + const emptyEl = document.getElementById('wishlist-page-empty'); + const nebulaEl = document.getElementById('wishlist-nebula'); + const countEl = document.getElementById('wishlist-page-count'); + const tracksSection = document.getElementById('wishlist-category-tracks'); + const statsStrip = document.getElementById('wishlist-stats-strip'); + + const [statsRes, cycleRes, albumRes, singleRes, watchlistRes] = await Promise.all([ + fetch('/api/wishlist/stats').then(r => r.json()), + fetch('/api/wishlist/cycle').then(r => r.json()), + fetch('/api/wishlist/tracks?category=albums').then(r => r.json()), + fetch('/api/wishlist/tracks?category=singles').then(r => r.json()), + fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })), + ]); + + // Build artist name → image URL map from watchlist + const _artistImageMap = new Map(); + if (watchlistRes.success && watchlistRes.artists) { + for (const wa of watchlistRes.artists) { + if (wa.artist_name && wa.image_url) _artistImageMap.set(wa.artist_name.toLowerCase(), wa.image_url); + } + } + + const { singles = 0, albums = 0, total = 0 } = statsRes; + const currentCycle = cycleRes.cycle || 'albums'; + + if (countEl) countEl.textContent = `${total} track${total !== 1 ? 's' : ''}`; + const navBadge = document.getElementById('wishlist-nav-badge'); + if (navBadge) { navBadge.textContent = total; navBadge.classList.toggle('hidden', total === 0); } + + const statAlbums = document.getElementById('wishlist-stat-albums'); + const statSingles = document.getElementById('wishlist-stat-singles'); + const statCycle = document.getElementById('wishlist-stat-cycle'); + if (statAlbums) statAlbums.textContent = albums; + if (statSingles) statSingles.textContent = singles; + if (statCycle) statCycle.textContent = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; + + if (total === 0) { + if (emptyEl) emptyEl.style.display = ''; + if (nebulaEl) nebulaEl.style.display = 'none'; + if (tracksSection) tracksSection.style.display = 'none'; + if (statsStrip) statsStrip.style.display = 'none'; + wishlistPageState.isInitialized = true; + return; + } + if (emptyEl) emptyEl.style.display = 'none'; + if (nebulaEl) nebulaEl.style.display = ''; + if (tracksSection) tracksSection.style.display = 'none'; + if (statsStrip) statsStrip.style.display = ''; + + _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], _artistImageMap, currentCycle); + startWishlistCountdownTimer(currentCycle, statsRes.next_run_in_seconds || 0); + + // Live processing: check if wishlist download is active and start polling + _startNebulaLivePolling(currentCycle, _artistImageMap); + + wishlistPageState.isInitialized = true; + + } catch (error) { + console.error('Error initializing wishlist page:', error); + showToast('Failed to load wishlist', 'error'); + } +} + +/* ═══════════════════════════════════════════════════════════════════ + WISHLIST NEBULA — Artist orbs with album/single satellites + ═══════════════════════════════════════════════════════════════════ */ + +function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, currentCycle) { + const field = document.getElementById('wl-nebula-field'); + if (!field) return; + artistImageMap = artistImageMap || new Map(); + + const artistMap = new Map(); + function _parse(track, type) { + let sd = track.spotify_data; + if (typeof sd === 'string') { try { sd = JSON.parse(sd); } catch (e) { return null; } } + if (!sd) return null; + const raw = sd.album; + const albumName = (typeof raw === 'string' ? raw : raw?.name) || 'Unknown'; + const albumImage = (typeof raw === 'object' && raw?.images?.[0]?.url) || ''; + let artist = 'Unknown Artist'; + if (sd.artists?.[0]?.name) artist = sd.artists[0].name; + else if (typeof sd.artists?.[0] === 'string') artist = sd.artists[0]; + return { track: sd.name || 'Unknown', artist, album: albumName, image: albumImage, type, id: track.spotify_track_id || track.id || '' }; + } + + for (const t of albumTracks) { const p = _parse(t, 'album'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); const a = artistMap.get(p.artist); if (!a.albums.has(p.album)) a.albums.set(p.album, { image: p.image, tracks: [] }); a.albums.get(p.album).tracks.push(p); } } + for (const t of singleTracks) { const p = _parse(t, 'single'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); artistMap.get(p.artist).singles.push(p); } } + + if (artistMap.size === 0) { field.innerHTML = '
Your wishlist is empty
'; return; } + + const sorted = [...artistMap.entries()].sort((a, b) => { + const ac = [...a[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + a[1].singles.length; + const bc = [...b[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + b[1].singles.length; + return bc - ac; + }); + + function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; } + + let html = ''; + sorted.forEach(([name, data], idx) => { + const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length; + const hasAlbums = data.albums.size > 0; + const hue = _hue(name); + const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm'; + + // Enhancement 1: prefer watchlist artist photo over album cover + let img = artistImageMap.get(name.toLowerCase()) || ''; + if (!img) { for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } } + if (!img && data.singles.length) img = data.singles[0].image || ''; + + // Enhancement 3: pulse if this artist has albums and current cycle is albums + const pulseClass = (hasAlbums && currentCycle === 'albums') ? ' orb-pulse' : ''; + + // Enhancement 7: staggered entry animation + const delay = Math.min(idx * 60, 800); + + html += `
`; + + // Enhancement 2: hover tooltip + html += `
${escapeHtml(name)}
${total} track${total !== 1 ? 's' : ''}
`; + + html += `
`; + html += `
`; + html += img ? `` : `
${escapeHtml(name.substring(0, 2).toUpperCase())}
`; + html += `
`; + + // Enhancement 5: album art ring (show up to 6 album covers around the orb) + const ringCovers = []; + for (const [, ad] of data.albums) { if (ad.image && ringCovers.length < 6) ringCovers.push(ad.image); } + for (const s of data.singles) { if (s.image && ringCovers.length < 6) ringCovers.push(s.image); } + if (ringCovers.length >= 3) { + html += `
`; + ringCovers.forEach((url, i) => { + const angle = (360 / ringCovers.length) * i; + html += ``; + }); + html += `
`; + } + + html += `
`; // /orb + + // Enhancement 8: clickable artist name → navigate to artist detail + html += `
${escapeHtml(name)}
`; + html += `
${total} track${total !== 1 ? 's' : ''}
`; + + // Expanded content + html += `
`; + if (data.albums.size > 0) { + html += `
`; + for (const [an, ad] of data.albums) { + const tileId = 'wl-tile-' + an.replace(/\W/g, '_') + '_' + idx; + html += `
`; + html += `
${ad.image ? `` : `
💿
`}
`; + html += `
${escapeHtml(an)}
${ad.tracks.length} track${ad.tracks.length !== 1 ? 's' : ''}
`; + html += `${ad.tracks.length}`; + html += ``; + // Track list (hidden until tile clicked) + html += `
`; + for (const tr of ad.tracks) { + html += `
`; + html += `${escapeHtml(tr.track)}`; + html += ``; + html += `
`; + } + html += `
`; + html += `
`; + } + html += `
`; + } + if (data.singles.length > 0) { + html += `
`; + for (const s of data.singles) { + html += `
`; + html += s.image ? `` : ``; + html += `
${escapeHtml(s.track)}
`; + html += ``; + html += `
`; + } + html += `
`; + } + html += `
`; // /expanded, /group + }); + + field.innerHTML = html; +} + +// Enhancement 8: navigate to the Search page pre-filled with this artist's name +function _navigateToArtistFromWishlist(artistName) { + navigateToPage('search'); + setTimeout(() => { + const searchInput = document.getElementById('enhanced-search-input'); + if (searchInput) { + searchInput.value = artistName; + searchInput.dispatchEvent(new Event('input')); + searchInput.focus(); + } + }, 300); +} + +function _toggleAlbumTile(tileEl) { + const wasExpanded = tileEl.classList.contains('tile-expanded'); + // Collapse all tiles in this group + tileEl.closest('.wl-album-fan')?.querySelectorAll('.wl-album-tile.tile-expanded').forEach(t => t.classList.remove('tile-expanded')); + if (!wasExpanded) tileEl.classList.add('tile-expanded'); +} + +function _toggleOrbExpand(el) { + const g = el.closest('.wl-orb-group'); + if (!g) return; + const was = g.classList.contains('expanded'); + document.querySelectorAll('.wl-orb-group.expanded').forEach(o => o.classList.remove('expanded')); + if (!was) g.classList.add('expanded'); +} + +async function _removeWishlistAlbum(albumName) { + if (!await showConfirmDialog({ title: 'Remove Album', message: `Remove all tracks from "${albumName}"?`, confirmText: 'Remove', destructive: true })) return; + try { + const res = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_name: albumName }) }); + const data = await res.json(); + if (data.success) { showToast(`Removed "${albumName}"`, 'success'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); } + else showToast(data.error || 'Failed', 'error'); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +async function _removeWishlistTrack(trackId) { + try { + const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) }); + const data = await res.json(); + if (data.success) { + showToast('Removed', 'success'); + await updateWishlistCount(); + // Re-render nebula to reflect removal + wishlistPageState.isInitialized = false; + await initializeWishlistPage(); + } + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +function _filterNebula() { + const q = (document.getElementById('wl-nebula-search')?.value || '').toLowerCase().trim(); + document.querySelectorAll('.wl-orb-group').forEach(g => { + const a = (g.dataset.artist || '').toLowerCase(); + const albums = [...g.querySelectorAll('.wl-satellite')].map(s => (s.dataset.album || '').toLowerCase()); + const match = !q || a.includes(q) || albums.some(al => al.includes(q)); + g.style.display = match ? '' : 'none'; + if (!match) g.classList.remove('expanded'); + }); +} + +async function _nebulaDownload() { + // Check if wishlist is already processing + try { + const statsResp = await fetch('/api/wishlist/stats'); + if (statsResp.ok) { + const stats = await statsResp.json(); + if (stats.is_auto_processing) { + // Navigate to downloads page so the user can see progress + navigateToPage('active-downloads'); + showToast('Wishlist is currently being auto-processed', 'info'); + return; + } + } + const procResp = await fetch('/api/active-processes'); + if (procResp.ok) { + const procData = await procResp.json(); + const wishlistBatch = (procData.active_processes || []).find(p => p.playlist_id === 'wishlist'); + if (wishlistBatch) { + // Show the existing download modal + WishlistModalState.clearUserClosed(); + const clientProcess = activeDownloadProcesses['wishlist']; + if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { + clientProcess.modalElement.style.display = 'flex'; + WishlistModalState.setVisible(); + } else { + await rehydrateModal(wishlistBatch, true); + } + return; + } + } + } catch (e) {} + + // No active process — show category choice + const choice = await _showNebulaDownloadChoice(); + if (choice) await openDownloadMissingWishlistModal(choice); +} + +function _showNebulaDownloadChoice() { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + + const albumCount = document.getElementById('wishlist-stat-albums')?.textContent || '0'; + const singleCount = document.getElementById('wishlist-stat-singles')?.textContent || '0'; + + overlay.innerHTML = ` +
+
+ +
+

Download Wishlist

+

Choose which category to process

+
+ + + +
+
+ `; + + overlay.querySelector('#ndc-albums').onclick = () => { overlay.remove(); resolve('albums'); }; + overlay.querySelector('#ndc-singles').onclick = () => { overlay.remove(); resolve('singles'); }; + overlay.querySelector('#ndc-cancel').onclick = () => { overlay.remove(); resolve(null); }; + + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); } + }); + + document.body.appendChild(overlay); + }); +} + +function _nebulaBack() { + const t = document.getElementById('wishlist-category-tracks'); + const n = document.getElementById('wishlist-nebula'); + if (t) t.style.display = 'none'; + if (n) n.style.display = ''; + window.selectedWishlistCategory = null; + wishlistPageState.isInitialized = false; + initializeWishlistPage(); +} + +// ── Live processing state for nebula ── +let _nebulaLivePollInterval = null; +let _nebulaLastTotal = null; + +function _startNebulaLivePolling(currentCycle, artistImageMap) { + _stopNebulaLivePolling(); + _nebulaLastTotal = null; + + _nebulaLivePollInterval = setInterval(async () => { + if (currentPage !== 'wishlist') { _stopNebulaLivePolling(); return; } + + try { + // Use wishlist stats which has is_auto_processing flag + const statsResp = await fetch('/api/wishlist/stats'); + if (!statsResp.ok) return; + const stats = await statsResp.json(); + const isProcessing = stats.is_auto_processing || false; + const newTotal = stats.total || 0; + + // Also check for manual wishlist download batches + let hasBatch = false; + try { + const procResp = await fetch('/api/active-processes'); + if (procResp.ok) { + const procData = await procResp.json(); + hasBatch = (procData.active_processes || []).some(p => p.playlist_id === 'wishlist'); + } + } catch (e) {} + + const active = isProcessing || hasBatch; + const nebulaField = document.getElementById('wl-nebula-field'); + if (!nebulaField) return; + + if (active) { + nebulaField.classList.add('nebula-processing'); + document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing')); + + // Tracks completed — re-render + if (_nebulaLastTotal !== null && newTotal < _nebulaLastTotal) { + const [albumRes, singleRes] = await Promise.all([ + fetch('/api/wishlist/tracks?category=albums').then(r => r.json()), + fetch('/api/wishlist/tracks?category=singles').then(r => r.json()), + ]); + _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], artistImageMap, currentCycle); + + const countEl = document.getElementById('wishlist-page-count'); + if (countEl) countEl.textContent = `${newTotal} track${newTotal !== 1 ? 's' : ''}`; + const sa = document.getElementById('wishlist-stat-albums'); + const ss = document.getElementById('wishlist-stat-singles'); + if (sa) sa.textContent = stats.albums || 0; + if (ss) ss.textContent = stats.singles || 0; + + // Re-add processing classes after re-render + document.getElementById('wl-nebula-field')?.classList.add('nebula-processing'); + document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing')); + } + _nebulaLastTotal = newTotal; + } else { + nebulaField.classList.remove('nebula-processing'); + document.querySelectorAll('.wl-orb-group.orb-processing').forEach(g => g.classList.remove('orb-processing')); + + if (_nebulaLastTotal !== null) { + _nebulaLastTotal = null; + wishlistPageState.isInitialized = false; + await initializeWishlistPage(); + await updateWishlistCount(); + } + } + } catch (e) {} + }, 5000); +} + +function _stopNebulaLivePolling() { + if (_nebulaLivePollInterval) { + clearInterval(_nebulaLivePollInterval); + _nebulaLivePollInterval = null; + } + _nebulaLastTotal = null; +} + +/** + * Sort the watchlist artist grid by the selected criteria. + */ +function sortWatchlistArtists(sortBy) { + const grid = document.getElementById('watchlist-artists-list'); + if (!grid) return; + const cards = Array.from(grid.querySelectorAll('.watchlist-artist-card')); + if (cards.length === 0) return; + + cards.sort((a, b) => { + switch (sortBy) { + case 'name-asc': + return (a.dataset.artistName || '').localeCompare(b.dataset.artistName || ''); + case 'name-desc': + return (b.dataset.artistName || '').localeCompare(a.dataset.artistName || ''); + case 'scan-oldest': { + const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0; + const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0; + return aTime - bTime; // oldest first (never scanned = 0 = top) + } + case 'scan-newest': { + const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0; + const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0; + return bTime - aTime; + } + case 'added-newest': { + const aTime = a.dataset.added ? new Date(a.dataset.added).getTime() : 0; + const bTime = b.dataset.added ? new Date(b.dataset.added).getTime() : 0; + return bTime - aTime; + } + default: + return 0; + } + }); + + // Re-append in sorted order (preserves event listeners) + cards.forEach(card => grid.appendChild(card)); +} + +/** + * Filter wishlist tracks by search query within the active track list. + */ +function filterWishlistTracks() { + const input = document.getElementById('wishlist-track-search-input'); + if (!input) return; + const query = input.value.toLowerCase().trim(); + const tracksList = document.getElementById('wishlist-tracks-list'); + if (!tracksList) return; + + // For albums view: filter album cards by album name or track names within + const albumCards = tracksList.querySelectorAll('.wishlist-album-card'); + if (albumCards.length > 0) { + albumCards.forEach(card => { + const albumHeader = card.querySelector('.wishlist-album-header'); + const albumName = (albumHeader?.querySelector('.wishlist-album-name')?.textContent || '').toLowerCase(); + const artistName = (albumHeader?.querySelector('.wishlist-album-artist')?.textContent || '').toLowerCase(); + const tracks = card.querySelectorAll('.wishlist-album-track'); + let albumHasMatch = !query || albumName.includes(query) || artistName.includes(query); + + // Also check individual track names + if (!albumHasMatch && tracks.length > 0) { + tracks.forEach(track => { + const trackName = (track.textContent || '').toLowerCase(); + if (trackName.includes(query)) albumHasMatch = true; + }); + } + + card.style.display = albumHasMatch ? '' : 'none'; + }); + return; + } + + // For singles view: filter individual track rows + const trackRows = tracksList.querySelectorAll('.playlist-track-item-with-image, .playlist-track-item'); + trackRows.forEach(row => { + const text = (row.textContent || '').toLowerCase(); + row.style.display = (!query || text.includes(query)) ? '' : 'none'; + }); +} + +/** + * Format a Date object as a relative time string (e.g. "2 hours ago") + */ +function _formatTimeAgo(date) { + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +/** + * Show watchlist modal (legacy — kept for backward compatibility) + */ +async function showWatchlistModal() { + try { + // Check if watchlist has any artists + const countResponse = await fetch('/api/watchlist/count'); + const countData = await countResponse.json(); + + if (!countData.success) { + console.error('Error getting watchlist count:', countData.error); + return; + } + + if (countData.count === 0) { + // Show empty state message + alert('Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.'); + return; + } + + // Get watchlist artists + const artistsResponse = await fetch('/api/watchlist/artists'); + const artistsData = await artistsResponse.json(); + + if (!artistsData.success) { + console.error('Error getting watchlist artists:', artistsData.error); + return; + } + + // Create modal if it doesn't exist + let modal = document.getElementById('watchlist-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'watchlist-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + // Get scan status and global config + const statusResponse = await fetch('/api/watchlist/scan/status'); + const statusData = await statusResponse.json(); + const scanStatus = statusData.success ? statusData.status : 'idle'; + + let globalOverrideActive = false; + try { + const globalConfigResponse = await fetch('/api/watchlist/global-config'); + const globalConfigData = await globalConfigResponse.json(); + globalOverrideActive = globalConfigData.success && globalConfigData.config.global_override_enabled; + } catch (e) { + console.debug('Could not fetch global config:', e); + } + + // Format countdown timer + const nextRunSeconds = countData.next_run_in_seconds || 0; + const countdownText = formatCountdownTime(nextRunSeconds); + + // Build modal content + modal.innerHTML = ` + + `; + + // Add event listeners for gear buttons + modal.querySelectorAll('.watchlist-card-gear').forEach(button => { + button.addEventListener('click', () => { + const artistId = button.getAttribute('data-artist-id'); + const artistName = button.getAttribute('data-artist-name'); + openWatchlistArtistConfigModal(artistId, artistName); + }); + }); + + // Add click handlers to artist cards (except for gear button or checkbox) + modal.querySelectorAll('.watchlist-artist-card').forEach(item => { + item.addEventListener('click', (e) => { + if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) { + return; + } + + const artistId = item.getAttribute('data-artist-id'); + const artistName = item.querySelector('.watchlist-card-name').textContent; + + console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`); + openWatchlistArtistDetailView(artistId, artistName); + }); + }); + + // Show modal + modal.style.display = 'flex'; + + // Start countdown timer update interval + startWatchlistCountdownTimer(nextRunSeconds); + + // Start polling for scan status if scanning + if (scanStatus === 'scanning') { + pollWatchlistScanStatus(); + } + + } catch (error) { + console.error('Error showing watchlist modal:', error); + } +} + +function startWatchlistCountdownTimer(initialSeconds) { + // Clear any existing interval + if (watchlistCountdownInterval) { + clearInterval(watchlistCountdownInterval); + } + + let remainingSeconds = initialSeconds; + + watchlistCountdownInterval = setInterval(async () => { + remainingSeconds--; + + if (remainingSeconds <= 0) { + // Timer expired, fetch fresh data + try { + const response = await fetch('/api/watchlist/count'); + const data = await response.json(); + remainingSeconds = data.next_run_in_seconds || 0; + + const timerElement = document.getElementById('watchlist-next-auto-timer'); + if (timerElement) { + const countdownText = formatCountdownTime(remainingSeconds); + timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; + } + } catch (error) { + console.debug('Error updating watchlist countdown:', error); + } + } else { + // Update the display + const timerElement = document.getElementById('watchlist-next-auto-timer'); + if (timerElement) { + const countdownText = formatCountdownTime(remainingSeconds); + timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; + } + } + }, 1000); // Update every second +} + +/** + * Close watchlist modal + */ +function closeWatchlistModal() { + // Stop countdown timer + if (watchlistCountdownInterval) { + clearInterval(watchlistCountdownInterval); + watchlistCountdownInterval = null; + } + + const modal = document.getElementById('watchlist-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +/** + * Populate the linked provider section in the watchlist config modal. + * Shows which Spotify/iTunes/Deezer artist is linked and allows changing it. + */ +function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId) { + const section = document.getElementById('watchlist-linked-provider-section'); + const content = document.getElementById('watchlist-linked-provider-content'); + if (!section || !content) return; + + section.style.display = ''; + + const sources = [ + { key: 'spotify', label: 'Spotify', icon: '🟢', id: spotifyId || '', color: '#1db954' }, + { key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' }, + { key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' }, + { key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' }, + ]; + + let html = '
'; + for (const src of sources) { + const matched = !!src.id; + const shortId = src.id ? (src.id.length > 16 ? src.id.substring(0, 14) + '...' : src.id) : ''; + html += ` +
+ ${src.icon} + ${src.label} + ${matched + ? `${shortId}` + : 'Not matched' + } + + ${matched ? `` : ''} +
`; + } + html += '
'; + + // Per-source search panel (hidden, populated on Fix/Match click) + html += ``; + + content.innerHTML = html; +} + +/** + * Open per-source search panel for fixing a specific provider match. + */ +function _openSourceSearch(sourceKey, artistId, artistName) { + const panel = document.getElementById('wl-linked-search-panel'); + if (!panel) return; + const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs' }; + document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`; + const input = document.getElementById('wl-linked-search-input'); + input.value = artistName; + document.getElementById('wl-linked-search-results').innerHTML = ''; + panel.style.display = ''; + panel.dataset.source = sourceKey; + panel.dataset.artistId = artistId; + panel.dataset.artistName = artistName; + input.focus(); + input.select(); + + const doSearch = () => _searchSourceArtists(sourceKey, artistId); + document.getElementById('wl-linked-search-go').onclick = doSearch; + input.onkeydown = (e) => { if (e.key === 'Enter') doSearch(); }; +} + +async function _searchSourceArtists(sourceKey, watchlistArtistId) { + const input = document.getElementById('wl-linked-search-input'); + const container = document.getElementById('wl-linked-search-results'); + const query = input?.value?.trim(); + if (!query || !container) return; + + container.innerHTML = '
Searching...
'; + + try { + const response = await fetch('/api/library/search-service', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: sourceKey, entity_type: 'artist', query }) + }); + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + const results = data.results || []; + if (!results.length) { + container.innerHTML = '
No artists found
'; + return; + } + + let html = ''; + for (const r of results) { + html += `
+ ${r.image ? `` : + `
🎵
`} +
+
${escapeHtml(r.name)}
+
${escapeHtml(r.extra || '')}
+
+ +
`; + } + container.innerHTML = html; + + container.querySelectorAll('.watchlist-linked-search-result').forEach(el => { + el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => { + e.stopPropagation(); + await _linkSourceArtist(sourceKey, watchlistArtistId, el.dataset.id, el.dataset.name); + }; + }); + } catch (err) { + console.error(`Error searching ${sourceKey}:`, err); + container.innerHTML = '
Search error
'; + } +} + +async function _linkSourceArtist(sourceKey, watchlistArtistId, newId, newName) { + try { + const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider_id: newId, provider: sourceKey }) + }); + const data = await response.json(); + if (!data.success) { + showToast(`Failed to link: ${data.error}`, 'error'); + return; + } + showToast(`Linked to "${newName}" on ${sourceKey}`, 'success'); + // Refresh the modal + const panel = document.getElementById('wl-linked-search-panel'); + const artistName = panel?.dataset?.artistName || newName; + closeWatchlistArtistConfigModal(); + setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300); + } catch (err) { + showToast('Failed to link artist', 'error'); + } +} + +async function _clearSourceMatch(sourceKey, watchlistArtistId, artistName) { + try { + const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider_id: '', provider: sourceKey }) + }); + const data = await response.json(); + if (!data.success) { + showToast(`Failed to clear: ${data.error}`, 'error'); + return; + } + showToast(`Cleared ${sourceKey} match`, 'success'); + closeWatchlistArtistConfigModal(); + setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300); + } catch (err) { + showToast('Failed to clear match', 'error'); + } +} + +/** + * Open watchlist artist configuration modal + * @param {string} artistId - Spotify artist ID + * @param {string} artistName - Artist name + */ +async function openWatchlistArtistConfigModal(artistId, artistName) { + try { + console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`); + + // Fetch artist config and info + const response = await fetch(`/api/watchlist/artist/${artistId}/config`); + const data = await response.json(); + + if (!data.success) { + console.error('Error loading artist config:', data.error); + showToast(`Error loading artist configuration: ${data.error}`, 'error'); + return; + } + + const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, watchlist_name } = data; + + // Populate linked provider section (use DB watchlist_name for mismatch comparison) + _populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id); + + // Check if global override is active + let globalOverrideActive = false; + try { + const globalResponse = await fetch('/api/watchlist/global-config'); + const globalData = await globalResponse.json(); + globalOverrideActive = globalData.success && globalData.config.global_override_enabled; + } catch (e) { + console.debug('Could not check global config:', e); + } + + // Generate hero section + const heroHTML = ` + ${artist.image_url ? ` + ${escapeHtml(artist.name)} + ` : ''} +
+

${escapeHtml(artist.name)}

+
+
+ ${formatNumber(artist.followers)} + Followers +
+
+ ${artist.popularity}/100 + Popularity +
+
+ ${artist.genres && artist.genres.length > 0 ? ` +
+ ${artist.genres.slice(0, 3).map(genre => + `${escapeHtml(genre)}` + ).join('')} +
+ ` : ''} +
+ `; + + // Populate hero section + const heroContainer = document.getElementById('watchlist-artist-config-hero'); + if (heroContainer) { + heroContainer.innerHTML = heroHTML; + } + + // Set checkbox states + document.getElementById('config-include-albums').checked = config.include_albums; + document.getElementById('config-include-eps').checked = config.include_eps; + document.getElementById('config-include-singles').checked = config.include_singles; + document.getElementById('config-include-live').checked = config.include_live || false; + document.getElementById('config-include-remixes').checked = config.include_remixes || false; + document.getElementById('config-include-acoustic').checked = config.include_acoustic || false; + document.getElementById('config-include-compilations').checked = config.include_compilations || false; + document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false; + document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : ''; + + // Populate metadata source selector + const sourceSelector = document.getElementById('config-metadata-source-selector'); + if (sourceSelector) { + const sources = [ + { key: 'spotify', label: 'Spotify', id: spotify_artist_id, color: '#1DB954' }, + { key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' }, + { key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' }, + { key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' }, + ]; + const globalSource = data.global_metadata_source || 'deezer'; + const currentOverride = config.preferred_metadata_source; + const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs' }[globalSource] || globalSource; + + let html = ``; + for (const src of sources) { + if (!src.id) continue; + const isActive = currentOverride === src.key; + html += ``; + } + sourceSelector.innerHTML = html; + sourceSelector.querySelectorAll('.config-msrc-btn').forEach(btn => { + btn.addEventListener('click', () => { + sourceSelector.querySelectorAll('.config-msrc-btn').forEach(b => { + b.classList.remove('active'); + b.style.borderColor = ''; + }); + btn.classList.add('active'); + const color = sources.find(s => s.key === btn.dataset.source)?.color; + if (color) btn.style.borderColor = color; + }); + }); + } + + // Show global override notice if active + const existingNotice = document.querySelector('.global-override-notice'); + if (existingNotice) existingNotice.remove(); + + if (globalOverrideActive) { + const notice = document.createElement('div'); + notice.className = 'global-override-notice watchlist-global-override-banner'; + notice.innerHTML = '⚠️Global override is active — these per-artist settings are currently ignored during scans.'; + const configBody = document.querySelector('.watchlist-artist-config-body'); + if (configBody) configBody.insertBefore(notice, configBody.firstChild); + } + + // Store artist ID for saving + const modal = document.getElementById('watchlist-artist-config-modal'); + if (modal) { + modal.setAttribute('data-artist-id', artistId); + } + + // Show modal + const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); + if (overlay) { + overlay.classList.remove('hidden'); + } + + // Add save button handler + const saveBtn = document.getElementById('save-artist-config-btn'); + if (saveBtn) { + // Remove old listeners + const newSaveBtn = saveBtn.cloneNode(true); + saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); + + // Add new listener + newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId)); + } + + } catch (error) { + console.error('Error opening watchlist artist config modal:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +/** + * Close watchlist artist configuration modal + */ +function closeWatchlistArtistConfigModal() { + const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + } + + // Clear hero content + const heroContainer = document.getElementById('watchlist-artist-config-hero'); + if (heroContainer) { + heroContainer.innerHTML = ''; + } + + // Clear linked provider section + const linkedContent = document.getElementById('watchlist-linked-provider-content'); + if (linkedContent) linkedContent.innerHTML = ''; + const linkedSection = document.getElementById('watchlist-linked-provider-section'); + if (linkedSection) linkedSection.style.display = 'none'; +} + +/** + * Open watchlist artist detail view (slides in from right) + */ +async function openWatchlistArtistDetailView(artistId, artistName) { + try { + const response = await fetch(`/api/watchlist/artist/${artistId}/config`); + const data = await response.json(); + + if (!data.success) { + showToast(`Error loading artist info: ${data.error}`, 'error'); + return; + } + + const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id } = data; + + // Remove existing overlay if any + const existing = document.querySelector('.watchlist-artist-detail-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'watchlist-artist-detail-overlay'; + + // Build pills + const pills = []; + if (config.include_albums) pills.push('Albums'); + if (config.include_eps) pills.push('EPs'); + if (config.include_singles) pills.push('Singles'); + if (config.include_live) pills.push('Live'); + if (config.include_remixes) pills.push('Remixes'); + if (config.include_acoustic) pills.push('Acoustic'); + if (config.include_compilations) pills.push('Compilations'); + + // Build scan info + const scanTimeText = config.last_scan_timestamp ? formatRelativeScanTime(config.last_scan_timestamp) : 'Never scanned'; + const dateAddedText = config.date_added ? `Added ${new Date(config.date_added).toLocaleDateString()}` : ''; + + // Build metadata tags (style, mood, label) + const metaTags = []; + if (artist.style) metaTags.push(`${escapeHtml(artist.style)}`); + if (artist.mood) metaTags.push(`${escapeHtml(artist.mood)}`); + if (artist.label) metaTags.push(`${escapeHtml(artist.label)}`); + + overlay.innerHTML = ` + ${artist.banner_url ? ` +
+ +
+
+ ` : ''} + +
+ + +
+ ${artist.image_url ? `${escapeHtml(artist.name)}` : ''} +
+

${escapeHtml(artist.name)}

+ ${artist.followers || artist.popularity ? ` +
+ ${artist.followers ? ` +
+ ${formatNumber(artist.followers)} + Followers +
` : ''} + ${artist.popularity ? ` +
+ ${artist.popularity}/100 + Popularity +
` : ''} +
+ ` : ''} + ${artist.genres && artist.genres.length > 0 ? ` +
+ ${artist.genres.map(g => `${escapeHtml(g)}`).join('')} +
+ ` : ''} +
+
+ + ${artist.summary ? ` +
+
About
+

${escapeHtml(artist.summary)}

+
+ ` : ''} + + ${metaTags.length > 0 ? ` +
+
Info
+
${metaTags.join('')}
+
+ ` : ''} + + ${recent_releases && recent_releases.length > 0 ? ` +
+
Recent Releases
+
+ ${recent_releases.map(r => ` +
+ ${r.album_cover_url ? `` : ''} +
+ ${escapeHtml(r.album_name)} + ${r.release_date}${r.track_count ? ` · ${r.track_count} tracks` : ''} +
+
+ `).join('')} +
+
+ ` : ''} + +
+
Watchlist
+
+ ${scanTimeText} + ${dateAddedText ? `·${dateAddedText}` : ''} +
+
+ ${pills.length > 0 ? pills.join('') : 'No release types enabled'} +
+
+ +
+ + + +
+
+ `; + + // Wire up event listeners (avoids inline onclick escaping issues) + overlay.querySelector('.watchlist-detail-back-btn').addEventListener('click', () => { + closeWatchlistArtistDetailView(); + }); + + overlay.querySelector('.watchlist-detail-discog-action').addEventListener('click', () => { + // Use the ID matching the active metadata source + let discogId, source; + const activeSrc = (currentMusicSourceName || '').toLowerCase(); + if (activeSrc.includes('spotify') && spotify_artist_id) { + discogId = spotify_artist_id; source = 'spotify'; + } else if (activeSrc.includes('discogs') && discogs_artist_id) { + discogId = discogs_artist_id; source = 'discogs'; + } else if (activeSrc.includes('deezer') && deezer_artist_id) { + discogId = deezer_artist_id; source = 'deezer'; + } else if (itunes_artist_id) { + discogId = itunes_artist_id; source = 'itunes'; + } else { + discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || itunes_artist_id; + source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; + } + if (discogId) { + closeWatchlistArtistDetailView(); + navigateToArtistDetail(discogId, artistName, source); + } + }); + + overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => { + // Remove overlay immediately so it doesn't block the config modal + const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay'); + if (detailOverlay) detailOverlay.remove(); + openWatchlistArtistConfigModal(artistId, artistName); + }); + + overlay.querySelector('.watchlist-detail-remove-action').addEventListener('click', () => { + removeFromWatchlistModal(artistId, artistName); + }); + + // Append to body as a fixed overlay + document.body.appendChild(overlay); + // Trigger slide-in animation + requestAnimationFrame(() => overlay.classList.add('visible')); + + } catch (error) { + console.error('Error opening artist detail view:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +/** + * Close watchlist artist detail view (slides out) + */ +function closeWatchlistArtistDetailView() { + const overlay = document.querySelector('.watchlist-artist-detail-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + overlay.addEventListener('transitionend', () => overlay.remove(), { once: true }); + } +} + +/** + * Open global watchlist settings modal + */ +async function openWatchlistGlobalSettingsModal() { + try { + const response = await fetch('/api/watchlist/global-config'); + const data = await response.json(); + + if (!data.success) { + showToast(`Error loading global settings: ${data.error}`, 'error'); + return; + } + + const config = data.config; + + // Populate checkboxes + document.getElementById('global-override-enabled').checked = config.global_override_enabled; + document.getElementById('global-include-albums').checked = config.include_albums; + document.getElementById('global-include-eps').checked = config.include_eps; + document.getElementById('global-include-singles').checked = config.include_singles; + document.getElementById('global-include-live').checked = config.include_live; + document.getElementById('global-include-remixes').checked = config.include_remixes; + document.getElementById('global-include-acoustic').checked = config.include_acoustic; + document.getElementById('global-include-compilations').checked = config.include_compilations; + document.getElementById('global-include-instrumentals').checked = config.include_instrumentals; + document.getElementById('global-exclude-terms').value = config.exclude_terms || ''; + + // Sync "Include Everything" checkbox + syncGlobalIncludeAllCheckbox(); + + // Update options visibility based on toggle state + toggleGlobalOverrideOptions(); + + // Update toggle label border + const toggleLabel = document.getElementById('global-override-toggle-label'); + if (toggleLabel) { + toggleLabel.style.border = config.global_override_enabled + ? '2px solid rgba(29, 185, 84, 0.5)' + : '2px solid rgba(255, 255, 255, 0.1)'; + } + + // Show modal + const overlay = document.getElementById('watchlist-global-config-modal-overlay'); + if (overlay) overlay.classList.remove('hidden'); + + } catch (error) { + console.error('Error opening global watchlist settings:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +/** + * Close global watchlist settings modal + */ +function closeWatchlistGlobalSettingsModal() { + const overlay = document.getElementById('watchlist-global-config-modal-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +/** + * Toggle global override options visibility + */ +function toggleGlobalOverrideOptions() { + const enabled = document.getElementById('global-override-enabled').checked; + const options = document.getElementById('global-override-options'); + if (options) { + options.style.opacity = enabled ? '1' : '0.4'; + options.style.pointerEvents = enabled ? 'auto' : 'none'; + } + + // Update toggle label border + const toggleLabel = document.getElementById('global-override-toggle-label'); + if (toggleLabel) { + toggleLabel.style.border = enabled + ? '2px solid rgba(29, 185, 84, 0.5)' + : '2px solid rgba(255, 255, 255, 0.1)'; + } +} + +/** + * Toggle all global include checkboxes + */ +function toggleGlobalIncludeAll() { + const checked = document.getElementById('global-include-all').checked; + ['global-include-albums', 'global-include-eps', 'global-include-singles', + 'global-include-live', 'global-include-remixes', 'global-include-acoustic', + 'global-include-compilations', 'global-include-instrumentals'].forEach(id => { + const el = document.getElementById(id); + if (el) el.checked = checked; + }); +} + +/** + * Sync the "Include Everything" checkbox based on individual checkbox states + */ +function syncGlobalIncludeAllCheckbox() { + const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles', + 'global-include-live', 'global-include-remixes', 'global-include-acoustic', + 'global-include-compilations', 'global-include-instrumentals']; + const allChecked = allIds.every(id => { + const el = document.getElementById(id); + return el && el.checked; + }); + const includeAllEl = document.getElementById('global-include-all'); + if (includeAllEl) includeAllEl.checked = allChecked; +} + +/** + * Save global watchlist configuration + */ +async function saveWatchlistGlobalConfig() { + try { + const globalOverrideEnabled = document.getElementById('global-override-enabled').checked; + const includeAlbums = document.getElementById('global-include-albums').checked; + const includeEps = document.getElementById('global-include-eps').checked; + const includeSingles = document.getElementById('global-include-singles').checked; + const includeLive = document.getElementById('global-include-live').checked; + const includeRemixes = document.getElementById('global-include-remixes').checked; + const includeAcoustic = document.getElementById('global-include-acoustic').checked; + const includeCompilations = document.getElementById('global-include-compilations').checked; + const includeInstrumentals = document.getElementById('global-include-instrumentals').checked; + const excludeTerms = (document.getElementById('global-exclude-terms').value || '').trim(); + + if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) { + showToast('Please select at least one release type', 'error'); + return; + } + + const saveBtn = document.getElementById('save-global-config-btn'); + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + } + + const response = await fetch('/api/watchlist/global-config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + global_override_enabled: globalOverrideEnabled, + include_albums: includeAlbums, + include_eps: includeEps, + include_singles: includeSingles, + include_live: includeLive, + include_remixes: includeRemixes, + include_acoustic: includeAcoustic, + include_compilations: includeCompilations, + include_instrumentals: includeInstrumentals, + exclude_terms: excludeTerms, + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Global watchlist settings saved', 'success'); + closeWatchlistGlobalSettingsModal(); + + // Refresh the watchlist page to update the grid + if (currentPage === 'watchlist') { + watchlistPageState.isInitialized = false; + await initializeWatchlistPage(); + } + } else { + showToast(`Error: ${data.error}`, 'error'); + } + + } catch (error) { + console.error('Error saving global config:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + const saveBtn = document.getElementById('save-global-config-btn'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Global Settings'; + } + } +} + +/** + * Save watchlist artist configuration + * @param {string} artistId - Spotify artist ID + */ +async function saveWatchlistArtistConfig(artistId) { + try { + const includeAlbums = document.getElementById('config-include-albums').checked; + const includeEps = document.getElementById('config-include-eps').checked; + const includeSingles = document.getElementById('config-include-singles').checked; + const includeLive = document.getElementById('config-include-live').checked; + const includeRemixes = document.getElementById('config-include-remixes').checked; + const includeAcoustic = document.getElementById('config-include-acoustic').checked; + const includeCompilations = document.getElementById('config-include-compilations').checked; + const includeInstrumentals = document.getElementById('config-include-instrumentals').checked; + const lookbackDaysVal = document.getElementById('config-lookback-days').value; + const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null; + const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active'); + const preferredMetadataSource = activeSourceBtn ? (activeSourceBtn.dataset.source || null) : null; + + // Validate at least one release type is selected + if (!includeAlbums && !includeEps && !includeSingles) { + showToast('Please select at least one release type', 'error'); + return; + } + + // Disable save button + const saveBtn = document.getElementById('save-artist-config-btn'); + if (saveBtn) { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving...'; + } + + // Send update to backend + const response = await fetch(`/api/watchlist/artist/${artistId}/config`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + include_albums: includeAlbums, + include_eps: includeEps, + include_singles: includeSingles, + include_live: includeLive, + include_remixes: includeRemixes, + include_acoustic: includeAcoustic, + include_compilations: includeCompilations, + include_instrumentals: includeInstrumentals, + lookback_days: lookbackDays, + preferred_metadata_source: preferredMetadataSource, + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Artist preferences saved successfully', 'success'); + closeWatchlistArtistConfigModal(); + + // Refresh watchlist page if we're on it + if (currentPage === 'watchlist') { + watchlistPageState.isInitialized = false; + await initializeWatchlistPage(); + } + } else { + showToast(`Error saving preferences: ${data.error}`, 'error'); + } + + } catch (error) { + console.error('Error saving watchlist artist config:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + // Re-enable save button + const saveBtn = document.getElementById('save-artist-config-btn'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = 'Save Preferences'; + } + } +} + +/** + * Format large numbers with commas + * @param {number} num - Number to format + * @returns {string} Formatted number + */ +function formatNumber(num) { + if (!num) return '0'; + return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +/** + * Format last scan timestamp as relative time + */ +function formatRelativeScanTime(isoString) { + if (!isoString) return 'Never scanned'; + const diff = Date.now() - new Date(isoString).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 1) return 'Scanned just now'; + if (mins < 60) return `Scanned ${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `Scanned ${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `Scanned ${days}d ago`; + const months = Math.floor(days / 30); + return `Scanned ${months}mo ago`; +} + +/** + * Filter watchlist artists based on search input + */ +function filterWatchlistArtists() { + const searchInput = document.getElementById('watchlist-search-input'); + const artistsList = document.getElementById('watchlist-artists-list'); + + if (!searchInput || !artistsList) return; + + const searchTerm = searchInput.value.toLowerCase().trim(); + const artistItems = artistsList.querySelectorAll('.watchlist-artist-card'); + + artistItems.forEach(item => { + const artistName = item.getAttribute('data-artist-name'); + + if (!searchTerm || artistName.includes(searchTerm)) { + item.style.display = ''; + } else { + item.style.display = 'none'; + } + }); + + // Refresh batch bar in case visible selection changed + updateWatchlistBatchBar(); +} + +/** + * Start watchlist scan + */ +async function cancelWatchlistScan() { + try { + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + if (cancelBtn) { + cancelBtn.disabled = true; + cancelBtn.textContent = 'Cancelling...'; + } + + const response = await fetch('/api/watchlist/scan/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to cancel scan'); + } + + showToast('Cancel request sent — scan will stop after current artist', 'info'); + + } catch (error) { + console.error('Error cancelling watchlist scan:', error); + showToast(`Error cancelling scan: ${error.message}`, 'error'); + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + if (cancelBtn) { + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Scan'; + } + } +} + +async function startWatchlistScan() { + try { + const button = document.getElementById('scan-watchlist-btn'); + button.disabled = true; + button.textContent = 'Starting scan...'; + button.classList.add('btn-processing'); + + const response = await fetch('/api/watchlist/scan', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to start scan'); + } + + button.textContent = 'Scanning...'; + + // Show cancel button + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + if (cancelBtn) { + cancelBtn.style.display = ''; + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Scan'; + } + + // Show scan status + const statusDiv = document.getElementById('watchlist-scan-status'); + if (statusDiv) { + statusDiv.style.display = 'flex'; + } + + // Start polling for updates + pollWatchlistScanStatus(); + + } catch (error) { + console.error('Error starting watchlist scan:', error); + const button = document.getElementById('scan-watchlist-btn'); + button.disabled = false; + button.textContent = 'Scan for New Releases'; + button.classList.remove('btn-processing'); + alert(`Error starting scan: ${error.message}`); + } +} + +/** + * Poll watchlist scan status + */ +function handleWatchlistScanData(data) { + const button = document.getElementById('scan-watchlist-btn'); + const liveActivity = document.getElementById('watchlist-live-activity'); + + // Show/hide cancel button based on scan status + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + if (cancelBtn) { + cancelBtn.style.display = data.status === 'scanning' ? '' : 'none'; + } + + // Update live visual activity display + if (liveActivity && data.status === 'scanning') { + liveActivity.style.display = 'flex'; + + // Update artist image and name + const artistImg = document.getElementById('watchlist-artist-img'); + const artistName = document.getElementById('watchlist-artist-name'); + if (artistImg && data.current_artist_image_url) { + artistImg.src = data.current_artist_image_url; + artistImg.style.display = 'block'; + } + if (artistName) { + artistName.textContent = data.current_artist_name || 'Processing...'; + } + + // Update album image and name + const albumImg = document.getElementById('watchlist-album-img'); + const albumName = document.getElementById('watchlist-album-name'); + if (albumImg && data.current_album_image_url) { + albumImg.src = data.current_album_image_url; + albumImg.style.display = 'block'; + } else if (albumImg) { + albumImg.style.display = 'none'; + } + if (albumName) { + albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); + } + + // Update current track + const trackName = document.getElementById('watchlist-track-name'); + if (trackName) { + trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); + } + + // Update wishlist additions feed + const additionsFeed = document.getElementById('watchlist-additions-feed'); + if (additionsFeed) { + if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) { + additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => ` +
+ +
+
${item.track_name}
+
${item.artist_name}
+
+
+ `).join(''); + } else { + additionsFeed.innerHTML = '
No tracks added yet...
'; + } + } + } else if (liveActivity && data.status !== 'scanning') { + liveActivity.style.display = 'none'; + } + + if (data.status === 'completed') { + if (button) { + button.disabled = false; + button.textContent = 'Scan for New Releases'; + button.classList.remove('btn-processing'); + } + + // Hide live activity + if (liveActivity) { + liveActivity.style.display = 'none'; + } + + // Show completion message in status div + const statusDiv = document.getElementById('watchlist-scan-status'); + if (statusDiv && data.summary) { + const newTracks = data.summary.new_tracks_found || 0; + const addedTracks = data.summary.tracks_added_to_wishlist || 0; + const totalArtists = data.summary.total_artists || 0; + const successfulScans = data.summary.successful_scans || 0; + + let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`; + if (newTracks > 0) { + completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`; + if (addedTracks > 0) { + completionMessage += `, added ${addedTracks} to wishlist`; + } + } else { + completionMessage += ', no new tracks found'; + } + + // Update the scan status display with completion message and summary + statusDiv.innerHTML = ` +
+
${completionMessage}
+
+ Artists: ${totalArtists} + + New tracks: ${newTracks} + + Added to wishlist: ${addedTracks} +
+
+ `; + } + + // Update watchlist count + updateWatchlistButtonCount(); + + console.log('Watchlist scan completed:', data.summary); + + } else if (data.status === 'cancelled') { + if (button) { + button.disabled = false; + button.textContent = 'Scan for New Releases'; + button.classList.remove('btn-processing'); + } + + // Hide cancel button + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + if (cancelBtn) { + cancelBtn.style.display = 'none'; + cancelBtn.disabled = false; + cancelBtn.textContent = 'Cancel Scan'; + } + + // Hide live activity + if (liveActivity) { + liveActivity.style.display = 'none'; + } + + // Show cancellation message + const statusDiv = document.getElementById('watchlist-scan-status'); + if (statusDiv && data.summary) { + const scanned = data.summary.total_artists || 0; + const newTracks = data.summary.new_tracks_found || 0; + const addedTracks = data.summary.tracks_added_to_wishlist || 0; + + statusDiv.innerHTML = ` +
+
Scan cancelled after ${scanned} artist${scanned !== 1 ? 's' : ''}
+
+ Scanned: ${scanned} + + New tracks: ${newTracks} + + Added to wishlist: ${addedTracks} +
+
+ `; + } + + // Update watchlist count + updateWatchlistButtonCount(); + + showToast('Watchlist scan cancelled', 'info'); + console.log('Watchlist scan cancelled:', data.summary); + + } else if (data.status === 'error') { + if (button) { + button.disabled = false; + button.textContent = 'Scan for New Releases'; + button.classList.remove('btn-processing'); + } + + // Hide cancel button + const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); + if (cancelBtn) { + cancelBtn.style.display = 'none'; + } + + // Hide live activity + if (liveActivity) { + liveActivity.style.display = 'none'; + } + + console.error('Watchlist scan error:', data.error); + } +} + +async function pollWatchlistScanStatus() { + if (socketConnected) return; // Phase 5: WS handles scan updates + try { + const response = await fetch('/api/watchlist/scan/status'); + const data = await response.json(); + + if (data.success) { + handleWatchlistScanData(data); + if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { + return; // Stop polling + } + } + + // Continue polling if still scanning + if (data.success && data.status === 'scanning') { + setTimeout(pollWatchlistScanStatus, 2000); // Poll every 2 seconds + } + + } catch (error) { + console.error('Error polling watchlist scan status:', error); + } +} + +/** + * Update similar artists for discovery feature + */ +async function updateSimilarArtists() { + try { + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + button.disabled = true; + button.textContent = 'Updating...'; + button.classList.add('btn-processing'); + if (scanButton) scanButton.disabled = true; + + const response = await fetch('/api/watchlist/update-similar-artists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to update similar artists'); + } + + showToast('Updating similar artists in background...', 'success'); + + // Poll for completion + pollSimilarArtistsUpdate(); + + } catch (error) { + console.error('Error updating similar artists:', error); + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + button.disabled = false; + button.textContent = 'Update Similar Artists'; + button.classList.remove('btn-processing'); + if (scanButton) scanButton.disabled = false; + + showToast(`Error: ${error.message}`, 'error'); + } +} + +/** + * Poll similar artists update status + */ +async function pollSimilarArtistsUpdate() { + try { + const response = await fetch('/api/watchlist/similar-artists-status'); + const data = await response.json(); + + if (data.success) { + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + if (data.status === 'completed') { + if (button) { + button.disabled = false; + button.textContent = 'Update Similar Artists'; + button.classList.remove('btn-processing'); + } + if (scanButton) scanButton.disabled = false; + + showToast(`Updated similar artists for ${data.artists_processed || 0} artists!`, 'success'); + return; // Stop polling + + } else if (data.status === 'error') { + if (button) { + button.disabled = false; + button.textContent = 'Update Similar Artists'; + button.classList.remove('btn-processing'); + } + if (scanButton) scanButton.disabled = false; + + showToast('Error updating similar artists', 'error'); + return; // Stop polling + } else if (data.status === 'running') { + // Update button text with progress + if (button && data.current_artist) { + button.textContent = `Updating... (${data.artists_processed || 0}/${data.total_artists || 0})`; + } + } + } + + // Continue polling if still running + if (data.success && data.status === 'running') { + setTimeout(pollSimilarArtistsUpdate, 1000); // Poll every 1 second + } + + } catch (error) { + console.error('Error polling similar artists update:', error); + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + if (button) { + button.disabled = false; + button.textContent = 'Update Similar Artists'; + button.classList.remove('btn-processing'); + } + if (scanButton) scanButton.disabled = false; + } +} + +/** + * Remove artist from watchlist via modal + */ +async function removeFromWatchlistModal(artistId, artistName) { + try { + const response = await fetch('/api/watchlist/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to remove from watchlist'); + } + + console.log(`❌ Removed ${artistName} from watchlist`); + + // Close detail view if open + closeWatchlistArtistDetailView(); + + // Refresh the watchlist page + watchlistPageState.isInitialized = false; + await initializeWatchlistPage(); + + // Update button count + updateWatchlistButtonCount(); + + // Update any visible artist cards + updateArtistCardWatchlistStatus(); + + } catch (error) { + console.error('Error removing from watchlist:', error); + alert(`Error removing ${artistName} from watchlist: ${error.message}`); + } +} + + +/** + * Get visible checked checkboxes (not hidden by search filter) + */ +function getVisibleCheckedWatchlist() { + return Array.from(document.querySelectorAll('.watchlist-select-cb:checked')).filter(cb => { + const item = cb.closest('.watchlist-artist-card'); + return item && item.style.display !== 'none'; + }); +} + +/** + * Update the batch action bar based on checkbox selection + */ +function updateWatchlistBatchBar() { + const checked = getVisibleCheckedWatchlist(); + const countEl = document.getElementById('watchlist-batch-count'); + const removeBtn = document.getElementById('watchlist-batch-remove-btn'); + const selectAllCb = document.getElementById('watchlist-select-all-cb'); + + if (checked.length > 0) { + countEl.textContent = `${checked.length} selected`; + removeBtn.style.display = ''; + } else { + countEl.textContent = ''; + removeBtn.style.display = 'none'; + } + + // Update select-all checkbox state + if (selectAllCb) { + const visible = Array.from(document.querySelectorAll('.watchlist-select-cb')).filter(cb => { + const card = cb.closest('.watchlist-artist-card'); + return card && card.style.display !== 'none'; + }); + selectAllCb.checked = visible.length > 0 && checked.length === visible.length; + selectAllCb.indeterminate = checked.length > 0 && checked.length < visible.length; + } +} + +function toggleWatchlistSelectAll(checked) { + const checkboxes = document.querySelectorAll('.watchlist-select-cb'); + checkboxes.forEach(cb => { + const card = cb.closest('.watchlist-artist-card'); + if (card && card.style.display !== 'none') { + cb.checked = checked; + } + }); + updateWatchlistBatchBar(); +} + +/** + * Batch remove selected artists from watchlist + */ +async function batchRemoveFromWatchlist() { + const checked = getVisibleCheckedWatchlist(); + if (checked.length === 0) return; + + const count = checked.length; + if (!await showConfirmDialog({ title: 'Remove Artists', message: `Remove ${count} artist${count !== 1 ? 's' : ''} from your watchlist?`, confirmText: 'Remove', destructive: true })) return; + + const artistIds = checked.map(cb => cb.getAttribute('data-artist-id')); + + try { + const response = await fetch('/api/watchlist/remove-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_ids: artistIds }) + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to remove artists'); + } + + console.log(`❌ Batch removed ${data.removed} artists from watchlist`); + + // Refresh the watchlist page + watchlistPageState.isInitialized = false; + await initializeWatchlistPage(); + + // Update button count + updateWatchlistButtonCount(); + + // Update any visible artist cards + updateArtistCardWatchlistStatus(); + + } catch (error) { + console.error('Error batch removing from watchlist:', error); + alert(`Error removing artists: ${error.message}`); + } +} + +// --- Metadata Updater Functions --- + +// Global state for metadata update polling +let metadataUpdatePolling = false; +let metadataUpdateInterval = null; + +/** + * Handle metadata update button click + */ +async function handleMetadataUpdateButtonClick() { + const button = document.getElementById('metadata-update-button'); + const currentAction = button.textContent; + + if (currentAction === 'Begin Update') { + // Get refresh interval from dropdown + const refreshSelect = document.getElementById('metadata-refresh-interval'); + const refreshIntervalDays = refreshSelect.value !== undefined ? parseInt(refreshSelect.value) : 30; + + try { + button.disabled = true; + button.textContent = 'Starting...'; + + const response = await fetch('/api/metadata/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_interval_days: refreshIntervalDays }) + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to start metadata update'); + } + + showToast('Metadata update started!', 'success'); + + // Start polling for status updates + startMetadataUpdatePolling(); + + } catch (error) { + console.error('Error starting metadata update:', error); + button.disabled = false; + button.textContent = 'Begin Update'; + showToast(`Error: ${error.message}`, 'error'); + } + } else { + // Stop metadata update + try { + button.disabled = true; + button.textContent = 'Stopping...'; + + const response = await fetch('/api/metadata/stop', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (!response.ok) { + throw new Error('Failed to stop metadata update'); + } + + } catch (error) { + console.error('Error stopping metadata update:', error); + button.disabled = false; + button.textContent = 'Stop Update'; + } + } +} + +/** + * Start polling for metadata update status + */ +function startMetadataUpdatePolling() { + if (metadataUpdatePolling) return; // Already polling + + metadataUpdatePolling = true; + metadataUpdateInterval = setInterval(checkMetadataUpdateStatus, 1000); // Poll every second + + // Also check immediately + checkMetadataUpdateStatus(); +} + +/** + * Stop polling for metadata update status + */ +function stopMetadataUpdatePolling() { + metadataUpdatePolling = false; + if (metadataUpdateInterval) { + clearInterval(metadataUpdateInterval); + metadataUpdateInterval = null; + } +} + +/** + * Check current metadata update status and update UI + */ +async function checkMetadataUpdateStatus() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/metadata/status'); + const data = await response.json(); + + if (data.success && data.status) { + updateMetadataProgressUI(data.status); + + // Stop polling if completed or error + if (data.status.status === 'completed' || data.status.status === 'error') { + stopMetadataUpdatePolling(); + } + } + + } catch (error) { + console.warn('Could not fetch metadata update status:', error); + } +} + +function updateMetadataStatusFromData(data) { + if (!data.success || !data.status) return; + const prev = _lastToolStatus['metadata']; + _lastToolStatus['metadata'] = data.status.status; + if (prev !== undefined && data.status.status === prev && data.status.status !== 'running' && data.status.status !== 'stopping') return; + updateMetadataProgressUI(data.status); + if (data.status.status === 'completed' || data.status.status === 'error') { + stopMetadataUpdatePolling(); + } +} + +/** + * Update metadata progress UI elements + */ +function updateMetadataProgressUI(status) { + const button = document.getElementById('metadata-update-button'); + const phaseLabel = document.getElementById('metadata-phase-label'); + const progressLabel = document.getElementById('metadata-progress-label'); + const progressBar = document.getElementById('metadata-progress-bar'); + const refreshSelect = document.getElementById('metadata-refresh-interval'); + + if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return; + + if (status.status === 'running') { + button.textContent = 'Stop Update'; + button.disabled = false; + refreshSelect.disabled = true; + + // Update current artist display + const currentArtist = status.current_artist || 'Processing...'; + phaseLabel.textContent = `Current Artist: ${currentArtist}`; + + // Update progress + const processed = status.processed || 0; + const total = status.total || 0; + const percentage = status.percentage || 0; + + progressLabel.textContent = `${processed} / ${total} artists (${percentage.toFixed(1)}%)`; + progressBar.style.width = `${percentage}%`; + + } else if (status.status === 'stopping') { + button.textContent = 'Stopping...'; + button.disabled = true; + phaseLabel.textContent = 'Current Artist: Stopping...'; + + } else if (status.status === 'completed') { + button.textContent = 'Begin Update'; + button.disabled = false; + refreshSelect.disabled = false; + + phaseLabel.textContent = 'Current Artist: Completed'; + + const processed = status.processed || 0; + const successful = status.successful || 0; + const failed = status.failed || 0; + + progressLabel.textContent = `Completed: ${processed} processed, ${successful} successful, ${failed} failed`; + progressBar.style.width = '100%'; + + showToast(`Metadata update completed: ${successful} artists updated, ${failed} failed`, 'success'); + + } else if (status.status === 'error') { + button.textContent = 'Begin Update'; + button.disabled = false; + refreshSelect.disabled = false; + + phaseLabel.textContent = 'Current Artist: Error occurred'; + progressLabel.textContent = status.error || 'Unknown error'; + progressBar.style.width = '0%'; + + } else { + // Idle state + button.textContent = 'Begin Update'; + button.disabled = false; + refreshSelect.disabled = false; + + phaseLabel.textContent = 'Current Artist: Not running'; + progressLabel.textContent = '0 / 0 artists (0.0%)'; + progressBar.style.width = '0%'; + } +} + +/** + * Check active media server and hide metadata updater if not Plex + */ +async function checkAndHideMetadataUpdaterForNonPlex() { + try { + const response = await fetch('/api/active-media-server'); + const data = await response.json(); + + if (data.success) { + const metadataCard = document.getElementById('metadata-updater-card'); + if (metadataCard) { + // Show metadata updater only for Plex and Jellyfin + if (data.active_server === 'plex' || data.active_server === 'jellyfin') { + metadataCard.style.display = 'flex'; + console.log(`Metadata updater shown: ${data.active_server} is active server`); + + // Update the header text to reflect the current server + const headerElement = metadataCard.querySelector('.card-header h3'); + if (headerElement) { + const serverDisplayName = data.active_server.charAt(0).toUpperCase() + data.active_server.slice(1); + headerElement.textContent = `${serverDisplayName} Metadata Updater`; + } + + // Update the description based on the server type + const descElement = metadataCard.querySelector('.metadata-updater-description'); + if (descElement) { + if (data.active_server === 'jellyfin') { + descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Jellyfin server for artists without photos.'; + } else { + descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Plex server for artists without photos.'; + } + } + } else { + // Hide metadata updater for Navidrome + metadataCard.style.display = 'none'; + console.log(`Metadata updater hidden: ${data.active_server} does not support image uploads`); + } + } + } + } catch (error) { + console.warn('Could not check active media server for metadata updater visibility:', error); + } +} + +async function checkAndShowMediaScanForPlex() { + /** + * Show media scan tool only for Plex (Jellyfin/Navidrome auto-scan) + */ + try { + const response = await fetch('/api/active-media-server'); + const data = await response.json(); + + if (data.success) { + const mediaScanCard = document.getElementById('media-scan-card'); + if (mediaScanCard) { + // Show media scan tool only for Plex + if (data.active_server === 'plex') { + mediaScanCard.style.display = 'flex'; + console.log('Media scan tool shown: Plex is active server'); + } else { + // Hide for Jellyfin/Navidrome (they auto-scan) + mediaScanCard.style.display = 'none'; + console.log(`Media scan tool hidden: ${data.active_server} auto-scans`); + } + } + } + } catch (error) { + console.warn('Could not check active media server for media scan visibility:', error); + } +} + +async function handleMediaScanButtonClick() { + /** + * Trigger a manual Plex media library scan + */ + const button = document.getElementById('media-scan-button'); + const phaseLabel = document.getElementById('media-scan-phase-label'); + const progressBar = document.getElementById('media-scan-progress-bar'); + const progressLabel = document.getElementById('media-scan-progress-label'); + const statusValue = document.getElementById('media-scan-status'); + + if (!button) return; + + try { + // Disable button and update UI + button.disabled = true; + phaseLabel.textContent = 'Requesting scan...'; + progressBar.style.width = '30%'; + progressLabel.textContent = 'Sending scan request to Plex'; + statusValue.textContent = 'Scanning...'; + statusValue.style.color = 'rgb(var(--accent-rgb))'; + + // Request scan (database update handled by system automation) + const response = await fetch('/api/scan/request', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + reason: 'Manual scan triggered from dashboard' + }) + }); + + const result = await response.json(); + + if (result.success) { + // Get delay from API response (graceful fallback to 60 if not provided) + const delaySeconds = (result.scan_info && result.scan_info.delay_seconds) || 60; + let remainingSeconds = delaySeconds; + let countdownInterval = null; + let pollInterval = null; + + // Update last scan time + const lastTimeEl = document.getElementById('media-scan-last-time'); + if (lastTimeEl) { + const now = new Date(); + lastTimeEl.textContent = now.toLocaleTimeString(); + } + + // Start countdown timer (visual feedback during delay) + phaseLabel.textContent = 'Scan scheduled...'; + progressBar.style.width = '0%'; + + countdownInterval = setInterval(() => { + remainingSeconds--; + + // Update progress bar (0% -> 100% over delay period) + const progress = ((delaySeconds - remainingSeconds) / delaySeconds) * 100; + progressBar.style.width = `${progress}%`; + + // Update progress label with countdown + if (remainingSeconds > 0) { + progressLabel.textContent = `Starting scan in ${remainingSeconds}s...`; + } else { + progressLabel.textContent = 'Scan starting now...'; + } + + // When countdown reaches 0, start polling + if (remainingSeconds <= 0) { + clearInterval(countdownInterval); + + // Transition to scanning phase + phaseLabel.textContent = 'Scan in progress...'; + progressBar.style.width = '100%'; + progressLabel.textContent = 'Media server is scanning library...'; + showToast('📡 Media scan started', 'success', 3000); + + // Start polling for scan completion (5 minutes = 150 polls × 2s) + let pollCount = 0; + const maxPolls = 150; // 5 minutes + + pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 5: WS handles scan status + pollCount++; + + if (pollCount > maxPolls) { + // Polling timeout after 5 minutes + clearInterval(pollInterval); + button.disabled = false; + phaseLabel.textContent = 'Scan completed'; + progressBar.style.width = '0%'; + progressLabel.textContent = 'Ready for next scan'; + statusValue.textContent = 'Idle'; + statusValue.style.color = '#b3b3b3'; + showToast('✅ Media scan completed', 'success', 3000); + return; + } + + try { + const statusResponse = await fetch('/api/scan/status'); + const statusData = await statusResponse.json(); + + if (statusData.success && statusData.status) { + const status = statusData.status; + + // Update status display + if (status.is_scanning) { + phaseLabel.textContent = 'Media server scanning...'; + progressLabel.textContent = status.progress_message || 'Scan in progress'; + } else if (status.status === 'idle') { + // Scan completed + clearInterval(pollInterval); + button.disabled = false; + phaseLabel.textContent = 'Scan completed successfully'; + progressBar.style.width = '0%'; + progressLabel.textContent = 'Ready for next scan'; + statusValue.textContent = 'Idle'; + statusValue.style.color = '#b3b3b3'; + showToast('✅ Media scan completed', 'success', 3000); + } + } + } catch (pollError) { + console.debug('Scan status poll error:', pollError); + } + }, 2000); // Poll every 2 seconds + } + }, 1000); // Update countdown every second + + } else { + // Error occurred + showToast(`❌ Scan request failed: ${result.error}`, 'error', 5000); + button.disabled = false; + phaseLabel.textContent = 'Scan failed'; + progressBar.style.width = '0%'; + progressLabel.textContent = result.error || 'Unknown error'; + statusValue.textContent = 'Error'; + statusValue.style.color = '#f44336'; + } + + } catch (error) { + console.error('Error requesting media scan:', error); + showToast('❌ Failed to request media scan', 'error', 3000); + button.disabled = false; + phaseLabel.textContent = 'Error'; + progressBar.style.width = '0%'; + progressLabel.textContent = error.message; + statusValue.textContent = 'Error'; + statusValue.style.color = '#f44336'; + } +} + +/** + * Check for ongoing metadata update and restore state on page load + */ +async function checkAndRestoreMetadataUpdateState() { + try { + const response = await fetch('/api/metadata/status'); + const data = await response.json(); + + if (data.success && data.status) { + const status = data.status; + + // If metadata update is running, restore the UI state and start polling + if (status.status === 'running') { + console.log('Found ongoing metadata update, restoring state...'); + updateMetadataProgressUI(status); + startMetadataUpdatePolling(); + } else if (status.status === 'completed' || status.status === 'error') { + // Show final state but don't start polling + updateMetadataProgressUI(status); + } + } + } catch (error) { + console.warn('Could not check metadata update state on page load:', error); + } +} + +// --- Live Log Viewer Functions --- + +// Global state for log polling +let logPolling = false; +let logInterval = null; +let lastLogCount = 0; + +/** + * Initialize the live log viewer for sync page + */ +function initializeLiveLogViewer() { + const logArea = document.getElementById('sync-log-area'); + if (!logArea) return; + + // Set initial content + logArea.value = 'Loading activity feed...'; + + // Start log polling + startLogPolling(); + + // Initial load + loadLogs(); +} + +/** + * Start polling for logs + */ +function startLogPolling() { + if (logPolling) return; // Already polling + + logPolling = true; + logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds + console.log('📝 Started activity feed polling for sync page'); +} + +/** + * Stop polling for logs + */ +function stopLogPolling() { + logPolling = false; + if (logInterval) { + clearInterval(logInterval); + logInterval = null; + console.log('📝 Stopped log polling'); + } +} + +/** + * Load and display activity feed as logs + */ +async function loadLogs() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/logs'); + const data = await response.json(); + updateLogsFromData(data); + } catch (error) { + console.warn('Could not load activity logs for sync page:', error); + const logArea = document.getElementById('sync-log-area'); + if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) { + logArea.value = 'Error loading activity feed. Check console for details.'; + } + } +} + +function updateLogsFromData(data) { + if (!data.logs || !Array.isArray(data.logs)) return; + const logArea = document.getElementById('sync-log-area'); + if (!logArea) return; + + const logText = data.logs.join('\n'); + + // Store current scroll state + const wasAtTop = logArea.scrollTop <= 10; + const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10; + + // Update content only if it has changed + if (logArea.value !== logText) { + logArea.value = logText; + + // Smart scrolling: stay at top for new entries, preserve user position if scrolled + if (wasAtTop || !wasUserScrolled) { + logArea.scrollTop = 0; // Stay at top since newest entries are now at top + } + } +} + +/** + * Stop log polling when leaving sync page + */ +function cleanupSyncPageLogs() { + stopLogPolling(); +} + +// --- Global Cleanup on Page Unload --- +// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed +// =============================== + diff --git a/webui/static/beatport-ui.js b/webui/static/beatport-ui.js new file mode 100644 index 00000000..e2c5a774 --- /dev/null +++ b/webui/static/beatport-ui.js @@ -0,0 +1,3903 @@ +// BEATPORT REBUILD SLIDER FUNCTIONALITY +// ================================= + +let beatportRebuildSliderState = { + currentSlide: 0, + totalSlides: 4, + autoPlayInterval: null, + autoPlayDelay: 5000 +}; + +/** + * Initialize the beatport rebuild slider functionality + */ +function initializeBeatportRebuildSlider() { + console.log('🔄 Initializing beatport rebuild slider...'); + + const slider = document.getElementById('beatport-rebuild-slider'); + if (!slider) { + console.warn('Beatport rebuild slider not found'); + return; + } + + // Check if already initialized to prevent duplicate event listeners + if (slider.dataset.initialized === 'true') { + console.log('Beatport rebuild slider already initialized, skipping...'); + startBeatportRebuildSliderAutoPlay(); // Just restart autoplay + return; + } + + // Mark as initialized + slider.dataset.initialized = 'true'; + + // Load real Beatport data first + loadBeatportHeroTracks(); + + console.log('✅ Beatport rebuild slider initialized successfully'); +} + +/** + * Load real Beatport hero tracks and populate the slider + */ +async function loadBeatportHeroTracks() { + console.log('🎯 Loading real Beatport hero tracks...'); + + try { + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/hero-tracks', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success && data.tracks && data.tracks.length > 0) { + console.log(`✅ Loaded ${data.tracks.length} Beatport tracks`); + populateBeatportSlider(data.tracks); + } else { + console.warn('❌ No tracks received from Beatport API, using placeholder data'); + setupBeatportSliderWithPlaceholders(); + } + } catch (error) { + if (error && error.name === 'AbortError') return; + console.error('❌ Error loading Beatport tracks:', error); + setupBeatportSliderWithPlaceholders(); + } +} + +/** + * Populate the slider with real Beatport track data + */ +function populateBeatportSlider(tracks) { + const sliderTrack = document.getElementById('beatport-rebuild-slider-track'); + const indicatorsContainer = document.querySelector('.beatport-rebuild-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) { + console.warn('Slider elements not found'); + return; + } + + // Clear existing content + sliderTrack.innerHTML = ''; + indicatorsContainer.innerHTML = ''; + + // Update state + beatportRebuildSliderState.totalSlides = tracks.length; + beatportRebuildSliderState.currentSlide = 0; + + // Generate slides HTML + tracks.forEach((track, index) => { + const slideHtml = ` +
+
+
+
+
+
+

${track.title}

+

${track.artist}

+

New on Beatport

+
+
+
+ `; + sliderTrack.insertAdjacentHTML('beforeend', slideHtml); + + // Add indicator + const indicatorHtml = ``; + indicatorsContainer.insertAdjacentHTML('beforeend', indicatorHtml); + }); + + // Now set up all the functionality + setupBeatportSliderFunctionality(); + + // Add individual click handlers for each slide (like top 10 releases pattern) + setupHeroSliderIndividualClickHandlers(tracks); + + console.log(`✅ Populated slider with ${tracks.length} real Beatport tracks`); +} + +/** + * Set up individual click handlers for hero slider slides (like top 10 releases) + */ +function setupHeroSliderIndividualClickHandlers(tracks) { + const slides = document.querySelectorAll('.beatport-rebuild-slide[data-url]'); + + slides.forEach((slide, index) => { + const releaseUrl = slide.getAttribute('data-url'); + if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') { + // Create release data object from the track data (similar to top 10 releases) + const track = tracks[index]; + if (track) { + const releaseData = { + url: releaseUrl, + title: track.title || 'Unknown Title', + artist: track.artist || 'Unknown Artist', + label: track.label || 'Unknown Label', + image_url: track.image_url || '' + }; + + // Add click handler that mimics the top 10 releases behavior + slide.addEventListener('click', (event) => { + // Prevent navigation button clicks from triggering this + if (event.target.closest('.beatport-rebuild-nav-btn') || + event.target.closest('.beatport-rebuild-indicator')) { + return; + } + + console.log(`🎯 Hero slider slide clicked: ${releaseData.title} by ${releaseData.artist}`); + handleBeatportReleaseCardClick(slide, releaseData); + }); + + slide.style.cursor = 'pointer'; + } + } + }); + + console.log(`✅ Set up individual click handlers for ${slides.length} hero slider slides`); +} + +/** + * Set up placeholder data if API fails + */ +function setupBeatportSliderWithPlaceholders() { + console.log('🔄 Setting up slider with placeholder data...'); + + // The HTML already has placeholder slides, just set up functionality + setupBeatportSliderFunctionality(); +} + +/** + * Set up all slider functionality after content is loaded + */ +function setupBeatportSliderFunctionality() { + // Set up navigation buttons + setupBeatportRebuildSliderNavigation(); + + // Set up indicators + setupBeatportRebuildSliderIndicators(); + + + // Start auto-play + startBeatportRebuildSliderAutoPlay(); + + // Set up pause on hover + setupBeatportRebuildSliderHoverPause(); +} + +/** + * Set up navigation button functionality + */ +function setupBeatportRebuildSliderNavigation() { + const prevBtn = document.getElementById('beatport-rebuild-prev-btn'); + const nextBtn = document.getElementById('beatport-rebuild-next-btn'); + + if (prevBtn) { + prevBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Previous button clicked, current slide:', beatportRebuildSliderState.currentSlide); + goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide - 1); + resetBeatportRebuildSliderAutoPlay(); + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Next button clicked, current slide:', beatportRebuildSliderState.currentSlide); + goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide + 1); + resetBeatportRebuildSliderAutoPlay(); + }); + } +} + +/** + * Set up indicator functionality + */ +function setupBeatportRebuildSliderIndicators() { + const indicators = document.querySelectorAll('.beatport-rebuild-indicator'); + + indicators.forEach((indicator, index) => { + indicator.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + goToBeatportRebuildSlide(index); + resetBeatportRebuildSliderAutoPlay(); + }); + }); +} + +/** + * Navigate to a specific slide + */ +function goToBeatportRebuildSlide(slideIndex) { + console.log('goToBeatportRebuildSlide called with:', slideIndex, 'current:', beatportRebuildSliderState.currentSlide); + + // Wrap around if out of bounds + if (slideIndex < 0) { + slideIndex = beatportRebuildSliderState.totalSlides - 1; + } else if (slideIndex >= beatportRebuildSliderState.totalSlides) { + slideIndex = 0; + } + + console.log('After wrapping, slideIndex:', slideIndex); + + // Update current slide + beatportRebuildSliderState.currentSlide = slideIndex; + + // Update slide visibility + const slides = document.querySelectorAll('.beatport-rebuild-slide'); + slides.forEach((slide, index) => { + slide.classList.remove('active', 'prev', 'next'); + + if (index === slideIndex) { + slide.classList.add('active'); + } else if (index < slideIndex) { + slide.classList.add('prev'); + } else { + slide.classList.add('next'); + } + }); + + // Update indicators + const indicators = document.querySelectorAll('.beatport-rebuild-indicator'); + indicators.forEach((indicator, index) => { + indicator.classList.toggle('active', index === slideIndex); + }); + + console.log('Slide updated to:', beatportRebuildSliderState.currentSlide); +} + +/** + * Start auto-play functionality + */ +function startBeatportRebuildSliderAutoPlay() { + if (beatportRebuildSliderState.autoPlayInterval) { + clearInterval(beatportRebuildSliderState.autoPlayInterval); + } + + beatportRebuildSliderState.autoPlayInterval = setInterval(() => { + goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide + 1); + }, beatportRebuildSliderState.autoPlayDelay); +} + +/** + * Reset auto-play timer + */ +function resetBeatportRebuildSliderAutoPlay() { + startBeatportRebuildSliderAutoPlay(); +} + +/** + * Set up hover pause functionality + */ +function setupBeatportRebuildSliderHoverPause() { + const sliderContainer = document.querySelector('.beatport-rebuild-slider-container'); + + if (sliderContainer) { + sliderContainer.addEventListener('mouseenter', () => { + if (beatportRebuildSliderState.autoPlayInterval) { + clearInterval(beatportRebuildSliderState.autoPlayInterval); + } + }); + + sliderContainer.addEventListener('mouseleave', () => { + startBeatportRebuildSliderAutoPlay(); + }); + } +} + + +/** + * Clean up beatport rebuild slider when switching away + */ +function cleanupBeatportRebuildSlider() { + if (beatportRebuildSliderState.autoPlayInterval) { + clearInterval(beatportRebuildSliderState.autoPlayInterval); + beatportRebuildSliderState.autoPlayInterval = null; + } +} + +// =================================== +// BEATPORT NEW RELEASES SLIDER +// =================================== + +// State management for new releases slider (copied from hero slider) +let beatportReleasesSliderState = { + currentSlide: 0, + totalSlides: 0, + autoPlayInterval: null, + autoPlayDelay: 8000, + isInitialized: false +}; + +/** + * Initialize the beatport new releases slider functionality (based on hero slider) + */ +function initializeBeatportReleasesSlider() { + console.log('🆕 Initializing beatport new releases slider...'); + + const slider = document.getElementById('beatport-releases-slider'); + if (!slider) { + console.warn('Beatport releases slider not found'); + return; + } + + // Prevent double initialization + if (slider.dataset.initialized === 'true') { + console.log('Releases slider already initialized'); + return; + } + + const sliderTrack = document.getElementById('beatport-releases-slider-track'); + const indicatorsContainer = document.getElementById('beatport-releases-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) { + console.warn('Releases slider elements not found'); + return; + } + + // Load data and initialize + loadBeatportNewReleases().then(success => { + if (success) { + setupBeatportReleasesSliderNavigation(); + setupBeatportReleasesSliderIndicators(); + setupBeatportReleasesSliderHoverPause(); + startBeatportReleasesSliderAutoPlay(); + slider.dataset.initialized = 'true'; + beatportReleasesSliderState.isInitialized = true; + console.log('✅ New releases slider initialized successfully'); + } + }); +} + +/** + * Load new releases data from API + */ +async function loadBeatportNewReleases() { + try { + console.log('📡 Fetching new releases data...'); + + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/new-releases', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success && data.releases && data.releases.length > 0) { + console.log(`📀 Loaded ${data.releases.length} releases`); + populateBeatportReleasesSlider(data.releases); + return true; + } else { + console.error('Failed to load releases:', data.error || 'No releases found'); + showBeatportReleasesError(data.error || 'No releases available'); + return false; + } + } catch (error) { + if (error && error.name === 'AbortError') return false; + console.error('Error loading new releases:', error); + showBeatportReleasesError('Failed to load releases'); + return false; + } +} + +/** + * Populate the releases slider with data (based on hero slider) + */ +function populateBeatportReleasesSlider(releases) { + const sliderTrack = document.getElementById('beatport-releases-slider-track'); + const indicatorsContainer = document.getElementById('beatport-releases-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) return; + + // Calculate slides needed (10 cards per slide) + const cardsPerSlide = 10; + const totalSlides = Math.ceil(releases.length / cardsPerSlide); + + // Clear existing content + sliderTrack.innerHTML = ''; + indicatorsContainer.innerHTML = ''; + + // Update state + beatportReleasesSliderState.totalSlides = totalSlides; + beatportReleasesSliderState.currentSlide = 0; + + console.log(`🎯 Creating ${totalSlides} slides with ${cardsPerSlide} cards each`); + + // Generate slides HTML (similar to hero slider) + for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) { + const startIndex = slideIndex * cardsPerSlide; + const endIndex = Math.min(startIndex + cardsPerSlide, releases.length); + const slideReleases = releases.slice(startIndex, endIndex); + + // Create grid HTML for this slide + let gridHtml = ''; + for (let i = 0; i < cardsPerSlide; i++) { + if (i < slideReleases.length) { + const release = slideReleases[i]; + gridHtml += ` +
+
+
+ ${release.image_url ? `${release.title}` : ''} +
+
+
${release.title}
+
${release.artist}
+
${release.label}
+
+
+
+ `; + } else { + // Placeholder card + gridHtml += ` +
+
+
+
📀
+
+
+
More Releases
+
Coming Soon
+
Beatport
+
+
+
+ `; + } + } + + const slideHtml = ` +
+
+ ${gridHtml} +
+
+ `; + + sliderTrack.innerHTML += slideHtml; + + // Create indicator + const indicatorHtml = ``; + indicatorsContainer.innerHTML += indicatorHtml; + } + + console.log(`✅ Created ${totalSlides} slides for releases slider`); + + // Add click handlers for individual release discovery (matching Top 10 Releases pattern) + const releaseCards = sliderTrack.querySelectorAll('.beatport-release-card[data-url]:not(.beatport-release-placeholder)'); + releaseCards.forEach((card) => { + const releaseUrl = card.getAttribute('data-url'); + if (releaseUrl && releaseUrl !== '#') { + // Find the corresponding release data + const releaseData = releases.find(release => release.url === releaseUrl); + if (releaseData) { + card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releaseData)); + card.style.cursor = 'pointer'; + } + } + }); +} + +/** + * Set up navigation functionality (copied from hero slider) + */ +function setupBeatportReleasesSliderNavigation() { + const prevBtn = document.getElementById('beatport-releases-prev-btn'); + const nextBtn = document.getElementById('beatport-releases-next-btn'); + + if (prevBtn) { + // Clone button to remove all existing event listeners + const newPrevBtn = prevBtn.cloneNode(true); + prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); + + newPrevBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Previous releases button clicked, current slide:', beatportReleasesSliderState.currentSlide); + goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide - 1); + resetBeatportReleasesSliderAutoPlay(); + }); + } + + if (nextBtn) { + // Clone button to remove all existing event listeners + const newNextBtn = nextBtn.cloneNode(true); + nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); + + newNextBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Next releases button clicked, current slide:', beatportReleasesSliderState.currentSlide); + goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide + 1); + resetBeatportReleasesSliderAutoPlay(); + }); + } +} + +/** + * Set up indicator functionality (copied from hero slider) + */ +function setupBeatportReleasesSliderIndicators() { + const indicators = document.querySelectorAll('.beatport-releases-indicator'); + + indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => { + goToBeatportReleasesSlide(index); + resetBeatportReleasesSliderAutoPlay(); + }); + }); +} + +/** + * Navigate to a specific slide (copied from hero slider) + */ +function goToBeatportReleasesSlide(slideIndex) { + console.log('goToBeatportReleasesSlide called with:', slideIndex, 'current:', beatportReleasesSliderState.currentSlide); + + // Wrap around if out of bounds + if (slideIndex < 0) { + slideIndex = beatportReleasesSliderState.totalSlides - 1; + } else if (slideIndex >= beatportReleasesSliderState.totalSlides) { + slideIndex = 0; + } + + console.log('After wrapping, slideIndex:', slideIndex); + + // Update current slide + beatportReleasesSliderState.currentSlide = slideIndex; + + // Update slide visibility + const slides = document.querySelectorAll('.beatport-releases-slide'); + slides.forEach((slide, index) => { + slide.classList.remove('active', 'prev', 'next'); + + if (index === slideIndex) { + slide.classList.add('active'); + } else if (index < slideIndex) { + slide.classList.add('prev'); + } else { + slide.classList.add('next'); + } + }); + + // Update indicators + const indicators = document.querySelectorAll('.beatport-releases-indicator'); + indicators.forEach((indicator, index) => { + indicator.classList.toggle('active', index === slideIndex); + }); + + console.log('Releases slide updated to:', beatportReleasesSliderState.currentSlide); +} + +/** + * Start auto-play functionality (copied from hero slider) + */ +function startBeatportReleasesSliderAutoPlay() { + if (beatportReleasesSliderState.autoPlayInterval) { + clearInterval(beatportReleasesSliderState.autoPlayInterval); + } + + beatportReleasesSliderState.autoPlayInterval = setInterval(() => { + goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide + 1); + }, beatportReleasesSliderState.autoPlayDelay); +} + +/** + * Reset auto-play timer (copied from hero slider) + */ +function resetBeatportReleasesSliderAutoPlay() { + startBeatportReleasesSliderAutoPlay(); +} + +/** + * Set up hover pause functionality (copied from hero slider) + */ +function setupBeatportReleasesSliderHoverPause() { + const sliderContainer = document.querySelector('.beatport-releases-slider-container'); + + if (sliderContainer) { + sliderContainer.addEventListener('mouseenter', () => { + if (beatportReleasesSliderState.autoPlayInterval) { + clearInterval(beatportReleasesSliderState.autoPlayInterval); + beatportReleasesSliderState.autoPlayInterval = null; + } + }); + + sliderContainer.addEventListener('mouseleave', () => { + startBeatportReleasesSliderAutoPlay(); + }); + } +} + +/** + * Show error state + */ +function showBeatportReleasesError(errorMessage) { + const sliderTrack = document.getElementById('beatport-releases-slider-track'); + if (!sliderTrack) return; + + sliderTrack.innerHTML = ` +
+
+

❌ Error Loading Releases

+

${errorMessage}

+
+
+ `; +} + +/** + * Clean up releases slider when switching away (copied from hero slider) + */ +function cleanupBeatportReleasesSlider() { + if (beatportReleasesSliderState.autoPlayInterval) { + clearInterval(beatportReleasesSliderState.autoPlayInterval); + beatportReleasesSliderState.autoPlayInterval = null; + } +} + +// =================================== +// BEATPORT HYPE PICKS SLIDER +// =================================== + +// Hype Picks Slider State +let beatportHypePicksSliderState = { + currentSlide: 0, + totalSlides: 0, + autoPlayInterval: null, + autoPlayDelay: 4000, + isInitialized: false +}; + +/** + * Initialize the beatport hype picks slider functionality (based on releases slider) + */ +function initializeBeatportHypePicksSlider() { + console.log('🔥 Initializing beatport hype picks slider...'); + + const slider = document.getElementById('beatport-hype-picks-slider'); + if (!slider) { + console.warn('Beatport hype picks slider not found'); + return; + } + + // Check if already initialized + if (beatportHypePicksSliderState.isInitialized) { + console.log('Beatport hype picks slider already initialized, skipping...'); + startBeatportHypePicksSliderAutoPlay(); // Just restart autoplay + return; + } + + // Mark as initialized + beatportHypePicksSliderState.isInitialized = true; + + // Reset state + beatportHypePicksSliderState.currentSlide = 0; + beatportHypePicksSliderState.totalSlides = 0; + + // Load data and initialize + loadBeatportHypePicks().then(success => { + if (success) { + setupBeatportHypePicksSliderNavigation(); + setupBeatportHypePicksSliderIndicators(); + setupBeatportHypePicksSliderHoverPause(); + startBeatportHypePicksSliderAutoPlay(); + } + }); + + console.log('✅ Beatport hype picks slider initialized successfully'); +} + +/** + * Load hype picks data from API + */ +async function loadBeatportHypePicks() { + try { + console.log('🔥 Fetching hype picks data...'); + + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/hype-picks', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success && data.releases && data.releases.length > 0) { + console.log(`🔥 Loaded ${data.releases.length} hype picks releases`); + populateBeatportHypePicksSlider(data.releases); + return true; + } else { + console.error('Failed to load hype picks:', data.error || 'No hype picks found'); + showBeatportHypePicksError(data.error || 'No hype picks available'); + return false; + } + } catch (error) { + if (error && error.name === 'AbortError') return false; + console.error('Error loading hype picks:', error); + showBeatportHypePicksError('Failed to load hype picks'); + return false; + } +} + +/** + * Populate the hype picks slider with data (based on releases slider) + */ +function populateBeatportHypePicksSlider(releases) { + const sliderTrack = document.getElementById('beatport-hype-picks-slider-track'); + const indicatorsContainer = document.getElementById('beatport-hype-picks-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) return; + + // Clear existing content + sliderTrack.innerHTML = ''; + indicatorsContainer.innerHTML = ''; + + // Group releases into slides (10 releases per slide in 5x2 grid) + const releasesPerSlide = 10; + const slides = []; + for (let i = 0; i < releases.length; i += releasesPerSlide) { + slides.push(releases.slice(i, i + releasesPerSlide)); + } + + console.log(`🔥 Hype Picks: Got ${releases.length} releases, creating ${slides.length} slides`); + beatportHypePicksSliderState.totalSlides = slides.length; + beatportHypePicksSliderState.currentSlide = 0; + + // Create slides + slides.forEach((slideReleases, slideIndex) => { + const slideHtml = ` +
+
+ ${slideReleases.map(release => createBeatportHypePickCard(release)).join('')} + ${slideReleases.length < releasesPerSlide ? + Array(releasesPerSlide - slideReleases.length).fill(0).map(() => + `
+
🔥
+
` + ).join('') : '' + } +
+
+ `; + sliderTrack.insertAdjacentHTML('beforeend', slideHtml); + console.log(`🔥 Created slide ${slideIndex + 1}/${slides.length} with ${slideReleases.length} releases`); + + // Create indicator + const indicatorHtml = ``; + indicatorsContainer.insertAdjacentHTML('beforeend', indicatorHtml); + }); + + // Add click handlers to track cards + setupBeatportHypePickCardHandlers(); +} + +/** + * Create a hype pick card HTML (for release cards, same as new releases) + */ +function createBeatportHypePickCard(release) { + const artworkUrl = release.image_url || ''; + const bgStyle = artworkUrl ? `style="--card-bg-image: url('${artworkUrl}')"` : ''; + + return ` +
+
+
+ ${artworkUrl ? `${release.title || 'Release'}` : ''} +
+
+
${release.title || 'Unknown Title'}
+
${release.artist || 'Unknown Artist'}
+
${release.label || 'Hype Pick'}
+
+
+
+ `; +} + +/** + * Setup navigation for hype picks slider (same pattern as releases) + */ +function setupBeatportHypePicksSliderNavigation() { + const prevBtn = document.getElementById('beatport-hype-picks-prev-btn'); + const nextBtn = document.getElementById('beatport-hype-picks-next-btn'); + + if (prevBtn) { + // Clone button to remove all existing event listeners + const newPrevBtn = prevBtn.cloneNode(true); + prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); + + newPrevBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Previous hype picks button clicked, current slide:', beatportHypePicksSliderState.currentSlide); + goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide - 1); + resetBeatportHypePicksSliderAutoPlay(); + }); + } + + if (nextBtn) { + // Clone button to remove all existing event listeners + const newNextBtn = nextBtn.cloneNode(true); + nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); + + newNextBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Next hype picks button clicked, current slide:', beatportHypePicksSliderState.currentSlide); + goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide + 1); + resetBeatportHypePicksSliderAutoPlay(); + }); + } +} + +/** + * Setup indicators for hype picks slider + */ +function setupBeatportHypePicksSliderIndicators() { + const indicators = document.querySelectorAll('.beatport-hype-picks-indicator'); + + indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => { + goToBeatportHypePicksSlide(index); + resetBeatportHypePicksSliderAutoPlay(); + }); + }); +} + +/** + * Navigate to specific slide + */ +function goToBeatportHypePicksSlide(slideIndex) { + console.log('goToBeatportHypePicksSlide called with:', slideIndex, 'current:', beatportHypePicksSliderState.currentSlide); + + // Handle wrap around + if (slideIndex < 0) { + slideIndex = beatportHypePicksSliderState.totalSlides - 1; + } else if (slideIndex >= beatportHypePicksSliderState.totalSlides) { + slideIndex = 0; + } + + // Update current slide + beatportHypePicksSliderState.currentSlide = slideIndex; + + // Update slides + const slides = document.querySelectorAll('.beatport-hype-picks-slide'); + slides.forEach((slide, index) => { + slide.classList.remove('active', 'prev', 'next'); + if (index === slideIndex) { + slide.classList.add('active'); + } else if (index < slideIndex) { + slide.classList.add('prev'); + } else { + slide.classList.add('next'); + } + }); + + // Update indicators + const indicators = document.querySelectorAll('.beatport-hype-picks-indicator'); + indicators.forEach((indicator, index) => { + indicator.classList.toggle('active', index === slideIndex); + }); + + console.log('Slide updated to:', beatportHypePicksSliderState.currentSlide); +} + +/** + * Start auto-play for hype picks slider + */ +function startBeatportHypePicksSliderAutoPlay() { + if (beatportHypePicksSliderState.autoPlayInterval) { + clearInterval(beatportHypePicksSliderState.autoPlayInterval); + } + + beatportHypePicksSliderState.autoPlayInterval = setInterval(() => { + goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide + 1); + }, beatportHypePicksSliderState.autoPlayDelay); + + console.log('🔥 Hype picks slider autoplay started'); +} + +/** + * Reset auto-play for hype picks slider + */ +function resetBeatportHypePicksSliderAutoPlay() { + startBeatportHypePicksSliderAutoPlay(); +} + +/** + * Setup hover pause for hype picks slider + */ +function setupBeatportHypePicksSliderHoverPause() { + const sliderContainer = document.querySelector('.beatport-hype-picks-slider-container'); + if (sliderContainer) { + sliderContainer.addEventListener('mouseenter', () => { + if (beatportHypePicksSliderState.autoPlayInterval) { + clearInterval(beatportHypePicksSliderState.autoPlayInterval); + } + }); + + sliderContainer.addEventListener('mouseleave', () => { + startBeatportHypePicksSliderAutoPlay(); + }); + } +} + +/** + * Setup click handlers for hype pick cards + */ +function setupBeatportHypePickCardHandlers() { + const cards = document.querySelectorAll('.beatport-hype-pick-card:not(.beatport-hype-pick-placeholder)'); + + cards.forEach(card => { + const releaseUrl = card.getAttribute('data-url'); + if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') { + // Extract release data from the card elements + const titleElement = card.querySelector('.beatport-hype-pick-title'); + const artistElement = card.querySelector('.beatport-hype-pick-artist'); + const labelElement = card.querySelector('.beatport-hype-pick-label'); + const imageElement = card.querySelector('.beatport-hype-pick-artwork img'); + + const releaseData = { + url: releaseUrl, + title: titleElement ? titleElement.textContent.trim() : 'Unknown Title', + artist: artistElement ? artistElement.textContent.trim() : 'Unknown Artist', + label: labelElement ? labelElement.textContent.trim() : 'Unknown Label', + image_url: imageElement ? imageElement.src : '' + }; + + card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releaseData)); + card.style.cursor = 'pointer'; + } + }); +} + +/** + * Show error state for hype picks slider + */ +function showBeatportHypePicksError(errorMessage) { + const sliderTrack = document.getElementById('beatport-hype-picks-slider-track'); + if (sliderTrack) { + sliderTrack.innerHTML = ` +
+
+

❌ Error Loading Hype Picks

+

${errorMessage}

+
+
+ `; + } +} + +/** + * Clean up hype picks slider when switching away + */ +function cleanupBeatportHypePicksSlider() { + if (beatportHypePicksSliderState.autoPlayInterval) { + clearInterval(beatportHypePicksSliderState.autoPlayInterval); + beatportHypePicksSliderState.autoPlayInterval = null; + } +} + +// =================================== +// BEATPORT FEATURED CHARTS SLIDER +// =================================== + +// State management for featured charts slider (copied from releases slider) +let beatportChartsSliderState = { + currentSlide: 0, + totalSlides: 0, + autoPlayInterval: null, + autoPlayDelay: 10000, // Slightly longer auto-play for charts + isInitialized: false +}; + +/** + * Initialize the beatport featured charts slider functionality (based on releases slider) + */ +function initializeBeatportChartsSlider() { + console.log('🔥 Initializing beatport featured charts slider...'); + + const slider = document.getElementById('beatport-charts-slider'); + if (!slider) { + console.warn('Beatport charts slider not found'); + return; + } + + // Prevent double initialization + if (slider.dataset.initialized === 'true') { + console.log('Charts slider already initialized'); + return; + } + + const sliderTrack = document.getElementById('beatport-charts-slider-track'); + const indicatorsContainer = document.getElementById('beatport-charts-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) { + console.warn('Charts slider elements not found'); + return; + } + + // Load data and initialize + loadBeatportFeaturedCharts().then(success => { + if (success) { + setupBeatportChartsSliderNavigation(); + setupBeatportChartsSliderIndicators(); + setupBeatportChartsSliderHoverPause(); + startBeatportChartsSliderAutoPlay(); + slider.dataset.initialized = 'true'; + beatportChartsSliderState.isInitialized = true; + console.log('✅ Featured charts slider initialized successfully'); + } + }); +} + +/** + * Load featured charts data from API + */ +async function loadBeatportFeaturedCharts() { + try { + console.log('📊 Loading featured charts data...'); + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/featured-charts', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success && data.charts && data.charts.length > 0) { + console.log(`📈 Loaded ${data.charts.length} featured charts`); + createBeatportChartsSlides(data.charts); + return true; + } else { + console.warn('No featured charts data available'); + return false; + } + } catch (error) { + if (error && error.name === 'AbortError') return false; + console.error('❌ Error loading featured charts:', error); + return false; + } +} + +/** + * Create chart slides with grid layout (copied from releases slider) + */ +function createBeatportChartsSlides(charts) { + const sliderTrack = document.getElementById('beatport-charts-slider-track'); + const indicatorsContainer = document.getElementById('beatport-charts-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) { + console.error('Charts slider elements not found'); + return; + } + + const cardsPerSlide = 10; // 5x2 grid + const totalSlides = Math.ceil(charts.length / cardsPerSlide); + + // Clear existing content + sliderTrack.innerHTML = ''; + indicatorsContainer.innerHTML = ''; + + // Update state + beatportChartsSliderState.totalSlides = totalSlides; + beatportChartsSliderState.currentSlide = 0; + + console.log(`🎯 Creating ${totalSlides} chart slides with ${cardsPerSlide} cards each`); + + // Generate slides HTML + for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) { + const startIndex = slideIndex * cardsPerSlide; + const endIndex = Math.min(startIndex + cardsPerSlide, charts.length); + const slideCharts = charts.slice(startIndex, endIndex); + + // Create grid HTML for this slide + const gridHtml = slideCharts.map(chart => { + const bgImageStyle = chart.image ? `--chart-bg-image: url('${chart.image}')` : ''; + return ` +
+
+
${chart.name || 'Unknown Chart'}
+
${chart.creator || 'Unknown Creator'}
+
+
+ `; + }).join(''); + + // Create slide HTML + const slideHtml = ` +
+
+ ${gridHtml} +
+
+ `; + + sliderTrack.innerHTML += slideHtml; + + // Create indicator + const indicatorHtml = ``; + indicatorsContainer.innerHTML += indicatorHtml; + } + + console.log(`✅ Created ${totalSlides} chart slides`); + + // Add click handlers for individual chart discovery (matching chart pattern) + const chartCards = sliderTrack.querySelectorAll('.beatport-chart-card[data-url]'); + chartCards.forEach((card) => { + const chartUrl = card.getAttribute('data-url'); + if (chartUrl && chartUrl !== '') { + // Find the corresponding chart data + const chartData = charts.find(chart => chart.url === chartUrl); + if (chartData) { + card.addEventListener('click', () => handleBeatportChartCardClick(card, chartData)); + card.style.cursor = 'pointer'; + } + } + }); +} + +/** + * Set up navigation functionality (copied from releases slider with button cloning) + */ +function setupBeatportChartsSliderNavigation() { + const prevBtn = document.getElementById('beatport-charts-prev-btn'); + const nextBtn = document.getElementById('beatport-charts-next-btn'); + + if (prevBtn) { + // Clone button to remove all existing event listeners + const newPrevBtn = prevBtn.cloneNode(true); + prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); + + newPrevBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Previous charts button clicked, current slide:', beatportChartsSliderState.currentSlide); + goToBeatportChartsSlide(beatportChartsSliderState.currentSlide - 1); + resetBeatportChartsSliderAutoPlay(); + }); + } + + if (nextBtn) { + // Clone button to remove all existing event listeners + const newNextBtn = nextBtn.cloneNode(true); + nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); + + newNextBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Next charts button clicked, current slide:', beatportChartsSliderState.currentSlide); + goToBeatportChartsSlide(beatportChartsSliderState.currentSlide + 1); + resetBeatportChartsSliderAutoPlay(); + }); + } +} + +/** + * Set up indicator functionality (copied from releases slider) + */ +function setupBeatportChartsSliderIndicators() { + const indicators = document.querySelectorAll('.beatport-charts-indicator'); + + indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => { + goToBeatportChartsSlide(index); + resetBeatportChartsSliderAutoPlay(); + }); + }); +} + +/** + * Navigate to a specific slide (copied from releases slider) + */ +function goToBeatportChartsSlide(slideIndex) { + console.log('goToBeatportChartsSlide called with:', slideIndex, 'current:', beatportChartsSliderState.currentSlide); + + // Wrap around if out of bounds + if (slideIndex < 0) { + slideIndex = beatportChartsSliderState.totalSlides - 1; + } else if (slideIndex >= beatportChartsSliderState.totalSlides) { + slideIndex = 0; + } + + console.log('After wrapping, slideIndex:', slideIndex); + + // Update current slide + beatportChartsSliderState.currentSlide = slideIndex; + + // Update slide visibility + const slides = document.querySelectorAll('.beatport-charts-slide'); + slides.forEach((slide, index) => { + slide.classList.remove('active', 'prev', 'next'); + + if (index === slideIndex) { + slide.classList.add('active'); + } else if (index < slideIndex) { + slide.classList.add('prev'); + } else { + slide.classList.add('next'); + } + }); + + // Update indicators + const indicators = document.querySelectorAll('.beatport-charts-indicator'); + indicators.forEach((indicator, index) => { + indicator.classList.toggle('active', index === slideIndex); + }); + + console.log('Charts slide updated to:', beatportChartsSliderState.currentSlide); +} + +/** + * Start auto-play functionality (copied from releases slider) + */ +function startBeatportChartsSliderAutoPlay() { + if (beatportChartsSliderState.autoPlayInterval) { + clearInterval(beatportChartsSliderState.autoPlayInterval); + } + + beatportChartsSliderState.autoPlayInterval = setInterval(() => { + goToBeatportChartsSlide(beatportChartsSliderState.currentSlide + 1); + }, beatportChartsSliderState.autoPlayDelay); +} + +/** + * Reset auto-play timer (copied from releases slider) + */ +function resetBeatportChartsSliderAutoPlay() { + startBeatportChartsSliderAutoPlay(); +} + +/** + * Set up hover pause functionality (copied from releases slider) + */ +function setupBeatportChartsSliderHoverPause() { + const sliderContainer = document.querySelector('.beatport-charts-slider-container'); + + if (sliderContainer) { + sliderContainer.addEventListener('mouseenter', () => { + if (beatportChartsSliderState.autoPlayInterval) { + clearInterval(beatportChartsSliderState.autoPlayInterval); + beatportChartsSliderState.autoPlayInterval = null; + } + }); + + sliderContainer.addEventListener('mouseleave', () => { + startBeatportChartsSliderAutoPlay(); + }); + } +} + +/** + * Clean up charts slider when switching away (copied from releases slider) + */ +function cleanupBeatportChartsSlider() { + if (beatportChartsSliderState.autoPlayInterval) { + clearInterval(beatportChartsSliderState.autoPlayInterval); + beatportChartsSliderState.autoPlayInterval = null; + } +} + +// =================================== +// BEATPORT DJ CHARTS SLIDER +// =================================== + +// State management for DJ charts slider (3 cards per slide) +let beatportDJSliderState = { + currentSlide: 0, + totalSlides: 0, + autoPlayInterval: null, + autoPlayDelay: 12000, // Longer auto-play for DJ charts + isInitialized: false +}; + +/** + * Initialize the beatport DJ charts slider functionality (based on charts slider) + */ +function initializeBeatportDJSlider() { + console.log('🎧 Initializing beatport DJ charts slider...'); + + const slider = document.getElementById('beatport-dj-slider'); + if (!slider) { + console.warn('Beatport DJ slider not found'); + return; + } + + // Prevent double initialization + if (slider.dataset.initialized === 'true') { + console.log('DJ slider already initialized'); + return; + } + + const sliderTrack = document.getElementById('beatport-dj-slider-track'); + const indicatorsContainer = document.getElementById('beatport-dj-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) { + console.warn('DJ slider elements not found'); + return; + } + + // Load data and initialize + loadBeatportDJCharts().then(success => { + if (success) { + setupBeatportDJSliderNavigation(); + setupBeatportDJSliderIndicators(); + setupBeatportDJSliderHoverPause(); + startBeatportDJSliderAutoPlay(); + slider.dataset.initialized = 'true'; + beatportDJSliderState.isInitialized = true; + console.log('✅ DJ charts slider initialized successfully'); + } + }); +} + +/** + * Load DJ charts data from API + */ +async function loadBeatportDJCharts() { + try { + console.log('🎧 Loading DJ charts data...'); + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/dj-charts', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success && data.charts && data.charts.length > 0) { + console.log(`📈 Loaded ${data.charts.length} DJ charts`); + createBeatportDJSlides(data.charts); + return true; + } else { + console.warn('No DJ charts data available'); + return false; + } + } catch (error) { + if (error && error.name === 'AbortError') return false; + console.error('❌ Error loading DJ charts:', error); + return false; + } +} + +/** + * Create DJ chart slides with 3 cards per slide layout + */ +function createBeatportDJSlides(charts) { + const sliderTrack = document.getElementById('beatport-dj-slider-track'); + const indicatorsContainer = document.getElementById('beatport-dj-slider-indicators'); + + if (!sliderTrack || !indicatorsContainer) { + console.error('DJ slider elements not found'); + return; + } + + const cardsPerSlide = 3; // 3 cards per slide for DJ charts + const totalSlides = Math.ceil(charts.length / cardsPerSlide); + + // Clear existing content + sliderTrack.innerHTML = ''; + indicatorsContainer.innerHTML = ''; + + // Update state + beatportDJSliderState.totalSlides = totalSlides; + beatportDJSliderState.currentSlide = 0; + + console.log(`🎯 Creating ${totalSlides} DJ chart slides with ${cardsPerSlide} cards each`); + + // Generate slides HTML + for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) { + const startIndex = slideIndex * cardsPerSlide; + const endIndex = Math.min(startIndex + cardsPerSlide, charts.length); + const slideCharts = charts.slice(startIndex, endIndex); + + // Create grid HTML for this slide + const gridHtml = slideCharts.map(chart => { + const bgImageStyle = chart.image ? `--dj-bg-image: url('${chart.image}')` : ''; + return ` +
+
+
${chart.name || 'Unknown Chart'}
+
${chart.creator || 'Unknown Creator'}
+
+
+ `; + }).join(''); + + // Create slide HTML + const slideHtml = ` +
+
+ ${gridHtml} +
+
+ `; + + sliderTrack.innerHTML += slideHtml; + + // Create indicator + const indicatorHtml = ``; + indicatorsContainer.innerHTML += indicatorHtml; + } + + console.log(`✅ Created ${totalSlides} DJ chart slides`); + + // Add click handlers for individual DJ chart discovery (matching chart pattern) + const djChartCards = sliderTrack.querySelectorAll('.beatport-dj-card[data-url]'); + djChartCards.forEach((card) => { + const chartUrl = card.getAttribute('data-url'); + if (chartUrl && chartUrl !== '') { + // Find the corresponding chart data + const chartData = charts.find(chart => chart.url === chartUrl); + if (chartData) { + card.addEventListener('click', () => handleBeatportDJChartCardClick(card, chartData)); + card.style.cursor = 'pointer'; + } + } + }); +} + +/** + * Set up navigation functionality (copied from charts slider with button cloning) + */ +function setupBeatportDJSliderNavigation() { + const prevBtn = document.getElementById('beatport-dj-prev-btn'); + const nextBtn = document.getElementById('beatport-dj-next-btn'); + + if (prevBtn) { + // Clone button to remove all existing event listeners + const newPrevBtn = prevBtn.cloneNode(true); + prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); + + newPrevBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Previous DJ button clicked, current slide:', beatportDJSliderState.currentSlide); + goToBeatportDJSlide(beatportDJSliderState.currentSlide - 1); + resetBeatportDJSliderAutoPlay(); + }); + } + + if (nextBtn) { + // Clone button to remove all existing event listeners + const newNextBtn = nextBtn.cloneNode(true); + nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); + + newNextBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Next DJ button clicked, current slide:', beatportDJSliderState.currentSlide); + goToBeatportDJSlide(beatportDJSliderState.currentSlide + 1); + resetBeatportDJSliderAutoPlay(); + }); + } +} + +/** + * Set up indicator functionality (copied from charts slider) + */ +function setupBeatportDJSliderIndicators() { + const indicators = document.querySelectorAll('.beatport-dj-indicator'); + + indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => { + goToBeatportDJSlide(index); + resetBeatportDJSliderAutoPlay(); + }); + }); +} + +/** + * Navigate to a specific slide (copied from charts slider) + */ +function goToBeatportDJSlide(slideIndex) { + console.log('goToBeatportDJSlide called with:', slideIndex, 'current:', beatportDJSliderState.currentSlide); + + // Wrap around if out of bounds + if (slideIndex < 0) { + slideIndex = beatportDJSliderState.totalSlides - 1; + } else if (slideIndex >= beatportDJSliderState.totalSlides) { + slideIndex = 0; + } + + console.log('After wrapping, slideIndex:', slideIndex); + + // Update current slide + beatportDJSliderState.currentSlide = slideIndex; + + // Update slide visibility + const slides = document.querySelectorAll('.beatport-dj-slide'); + slides.forEach((slide, index) => { + slide.classList.remove('active', 'prev', 'next'); + + if (index === slideIndex) { + slide.classList.add('active'); + } else if (index < slideIndex) { + slide.classList.add('prev'); + } else { + slide.classList.add('next'); + } + }); + + // Update indicators + const indicators = document.querySelectorAll('.beatport-dj-indicator'); + indicators.forEach((indicator, index) => { + indicator.classList.toggle('active', index === slideIndex); + }); + + console.log('DJ slide updated to:', beatportDJSliderState.currentSlide); +} + +/** + * Start auto-play functionality (copied from charts slider) + */ +function startBeatportDJSliderAutoPlay() { + if (beatportDJSliderState.autoPlayInterval) { + clearInterval(beatportDJSliderState.autoPlayInterval); + } + + beatportDJSliderState.autoPlayInterval = setInterval(() => { + goToBeatportDJSlide(beatportDJSliderState.currentSlide + 1); + }, beatportDJSliderState.autoPlayDelay); +} + +/** + * Reset auto-play timer (copied from charts slider) + */ +function resetBeatportDJSliderAutoPlay() { + startBeatportDJSliderAutoPlay(); +} + +/** + * Set up hover pause functionality (copied from charts slider) + */ +function setupBeatportDJSliderHoverPause() { + const sliderContainer = document.querySelector('.beatport-dj-slider-container'); + + if (sliderContainer) { + sliderContainer.addEventListener('mouseenter', () => { + if (beatportDJSliderState.autoPlayInterval) { + clearInterval(beatportDJSliderState.autoPlayInterval); + beatportDJSliderState.autoPlayInterval = null; + } + }); + + sliderContainer.addEventListener('mouseleave', () => { + startBeatportDJSliderAutoPlay(); + }); + } +} + +/** + * Clean up DJ slider when switching away (copied from charts slider) + */ +function cleanupBeatportDJSlider() { + if (beatportDJSliderState.autoPlayInterval) { + clearInterval(beatportDJSliderState.autoPlayInterval); + beatportDJSliderState.autoPlayInterval = null; + } +} + +/** + * Load top 10 lists data from API and populate both lists + */ +async function loadBeatportTop10Lists() { + try { + console.log('🏆 Loading top 10 lists data...'); + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/homepage/top-10-lists', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success) { + console.log(`🎵 Loaded ${data.beatport_count} Beatport Top 10 + ${data.hype_count} Hype Top 10 tracks`); + + // Populate both lists + populateBeatportTop10List(data.beatport_top10); + populateHypeTop10List(data.hype_top10); + return true; + } else { + console.error('Failed to load top 10 lists:', data.error); + showTop10ListsError(data.error || 'No data available'); + return false; + } + } catch (error) { + if (error && error.name === 'AbortError') return false; + console.error('Error loading top 10 lists:', error); + showTop10ListsError('Failed to load top 10 lists'); + return false; + } +} + +/** + * Clean track/artist text for proper spacing + */ +function cleanTrackText(text) { + if (!text) return text; + + // Fix common spacing issues + text = text.replace(/([a-z$!@#%&*])([A-Z])/g, '$1 $2'); // Add space between lowercase/symbols and uppercase + text = text.replace(/([a-zA-Z]),([a-zA-Z])/g, '$1, $2'); // Add space after comma + text = text.replace(/([a-zA-Z])(Mix|Remix|Extended|Version)\b/g, '$1 $2'); // Fix mix types + text = text.replace(/\s+/g, ' '); // Collapse multiple spaces + text = text.trim(); + + return text; +} + +/** + * Populate Beatport Top 10 list with data + */ +function populateBeatportTop10List(tracks) { + const container = document.getElementById('beatport-top10-list'); + if (!container || !tracks || tracks.length === 0) return; + + // Generate HTML for the tracks + let tracksHtml = ` +
+

🎵 Beatport Top 10

+

Most popular tracks on Beatport

+
+
+ `; + + tracks.forEach((track, index) => { + // Clean the text data before injection + const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); + const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); + const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); + + tracksHtml += ` +
+
${track.rank || index + 1}
+
+ ${track.artwork_url ? + `${cleanTitle}` : + '
🎵
' + } +
+
+

${cleanTitle}

+

${cleanArtist}

+

${cleanLabel}

+
+
+ `; + }); + + tracksHtml += '
'; + container.innerHTML = tracksHtml; +} + +/** + * Populate Hype Top 10 list with data + */ +function populateHypeTop10List(tracks) { + const container = document.getElementById('beatport-hype10-list'); + if (!container || !tracks || tracks.length === 0) return; + + // Generate HTML for the tracks + let tracksHtml = ` +
+

🔥 Hype Top 10

+

Editor's trending picks

+
+
+ `; + + tracks.forEach((track, index) => { + // Clean the text data before injection + const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); + const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); + const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); + + tracksHtml += ` +
+
${track.rank || index + 1}
+
+ ${track.artwork_url ? + `${cleanTitle}` : + '
🔥
' + } +
+
+

${cleanTitle}

+

${cleanArtist}

+

${cleanLabel}

+
+
+ `; + }); + + tracksHtml += '
'; + container.innerHTML = tracksHtml; +} + +/** + * Show error message for top 10 lists + */ +function showTop10ListsError(errorMessage) { + const beatportContainer = document.getElementById('beatport-top10-list'); + const hypeContainer = document.getElementById('beatport-hype10-list'); + + const errorHtml = ` +
+

❌ Error Loading Data

+

${errorMessage}

+
+ `; + + if (beatportContainer) beatportContainer.innerHTML = errorHtml; + if (hypeContainer) hypeContainer.innerHTML = errorHtml; +} + +/** + * Load top 10 releases data from API and populate the list + */ +async function loadBeatportTop10Releases() { + try { + console.log('💿 Loading top 10 releases data...'); + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/homepage/top-10-releases-cards', signal ? { signal } : undefined); + const data = await response.json(); + + if (data.success) { + console.log(`💿 Loaded ${data.releases_count} Top 10 Releases`); + populateBeatportTop10Releases(data.releases); + return true; + } else { + console.error('Failed to load top 10 releases:', data.error); + showTop10ReleasesError(data.error || 'No data available'); + return false; + } + } catch (error) { + if (error && error.name === 'AbortError') return false; + console.error('Error loading top 10 releases:', error); + showTop10ReleasesError('Failed to load top 10 releases'); + return false; + } +} + +/** + * Populate Top 10 Releases list with data + */ +function populateBeatportTop10Releases(releases) { + const container = document.getElementById('beatport-releases-top10-list'); + if (!container || !releases || releases.length === 0) return; + + // Generate HTML for the releases + let releasesHtml = ` +
+ `; + + releases.forEach((release, index) => { + releasesHtml += ` +
+
${release.rank || index + 1}
+
+ ${release.image_url ? + `${release.title}` : + '
💿
' + } +
+
+

${release.title || 'Unknown Title'}

+

${release.artist || 'Unknown Artist'}

+

${release.label || 'Unknown Label'}

+
+
+ `; + }); + + releasesHtml += '
'; + container.innerHTML = releasesHtml; + + // Set background images for cards + const cards = container.querySelectorAll('.beatport-releases-top10-card[data-bg-image]'); + cards.forEach(card => { + const bgImage = card.getAttribute('data-bg-image'); + if (bgImage) { + // Transform image URL from 95x95 to 500x500 for higher quality background + const highResImage = bgImage.replace('/image_size/95x95/', '/image_size/500x500/'); + card.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.8)), url('${highResImage}')`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } + }); + + // Add click handlers for individual release discovery + const releaseCards = container.querySelectorAll('.beatport-releases-top10-card[data-url]'); + releaseCards.forEach((card, index) => { + card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releases[index])); + card.style.cursor = 'pointer'; + }); +} + +/** + * Show error message for top 10 releases + */ +function showTop10ReleasesError(errorMessage) { + const container = document.getElementById('beatport-releases-top10-list'); + + const errorHtml = ` +
+

❌ Error Loading Releases

+

${errorMessage}

+
+ `; + + if (container) container.innerHTML = errorHtml; +} + +/** + * Handle click on individual Top 10 Release card - create discovery process for single release + */ +async function handleBeatportReleaseCardClick(cardElement, release) { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + + console.log(`💿 Individual release card clicked: ${release.title} by ${release.artist}`); + + if (!release.url || release.url === '#') { + _beatportModalOpening = false; + showToast('No release URL available', 'error'); + return; + } + + try { + showToast(`Loading ${release.title}...`, 'info'); + showLoadingOverlay(`Getting tracks from ${release.title}...`); + + // Fetch structured release metadata for direct download modal + console.log(`🎵 Fetching release metadata: ${release.url}`); + const response = await fetch('/api/beatport/release-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ release_url: release.url }) + }); + + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(data.error || 'No tracks found in this release'); + } + + console.log(`✅ Got ${data.tracks.length} tracks from ${data.album.name}`); + + // Format artists as array of strings for compatibility with download modal + const formattedTracks = data.tracks.map(track => ({ + ...track, + artists: track.artists.map(a => typeof a === 'object' ? a.name : a) + })); + + const virtualPlaylistId = `beatport_release_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const playlistName = data.album.name; + + // Open download modal directly - same as clicking an album on the Artists page + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + playlistName, + formattedTracks, + data.album, + data.artist, + false + ); + + // Register Beatport download bubble for releases (albums, EPs, singles) + const releaseImage = (data.album.images && data.album.images.length > 0) ? data.album.images[0].url : (release.image_url || ''); + registerBeatportDownload(playlistName, releaseImage, virtualPlaylistId); + + hideLoadingOverlay(); + _beatportModalOpening = false; + console.log(`✅ Opened download modal for ${playlistName}`); + + } catch (error) { + console.error(`❌ Error handling release click for ${release.title}:`, error); + hideLoadingOverlay(); + _beatportModalOpening = false; + showToast(`Error loading ${release.title}: ${error.message}`, 'error'); + } +} + +/** + * Convert scraped Beatport tracks into download-modal-compatible format and open the modal. + * Used by all chart/playlist handlers (Top 100, Hype 100, Featured Charts, DJ Charts, genre charts). + * Charts open as compilations — each track is searched independently on Soulseek. + */ +// Guard against multiple rapid clicks opening duplicate modals +let _beatportModalOpening = false; + +/** + * Enrich tracks via a single batch request to the backend. + * Progress is reported via WebSocket (beatport:enrich_progress) and updates the loading overlay. + * Returns the enriched tracks array. + */ +async function _enrichTracksWithProgress(tracks, chartName) { + const enrichmentId = `enrich_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; + + try { + const resp = await fetch('/api/beatport/enrich-tracks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks, enrichment_id: enrichmentId }) + }); + const data = await resp.json(); + + // Synchronous path — all tracks were cached, results returned inline + if (data.success && data.tracks) { + return data.tracks; + } + + // Async path — poll for progress until done + if (data.success && data.async) { + while (true) { + await new Promise(r => setTimeout(r, 800)); + try { + const progressResp = await fetch(`/api/beatport/enrich-progress/${enrichmentId}?_=${Date.now()}`); + const progress = await progressResp.json(); + if (!progress.success) break; + + // Update loading overlay with live progress + const overlayText = document.querySelector('#loading-overlay .loading-message'); + if (overlayText) { + overlayText.textContent = `Fetching track metadata... (${progress.completed}/${progress.total}) ${progress.current_track || ''}`; + } + + if (progress.done) { + if (progress.tracks) { + return progress.tracks; + } + console.warn('⚠️ Async enrichment failed:', progress.error); + return tracks; + } + } catch (pollErr) { + console.warn('⚠️ Progress poll error:', pollErr); + } + } + } + + console.warn('⚠️ Enrichment failed, returning original tracks'); + return tracks; + } catch (e) { + console.warn('⚠️ Failed to enrich tracks:', e); + return tracks; + } +} + +function parseBeatportDuration(raw) { + if (!raw) return 0; + if (typeof raw === 'string' && raw.includes(':')) { + const parts = raw.split(':'); + return (parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)) * 1000 || 0; + } + return (parseInt(raw, 10) || 0) * 1000; +} + +function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) { + // Note: callers already guard against double-clicks via _beatportModalOpening. + // Reset the flag here so the modal can open even after fast (cached) enrichment. + _beatportModalOpening = false; + + const albumObj = { + id: `beatport_chart_${Date.now()}`, + name: chartName, + album_type: 'compilation', + images: chartImage ? [{ url: chartImage }] : [], + total_tracks: tracks.length + }; + + const formattedTracks = tracks.map((track, index) => { + // Use per-track release metadata if available (from JSON extraction) + const hasRelease = track.release_name && track.release_name.length > 0; + const trackAlbum = hasRelease ? { + id: `beatport_release_${track.release_id || index}`, + name: cleanTrackText(track.release_name), + album_type: 'single', + images: track.release_image ? [{ url: track.release_image }] : [], + release_date: track.release_date || '', + total_tracks: 1 + } : albumObj; + + // Combine title + mix_name + let trackName = cleanTrackText(track.title || 'Unknown Title'); + if (track.mix_name && track.mix_name.toLowerCase() !== 'original mix') { + trackName = `${trackName} (${cleanTrackText(track.mix_name)})`; + } + + // Split combined artist string into individual names for proper folder structure + const rawArtist = cleanTrackText(track.artist || 'Unknown Artist'); + const artistList = rawArtist.includes(',') + ? rawArtist.split(',').map(a => a.trim()).filter(a => a) + : [rawArtist]; + + return { + id: `beatport_chart_${index}`, + name: trackName, + artists: artistList, + duration_ms: parseBeatportDuration(track.duration), + track_number: index + 1, + disc_number: 1, + album: trackAlbum + }; + }); + + const virtualPlaylistId = `beatport_chart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + // Compilation artist + const artistObj = { id: 'beatport_various', name: 'Various Artists' }; + + openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + chartName, + formattedTracks, + albumObj, + artistObj, + false, + 'playlist' + ); + + // Register Beatport download bubble + registerBeatportDownload(chartName, chartImage, virtualPlaylistId); +} + +/** + * Handle click on individual chart card - open download modal directly + */ +async function handleBeatportChartCardClick(cardElement, chart) { + console.log(`📊 Individual chart card clicked: ${chart.name} by ${chart.creator}`); + + if (!chart.url || chart.url === '') { + showToast('No chart URL available', 'error'); + return; + } + + try { + const chartName = `${chart.name} - ${chart.creator}`; + showToast(`Loading ${chart.name}...`, 'info'); + showLoadingOverlay(`Scraping ${chart.name}...`); + + const response = await fetch('/api/beatport/chart/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chart_url: chart.url, + chart_name: `Featured Chart: ${chart.name}`, + limit: 100, + enrich: false + }) + }); + + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in this chart'); + } + + console.log(`✅ Fetched ${data.tracks.length} raw tracks from ${chart.name}, enriching...`); + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, chart.image); + + } catch (error) { + console.error(`❌ Error handling chart click for ${chart.name}:`, error); + hideLoadingOverlay(); + showToast(`Error loading ${chart.name}: ${error.message}`, 'error'); + } +} + +/** + * Handle click on individual DJ chart card - open download modal directly + */ +async function handleBeatportDJChartCardClick(cardElement, chart) { + console.log(`🎧 Individual DJ chart card clicked: ${chart.name} by ${chart.creator}`); + + if (!chart.url || chart.url === '') { + showToast('No DJ chart URL available', 'error'); + return; + } + + try { + const chartName = `${chart.name} - ${chart.creator}`; + showToast(`Loading ${chart.name}...`, 'info'); + showLoadingOverlay(`Scraping ${chart.name}...`); + + const response = await fetch('/api/beatport/chart/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chart_url: chart.url, + chart_name: `DJ Chart: ${chart.name}`, + limit: 100, + enrich: false + }) + }); + + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in this DJ chart'); + } + + console.log(`✅ Fetched ${data.tracks.length} raw tracks from ${chart.name}, enriching...`); + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, chart.image); + + } catch (error) { + console.error(`❌ Error handling DJ chart click for ${chart.name}:`, error); + hideLoadingOverlay(); + showToast(`Error loading ${chart.name}: ${error.message}`, 'error'); + } +} + +/** + * Handle click on Beatport Top 100 button - open download modal directly + */ +async function handleBeatportTop100Click() { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + setTimeout(() => { _beatportModalOpening = false; }, 2000); + + console.log('💯 Beatport Top 100 button clicked'); + + try { + showLoadingOverlay('Scraping Beatport Top 100...'); + + // Fetch track list without enrichment (fast) + const response = await fetch('/api/beatport/top-100?enrich=false', { method: 'GET' }); + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in Beatport Top 100'); + } + + console.log(`✅ Fetched ${data.tracks.length} tracks, enriching one-by-one...`); + + // Enrich one-by-one with live progress + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, 'Beatport Top 100'); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, 'Beatport Top 100', null); + + } catch (error) { + console.error('❌ Error handling Beatport Top 100 click:', error); + hideLoadingOverlay(); + showToast(`Error loading Beatport Top 100: ${error.message}`, 'error'); + } +} + +/** + * Handle click on Hype Top 100 button - open download modal directly + */ +async function handleHypeTop100Click() { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + setTimeout(() => { _beatportModalOpening = false; }, 2000); + + console.log('🔥 Hype Top 100 button clicked'); + + try { + showLoadingOverlay('Scraping Hype Top 100...'); + + // Fetch track list without enrichment (fast) + const response = await fetch('/api/beatport/hype-top-100?enrich=false', { method: 'GET' }); + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in Hype Top 100'); + } + + console.log(`✅ Fetched ${data.tracks.length} tracks, enriching one-by-one...`); + + // Enrich one-by-one with live progress + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, 'Hype Top 100'); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, 'Hype Top 100', null); + + } catch (error) { + console.error('❌ Error handling Hype Top 100 click:', error); + hideLoadingOverlay(); + showToast(`Error loading Hype Top 100: ${error.message}`, 'error'); + } +} + +// ================================= // +// GENRE BROWSER MODAL FUNCTIONS // +// ================================= // + +// Cache for genre browser data to avoid re-loading +let genreBrowserCache = { + genres: null, + imagesLoaded: false, + lastLoaded: null, + imageLoadingActive: false, + imageWorkers: null +}; + +function initializeGenreBrowserModal() { + console.log('🎵 Initializing Genre Browser Modal...'); + + // Browse by Genre button click handler + const browseByGenreBtn = document.getElementById('browse-by-genre-btn'); + if (browseByGenreBtn) { + browseByGenreBtn.addEventListener('click', () => { + console.log('🎵 Browse by Genre button clicked'); + openGenreBrowserModal(); + }); + } + + // Modal close button handler + const modalCloseBtn = document.getElementById('genre-browser-modal-close'); + if (modalCloseBtn) { + modalCloseBtn.addEventListener('click', closeGenreBrowserModal); + } + + // Click outside modal to close + const modalOverlay = document.getElementById('genre-browser-modal'); + if (modalOverlay) { + modalOverlay.addEventListener('click', (e) => { + if (e.target === modalOverlay) { + closeGenreBrowserModal(); + } + }); + } + + // Search functionality + const searchInput = document.getElementById('genre-browser-search'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + filterGenreBrowserCards(e.target.value); + }); + } + + // ESC key to close modal + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && isGenreBrowserModalOpen()) { + closeGenreBrowserModal(); + } + }); + + console.log('✅ Genre Browser Modal initialized'); +} + +function openGenreBrowserModal() { + console.log('🎵 Opening Genre Browser Modal...'); + + const modal = document.getElementById('genre-browser-modal'); + if (modal) { + modal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Prevent background scrolling + + // Check cache before loading genres + if (genreBrowserCache.genres && genreBrowserCache.genres.length > 0) { + console.log('💾 Using cached genres data'); + displayCachedGenres(); + } else { + console.log('🔄 No cached data, loading genres...'); + loadGenreBrowserGenres(); + } + + console.log('✅ Genre Browser Modal opened'); + } +} + +function closeGenreBrowserModal() { + console.log('🎵 Closing Genre Browser Modal...'); + + const modal = document.getElementById('genre-browser-modal'); + if (modal) { + modal.classList.remove('active'); + document.body.style.overflow = ''; // Restore scrolling + + // Clear search input but keep the genre data cached + const searchInput = document.getElementById('genre-browser-search'); + if (searchInput) { + searchInput.value = ''; + // Also reset the display filter to show all genres + filterGenreBrowserCards(''); + } + + // Pause image loading workers if they're running + if (genreBrowserCache.imageLoadingActive) { + console.log('⏸️ Pausing image loading workers...'); + genreBrowserCache.imageLoadingActive = false; + } + + console.log('✅ Genre Browser Modal closed (data preserved in cache)'); + } +} + +function isGenreBrowserModalOpen() { + const modal = document.getElementById('genre-browser-modal'); + return modal && modal.classList.contains('active'); +} + +async function loadGenreBrowserGenres() { + console.log('🔍 Loading genres for Genre Browser Modal...'); + + const genresGrid = document.getElementById('genre-browser-genres-grid'); + if (!genresGrid) { + console.error('❌ Genre browser grid not found'); + return; + } + + // Show loading state + genresGrid.innerHTML = ` +
+
+

🔍 Discovering current Beatport genres...

+
+ `; + + try { + // First, fetch genres quickly without images + console.log('🚀 Fetching genres without images for fast loading...'); + const fastResponse = await fetch('/api/beatport/genres'); + if (!fastResponse.ok) { + throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`); + } + + const fastData = await fastResponse.json(); + const genres = fastData.genres || []; + + if (genres.length === 0) { + genresGrid.innerHTML = ` +
+

⚠️ No genres available

+ +
+ `; + return; + } + + // Filter out unwanted genres (section titles, etc.) + const filteredGenres = genres.filter(genre => { + const name = genre.name.toLowerCase().trim(); + const unwantedGenres = [ + 'open format', + 'electronic', + 'genres', + 'browse', + 'charts', + 'new releases', + 'trending', + 'featured', + 'popular' + ]; + + const isUnwanted = unwantedGenres.includes(name); + if (isUnwanted) { + console.log(`🚫 Filtered out unwanted genre: "${genre.name}"`); + } + return !isUnwanted; + }); + + console.log(`📋 Filtered genres: ${genres.length} → ${filteredGenres.length} (removed ${genres.length - filteredGenres.length} unwanted)`); + + // Generate genre cards dynamically (without images first) + const genreCardsHTML = filteredGenres.map(genre => ` +
+
🎵
+
+

${genre.name}

+

Top 10 & Top 100 Charts

+
+
+ `).join(''); + + genresGrid.innerHTML = genreCardsHTML; + + // Add click event listeners to genre cards + addGenreBrowserCardClickListeners(); + + // Cache the filtered genres data + genreBrowserCache.genres = filteredGenres; + genreBrowserCache.lastLoaded = new Date(); + genreBrowserCache.imagesLoaded = false; + + console.log(`✅ Loaded ${filteredGenres.length} Beatport genres for modal (fast mode)`); + console.log(`💾 Cached ${filteredGenres.length} genres for future use`); + showToast(`Loaded ${filteredGenres.length} genres for browsing`, 'success'); + + // Now fetch images progressively in the background + if (filteredGenres.length > 5) { + console.log('🖼️ Loading genre images progressively for modal...'); + loadGenreBrowserImagesProgressively(filteredGenres); + } + + } catch (error) { + console.error('❌ Error loading genres for modal:', error); + genresGrid.innerHTML = ` +
+

❌ Failed to load genres: ${error.message}

+ +
+ `; + showToast(`Error loading genres: ${error.message}`, 'error'); + } +} + +function displayCachedGenres() { + console.log('💾 Displaying cached genres...'); + + const genresGrid = document.getElementById('genre-browser-genres-grid'); + if (!genresGrid) { + console.error('❌ Genre browser grid not found'); + return; + } + + const genres = genreBrowserCache.genres; + if (!genres || genres.length === 0) { + console.error('❌ No cached genres available'); + return; + } + + // Generate genre cards from cached data + const genreCardsHTML = genres.map(genre => ` +
+
🎵
+
+

${genre.name}

+

Top 10 & Top 100 Charts

+
+
+ `).join(''); + + genresGrid.innerHTML = genreCardsHTML; + + // Add click event listeners to genre cards + addGenreBrowserCardClickListeners(); + + console.log(`✅ Displayed ${genres.length} cached genres instantly`); + + // Handle image loading based on current state + if (genreBrowserCache.imagesLoaded) { + console.log('🖼️ Images already loaded, restoring them...'); + restoreCachedImages(genres); + } else if (!genreBrowserCache.imageLoadingActive && genres.length > 5) { + // Resume or start image loading + const cachedCount = genres.filter(g => g.imageUrl).length; + if (cachedCount > 0) { + console.log(`🔄 Resuming image loading (${cachedCount}/${genres.length} already cached)...`); + restoreCachedImages(genres); // Show already cached images + } else { + console.log('🖼️ Starting fresh image loading for cached genres...'); + } + loadGenreBrowserImagesProgressively(genres); + } else { + console.log('📷 Image loading in progress, showing cached images...'); + restoreCachedImages(genres); + } +} + +function restoreCachedImages(genres) { + // Restore images that were already loaded in previous sessions + genres.forEach(genre => { + if (genre.imageUrl) { + const genreCard = document.querySelector( + `.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` + ); + + if (genreCard) { + const imageElement = genreCard.querySelector('.genre-browser-card-image'); + if (imageElement) { + imageElement.innerHTML = `${genre.name}`; + genreCard.classList.remove('genre-browser-card-fallback'); + } + } + } + }); +} + +async function loadGenreBrowserImagesProgressively(genres) { + // Load genre images with 2 concurrent workers for faster loading + // Only process genres that don't already have cached images + const imageQueue = genres.filter(genre => !genre.imageUrl); + let imagesLoaded = 0; + const maxWorkers = 2; + + // Mark loading as active + genreBrowserCache.imageLoadingActive = true; + + console.log(`🖼️ Starting progressive image loading for modal with ${maxWorkers} workers for ${imageQueue.length} remaining genres (${genres.length - imageQueue.length} already cached)`); + + // If all images are already cached, mark as complete + if (imageQueue.length === 0) { + console.log('✅ All images already cached, marking as complete'); + genreBrowserCache.imagesLoaded = true; + genreBrowserCache.imageLoadingActive = false; + return; + } + + // Function to process a single image + async function processImage(genre) { + try { + // Fetch individual genre image from backend + const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`); + + if (response.ok) { + const data = await response.json(); + + if (data.success && data.image_url) { + // Cache the image URL in the genre object + genre.imageUrl = data.image_url; + + // Find the genre card in the modal + const genreCard = document.querySelector( + `.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` + ); + + if (genreCard) { + const imageElement = genreCard.querySelector('.genre-browser-card-image'); + if (imageElement) { + // Replace the fallback emoji with the actual image + imageElement.innerHTML = `${genre.name}`; + genreCard.classList.remove('genre-browser-card-fallback'); + + console.log(`✅ Loaded and cached image for ${genre.name} in modal`); + } + } + } + } + + imagesLoaded++; + console.log(`📷 Progress: ${imagesLoaded}/${genres.length} images loaded for modal`); + + } catch (error) { + console.log(`⚠️ Could not load image for ${genre.name} in modal: ${error.message}`); + imagesLoaded++; + } + } + + // Worker function to process images from the queue + async function worker() { + while (imageQueue.length > 0 && genreBrowserCache.imageLoadingActive) { + const genre = imageQueue.shift(); + if (genre) { + await processImage(genre); + // Small delay to prevent overwhelming the server + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Check if we should pause + if (!genreBrowserCache.imageLoadingActive) { + console.log('⏸️ Worker paused - modal closed'); + break; + } + } + } + + // Start the workers + const workers = []; + for (let i = 0; i < maxWorkers; i++) { + workers.push(worker()); + } + + // Wait for all workers to complete + await Promise.all(workers); + + // Check if loading was completed or paused + if (genreBrowserCache.imageLoadingActive) { + // Completed successfully + genreBrowserCache.imagesLoaded = true; + genreBrowserCache.imageLoadingActive = false; + console.log(`🎉 Completed loading all genre images for modal (${imagesLoaded}/${genres.length})`); + console.log(`💾 Marked images as loaded in cache`); + } else { + // Was paused + console.log(`⏸️ Image loading paused (${imagesLoaded}/${genres.length} completed)`); + console.log(`💾 Partial progress saved in cache`); + } +} + +function filterGenreBrowserCards(searchTerm) { + const genreCards = document.querySelectorAll('.genre-browser-card'); + const searchLower = searchTerm.toLowerCase(); + + genreCards.forEach(card => { + const genreName = card.dataset.genreName?.toLowerCase() || ''; + const shouldShow = genreName.includes(searchLower); + + card.style.display = shouldShow ? 'block' : 'none'; + }); + + console.log(`🔍 Filtered genre cards with search term: "${searchTerm}"`); +} + +// === GENRE BROWSER CARD CLICK HANDLERS === + +function addGenreBrowserCardClickListeners() { + const genreCards = document.querySelectorAll('.genre-browser-card'); + genreCards.forEach(card => { + card.addEventListener('click', () => { + const genreSlug = card.dataset.genreSlug; + const genreId = card.dataset.genreId; + const genreName = card.dataset.genreName; + + console.log(`🎵 Genre card clicked: ${genreName} (${genreSlug})`); + handleGenreBrowserCardClick(genreSlug, genreId, genreName); + }); + }); + + console.log(`🔗 Added click listeners to ${genreCards.length} genre browser cards`); +} + +async function handleGenreBrowserCardClick(genreSlug, genreId, genreName) { + console.log(`🎠 Loading hero slider for ${genreName}...`); + + try { + // Show the genre page view + showGenrePageView(genreSlug, genreId, genreName); + + // Load the hero slider data + // Load hero slider, Top 10 lists, and Top 10 releases in parallel + await Promise.all([ + loadGenreHeroSlider(genreSlug, genreId, genreName), + loadGenreTop10Lists(genreSlug, genreId, genreName), + loadGenreTop10Releases(genreSlug, genreId, genreName) + ]); + + } catch (error) { + console.error(`❌ Error loading genre page for ${genreName}:`, error); + showToast(`Error loading ${genreName}: ${error.message}`, 'error'); + + // Return to genre list on error + showGenreListView(); + } +} + +function showGenrePageView(genreSlug, genreId, genreName) { + console.log(`🎯 Showing genre page view for ${genreName}`); + + // CRITICAL: Stop all other slider auto-play to prevent conflicts + if (typeof beatportRebuildSliderState !== 'undefined' && beatportRebuildSliderState.autoPlayInterval) { + clearInterval(beatportRebuildSliderState.autoPlayInterval); + console.log('🛑 Stopped main slider auto-play to prevent conflicts'); + } + + const modal = document.getElementById('genre-browser-modal'); + if (!modal) return; + + // Hide genre list elements + const searchSection = modal.querySelector('.genre-browser-search-section'); + const genresSection = modal.querySelector('.genre-browser-genres-section'); + + if (searchSection) searchSection.style.display = 'none'; + if (genresSection) genresSection.style.display = 'none'; + + // Create or show genre page content + let genrePageContent = modal.querySelector('.genre-page-content'); + if (!genrePageContent) { + genrePageContent = document.createElement('div'); + genrePageContent.className = 'genre-page-content'; + genrePageContent.innerHTML = ` +
+ +

+
+
+
+
+

🎠 Loading hero releases...

+
+
+
+
+ +
+
+
+
+
+

🎵 Loading Top 10 lists...

+
+
+
+
+
+

💿 Loading Top 10 releases...

+
+
+ `; + + modal.querySelector('.genre-browser-modal-content').appendChild(genrePageContent); + + // Add back button listener + const backButton = genrePageContent.querySelector('#genre-back-button'); + if (backButton) { + backButton.addEventListener('click', showGenreListView); + } + + // Add genre top 100 button listener + const genreTop100Button = genrePageContent.querySelector('#genre-top100-btn'); + if (genreTop100Button) { + genreTop100Button.addEventListener('click', () => { + handleGenreTop100Click(genreSlug, genreId, genreName); + }); + } + } + + // Update title and show genre page + const titleElement = genrePageContent.querySelector('.genre-page-title'); + if (titleElement) titleElement.textContent = genreName; + + genrePageContent.style.display = 'block'; + + // Store current genre info for potential back navigation + genrePageContent.dataset.genreSlug = genreSlug; + genrePageContent.dataset.genreId = genreId; + genrePageContent.dataset.genreName = genreName; +} + +function showGenreListView() { + console.log(`🔙 Returning to genre list view`); + + // Clean up genre hero slider + if (window.genreHeroSliderState && window.genreHeroSliderState.autoPlayInterval) { + clearInterval(window.genreHeroSliderState.autoPlayInterval); + console.log('🧹 Cleaned up genre hero slider auto-play'); + } + + // CRITICAL: Restart main slider auto-play + if (typeof beatportRebuildSliderState !== 'undefined' && !beatportRebuildSliderState.autoPlayInterval) { + if (typeof startBeatportRebuildSliderAutoPlay === 'function') { + startBeatportRebuildSliderAutoPlay(); + console.log('🔄 Restarted main slider auto-play'); + } + } + + const modal = document.getElementById('genre-browser-modal'); + if (!modal) return; + + // Show genre list elements + const searchSection = modal.querySelector('.genre-browser-search-section'); + const genresSection = modal.querySelector('.genre-browser-genres-section'); + const genrePageContent = modal.querySelector('.genre-page-content'); + + if (searchSection) searchSection.style.display = 'block'; + if (genresSection) genresSection.style.display = 'block'; + if (genrePageContent) genrePageContent.style.display = 'none'; +} + +async function loadGenreHeroSlider(genreSlug, genreId, genreName) { + console.log(`🎠 Loading hero slider data for ${genreName}...`); + + const container = document.getElementById('genre-hero-slider-container'); + if (!container) return; + + try { + // Show loading state + container.innerHTML = ` +
+
+

🎠 Loading ${genreName} hero releases...

+
+ `; + + // Fetch hero slider data from API + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/hero`); + if (!response.ok) { + throw new Error(`API returned ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (!data.success || !data.releases || data.releases.length === 0) { + throw new Error(data.message || 'No hero releases found'); + } + + console.log(`✅ Loaded ${data.count} hero releases for ${genreName} (cached: ${data.cached})`); + + // Create hero slider HTML + const heroSliderHTML = createGenreHeroSliderHTML(data.releases, genreName); + container.innerHTML = heroSliderHTML; + + // Add click handlers to individual releases (for future download functionality) + addGenreHeroReleaseClickHandlers(data.releases); + + showToast(`Loaded ${data.count} ${genreName} releases`, 'success'); + + } catch (error) { + console.error(`❌ Error loading hero slider for ${genreName}:`, error); + + container.innerHTML = ` +
+

❌ Failed to load ${genreName} releases

+

${error.message}

+ +
+ `; + + throw error; + } +} + +function createGenreHeroSliderHTML(releases, genreName) { + const slidesHTML = releases.map((release, index) => { + // Convert relative URL to absolute URL + const absoluteUrl = release.url.startsWith('http') + ? release.url + : `https://www.beatport.com${release.url}`; + + return ` +
+
+
+
+
+
+

${release.title}

+

${release.artists_string}

+

${release.label || genreName + ' Hero Release'}

+
+
+
`; + }).join(''); + + const indicatorsHTML = releases.map((_, index) => ` + + `).join(''); + + return ` +
+
+
+ ${slidesHTML} +
+ + +
+ + +
+ + +
+ ${indicatorsHTML} +
+
+
+ `; +} + +function addGenreHeroReleaseClickHandlers(releases) { + // Clear any existing intervals first + if (window.genreHeroSliderState && window.genreHeroSliderState.autoPlayInterval) { + clearInterval(window.genreHeroSliderState.autoPlayInterval); + console.log('🧹 Cleared previous genre hero auto-play interval'); + } + + // CRITICAL: Clear ALL possible conflicting intervals + if (typeof beatportRebuildSliderState !== 'undefined' && beatportRebuildSliderState.autoPlayInterval) { + clearInterval(beatportRebuildSliderState.autoPlayInterval); + console.log('🛑 Cleared main rebuild slider auto-play interval'); + } + + // Initialize global slider state for genre hero slider + window.genreHeroSliderState = { + currentSlide: 0, + totalSlides: releases.length, + autoPlayInterval: null + }; + + console.log(`🎠 Initializing genre hero slider with ${releases.length} slides`); + + // Set up navigation button handlers + const prevBtn = document.getElementById('genre-hero-prev-btn'); + const nextBtn = document.getElementById('genre-hero-next-btn'); + + if (prevBtn) { + prevBtn.addEventListener('click', () => { + window.genreHeroSliderState.currentSlide = window.genreHeroSliderState.currentSlide > 0 + ? window.genreHeroSliderState.currentSlide - 1 + : window.genreHeroSliderState.totalSlides - 1; + updateGenreHeroSlide(window.genreHeroSliderState.currentSlide); + console.log(`⬅️ Previous: Moving to slide ${window.genreHeroSliderState.currentSlide}`); + }); + } + + if (nextBtn) { + nextBtn.addEventListener('click', () => { + window.genreHeroSliderState.currentSlide = (window.genreHeroSliderState.currentSlide + 1) % window.genreHeroSliderState.totalSlides; + updateGenreHeroSlide(window.genreHeroSliderState.currentSlide); + console.log(`➡️ Next: Moving to slide ${window.genreHeroSliderState.currentSlide}`); + }); + } + + // Set up indicator handlers + const indicators = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-indicator'); + indicators.forEach((indicator, index) => { + indicator.addEventListener('click', () => { + window.genreHeroSliderState.currentSlide = index; + updateGenreHeroSlide(index); + console.log(`🎯 Indicator: Jumping to slide ${index}`); + }); + }); + + // Set up individual slide click handlers (like the main hero slider) + const slides = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-slide[data-url]'); + console.log(`🔗 Found ${slides.length} slides to set up click handlers for`); + + slides.forEach((slide, index) => { + const releaseUrl = slide.getAttribute('data-url'); + if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') { + const release = releases[index]; + if (release) { + // Ensure we use the absolute URL and match the expected data structure + const releaseData = { + url: releaseUrl, // This is already the absolute URL from data-url + title: release.title || 'Unknown Title', + artist: release.artists_string || 'Unknown Artist', // handleBeatportReleaseCardClick expects 'artist' + label: release.label || 'Unknown Label', + image_url: release.image_url || '', + // Include all original data for completeness + artists_string: release.artists_string, + type: release.type, + source: release.source, + badges: release.badges || [] + }; + + slide.addEventListener('click', async (event) => { + // Prevent navigation button clicks from triggering this + if (event.target.closest('.beatport-rebuild-nav-btn') || + event.target.closest('.beatport-rebuild-indicator')) { + return; + } + + console.log(`🎵 Genre hero slide clicked: ${releaseData.title} by ${releaseData.artist}`); + + // Use the exact same functionality as the main hero slider + await handleBeatportReleaseCardClick(slide, releaseData); + }); + + slide.style.cursor = 'pointer'; + } + } + }); + + // Ensure first slide is active BEFORE starting auto-play + updateGenreHeroSlide(0); + + // Delay auto-play start to let DOM settle + setTimeout(() => { + startGenreHeroSliderAutoPlay(); + }, 100); + + // Pause on hover + const sliderContainer = document.querySelector('#genre-hero-slider'); + if (sliderContainer) { + sliderContainer.addEventListener('mouseenter', () => { + if (window.genreHeroSliderState.autoPlayInterval) { + clearInterval(window.genreHeroSliderState.autoPlayInterval); + console.log('⏸️ Paused auto-play on hover'); + } + }); + + sliderContainer.addEventListener('mouseleave', () => { + // Delay restart to avoid rapid state changes + setTimeout(() => { + startGenreHeroSliderAutoPlay(); + }, 100); + console.log('▶️ Resumed auto-play after hover'); + }); + } + + console.log(`✅ Set up slider functionality for ${releases.length} genre hero releases`); +} + +function updateGenreHeroSlide(slideIndex) { + if (!window.genreHeroSliderState) { + console.error('❌ Genre hero slider state not initialized'); + return; + } + + // First update the state + window.genreHeroSliderState.currentSlide = slideIndex; + + // Update slide visibility - use the exact same logic as main slider + const slides = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-slide'); + console.log(`🔄 Updating slide to index ${slideIndex}, found ${slides.length} slides`); + + if (slideIndex >= slides.length || slideIndex < 0) { + console.error(`❌ Invalid slide index ${slideIndex}, max is ${slides.length - 1}`); + return; + } + + slides.forEach((slide, index) => { + slide.classList.remove('active', 'prev', 'next'); + + if (index === slideIndex) { + slide.classList.add('active'); + console.log(`✅ Activated slide ${index}: ${slide.getAttribute('data-slide')} - Title: ${slide.querySelector('.beatport-rebuild-track-title')?.textContent}`); + } else if (index < slideIndex) { + slide.classList.add('prev'); + } else { + slide.classList.add('next'); + } + }); + + // Update indicators + const indicators = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-indicator'); + indicators.forEach((indicator, index) => { + indicator.classList.toggle('active', index === slideIndex); + }); + + console.log(`Genre slide updated to: ${window.genreHeroSliderState.currentSlide}`); +} + +function startGenreHeroSliderAutoPlay() { + if (!window.genreHeroSliderState) { + console.error('❌ Cannot start auto-play: Genre hero slider state not initialized'); + return; + } + + // Clear any existing intervals first + if (window.genreHeroSliderState.autoPlayInterval) { + clearInterval(window.genreHeroSliderState.autoPlayInterval); + console.log('🧹 Cleared existing auto-play interval'); + } + + window.genreHeroSliderState.autoPlayInterval = setInterval(() => { + if (!window.genreHeroSliderState) { + console.error('❌ Auto-play fired but state is gone, clearing interval'); + clearInterval(window.genreHeroSliderState.autoPlayInterval); + return; + } + + const currentSlide = window.genreHeroSliderState.currentSlide; + const totalSlides = window.genreHeroSliderState.totalSlides; + const nextSlide = (currentSlide + 1) % totalSlides; + + console.log(`⏰ Auto-play: Current=${currentSlide}, Total=${totalSlides}, Next=${nextSlide}`); + + // Validate the next slide index + if (nextSlide >= 0 && nextSlide < totalSlides) { + updateGenreHeroSlide(nextSlide); + } else { + console.error(`❌ Invalid nextSlide calculated: ${nextSlide}, resetting to 0`); + updateGenreHeroSlide(0); + } + }, 5000); // 5 second intervals like the main slider + + console.log(`▶️ Started auto-play for genre hero slider (${window.genreHeroSliderState.totalSlides} slides)`); +} + +/** + * Load Top 10 lists for a specific genre (Beatport + Hype) + */ +async function loadGenreTop10Lists(genreSlug, genreId, genreName) { + console.log(`🎵 Loading Top 10 lists for ${genreName}...`); + + const container = document.getElementById('genre-top10-lists-container'); + if (!container) { + console.error('❌ Genre Top 10 lists container not found'); + return; + } + + try { + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-lists`); + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load Top 10 lists'); + } + + console.log(`✅ Loaded ${data.beatport_count} Beatport + ${data.hype_count} Hype Top 10 tracks for ${genreName}`); + + // Generate HTML using exact same structure as main page (but unique IDs) + const top10ListsHTML = createGenreTop10ListsHTML(data, genreName); + container.innerHTML = top10ListsHTML; + + // Add container-level click handlers exactly like main page + addGenreTop10ClickHandlers(); + + console.log(`✅ Successfully populated genre Top 10 lists for ${genreName}`); + + } catch (error) { + console.error(`❌ Error loading Top 10 lists for ${genreName}:`, error); + + // Show error state + container.innerHTML = ` +
+

❌ Error Loading Top 10 Lists

+

Could not load Top 10 tracks for ${genreName}

+

${error.message}

+
+ `; + } +} + +/** + * Create HTML for genre Top 10 lists (exact structure as main page, unique IDs) + */ +function createGenreTop10ListsHTML(data, genreName) { + const { beatport_top10, hype_top10, has_hype_section } = data; + + // Use exact same structure as main page but with genre-specific IDs + let html = ` +
+
+

🏆 ${genreName} Top 10 Lists

+

Current trending ${genreName.toLowerCase()} tracks

+
+ +
+ +
+
+

🎵 Beatport Top 10

+

Most popular ${genreName.toLowerCase()} tracks

+
+
+ `; + + // Add Beatport Top 10 tracks (same classes as main page) + beatport_top10.forEach((track, index) => { + const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); + const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); + const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); + + html += ` +
+
${track.rank || index + 1}
+
+ ${track.artwork_url ? + `${cleanTitle}` : + '
🎵
' + } +
+
+

${cleanTitle}

+

${cleanArtist}

+

${cleanLabel}

+
+
+ `; + }); + + html += ` +
+
+ `; + + // Add Hype Top 10 section (same classes, unique ID) + if (has_hype_section && hype_top10.length > 0) { + html += ` + +
+
+

🔥 Hype Top 10

+

Editor's trending ${genreName.toLowerCase()} picks

+
+
+ `; + + // Add Hype Top 10 tracks (same classes as main page) + hype_top10.forEach((track, index) => { + const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); + const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); + const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); + + html += ` +
+
${track.rank || index + 1}
+
+ ${track.artwork_url ? + `${cleanTitle}` : + '
🔥
' + } +
+
+

${cleanTitle}

+

${cleanArtist}

+

${cleanLabel}

+
+
+ `; + }); + + html += ` +
+
+ `; + } + // No else block - completely hide hype section when no hype tracks available + + html += ` +
+
+ `; + + return html; +} + +/** + * Add container-level click handlers for genre Top 10 lists (exact parity with main page) + */ +function addGenreTop10ClickHandlers() { + console.log('🔗 Adding container-level click handlers for genre Top 10 lists...'); + + // Add container-level click handler for Beatport Top 10 (exact match to main page) + const beatportContainer = document.getElementById('genre-beatport-top10-list'); + if (beatportContainer) { + beatportContainer.addEventListener('click', () => { + console.log('🎵 Genre Beatport Top 10 container clicked'); + handleGenreBeatportTop10Click(); + }); + console.log('✅ Added Beatport Top 10 container click handler'); + } + + // Add container-level click handler for Hype Top 10 (exact match to main page) + const hypeContainer = document.getElementById('genre-beatport-hype10-list'); + if (hypeContainer) { + hypeContainer.addEventListener('click', () => { + console.log('🔥 Genre Hype Top 10 container clicked'); + handleGenreHypeTop10Click(); + }); + console.log('✅ Added Hype Top 10 container click handler'); + } + + console.log(`✅ Set up container-level click handlers for genre Top 10 lists`); +} + +/** + * Handle genre Beatport Top 10 container click (exact parity with main page) + */ +async function handleGenreBeatportTop10Click() { + console.log('🎵 Handling Genre Beatport Top 10 click'); + + // Get the actual genre name from the page title + const genreName = document.querySelector('.genre-page-title')?.textContent?.trim() || 'Genre'; + + // Use actual genre name in chart title + await handleGenreChartClick('genre_beatport_top10', `${genreName} Beatport Top 10`, 'genre_beatport_top10'); +} + +/** + * Handle genre Hype Top 10 container click (exact parity with main page) + */ +async function handleGenreHypeTop10Click() { + console.log('🔥 Handling Genre Hype Top 10 click'); + + // Get the actual genre name from the page title + const genreName = document.querySelector('.genre-page-title')?.textContent?.trim() || 'Genre'; + + // Use actual genre name in chart title + await handleGenreChartClick('genre_hype_top10', `${genreName} Hype Top 10`, 'genre_hype_top10'); +} + +/** + * Handle genre chart click (based on main page handleRebuildChartClick) + */ +async function handleGenreChartClick(trackDataKey, chartName, chartType) { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + setTimeout(() => { _beatportModalOpening = false; }, 2000); + + try { + // Extract track data from DOM cards + const trackData = await getGenrePageTrackData(trackDataKey); + if (!trackData || trackData.length === 0) { + throw new Error(`No track data found for ${chartName}`); + } + + console.log(`✅ Got ${trackData.length} tracks from ${chartName}, enriching one-by-one...`); + showLoadingOverlay(`Fetching track metadata... (0/${trackData.length})`); + + const enrichedTracks = await _enrichTracksWithProgress(trackData, chartName); + + console.log(`✅ Enriched ${enrichedTracks.length} tracks`); + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); + + } catch (error) { + hideLoadingOverlay(); + console.error(`❌ Error handling ${chartName} click:`, error); + showToast(`Error loading ${chartName}: ${error.message}`, 'error'); + } +} + +/** + * Extract track data from genre page DOM (based on main page getRebuildPageTrackData) + */ +async function getGenrePageTrackData(trackDataKey) { + console.log(`🔍 Extracting ${trackDataKey} data from genre page DOM`); + + let containerSelector, cardSelector; + if (trackDataKey === 'genre_beatport_top10') { + containerSelector = '#genre-beatport-top10-list'; + cardSelector = '.beatport-top10-card[data-url]'; + } else if (trackDataKey === 'genre_hype_top10') { + containerSelector = '#genre-beatport-hype10-list'; + cardSelector = '.beatport-hype10-card[data-url]'; + } else { + throw new Error(`Unknown track data key: ${trackDataKey}`); + } + + const container = document.querySelector(containerSelector); + if (!container) { + throw new Error(`Container ${containerSelector} not found`); + } + + const trackCards = container.querySelectorAll(cardSelector); + if (trackCards.length === 0) { + throw new Error(`No track cards found in ${containerSelector}`); + } + + // Extract track data from DOM cards (exact same pattern as main page) + const tracks = Array.from(trackCards).map(card => { + const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title'; + const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist'; + const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label'; + const url = card.getAttribute('data-url') || ''; + const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || ''; + + return { + title: title, + artist: artist, + label: label, + url: url, + rank: rank + }; + }); + + console.log(`📋 Extracted ${tracks.length} tracks from ${containerSelector}`); + return tracks; +} + +/** + * Handle genre-specific Top 100 button click - create discovery process for genre top 100 tracks + */ +async function handleGenreTop100Click(genreSlug, genreId, genreName) { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + setTimeout(() => { _beatportModalOpening = false; }, 2000); + + console.log(`💯 Genre Top 100 button clicked for ${genreName}`); + + const chartName = `${genreName} Top 100`; + + try { + showLoadingOverlay(`Scraping ${chartName}...`); + + // Use the genre tracks endpoint without enrichment + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/tracks?enrich=false`, { method: 'GET' }); + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartName}`); + } + + console.log(`✅ Fetched ${data.tracks.length} tracks, enriching one-by-one...`); + + // Enrich one-by-one with live progress + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); + + } catch (error) { + console.error(`❌ Error handling ${chartName} click:`, error); + hideLoadingOverlay(); + showToast(`Error loading ${chartName}: ${error.message}`, 'error'); + } +} + +/** + * Load Top 10 releases for a specific genre + */ +async function loadGenreTop10Releases(genreSlug, genreId, genreName) { + console.log(`💿 Loading Top 10 releases for ${genreName}...`); + + const container = document.getElementById('genre-top10-releases-container'); + if (!container) { + console.error('❌ Genre Top 10 releases container not found'); + return; + } + + try { + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-releases`); + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load Top 10 releases'); + } + + console.log(`💿 Loaded ${data.releases.length} Top 10 releases for ${genreName}`); + createGenreTop10ReleasesHTML(data.releases, genreName); + + } catch (error) { + console.error(`❌ Error loading Top 10 releases for ${genreName}:`, error); + showGenreTop10ReleasesError(error.message || 'Failed to load Top 10 releases'); + } +} + +/** + * Create HTML for genre Top 10 releases section (exact parity with main page) + */ +function createGenreTop10ReleasesHTML(releases, genreName) { + const container = document.getElementById('genre-top10-releases-container'); + if (!container || !releases || releases.length === 0) return; + + // Create section with unique ID but exact same structure as main page + const sectionHtml = ` +
+
+

💿 Top 10 ${genreName} Releases

+

Most popular albums and EPs for ${genreName}

+
+
+
+ ${createGenreTop10ReleasesCardsHTML(releases)} +
+
+
+ `; + + container.innerHTML = sectionHtml; + + // Add background images and click handlers + addGenreTop10ReleasesInteractivity(releases); +} + +/** + * Create release cards HTML for genre Top 10 releases + */ +function createGenreTop10ReleasesCardsHTML(releases) { + let cardsHtml = '
'; + + releases.forEach((release, index) => { + cardsHtml += ` +
+
${release.rank || index + 1}
+
+ ${release.image_url ? + `${release.title}` : + '
💿
' + } +
+
+

${release.title || 'Unknown Title'}

+

${release.artist || 'Unknown Artist'}

+

${release.label || 'Unknown Label'}

+
+
+ `; + }); + + cardsHtml += '
'; + return cardsHtml; +} + +/** + * Add interactivity to genre Top 10 releases cards + */ +function addGenreTop10ReleasesInteractivity(releases) { + const container = document.getElementById('genre-beatport-releases-top10-list'); + if (!container) return; + + // Set background images for cards + const cards = container.querySelectorAll('.beatport-releases-top10-card[data-bg-image]'); + cards.forEach(card => { + const bgImage = card.getAttribute('data-bg-image'); + if (bgImage) { + // Transform image URL from 95x95 to 500x500 for higher quality background + const highResImage = bgImage.replace('/image_size/95x95/', '/image_size/500x500/'); + card.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.8)), url('${highResImage}')`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } + }); + + // Add click handlers for individual release discovery (exact same pattern as main page) + const releaseCards = container.querySelectorAll('.beatport-releases-top10-card[data-url]'); + releaseCards.forEach((card, index) => { + card.addEventListener('click', () => handleGenreReleaseCardClick(card, releases[index])); + card.style.cursor = 'pointer'; + }); +} + +/** + * Handle click on individual genre Top 10 Release card (exact parity with main page) + */ +async function handleGenreReleaseCardClick(cardElement, release) { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + + console.log(`💿 Individual genre release card clicked: ${release.title} by ${release.artist}`); + + if (!release.url || release.url === '#') { + _beatportModalOpening = false; + showToast('No release URL available', 'error'); + return; + } + + try { + showToast(`Loading ${release.title}...`, 'info'); + showLoadingOverlay(`Getting tracks from ${release.title}...`); + + // Fetch structured release metadata for direct download modal + console.log(`🎵 Fetching release metadata: ${release.url}`); + const response = await fetch('/api/beatport/release-metadata', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ release_url: release.url }) + }); + + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(data.error || 'No tracks found in this release'); + } + + console.log(`✅ Got ${data.tracks.length} tracks from ${data.album.name}`); + + const formattedTracks = data.tracks.map(track => ({ + ...track, + artists: track.artists.map(a => typeof a === 'object' ? a.name : a) + })); + + const virtualPlaylistId = `beatport_release_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const playlistName = data.album.name; + + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + playlistName, + formattedTracks, + data.album, + data.artist, + false + ); + + hideLoadingOverlay(); + _beatportModalOpening = false; + console.log(`✅ Opened download modal for ${playlistName}`); + + } catch (error) { + console.error(`❌ Error handling release click for ${release.title}:`, error); + hideLoadingOverlay(); + _beatportModalOpening = false; + showToast(`Error loading ${release.title}: ${error.message}`, 'error'); + } +} + +/** + * Show error message for genre Top 10 releases + */ +function showGenreTop10ReleasesError(errorMessage) { + const container = document.getElementById('genre-top10-releases-container'); + + const errorHtml = ` +
+
+

💿 Top 10 Releases

+

Error loading releases

+
+
+
+

❌ Error Loading Releases

+

${errorMessage}

+
+
+
+ `; + + if (container) container.innerHTML = errorHtml; +} + +// Initialize the Genre Browser Modal when the page loads +document.addEventListener('DOMContentLoaded', () => { + initializeGenreBrowserModal(); +}); + +// ============ Plex Music Library Selection ============ + +async function loadPlexMusicLibraries() { + try { + const response = await fetch('/api/plex/music-libraries'); + const data = await response.json(); + + if (data.success && data.libraries && data.libraries.length > 0) { + const selector = document.getElementById('plex-music-library'); + const container = document.getElementById('plex-library-selector-container'); + + // Clear existing options + selector.innerHTML = ''; + + // Add options for each library + data.libraries.forEach(library => { + const option = document.createElement('option'); + option.value = library.title; + option.textContent = library.title; + + // Mark the currently selected library + if (library.title === data.current || library.title === data.selected) { + option.selected = true; + } + + selector.appendChild(option); + }); + + // Show the container + container.style.display = 'block'; + } else { + // Hide if no libraries found or not connected + document.getElementById('plex-library-selector-container').style.display = 'none'; + } + } catch (error) { + console.error('Error loading Plex music libraries:', error); + document.getElementById('plex-library-selector-container').style.display = 'none'; + } +} + +async function selectPlexLibrary() { + const selector = document.getElementById('plex-music-library'); + const selectedLibrary = selector.value; + + if (!selectedLibrary) return; + + try { + const response = await fetch('/api/plex/select-music-library', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + library_name: selectedLibrary + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`Plex music library switched to: ${selectedLibrary}`); + } else { + console.error('Failed to switch library:', data.error); + alert(`Failed to switch library: ${data.error}`); + } + } catch (error) { + console.error('Error selecting Plex library:', error); + alert('Error selecting library. Please try again.'); + } +} + +// ============ Jellyfin User Selection ============ + +async function loadJellyfinUsers() { + try { + const response = await fetch('/api/jellyfin/users'); + const data = await response.json(); + + if (data.success && data.users && data.users.length > 0) { + const selector = document.getElementById('jellyfin-user'); + const container = document.getElementById('jellyfin-user-selector-container'); + + // Clear existing options + selector.innerHTML = ''; + + // Add options for each user + data.users.forEach(user => { + const option = document.createElement('option'); + option.value = user.name; + option.textContent = user.name; + + // Mark the currently selected user + if (user.name === data.current || user.name === data.selected) { + option.selected = true; + } + + selector.appendChild(option); + }); + + // Show the container + container.style.display = 'block'; + } else { + // Hide if no users found or not connected + document.getElementById('jellyfin-user-selector-container').style.display = 'none'; + } + } catch (error) { + console.error('Error loading Jellyfin users:', error); + document.getElementById('jellyfin-user-selector-container').style.display = 'none'; + } +} + +async function selectJellyfinUser() { + const selector = document.getElementById('jellyfin-user'); + const selectedUser = selector.value; + + if (!selectedUser) return; + + try { + const response = await fetch('/api/jellyfin/select-user', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + username: selectedUser + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`Jellyfin user switched to: ${selectedUser}`); + // Refresh library dropdown for the new user + loadJellyfinMusicLibraries(); + } else { + console.error('Failed to switch user:', data.error); + alert(`Failed to switch user: ${data.error}`); + } + } catch (error) { + console.error('Error selecting Jellyfin user:', error); + alert('Error selecting user. Please try again.'); + } +} + +// ============ Jellyfin Music Library Selection ============ + +async function loadJellyfinMusicLibraries() { + try { + const response = await fetch('/api/jellyfin/music-libraries'); + const data = await response.json(); + + if (data.success && data.libraries && data.libraries.length > 0) { + const selector = document.getElementById('jellyfin-music-library'); + const container = document.getElementById('jellyfin-library-selector-container'); + + // Clear existing options + selector.innerHTML = ''; + + // Add options for each library + data.libraries.forEach(library => { + const option = document.createElement('option'); + option.value = library.title; + option.textContent = library.title; + + // Mark the currently selected library + if (library.title === data.current || library.title === data.selected) { + option.selected = true; + } + + selector.appendChild(option); + }); + + // Show the container + container.style.display = 'block'; + } else { + // Hide if no libraries found or not connected + document.getElementById('jellyfin-library-selector-container').style.display = 'none'; + } + } catch (error) { + console.error('Error loading Jellyfin music libraries:', error); + document.getElementById('jellyfin-library-selector-container').style.display = 'none'; + } +} + +async function selectJellyfinLibrary() { + const selector = document.getElementById('jellyfin-music-library'); + const selectedLibrary = selector.value; + + if (!selectedLibrary) return; + + try { + const response = await fetch('/api/jellyfin/select-music-library', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + library_name: selectedLibrary + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`Jellyfin music library switched to: ${selectedLibrary}`); + } else { + console.error('Failed to switch library:', data.error); + alert(`Failed to switch library: ${data.error}`); + } + } catch (error) { + console.error('Error selecting Jellyfin library:', error); + alert('Error selecting library. Please try again.'); + } +} + +// ============ Navidrome Music Folder Selection ============ + +async function loadNavidromeMusicFolders() { + try { + const response = await fetch('/api/navidrome/music-folders'); + const data = await response.json(); + + if (data.success && data.folders && data.folders.length > 0) { + const selector = document.getElementById('navidrome-music-folder'); + const container = document.getElementById('navidrome-folder-selector-container'); + + selector.innerHTML = ''; + + data.folders.forEach(folder => { + const option = document.createElement('option'); + option.value = folder.title; + option.textContent = folder.title; + + if (folder.title === data.current || folder.title === data.selected) { + option.selected = true; + } + + selector.appendChild(option); + }); + + container.style.display = 'block'; + } else { + document.getElementById('navidrome-folder-selector-container').style.display = 'none'; + } + } catch (error) { + console.error('Error loading Navidrome music folders:', error); + document.getElementById('navidrome-folder-selector-container').style.display = 'none'; + } +} + +async function selectNavidromeMusicFolder() { + const selector = document.getElementById('navidrome-music-folder'); + const selectedFolder = selector.value; + + try { + const response = await fetch('/api/navidrome/select-music-folder', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_name: selectedFolder }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + } else { + console.error('Failed to set music folder:', data.error); + showToast(`Failed to set music folder: ${data.error}`, 'error', 'set-media'); + } + } catch (error) { + console.error('Error selecting Navidrome music folder:', error); + showToast('Error selecting music folder. Please try again.', 'error', 'set-media'); + } +} + +// ============================================ + diff --git a/webui/static/core.js b/webui/static/core.js new file mode 100644 index 00000000..32a75406 --- /dev/null +++ b/webui/static/core.js @@ -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 = ` +
+

Mass Deletion Warning

+

+ You are about to permanently delete ${orphanCount.toLocaleString()} files from your disk. +

+

+ 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. +

+

+ To confirm you understand the risk, type witness me below: +

+ +
+ + +
+
+ `; + + 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) + +// =============================== + diff --git a/webui/static/discover.js b/webui/static/discover.js new file mode 100644 index 00000000..eb50d2ba --- /dev/null +++ b/webui/static/discover.js @@ -0,0 +1,8905 @@ +// == DISCOVER PAGE == +// ============================================ + +let discoverHeroIndex = 0; +let discoverHeroArtists = []; +let discoverHeroInterval = null; +let discoverPageInitialized = false; + +// Store discover playlist tracks for download/sync functionality +let discoverReleaseRadarTracks = []; +let discoverWeeklyTracks = []; +let discoverRecentAlbums = []; +let discoverSeasonalAlbums = []; +let discoverSeasonalTracks = []; +let currentSeasonKey = null; + +// Personalized playlists storage +let personalizedRecentlyAdded = []; +let personalizedTopTracks = []; +let personalizedForgottenFavorites = []; +let personalizedPopularPicks = []; +let personalizedHiddenGems = []; +let personalizedDailyMixes = []; +let personalizedDiscoveryShuffle = []; +let personalizedFamiliarFavorites = []; +let buildPlaylistSelectedArtists = []; + +async function loadDiscoverPage() { + console.log('Loading discover page...'); + + // Load all sections + await Promise.all([ + loadDiscoverHero(), + loadYourArtists(), + loadYourAlbums(), + loadSpotifyLibrarySection(), + loadDiscoverRecentReleases(), + loadSeasonalContent(), // Seasonal discovery + loadPersonalizedRecentlyAdded(), // NEW: Recently added from library + // loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN) + loadDiscoverReleaseRadar(), + loadDiscoverWeekly(), + loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool + loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool + loadPersonalizedTopTracks(), // NEW: Your top tracks + loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites + loadDiscoveryShuffle(), // NEW: Discovery Shuffle + loadFamiliarFavorites(), // NEW: Familiar Favorites + loadBecauseYouListenTo(), // Personalized by listening stats + loadCacheUndiscoveredAlbums(), // From metadata cache + loadCacheGenreNewReleases(), // From metadata cache + loadCacheLabelExplorer(), // From metadata cache + loadCacheDeepCuts(), // From metadata cache + loadCacheGenreExplorer(), // From metadata cache + initializeLastfmRadioSection(), // Last.fm Radio section (gated on API key) + initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed) + loadDecadeBrowserTabs(), // Time Machine (tabbed by decade) + loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre) + loadListenBrainzPlaylistsFromBackend(), // Load ListenBrainz playlist states for persistence + loadDiscoveryBlacklist() // Blocked artists list + ]); + + // Check for active syncs after page load + checkForActiveDiscoverSyncs(); +} + +async function checkForActiveDiscoverSyncs() { + // Check if Fresh Tape sync is active + try { + const releaseRadarResponse = await fetch('/api/sync/status/discover_release_radar'); + if (releaseRadarResponse.ok) { + const data = await releaseRadarResponse.json(); + if (data.status === 'syncing' || data.status === 'starting') { + console.log('🔄 Resuming Fresh Tape sync polling after page refresh'); + + // Show status display + const statusDisplay = document.getElementById('release-radar-sync-status'); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + // Disable button + const syncButton = document.getElementById('release-radar-sync-btn'); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Resume polling + startDiscoverSyncPolling('release_radar', 'discover_release_radar'); + } + } + } catch (error) { + // Sync not active, ignore + } + + // Check if The Archives sync is active + try { + const discoveryWeeklyResponse = await fetch('/api/sync/status/discover_discovery_weekly'); + if (discoveryWeeklyResponse.ok) { + const data = await discoveryWeeklyResponse.json(); + if (data.status === 'syncing' || data.status === 'starting') { + console.log('🔄 Resuming The Archives sync polling after page refresh'); + + // Show status display + const statusDisplay = document.getElementById('discovery-weekly-sync-status'); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + // Disable button + const syncButton = document.getElementById('discovery-weekly-sync-btn'); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Resume polling + startDiscoverSyncPolling('discovery_weekly', 'discover_discovery_weekly'); + } + } + } catch (error) { + // Sync not active, ignore + } + + // Check if Seasonal Playlist sync is active + try { + const seasonalResponse = await fetch('/api/sync/status/discover_seasonal_playlist'); + if (seasonalResponse.ok) { + const data = await seasonalResponse.json(); + if (data.status === 'syncing' || data.status === 'starting') { + console.log('🔄 Resuming Seasonal Playlist sync polling after page refresh'); + + const statusDisplay = document.getElementById('seasonal-playlist-sync-status'); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + const syncButton = document.getElementById('seasonal-playlist-sync-btn'); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + startDiscoverSyncPolling('seasonal_playlist', 'discover_seasonal_playlist'); + } + } + } catch (error) { + // Sync not active, ignore + } +} + +async function loadDiscoverHero() { + try { + const response = await fetch('/api/discover/hero'); + if (!response.ok) { + console.error('Failed to fetch discover hero'); + return; + } + + const data = await response.json(); + if (!data.success || !data.artists || data.artists.length === 0) { + console.log('No hero artists available'); + showDiscoverHeroEmpty(); + return; + } + + discoverHeroArtists = data.artists; + discoverHeroIndex = 0; + + // Display first artist + displayDiscoverHeroArtist(discoverHeroArtists[0]); + + // Start slideshow (change every 8 seconds) + if (discoverHeroInterval) { + clearInterval(discoverHeroInterval); + } + if (discoverHeroArtists.length > 1) { + discoverHeroInterval = setInterval(() => { + discoverHeroIndex = (discoverHeroIndex + 1) % discoverHeroArtists.length; + displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); + }, 8000); + } + + // Check if all hero artists are already watched + checkAllHeroWatchlistStatus(); + + } catch (error) { + console.error('Error loading discover hero:', error); + showDiscoverHeroEmpty(); + } +} + +function displayDiscoverHeroArtist(artist) { + const titleEl = document.getElementById('discover-hero-title'); + const subtitleEl = document.getElementById('discover-hero-subtitle'); + const metaEl = document.getElementById('discover-hero-meta'); + const imageEl = document.getElementById('discover-hero-image'); + const bgEl = document.getElementById('discover-hero-bg'); + + if (titleEl) { + titleEl.textContent = artist.artist_name; + } + + if (subtitleEl) { + // Show recommendation context based on occurrence count + let subtitle = ''; + if (artist.occurrence_count > 1) { + subtitle = `Similar to ${artist.occurrence_count} artists in your watchlist`; + } else { + subtitle = 'Similar to an artist in your watchlist'; + } + subtitleEl.textContent = subtitle; + } + + // Build metadata section with popularity and genres + if (metaEl) { + let metaHTML = '
'; + + // Add popularity indicator + if (artist.popularity !== undefined && artist.popularity > 0) { + const popularityClass = artist.popularity >= 80 ? 'high' : + artist.popularity >= 50 ? 'medium' : 'low'; + metaHTML += ` +
+ + ${artist.popularity}/100 + Popularity +
+ `; + } + + // Add genre tags + if (artist.genres && artist.genres.length > 0) { + metaHTML += '
'; + artist.genres.slice(0, 3).forEach(genre => { + metaHTML += `${genre}`; + }); + metaHTML += '
'; + } + + metaHTML += '
'; + metaEl.innerHTML = metaHTML; + } + + if (imageEl && artist.image_url) { + imageEl.innerHTML = `${artist.artist_name}`; + } else if (imageEl) { + imageEl.innerHTML = '
🎧
'; + } + + if (bgEl && artist.image_url) { + bgEl.style.backgroundImage = `url('${artist.image_url}')`; + bgEl.style.backgroundSize = 'cover'; + bgEl.style.backgroundPosition = 'center'; + } + + // Store artist ID for both buttons and update watchlist state + // Use artist_id which is set by the backend to the appropriate ID for the active source + const addBtn = document.getElementById('discover-hero-add'); + const discographyBtn = document.getElementById('discover-hero-discography'); + const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id; + + if (addBtn && artistId) { + addBtn.setAttribute('data-artist-id', artistId); + addBtn.setAttribute('data-artist-name', artist.artist_name); + // Also store both IDs for cross-source operations + if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); + if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id); + + // Check if this artist is already in watchlist and update button appearance + checkAndUpdateDiscoverHeroWatchlistButton(artistId); + } + + if (discographyBtn && artistId) { + discographyBtn.setAttribute('data-artist-id', artistId); + discographyBtn.setAttribute('data-artist-name', artist.artist_name); + // Source the click handler will pass to navigateToArtistDetail. Without + // this, source-only hero artists (which is the typical case — they + // come from discover similar-artists, not the library) get looked up + // as library IDs and 404. Backend always includes artist.source. + if (artist.source) discographyBtn.setAttribute('data-source', artist.source); + else discographyBtn.removeAttribute('data-source'); + // Also store both IDs for cross-source operations + if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); + if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id); + } + + // Update slideshow indicators + updateDiscoverHeroIndicators(); +} + +async function checkAndUpdateDiscoverHeroWatchlistButton(artistId) { + try { + const response = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const data = await response.json(); + if (!data.success) return; + + const addBtn = document.getElementById('discover-hero-add'); + if (!addBtn) return; + + const icon = addBtn.querySelector('.watchlist-icon'); + const text = addBtn.querySelector('.watchlist-text'); + + if (data.is_watching) { + // Artist is in watchlist + if (icon) icon.textContent = '👁️'; + if (text) text.textContent = 'Watching...'; + addBtn.classList.add('watching'); + } else { + // Artist not in watchlist + if (icon) icon.textContent = '👁️'; + if (text) text.textContent = 'Add to Watchlist'; + addBtn.classList.remove('watching'); + } + } catch (error) { + console.error('Error checking watchlist status for hero:', error); + } +} + +function toggleDiscoverHeroWatchlist(event) { + event.stopPropagation(); + + const button = document.getElementById('discover-hero-add'); + if (!button) return; + + const artistId = button.getAttribute('data-artist-id'); + const artistName = button.getAttribute('data-artist-name'); + + if (!artistId || !artistName) { + console.error('No artist data found on discover hero button'); + return; + } + + // Call the existing toggleWatchlist function + toggleWatchlist(event, artistId, artistName); +} + +async function watchAllHeroArtists(btn) { + if (!discoverHeroArtists || discoverHeroArtists.length === 0) return; + if (btn.classList.contains('all-watched')) return; + + const textEl = btn.querySelector('.watch-all-text'); + const originalText = textEl ? textEl.textContent : ''; + + // Loading state + btn.disabled = true; + if (textEl) textEl.textContent = 'Adding...'; + + try { + const artists = discoverHeroArtists.map(a => ({ + artist_id: a.artist_id, + artist_name: a.artist_name + })); + + const response = await fetch('/api/watchlist/add-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artists }) + }); + + const data = await response.json(); + if (data.success) { + if (textEl) textEl.textContent = 'All Watched'; + btn.classList.add('all-watched'); + btn.disabled = true; + + // Sync the per-slide watchlist button for current artist + const currentArtist = discoverHeroArtists[discoverHeroIndex]; + if (currentArtist) { + checkAndUpdateDiscoverHeroWatchlistButton(currentArtist.artist_id); + } + + // Update watchlist count badge + if (typeof updateWatchlistButtonCount === 'function') { + updateWatchlistButtonCount(); + } + } else { + if (textEl) textEl.textContent = originalText; + btn.disabled = false; + } + } catch (error) { + console.error('Error watching all hero artists:', error); + if (textEl) textEl.textContent = originalText; + btn.disabled = false; + } +} + +// Cache for recommended artists data so reopening is instant +let _recommendedArtistsCache = null; + +async function openRecommendedArtistsModal() { + let modal = document.getElementById('recommended-artists-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'recommended-artists-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + + modal.addEventListener('click', function (e) { + if (e.target === modal) closeRecommendedArtistsModal(); + }); + } + + // If cached, render instantly and refresh watchlist statuses + if (_recommendedArtistsCache) { + modal.style.display = 'flex'; + renderRecommendedArtistsModal(modal, _recommendedArtistsCache); + checkRecommendedWatchlistStatuses(_recommendedArtistsCache); + return; + } + + // Show loading + modal.innerHTML = ` + + `; + modal.style.display = 'flex'; + + try { + // Phase 1: Fetch basic data (instant — no API enrichment) + const response = await fetch('/api/discover/similar-artists'); + const data = await response.json(); + + if (!data.success || !data.artists || data.artists.length === 0) { + modal.querySelector('.playlist-modal-body').innerHTML = ` + + `; + modal.querySelector('.playlist-track-count').textContent = '0 artists'; + return; + } + + // Render cards immediately with fallback images + _recommendedArtistsCache = data.artists; + renderRecommendedArtistsModal(modal, data.artists); + + // Phase 2: Enrich with images/genres progressively in batches of 50 + // Skip artists that already have cached metadata from the initial response + const source = data.source || 'spotify'; + const idKey = source === 'spotify' ? 'spotify_artist_id' : source === 'deezer' ? 'deezer_artist_id' : 'itunes_artist_id'; + const allIds = data.artists + .filter(a => !a.image_url) // Only enrich artists without cached images + .map(a => a[idKey]).filter(Boolean); + + for (let i = 0; i < allIds.length; i += 50) { + const batchIds = allIds.slice(i, i + 50); + try { + const enrichResp = await fetch('/api/discover/similar-artists/enrich', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_ids: batchIds, source }) + }); + const enrichData = await enrichResp.json(); + if (enrichData.success && enrichData.artists) { + // Update cards and cache as each batch arrives + for (const [aid, info] of Object.entries(enrichData.artists)) { + // Update the card in DOM + const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`); + if (card && info.image_url) { + const imgContainer = card.querySelector('.recommended-card-image'); + if (imgContainer) { + imgContainer.innerHTML = ``; + } + } + if (card && info.genres && info.genres.length > 0) { + const genresContainer = card.querySelector('.recommended-card-genres'); + if (genresContainer) { + genresContainer.innerHTML = info.genres.map(g => + `${escapeHtml(g)}` + ).join(''); + } else { + const infoDiv = card.querySelector('.recommended-card-info'); + if (infoDiv) { + const genreDiv = document.createElement('div'); + genreDiv.className = 'recommended-card-genres'; + genreDiv.innerHTML = info.genres.map(g => + `${escapeHtml(g)}` + ).join(''); + infoDiv.appendChild(genreDiv); + } + } + } + + // Update cache + const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid); + if (cached) { + if (info.image_url) cached.image_url = info.image_url; + if (info.genres) cached.genres = info.genres; + if (info.artist_name) cached.artist_name = info.artist_name; + } + } + } + } catch (enrichErr) { + console.error('Error enriching batch:', enrichErr); + } + } + + // Phase 3: Check watchlist statuses + checkRecommendedWatchlistStatuses(data.artists); + + } catch (error) { + console.error('Error loading recommended artists:', error); + modal.querySelector('.playlist-modal-body').innerHTML = ` + + `; + } +} + +function renderRecommendedArtistsModal(modal, artists) { + modal.innerHTML = ` + + `; + + // Event delegation for card clicks and watchlist buttons + const grid = modal.querySelector('#recommended-artists-grid'); + if (grid) { + grid.addEventListener('click', function (e) { + const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn'); + if (watchlistBtn) { + e.stopPropagation(); + toggleRecommendedWatchlist(watchlistBtn); + return; + } + + const card = e.target.closest('.recommended-artist-card'); + if (card) { + const artistId = card.getAttribute('data-artist-id'); + const nameEl = card.querySelector('.recommended-card-name'); + const artistName = nameEl ? nameEl.textContent : ''; + viewRecommendedArtistDiscography(artistId, artistName); + } + }); + } +} + +async function addAllRecommendedToWatchlist(btn) { + if (!_recommendedArtistsCache || _recommendedArtistsCache.length === 0) return; + if (btn.classList.contains('all-added')) return; + + const originalText = btn.textContent; + btn.disabled = true; + btn.textContent = 'Adding...'; + + try { + const artists = _recommendedArtistsCache.map(a => ({ + artist_id: a.artist_id, + artist_name: a.artist_name + })); + + const resp = await fetch('/api/watchlist/add-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artists }) + }); + const data = await resp.json(); + + if (data.success) { + btn.textContent = `All Added (${data.added} new)`; + btn.classList.add('all-added'); + btn.disabled = true; + + // Update all watchlist buttons in the modal to "Watching" + document.querySelectorAll('.recommended-card-watchlist-btn').forEach(wBtn => { + wBtn.classList.add('watching'); + wBtn.textContent = 'Watching'; + }); + + if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount(); + } else { + btn.textContent = originalText; + btn.disabled = false; + } + } catch (error) { + console.error('Error adding all recommended to watchlist:', error); + btn.textContent = originalText; + btn.disabled = false; + } +} + +function closeRecommendedArtistsModal() { + const modal = document.getElementById('recommended-artists-modal'); + if (modal) modal.style.display = 'none'; +} + +function filterRecommendedArtists() { + const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase(); + const cards = document.querySelectorAll('.recommended-artist-card'); + cards.forEach(card => { + const name = card.getAttribute('data-artist-name') || ''; + card.style.display = name.includes(query) ? '' : 'none'; + }); +} + +async function toggleRecommendedWatchlist(btn) { + const artistId = btn.getAttribute('data-artist-id'); + const artistName = btn.getAttribute('data-artist-name'); + if (!artistId || !artistName) return; + + btn.disabled = true; + const wasWatching = btn.classList.contains('watching'); + + try { + if (wasWatching) { + const resp = await fetch('/api/watchlist/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + const data = await resp.json(); + if (data.success) { + btn.classList.remove('watching'); + btn.textContent = 'Add to Watchlist'; + } + } else { + const resp = await fetch('/api/watchlist/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId, artist_name: artistName }) + }); + const data = await resp.json(); + if (data.success) { + btn.classList.add('watching'); + btn.textContent = 'Watching'; + } + } + if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount(); + } catch (error) { + console.error('Error toggling recommended watchlist:', error); + } finally { + btn.disabled = false; + } +} + +async function checkRecommendedWatchlistStatuses(artists) { + try { + const artistIds = artists.map(a => a.artist_id).filter(Boolean); + if (!artistIds.length) return; + + const resp = await fetch('/api/watchlist/check-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_ids: artistIds }) + }); + const data = await resp.json(); + if (data.success && data.results) { + for (const [aid, isWatching] of Object.entries(data.results)) { + if (isWatching) { + const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${aid}"]`); + if (btn) { + btn.classList.add('watching'); + btn.textContent = 'Watching'; + } + } + } + } + } catch (e) { + // Non-critical + } +} + +async function viewRecommendedArtistDiscography(artistId, artistName) { + closeRecommendedArtistsModal(); + navigateToArtistDetail(artistId, artistName); +} + +async function checkAllHeroWatchlistStatus() { + const btn = document.getElementById('discover-hero-watch-all'); + if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return; + + try { + let allWatched = true; + for (const artist of discoverHeroArtists) { + const response = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artist.artist_id }) + }); + const data = await response.json(); + if (!data.success || !data.is_watching) { + allWatched = false; + break; + } + } + + const textEl = btn.querySelector('.watch-all-text'); + if (allWatched) { + if (textEl) textEl.textContent = 'All Watched'; + btn.classList.add('all-watched'); + btn.disabled = true; + } else { + if (textEl) textEl.textContent = 'Watch All'; + btn.classList.remove('all-watched'); + btn.disabled = false; + } + } catch (error) { + console.error('Error checking hero watchlist status:', error); + } +} + +function navigateDiscoverHero(direction) { + if (!discoverHeroArtists || discoverHeroArtists.length === 0) return; + + // Update index with wrapping + discoverHeroIndex = (discoverHeroIndex + direction + discoverHeroArtists.length) % discoverHeroArtists.length; + + // Display the artist + displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); + + // Update indicators + updateDiscoverHeroIndicators(); +} + +function updateDiscoverHeroIndicators() { + const indicatorsContainer = document.getElementById('discover-hero-indicators'); + if (!indicatorsContainer || !discoverHeroArtists || discoverHeroArtists.length === 0) return; + + // Create indicator dots + indicatorsContainer.innerHTML = discoverHeroArtists.map((_, index) => ` + + `).join(''); +} + +function jumpToDiscoverHeroSlide(index) { + if (!discoverHeroArtists || index < 0 || index >= discoverHeroArtists.length) return; + + discoverHeroIndex = index; + displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); + updateDiscoverHeroIndicators(); +} + +async function viewDiscoverHeroDiscography() { + const button = document.getElementById('discover-hero-discography'); + if (!button) return; + + const artistId = button.getAttribute('data-artist-id'); + const artistName = button.getAttribute('data-artist-name'); + // Pass the source so /api/artist-detail knows to synthesize from that + // metadata provider instead of doing a local DB lookup. Hero similar + // artists are almost always source-only (not in the library). + const source = button.getAttribute('data-source') || null; + + if (!artistId || !artistName) { + console.error('No artist data found for discography view'); + return; + } + + console.log(`🎵 Navigating to artist detail for: ${artistName} (source: ${source || 'library'})`); + navigateToArtistDetail(artistId, artistName, source); +} + +function showDiscoverHeroEmpty() { + const titleEl = document.getElementById('discover-hero-title'); + const subtitleEl = document.getElementById('discover-hero-subtitle'); + + if (titleEl) titleEl.textContent = 'No Recommendations Yet'; + if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations'; +} + +async function loadDiscoverRecentReleases() { + try { + const carousel = document.getElementById('recent-releases-carousel'); + if (!carousel) return; + + carousel.innerHTML = '

Loading recent releases...

'; + + const response = await fetch('/api/discover/recent-releases'); + if (!response.ok) { + throw new Error('Failed to fetch recent releases'); + } + + const data = await response.json(); + if (!data.success || !data.albums || data.albums.length === 0) { + carousel.innerHTML = '

No recent releases found

'; + return; + } + + // Store albums for download functionality + discoverRecentAlbums = data.albums; + + // Build carousel HTML + let html = ''; + data.albums.forEach((album, index) => { + const coverUrl = album.album_cover_url || '/static/placeholder-album.png'; + html += ` +
+
+ ${album.album_name} +
+
+

${album.album_name}

+

${album.artist_name}

+

${album.release_date}

+
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading recent releases:', error); + const carousel = document.getElementById('recent-releases-carousel'); + if (carousel) { + carousel.innerHTML = '

Failed to load recent releases

'; + } + } +} + +// =============================== +// =============================== +// YOUR ALBUMS SECTION +// =============================== + +let yourAlbums = []; +let yourAlbumsPage = 1; +let yourAlbumsTotal = 0; +const YOUR_ALBUMS_PAGE_SIZE = 48; +let _yourAlbumsSearchTimeout = null; + +function debouncedYourAlbumsSearch() { + clearTimeout(_yourAlbumsSearchTimeout); + _yourAlbumsSearchTimeout = setTimeout(() => { + yourAlbumsPage = 1; + loadYourAlbumsGrid(); + }, 400); +} + +async function loadYourAlbums() { + const section = document.getElementById('your-albums-section'); + if (!section) return; + try { + const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success) return; + + const totalCount = (data.stats && data.stats.total) || 0; + if (totalCount === 0 && !data.stale) return; // Nothing to show yet + + section.style.display = ''; + yourAlbums = data.albums || []; + yourAlbumsTotal = data.total || 0; + yourAlbumsPage = 1; + + const subtitle = document.getElementById('your-albums-subtitle'); + if (subtitle && data.stats) { + const s = data.stats; + subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; + } + + const filters = document.getElementById('your-albums-filters'); + if (filters && totalCount > 0) filters.style.display = ''; + + const downloadBtn = document.getElementById('your-albums-download-btn'); + if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = ''; + + _renderYourAlbumsGrid(yourAlbums); + _renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage); + + if (data.stale && totalCount === 0) { + const grid = document.getElementById('your-albums-grid'); + if (grid) grid.innerHTML = '

Fetching your albums from connected services...

'; + _pollYourAlbums(); + } + } catch (e) { + console.error('Error loading your albums:', e); + } +} + +function _pollYourAlbums() { + let attempts = 0; + const poll = setInterval(async () => { + attempts++; + if (attempts > 12) { clearInterval(poll); return; } + try { + const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success) return; + const total = (data.stats && data.stats.total) || 0; + if (total > 0) { + clearInterval(poll); + loadYourAlbums(); + } + } catch (e) { } + }, 5000); +} + +async function loadYourAlbumsGrid() { + const grid = document.getElementById('your-albums-grid'); + if (!grid) return; + grid.innerHTML = '

Loading...

'; + try { + const search = (document.getElementById('your-albums-search')?.value || '').trim(); + const status = document.getElementById('your-albums-status-filter')?.value || 'all'; + const sort = document.getElementById('your-albums-sort')?.value || 'artist_name'; + const params = new URLSearchParams({ page: yourAlbumsPage, per_page: YOUR_ALBUMS_PAGE_SIZE, sort, status }); + if (search) params.set('search', search); + const resp = await fetch(`/api/discover/your-albums?${params}`); + const data = await resp.json(); + if (!data.success) throw new Error(data.error); + yourAlbums = data.albums || []; + yourAlbumsTotal = data.total || 0; + const subtitle = document.getElementById('your-albums-subtitle'); + if (subtitle && data.stats) { + const s = data.stats; + subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; + } + _renderYourAlbumsGrid(yourAlbums); + _renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage); + } catch (e) { + console.error('Error loading your albums grid:', e); + grid.innerHTML = '

Failed to load albums

'; + } +} + +function _renderYourAlbumsGrid(albums) { + const grid = document.getElementById('your-albums-grid'); + if (!grid) return; + if (!albums || albums.length === 0) { + grid.innerHTML = '

No albums found

'; + return; + } + let html = ''; + albums.forEach((album, index) => { + const coverUrl = album.image_url || '/static/placeholder-album.png'; + const year = album.release_date ? album.release_date.substring(0, 4) : ''; + const badgeClass = album.in_library ? 'owned' : 'missing'; + const badgeIcon = album.in_library ? '\u2713' : '\u2193'; + const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : ''; + const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 '); + const sources = (album.source_services || []).join(', '); + html += ` +
+
+ ${escapeHtml(album.album_name)} +
${badgeIcon}
+
+
+

${escapeHtml(album.album_name)}

+

${escapeHtml(album.artist_name)}

+

${escapeHtml(meta)}

+
+
`; + }); + grid.innerHTML = html; +} + +function _renderYourAlbumsPagination(total, page) { + const container = document.getElementById('your-albums-pagination'); + if (!container) return; + if (total <= YOUR_ALBUMS_PAGE_SIZE) { container.style.display = 'none'; return; } + container.style.display = ''; + const totalPages = Math.ceil(total / YOUR_ALBUMS_PAGE_SIZE); + const start = (page - 1) * YOUR_ALBUMS_PAGE_SIZE + 1; + const end = Math.min(page * YOUR_ALBUMS_PAGE_SIZE, total); + container.innerHTML = ` + + ${start}\u2013${end} of ${total} + + `; +} + +function _yourAlbumsPrevPage() { + if (yourAlbumsPage > 1) { yourAlbumsPage--; loadYourAlbumsGrid(); } +} +function _yourAlbumsNextPage() { + const totalPages = Math.ceil(yourAlbumsTotal / YOUR_ALBUMS_PAGE_SIZE); + if (yourAlbumsPage < totalPages) { yourAlbumsPage++; loadYourAlbumsGrid(); } +} + +async function openYourAlbumDownload(index) { + const album = yourAlbums[index]; + if (!album) { showToast('Album data not found', 'error'); return; } + showLoadingOverlay(`Loading tracks for ${album.album_name}...`); + try { + // Prefer Spotify ID, fall back to Deezer, then search by name + let albumData = null; + const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + if (album.spotify_album_id) { + const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`); + if (r.ok) albumData = await r.json(); + } + if (!albumData && album.deezer_album_id) { + const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`); + if (r.ok) albumData = await r.json(); + } + if (!albumData) { + // Last resort — search by name + const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`); + if (r.ok) albumData = await r.json(); + } + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found for this album'); + } + const tracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) artists = artists.map(a => a.name || a); + return { + id: track.id, name: track.name, artists, + album: { + id: albumData.id, name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + const virtualId = `discover_album_${album.spotify_album_id || album.deezer_album_id || album.tidal_album_id || index}`; + const albumObj = { + id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', + images: albumData.images || [], artists: [{ name: album.artist_name }] + }; + const artistObj = { id: null, name: album.artist_name }; + await openDownloadMissingModalForArtistAlbum(virtualId, albumData.name, tracks, albumObj, artistObj, false); + hideLoadingOverlay(); + } catch (e) { + console.error('Error opening your album download:', e); + showToast(`Failed to load album: ${e.message}`, 'error'); + hideLoadingOverlay(); + } +} + +async function refreshYourAlbums() { + const btn = document.getElementById('your-albums-refresh-btn'); + if (btn) btn.disabled = true; + const subtitle = document.getElementById('your-albums-subtitle'); + if (subtitle) subtitle.textContent = 'Refreshing from connected services...'; + try { + await fetch('/api/discover/your-albums/refresh?clear=true', { method: 'POST' }); + showToast('Refresh started — checking for new albums...', 'info'); + const poll = setInterval(async () => { + try { + const resp = await fetch('/api/discover/your-albums?page=1&per_page=48'); + const data = await resp.json(); + if (data.success && data.stats && data.stats.total > 0) { + clearInterval(poll); + loadYourAlbums(); + if (btn) btn.disabled = false; + } + } catch (e) { } + }, 4000); + setTimeout(() => { clearInterval(poll); if (btn) btn.disabled = false; }, 60000); + } catch (e) { + showToast('Failed to start refresh', 'error'); + if (btn) btn.disabled = false; + } +} + +async function openYourAlbumsSourcesModal() { + const existing = document.getElementById('ya-albums-sources-modal-overlay'); + if (existing) existing.remove(); + + let enabled = ['spotify', 'tidal', 'deezer']; + let connected = []; + try { + const resp = await fetch('/api/discover/your-albums/sources'); + if (resp.ok) { + const data = await resp.json(); + if (data.enabled) enabled = data.enabled; + if (data.connected) connected = data.connected; + } + } catch (e) { } + + const sourceInfo = [ + { id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' }, + { id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' }, + { id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' }, + ]; + const state = {}; + sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); }); + + const overlay = document.createElement('div'); + overlay.id = 'ya-albums-sources-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const rows = sourceInfo.map(s => { + const isConnected = connected.includes(s.id); + const isOn = state[s.id]; + return ` +
+
+ ${s.icon} +
+
${s.label}
+
${isConnected ? 'Connected' : 'Not connected'}
+
+
+ +
`; + }).join(''); + + overlay.innerHTML = ` +
+

Your Albums Sources

+

Choose which connected services contribute albums to this section.

+
${rows}
+ +
+ `; + document.body.appendChild(overlay); + window._yaaSourcesState = state; +} + +function _yaaSourceRowClick(id) { + const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`); + if (row && row.classList.contains('disconnected')) return; + _yaaSourceToggle(id); +} +function _yaaSourceToggle(id) { + const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`); + if (row && row.classList.contains('disconnected')) return; + window._yaaSourcesState[id] = !window._yaaSourcesState[id]; + const btn = document.getElementById(`yaa-toggle-${id}`); + if (btn) btn.classList.toggle('on', window._yaaSourcesState[id]); +} +async function _yaaSourcesSave() { + const enabledArr = Object.entries(window._yaaSourcesState).filter(([, v]) => v).map(([k]) => k); + if (enabledArr.length === 0) { showToast('Select at least one source', 'error'); return; } + try { + const resp = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ discover: { your_albums_sources: enabledArr.join(',') } }) + }); + if (resp.ok) { + document.getElementById('ya-albums-sources-modal-overlay')?.remove(); + showToast('Sources saved — refresh to apply', 'success'); + const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer' }; + const subtitle = document.getElementById('your-albums-subtitle'); + if (subtitle) { + const names = enabledArr.map(s => sourceNames[s] || s).join(' and '); + subtitle.textContent = `Albums you\u2019ve saved on ${names}`; + } + } else { + showToast('Failed to save sources', 'error'); + } + } catch (e) { + showToast('Failed to save sources', 'error'); + } +} + +async function downloadMissingYourAlbums() { + try { + const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing'); + const data = await resp.json(); + if (!data.success || !data.albums || data.albums.length === 0) { + showToast('No missing albums to download', 'info'); + return; + } + const missing = data.albums.filter(a => !a.in_library); + if (missing.length === 0) { showToast('All albums are already in your library!', 'success'); return; } + if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your saved albums?`)) return; + showToast(`Starting download for ${missing.length} albums...`, 'info'); + for (let i = 0; i < missing.length; i++) { + const album = missing[i]; + try { + showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info'); + const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + let albumData = null; + if (album.spotify_album_id) { + const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`); + if (r.ok) albumData = await r.json(); + } + if (!albumData && album.deezer_album_id) { + const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`); + if (r.ok) albumData = await r.json(); + } + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) continue; + const tracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) artists = artists.map(a => a.name || a); + return { + id: track.id, name: track.name, artists, + album: { + id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, track_number: track.track_number || 0 + }; + }); + const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || i}`; + await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks, + { name: album.artist_name, source: albumData.source || 'spotify' }, + { + id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', + images: albumData.images || [] + } + ); + } catch (err) { console.error(`Error queuing ${album.album_name}:`, err); } + } + } catch (e) { + console.error('Error downloading missing your albums:', e); + showToast(`Error: ${e.message}`, 'error'); + } +} + +// =============================== +// SPOTIFY LIBRARY SECTION +// =============================== + +let spotifyLibraryAlbums = []; +let spotifyLibraryPage = 0; +let spotifyLibraryTotal = 0; +const SPOTIFY_LIBRARY_PAGE_SIZE = 48; +let _spotifyLibrarySearchTimeout = null; + +function debouncedSpotifyLibrarySearch() { + clearTimeout(_spotifyLibrarySearchTimeout); + _spotifyLibrarySearchTimeout = setTimeout(() => { + spotifyLibraryPage = 0; + loadSpotifyLibraryAlbums(); + }, 400); +} + +async function loadSpotifyLibrarySection() { + try { + const section = document.getElementById('spotify-library-section'); + if (!section) return; + + const response = await fetch(`/api/discover/spotify-library?offset=0&limit=${SPOTIFY_LIBRARY_PAGE_SIZE}`); + if (!response.ok) throw new Error('Failed to fetch'); + + const data = await response.json(); + if (!data.success || !data.albums || data.albums.length === 0) { + section.style.display = 'none'; + return; + } + + section.style.display = ''; + spotifyLibraryAlbums = data.albums; + spotifyLibraryTotal = data.total; + spotifyLibraryPage = 0; + + // Update subtitle with stats + const subtitle = document.getElementById('spotify-library-subtitle'); + if (subtitle && data.stats) { + const s = data.stats; + subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; + } + + // Show download missing button if there are missing albums + const dlBtn = document.getElementById('spotify-library-download-missing-btn'); + if (dlBtn && data.stats && data.stats.missing > 0) { + dlBtn.style.display = ''; + } + + // Show filters + const filters = document.getElementById('spotify-library-filters'); + if (filters) filters.style.display = ''; + + renderSpotifyLibraryGrid(data.albums); + renderSpotifyLibraryPagination(data.total, 0); + + } catch (error) { + console.error('Error loading Spotify library section:', error); + const section = document.getElementById('spotify-library-section'); + if (section) section.style.display = 'none'; + } +} + +async function loadSpotifyLibraryAlbums() { + const grid = document.getElementById('spotify-library-grid'); + if (!grid) return; + + grid.innerHTML = '

Loading...

'; + + try { + const search = (document.getElementById('spotify-library-search')?.value || '').trim(); + const status = document.getElementById('spotify-library-status-filter')?.value || 'all'; + const sort = document.getElementById('spotify-library-sort')?.value || 'date_saved'; + const offset = spotifyLibraryPage * SPOTIFY_LIBRARY_PAGE_SIZE; + + const params = new URLSearchParams({ + offset, limit: SPOTIFY_LIBRARY_PAGE_SIZE, sort, sort_dir: 'desc', status + }); + if (search) params.set('search', search); + + const response = await fetch(`/api/discover/spotify-library?${params}`); + const data = await response.json(); + + if (!data.success) throw new Error(data.error); + + spotifyLibraryAlbums = data.albums; + spotifyLibraryTotal = data.total; + + // Update subtitle + const subtitle = document.getElementById('spotify-library-subtitle'); + if (subtitle && data.stats) { + const s = data.stats; + subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; + } + + renderSpotifyLibraryGrid(data.albums); + renderSpotifyLibraryPagination(data.total, offset); + + } catch (error) { + console.error('Error loading Spotify library albums:', error); + grid.innerHTML = '

Failed to load albums

'; + } +} + +function renderSpotifyLibraryGrid(albums) { + const grid = document.getElementById('spotify-library-grid'); + if (!grid) return; + + if (!albums || albums.length === 0) { + grid.innerHTML = '

No albums found

'; + return; + } + + let html = ''; + albums.forEach((album, index) => { + const coverUrl = album.image_url || '/static/placeholder-album.png'; + const year = album.release_date ? album.release_date.substring(0, 4) : ''; + const badgeClass = album.in_library ? 'owned' : 'missing'; + const badgeIcon = album.in_library ? '\u2713' : '\u2193'; + const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : ''; + const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 '); + + html += ` +
+
+ ${album.album_name} +
${badgeIcon}
+
+
+

${album.album_name}

+

${album.artist_name}

+

${meta}

+
+
+ `; + }); + + grid.innerHTML = html; +} + +function renderSpotifyLibraryPagination(total, offset) { + const container = document.getElementById('spotify-library-pagination'); + if (!container) return; + + if (total <= SPOTIFY_LIBRARY_PAGE_SIZE) { + container.style.display = 'none'; + return; + } + + container.style.display = ''; + const totalPages = Math.ceil(total / SPOTIFY_LIBRARY_PAGE_SIZE); + const currentPage = Math.floor(offset / SPOTIFY_LIBRARY_PAGE_SIZE) + 1; + const showEnd = Math.min(offset + SPOTIFY_LIBRARY_PAGE_SIZE, total); + + container.innerHTML = ` + + ${offset + 1}\u2013${showEnd} of ${total} + + `; +} + +function spotifyLibraryPrevPage() { + if (spotifyLibraryPage > 0) { + spotifyLibraryPage--; + loadSpotifyLibraryAlbums(); + } +} + +function spotifyLibraryNextPage() { + const totalPages = Math.ceil(spotifyLibraryTotal / SPOTIFY_LIBRARY_PAGE_SIZE); + if (spotifyLibraryPage < totalPages - 1) { + spotifyLibraryPage++; + loadSpotifyLibraryAlbums(); + } +} + +async function openSpotifyLibraryAlbumDownload(index) { + const album = spotifyLibraryAlbums[index]; + if (!album) { + showToast('Album data not found', 'error'); + return; + } + + console.log(`\u{1F4E5} Opening download modal for Spotify library album: ${album.album_name}`); + showLoadingOverlay(`Loading tracks for ${album.album_name}...`); + + try { + const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`); + if (!response.ok) throw new Error('Failed to fetch album tracks'); + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + + const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`; + const artistContext = { + id: album.artist_id, + name: album.artist_name, + source: 'spotify' + }; + const albumContext = { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); + hideLoadingOverlay(); + + } catch (error) { + console.error('Error opening Spotify library album download:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +async function refreshSpotifyLibraryCache() { + try { + showToast('Refreshing Spotify library...', 'info'); + const response = await fetch('/api/discover/spotify-library/refresh', { method: 'POST' }); + const data = await response.json(); + if (data.success) { + showToast('Spotify library refresh started — will update shortly', 'success'); + // Reload after a delay to let the sync run + setTimeout(() => loadSpotifyLibrarySection(), 10000); + } else { + showToast(`Error: ${data.error}`, 'error'); + } + } catch (error) { + showToast(`Error: ${error.message}`, 'error'); + } +} + +async function downloadMissingSpotifyLibraryAlbums() { + // Fetch all missing albums (no pagination limit) + try { + const response = await fetch('/api/discover/spotify-library?status=missing&limit=500&offset=0'); + const data = await response.json(); + if (!data.success || !data.albums || data.albums.length === 0) { + showToast('No missing albums to download', 'info'); + return; + } + + const missing = data.albums.filter(a => !a.in_library); + if (missing.length === 0) { + showToast('All albums are already in your library!', 'success'); + return; + } + + if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your Spotify library?`)) { + return; + } + + showToast(`Starting download for ${missing.length} albums...`, 'info'); + + // Download one at a time to avoid overwhelming the system + for (let i = 0; i < missing.length; i++) { + const album = missing[i]; + try { + showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info'); + + const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`); + if (!response.ok) continue; + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) continue; + + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) artists = artists.map(a => a.name || a); + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + + const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`; + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, { + id: album.artist_id, name: album.artist_name, source: 'spotify' + }, { + id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', + images: albumData.images || [] + }); + + } catch (err) { + console.error(`Error downloading album ${album.album_name}:`, err); + } + } + + } catch (error) { + console.error('Error downloading missing Spotify library albums:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +async function loadDiscoverReleaseRadar() { + try { + const playlistContainer = document.getElementById('release-radar-playlist'); + if (!playlistContainer) return; + + playlistContainer.innerHTML = '

Loading release radar...

'; + + const response = await fetch('/api/discover/release-radar'); + if (!response.ok) { + throw new Error('Failed to fetch release radar'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No new releases available

'; + return; + } + + // Store tracks for download/sync functionality + discoverReleaseRadarTracks = data.tracks; + + // Build compact playlist HTML + let html = '
'; + data.tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + html += ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; + + } catch (error) { + console.error('Error loading release radar:', error); + const playlistContainer = document.getElementById('release-radar-playlist'); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load release radar

'; + } + } +} + +async function loadDiscoverWeekly() { + try { + const playlistContainer = document.getElementById('discovery-weekly-playlist'); + if (!playlistContainer) return; + + playlistContainer.innerHTML = '

Curating your discovery playlist...

'; + + const response = await fetch('/api/discover/weekly'); + if (!response.ok) { + throw new Error('Failed to fetch discovery weekly'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No tracks available yet

'; + return; + } + + // Store tracks for download/sync functionality + discoverWeeklyTracks = data.tracks; + + // Build compact playlist HTML + let html = '
'; + data.tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + html += ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; + + } catch (error) { + console.error('Error loading discovery weekly:', error); + const playlistContainer = document.getElementById('discovery-weekly-playlist'); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load discovery weekly

'; + } + } +} + +// =============================== +// DECADE BROWSER +// =============================== + +let selectedDecade = null; +let decadeTracks = []; + +async function loadDecadeBrowser() { + try { + const carousel = document.getElementById('decade-browser-carousel'); + if (!carousel) return; + + // Fetch available decades from backend + const response = await fetch('/api/discover/decades/available'); + if (!response.ok) { + throw new Error('Failed to fetch available decades'); + } + + const data = await response.json(); + if (!data.success || !data.decades || data.decades.length === 0) { + carousel.innerHTML = '

No decade content available yet. Run a watchlist scan to populate your discovery pool!

'; + return; + } + + // Build decade cards matching Recent Releases style + let html = ''; + data.decades.forEach(decade => { + const icon = getDecadeIcon(decade.year); + const label = `${decade.year}s`; + html += ` +
+
+
${icon}
+
+
+

${label}

+

${decade.track_count} tracks

+

Classics

+
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading decade browser:', error); + const carousel = document.getElementById('decade-browser-carousel'); + if (carousel) { + carousel.innerHTML = '

Failed to load decades

'; + } + } +} + +function getDecadeIcon(year) { + const icons = { + 1950: '🎺', + 1960: '🎸', + 1970: '🕺', + 1980: '📻', + 1990: '💿', + 2000: '📱', + 2010: '🎧', + 2020: '🌐' + }; + return icons[year] || '🎵'; +} + +async function openDecadePlaylist(decade) { + try { + showLoadingOverlay(`Loading ${decade}s playlist...`); + + const response = await fetch(`/api/discover/decade/${decade}`); + if (!response.ok) { + throw new Error('Failed to fetch decade playlist'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + const message = data.message || `No tracks found for the ${decade}s`; + showToast(message, 'info'); + hideLoadingOverlay(); + return; + } + + selectedDecade = decade; + decadeTracks = data.tracks; + + // Open download modal + const playlistName = `${decade}s Classics`; + const virtualPlaylistId = `decade_${decade}`; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks); + hideLoadingOverlay(); + + } catch (error) { + console.error(`Error opening ${decade}s playlist:`, error); + showToast(`Failed to load ${decade}s playlist`, 'error'); + hideLoadingOverlay(); + } +} + +// =============================== +// GENRE BROWSER +// =============================== + +let selectedGenre = null; +let genreTracks = []; + +async function loadGenreBrowser() { + try { + const carousel = document.getElementById('genre-browser-carousel'); + if (!carousel) return; + + // Fetch available genres from backend + const response = await fetch('/api/discover/genres/available'); + if (!response.ok) { + throw new Error('Failed to fetch available genres'); + } + + const data = await response.json(); + if (!data.success || !data.genres || data.genres.length === 0) { + carousel.innerHTML = '

No genre content available yet. Run a watchlist scan to populate your discovery pool!

'; + return; + } + + // Build genre cards matching Recent Releases style + let html = ''; + data.genres.forEach(genre => { + const icon = getGenreIcon(genre.name); + const displayName = capitalizeGenre(genre.name); + html += ` +
+
+
${icon}
+
+
+

${displayName}

+

${genre.track_count} tracks

+

Curated

+
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading genre browser:', error); + const carousel = document.getElementById('genre-browser-carousel'); + if (carousel) { + carousel.innerHTML = '

Failed to load genres

'; + } + } +} + +function getGenreIcon(genreName) { + const genre = genreName.toLowerCase(); + + // Parent genre exact matches (consolidated categories) + if (genre === 'electronic/dance') return '🎹'; + if (genre === 'hip hop/rap') return '🎤'; + if (genre === 'rock') return '🎸'; + if (genre === 'pop') return '🎵'; + if (genre === 'r&b/soul') return '🎙️'; + if (genre === 'jazz') return '🎺'; + if (genre === 'classical') return '🎻'; + if (genre === 'metal') return '🤘'; + if (genre === 'country') return '🪕'; + if (genre === 'folk/indie') return '🎧'; + if (genre === 'latin') return '💃'; + if (genre === 'reggae/dancehall') return '🌴'; + if (genre === 'world') return '🌍'; + if (genre === 'alternative') return '🎭'; + if (genre === 'blues') return '🎸'; + if (genre === 'funk/disco') return '🕺'; + + // Fallback: partial matching for specific genres + if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') || + genre.includes('electro') || genre.includes('trance') || genre.includes('electronic')) { + return '🎹'; + } + if (genre.includes('hip hop') || genre.includes('rap') || genre.includes('trap')) { + return '🎤'; + } + if (genre.includes('rock') || genre.includes('punk')) { + return '🎸'; + } + if (genre.includes('metal')) { + return '🤘'; + } + if (genre.includes('jazz') || genre.includes('blues')) { + return '🎺'; + } + if (genre.includes('pop')) { + return '🎵'; + } + if (genre.includes('r&b') || genre.includes('soul')) { + return '🎙️'; + } + if (genre.includes('country') || genre.includes('folk')) { + return '🪕'; + } + if (genre.includes('classical') || genre.includes('orchestra')) { + return '🎻'; + } + if (genre.includes('indie') || genre.includes('alternative')) { + return '🎧'; + } + if (genre.includes('latin') || genre.includes('reggaeton') || genre.includes('salsa')) { + return '💃'; + } + if (genre.includes('reggae') || genre.includes('dancehall')) { + return '🌴'; + } + if (genre.includes('funk') || genre.includes('disco')) { + return '🕺'; + } + + // Default + return '🎶'; +} + +function capitalizeGenre(genre) { + // Capitalize each word in genre, handling both spaces and slashes + return genre.split(/(\s|\/)/g) + .map(part => { + if (part === ' ' || part === '/') return part; + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join(''); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +async function openGenrePlaylist(genre) { + try { + showLoadingOverlay(`Loading ${capitalizeGenre(genre)} playlist...`); + + const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre)}`); + if (!response.ok) { + throw new Error('Failed to fetch genre playlist'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + const message = data.message || `No tracks found for ${genre}`; + showToast(message, 'info'); + hideLoadingOverlay(); + return; + } + + selectedGenre = genre; + genreTracks = data.tracks; + + // Open download modal + const playlistName = `${capitalizeGenre(genre)} Mix`; + const virtualPlaylistId = `genre_${genre.replace(/\s+/g, '_')}`; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks); + hideLoadingOverlay(); + + } catch (error) { + console.error(`Error opening ${genre} playlist:`, error); + showToast(`Failed to load ${genre} playlist`, 'error'); + hideLoadingOverlay(); + } +} + +// =============================== +// TIME MACHINE (TABBED BY DECADE) +// =============================== + +let decadeTracksCache = {}; // Store tracks for each decade +let activeDecade = null; + +async function loadDecadeBrowserTabs() { + try { + const tabsContainer = document.getElementById('decade-tabs'); + const contentsContainer = document.getElementById('decade-tab-contents'); + + if (!tabsContainer || !contentsContainer) return; + + // Fetch available decades from backend + const response = await fetch('/api/discover/decades/available'); + if (!response.ok) { + throw new Error('Failed to fetch available decades'); + } + + const data = await response.json(); + if (!data.success || !data.decades || data.decades.length === 0) { + tabsContainer.innerHTML = '

No decade content available yet. Run a watchlist scan to populate your discovery pool!

'; + return; + } + + // Build decade tabs + let tabsHTML = ''; + let contentsHTML = ''; + + data.decades.forEach((decade, index) => { + const isActive = index === 0; + const icon = getDecadeIcon(decade.year); + const tabId = `decade-${decade.year}`; + + // Tab button + tabsHTML += ` + + `; + + // Tab content + contentsHTML += ` +
+ +
+
+
+

${decade.year}s Classics

+

${decade.track_count} tracks

+
+
+ + +
+
+ + + +
+ + +
+

Loading ${decade.year}s tracks...

+
+
+ `; + }); + + tabsContainer.innerHTML = tabsHTML; + contentsContainer.innerHTML = contentsHTML; + + // Load first decade's tracks + if (data.decades.length > 0) { + await loadDecadeTracks(data.decades[0].year); + } + + } catch (error) { + console.error('Error loading decade browser tabs:', error); + const tabsContainer = document.getElementById('decade-tabs'); + if (tabsContainer) { + tabsContainer.innerHTML = '

Failed to load decades

'; + } + } +} + +function switchDecadeTab(decade) { + // Update tab buttons + const tabs = document.querySelectorAll('.decade-tab'); + tabs.forEach(tab => { + if (parseInt(tab.getAttribute('data-decade')) === decade) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + // Update tab content + const tabContents = document.querySelectorAll('.decade-tab-content'); + tabContents.forEach(content => { + if (content.id === `decade-${decade}-content`) { + content.classList.add('active'); + } else { + content.classList.remove('active'); + } + }); + + // Load tracks if not already loaded + if (!decadeTracksCache[decade]) { + loadDecadeTracks(decade); + } +} + +async function loadDecadeTracks(decade) { + try { + const playlistContainer = document.getElementById(`decade-${decade}-playlist`); + if (!playlistContainer) return; + + const response = await fetch(`/api/discover/decade/${decade}`); + if (!response.ok) { + throw new Error('Failed to fetch decade playlist'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No tracks found for the ' + decade + 's

'; + return; + } + + // Store tracks in cache + decadeTracksCache[decade] = data.tracks; + activeDecade = decade; + + // Build compact playlist HTML + let html = '
'; + data.tracks.forEach((track, index) => { + // Extract track data from track_data_json if available + let trackData = track; + if (track.track_data_json) { + trackData = track.track_data_json; + } + + // Get track properties with fallbacks + const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track'; + const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist'; + const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album'; + const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png'; + const durationMs = trackData.duration_ms || track.duration_ms || 0; + + const durationMin = Math.floor(durationMs / 60000); + const durationSec = Math.floor((durationMs % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + html += ` +
+
${index + 1}
+
+ ${albumName} +
+
+
${trackName}
+
${artistName}
+
+
${albumName}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; + + } catch (error) { + console.error('Error loading decade tracks:', error); + const playlistContainer = document.getElementById(`decade-${decade}-playlist`); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load decade tracks

'; + } + } +} + +async function startDecadeSync(decade) { + const tracks = decadeTracksCache[decade]; + if (!tracks || tracks.length === 0) { + showToast('No tracks available for this decade', 'warning'); + return; + } + + // Convert to format expected by sync API + const spotifyTracks = tracks.map(track => { + // Extract track data from track_data_json if available + let trackData = track; + if (track.track_data_json) { + trackData = track.track_data_json; + } + + // Build properly formatted Spotify track object + let spotifyTrack = { + id: trackData.id || track.spotify_track_id, + name: trackData.name || trackData.track_name || track.track_name, + artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], + album: trackData.album || { + name: trackData.album_name || track.album_name, + images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) + }, + duration_ms: trackData.duration_ms || track.duration_ms || 0 + }; + + // Normalize artists to array of strings for sync compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + const virtualPlaylistId = `discover_decade_${decade}`; + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + + const virtualPlaylist = { + id: virtualPlaylistId, + name: `${decade}s Classics`, + track_count: spotifyTracks.length + }; + + if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { + spotifyPlaylists.push(virtualPlaylist); + } + + // Show sync status display + const statusDisplay = document.getElementById(`decade-${decade}-sync-status`); + if (statusDisplay) statusDisplay.style.display = 'block'; + + // Disable sync button + const syncButton = document.getElementById(`decade-${decade}-sync-btn`); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Start sync + await startPlaylistSync(virtualPlaylistId); + + // Start polling + startDecadeSyncPolling(decade, virtualPlaylistId); +} + +function startDecadeSyncPolling(decade, virtualPlaylistId) { + const pollerId = `decade_${decade}`; + + if (discoverSyncPollers[pollerId]) { + clearInterval(discoverSyncPollers[pollerId]); + } + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); + _syncProgressCallbacks[virtualPlaylistId] = (data) => { + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const el = (id) => document.getElementById(id); + if (el(`decade-${decade}-sync-completed`)) el(`decade-${decade}-sync-completed`).textContent = matched; + if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = pending; + if (el(`decade-${decade}-sync-failed`)) el(`decade-${decade}-sync-failed`).textContent = failed; + if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = pct; + if (data.status === 'finished') { + if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; } + socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); + delete _syncProgressCallbacks[virtualPlaylistId]; + const syncButton = el(`decade-${decade}-sync-btn`); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + showToast(`${decade}s Classics sync complete!`, 'success'); + setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + } + }; + } + + discoverSyncPollers[pollerId] = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!response.ok) return; + + const data = await response.json(); + const progress = data.progress || {}; + + const completedEl = document.getElementById(`decade-${decade}-sync-completed`); + const pendingEl = document.getElementById(`decade-${decade}-sync-pending`); + const failedEl = document.getElementById(`decade-${decade}-sync-failed`); + const percentageEl = document.getElementById(`decade-${decade}-sync-percentage`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + if (completedEl) completedEl.textContent = matched; + if (pendingEl) pendingEl.textContent = pending; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = completionPercentage; + + if (data.status === 'finished') { + clearInterval(discoverSyncPollers[pollerId]); + delete discoverSyncPollers[pollerId]; + + const syncButton = document.getElementById(`decade-${decade}-sync-btn`); + if (syncButton) { + syncButton.disabled = false; + syncButton.style.opacity = '1'; + syncButton.style.cursor = 'pointer'; + } + + showToast(`${decade}s Classics sync complete!`, 'success'); + + setTimeout(() => { + const statusDisplay = document.getElementById(`decade-${decade}-sync-status`); + if (statusDisplay) statusDisplay.style.display = 'none'; + }, 3000); + } + } catch (error) { + console.error(`Error polling sync status for decade ${decade}:`, error); + } + }, 500); +} + +async function openDownloadModalForDecade(decade) { + const tracks = decadeTracksCache[decade]; + if (!tracks || tracks.length === 0) { + showToast('No tracks available for this decade', 'warning'); + return; + } + + // Convert to format expected by download modal + const spotifyTracks = tracks.map(track => { + // Extract track data from track_data_json if available + let trackData = track; + if (track.track_data_json) { + trackData = track.track_data_json; + } + + // Build properly formatted Spotify track object + let spotifyTrack = { + id: trackData.id || track.spotify_track_id, + name: trackData.name || trackData.track_name || track.track_name, + artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], + album: trackData.album || { + name: trackData.album_name || track.album_name, + images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) + }, + duration_ms: trackData.duration_ms || track.duration_ms || 0 + }; + + return spotifyTrack; + }); + + const playlistName = `${decade}s Classics`; + const virtualPlaylistId = `decade_${decade}`; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); +} + +// =============================== +// BROWSE BY GENRE (TABBED BY GENRE) +// =============================== + +let genreTracksCache = {}; // Store tracks for each genre +let activeGenre = null; +let availableGenres = []; + +async function loadGenreBrowserTabs() { + try { + const tabsContainer = document.getElementById('genre-tabs'); + const contentsContainer = document.getElementById('genre-tab-contents'); + + if (!tabsContainer || !contentsContainer) return; + + // Fetch available genres from backend + const response = await fetch('/api/discover/genres/available'); + if (!response.ok) { + throw new Error('Failed to fetch available genres'); + } + + const data = await response.json(); + if (!data.success || !data.genres || data.genres.length === 0) { + tabsContainer.innerHTML = '

No genre content available yet. Run a watchlist scan to populate your discovery pool!

'; + return; + } + + availableGenres = data.genres; + + // Build genre tabs (limit to first 8-10 to avoid overcrowding) + const displayGenres = data.genres.slice(0, 10); + let tabsHTML = ''; + let contentsHTML = ''; + + displayGenres.forEach((genre, index) => { + const isActive = index === 0; + const icon = getGenreIcon(genre.name); + const genreName = genre.name; + const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); + const tabId = `genre-${genreId}`; + + // Tab button + tabsHTML += ` + + `; + + // Tab content + contentsHTML += ` +
+ +
+
+
+

${capitalizeGenre(genreName)} Mix

+

${genre.track_count} tracks

+
+
+ + +
+
+ + + +
+ + +
+

Loading ${capitalizeGenre(genreName)} tracks...

+
+
+ `; + }); + + tabsContainer.innerHTML = tabsHTML; + contentsContainer.innerHTML = contentsHTML; + + // Load first genre's tracks + if (displayGenres.length > 0) { + await loadGenreTracks(displayGenres[0].name); + } + + } catch (error) { + console.error('Error loading genre browser tabs:', error); + const tabsContainer = document.getElementById('genre-tabs'); + if (tabsContainer) { + tabsContainer.innerHTML = '

Failed to load genres

'; + } + } +} + +function switchGenreTab(genreName) { + // Update tab buttons + const tabs = document.querySelectorAll('.genre-tab'); + tabs.forEach(tab => { + if (tab.getAttribute('data-genre') === genreName) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + // Update tab content + const tabContents = document.querySelectorAll('.genre-tab-content'); + tabContents.forEach(content => { + if (content.getAttribute('data-genre') === genreName) { + content.classList.add('active'); + } else { + content.classList.remove('active'); + } + }); + + // Load tracks if not already loaded + if (!genreTracksCache[genreName]) { + loadGenreTracks(genreName); + } +} + +async function loadGenreTracks(genreName) { + try { + const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); + const playlistContainer = document.getElementById(`genre-${genreId}-playlist`); + if (!playlistContainer) return; + + const response = await fetch(`/api/discover/genre/${encodeURIComponent(genreName)}`); + if (!response.ok) { + throw new Error('Failed to fetch genre playlist'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = `

No tracks found for ${capitalizeGenre(genreName)}

`; + return; + } + + // Store tracks in cache + genreTracksCache[genreName] = data.tracks; + activeGenre = genreName; + + // Build compact playlist HTML + let html = '
'; + data.tracks.forEach((track, index) => { + // Extract track data from track_data_json if available + let trackData = track; + if (track.track_data_json) { + trackData = track.track_data_json; + } + + // Get track properties with fallbacks + const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track'; + const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist'; + const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album'; + const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png'; + const durationMs = trackData.duration_ms || track.duration_ms || 0; + + const durationMin = Math.floor(durationMs / 60000); + const durationSec = Math.floor((durationMs % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + html += ` +
+
${index + 1}
+
+ ${albumName} +
+
+
${trackName}
+
${artistName}
+
+
${albumName}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; + + } catch (error) { + console.error('Error loading genre tracks:', error); + const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); + const playlistContainer = document.getElementById(`genre-${genreId}-playlist`); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load genre tracks

'; + } + } +} + +async function startGenreSync(genreName) { + const tracks = genreTracksCache[genreName]; + if (!tracks || tracks.length === 0) { + showToast('No tracks available for this genre', 'warning'); + return; + } + + const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); + + // Convert to format expected by sync API + const spotifyTracks = tracks.map(track => { + // Extract track data from track_data_json if available + let trackData = track; + if (track.track_data_json) { + trackData = track.track_data_json; + } + + // Build properly formatted Spotify track object + let spotifyTrack = { + id: trackData.id || track.spotify_track_id, + name: trackData.name || trackData.track_name || track.track_name, + artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], + album: trackData.album || { + name: trackData.album_name || track.album_name, + images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) + }, + duration_ms: trackData.duration_ms || track.duration_ms || 0 + }; + + // Normalize artists to array of strings for sync compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + const virtualPlaylistId = `discover_genre_${genreName.replace(/\s+/g, '_')}`; + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + + const virtualPlaylist = { + id: virtualPlaylistId, + name: `${capitalizeGenre(genreName)} Mix`, + track_count: spotifyTracks.length + }; + + if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { + spotifyPlaylists.push(virtualPlaylist); + } + + // Show sync status display + const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`); + if (statusDisplay) statusDisplay.style.display = 'block'; + + // Disable sync button + const syncButton = document.getElementById(`genre-${genreId}-sync-btn`); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Start sync + await startPlaylistSync(virtualPlaylistId); + + // Start polling + startGenreSyncPolling(genreName, genreId, virtualPlaylistId); +} + +function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { + const pollerId = `genre_${genreId}`; + + if (discoverSyncPollers[pollerId]) { + clearInterval(discoverSyncPollers[pollerId]); + } + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); + _syncProgressCallbacks[virtualPlaylistId] = (data) => { + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const el = (id) => document.getElementById(id); + if (el(`genre-${genreId}-sync-completed`)) el(`genre-${genreId}-sync-completed`).textContent = matched; + if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = pending; + if (el(`genre-${genreId}-sync-failed`)) el(`genre-${genreId}-sync-failed`).textContent = failed; + if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = pct; + if (data.status === 'finished') { + if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; } + socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); + delete _syncProgressCallbacks[virtualPlaylistId]; + const syncButton = el(`genre-${genreId}-sync-btn`); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); + setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + } + }; + } + + discoverSyncPollers[pollerId] = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!response.ok) return; + + const data = await response.json(); + const progress = data.progress || {}; + + const completedEl = document.getElementById(`genre-${genreId}-sync-completed`); + const pendingEl = document.getElementById(`genre-${genreId}-sync-pending`); + const failedEl = document.getElementById(`genre-${genreId}-sync-failed`); + const percentageEl = document.getElementById(`genre-${genreId}-sync-percentage`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + if (completedEl) completedEl.textContent = matched; + if (pendingEl) pendingEl.textContent = pending; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = completionPercentage; + + if (data.status === 'finished') { + clearInterval(discoverSyncPollers[pollerId]); + delete discoverSyncPollers[pollerId]; + + const syncButton = document.getElementById(`genre-${genreId}-sync-btn`); + if (syncButton) { + syncButton.disabled = false; + syncButton.style.opacity = '1'; + syncButton.style.cursor = 'pointer'; + } + + showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); + + setTimeout(() => { + const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`); + if (statusDisplay) statusDisplay.style.display = 'none'; + }, 3000); + } + } catch (error) { + console.error(`Error polling sync status for genre ${genreName}:`, error); + } + }, 500); +} + +async function openDownloadModalForGenre(genreName) { + const tracks = genreTracksCache[genreName]; + if (!tracks || tracks.length === 0) { + showToast('No tracks available for this genre', 'warning'); + return; + } + + // Convert to format expected by download modal + const spotifyTracks = tracks.map(track => { + // Extract track data from track_data_json if available + let trackData = track; + if (track.track_data_json) { + trackData = track.track_data_json; + } + + // Build properly formatted Spotify track object + let spotifyTrack = { + id: trackData.id || track.spotify_track_id, + name: trackData.name || trackData.track_name || track.track_name, + artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], + album: trackData.album || { + name: trackData.album_name || track.album_name, + images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) + }, + duration_ms: trackData.duration_ms || track.duration_ms || 0 + }; + + return spotifyTrack; + }); + + const playlistName = `${capitalizeGenre(genreName)} Mix`; + const virtualPlaylistId = `genre_${genreName.replace(/\s+/g, '_')}`; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); +} + +// =============================== +// LISTENBRAINZ PLAYLISTS +// =============================== + +let listenbrainzPlaylistsCache = {}; // Store playlists by type +let listenbrainzTracksCache = {}; // Store tracks for each playlist +let activeListenBrainzTab = 'recommendations'; // Track active tab +let activeListenBrainzSubTab = null; // Track active sub-tab within recommendations + +// ── Last.fm Track Radio ────────────────────────────────────────────────────── + +let _lastfmRadioDebounceTimer = null; +let _lastfmRadioSelected = null; // {name, artist} + +function debouncedLastfmTrackSearch(query) { + clearTimeout(_lastfmRadioDebounceTimer); + const q = (query || '').trim(); + if (!q) { + document.getElementById('lastfm-radio-dropdown').style.display = 'none'; + return; + } + _lastfmRadioDebounceTimer = setTimeout(() => _runLastfmTrackSearch(q), 400); +} + +async function _runLastfmTrackSearch(q) { + if (q.length < 2) return; + const dropdown = document.getElementById('lastfm-radio-dropdown'); + // Show a mini spinner while fetching + dropdown.innerHTML = '
'; + dropdown.style.display = 'block'; + try { + const res = await fetch(`/api/lastfm/search/tracks?q=${encodeURIComponent(q)}`); + if (!res.ok) { dropdown.style.display = 'none'; return; } + const data = await res.json(); + if (!data.results || data.results.length === 0) { + dropdown.style.display = 'none'; + return; + } + dropdown.innerHTML = data.results.map(t => { + const imgHtml = t.image_url + ? `` + : '
'; + const listeners = t.listeners > 0 + ? `${(t.listeners / 1000).toFixed(0)}k listeners` + : ''; + return ` +
+
${imgHtml}
+
+ ${t.name} + ${t.artist}${listeners ? ' · ' + t.listeners.toLocaleString() + ' listeners' : ''} +
+
`; + }).join(''); + dropdown.style.display = 'block'; + } catch (e) { + console.error('Last.fm search error:', e); + dropdown.style.display = 'none'; + } +} + +function selectLastfmRadioTrack(name, artist) { + // Close dropdown and update input to show selection + document.getElementById('lastfm-radio-dropdown').style.display = 'none'; + document.getElementById('lastfm-radio-input').value = `${name} — ${artist}`; + document.getElementById('lastfm-radio-input').blur(); + // Immediately kick off generation + _generateLastfmRadioFor(name, artist); +} + +function clearLastfmRadioSelection() { + document.getElementById('lastfm-radio-input').value = ''; + document.getElementById('lastfm-radio-dropdown').style.display = 'none'; +} + +// Keep generateLastfmRadio as public alias (called by nothing now but harmless) +async function generateLastfmRadio() { + const input = (document.getElementById('lastfm-radio-input').value || '').trim(); + if (!input) return; + // Parse "Track — Artist" format if present + const parts = input.split(' — '); + if (parts.length >= 2) { + await _generateLastfmRadioFor(parts[0].trim(), parts[1].trim()); + } +} + +async function _generateLastfmRadioFor(name, artist) { + const container = document.getElementById('lastfm-radio-playlists'); + const input = document.getElementById('lastfm-radio-input'); + + // Show loading state in the playlists area + if (container) { + container.innerHTML = ` +
+
+

Building radio for ${name} by ${artist}

+
`; + } + if (input) input.disabled = true; + + try { + const res = await fetch('/api/lastfm/radio/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_name: name, artist_name: artist }), + }); + const data = await res.json(); + if (!data.success) { + if (container) container.innerHTML = ''; + showToast(data.error || 'Failed to generate radio', 'error'); + return; + } + // Reload all radio playlist cards + await _loadLastfmRadioPlaylists(); + } catch (e) { + if (container) container.innerHTML = ''; + showToast('Error generating Last.fm radio', 'error'); + console.error(e); + } finally { + if (input) input.disabled = false; + } +} + +async function initializeLastfmRadioSection() { + try { + const cfgRes = await fetch('/api/lastfm/configured'); + if (!cfgRes.ok) return; + const { configured } = await cfgRes.json(); + const section = document.getElementById('lastfm-radio-section'); + if (!section) return; + if (!configured) { + section.style.display = 'none'; + return; + } + section.style.display = ''; + await _loadLastfmRadioPlaylists(); + } catch (e) { + console.error('Error initializing Last.fm Radio section:', e); + } +} + +async function _loadLastfmRadioPlaylists() { + const container = document.getElementById('lastfm-radio-playlists'); + if (!container) return; + try { + const res = await fetch('/api/discover/listenbrainz/lastfm-radio'); + if (!res.ok) return; + const data = await res.json(); + if (!data.success || !data.playlists || data.playlists.length === 0) { + container.innerHTML = ''; + return; + } + // Reuse the same LB playlist card builder — cards are identical + container.innerHTML = buildListenBrainzPlaylistsHtml(data.playlists, 'lastfm_radio'); + loadTracksForPlaylists(data.playlists); + } catch (e) { + console.error('Error loading Last.fm radio playlists:', e); + } +} + +// Close dropdown when clicking outside +document.addEventListener('click', (e) => { + const section = document.getElementById('lastfm-radio-search-section'); + if (section && !section.contains(e.target)) { + const dd = document.getElementById('lastfm-radio-dropdown'); + if (dd) dd.style.display = 'none'; + } +}); + +// ──────────────────────────────────────────────────────────────────────────── + +async function initializeListenBrainzTabs() { + try { + console.log('🧠 Initializing ListenBrainz tabs...'); + + // Fetch all playlist types + const [createdForRes, userPlaylistsRes, collaborativeRes] = await Promise.all([ + fetch('/api/discover/listenbrainz/created-for'), + fetch('/api/discover/listenbrainz/user-playlists'), + fetch('/api/discover/listenbrainz/collaborative'), + ]); + + console.log('📡 API Responses:', { + createdFor: createdForRes.status, + userPlaylists: userPlaylistsRes.status, + collaborative: collaborativeRes.status, + }); + + const tabs = [ + { id: 'recommendations', label: '🎁 Recommendations', hasData: false }, + { id: 'user', label: '📚 Your Playlists', hasData: false }, + { id: 'collaborative', label: '🤝 Collaborative', hasData: false }, + ]; + + // Track LB username for header display + let lbUsername = null; + + // Check which tabs have data + if (createdForRes.ok) { + const data = await createdForRes.json(); + console.log('📋 Created For data:', data); + if (data.username) lbUsername = data.username; + if (data.success && data.playlists && data.playlists.length > 0) { + listenbrainzPlaylistsCache['recommendations'] = data.playlists; + tabs[0].hasData = true; + console.log(`✅ Found ${data.playlists.length} recommendation playlists`); + } + } + + if (userPlaylistsRes.ok) { + const data = await userPlaylistsRes.json(); + console.log('📚 User Playlists data:', data); + if (data.username && !lbUsername) lbUsername = data.username; + if (data.success && data.playlists && data.playlists.length > 0) { + listenbrainzPlaylistsCache['user'] = data.playlists; + tabs[1].hasData = true; + console.log(`✅ Found ${data.playlists.length} user playlists`); + } + } + + if (collaborativeRes.ok) { + const data = await collaborativeRes.json(); + console.log('🤝 Collaborative data:', data); + if (data.username && !lbUsername) lbUsername = data.username; + if (data.success && data.playlists && data.playlists.length > 0) { + listenbrainzPlaylistsCache['collaborative'] = data.playlists; + tabs[2].hasData = true; + console.log(`✅ Found ${data.playlists.length} collaborative playlists`); + } + } + + // Build tabs HTML + const tabsContainer = document.getElementById('listenbrainz-tabs'); + console.log('🔧 Building tabs. Available tabs:', tabs.filter(t => t.hasData).map(t => t.label)); + + let tabsHtml = '
'; // Reuse decade tabs styling + + tabs.forEach(tab => { + if (tab.hasData) { + const isActive = tab.id === activeListenBrainzTab; + tabsHtml += ` + + `; + } + }); + tabsHtml += '
'; + + if (tabs.every(t => !t.hasData)) { + console.log('⚠️ No tabs have data'); + tabsContainer.innerHTML = ` +
`; + return; + } + + tabsContainer.innerHTML = tabsHtml; + + // Update section subtitle with username + const lbSubtitle = document.getElementById('listenbrainz-section-subtitle'); + if (lbSubtitle) { + lbSubtitle.textContent = lbUsername ? `Playlists for ${lbUsername}` : 'Playlists from ListenBrainz'; + } + + // Load first available tab + const firstTab = tabs.find(t => t.hasData); + if (firstTab) { + console.log(`🎯 Loading first tab: ${firstTab.label} (${firstTab.id})`); + activeListenBrainzTab = firstTab.id; + loadListenBrainzTabContent(firstTab.id); + } else { + console.log('❌ No first tab found'); + } + + } catch (error) { + console.error('Error initializing ListenBrainz tabs:', error); + const tabsContainer = document.getElementById('listenbrainz-tabs'); + if (tabsContainer) { + tabsContainer.innerHTML = '

Failed to load playlists

'; + } + } +} + +function switchListenBrainzTab(tabId) { + // Update active tab + activeListenBrainzTab = tabId; + + // Update tab buttons + const tabs = document.querySelectorAll('#listenbrainz-tabs .decade-tab'); + tabs.forEach(tab => { + if (tab.dataset.tab === tabId) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + // Load content + loadListenBrainzTabContent(tabId); +} + +function groupListenBrainzPlaylists(playlists) { + const groups = {}; + const groupOrder = []; + + playlists.forEach(playlist => { + const playlistData = playlist.playlist || playlist; + const title = (playlistData.title || '').toLowerCase(); + + let groupName; + if (title.includes('weekly jams')) { + groupName = 'Weekly Jams'; + } else if (title.includes('weekly exploration')) { + groupName = 'Weekly Exploration'; + } else if (title.includes('top discoveries')) { + groupName = 'Top Discoveries'; + } else if (title.includes('top missed recordings')) { + groupName = 'Top Missed Recordings'; + } else if (title.includes('daily jams')) { + groupName = 'Daily Jams'; + } else { + groupName = 'Other'; + } + + if (!groups[groupName]) { + groups[groupName] = []; + groupOrder.push(groupName); + } + groups[groupName].push(playlist); + }); + + // Move "Other" to the end if it exists + const otherIdx = groupOrder.indexOf('Other'); + if (otherIdx !== -1 && otherIdx !== groupOrder.length - 1) { + groupOrder.splice(otherIdx, 1); + groupOrder.push('Other'); + } + + return { groups, groupOrder }; +} + +function buildListenBrainzPlaylistsHtml(playlists, tabId) { + let html = ''; + playlists.forEach((playlist, index) => { + const playlistData = playlist.playlist || playlist; + const identifier = playlistData.identifier?.split('/').pop() || ''; + console.log(`📋 Playlist ${index}:`, { + title: playlistData.title, + fullIdentifier: playlistData.identifier, + extractedIdentifier: identifier + }); + const title = playlistData.title || 'Untitled Playlist'; + const creator = playlistData.creator || 'ListenBrainz'; + + let trackCount = 50; + if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { + trackCount = playlistData.annotation.track_count; + } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { + trackCount = playlistData.track.length; + } + + const playlistId = `discover-lb-playlist-${identifier}`; // Use consistent MBID-based ID + const virtualPlaylistId = `discover_lb_${tabId}_${identifier}`; + + html += ` +
+
+
+

${title}

+ +
+
+ + + + + +
+
+ + +
+

Loading tracks...

+
+
+ `; + }); + return html; +} + +function loadTracksForPlaylists(playlists) { + playlists.forEach((playlist) => { + const playlistData = playlist.playlist || playlist; + const identifier = playlistData.identifier?.split('/').pop() || ''; + const playlistId = `discover-lb-playlist-${identifier}`; + loadListenBrainzPlaylistTracks(identifier, playlistId); + }); +} + +function switchListenBrainzSubTab(groupId) { + activeListenBrainzSubTab = groupId; + + // Update sub-tab buttons + const subTabs = document.querySelectorAll('#lb-subtabs-bar .lb-subtab'); + subTabs.forEach(tab => { + if (tab.dataset.group === groupId) { + tab.classList.add('active'); + } else { + tab.classList.remove('active'); + } + }); + + // Show/hide sub-tab content panels + const panels = document.querySelectorAll('.lb-subtab-panel'); + panels.forEach(panel => { + if (panel.dataset.group === groupId) { + panel.style.display = 'block'; + // Load tracks for playlists in this panel if not already loaded + const unloaded = panel.querySelectorAll('.discover-loading'); + if (unloaded.length > 0) { + const groupPlaylists = panel._playlists; + if (groupPlaylists) { + loadTracksForPlaylists(groupPlaylists); + } + } + } else { + panel.style.display = 'none'; + } + }); +} + +async function loadListenBrainzTabContent(tabId) { + const container = document.getElementById('listenbrainz-tab-content'); + if (!container) return; + + const playlists = listenbrainzPlaylistsCache[tabId] || []; + if (playlists.length === 0) { + container.innerHTML = '

No playlists in this category

'; + return; + } + + // For recommendations tab with multiple playlists, group into sub-tabs + if (tabId === 'recommendations' && playlists.length > 1) { + const { groups, groupOrder } = groupListenBrainzPlaylists(playlists); + + // If only one group, no need for sub-tabs + if (groupOrder.length <= 1) { + const html = buildListenBrainzPlaylistsHtml(playlists, tabId); + container.innerHTML = html; + loadTracksForPlaylists(playlists); + return; + } + + // Build sub-tabs bar + const firstGroup = activeListenBrainzSubTab && groupOrder.includes(activeListenBrainzSubTab) + ? activeListenBrainzSubTab + : groupOrder[0]; + activeListenBrainzSubTab = firstGroup; + + let subTabsHtml = '
'; + groupOrder.forEach(groupName => { + const isActive = groupName === firstGroup; + const count = groups[groupName].length; + subTabsHtml += ` + + `; + }); + subTabsHtml += '
'; + + // Build content panels for each group + let panelsHtml = ''; + groupOrder.forEach(groupName => { + const isActive = groupName === firstGroup; + panelsHtml += `
`; + panelsHtml += buildListenBrainzPlaylistsHtml(groups[groupName], tabId); + panelsHtml += '
'; + }); + + container.innerHTML = subTabsHtml + panelsHtml; + + // Store playlist references on panels for lazy loading + groupOrder.forEach(groupName => { + const panel = container.querySelector(`.lb-subtab-panel[data-group="${groupName}"]`); + if (panel) { + panel._playlists = groups[groupName]; + } + }); + + // Load tracks only for the active sub-tab + loadTracksForPlaylists(groups[firstGroup]); + return; + } + + // Default: flat list for user/collaborative tabs (or single-group recommendations) + const html = buildListenBrainzPlaylistsHtml(playlists, tabId); + container.innerHTML = html; + loadTracksForPlaylists(playlists); +} + +async function loadListenBrainzPlaylistTracks(identifier, playlistId) { + try { + const playlistContainer = document.getElementById(`${playlistId}-playlist`); + if (!playlistContainer) return; + + // Check cache first + if (listenbrainzTracksCache[identifier]) { + displayListenBrainzTracks(listenbrainzTracksCache[identifier], playlistId); + return; + } + + console.log(`🔄 Fetching tracks for playlist: ${identifier}`); + const response = await fetch(`/api/discover/listenbrainz/playlist/${identifier}`); + console.log(`📡 Response status: ${response.status}`); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`❌ Failed to fetch playlist: ${response.status} - ${errorText}`); + throw new Error('Failed to fetch playlist tracks'); + } + + const data = await response.json(); + console.log(`📋 Received data:`, data); + console.log(`📊 Tracks count: ${data.tracks?.length || 0}`); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No tracks available

'; + return; + } + + // Cache the tracks + listenbrainzTracksCache[identifier] = data.tracks; + + // Display tracks + displayListenBrainzTracks(data.tracks, playlistId); + + } catch (error) { + console.error('Error loading ListenBrainz playlist tracks:', error); + const playlistContainer = document.getElementById(`${playlistId}-playlist`); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load tracks

'; + } + } +} + +/** + * Clean artist name by removing featured artists + * e.g., "Blackstreet feat. Dr. Dre & Queen Pen" -> "Blackstreet" + */ +function cleanArtistName(artistName) { + if (!artistName) return artistName; + + // Remove everything after common featuring patterns (case insensitive) + const patterns = [ + /\s+feat\.?\s+.*/i, // "feat." or "feat" + /\s+featuring\s+.*/i, // "featuring" + /\s+ft\.?\s+.*/i, // "ft." or "ft" + /\s+with\s+.*/i, // "with" + /\s+x\s+.*/i // " x " (common in collaborations) + ]; + + let cleaned = artistName; + for (const pattern of patterns) { + cleaned = cleaned.replace(pattern, ''); + } + + return cleaned.trim(); +} + +function displayListenBrainzTracks(tracks, playlistId) { + const playlistContainer = document.getElementById(`${playlistId}-playlist`); + if (!playlistContainer) return; + + console.log(`🎨 Displaying ${tracks.length} tracks for ${playlistId}`); + if (tracks.length > 0) { + console.log('Sample track data:', tracks[0]); + } + + // Update track count in the metadata section + const metaElement = document.getElementById(`${playlistId}-meta`); + if (metaElement) { + // Extract creator from existing text (before the bullet) + const currentText = metaElement.textContent; + const creatorMatch = currentText.match(/by (.+?) •/); + const creator = creatorMatch ? creatorMatch[1] : 'ListenBrainz'; + metaElement.textContent = `by ${creator} • ${tracks.length} track${tracks.length !== 1 ? 's' : ''}`; + } + + // Simple SVG placeholder for missing album art (music note icon) + const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMyYTJhMmEiLz48cGF0aCBkPSJNMjQgMTJ2MTIuNUEzLjUgMy41IDAgMSAxIDIwLjUgMjFWMTZsLTUgMXY5YTMuNSAzLjUgMCAxIDEtMy41LTMuNVYxM2wxMi0zeiIgZmlsbD0iIzU1NSIvPjwvc3ZnPg=='; + + let html = '
'; + tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || placeholderImage; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = track.duration_ms > 0 ? `${durationMin}:${durationSec.toString().padStart(2, '0')}` : ''; + + const albumName = track.album_name ? escapeHtml(track.album_name) : ''; + + html += ` +
+
${index + 1}
+
+ ${albumName} +
+
+
${escapeHtml(track.track_name || 'Unknown Track')}
+
${escapeHtml(cleanArtistName(track.artist_name) || 'Unknown Artist')}
+
+
${albumName}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; +} + +function _toggleWingItDropdownLB(btn, identifier, title) { + const existing = document.querySelector('.wing-it-dropdown.visible'); + if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; } + + const wrap = btn.closest('.wing-it-wrap'); + if (!wrap) return; + + const dropdown = document.createElement('div'); + dropdown.className = 'wing-it-dropdown'; + dropdown.innerHTML = ` + + + `; + + dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { + item.addEventListener('click', () => { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + showToast('No tracks cached. Try opening the playlist first.', 'error'); + return; + } + if (item.dataset.action === 'download') { + wingItDownload(tracks, title, 'ListenBrainz', identifier, true); + } else { + _wingItSync(tracks, title, 'ListenBrainz', identifier); + } + }); + }); + + const btnRect2 = btn.getBoundingClientRect(); + if (btnRect2.top < 200) dropdown.classList.add('flip-down'); + + wrap.appendChild(dropdown); + requestAnimationFrame(() => dropdown.classList.add('visible')); + + setTimeout(() => { + const closeHandler = e => { + if (!dropdown.contains(e.target) && e.target !== btn) { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 50); +} + +async function _wingItFromLBCard(identifier, title) { + // Legacy — kept for backward compat + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + showToast('No tracks cached for this playlist. Try opening the discovery modal first.', 'error'); + return; + } + wingItDownload(tracks, title, 'ListenBrainz', identifier); +} + +async function openDownloadModalForListenBrainzPlaylist(identifier, title) { + try { + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + showToast('No tracks to download', 'error'); + return; + } + + console.log(`🎵 Opening ListenBrainz discovery modal: ${title}`); + console.log(`🔍 Looking for existing state with identifier: ${identifier}`); + console.log(`📋 All ListenBrainz states:`, Object.keys(listenbrainzPlaylistStates)); + + // Check if state already exists from backend hydration (like Beatport does) + const existingState = listenbrainzPlaylistStates[identifier]; + console.log(`🔍 Existing state found:`, existingState ? `Phase: ${existingState.phase}` : 'None'); + + if (existingState && existingState.phase !== 'fresh') { + // State exists - rehydrate the modal with existing data + console.log(`🔄 Rehydrating existing ListenBrainz state (Phase: ${existingState.phase})`); + + // If downloading/download_complete, rehydrate download modal instead + if ((existingState.phase === 'downloading' || existingState.phase === 'download_complete') && + existingState.convertedSpotifyPlaylistId && existingState.download_process_id) { + + console.log(`📥 Rehydrating download modal for ListenBrainz playlist: ${title}`); + + // Implement download modal rehydration (like Beatport does) + const convertedPlaylistId = existingState.convertedSpotifyPlaylistId; + + try { + // Check if modal already exists (user just closed it) + if (activeDownloadProcesses[convertedPlaylistId]) { + console.log(`✅ Download modal already exists, just showing it`); + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } + return; + } + + // Create the download modal using the ListenBrainz state + console.log(`🆕 Creating new download modal for rehydration`); + // Get tracks from the existing state + let spotifyTracks = []; + + if (existingState && existingState.discovery_results) { + spotifyTracks = existingState.discovery_results + .filter(result => result.spotify_data) + .map(result => { + const track = result.spotify_data; + // Ensure artists is an array of strings + if (track.artists && Array.isArray(track.artists)) { + track.artists = track.artists.map(artist => + typeof artist === 'string' ? artist : (artist.name || artist) + ); + } else if (track.artists && typeof track.artists === 'string') { + track.artists = [track.artists]; + } else { + track.artists = ['Unknown Artist']; + } + return { + id: track.id, + name: track.name, + artists: track.artists, + album: track.album || 'Unknown Album', + duration_ms: track.duration_ms || 0, + external_urls: track.external_urls || {} + }; + }); + } + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForYouTube( + convertedPlaylistId, + title, + spotifyTracks + ); + + // Set the modal to running state with the correct batch ID + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = existingState.phase === 'download_complete' ? 'complete' : 'running'; + process.batchId = existingState.download_process_id; + + // Update UI to running state + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for this process + startModalDownloadPolling(convertedPlaylistId); + + // Add to discover download sidebar if this has discoverMetadata + if (process.discoverMetadata) { + const playlistName = title; + const imageUrl = process.discoverMetadata.imageUrl; + const type = process.discoverMetadata.type || 'album'; + addDiscoverDownload(convertedPlaylistId, playlistName, type, imageUrl); + console.log(`📥 [REHYDRATION] Added ListenBrainz download to sidebar: ${playlistName}`); + } + + // Show modal since user clicked the download button (different from background rehydration) + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } + console.log(`✅ Rehydrated download modal for ListenBrainz playlist: ${title}`); + } + } else { + console.warn(`⚠️ No Spotify tracks found for ListenBrainz download modal: ${title}`); + } + } catch (error) { + console.warn(`⚠️ Error setting up download process for ListenBrainz playlist "${title}":`, error.message); + } + + return; + } + + // Open discovery modal with existing state + openYouTubeDiscoveryModal(identifier); + + // If still discovering, resume polling + if (existingState.phase === 'discovering') { + console.log(`🔄 Resuming discovery polling for: ${title}`); + startListenBrainzDiscoveryPolling(identifier); + } + + return; + } + + // No existing state - create fresh state and start discovery + console.log(`🆕 Creating fresh ListenBrainz state for: ${title}`); + + // Create YouTube-style state entry for this ListenBrainz playlist (like Beatport does) + const listenbrainzState = { + phase: 'fresh', + playlist: { + name: title, + tracks: tracks.map(track => ({ + track_name: track.track_name, + artist_name: track.artist_name, + album_name: track.album_name, + duration_ms: track.duration_ms || 0, + mbid: track.mbid, + release_mbid: track.release_mbid, + album_cover_url: track.album_cover_url + })), + description: `${tracks.length} tracks from ${title}`, + source: 'listenbrainz' + }, + is_listenbrainz_playlist: true, + playlist_mbid: identifier, // Link to ListenBrainz playlist + // Initialize discovery state properties (both naming conventions for modal compatibility) + discovery_results: [], + discoveryResults: [], + discovery_progress: 0, + discoveryProgress: 0, + spotify_matches: 0, + spotifyMatches: 0, + spotify_total: tracks.length, + spotifyTotal: tracks.length + }; + + // Store in ListenBrainz playlist states + listenbrainzPlaylistStates[identifier] = listenbrainzState; + + // Start discovery automatically (like Beatport and Tidal do) + try { + console.log(`🔍 Starting ListenBrainz discovery for: ${title}`); + + // Call the discovery start endpoint with playlist data + const response = await fetch(`/api/listenbrainz/discovery/start/${identifier}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + playlist: listenbrainzState.playlist + }) + }); + + const result = await response.json(); + if (result.success) { + // Update state to discovering + listenbrainzPlaylistStates[identifier].phase = 'discovering'; + + // Start polling for progress + startListenBrainzDiscoveryPolling(identifier); + + console.log(`✅ Started ListenBrainz discovery for: ${title}`); + } else { + console.error('❌ Error starting ListenBrainz discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + } + } catch (error) { + console.error('❌ Error starting ListenBrainz discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + + // Open the existing YouTube discovery modal infrastructure + openYouTubeDiscoveryModal(identifier); + + console.log(`✅ ListenBrainz discovery modal opened for ${title} with ${tracks.length} tracks`); + + } catch (error) { + console.error('Error opening discovery modal for ListenBrainz playlist:', error); + showToast('Failed to open discovery modal', 'error'); + } +} + +async function openListenBrainzPlaylist(playlistMbid, playlistName) { + try { + showLoadingOverlay(`Loading ${playlistName}...`); + + const response = await fetch(`/api/discover/listenbrainz/playlist/${playlistMbid}`); + if (!response.ok) { + throw new Error('Failed to fetch playlist'); + } + + const data = await response.json(); + if (!data.success || !data.playlist) { + showToast('Failed to load playlist', 'error'); + hideLoadingOverlay(); + return; + } + + const playlist = data.playlist; + const tracks = playlist.tracks || []; + + if (tracks.length === 0) { + showToast('This playlist is empty', 'info'); + hideLoadingOverlay(); + return; + } + + // Convert to Spotify-like format for compatibility with download modal + const spotifyTracks = tracks.map(track => ({ + id: track.recording_mbid || `listenbrainz_${track.title}_${track.creator}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing + name: track.title || 'Unknown', + artists: [{ name: cleanArtistName(track.creator || 'Unknown') }], // Proper Spotify format + album: { + name: track.album || 'Unknown Album', + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0, + listenbrainz_metadata: track.additional_metadata + })); + + const virtualPlaylistId = `listenbrainz_${playlistMbid}`; + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + hideLoadingOverlay(); + + } catch (error) { + console.error(`Error opening ListenBrainz playlist:`, error); + showToast(`Failed to load playlist`, 'error'); + hideLoadingOverlay(); + } +} + +async function refreshListenBrainzPlaylists() { + const button = document.getElementById('listenbrainz-refresh-btn'); + if (!button) return; + + try { + // Show loading state on button + const originalContent = button.innerHTML; + button.disabled = true; + button.innerHTML = 'Refreshing...'; + + console.log('🔄 Refreshing ListenBrainz playlists...'); + showToast('Refreshing ListenBrainz playlists...', 'info'); + + const response = await fetch('/api/discover/listenbrainz/refresh', { + method: 'POST' + }); + + if (!response.ok) { + throw new Error(`Failed to refresh: ${response.statusText}`); + } + + const data = await response.json(); + + if (data.success) { + const summary = data.summary || {}; + let message = 'ListenBrainz playlists refreshed!'; + + // Build summary message + const updates = []; + for (const [type, stats] of Object.entries(summary)) { + const total = (stats.new || 0) + (stats.updated || 0); + if (total > 0) { + updates.push(`${total} ${type}`); + } + } + + if (updates.length > 0) { + message += ` Updated: ${updates.join(', ')}`; + } else { + message = 'All playlists are up to date'; + } + + console.log('✅ Refresh complete:', data.summary); + showToast(message, 'success'); + + // Reload the tabs to show updated data + await initializeListenBrainzTabs(); + + } else { + throw new Error(data.error || 'Unknown error'); + } + + // Restore button + button.disabled = false; + button.innerHTML = originalContent; + + } catch (error) { + console.error('Error refreshing ListenBrainz playlists:', error); + showToast(`Failed to refresh: ${error.message}`, 'error'); + + // Restore button + button.disabled = false; + button.innerHTML = '🔄Refresh'; + } +} + +// =============================== +// SEASONAL DISCOVERY +// =============================== + +async function loadSeasonalContent() { + try { + const response = await fetch('/api/discover/seasonal/current'); + if (!response.ok) { + console.error('Failed to fetch seasonal content'); + return; + } + + const data = await response.json(); + + // If no active season, hide seasonal sections + if (!data.success || !data.season) { + hideSeasonalSections(); + return; + } + + currentSeasonKey = data.season; + + // Load seasonal albums + await loadSeasonalAlbums(data); + + // Load seasonal playlist if available + if (data.playlist_available) { + await loadSeasonalPlaylist(data); + } + + } catch (error) { + console.error('Error loading seasonal content:', error); + hideSeasonalSections(); + } +} + +async function loadSeasonalAlbums(seasonData) { + try { + const carousel = document.getElementById('seasonal-albums-carousel'); + if (!carousel) return; + + // Show seasonal section + const seasonalSection = document.getElementById('seasonal-albums-section'); + if (seasonalSection) { + seasonalSection.style.display = 'block'; + } + + // Update header + const seasonalTitle = document.getElementById('seasonal-albums-title'); + const seasonalSubtitle = document.getElementById('seasonal-albums-subtitle'); + + if (seasonalTitle) { + seasonalTitle.textContent = `${seasonData.icon} ${seasonData.name}`; + } + if (seasonalSubtitle) { + seasonalSubtitle.textContent = seasonData.description; + } + + // Store albums for download functionality + discoverSeasonalAlbums = seasonData.albums || []; + + if (discoverSeasonalAlbums.length === 0) { + carousel.innerHTML = '

No seasonal albums found

'; + return; + } + + // Build carousel HTML + let html = ''; + discoverSeasonalAlbums.forEach((album, index) => { + const coverUrl = album.album_cover_url || '/static/placeholder-album.png'; + html += ` +
+
+ ${album.album_name} +
+
+

${album.album_name}

+

${album.artist_name}

+ ${album.release_date ? `

${album.release_date}

` : ''} +
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading seasonal albums:', error); + } +} + +async function loadSeasonalPlaylist(seasonData) { + try { + const playlistContainer = document.getElementById('seasonal-playlist'); + if (!playlistContainer) return; + + // Show seasonal playlist section + const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section'); + if (seasonalPlaylistSection) { + seasonalPlaylistSection.style.display = 'block'; + } + + // Update header + const playlistTitle = document.getElementById('seasonal-playlist-title'); + const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle'); + + if (playlistTitle) { + playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`; + } + if (playlistSubtitle) { + playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`; + } + + playlistContainer.innerHTML = '

Loading playlist...

'; + + // Fetch playlist tracks + const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`); + if (!response.ok) { + throw new Error('Failed to fetch seasonal playlist'); + } + + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No tracks available yet

'; + return; + } + + // Store tracks for download/sync functionality + discoverSeasonalTracks = data.tracks; + + // Build compact playlist HTML + let html = '
'; + data.tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + + html += ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; + + } catch (error) { + console.error('Error loading seasonal playlist:', error); + const playlistContainer = document.getElementById('seasonal-playlist'); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load playlist

'; + } + } +} + +function hideSeasonalSections() { + const seasonalAlbumsSection = document.getElementById('seasonal-albums-section'); + const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section'); + + if (seasonalAlbumsSection) { + seasonalAlbumsSection.style.display = 'none'; + } + if (seasonalPlaylistSection) { + seasonalPlaylistSection.style.display = 'none'; + } +} + +async function openDownloadModalForSeasonalAlbum(albumIndex) { + const album = discoverSeasonalAlbums[albumIndex]; + if (!album) { + showToast('Album data not found', 'error'); + return; + } + + console.log(`📥 Opening Download Missing Tracks modal for seasonal album: ${album.album_name}`); + showLoadingOverlay(`Loading tracks for ${album.album_name}...`); + + try { + // Determine source and album ID - use source-agnostic endpoint (matches Recent Releases) + const source = album.source || (album.spotify_album_id && !album.spotify_album_id.match(/^\d+$/) ? 'spotify' : 'itunes'); + const albumId = album.spotify_album_id; + + if (!albumId) { + throw new Error('No album ID available'); + } + + // Fetch album tracks from appropriate source (pass name/artist for Hydrabase support) + const _dap1 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap1}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + // Convert to expected format with full album context (matches Recent Releases) + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + + // Create virtual playlist ID + const virtualPlaylistId = `seasonal_album_${albumId}`; + + // Pass proper artist/album context for album download (1 worker + source reuse) + const artistContext = { + name: album.artist_name, + source: source + }; + + const albumContext = { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }; + + // Open download modal with album context (same as Recent Releases) + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); + + hideLoadingOverlay(); + + } catch (error) { + console.error(`Error loading seasonal album: ${error.message}`); + hideLoadingOverlay(); + showToast(`Failed to load album tracks: ${error.message}`, 'error'); + } +} + +async function openDownloadModalForSeasonalPlaylist() { + if (!discoverSeasonalTracks || discoverSeasonalTracks.length === 0) { + alert('No seasonal tracks available'); + return; + } + + // Convert to track format expected by modal + const tracks = discoverSeasonalTracks.map(track => ({ + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { name: track.album_name } + })); + + openDownloadMissingModal(tracks, `${currentSeasonKey} Seasonal Mix`); +} + +async function syncSeasonalPlaylist() { + if (!currentSeasonKey) { + alert('No active season'); + return; + } + + // Use the same sync logic as other discover playlists + // Create a virtual playlist ID for tracking + const virtualPlaylistId = `discover_seasonal_${currentSeasonKey}`; + + // Build playlist data from seasonal tracks + const playlistData = { + id: virtualPlaylistId, + name: `${currentSeasonKey.charAt(0).toUpperCase() + currentSeasonKey.slice(1)} Mix`, + tracks: discoverSeasonalTracks.map(track => ({ + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { name: track.album_name }, + duration_ms: track.duration_ms + })) + }; + + // Trigger sync (reuse existing sync infrastructure) + await syncPlaylistToLibrary(playlistData); +} + +// =============================== +// PERSONALIZED PLAYLISTS +// =============================== + +async function loadPersonalizedRecentlyAdded() { + try { + const container = document.getElementById('personalized-recently-added'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/recently-added'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedRecentlyAdded = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading recently added:', error); + } +} + +async function loadPersonalizedTopTracks() { + try { + const container = document.getElementById('personalized-top-tracks'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/top-tracks'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedTopTracks = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading top tracks:', error); + } +} + +async function loadPersonalizedForgottenFavorites() { + try { + const container = document.getElementById('personalized-forgotten-favorites'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/forgotten-favorites'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedForgottenFavorites = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading forgotten favorites:', error); + } +} + +async function loadPersonalizedPopularPicks() { + try { + const container = document.getElementById('personalized-popular-picks'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/popular-picks'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedPopularPicks = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading popular picks:', error); + } +} + +async function loadPersonalizedHiddenGems() { + try { + const container = document.getElementById('personalized-hidden-gems'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/hidden-gems'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedHiddenGems = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading hidden gems:', error); + } +} + +async function loadPersonalizedDailyMixes() { + try { + const container = document.getElementById('daily-mixes-grid'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/daily-mixes'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.mixes || data.mixes.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedDailyMixes = data.mixes; + + // Render Daily Mix cards + let html = ''; + data.mixes.forEach((mix, index) => { + const coverUrl = mix.tracks && mix.tracks.length > 0 ? + (mix.tracks[0].album_cover_url || '/static/placeholder-album.png') : + '/static/placeholder-album.png'; + + html += ` +
+
+ ${mix.name} +
+
+
+

${mix.name}

+

${mix.description}

+

${mix.track_count} tracks

+
+
+ `; + }); + + container.innerHTML = html; + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading daily mixes:', error); + } +} + +function renderCompactPlaylist(container, tracks) { + let html = '
'; + + tracks.forEach((track, index) => { + const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; + const durationMin = Math.floor(track.duration_ms / 60000); + const durationSec = Math.floor((track.duration_ms % 60000) / 1000); + const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; + const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '"'); + + html += ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+ +
+ `; + }); + + html += '
'; + container.innerHTML = html; +} + +async function blockDiscoveryArtist(artistName) { + if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return; + try { + const res = await fetch('/api/discover/artist-blacklist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_name: artistName }) + }); + const data = await res.json(); + if (data.success) { + showToast(`Blocked ${artistName} from discovery`, 'success'); + // Refresh all discovery sections to remove the artist + loadPersonalizedHiddenGems(); + loadDiscoveryShuffle(); + loadPersonalizedDailyMixes(); + } else { + showToast(data.error || 'Failed to block artist', 'error'); + } + } catch (e) { + showToast('Error blocking artist', 'error'); + } +} + +async function openDiscoveryBlacklistModal() { + if (document.getElementById('discovery-blacklist-modal-overlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'discovery-blacklist-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
+
+

Blocked Artists

+

These artists won't appear in any discovery playlist across all sources

+ +
+ +
+
Loading...
+
+ +
+ `; + document.body.appendChild(overlay); + + // Wire up search + let searchTimer = null; + const input = document.getElementById('dbl-search-input'); + input.addEventListener('input', () => { + clearTimeout(searchTimer); + const q = input.value.trim(); + if (q.length < 2) { document.getElementById('dbl-search-results').style.display = 'none'; return; } + searchTimer = setTimeout(() => _dblSearch(q), 300); + }); + + _dblLoadList(); +} + +async function _dblSearch(query) { + const resultsEl = document.getElementById('dbl-search-results'); + if (!resultsEl) return; + try { + // Use existing enhanced search to find artists + const res = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query, limit: 8 }) + }); + const data = await res.json(); + const artists = data.spotify_artists || data.artists || []; + if (artists.length === 0) { + resultsEl.innerHTML = '
No artists found
'; + resultsEl.style.display = 'block'; + return; + } + resultsEl.innerHTML = artists.map(a => { + const name = _escToast(a.name || ''); + const img = a.image_url ? `` : '
🎤
'; + return `
+ ${img} + ${name} + Block +
`; + }).join(''); + resultsEl.style.display = 'block'; + } catch (e) { + resultsEl.style.display = 'none'; + } +} + +async function _dblBlockFromSearch(artistName) { + try { + const res = await fetch('/api/discover/artist-blacklist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_name: artistName }) + }); + const data = await res.json(); + if (data.success) { + showToast(`Blocked ${artistName} from discovery`, 'success'); + document.getElementById('dbl-search-results').style.display = 'none'; + const input = document.getElementById('dbl-search-input'); + if (input) input.value = ''; + _dblLoadList(); + } + } catch (e) { + showToast('Error blocking artist', 'error'); + } +} + +async function _dblLoadList() { + const container = document.getElementById('dbl-list'); + if (!container) return; + try { + const res = await fetch('/api/discover/artist-blacklist'); + const data = await res.json(); + if (!data.success || !data.entries || data.entries.length === 0) { + container.innerHTML = '
No blocked artists yet — search above to block one
'; + return; + } + container.innerHTML = data.entries.map(e => ` +
+ ${_escToast(e.artist_name)} + ${e.created_at ? new Date(e.created_at).toLocaleDateString() : ''} + +
+ `).join(''); + } catch (e) { + container.innerHTML = '
Failed to load
'; + } +} + +async function unblockDiscoveryArtist(id, name) { + try { + const res = await fetch(`/api/discover/artist-blacklist/${id}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + showToast(`Unblocked ${name}`, 'success'); + _dblLoadList(); + } + } catch (e) { + showToast('Error unblocking artist', 'error'); + } +} + +// Backwards compat — called during page init but now a no-op (modal handles it) +// ── Your Artists (Liked Artists Pool) ── + +async function loadYourArtists() { + const section = document.getElementById('your-artists-section'); + const carousel = document.getElementById('your-artists-carousel'); + const subtitle = document.getElementById('your-artists-subtitle'); + if (!section || !carousel) return; + + try { + const resp = await fetch('/api/discover/your-artists'); + if (!resp.ok) return; + const data = await resp.json(); + + if (!data.artists || data.artists.length === 0) { + if (data.stale) { + // First load — show section with loading state, poll until ready + section.style.display = ''; + if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...'; + carousel.innerHTML = ` +
+
+ Fetching and matching artists from your services... +
+ `; + _pollYourArtists(); + } else { + section.style.display = 'none'; + } + return; + } + + // Show section + section.style.display = ''; + + // Update subtitle with source info + const sources = new Set(); + data.artists.forEach(a => (a.source_services || []).forEach(s => sources.add(s))); + const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' }; + const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and '); + if (subtitle) subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`; + + if (data.stale) { + if (subtitle) subtitle.textContent += ' (updating...)'; + _pollYourArtists(); + } + + // Store for modal access and render carousel cards + window._yaArtists = {}; + window._yaActiveSource = data.active_source || 'spotify'; + data.artists.forEach(a => { window._yaArtists[a.id] = a; }); + carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join(''); + + } catch (err) { + console.error('Error loading Your Artists:', err); + } +} + +function _pollYourArtists() { + // Poll every 5s until artists appear, then stop + if (window._yaPoller) clearInterval(window._yaPoller); + let attempts = 0; + window._yaPoller = setInterval(async () => { + attempts++; + if (attempts > 60) { clearInterval(window._yaPoller); window._yaPoller = null; return; } + try { + const resp = await fetch('/api/discover/your-artists'); + if (!resp.ok) return; + const data = await resp.json(); + if (data.artists && data.artists.length > 0) { + clearInterval(window._yaPoller); + window._yaPoller = null; + loadYourArtists(); // Re-render with real data + } + } catch (e) { } + }, 5000); +} + +function _renderYourArtistCard(artist) { + const _esc = (s) => escapeHtml(s || ''); + const img = artist.image_url || ''; + + // Build metadata source badges (same pattern as library page) + const badges = []; + if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify' }); + if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music' }); + if (artist.deezer_artist_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer' }); + if (artist.discogs_artist_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs' }); + const badgeHTML = badges.map(b => + `
${b.logo ? `` : `${b.fb}`}
` + ).join(''); + + // Origin dots (which services the artist came from) + const sources = artist.source_services || []; + const sourceColors = { spotify: '#1DB954', lastfm: '#D51007', tidal: '#00FFFF', deezer: '#A238FF' }; + const originDots = sources.map(s => + `` + ).join(''); + + const watchlistClass = artist.on_watchlist ? 'active' : ''; + const hasId = artist.active_source_id && artist.active_source_id !== ''; + + // Navigate to Artists page (name click) — source artist id, needs inline view + const navAction = hasId + ? `event.stopPropagation(); navigateToArtistDetail('${escapeForInlineJs(artist.active_source_id)}', '${escapeForInlineJs(artist.artist_name)}')` + : ''; + + // Open info modal (card body click) — pass pool ID so we can look up all data + const infoAction = hasId + ? `openYourArtistInfoModal(${artist.id})` + : ''; + + // Deezer fallback for images + const deezerFb = artist.deezer_artist_id ? `onerror="if(!this.dataset.tried){this.dataset.tried='1';this.src='https://api.deezer.com/artist/${artist.deezer_artist_id}/image?size=big'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}"` : `onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"`; + + return ` +
+
+ ${img ? `` : ''} +
+
+
+
${badgeHTML}
+ +
+
+
${originDots}
+
+
${_esc(artist.artist_name)}
+
+
+ `; +} + +async function openYourArtistInfoModal(poolId) { + const pool = (window._yaArtists || {})[poolId]; + if (!pool) return; + + const artistId = pool.active_source_id; + const artistName = pool.artist_name; + const imageUrl = pool.image_url || ''; + + const existing = document.getElementById('ya-info-modal-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'ya-info-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.style.zIndex = '10001'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + // Build matched source badges from pool data + const _mb = (logo, fb, title) => `
${logo ? `` : `${fb}`}
`; + const matchBadges = []; + if (pool.spotify_artist_id) matchBadges.push(_mb(SPOTIFY_LOGO_URL, 'SP', 'Matched on Spotify')); + if (pool.itunes_artist_id) matchBadges.push(_mb(ITUNES_LOGO_URL, 'IT', 'Matched on Apple Music')); + if (pool.deezer_artist_id) matchBadges.push(_mb(DEEZER_LOGO_URL, 'Dz', 'Matched on Deezer')); + if (pool.discogs_artist_id) matchBadges.push(_mb(DISCOGS_LOGO_URL, 'DC', 'Matched on Discogs')); + + // Origin info + const sources = pool.source_services || []; + const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' }; + const originText = sources.map(s => sourceNames[s] || s).join(', '); + + overlay.innerHTML = ` +
+ +
+
+
+
+ ${imageUrl ? `` : '
'} +
+
+

${escapeHtml(artistName)}

+
${matchBadges.join('')}
+ ${originText ? `
Followed on ${escapeHtml(originText)}
` : ''} +
+
+
+
+
Loading artist info...
+
+ +
+ `; + document.body.appendChild(overlay); + + // Fetch enrichment data (with timeout) + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8000); + const lookupId = artistId || encodeURIComponent(artistName); + const resp = await fetch(`/api/discover/your-artists/info/${lookupId}?name=${encodeURIComponent(artistName)}`, { signal: controller.signal }); + clearTimeout(timeout); + const artist = resp.ok ? await resp.json() : {}; + const bodyEl = document.getElementById('ya-info-body'); + const footerEl = document.getElementById('ya-info-footer'); + + const genres = artist.genres || []; + const bio = artist.summary || ''; + const listeners = artist.lastfm_listeners || artist.followers || 0; + const playcount = artist.lastfm_playcount || 0; + const popularity = artist.popularity || 0; + + let bodyHTML = ''; + + // Stats + if (listeners || playcount || popularity) { + bodyHTML += `
+ ${listeners ? `
${Number(listeners).toLocaleString()}listeners
` : ''} + ${playcount ? `
${Number(playcount).toLocaleString()}plays
` : ''} + ${popularity ? `
${popularity}popularity
` : ''} +
`; + } + + // Genres + if (genres.length > 0) { + bodyHTML += `
+
${genres.map(g => `${escapeHtml(g)}`).join('')}
+
`; + } + + // Bio + if (bio) { + const cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); + if (cleanBio) { + bodyHTML += `
+
About
+
${escapeHtml(cleanBio.length > 600 ? cleanBio.substring(0, 600) + '...' : cleanBio)}
+
`; + } + } + + // Related artists from map connections + const related = pool._related || []; + if (related.length > 0) { + const relLabel = pool.on_watchlist ? 'Similar Artists' : 'Connected To'; + bodyHTML += `
+
${relLabel}
+ +
`; + } + + if (!bodyHTML) bodyHTML = '
No additional info available
'; + if (bodyEl) bodyEl.innerHTML = bodyHTML; + + // Footer + if (footerEl) { + const watchBtn = pool.on_watchlist + ? `` + : ``; + footerEl.innerHTML = ` + ${watchBtn} + + + `; + } + } catch (err) { + console.error('[Artist Info] Error loading artist info:', err); + const bodyEl = document.getElementById('ya-info-body'); + if (bodyEl) bodyEl.innerHTML = `
Could not load artist info
`; + } +} + +async function toggleYourArtistWatchlist(poolId, artistName, sourceId, source, btnEl) { + const isWatched = btnEl && btnEl.classList.contains('active'); + try { + if (isWatched) { + const resp = await fetch('/api/watchlist/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: sourceId }) + }); + if (resp.ok) { + if (btnEl) { + btnEl.classList.remove('active'); + const svg = btnEl.querySelector('svg'); + if (svg) svg.setAttribute('fill', 'none'); + } + showToast(`Removed ${artistName} from watchlist`, 'info'); + // Sync card eye icon + _syncYaCardWatchlist(poolId, false); + } + } else { + const resp = await fetch('/api/watchlist/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: sourceId, artist_name: artistName, source: source }) + }); + if (resp.ok) { + if (btnEl) { + btnEl.classList.add('active'); + const svg = btnEl.querySelector('svg'); + if (svg) svg.setAttribute('fill', 'currentColor'); + } + showToast(`Added ${artistName} to watchlist`, 'success'); + _syncYaCardWatchlist(poolId, true); + } + } + } catch (err) { + showToast('Failed to update watchlist', 'error'); + } +} + +function _syncYaCardWatchlist(poolId, watched) { + // Sync the card's eye icon with watchlist state (covers modal → card sync) + document.querySelectorAll('.ya-card .ya-watchlist-btn').forEach(btn => { + const card = btn.closest('.ya-card'); + if (!card) return; + // Match by onclick containing the poolId + const onclick = btn.getAttribute('onclick') || ''; + if (onclick.includes(`(${poolId},`)) { + if (watched) { + btn.classList.add('active'); + const svg = btn.querySelector('svg'); + if (svg) svg.setAttribute('fill', 'currentColor'); + } else { + btn.classList.remove('active'); + const svg = btn.querySelector('svg'); + if (svg) svg.setAttribute('fill', 'none'); + } + } + }); + // Update pool data + if (window._yaArtists && window._yaArtists[poolId]) { + window._yaArtists[poolId].on_watchlist = watched ? 1 : 0; + } +} + +async function refreshYourArtists() { + const btn = document.getElementById('your-artists-refresh-btn'); + if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; } + const subtitle = document.getElementById('your-artists-subtitle'); + if (subtitle) subtitle.textContent = 'Refreshing from your services...'; + + try { + await fetch('/api/discover/your-artists/refresh?clear=true', { method: 'POST' }); + // Poll until done + let attempts = 0; + const poll = setInterval(async () => { + attempts++; + if (attempts > 60) { clearInterval(poll); return; } // 5 min max + try { + const resp = await fetch('/api/discover/your-artists'); + const data = await resp.json(); + if (!data.stale && data.artists && data.artists.length > 0) { + clearInterval(poll); + loadYourArtists(); + if (btn) { btn.disabled = false; btn.style.opacity = ''; } + showToast(`Found ${data.total} artists from your services`, 'success'); + } + } catch (e) { } + }, 5000); + } catch (err) { + showToast('Failed to start refresh', 'error'); + if (btn) { btn.disabled = false; btn.style.opacity = ''; } + } +} + +async function openYourArtistsSourcesModal() { + const existing = document.getElementById('ya-sources-modal-overlay'); + if (existing) existing.remove(); + + // Fetch current config + connected services + let enabled = ['spotify', 'tidal', 'lastfm', 'deezer']; + let connected = []; + try { + const resp = await fetch('/api/discover/your-artists/sources'); + if (resp.ok) { + const data = await resp.json(); + if (data.enabled) enabled = data.enabled; + if (data.connected) connected = data.connected; + } + } catch (e) { } + + const sourceInfo = [ + { id: 'spotify', label: 'Spotify', icon: '🎵' }, + { id: 'tidal', label: 'Tidal', icon: '🌊' }, + { id: 'lastfm', label: 'Last.fm', icon: '📻' }, + { id: 'deezer', label: 'Deezer', icon: '🎶' }, + ]; + + const state = {}; + sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); }); + + const overlay = document.createElement('div'); + overlay.id = 'ya-sources-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const rows = sourceInfo.map(s => { + const isConnected = connected.includes(s.id); + const isOn = state[s.id]; + return ` +
+
+ ${s.icon} +
+
${s.label}
+
${isConnected ? 'Connected' : 'Not connected'}
+
+
+ +
`; + }).join(''); + + overlay.innerHTML = ` +
+

Your Artists Sources

+

Choose which connected services contribute artists to this section.

+
${rows}
+ +
+ `; + document.body.appendChild(overlay); + window._yaSourcesState = state; +} + +function _yaSourceRowClick(id) { + // Don't allow toggling disconnected services + const row = document.querySelector(`.ya-source-row[data-source="${id}"]`); + if (row && row.classList.contains('disconnected')) return; + _yaSourceToggle(id); +} + +function _yaSourceToggle(id) { + // Don't allow toggling disconnected services + const row = document.querySelector(`.ya-source-row[data-source="${id}"]`); + if (row && row.classList.contains('disconnected')) return; + window._yaSourcesState[id] = !window._yaSourcesState[id]; + const btn = document.getElementById(`ya-toggle-${id}`); + if (btn) btn.classList.toggle('on', window._yaSourcesState[id]); +} + +async function _yaSourcesSave() { + const enabledArr = Object.entries(window._yaSourcesState) + .filter(([, v]) => v).map(([k]) => k); + if (enabledArr.length === 0) { + showToast('Select at least one source', 'error'); + return; + } + const enabled = enabledArr.join(','); + try { + const resp = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ discover: { your_artists_sources: enabled } }) + }); + if (resp.ok) { + document.getElementById('ya-sources-modal-overlay')?.remove(); + showToast('Sources saved — refresh to apply', 'success'); + // Update subtitle immediately + const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', lastfm: 'Last.fm', deezer: 'Deezer' }; + const subtitle = document.getElementById('your-artists-subtitle'); + if (subtitle) { + const names = enabledArr.map(s => sourceNames[s] || s).join(' and '); + subtitle.textContent = `Artists you follow on ${names}`; + } + } else { + showToast('Failed to save sources', 'error'); + } + } catch (e) { + showToast('Failed to save sources', 'error'); + } +} + +async function openYourArtistsModal() { + const existing = document.getElementById('your-artists-modal-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'your-artists-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
+
+
+

Your Artists

+

Loading...

+
+ +
+
+ +
+ + + + + +
+ +
+
+
Loading...
+
+ +
+ `; + document.body.appendChild(overlay); + + // Search debounce + let searchTimer = null; + overlay.querySelector('#ya-modal-search').addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => _yaLoadModal(), 300); + }); + + window._yaModalState = { page: 1, source: '', sort: 'name' }; + _yaLoadModal(); +} + +function _yaFilterSource(source) { + window._yaModalState.source = source; + window._yaModalState.page = 1; + document.querySelectorAll('.ya-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.source === source)); + _yaLoadModal(); +} + +async function _yaLoadModal() { + const body = document.getElementById('ya-modal-body'); + const footer = document.getElementById('ya-modal-footer'); + const subtitle = document.getElementById('ya-modal-subtitle'); + if (!body) return; + + const state = window._yaModalState || { page: 1, source: '', sort: 'name' }; + const search = document.getElementById('ya-modal-search')?.value || ''; + const sort = document.getElementById('ya-modal-sort')?.value || 'name'; + state.sort = sort; + + const params = new URLSearchParams({ page: state.page, per_page: 60, sort: state.sort }); + if (state.source) params.set('source', state.source); + if (search) params.set('search', search); + + try { + const resp = await fetch(`/api/discover/your-artists/all?${params}`); + const data = await resp.json(); + + if (subtitle) subtitle.textContent = `${data.total} artists matched`; + + if (!data.artists || data.artists.length === 0) { + body.innerHTML = '
No artists found
'; + if (footer) footer.innerHTML = ''; + return; + } + + // Store for info modal access + if (!window._yaArtists) window._yaArtists = {}; + data.artists.forEach(a => { window._yaArtists[a.id] = a; }); + body.innerHTML = `
${data.artists.map(a => _renderYourArtistCard(a)).join('')}
`; + + // Pagination + const totalPages = Math.ceil(data.total / 60); + if (footer && totalPages > 1) { + footer.innerHTML = ` +
+ + Page ${state.page} of ${totalPages} + +
+ `; + } else if (footer) { + footer.innerHTML = ''; + } + } catch (err) { + body.innerHTML = '
Failed to load
'; + } +} + +function loadDiscoveryBlacklist() { } + +// ── Artist Map — Circle-packed staged canvas visualization ── +const _artMap = { + placed: [], + edges: [], + images: {}, + canvas: null, ctx: null, + offscreen: null, offCtx: null, // offscreen buffer for fast pan/zoom + width: 0, height: 0, + offsetX: 0, offsetY: 0, zoom: 0.15, + hoveredNode: null, animFrame: null, + dirty: true, // true = need to rebuild offscreen buffer + WATCHLIST_R: 320, + BUFFER: 8, +}; + +async function openArtistMap() { + const container = document.getElementById('artist-map-container'); + if (!container) return; + + // Hide discover sections, show map + document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { + el._prevDisplay = el.style.display; + el.style.display = 'none'; + }); + container.style.display = 'flex'; + + const canvas = document.getElementById('artist-map-canvas'); + _artMap.canvas = canvas; + _artMap.ctx = canvas.getContext('2d'); + _artMap.width = container.clientWidth; + _artMap.height = container.clientHeight - 50; + canvas.width = _artMap.width * window.devicePixelRatio; + canvas.height = _artMap.height * window.devicePixelRatio; + canvas.style.width = _artMap.width + 'px'; + canvas.style.height = _artMap.height + 'px'; + _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + _artMap.offsetX = _artMap.width / 2; + _artMap.offsetY = _artMap.height / 2; + _artMap.placed = []; + _artMap.images = {}; + _artMap._nodeById = null; + + // Loading screen + _artMap.ctx.fillStyle = '#0a0a14'; + _artMap.ctx.fillRect(0, 0, _artMap.width, _artMap.height); + _artMap.ctx.fillStyle = 'rgba(255,255,255,0.3)'; + _artMap.ctx.font = '14px system-ui'; + _artMap.ctx.textAlign = 'center'; + _artMap.ctx.fillText('Building artist map...', _artMap.width / 2, _artMap.height / 2); + + try { + const resp = await fetch('/api/discover/artist-map'); + const data = await resp.json(); + if (!data.success || !data.nodes.length) { + _artMap.ctx.fillText('No watchlist artists. Add artists to your watchlist first.', _artMap.width / 2, _artMap.height / 2 + 30); + return; + } + + document.getElementById('artist-map-stats').textContent = + `${data.watchlist_count} watchlist · ${data.similar_count} similar`; + + _artMap.edges = data.edges; + const WR = _artMap.WATCHLIST_R; + const BUF = _artMap.BUFFER; + + // ── PHASE 1: Place watchlist artists with guaranteed no-overlap ── + const wNodes = data.nodes.filter(n => n.type === 'watchlist'); + // Minimum center-to-center distance between watchlist nodes + const minCenterDist = WR * 3.5; // WR*2 for radii + WR*1.5 gap — similar artists fill the gaps via spiral packing + + // Place watchlist nodes in a spiral — deterministic, guaranteed spacing + wNodes.forEach((n, i) => { + n.radius = WR; + n.opacity = 0; + if (i === 0) { + n.x = 0; n.y = 0; + } else { + // Golden angle spiral for even distribution + const angle = i * 2.399963; // golden angle in radians + const r = minCenterDist * Math.sqrt(i) * 0.7; + n.x = Math.cos(angle) * r; + n.y = Math.sin(angle) * r; + } + }); + + // Post-process: push apart any watchlist nodes that ended up too close + for (let pass = 0; pass < 50; pass++) { + let moved = false; + for (let i = 0; i < wNodes.length; i++) { + for (let j = i + 1; j < wNodes.length; j++) { + const dx = wNodes[j].x - wNodes[i].x; + const dy = wNodes[j].y - wNodes[i].y; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + if (dist < minCenterDist) { + const push = (minCenterDist - dist) / 2 + 1; + const nx = (dx / dist) * push; + const ny = (dy / dist) * push; + wNodes[i].x -= nx; wNodes[i].y -= ny; + wNodes[j].x += nx; wNodes[j].y += ny; + moved = true; + } + } + } + if (!moved) break; + } + + wNodes.forEach(n => { _artMap.placed.push(n); }); + + // ── PHASE 2: Place similar artists around their source watchlist nodes ── + const sNodes = data.nodes.filter(n => n.type === 'similar'); + sNodes.forEach(n => { + const occ = n.occurrence || 1; + const rank = n.rank || 5; + // Bigger overall: min 25% of WR, max 55%, scaled by relevance + n.radius = Math.min(WR * 0.55, Math.max(WR * 0.25, WR * 0.2 + occ * WR * 0.06 + (10 - rank) * WR * 0.025)); + }); + sNodes.sort((a, b) => b.radius - a.radius); + + // Build edge lookup: target_id → source node (O(1) instead of .find()) + const edgeMap = {}; + _artMap.edges.forEach(e => { edgeMap[e.target] = e.source; }); + const nodeById = {}; + _artMap.placed.forEach(n => { nodeById[n.id] = n; }); + + // Spatial grid for fast collision detection + // Cell size must cover the largest possible bubble diameter + buffer + const CELL = WR * 2 + BUF * 2; + const grid = {}; + function _gridKey(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; } + function _gridAdd(n) { + const k = _gridKey(n.x, n.y); + if (!grid[k]) grid[k] = []; + grid[k].push(n); + } + function _gridCheck(x, y, r) { + const cx = Math.floor(x / CELL); + const cy = Math.floor(y / CELL); + // Search wider radius to catch large watchlist bubbles + for (let dx = -3; dx <= 3; dx++) { + for (let dy = -3; dy <= 3; dy++) { + const cell = grid[`${cx + dx},${cy + dy}`]; + if (!cell) continue; + for (const p of cell) { + const ddx = x - p.x, ddy = y - p.y; + const minD = r + p.radius + BUF; + if (ddx * ddx + ddy * ddy < minD * minD) return true; + } + } + } + return false; + } + // Add watchlist nodes to grid + _artMap.placed.forEach(n => _gridAdd(n)); + + // Place similar nodes with spatial grid collision + for (const sn of sNodes) { + const srcId = edgeMap[sn.id]; + const src = srcId != null ? nodeById[srcId] : null; + const cx = src ? src.x : 0; + const cy = src ? src.y : 0; + const startDist = (src ? src.radius : WR) + sn.radius + BUF; + + let placed = false; + for (let dist = startDist; dist < startDist + WR * 3; dist += sn.radius * 0.5) { + const steps = Math.max(8, Math.floor(dist * 0.1)); + const off = Math.random() * Math.PI * 2; + for (let a = 0; a < steps; a++) { + const angle = off + (a / steps) * Math.PI * 2; + const tx = cx + Math.cos(angle) * dist; + const ty = cy + Math.sin(angle) * dist; + if (!_gridCheck(tx, ty, sn.radius)) { + sn.x = tx; sn.y = ty; sn.opacity = 0; + _artMap.placed.push(sn); + nodeById[sn.id] = sn; + _gridAdd(sn); + placed = true; + break; + } + } + if (placed) break; + } + } + + // Auto-zoom to fit all nodes + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + _artMap.placed.forEach(n => { + minX = Math.min(minX, n.x - n.radius); + maxX = Math.max(maxX, n.x + n.radius); + minY = Math.min(minY, n.y - n.radius); + maxY = Math.max(maxY, n.y + n.radius); + }); + const mapW = maxX - minX + 200; + const mapH = maxY - minY + 200; + _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); + _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; + _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; + + // Setup interaction + _artMapSetupInteraction(canvas); + + // ── PHASE 3: Set all visible, build buffer, render ── + // Show loading overlay while buffer builds + const loadingEl = document.createElement('div'); + loadingEl.id = 'artist-map-loading'; + loadingEl.innerHTML = ` +
+
+
Placing ${_artMap.placed.length} artists on the map...
+
+ `; + container.appendChild(loadingEl); + + // Defer heavy work so loading overlay renders first + setTimeout(async () => { + _artMap.placed.forEach(n => { n.opacity = 1; }); + + // Load images in parallel using createImageBitmap (non-blocking) + const loadingText = container.querySelector('.artist-map-loading-text'); + const imgNodes = _artMap.placed.filter(n => n.image_url); + let loaded = 0; + + if (loadingText) loadingText.textContent = `Loading ${imgNodes.length} artist images...`; + + // Batch image loading — 20 concurrent fetches + const CONCURRENT = 20; + let idx = 0; + + async function loadNextBatch() { + const batch = []; + while (idx < imgNodes.length && batch.length < CONCURRENT) { + const n = imgNodes[idx++]; + if (_artMap.images[n.id]) { loaded++; continue; } + batch.push( + _artMapLoadImage(n.image_url) + .then(bmp => { if (bmp) _artMap.images[n.id] = bmp; }) + .finally(() => { + loaded++; + if (loadingText && loaded % 50 === 0) { + loadingText.textContent = `Loading images... ${loaded}/${imgNodes.length}`; + } + }) + ); + } + if (batch.length) await Promise.all(batch); + if (idx < imgNodes.length) return loadNextBatch(); + } + + await loadNextBatch(); + + // Build buffer and render + if (loadingText) loadingText.textContent = 'Rendering map...'; + await new Promise(r => setTimeout(r, 20)); // let text update render + + _artMap.dirty = true; + _artMapRender(); + + const le = document.getElementById('artist-map-loading'); + if (le) le.remove(); + }, 50); + + } catch (err) { + console.error('Artist map error:', err); + } +} + +function artMapZoom(factor) { + const cx = _artMap.width / 2; + const cy = _artMap.height / 2; + const targetZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor)); + const targetOX = cx - (cx - _artMap.offsetX) * (targetZoom / _artMap.zoom); + const targetOY = cy - (cy - _artMap.offsetY) * (targetZoom / _artMap.zoom); + _artMapAnimateTo(targetZoom, targetOX, targetOY); +} + +function artMapFitToView() { + if (!_artMap.placed.length) return; + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + _artMap.placed.forEach(n => { + if ((n.opacity || 0) < 0.01) return; + minX = Math.min(minX, n.x - n.radius); + maxX = Math.max(maxX, n.x + n.radius); + minY = Math.min(minY, n.y - n.radius); + maxY = Math.max(maxY, n.y + n.radius); + }); + const mapW = maxX - minX + 100; + const mapH = maxY - minY + 100; + const targetZoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); + const targetOX = _artMap.width / 2 - ((minX + maxX) / 2) * targetZoom; + const targetOY = _artMap.height / 2 - ((minY + maxY) / 2) * targetZoom; + _artMapAnimateTo(targetZoom, targetOX, targetOY); +} + +function _artMapAnimateTo(targetZoom, targetOX, targetOY) { + if (_artMap._animating) cancelAnimationFrame(_artMap._animating); + const startZoom = _artMap.zoom; + const startOX = _artMap.offsetX; + const startOY = _artMap.offsetY; + const duration = 250; + const start = performance.now(); + + function step(now) { + const t = Math.min(1, (now - start) / duration); + // Ease out cubic + const e = 1 - Math.pow(1 - t, 3); + _artMap.zoom = startZoom + (targetZoom - startZoom) * e; + _artMap.offsetX = startOX + (targetOX - startOX) * e; + _artMap.offsetY = startOY + (targetOY - startOY) * e; + _artMapRender(); // blit only, no rebuild + if (t < 1) { + _artMap._animating = requestAnimationFrame(step); + } else { + _artMap._animating = null; + _artMap.dirty = true; + _artMapRender(); // rebuild at final zoom level + } + } + _artMap._animating = requestAnimationFrame(step); +} + +function closeArtistMap() { + const container = document.getElementById('artist-map-container'); + if (container) container.style.display = 'none'; + const sidebar = document.getElementById('artmap-genre-sidebar'); + if (sidebar) sidebar.style.display = 'none'; + if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame); + if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler); + _artMapHideContextMenu(); + + // Restore discover sections + document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { + el.style.display = el._prevDisplay !== undefined ? el._prevDisplay : ''; + }); +} + +// No force simulation — layout is pre-computed via circle packing + +function _artMapRebuildBuffer() { + /**Render ALL nodes once to offscreen canvas. Only called on data changes, not pan/zoom.**/ + const placed = _artMap.placed; + if (!placed.length) return; + + const visible = placed.filter(n => (n.opacity || 0) > 0.01); + if (!visible.length) return; + + // World bounds + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + visible.forEach(n => { + minX = Math.min(minX, n.x - n.radius - 10); + maxX = Math.max(maxX, n.x + n.radius + 10); + minY = Math.min(minY, n.y - n.radius - 10); + maxY = Math.max(maxY, n.y + n.radius + 10); + }); + + const bw = maxX - minX; + const bh = maxY - minY; + // Scale based on zoom — higher zoom = higher res buffer, capped for memory + const z = _artMap.zoom || 0.1; + const scale = Math.min(z * 2, 1.0, 10240 / Math.max(bw, bh)); + + if (!_artMap.offscreen) _artMap.offscreen = document.createElement('canvas'); + const oc = _artMap.offscreen; + oc.width = Math.ceil(bw * scale); + oc.height = Math.ceil(bh * scale); + const octx = oc.getContext('2d'); + _artMap._bufferScale = scale; + _artMap._bufferMinX = minX; + _artMap._bufferMinY = minY; + + octx.scale(scale, scale); + octx.translate(-minX, -minY); + + // Build node lookup + if (!_artMap._nodeById) { + _artMap._nodeById = {}; + placed.forEach(n => { _artMap._nodeById[n.id] = n; }); + } + + // Draw edges (connection lines between related nodes) + if (_artMap.edges && _artMap.edges.length > 0) { + octx.lineWidth = 1; + octx.strokeStyle = 'rgba(138,43,226,0.08)'; + octx.beginPath(); + for (const edge of _artMap.edges) { + const s = _artMap._nodeById[edge.source]; + const t = _artMap._nodeById[edge.target]; + if (!s || !t || (s.opacity || 0) < 0.05 || (t.opacity || 0) < 0.05) continue; + octx.moveTo(s.x, s.y); + octx.lineTo(t.x, t.y); + } + octx.stroke(); + } + + // Draw ALL nodes — genre labels first, similar next, watchlist on top + const hideSimilar = _artMap._hideSimilar || false; + // Pass 0: genre labels, Pass 1: similar/ring2, Pass 2: watchlist/center/ring1 + for (let pass = 0; pass < 3; pass++) { + for (const n of visible) { + if (pass === 0 && n._isLabel) { /* draw */ } + else if (pass === 1 && !n._isLabel && n.type !== 'watchlist' && n.type !== 'center' && n.ring !== 1) { /* draw */ } + else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ } + else continue; + if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue; + const op = n.opacity || 0; + if (op < 0.01) continue; + const r = n.radius; + const isW = n.type === 'watchlist' || n.type === 'center'; + octx.globalAlpha = op; + + // Genre label node — transparent circle with large text + if (n._isLabel) { + octx.globalAlpha = 0.6; + octx.beginPath(); + octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); + octx.fillStyle = 'rgba(138,43,226,0.04)'; + octx.fill(); + octx.strokeStyle = 'rgba(138,43,226,0.08)'; + octx.lineWidth = 1; + octx.stroke(); + const labelSize = Math.max(12, n.radius * 0.25); + octx.font = `800 ${labelSize}px system-ui`; + octx.textAlign = 'center'; + octx.textBaseline = 'middle'; + octx.fillStyle = 'rgba(138,43,226,0.35)'; + octx.fillText(n.name, n.x, n.y - labelSize * 0.3); + octx.font = `500 ${labelSize * 0.5}px system-ui`; + octx.fillStyle = 'rgba(255,255,255,0.15)'; + octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5); + octx.globalAlpha = 1; + continue; + } + + // Render quality based on node size in buffer pixels + const rScaled = r * scale; + const isSmall = rScaled < 8; + const isTiny = rScaled < 3; + + // Tiny nodes: just a colored dot (no clip, no image, no text) + if (isTiny) { + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.fillStyle = isW ? '#6b21a8' : '#2a2a40'; + octx.fill(); + continue; + } + + // Small nodes: filled circle + border, no image clip + if (isSmall) { + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + const img = _artMap.images[n.id]; + if (img) { + octx.save(); octx.clip(); + octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); + octx.restore(); + } else { + octx.fillStyle = isW ? '#1a0a30' : '#141420'; + octx.fill(); + } + octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)'; + octx.lineWidth = isW ? 1.5 : 0.5; + octx.stroke(); + continue; + } + + // Full quality: glow + clip + image + text + if (isW) { + octx.beginPath(); + octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); + octx.strokeStyle = 'rgba(138,43,226,0.25)'; + octx.lineWidth = 5; + octx.stroke(); + } + + octx.save(); + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.closePath(); + octx.clip(); + + const img = _artMap.images[n.id]; + if (img) { + octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); + octx.fillStyle = 'rgba(0,0,0,0.45)'; + octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); + } else { + octx.fillStyle = isW ? '#1a0a30' : '#141420'; + octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); + } + octx.restore(); + + octx.beginPath(); + octx.arc(n.x, n.y, r, 0, Math.PI * 2); + octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)'; + octx.lineWidth = isW ? 2 : 0.5; + octx.stroke(); + + const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3); + octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; + octx.textAlign = 'center'; + octx.textBaseline = 'middle'; + octx.fillStyle = '#fff'; + const maxC = isW ? 20 : 12; + const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name; + octx.fillText(label, n.x, n.y); + } + } + + octx.globalAlpha = 1; + + _artMap.dirty = false; +} + +function _artMapRender() { + /**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/ + const ctx = _artMap.ctx; + const w = _artMap.width; + const h = _artMap.height; + + ctx.fillStyle = '#0a0a14'; + ctx.fillRect(0, 0, w, h); + + if (_artMap.dirty || !_artMap.offscreen) _artMapRebuildBuffer(); + if (!_artMap.offscreen) return; + + const oc = _artMap.offscreen; + const s = _artMap._bufferScale; + const mx = _artMap._bufferMinX; + const my = _artMap._bufferMinY; + const z = _artMap.zoom; + + // Blit offscreen buffer: the buffer was drawn with scale(s) + translate(-minX,-minY) + // So buffer pixel (bx,by) corresponds to world (bx/s + minX, by/s + minY) + // Screen position of world (wx,wy) = offsetX + wx*zoom, offsetY + wy*zoom + // Therefore buffer origin on screen = offsetX + minX*zoom, offsetY + minY*zoom + // And buffer is drawn at size (bufferWidth * zoom/s, bufferHeight * zoom/s) + ctx.drawImage(oc, + _artMap.offsetX + mx * z, + _artMap.offsetY + my * z, + oc.width * z / s, + oc.height * z / s + ); + + // ── Interactive overlay (drawn on main canvas, not buffer) ── + const cFade = _artMap._constellationFade || 0; + if (cFade > 0 && (_artMap.hoveredNode || _artMap._constellationCache)) { + const n = _artMap.hoveredNode || (_artMap._constellationCache ? (_artMap._nodeById || {})[_artMap._constellationCache.nodeId] : null); + if (!n) { _artMap._constellationFade = 0; _artMap._constellationCache = null; } + if (n) { + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); + + // Cache connected node lookup (don't recompute every frame) + if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) { + const connectedIds = new Set(); + if (n.type === 'watchlist') { + for (const e of _artMap.edges) { + if (e.source === n.id) connectedIds.add(e.target); + } + } else { + const sourceIds = new Set(); + for (const e of _artMap.edges) { + if (e.target === n.id) sourceIds.add(e.source); + } + for (const sid of sourceIds) { + connectedIds.add(sid); + for (const e of _artMap.edges) { + if (e.source === sid) connectedIds.add(e.target); + } + } + } + const nById = _artMap._nodeById || {}; + _artMap._constellationCache = { + nodeId: n.id, + nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)], + }; + } + + const highlightNodes = _artMap._constellationCache.nodes; + + if (highlightNodes.length > 1) { + // Semi-transparent dark overlay on entire visible area + ctx.save(); + ctx.resetTransform(); + ctx.globalAlpha = 0.6 * cFade; + ctx.fillStyle = '#0a0a14'; + ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height); + ctx.globalAlpha = 1; + ctx.restore(); + + // Draw glowing connection lines + for (const cn of highlightNodes) { + if (cn === n) continue; + ctx.beginPath(); + ctx.moveTo(n.x, n.y); + ctx.lineTo(cn.x, cn.y); + // Gradient line + const lineGrad = ctx.createLinearGradient(n.x, n.y, cn.x, cn.y); + lineGrad.addColorStop(0, `rgba(138,43,226,${0.5 * cFade})`); + lineGrad.addColorStop(1, `rgba(138,43,226,${0.15 * cFade})`); + ctx.strokeStyle = lineGrad; + ctx.lineWidth = 2; + ctx.stroke(); + } + + // Redraw highlighted nodes on top + ctx.globalAlpha = cFade; + for (const hn of highlightNodes) { + const r = hn.radius; + const isW = hn.type === 'watchlist'; + const isHov = hn === n; + + // Glow + if (isHov) { + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(138,43,226,0.4)'; + ctx.lineWidth = 6; + ctx.stroke(); + } + + // Circle + image + ctx.save(); + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + const img = _artMap.images[hn.id]; + if (img) { + ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + } else { + ctx.fillStyle = isW ? '#1a0a30' : '#141420'; + ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + } + ctx.restore(); + + // Border + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)'; + ctx.lineWidth = isHov ? 3 : 1.5; + ctx.stroke(); + + // Name + const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3); + ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#fff'; + const maxC = isW ? 20 : 12; + const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + '…' : hn.name; + ctx.fillText(label, hn.x, hn.y); + } + ctx.globalAlpha = 1; + } else { + // Single node, no connections + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 3; + ctx.stroke(); + } + + ctx.restore(); + } // end if(n) + } else if (_artMap.hoveredNode && !_artMap._constellationActive) { + // Pre-constellation: just show a simple highlight ring (instant, no delay) + const n = _artMap.hoveredNode; + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); + ctx.beginPath(); + ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.35)'; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + } + + // Click ripple animation + if (_artMap._ripple) { + const rip = _artMap._ripple; + const elapsed = performance.now() - rip.start; + const progress = elapsed / 400; // 400ms duration + if (progress < 1) { + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); + const ripR = rip.radius + rip.radius * progress * 0.5; + ctx.beginPath(); + ctx.arc(rip.x, rip.y, ripR, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(138,43,226,${0.5 * (1 - progress)})`; + ctx.lineWidth = 3 * (1 - progress); + ctx.stroke(); + ctx.restore(); + requestAnimationFrame(() => _artMapRender()); + } else { + _artMap._ripple = null; + } + } +} + +function artMapSearch(query) { + const results = document.getElementById('artist-map-search-results'); + if (!results) return; + if (!query || query.length < 2) { results.style.display = 'none'; return; } + + const q = query.toLowerCase(); + const matches = _artMap.placed.filter(n => (n.opacity || 0) > 0.5 && n.name.toLowerCase().includes(q)).slice(0, 8); + + if (!matches.length) { results.style.display = 'none'; return; } + + results.style.display = 'block'; + results.innerHTML = matches.map(n => + `
+ ${n.type === 'watchlist' ? '★' : '○'} + ${escapeHtml(n.name)} +
` + ).join(''); +} + +function artMapZoomToNode(nodeId) { + const n = _artMap.placed.find(p => p.id === nodeId); + if (!n) return; + // Zoom to show this node centered, at a comfortable zoom level + const targetZoom = Math.max(0.3, Math.min(1, 200 / n.radius)); + const targetOX = _artMap.width / 2 - n.x * targetZoom; + const targetOY = _artMap.height / 2 - n.y * targetZoom; + _artMapAnimateTo(targetZoom, targetOX, targetOY); + // Highlight briefly after animation + setTimeout(() => { _artMap.hoveredNode = n; _artMapRender(); }, 300); + setTimeout(() => { _artMap.hoveredNode = null; _artMapRender(); }, 2500); + // Close search + const results = document.getElementById('artist-map-search-results'); + if (results) results.style.display = 'none'; + const input = document.getElementById('artist-map-search'); + if (input) input.value = ''; +} + +function _artMapShowTooltip(e, node) { + const tip = document.getElementById('artist-map-tooltip'); + if (!tip) return; + if (!node) { tip.style.display = 'none'; return; } + + const img = node.image_url ? `` : '
'; + const genres = (node.genres || []).slice(0, 3); + const genreHTML = genres.length ? `
${genres.map(g => `${escapeHtml(g)}`).join('')}
` : ''; + const typeLabel = node.type === 'watchlist' ? '★ Watchlist' : ''; + + tip.innerHTML = ` +
+ ${img} +
+
${escapeHtml(node.name)}
+ ${typeLabel} + ${genreHTML} +
+
+ `; + tip.style.display = 'block'; + + // Position — keep on screen + const x = Math.min(e.clientX + 16, window.innerWidth - tip.offsetWidth - 10); + const y = Math.min(e.clientY - 10, window.innerHeight - tip.offsetHeight - 10); + tip.style.left = x + 'px'; + tip.style.top = y + 'px'; +} + +function _artMapAnimateConstellation() { + if (_artMap._constellationActive && _artMap._constellationFade < 1) { + _artMap._constellationFade = Math.min(1, (_artMap._constellationFade || 0) + 0.08); + _artMapRender(); + requestAnimationFrame(_artMapAnimateConstellation); + } else if (!_artMap._constellationActive && _artMap._constellationFade > 0) { + _artMap._constellationFade = Math.max(0, _artMap._constellationFade - 0.1); + _artMapRender(); + if (_artMap._constellationFade > 0) { + requestAnimationFrame(_artMapAnimateConstellation); + } else { + _artMap._constellationCache = null; + } + } +} + +function artMapShowShortcuts() { + const existing = document.getElementById('artmap-shortcuts-overlay'); + if (existing) { existing.remove(); return; } + + const overlay = document.createElement('div'); + overlay.id = 'artmap-shortcuts-overlay'; + overlay.className = 'modal-overlay'; + overlay.style.zIndex = '10002'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
+
+

Keyboard Shortcuts

+ +
+
+
EscClose map
+
+ / -Zoom in / out
+
FFit to view
+
SFocus search
+
HToggle similar artists
+
ScrollZoom at cursor
+
ClickArtist info
+
Right-clickContext menu
+
DragPan around
+
Hover 1sShow connections
+
+
+ `; + document.body.appendChild(overlay); +} + +async function openArtistMapGenre() { + // Show picker immediately — uses lightweight genre list endpoint + const genre = await _showGenrePickerModal(); + if (!genre) return; + _openGenreMapWithSelection(genre); +} + +async function _showGenrePickerModal() { + return new Promise(resolve => { + const existing = document.getElementById('artmap-genre-picker'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'artmap-genre-picker'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + + overlay.innerHTML = ` +
+
+ + + +
+

Select a Genre

+

Choose a genre to explore its artists

+
+
+ +
+
Loading genres...
+
+
+ `; + document.body.appendChild(overlay); + + // Use cached data or fetch + const renderGenreList = (data) => { + if (!data?.success || !data?.genres?.length) { + document.getElementById('artmap-genre-picker-list').innerHTML = '
No genres found
'; + return; + } + const list = document.getElementById('artmap-genre-picker-list'); + list.innerHTML = data.genres.map(g => ` +
+
${escapeHtml(g.name)}
+
${g.count} artists
+
+ `).join(''); + }; + + if (window._artMapGenreList) { + renderGenreList(window._artMapGenreList); + } else { + fetch('/api/discover/artist-map/genre-list') + .then(r => r.json()) + .then(data => { window._artMapGenreList = data; renderGenreList(data); }) + .catch(() => { document.getElementById('artmap-genre-picker-list').innerHTML = '
Error loading genres
'; }); + } + + overlay._resolve = (genre) => { overlay.remove(); resolve(genre); }; + }); +} + +function _switchGenre(genre) { + _artMap._skipSectionToggle = true; + _openGenreMapWithSelection(genre); +} + +function _filterGenreSidebar(query) { + const q = query.toLowerCase(); + document.querySelectorAll('.artmap-genre-sidebar-item').forEach(el => { + el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none'; + }); +} + +async function _changeGenre() { + const genre = await _showGenrePickerModal(); + if (!genre) return; + _artMap._skipSectionToggle = true; + _openGenreMapWithSelection(genre); +} + +function _filterGenrePicker(query) { + const q = query.toLowerCase(); + document.querySelectorAll('.artmap-genre-picker-item').forEach(el => { + el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none'; + }); +} + +async function _openGenreMapWithSelection(selectedGenre) { + const container = document.getElementById('artist-map-container'); + if (!container) return; + + const skipToggle = _artMap._skipSectionToggle; + _artMap._skipSectionToggle = false; + + if (!skipToggle) { + document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { + el._prevDisplay = el.style.display; + el.style.display = 'none'; + }); + } + container.style.display = 'flex'; + + // Show + populate genre sidebar + const sidebar = document.getElementById('artmap-genre-sidebar'); + const genreListData = window._artMapGenreList || window._artMapGenreData; + if (sidebar && genreListData?.genres) { + sidebar.style.display = 'flex'; + const list = document.getElementById('artmap-genre-sidebar-list'); + if (list) { + list.innerHTML = genreListData.genres.map(g => ` +
+ ${escapeHtml(g.name)} + ${g.count} +
+ `).join(''); + } + } + + const canvas = document.getElementById('artist-map-canvas'); + const contentRow = canvas.parentElement; + _artMap.canvas = canvas; + _artMap.ctx = canvas.getContext('2d'); + _artMap.width = canvas.clientWidth || (container.clientWidth - (sidebar?.offsetWidth || 0)); + _artMap.height = contentRow?.clientHeight || (container.clientHeight - 50); + canvas.width = _artMap.width * window.devicePixelRatio; + canvas.height = _artMap.height * window.devicePixelRatio; + canvas.style.width = _artMap.width + 'px'; + canvas.style.height = _artMap.height + 'px'; + _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + _artMap.offsetX = _artMap.width / 2; + _artMap.offsetY = _artMap.height / 2; + _artMap.placed = []; + _artMap.edges = []; + _artMap.images = {}; + _artMap._nodeById = null; + _artMap.dirty = true; + + // Show loading + const loadingEl = document.createElement('div'); + loadingEl.id = 'artist-map-loading'; + loadingEl.innerHTML = `
Loading genre map...
`; + container.appendChild(loadingEl); + + // Update toolbar + document.querySelector('.artmap-brand-text').textContent = 'Genre Map'; + document.getElementById('artist-map-stats').textContent = 'Loading...'; + + try { + // Use cached data from picker or fetch fresh + const data = window._artMapGenreData || await fetch('/api/discover/artist-map/genres').then(r => r.json()); + const loadingText = document.getElementById('artmap-genre-loading-text'); + if (!data.success || !data.nodes.length) { + if (loadingText) loadingText.textContent = 'No artists with genre data found.'; + return; + } + + // Find the selected genre + closely related genres (high artist overlap) + const allGenres = data.genres; + const primary = allGenres.find(g => g.name === selectedGenre); + if (!primary) { + if (loadingText) loadingText.textContent = `Genre "${selectedGenre}" not found.`; + return; + } + const primarySet = new Set(primary.artist_ids); + + // Find up to 4 related genres by artist overlap + const related = allGenres + .filter(g => g.name !== selectedGenre) + .map(g => { + const overlap = g.artist_ids.filter(id => primarySet.has(id)).length; + return { ...g, overlap }; + }) + .filter(g => g.overlap > primarySet.size * 0.1) // At least 10% overlap + .sort((a, b) => b.overlap - a.overlap) + .slice(0, 4); + + const genres = [primary, ...related]; + const totalArtists = genres.reduce((sum, g) => sum + g.artist_ids.length, 0); + + document.getElementById('artist-map-stats').innerHTML = + `${escapeHtml(selectedGenre)} ▾ · ${genres.length} genre${genres.length > 1 ? 's' : ''} · ${totalArtists} artists`; + + const WR = _artMap.WATCHLIST_R; + const BUF = _artMap.BUFFER; + + const maxPerGenre = 500; + const nodeR = WR * 0.2; + + // Calculate actual cluster radius for each genre based on ring count + function getClusterRadius(artistCount) { + const count = Math.min(artistCount, maxPerGenre); + let ringDist = WR + nodeR * 2 + BUF; + let placed = 0; + while (placed < count) { + const circ = 2 * Math.PI * ringDist; + const inRing = Math.max(1, Math.floor(circ / (nodeR * 2 + BUF))); + placed += Math.min(inRing, count - placed); + ringDist += nodeR * 2 + BUF; + } + return ringDist; + } + + // Pre-compute cluster radii + genres.forEach(g => { g._clusterR = getClusterRadius(g.artist_ids.length); }); + + // Golden spiral placement + genres.forEach((g, i) => { + if (i === 0) { g._cx = 0; g._cy = 0; } + else { + const angle = i * 2.399963; + const r = g._clusterR * Math.sqrt(i) * 0.9; + g._cx = Math.cos(angle) * r; + g._cy = Math.sin(angle) * r; + } + }); + + // Push apart using actual cluster radii — no overlap possible + for (let pass = 0; pass < 80; pass++) { + let moved = false; + for (let i = 0; i < genres.length; i++) { + for (let j = i + 1; j < genres.length; j++) { + const dx = genres[j]._cx - genres[i]._cx; + const dy = genres[j]._cy - genres[i]._cy; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const minDist = genres[i]._clusterR + genres[j]._clusterR + BUF * 4; + if (dist < minDist) { + const push = (minDist - dist) / 2 + 1; + genres[i]._cx -= (dx / dist) * push; genres[i]._cy -= (dy / dist) * push; + genres[j]._cx += (dx / dist) * push; genres[j]._cy += (dy / dist) * push; + moved = true; + } + } + } + if (!moved) break; + } + + let placedCount = 0; + + // Place genre labels as big watchlist-style bubbles + for (const g of genres) { + _artMap.placed.push({ + id: `genre_${g.name}`, name: g.name.toUpperCase(), + x: g._cx, y: g._cy, radius: WR, opacity: 1, + type: 'genre_label', image_url: '', genres: [g.name], + _isLabel: true, _count: g.count + }); + } + + // Place artists in concentric rings — deterministic O(1) per node, handles 10K+ instantly + let genreIdx = 0; + + async function placeGenreArtists() { + for (; genreIdx < genres.length; genreIdx++) { + const genre = genres[genreIdx]; + const artists = genre.artist_ids.slice(0, maxPerGenre); + const sorted = artists.map(nid => data.nodes[nid]).filter(Boolean).sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); + + let ringDist = WR + nodeR * 2 + BUF; + let ringNum = 0; + let placed = 0; + + while (placed < sorted.length) { + const circumference = 2 * Math.PI * ringDist; + const nodesInRing = Math.max(1, Math.floor(circumference / (nodeR * 2 + BUF))); + const count = Math.min(nodesInRing, sorted.length - placed); + const angleStep = (2 * Math.PI) / nodesInRing; + const angleOffset = ringNum * 0.618; + + for (let i = 0; i < count; i++) { + const n = sorted[placed + i]; + if (!n) continue; + const isW = n.type === 'watchlist' || n.type === 'center'; + const r = isW ? nodeR * 1.5 : nodeR; + const angle = angleOffset + i * angleStep; + + _artMap.placed.push({ + id: placedCount + 1000, _origId: n.id, name: n.name, + x: genre._cx + Math.cos(angle) * ringDist, + y: genre._cy + Math.sin(angle) * ringDist, + radius: r, opacity: 1, + type: isW ? 'watchlist' : 'similar', + image_url: n.image_url || '', genres: n.genres || [], + spotify_id: n.spotify_id || '', itunes_id: n.itunes_id || '', + deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '', + }); + placedCount++; + } + placed += count; + ringDist += nodeR * 2 + BUF; + ringNum++; + } + + if (loadingText) loadingText.textContent = `Placing artists... ${genreIdx + 1}/${genres.length} genres (${placedCount} placed)`; + if (genreIdx % 5 === 0) await new Promise(r => setTimeout(r, 0)); + } + } + await placeGenreArtists(); + + // Build edges: connect artists that appear in multiple genre clusters + _artMap.edges = []; + const artistNodes = {}; + _artMap.placed.forEach(n => { + if (n._origId != null) { + if (!artistNodes[n._origId]) artistNodes[n._origId] = []; + artistNodes[n._origId].push(n.id); + } + }); + Object.values(artistNodes).forEach(ids => { + if (ids.length > 1) { + for (let i = 0; i < ids.length - 1; i++) { + _artMap.edges.push({ source: ids[i], target: ids[i + 1], weight: 5 }); + } + } + }); + + _artMap._nodeById = {}; + _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); + + // Auto-zoom + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + _artMap.placed.forEach(n => { + minX = Math.min(minX, n.x - n.radius); + maxX = Math.max(maxX, n.x + n.radius); + minY = Math.min(minY, n.y - n.radius); + maxY = Math.max(maxY, n.y + n.radius); + }); + const mapW = maxX - minX + 200, mapH = maxY - minY + 200; + _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); + _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; + _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; + + _artMapSetupInteraction(canvas); + + // Load images + render + if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`; + + setTimeout(async () => { + const imgNodes = _artMap.placed.filter(n => n.image_url && !n._isLabel); + let loaded = 0; + const CONCURRENT = 20; + let idx = 0; + async function loadBatch() { + const batch = []; + while (idx < imgNodes.length && batch.length < CONCURRENT) { + const n = imgNodes[idx++]; + batch.push(_artMapLoadImage(n.image_url) + .then(bmp => { if (bmp) _artMap.images[n.id] = bmp; }) + .finally(() => { loaded++; })); + } + if (batch.length) await Promise.all(batch); + if (idx < imgNodes.length) return loadBatch(); + } + await loadBatch(); + _artMap.dirty = true; + _artMapRender(); + const le = document.getElementById('artist-map-loading'); + if (le) le.remove(); + }, 50); + + _artMap.dirty = true; + _artMapRender(); + + } catch (err) { + console.error('Genre map error:', err); + const lt = container.querySelector('.artist-map-loading-text'); + if (lt) lt.textContent = 'Error loading genre map'; + } +} + +function openArtistMapExplorerDirect(name) { + if (!name) return; + // Already in map — just reload with new data, don't re-hide sections + _artMap._skipSectionToggle = true; + _openArtistMapExplorerWithName(name); +} + +async function openArtistMapExplorer() { + const name = await _showArtistMapSearchPrompt(); + if (!name) return; + _openArtistMapExplorerWithName(name); +} + +async function _openArtistMapExplorerWithName(name) { + + const container = document.getElementById('artist-map-container'); + if (!container) return; + + const skipToggle = _artMap._skipSectionToggle; + _artMap._skipSectionToggle = false; + + if (!skipToggle) { + document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { + el._prevDisplay = el.style.display; + el.style.display = 'none'; + }); + } + container.style.display = 'flex'; + + const canvas = document.getElementById('artist-map-canvas'); + _artMap.canvas = canvas; + _artMap.ctx = canvas.getContext('2d'); + _artMap.width = container.clientWidth; + _artMap.height = container.clientHeight - 50; + canvas.width = _artMap.width * window.devicePixelRatio; + canvas.height = _artMap.height * window.devicePixelRatio; + canvas.style.width = _artMap.width + 'px'; + canvas.style.height = _artMap.height + 'px'; + _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + _artMap.offsetX = _artMap.width / 2; + _artMap.offsetY = _artMap.height / 2; + _artMap.placed = []; + _artMap.edges = []; + _artMap.images = {}; + _artMap._nodeById = null; + _artMap.dirty = true; + + const loadingEl = document.createElement('div'); + loadingEl.id = 'artist-map-loading'; + loadingEl.innerHTML = `
Exploring ${escapeHtml(name)}...
`; + container.appendChild(loadingEl); + + document.querySelector('.artmap-brand-text').textContent = 'Artist Explorer'; + + try { + const resp = await fetch(`/api/discover/artist-map/explore?name=${encodeURIComponent(name.trim())}`); + const data = await resp.json(); + if (!data.success || !data.nodes.length) { + const lt = document.querySelector('.artist-map-loading-text'); + if (lt) { + lt.textContent = resp.status === 404 + ? `"${name}" doesn't appear to be a real artist. Try a different name.` + : `No data found for "${name}". Try a different artist.`; + } + setTimeout(() => { + const le = document.getElementById('artist-map-loading'); + if (le) le.remove(); + closeArtistMap(); + }, 2500); + return; + } + + const ring1Count = data.nodes.filter(n => n.ring === 1).length; + const ring2Count = data.nodes.filter(n => n.ring === 2).length; + document.getElementById('artist-map-stats').textContent = + `${data.center} · ${ring1Count} similar · ${ring2Count} extended`; + + _artMap.edges = data.edges; + const WR = _artMap.WATCHLIST_R; + const BUF = _artMap.BUFFER; + + // Layout: center node at origin, ring 1 in circle around it, ring 2 around ring 1 + const centerNode = data.nodes[0]; + centerNode.x = 0; centerNode.y = 0; + centerNode.radius = WR * 1.2; // Extra large center + centerNode.opacity = 1; + centerNode.type = 'center'; + _artMap.placed.push(centerNode); + + const CELL = WR * 2 + BUF * 2; + const grid = {}; + function _gk(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; } + function _ga(n) { const k = _gk(n.x, n.y); if (!grid[k]) grid[k] = []; grid[k].push(n); } + function _gc(x, y, r) { + const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL); + for (let dx = -3; dx <= 3; dx++) for (let dy = -3; dy <= 3; dy++) { + const cell = grid[`${cx + dx},${cy + dy}`]; + if (!cell) continue; + for (const p of cell) { + const ddx = x - p.x, ddy = y - p.y; + if (ddx * ddx + ddy * ddy < (r + p.radius + BUF) * (r + p.radius + BUF)) return true; + } + } + return false; + } + _ga(centerNode); + + // Place ring 1 in a circle + const ring1 = data.nodes.filter(n => n.ring === 1); + const ring1Dist = WR * 2.5; + ring1.forEach((n, i) => { + const angle = (i / ring1.length) * Math.PI * 2; + const rank = n.rank || 5; + n.radius = WR * 0.4 + (10 - rank) * WR * 0.03; + n.opacity = 1; + + // Find non-colliding position near ideal + let placed = false; + for (let dist = ring1Dist; dist < ring1Dist + WR * 3; dist += n.radius * 0.5) { + for (let ao = 0; ao < 6; ao++) { + const a = angle + (ao * 0.1 * (ao % 2 ? 1 : -1)); + const tx = Math.cos(a) * dist; + const ty = Math.sin(a) * dist; + if (!_gc(tx, ty, n.radius)) { + n.x = tx; n.y = ty; + _artMap.placed.push(n); + _ga(n); + placed = true; + break; + } + } + if (placed) break; + } + }); + + // Place ring 2 around their ring 1 sources + const ring2 = data.nodes.filter(n => n.ring === 2); + const nodeById = {}; + _artMap.placed.forEach(n => { nodeById[n.id] = n; }); + + ring2.forEach(n => { + // Find the ring 1 node that connects to this + const edge = data.edges.find(e => e.target === n.id); + const src = edge ? nodeById[edge.source] : null; + const cx = src ? src.x : 0; + const cy = src ? src.y : 0; + + n.radius = WR * 0.2 + (n.popularity || 0) / 100 * WR * 0.1; + n.opacity = 1; + + const startDist = (src ? src.radius : WR) + n.radius + BUF; + let placed = false; + for (let dist = startDist; dist < startDist + WR * 2; dist += n.radius * 0.5) { + const steps = Math.max(8, Math.floor(dist * 0.08)); + const off = Math.random() * Math.PI * 2; + for (let a = 0; a < steps; a++) { + const angle = off + (a / steps) * Math.PI * 2; + const tx = cx + Math.cos(angle) * dist; + const ty = cy + Math.sin(angle) * dist; + if (!_gc(tx, ty, n.radius)) { + n.x = tx; n.y = ty; + _artMap.placed.push(n); + _ga(n); + placed = true; + break; + } + } + if (placed) break; + } + }); + + // Build node lookup for edges + _artMap._nodeById = {}; + _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); + + // Auto-zoom + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + _artMap.placed.forEach(n => { + minX = Math.min(minX, n.x - n.radius); + maxX = Math.max(maxX, n.x + n.radius); + minY = Math.min(minY, n.y - n.radius); + maxY = Math.max(maxY, n.y + n.radius); + }); + const mapW = maxX - minX + 200, mapH = maxY - minY + 200; + _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); + _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; + _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; + + _artMapSetupInteraction(canvas); + + // Load images + const loadingText = container.querySelector('.artist-map-loading-text'); + if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`; + + setTimeout(async () => { + const imgNodes = _artMap.placed.filter(n => n.image_url); + let loaded = 0; + const CONCURRENT = 20; + let idx = 0; + async function loadBatch() { + const batch = []; + while (idx < imgNodes.length && batch.length < CONCURRENT) { + const n = imgNodes[idx++]; + batch.push(_artMapLoadImage(n.image_url) + .then(bmp => { if (bmp) _artMap.images[n.id] = bmp; }) + .finally(() => { loaded++; })); + } + if (batch.length) await Promise.all(batch); + if (idx < imgNodes.length) return loadBatch(); + } + await loadBatch(); + _artMap.dirty = true; + _artMapRender(); + const le = document.getElementById('artist-map-loading'); + if (le) le.remove(); + }, 50); + + _artMap.dirty = true; + _artMapRender(); + + } catch (err) { + console.error('Artist explorer error:', err); + const lt = container.querySelector('.artist-map-loading-text'); + if (lt) lt.textContent = 'Error loading explorer'; + } +} + +function _showArtistMapSearchPrompt() { + return new Promise(resolve => { + const existing = document.getElementById('artmap-search-prompt'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'artmap-search-prompt'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + + overlay.innerHTML = ` +
+
+ + + + +
+

Artist Explorer

+

Enter an artist to explore their connections

+
+
+ +
+ + +
+
+ `; + document.body.appendChild(overlay); + + const input = overlay.querySelector('#artmap-explore-input'); + const goBtn = overlay.querySelector('#artmap-explore-go'); + + const submit = () => { + const val = input.value.trim(); + overlay.remove(); + resolve(val || null); + }; + + goBtn.onclick = submit; + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); + setTimeout(() => input.focus(), 50); + }); +} + +function artMapToggleSimilar() { + _artMap._hideSimilar = !_artMap._hideSimilar; + _artMap.dirty = true; + _artMapRender(); + const btn = document.getElementById('artmap-toggle-similar'); + if (btn) btn.style.opacity = _artMap._hideSimilar ? '0.4' : '1'; + showToast(_artMap._hideSimilar ? 'Showing watchlist only' : 'Showing all artists', 'info', 1500); +} + +function _artMapLoadImage(url) { + // Try direct CORS fetch first (zero server load, works for Spotify/iTunes/Discogs) + return fetch(url, { mode: 'cors' }) + .then(r => r.ok ? r.blob() : Promise.reject('not ok')) + .then(b => createImageBitmap(b)) + .catch(() => { + // Fallback: server proxy for CDNs without CORS headers + return fetch('/api/image-proxy?url=' + encodeURIComponent(url)) + .then(r => r.ok ? r.blob() : null) + .then(b => b ? createImageBitmap(b) : null) + .catch(() => null); + }); +} + +function _artMapHideContextMenu() { + const m = document.getElementById('artist-map-context'); + if (m) m.style.display = 'none'; +} + +function _artMapSetupInteraction(canvas) { + // Prevent stacking listeners on repeated opens + if (canvas._artMapListenersAttached) return; + canvas._artMapListenersAttached = true; + + let isPanning = false, panStartX = 0, panStartY = 0; + + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const newZoom = Math.max(0.02, Math.min(5, _artMap.zoom * delta)); + // Zoom toward mouse + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + _artMap.offsetX = mx - (mx - _artMap.offsetX) * (newZoom / _artMap.zoom); + _artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom); + _artMap.zoom = newZoom; + _artMapRender(); // fast blit + // Debounce hi-res rebuild after zoom settles + clearTimeout(_artMap._zoomRebuild); + _artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300); + }, { passive: false }); + + let clickStart = null; + + // Keyboard shortcuts + function _artMapKeyHandler(e) { + if (!document.getElementById('artist-map-container') || document.getElementById('artist-map-container').style.display === 'none') return; + if (e.target.tagName === 'INPUT') return; // don't intercept search typing + if (e.key === 'Escape') { closeArtistMap(); e.preventDefault(); } + else if (e.key === '=' || e.key === '+') { artMapZoom(1.3); e.preventDefault(); } + else if (e.key === '-') { artMapZoom(0.7); e.preventDefault(); } + else if (e.key === '0') { artMapFitToView(); e.preventDefault(); } + else if (e.key === 'f' || e.key === 'F') { artMapFitToView(); e.preventDefault(); } + else if (e.key === 's' || e.key === 'S') { + const input = document.getElementById('artist-map-search'); + if (input) { input.focus(); e.preventDefault(); } + } + else if (e.key === 'h' || e.key === 'H') { + // Toggle similar artists visibility + _artMap._hideSimilar = !_artMap._hideSimilar; + _artMap.dirty = true; + _artMapRender(); + } + } + window.addEventListener('keydown', _artMapKeyHandler); + _artMap._keyHandler = _artMapKeyHandler; + + // Right-click context menu + canvas.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const { nx, ny } = _artMapScreenToWorld(e, canvas); + const node = _artMapHitTest(nx, ny); + if (!node || node._isLabel) { _artMapHideContextMenu(); return; } + + const menu = document.getElementById('artist-map-context') || (() => { + const m = document.createElement('div'); + m.id = 'artist-map-context'; + m.className = 'artmap-context-menu'; + document.getElementById('artist-map-container').appendChild(m); + return m; + })(); + + const hasId = node.spotify_id || node.itunes_id || node.deezer_id; + const activeSource = window._yaActiveSource || 'spotify'; + const bestId = node[activeSource + '_id'] || node.spotify_id || node.itunes_id || node.deezer_id || ''; + const bestSource = node[activeSource + '_id'] ? activeSource : node.spotify_id ? 'spotify' : node.itunes_id ? 'itunes' : 'deezer'; + + menu.innerHTML = ` +
+ Artist Info +
+
+ 💿 View Discography +
+
+ 👁 ${node.type === 'watchlist' ? 'On Watchlist' : 'Add to Watchlist'} +
+ `; + menu.style.display = 'block'; + menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px'; + menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px'; + + // Close on next click anywhere + setTimeout(() => { + window.addEventListener('click', _artMapHideContextMenu, { once: true }); + }, 10); + }); + + canvas.addEventListener('mousedown', (e) => { + if (e.button !== 0) return; // left button only + clickStart = { x: e.clientX, y: e.clientY, time: Date.now() }; + isPanning = true; + panStartX = e.clientX; + panStartY = e.clientY; + }); + + canvas.addEventListener('mousemove', (e) => { + if (isPanning) { + _artMap.offsetX += e.clientX - panStartX; + _artMap.offsetY += e.clientY - panStartY; + panStartX = e.clientX; + panStartY = e.clientY; + _artMapRender(); + } else { + const { nx, ny } = _artMapScreenToWorld(e, canvas); + const prev = _artMap.hoveredNode; + _artMap.hoveredNode = _artMapHitTest(nx, ny); + canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab'; + _artMapShowTooltip(e, _artMap.hoveredNode); + if (prev !== _artMap.hoveredNode) { + // Reset constellation highlight timer + clearTimeout(_artMap._constellationTimer); + if (_artMap._constellationActive) { + _artMap._constellationActive = false; + _artMapAnimateConstellation(); // fade out + } + if (_artMap.hoveredNode) { + // Delay constellation effect by 800ms of sustained hover + _artMap._constellationTimer = setTimeout(() => { + if (_artMap.hoveredNode) { + _artMap._constellationActive = true; + _artMap._constellationFade = 0; + _artMap._constellationCache = null; + _artMapAnimateConstellation(); + } + }, 800); + } + _artMapRender(); + } + } + }); + + canvas.addEventListener('mouseup', (e) => { + if (e.button !== 0) return; // left button only + const wasDrag = clickStart && (Math.abs(e.clientX - clickStart.x) > 5 || Math.abs(e.clientY - clickStart.y) > 5); + isPanning = false; + + if (!wasDrag && clickStart) { + // It was a click — find the node under cursor + const { nx, ny } = _artMapScreenToWorld(e, canvas); + const node = _artMapHitTest(nx, ny); + if (node) { + _artMap._ripple = { x: node.x, y: node.y, radius: node.radius, start: performance.now() }; + _artMapRender(); + if (node.spotify_id || node.itunes_id || node.deezer_id) { + setTimeout(() => openYourArtistInfoModal_direct(node), 200); + } + } + } + + clickStart = null; + _artMapShowTooltip(e, null); + }); + + canvas.addEventListener('mouseleave', () => { + _artMapShowTooltip(null, null); + clearTimeout(_artMap._constellationTimer); + if (_artMap._constellationActive) { + _artMap._constellationActive = false; + _artMapAnimateConstellation(); + } + _artMap.hoveredNode = null; + _artMapRender(); + }); + + // Touch support — single finger pan, pinch to zoom + let lastTouches = null; + canvas.addEventListener('touchstart', (e) => { + e.preventDefault(); + lastTouches = [...e.touches]; + }, { passive: false }); + + canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + if (!lastTouches) return; + const touches = [...e.touches]; + + if (touches.length === 1 && lastTouches.length === 1) { + // Pan + _artMap.offsetX += touches[0].clientX - lastTouches[0].clientX; + _artMap.offsetY += touches[0].clientY - lastTouches[0].clientY; + _artMapRender(); + } else if (touches.length === 2 && lastTouches.length === 2) { + // Pinch zoom + const prevDist = Math.hypot(lastTouches[1].clientX - lastTouches[0].clientX, lastTouches[1].clientY - lastTouches[0].clientY); + const curDist = Math.hypot(touches[1].clientX - touches[0].clientX, touches[1].clientY - touches[0].clientY); + const factor = curDist / prevDist; + const cx = (touches[0].clientX + touches[1].clientX) / 2; + const cy = (touches[0].clientY + touches[1].clientY) / 2; + const newZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor)); + _artMap.offsetX = cx - (cx - _artMap.offsetX) * (newZoom / _artMap.zoom); + _artMap.offsetY = cy - (cy - _artMap.offsetY) * (newZoom / _artMap.zoom); + _artMap.zoom = newZoom; + _artMap.dirty = true; + _artMapRender(); + } + lastTouches = touches; + }, { passive: false }); + + canvas.addEventListener('touchend', (e) => { + e.preventDefault(); + // Tap to click + if (lastTouches && lastTouches.length === 1 && e.changedTouches.length === 1) { + const t = e.changedTouches[0]; + const rect = canvas.getBoundingClientRect(); + const wx = (t.clientX - rect.left - _artMap.offsetX) / _artMap.zoom; + const wy = (t.clientY - rect.top - _artMap.offsetY) / _artMap.zoom; + const node = _artMapHitTest(wx, wy); + if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { + openYourArtistInfoModal_direct(node); + } + } + lastTouches = null; + }, { passive: false }); + + // Handle resize + window.addEventListener('resize', () => { + const container = document.getElementById('artist-map-container'); + if (!container || container.style.display === 'none') return; + _artMap.width = container.clientWidth; + _artMap.height = container.clientHeight - 50; + canvas.width = _artMap.width * window.devicePixelRatio; + canvas.height = _artMap.height * window.devicePixelRatio; + canvas.style.width = _artMap.width + 'px'; + canvas.style.height = _artMap.height + 'px'; + _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); + }); +} + +function _artMapScreenToWorld(e, canvas) { + const rect = canvas.getBoundingClientRect(); + const sx = e.clientX - rect.left; + const sy = e.clientY - rect.top; + // Inverse of: translate(offsetX, offsetY) → scale(zoom) + return { + nx: (sx - _artMap.offsetX) / _artMap.zoom, + ny: (sy - _artMap.offsetY) / _artMap.zoom, + }; +} + +function _artMapHitTest(wx, wy) { + // Check watchlist first (drawn on top), then similar + const sorted = [..._artMap.placed].sort((a, b) => + (b.type === 'watchlist' ? 1 : 0) - (a.type === 'watchlist' ? 1 : 0)); + for (const n of sorted) { + if ((n.opacity || 0) < 0.3) continue; + const dx = wx - n.x; + const dy = wy - n.y; + if (dx * dx + dy * dy <= n.radius * n.radius) return n; + } + return null; +} + +async function openYourArtistInfoModal_direct(node) { + // Determine best source ID — prefer active metadata source + let bestId = '', bestSource = ''; + // Check what the active source is + const activeSource = window._yaActiveSource || 'spotify'; + const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id'] + : activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id'] + : activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id'] + : ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']; + const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs' }; + for (const key of sourceOrder) { + if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; } + } + + // Gather ALL connected artists from map edges (both directions) + const related = []; + const relatedIds = new Set(); + const nById = _artMap._nodeById || {}; + _artMap.edges.forEach(e => { + if (e.source === node.id && nById[e.target] && !relatedIds.has(e.target)) { + related.push(nById[e.target]); + relatedIds.add(e.target); + } + if (e.target === node.id && nById[e.source] && !relatedIds.has(e.source)) { + related.push(nById[e.source]); + relatedIds.add(e.source); + } + }); + + const poolEntry = { + id: node.id, + artist_name: node.name, + active_source_id: bestId, + active_source: bestSource, + image_url: node.image_url || '', + spotify_artist_id: node.spotify_id || '', + itunes_artist_id: node.itunes_id || '', + deezer_artist_id: node.deezer_id || '', + discogs_artist_id: node.discogs_id || '', + source_services: [], + on_watchlist: node.type === 'watchlist' ? 1 : 0, + _related: related, + }; + if (!window._yaArtists) window._yaArtists = {}; + window._yaArtists[node.id] = poolEntry; + openYourArtistInfoModal(node.id); +} + +async function loadDiscoveryShuffle() { + try { + const container = document.getElementById('personalized-discovery-shuffle'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/discovery-shuffle?limit=50'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedDiscoveryShuffle = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading discovery shuffle:', error); + } +} + +async function loadFamiliarFavorites() { + try { + const container = document.getElementById('personalized-familiar-favorites'); + if (!container) return; + + const response = await fetch('/api/discover/personalized/familiar-favorites?limit=50'); + if (!response.ok) return; + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + container.closest('.discover-section').style.display = 'none'; + return; + } + + personalizedFamiliarFavorites = data.tracks; + renderCompactPlaylist(container, data.tracks); + container.closest('.discover-section').style.display = 'block'; + + } catch (error) { + console.error('Error loading familiar favorites:', error); + } +} + +// =============================== +// BECAUSE YOU LISTEN TO +// =============================== + +async function loadBecauseYouListenTo() { + try { + const resp = await fetch('/api/discover/because-you-listen-to'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.sections || data.sections.length === 0) return; + + // Find or create the BYLT container + let byltContainer = document.getElementById('discover-bylt-sections'); + if (!byltContainer) { + // Insert after the release radar section + const releaseRadar = document.getElementById('discover-release-radar'); + if (!releaseRadar) return; + const parent = releaseRadar.closest('.discover-section'); + if (!parent) return; + + byltContainer = document.createElement('div'); + byltContainer.id = 'discover-bylt-sections'; + parent.parentNode.insertBefore(byltContainer, parent.nextSibling); + } + + byltContainer.innerHTML = data.sections.map((section, idx) => ` +
+
+
+ ${section.artist_image ? `` : ''} +
+
Because you listen to
+

${_esc(section.artist_name)}

+
+
+
+ +
+ `).join(''); + + // Render track cards in each carousel + data.sections.forEach((section, idx) => { + const carousel = document.getElementById(`bylt-carousel-${idx}`); + if (!carousel) return; + carousel.innerHTML = section.tracks.map(t => ` +
+
+ ${t.image_url ? `` : '
🎵
'} +
+
${_esc(t.name)}
+
${_esc(t.artist)}
+
+ `).join(''); + }); + + } catch (error) { + console.debug('Error loading Because You Listen To:', error); + } +} + +// =============================== +// CACHE DISCOVERY SECTIONS +// =============================== + +// Global arrays for cache discovery click handlers +let _cacheDiscoverData = {}; + +function _cacheDiscoverCard(item, type, sectionKey, index) { + const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const coverUrl = item.image_url || '/static/placeholder-album.png'; + const title = item.name || ''; + const subtitle = item.artist_name || ''; + const meta = item.release_date ? item.release_date.substring(0, 10) : (item.label || ''); + const onclick = `openCacheDiscoverAlbum('${sectionKey}',${index})`; + const libBadge = item.in_library ? '
In Library
' : ''; + return `
+
+ ${_esc(title)} + ${libBadge} +
+
+

${_esc(title)}

+

${_esc(subtitle)}

+ ${meta ? `

${_esc(meta)}

` : ''} +
+
`; +} + +async function openCacheDiscoverAlbum(sectionKey, index) { + const items = _cacheDiscoverData[sectionKey]; + if (!items || !items[index]) return; + const item = items[index]; + const source = item.source || 'spotify'; + const albumId = item.entity_id; + + // Deep cuts / genre dive tracks — find the real album by searching the cache + if (sectionKey === 'deep_cuts' || sectionKey === 'genre_dive_tracks') { + document.getElementById('genre-deep-dive-modal')?.remove(); + const albumName = item.album_name || item.name || ''; + const artistName = item.artist_name || ''; + const trackAlbumId = item.album_id || ''; + const trackSource = item.source || source; + + if (!artistName) { + showToast('No artist data available for this track', 'error'); + return; + } + + showLoadingOverlay(`Loading ${albumName}...`); + try { + let resolvedSource = trackSource; + let resolvedId = trackAlbumId; + let response; + + // If we have an album_id, use it directly + if (trackAlbumId) { + const _params = new URLSearchParams({ name: albumName, artist: artistName }); + response = await fetch(`/api/discover/album/${trackSource}/${trackAlbumId}?${_params}`); + } + + // Fallback: resolve by name+artist if no album_id or direct fetch failed + if (!trackAlbumId || (response && !response.ok)) { + const searchResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(albumName)}&artist=${encodeURIComponent(artistName)}`); + if (searchResp.ok) { + const searchData = await searchResp.json(); + if (searchData.success && searchData.entity_id) { + resolvedSource = searchData.source || trackSource; + resolvedId = searchData.entity_id; + const _params = new URLSearchParams({ name: albumName, artist: artistName }); + response = await fetch(`/api/discover/album/${resolvedSource}/${resolvedId}?${_params}`); + } + } + } + + if (!response || !response.ok) throw new Error('Failed to fetch album tracks'); + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found'); + + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: artistName }]; + if (Array.isArray(artists)) artists = artists.map(a => a.name || a); + return { + id: track.id, name: track.name, artists, + album: { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] }, + duration_ms: track.duration_ms || 0, track_number: track.track_number || 0, + }; + }); + const artistContext = { id: albumData.artists?.[0]?.id || '', name: artistName, source: resolvedSource }; + const albumContext = { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] }; + await openDownloadMissingModalForYouTube(`discover_cache_${resolvedId}`, albumData.name, spotifyTracks, artistContext, albumContext); + hideLoadingOverlay(); + } catch (error) { + console.error('Error opening deep cut album:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + hideLoadingOverlay(); + } + return; + } + + if (!albumId) { + showToast('No album ID available', 'error'); + return; + } + + // Close genre deep dive modal if open + document.getElementById('genre-deep-dive-modal')?.remove(); + + showLoadingOverlay(`Loading ${item.name || 'album'}...`); + try { + const _params = new URLSearchParams({ name: item.name || '', artist: item.artist_name || '' }); + let response = await fetch(`/api/discover/album/${source}/${albumId}?${_params}`); + + // If 404 (stale cache entry), try resolving via name+artist + if (response.status === 404) { + const resolveResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(item.name || '')}&artist=${encodeURIComponent(item.artist_name || '')}`); + if (resolveResp.ok) { + const resolved = await resolveResp.json(); + if (resolved.success && resolved.entity_id && resolved.entity_id !== albumId) { + response = await fetch(`/api/discover/album/${resolved.source || source}/${resolved.entity_id}?${_params}`); + } + } + } + + if (!response.ok) throw new Error('Album not available — it may have been removed from the source'); + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found'); + + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || albumData.artists || [{ name: item.artist_name }]; + if (Array.isArray(artists)) artists = artists.map(a => a.name || a); + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [], + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0, + }; + }); + + const artistContext = { + id: albumData.artists?.[0]?.id || '', + name: item.artist_name || albumData.artists?.[0]?.name || '', + source: source, + }; + const albumContext = { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [], + }; + + await openDownloadMissingModalForYouTube( + `discover_cache_${albumId}`, albumData.name, spotifyTracks, artistContext, albumContext + ); + hideLoadingOverlay(); + } catch (error) { + console.error('Error opening cache discover album:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +function _insertCacheSection(id, title, subtitle, html, position) { + const container = document.getElementById('discover-bylt-sections') || document.querySelector('.discover-container'); + if (!container) return; + let section = document.getElementById(id); + if (!section) { + section = document.createElement('div'); + section.id = id; + section.className = 'discover-section'; + if (position === 'top') { + // Insert after the hero section (first child), not before it + const hero = container.querySelector('.discover-hero'); + if (hero && hero.nextSibling) { + container.insertBefore(section, hero.nextSibling); + } else { + container.prepend(section); + } + } else { + container.appendChild(section); + } + } + section.innerHTML = ` +
+
+
${subtitle}
+

${title}

+
+
+ + `; +} + +async function loadCacheUndiscoveredAlbums() { + try { + const resp = await fetch('/api/discover/undiscovered-albums'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.albums || !data.albums.length) return; + _cacheDiscoverData['undiscovered'] = data.albums; + _insertCacheSection('cache-undiscovered', + 'Undiscovered Albums', 'From artists you love', + data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'undiscovered', i)).join('')); + } catch (e) { console.debug('Cache undiscovered albums:', e); } +} + +async function loadCacheGenreNewReleases() { + try { + const resp = await fetch('/api/discover/genre-new-releases'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.albums || !data.albums.length) return; + _cacheDiscoverData['genre_releases'] = data.albums; + _insertCacheSection('cache-genre-releases', + 'New In Your Genres', 'Released in the last 90 days', + data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_releases', i)).join('')); + } catch (e) { console.debug('Cache genre new releases:', e); } +} + +async function loadCacheLabelExplorer() { + try { + const resp = await fetch('/api/discover/label-explorer'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.albums || !data.albums.length) return; + _cacheDiscoverData['label_explorer'] = data.albums; + _insertCacheSection('cache-label-explorer', + 'From Your Labels', 'Popular on labels in your library', + data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'label_explorer', i)).join('')); + } catch (e) { console.debug('Cache label explorer:', e); } +} + +async function loadCacheDeepCuts() { + try { + const resp = await fetch('/api/discover/deep-cuts'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.tracks || !data.tracks.length) return; + _cacheDiscoverData['deep_cuts'] = data.tracks; + _insertCacheSection('cache-deep-cuts', + 'Deep Cuts', 'Hidden tracks from artists you know', + data.tracks.map((t, i) => _cacheDiscoverCard(t, 'track', 'deep_cuts', i)).join('')); + } catch (e) { console.debug('Cache deep cuts:', e); } +} + +async function loadCacheGenreExplorer() { + try { + const resp = await fetch('/api/discover/genre-explorer'); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.genres || !data.genres.length) return; + const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/'/g, '''); + const html = `
${data.genres.map(g => ` +
+ ${_esc(g.genre)} + ${g.artist_count} artist${g.artist_count !== 1 ? 's' : ''} + ${!g.explored ? 'New' : ''} +
+ `).join('')}
`; + _insertCacheSection('cache-genre-explorer', + 'Genre Explorer', 'Tap a genre to explore', html, 'top'); + } catch (e) { console.debug('Cache genre explorer:', e); } +} + +async function openGenreDeepDive(genre) { + document.getElementById('genre-deep-dive-modal')?.remove(); + + const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + const _fmtNum = (n) => { + if (!n) return ''; + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(0) + 'K'; + return n.toString(); + }; + const _fmtDur = (ms) => { + if (!ms) return ''; + const m = Math.floor(ms / 60000); + const s = Math.floor((ms % 60000) / 1000); + return `${m}:${s.toString().padStart(2, '0')}`; + }; + + const overlay = document.createElement('div'); + overlay.id = 'genre-deep-dive-modal'; + overlay.className = 'genre-dive-overlay'; + overlay.innerHTML = ` +
+
+
+
Genre Deep Dive
+

${_esc(genre)}

+
+ +
+
+
Exploring ${_esc(genre)}...
+
+
+ `; + overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); + document.body.appendChild(overlay); + + try { + const resp = await fetch(`/api/discover/genre-deep-dive?genre=${encodeURIComponent(genre)}`); + if (!resp.ok) throw new Error('Failed to load'); + const data = await resp.json(); + if (!data.success) throw new Error('Failed'); + + const body = document.getElementById('genre-dive-body'); + if (!body) return; + + // Update header with counts + const subtitle = document.querySelector('.genre-dive-subtitle'); + if (subtitle) { + const parts = []; + if (data.artists?.length) parts.push(`${data.artists.length} artist${data.artists.length !== 1 ? 's' : ''}`); + if (data.tracks?.length) parts.push(`${data.tracks.length} track${data.tracks.length !== 1 ? 's' : ''}`); + if (data.albums?.length) parts.push(`${data.albums.length} album${data.albums.length !== 1 ? 's' : ''}`); + subtitle.textContent = parts.length ? parts.join(' · ') : 'Genre Deep Dive'; + } + + let html = ''; + + // Related genres — clickable pills that reload the modal + if (data.related_genres && data.related_genres.length) { + html += ``; + } + + // Artists section — clickable, navigates to artist page + // Uses library_id for in-library artists (source-agnostic), falls back to search by name + if (data.artists && data.artists.length) { + html += `
+

🎤 Artists in ${_esc(genre)}

+
+ ${data.artists.map(a => { + // Always open on Artists page with discography — pass source for correct routing + const imgUrl = _esc(a.image_url || ''); + const artSource = _esc(a.source || ''); + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToArtistDetail('${_esc(a.entity_id)}','${_esc(a.name)}','${artSource}' || null)"`; + const srcClass = (a.source || '').toLowerCase(); + return `
+
+ ${!a.image_url ? '🎤' : ''} +
+ +
${_esc(a.name)}
+ ${a.followers ? `
${_fmtNum(a.followers)} followers
` : ''} + ${a.library_id ? '
In Library
' : ''} +
`; + }).join('')} +
+
`; + } + + // Tracks section — clickable, opens album download + if (data.tracks && data.tracks.length) { + _cacheDiscoverData['genre_dive_tracks'] = data.tracks; + html += `
+

🎵 Popular Tracks

+
+ ${data.tracks.map((t, i) => { + const tSrcClass = (t.source || '').toLowerCase(); + return ` +
+
${i + 1}
+
+ ${!t.image_url ? '🎵' : ''} +
+
+
${_esc(t.name)}
+
${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}
+
+ +
${_fmtDur(t.duration_ms)}
+
+ `}).join('')} +
+
`; + } + + // Albums section + if (data.albums && data.albums.length) { + _cacheDiscoverData['genre_dive_albums'] = data.albums; + html += `
+

💿 Albums

+ +
`; + } + + if (!html) { + html = '
🔍

No cached data found for this genre yet

Search for artists in this genre to build up the cache

'; + } + + body.innerHTML = html; + } catch (e) { + const body = document.getElementById('genre-dive-body'); + if (body) body.innerHTML = '
Failed to load genre data
'; + } +} + +// =============================== +// BUILD A PLAYLIST FEATURE +// =============================== + +let buildPlaylistSearchTimeout = null; + +async function searchBuildPlaylistArtists() { + const searchInput = document.getElementById('build-playlist-search'); + const resultsContainer = document.getElementById('build-playlist-search-results'); + const spinner = document.getElementById('bp-search-spinner'); + const query = searchInput.value.trim(); + + if (!query) { + resultsContainer.innerHTML = ''; + resultsContainer.style.display = 'none'; + if (spinner) spinner.style.display = 'none'; + return; + } + + // Debounce search + clearTimeout(buildPlaylistSearchTimeout); + buildPlaylistSearchTimeout = setTimeout(async () => { + if (spinner) spinner.style.display = 'flex'; + try { + const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`); + const data = await response.json(); + if (!response.ok) { + showToast(data.error || 'Search failed', 'error'); + return; + } + if (!data.success || !data.artists || data.artists.length === 0) { + resultsContainer.innerHTML = '
No artists found for "' + query.replace(/'; + resultsContainer.style.display = 'block'; + return; + } + + // Filter out already-selected artists + const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id)); + const filtered = data.artists.filter(a => !selectedIds.has(a.id)); + + if (filtered.length === 0) { + resultsContainer.innerHTML = '
All results already selected
'; + resultsContainer.style.display = 'block'; + return; + } + + // Render search results + let html = ''; + filtered.forEach(artist => { + const imageUrl = artist.image_url || '/static/placeholder-album.png'; + const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"'); + html += ` +
+ ${artist.name} + ${artist.name} + + Add +
+ `; + }); + + resultsContainer.innerHTML = html; + resultsContainer.style.display = 'block'; + + } catch (error) { + console.error('Error searching artists:', error); + } finally { + if (spinner) spinner.style.display = 'none'; + } + }, 400); +} + +function addBuildPlaylistArtist(artistId, artistName, imageUrl) { + if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) { + showToast('Artist already selected', 'warning'); + return; + } + if (buildPlaylistSelectedArtists.length >= 5) { + showToast('Maximum 5 seed artists', 'warning'); + return; + } + + buildPlaylistSelectedArtists.push({ + id: artistId, + name: artistName, + image_url: imageUrl + }); + + renderBuildPlaylistSelectedArtists(); + + // Clear search + document.getElementById('build-playlist-search').value = ''; + document.getElementById('build-playlist-search-results').innerHTML = ''; + document.getElementById('build-playlist-search-results').style.display = 'none'; +} + +function removeBuildPlaylistArtist(artistId) { + buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId); + renderBuildPlaylistSelectedArtists(); +} + +function renderBuildPlaylistSelectedArtists() { + const container = document.getElementById('build-playlist-selected-artists'); + const generateBtn = document.getElementById('build-playlist-generate-btn'); + const counter = document.getElementById('bp-selected-counter'); + const count = buildPlaylistSelectedArtists.length; + + if (counter) counter.textContent = `${count} / 5`; + + if (count === 0) { + container.innerHTML = ` +
+ + Search above to add seed artists +
`; + generateBtn.disabled = true; + return; + } + + let html = ''; + buildPlaylistSelectedArtists.forEach(artist => { + const escapedId = artist.id.replace(/'/g, "\\'"); + html += ` +
+ ${artist.name} + ${artist.name} + +
+ `; + }); + + container.innerHTML = html; + generateBtn.disabled = false; +} + +let buildPlaylistTracks = []; + +async function generateBuildPlaylist() { + if (buildPlaylistSelectedArtists.length === 0) { + showToast('Please select at least 1 artist', 'warning'); + return; + } + + const generateBtn = document.getElementById('build-playlist-generate-btn'); + const resultsContainer = document.getElementById('build-playlist-results'); + const resultsWrapper = document.getElementById('build-playlist-results-wrapper'); + const loadingIndicator = document.getElementById('build-playlist-loading'); + const metadataDisplay = document.getElementById('build-playlist-metadata-display'); + const titleEl = document.getElementById('build-playlist-results-title'); + const subtitleEl = document.getElementById('build-playlist-results-subtitle'); + + // Show loading, hide search area + generateBtn.disabled = true; + loadingIndicator.style.display = 'flex'; + resultsWrapper.style.display = 'none'; + resultsContainer.innerHTML = ''; + + try { + const seedIds = buildPlaylistSelectedArtists.map(a => a.id); + const response = await fetch('/api/discover/build-playlist/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + seed_artist_ids: seedIds, + playlist_size: 50 + }) + }); + + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || 'Failed to generate playlist'); + } + if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) { + throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.'); + } + + // Store tracks globally + buildPlaylistTracks = data.playlist.tracks; + + // Update title and subtitle + const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', '); + titleEl.textContent = 'Custom Playlist'; + subtitleEl.textContent = `Based on: ${artistNames}`; + + // Render metadata + const metadata = data.playlist.metadata; + metadataDisplay.innerHTML = ` + + `; + + // Render playlist + renderCompactPlaylist(resultsContainer, data.playlist.tracks); + + // Show results wrapper + resultsWrapper.style.display = 'block'; + + } catch (error) { + console.error('Error generating playlist:', error); + resultsWrapper.style.display = 'none'; + showToast(error.message || 'Failed to generate playlist', 'error'); + } finally { + loadingIndicator.style.display = 'none'; + generateBtn.disabled = false; + } +} + +async function openDownloadModalForBuildPlaylist() { + if (!buildPlaylistTracks || buildPlaylistTracks.length === 0) { + showToast('No playlist tracks available', 'warning'); + return; + } + + const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', '); + const playlistName = `Custom Playlist - ${artistNames}`; + const virtualPlaylistId = 'build_playlist_custom'; + + // Open download modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, buildPlaylistTracks); +} + +function openDailyMix(mixIndex) { + const mix = personalizedDailyMixes[mixIndex]; + if (!mix || !mix.tracks) return; + + // TODO: Open modal or dedicated view for Daily Mix + console.log('Opening Daily Mix:', mix.name); +} + +// =============================== +// DISCOVER PLAYLIST ACTIONS +// =============================== + +async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) { + console.log(`📥 Opening Download Missing Tracks modal for ${playlistName}`); + + try { + // Get tracks based on playlist type + let tracks = []; + if (playlistType === 'release_radar') { + tracks = discoverReleaseRadarTracks; + } else if (playlistType === 'discovery_weekly') { + tracks = discoverWeeklyTracks; + } else if (playlistType === 'seasonal_playlist') { + tracks = discoverSeasonalTracks; + } else if (playlistType === 'popular_picks') { + tracks = personalizedPopularPicks; + } else if (playlistType === 'hidden_gems') { + tracks = personalizedHiddenGems; + } else if (playlistType === 'discovery_shuffle') { + tracks = personalizedDiscoveryShuffle; + } else if (playlistType === 'familiar_favorites') { + tracks = personalizedFamiliarFavorites; + } else if (playlistType === 'recently_added') { + tracks = personalizedRecentlyAdded; + } else if (playlistType === 'top_tracks') { + tracks = personalizedTopTracks; + } else if (playlistType === 'forgotten_favorites') { + tracks = personalizedForgottenFavorites; + } else if (playlistType === 'build_playlist') { + tracks = buildPlaylistTracks; + } + + if (!tracks || tracks.length === 0) { + showToast(`No tracks available for ${playlistName}`, 'warning'); + return; + } + + // Convert discover tracks to format expected by download modal + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + + // Use track_data_json if available, otherwise construct from track data + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + // Fallback: construct track object from available data + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + + // Normalize artists to array of strings for modal compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + // Create virtual playlist ID + const virtualPlaylistId = `discover_${playlistType}`; + + // Use existing modal system (same as YouTube/Tidal playlists) + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error opening download modal for discover playlist:', error); + showToast(`Failed to open download modal: ${error.message}`, 'error'); + hideLoadingOverlay(); // Ensure overlay is hidden on error + } +} + +function updateDiscoverDownloadButton(playlistType, state) { + /** + * Update the download button appearance based on download state + * @param {string} playlistType - 'release_radar' or 'discovery_weekly' + * @param {string} state - 'idle', 'downloading', or 'complete' + */ + const buttonId = `${playlistType}-download-btn`; + const button = document.getElementById(buttonId); + + if (!button) return; + + const icon = button.querySelector('.button-icon'); + const text = button.querySelector('.button-text'); + + if (state === 'downloading') { + if (icon) icon.textContent = '⏳'; + if (text) text.textContent = 'View Progress'; + button.title = 'View download progress'; + } else { + if (icon) icon.textContent = '↓'; + if (text) text.textContent = 'Download'; + button.title = 'Download missing tracks'; + } +} + +function checkForActiveDiscoverDownloads() { + /** + * Check for active download processes and update button states + * Only runs if discover page is actually loaded in the DOM + */ + // Check if discover page is loaded by looking for a discover-specific element + const discoverPage = document.getElementById('release-radar-download-btn') || + document.getElementById('discovery-weekly-download-btn'); + + if (!discoverPage) return; + + const discoverPlaylists = [ + { id: 'discover_release_radar', type: 'release_radar' }, + { id: 'discover_discovery_weekly', type: 'discovery_weekly' } + ]; + + discoverPlaylists.forEach(({ id, type }) => { + if (activeDownloadProcesses[id]) { + const process = activeDownloadProcesses[id]; + if (process.status === 'running' || process.status === 'idle') { + updateDiscoverDownloadButton(type, 'downloading'); + } + } + }); +} + +async function startDiscoverPlaylistSync(playlistType, playlistName) { + console.log(`🔄 Starting sync for ${playlistName}`); + + // Get tracks based on playlist type + let tracks = []; + if (playlistType === 'release_radar') { + tracks = discoverReleaseRadarTracks; + } else if (playlistType === 'discovery_weekly') { + tracks = discoverWeeklyTracks; + } else if (playlistType === 'seasonal_playlist') { + tracks = discoverSeasonalTracks; + } else if (playlistType === 'popular_picks') { + tracks = personalizedPopularPicks; + } else if (playlistType === 'hidden_gems') { + tracks = personalizedHiddenGems; + } else if (playlistType === 'discovery_shuffle') { + tracks = personalizedDiscoveryShuffle; + } else if (playlistType === 'familiar_favorites') { + tracks = personalizedFamiliarFavorites; + } else if (playlistType === 'build_playlist') { + tracks = buildPlaylistTracks; + } + + if (!tracks || tracks.length === 0) { + showToast(`No tracks available for ${playlistName}`, 'warning'); + return; + } + + // Convert to format expected by sync API + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + + // Use track_data_json if available + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + // Fallback: construct track object + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + + // Normalize artists to array of strings for sync compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + // Create virtual playlist ID + const virtualPlaylistId = `discover_${playlistType}`; + + // Store in cache for sync function + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + + // Create virtual playlist object + const virtualPlaylist = { + id: virtualPlaylistId, + name: playlistName, + track_count: spotifyTracks.length + }; + + // Add to spotify playlists array if not already there + if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { + spotifyPlaylists.push(virtualPlaylist); + } + + // Show sync status display (convert underscores to hyphens for ID) + const statusId = playlistType.replace(/_/g, '-') + '-sync-status'; + const statusDisplay = document.getElementById(statusId); + if (statusDisplay) { + statusDisplay.style.display = 'block'; + } + + // Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID) + const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; + const syncButton = document.getElementById(buttonId); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + syncButton.style.cursor = 'not-allowed'; + } + + // Start sync using existing function + await startPlaylistSync(virtualPlaylistId); + + // Extract image URL from first track for download bar bubble + let imageUrl = null; + if (spotifyTracks && spotifyTracks.length > 0) { + const firstTrack = spotifyTracks[0]; + if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { + imageUrl = firstTrack.album.images[0].url; + } + } + + // Add to discover download bar + addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl); + + // Start polling for progress updates + startDiscoverSyncPolling(playlistType, virtualPlaylistId); +} + +// Track active discover sync pollers +const discoverSyncPollers = {}; + +function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { + // Stop any existing poller for this playlist type + if (discoverSyncPollers[playlistType]) { + clearInterval(discoverSyncPollers[playlistType]); + } + + console.log(`🔄 Starting sync polling for ${playlistType} (${virtualPlaylistId})`); + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); + _syncProgressCallbacks[virtualPlaylistId] = (data) => { + const prefix = playlistType.replace(/_/g, '-'); + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const pct = total > 0 ? Math.round((processed / total) * 100) : 0; + const el = (id) => document.getElementById(id); + if (el(`${prefix}-sync-completed`)) el(`${prefix}-sync-completed`).textContent = matched; + if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = pending; + if (el(`${prefix}-sync-failed`)) el(`${prefix}-sync-failed`).textContent = failed; + if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = pct; + if (data.status === 'finished') { + if (discoverSyncPollers[playlistType]) { clearInterval(discoverSyncPollers[playlistType]); delete discoverSyncPollers[playlistType]; } + socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); + delete _syncProgressCallbacks[virtualPlaylistId]; + const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; + const syncButton = el(buttonId); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } + const playlistNames = { + 'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives', + 'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks', + 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', + 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' + }; + showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success'); + setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); + } + }; + } + + // Poll every 500ms for progress updates + discoverSyncPollers[playlistType] = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); + if (!response.ok) { + console.log(`⚠️ Sync status response not OK: ${response.status}`); + return; + } + + const data = await response.json(); + console.log(`📊 Sync status for ${playlistType}:`, data); + + // Update UI with progress (data structure: {status: ..., progress: {...}}) + // Convert underscores to hyphens for HTML IDs + const prefix = playlistType.replace(/_/g, '-'); + const progress = data.progress || {}; + + const completedEl = document.getElementById(`${prefix}-sync-completed`); + const pendingEl = document.getElementById(`${prefix}-sync-pending`); + const failedEl = document.getElementById(`${prefix}-sync-failed`); + const percentageEl = document.getElementById(`${prefix}-sync-percentage`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const processed = matched + failed; + const pending = total - processed; + const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + if (completedEl) completedEl.textContent = matched; + if (pendingEl) pendingEl.textContent = pending; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = completionPercentage; + + // If complete, stop polling and hide status after delay + if (data.status === 'finished') { + console.log(`✅ Sync complete for ${playlistType}`); + clearInterval(discoverSyncPollers[playlistType]); + delete discoverSyncPollers[playlistType]; + + // Re-enable sync button + const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; + const syncButton = document.getElementById(buttonId); + if (syncButton) { + syncButton.disabled = false; + syncButton.style.opacity = '1'; + syncButton.style.cursor = 'pointer'; + } + + // Show completion toast with playlist name + const playlistNames = { + 'release_radar': 'Fresh Tape', + 'discovery_weekly': 'The Archives', + 'seasonal_playlist': 'Seasonal Mix', + 'popular_picks': 'Popular Picks', + 'hidden_gems': 'Hidden Gems', + 'discovery_shuffle': 'Discovery Shuffle', + 'familiar_favorites': 'Familiar Favorites', + 'build_playlist': 'Custom Playlist' + }; + const displayName = playlistNames[playlistType] || playlistType; + showToast(`${displayName} sync complete!`, 'success'); + + // Hide status display after 3 seconds + setTimeout(() => { + const statusDisplay = document.getElementById(`${prefix}-sync-status`); + if (statusDisplay) { + statusDisplay.style.display = 'none'; + } + }, 3000); + } + + } catch (error) { + console.error(`❌ Error polling sync status for ${playlistType}:`, error); + } + }, 500); +} + +async function openDownloadModalForRecentAlbum(albumIndex) { + const album = discoverRecentAlbums[albumIndex]; + if (!album) { + showToast('Album data not found', 'error'); + return; + } + + console.log(`📥 Opening Download Missing Tracks modal for album: ${album.album_name}`); + showLoadingOverlay(`Loading tracks for ${album.album_name}...`); + + try { + // Determine source and album ID - use source-agnostic endpoint + const source = album.source || (album.album_spotify_id ? 'spotify' : album.album_deezer_id ? 'deezer' : 'itunes'); + const albumId = source === 'spotify' ? album.album_spotify_id : source === 'deezer' ? album.album_deezer_id : album.album_itunes_id; + + if (!albumId) { + throw new Error(`No ${source} album ID available`); + } + + // Fetch album tracks from appropriate source (pass name/artist for Hydrabase support) + const _dap2 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); + const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap2}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + // Convert to expected format - CRITICAL FIX: Use fresh albumData from Spotify, not cached album + const spotifyTracks = albumData.tracks.map(track => { + // Normalize artists to array of strings + let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + id: albumData.id, // ✅ Album ID for proper tracking + name: albumData.name, // ✅ Use fresh data, not cached + album_type: albumData.album_type || 'album', // ✅ Critical: Album type for classification + total_tracks: albumData.total_tracks || 0, // ✅ Total tracks for context + release_date: albumData.release_date || '', // ✅ Release date + images: albumData.images || [] // ✅ Use Spotify images + }, + duration_ms: track.duration_ms || 0, + track_number: track.track_number || 0 + }; + }); + + // Create virtual playlist ID using the appropriate album ID + const virtualPlaylistId = `discover_album_${albumId}`; + + // CRITICAL FIX: Pass proper artist/album context for modal display + const artistContext = { + id: source === 'spotify' ? album.artist_spotify_id : source === 'deezer' ? album.artist_deezer_id : album.artist_itunes_id, + name: album.artist_name, + source: source + }; + + const albumContext = { + id: albumData.id, + name: albumData.name, + album_type: albumData.album_type || 'album', + total_tracks: albumData.total_tracks || 0, + release_date: albumData.release_date || '', + images: albumData.images || [] + }; + + // Open download modal with artist/album context + await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); + + hideLoadingOverlay(); + + } catch (error) { + console.error('Error opening album download modal:', error); + showToast(`Failed to load album: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +// =============================== +// DISCOVER DOWNLOAD BAR +// =============================== + +// Track discover page downloads +let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime } + +/** + * Add a download to the discover download bar + */ +function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) { + console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`); + + // Always register the download in state (needed for dashboard even when not on discover page) + discoverDownloads[playlistId] = { + name: playlistName, + type: playlistType, + status: 'in_progress', + virtualPlaylistId: playlistId, + imageUrl: imageUrl, + startTime: new Date() + }; + + console.log(`📊 [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads)); + + // Update discover page sidebar if it exists (user is on discover page) + const downloadSidebar = document.getElementById('discover-download-sidebar'); + if (downloadSidebar) { + updateDiscoverDownloadBar(); // Also saves snapshot internally + } else { + console.log('ℹ️ [DOWNLOAD SIDEBAR] Sidebar not present - skipping sidebar UI update'); + saveDiscoverDownloadSnapshot(); // Persist state even when sidebar is absent + } + + updateDashboardDownloads(); + monitorDiscoverDownload(playlistId); +} + +/** + * Monitor a discover download for completion + */ +function monitorDiscoverDownload(playlistId) { + let notFoundCount = 0; + const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds) + + // Phase 5: Subscribe via WebSocket for sync status updates + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); + _syncProgressCallbacks[playlistId] = (data) => { + if (!discoverDownloads[playlistId]) return; + if (data.status === 'complete' || data.status === 'finished') { + discoverDownloads[playlistId].status = 'completed'; + updateDiscoverDownloadBar(); + updateDashboardDownloads(); + socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); + delete _syncProgressCallbacks[playlistId]; + setTimeout(() => { + if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { + removeDiscoverDownload(playlistId); + } + }, 30000); + } + }; + } + + const checkInterval = setInterval(async () => { + try { + // Check if download still exists + if (!discoverDownloads[playlistId]) { + clearInterval(checkInterval); + if (_syncProgressCallbacks[playlistId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); + delete _syncProgressCallbacks[playlistId]; + } + return; + } + + // First check if there's an active download process (modal-based downloads) + const activeProcess = activeDownloadProcesses[playlistId]; + if (activeProcess) { + console.log(`📂 [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`); + + if (activeProcess.status === 'complete') { + console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`); + discoverDownloads[playlistId].status = 'completed'; + updateDiscoverDownloadBar(); + updateDashboardDownloads(); + clearInterval(checkInterval); + + // Auto-remove completed downloads after 30 seconds + setTimeout(() => { + if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { + removeDiscoverDownload(playlistId); + } + }, 30000); + } + return; // Continue monitoring + } + + // Check sync status API (for sync-based downloads) + if (socketConnected) return; // Phase 5: WS handles sync status + const response = await fetch(`/api/sync/status/${playlistId}`); + if (response.ok) { + const data = await response.json(); + notFoundCount = 0; // Reset counter if found + + console.log(`🔄 [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`); + + if (data.status === 'complete') { + console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`); + discoverDownloads[playlistId].status = 'completed'; + updateDiscoverDownloadBar(); + updateDashboardDownloads(); + clearInterval(checkInterval); + + // Auto-remove completed downloads after 30 seconds + setTimeout(() => { + if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { + removeDiscoverDownload(playlistId); + } + }, 30000); + } + } else if (response.status === 404) { + notFoundCount++; + console.log(`🔍 [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`); + + // Only remove after multiple attempts (give it time to start) + if (notFoundCount >= maxNotFoundAttempts) { + console.log(`⏹️ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`); + clearInterval(checkInterval); + removeDiscoverDownload(playlistId); + } + } + } catch (error) { + console.error(`❌ [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error); + } + }, 2000); // Check every 2 seconds +} + +/** + * Remove a download from the bar + */ +function removeDiscoverDownload(playlistId) { + console.log(`🗑️ Removing discover download: ${playlistId}`); + delete discoverDownloads[playlistId]; + updateDiscoverDownloadBar(); + updateDashboardDownloads(); + saveDiscoverDownloadSnapshot(); // Save state after removal +} + +/** + * Update the discover download sidebar UI + */ +function updateDiscoverDownloadBar() { + const downloadSidebar = document.getElementById('discover-download-sidebar'); + const bubblesContainer = document.getElementById('discover-download-bubbles'); + const countElement = document.getElementById('discover-download-count'); + + console.log(`🔄 [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, { + downloadSidebar: !!downloadSidebar, + bubblesContainer: !!bubblesContainer, + countElement: !!countElement + }); + + if (!downloadSidebar || !bubblesContainer || !countElement) { + console.warn('⚠️ [DOWNLOAD SIDEBAR] Missing elements, cannot update'); + return; + } + + const activeDownloads = Object.keys(discoverDownloads); + const count = activeDownloads.length; + + console.log(`📊 [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`); + + // Update count + countElement.textContent = count; + + // Show/hide sidebar + if (count === 0) { + console.log(`👁️ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`); + downloadSidebar.classList.add('hidden'); + return; + } else { + console.log(`👁️ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`); + downloadSidebar.classList.remove('hidden'); + } + + // Update bubbles + bubblesContainer.innerHTML = activeDownloads.map(playlistId => { + const download = discoverDownloads[playlistId]; + const isCompleted = download.status === 'completed'; + const icon = isCompleted ? '✅' : '⏳'; + + // Use image if available, otherwise gradient background + const imageUrl = download.imageUrl || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${imageUrl}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
+ ${icon} +
+
+
${escapeHtml(download.name)}
+
+ `; + }).join(''); + + console.log(`📊 Updated discover download sidebar: ${count} active downloads`); + + // Save snapshot after UI update + saveDiscoverDownloadSnapshot(); +} + +/** + * Open download modal for a discover playlist + */ +async function openDiscoverDownloadModal(playlistId) { + console.log(`📂 [DOWNLOAD BAR] Opening download modal for: ${playlistId}`); + + // Check if there's an active download process with modal + let process = activeDownloadProcesses[playlistId]; + + console.log(`📋 [DOWNLOAD BAR] Process found:`, { + exists: !!process, + hasModalElement: !!(process && process.modalElement), + hasModalId: !!(process && process.modalId) + }); + + if (process) { + // Try modalElement first (album downloads) + if (process.modalElement) { + console.log(`✅ [DOWNLOAD BAR] Opening modal via modalElement`); + process.modalElement.style.display = 'flex'; + return; + } + + // Try modalId (sync downloads) + if (process.modalId) { + const modal = document.getElementById(process.modalId); + if (modal) { + console.log(`✅ [DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`); + modal.style.display = 'flex'; + return; + } + } + } + + // If no process found, try to rehydrate from backend + console.log(`💧 [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`); + const rehydrated = await rehydrateDiscoverDownloadModal(playlistId); + + if (rehydrated) { + console.log(`✅ [DOWNLOAD BAR] Successfully rehydrated modal, opening it...`); + // Try again after rehydration + process = activeDownloadProcesses[playlistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + return; + } + } + + // Fallback: show toast + const download = discoverDownloads[playlistId]; + if (download) { + console.log(`ℹ️ [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`); + showToast(`Download: ${download.name} - ${download.status}`, 'info'); + } else { + console.warn(`⚠️ [DOWNLOAD BAR] No download or process found for: ${playlistId}`); + } +} + +/** + * Initialize discover download sidebar on page load + */ +function initializeDiscoverDownloadBar() { + console.log('🎵 Initializing discover download sidebar...'); + + // Start with sidebar hidden (will be shown if downloads exist after hydration) + const downloadSidebar = document.getElementById('discover-download-sidebar'); + if (downloadSidebar) { + downloadSidebar.classList.add('hidden'); + } +} + +// --- Discover Download Modal Rehydration --- + +async function rehydrateDiscoverDownloadModal(playlistId) { + /** + * Rehydrates a discover download modal from backend process data. + * Fetches tracks from backend API and recreates the modal (user-requested). + */ + try { + console.log(`💧 [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`); + + // Check if there's an active backend process for this playlist + const batchResponse = await fetch(`/api/download_status/batch`); + if (!batchResponse.ok) { + console.log(`⚠️ [REHYDRATE] Failed to fetch batch info`); + return false; + } + + const batchData = await batchResponse.json(); + const batches = batchData.batches || {}; + + // Find the batch for this playlist (batches is an object with batch_id keys) + let batchId = null; + let batch = null; + for (const [id, batchStatus] of Object.entries(batches)) { + if (batchStatus.playlist_id === playlistId) { + batchId = id; + batch = batchStatus; + break; + } + } + + if (!batch || !batchId) { + console.log(`⚠️ [REHYDRATE] No active batch found for ${playlistId}`); + return false; + } + + console.log(`✅ [REHYDRATE] Found active batch for ${playlistId}: ${batchId}`, batch); + + // Get the download metadata from discoverDownloads + const downloadData = discoverDownloads[playlistId]; + if (!downloadData) { + console.log(`⚠️ [REHYDRATE] No download metadata found for ${playlistId}`); + return false; + } + + // Handle album downloads from Recent Releases + if (playlistId.startsWith('discover_album_')) { + const albumId = playlistId.replace('discover_album_', ''); + console.log(`💧 [REHYDRATE] Album download - fetching album ${albumId}...`); + + try { + const albumResponse = await fetch(`/api/spotify/album/${albumId}`); + if (!albumResponse.ok) { + console.error(`❌ [REHYDRATE] Failed to fetch album: ${albumResponse.status}`); + return false; + } + + const albumData = await albumResponse.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + console.error(`❌ [REHYDRATE] No tracks in album`); + return false; + } + + // Convert tracks to expected format + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || []; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + name: albumData.name || downloadData.name.split(' - ')[0], + images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + }); + + console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`); + + // Create modal + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + + // Update process + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for status updates + startModalDownloadPolling(playlistId); + console.log(`✅ [REHYDRATE] Successfully rehydrated album modal with polling`); + return true; + } + return false; + + } catch (error) { + console.error(`❌ [REHYDRATE] Error fetching album:`, error); + return false; + } + } + + // Determine API endpoint based on playlist ID + let apiEndpoint; + if (playlistId === 'discover_release_radar') { + apiEndpoint = '/api/discover/release-radar'; + } else if (playlistId === 'discover_discovery_weekly') { + apiEndpoint = '/api/discover/discovery-weekly'; + } else if (playlistId === 'discover_seasonal_playlist') { + apiEndpoint = '/api/discover/seasonal-playlist'; + } else if (playlistId === 'discover_popular_picks') { + apiEndpoint = '/api/discover/popular-picks'; + } else if (playlistId === 'discover_hidden_gems') { + apiEndpoint = '/api/discover/hidden-gems'; + } else if (playlistId === 'discover_discovery_shuffle') { + apiEndpoint = '/api/discover/discovery-shuffle'; + } else if (playlistId === 'discover_familiar_favorites') { + apiEndpoint = '/api/discover/familiar-favorites'; + } else if (playlistId === 'build_playlist_custom') { + apiEndpoint = '/api/discover/build-playlist'; + } else if (playlistId.startsWith('discover_lb_')) { + // ListenBrainz playlist - fetch from cache + const identifier = playlistId.replace('discover_lb_', ''); + const tracks = listenbrainzTracksCache[identifier]; + if (!tracks || tracks.length === 0) { + console.log(`⚠️ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`); + return false; + } + + // Convert to Spotify format + const spotifyTracks = tracks.map(track => ({ + id: track.mbid || `listenbrainz_${track.track_name}_${track.artist_name}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing + name: track.track_name, + artists: [{ name: cleanArtistName(track.artist_name) }], // Proper Spotify format + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0, + mbid: track.mbid + })); + + // Create modal and update process + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for status updates + startModalDownloadPolling(playlistId); + console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz modal with polling`); + return true; + } + return false; + } else if (playlistId.startsWith('listenbrainz_')) { + // ListenBrainz download from discovery modal - get from backend state + const mbid = playlistId.replace('listenbrainz_', ''); + console.log(`💧 [REHYDRATE] ListenBrainz download - fetching state for MBID: ${mbid}`); + + try { + // Fetch ListenBrainz state from backend + const stateResponse = await fetch(`/api/listenbrainz/state/${mbid}`); + if (!stateResponse.ok) { + console.log(`⚠️ [REHYDRATE] Failed to fetch ListenBrainz state`); + return false; + } + + const stateData = await stateResponse.json(); + if (!stateData || !stateData.discovery_results) { + console.log(`⚠️ [REHYDRATE] No discovery results in ListenBrainz state`); + return false; + } + + // Convert discovery results to Spotify tracks + const spotifyTracks = stateData.discovery_results + .filter(result => result.spotify_data) + .map(result => { + const track = result.spotify_data; + // Ensure artists is in proper Spotify format: [{name: ...}] + let artistsArray = []; + if (track.artists && Array.isArray(track.artists)) { + artistsArray = track.artists.map(artist => { + if (typeof artist === 'string') { + return { name: artist }; + } else if (artist && artist.name) { + return { name: artist.name }; + } else { + return { name: String(artist || 'Unknown Artist') }; + } + }); + } else if (track.artists && typeof track.artists === 'string') { + artistsArray = [{ name: track.artists }]; + } else { + artistsArray = [{ name: 'Unknown Artist' }]; + } + return { + id: track.id, + name: track.name, + artists: artistsArray, + album: track.album || { name: 'Unknown Album', images: [] }, + duration_ms: track.duration_ms || 0, + external_urls: track.external_urls || {} + }; + }); + + if (spotifyTracks.length === 0) { + console.log(`⚠️ [REHYDRATE] No Spotify tracks in ListenBrainz discovery results`); + return false; + } + + console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks from ListenBrainz state`); + + // Create modal and update process + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for status updates + startModalDownloadPolling(playlistId); + console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz download modal with polling`); + return true; + } + return false; + + } catch (error) { + console.error(`❌ [REHYDRATE] Error fetching ListenBrainz state:`, error); + return false; + } + } else { + console.error(`❌ [REHYDRATE] Unknown discover playlist type: ${playlistId}`); + return false; + } + + // Fetch tracks from API + console.log(`📡 [REHYDRATE] Fetching tracks from ${apiEndpoint}...`); + const response = await fetch(apiEndpoint); + if (!response.ok) { + console.error(`❌ [REHYDRATE] Failed to fetch tracks: ${response.status}`); + return false; + } + + const data = await response.json(); + if (!data.success || !data.tracks) { + console.error(`❌ [REHYDRATE] Invalid track data:`, data); + return false; + } + + const tracks = data.tracks; + console.log(`✅ [REHYDRATE] Retrieved ${tracks.length} tracks`); + + // Transform tracks to Spotify format + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + return spotifyTrack; + }); + + // Create the modal + await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); + + // Update process with batch info + const process = activeDownloadProcesses[playlistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + + // Update button states + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for status updates + startModalDownloadPolling(playlistId); + + // Don't hide the modal - user clicked to open it + console.log(`✅ [REHYDRATE] Successfully rehydrated modal for ${downloadData.name} with polling`); + return true; + } else { + console.error(`❌ [REHYDRATE] Failed to find rehydrated process for ${playlistId}`); + return false; + } + + } catch (error) { + console.error(`❌ [REHYDRATE] Error rehydrating discover download modal:`, error); + return false; + } +} + +// --- Discover Download Snapshot System --- + +let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves + +async function saveDiscoverDownloadSnapshot() { + /** + * Saves current discoverDownloads state to backend for persistence. + * Debounced to prevent excessive backend calls. + */ + + // Clear any existing timeout + if (discoverSnapshotSaveTimeout) { + clearTimeout(discoverSnapshotSaveTimeout); + } + + // Debounce the actual save + discoverSnapshotSaveTimeout = setTimeout(async () => { + try { + const downloadCount = Object.keys(discoverDownloads).length; + + // Don't save empty state + if (downloadCount === 0) { + console.log('📸 Skipping discover snapshot save - no downloads to save'); + return; + } + + console.log(`📸 Saving discover download snapshot: ${downloadCount} downloads`); + + // Prepare snapshot data (clean format) + const cleanDownloads = {}; + for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) { + cleanDownloads[playlistId] = { + name: downloadData.name, + type: downloadData.type, + status: downloadData.status, + virtualPlaylistId: downloadData.virtualPlaylistId, + imageUrl: downloadData.imageUrl, + startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime + }; + } + + const response = await fetch('/api/discover_downloads/snapshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + downloads: cleanDownloads + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`✅ Discover download snapshot saved: ${downloadCount} downloads`); + } else { + console.error('❌ Failed to save discover download snapshot:', data.error); + } + + } catch (error) { + console.error('❌ Error saving discover download snapshot:', error); + } + }, 1000); // 1 second debounce +} + +async function hydrateDiscoverDownloadsFromSnapshot() { + /** + * Hydrates discover downloads from backend snapshot with live status. + * Called on page load to restore download state. + */ + try { + console.log('🔄 Loading discover download snapshot from backend...'); + + const response = await fetch('/api/discover_downloads/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load discover download snapshot:', data.error); + return; + } + + const downloads = data.downloads || {}; + const stats = data.stats || {}; + + console.log(`🔄 Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); + + if (Object.keys(downloads).length === 0) { + console.log('ℹ️ No discover downloads to hydrate'); + return; + } + + // Clear existing state + discoverDownloads = {}; + + // Restore discoverDownloads with hydrated data + for (const [playlistId, downloadData] of Object.entries(downloads)) { + discoverDownloads[playlistId] = { + name: downloadData.name, + type: downloadData.type, + status: downloadData.status, // Live status from backend + virtualPlaylistId: downloadData.virtualPlaylistId, + imageUrl: downloadData.imageUrl, + startTime: new Date(downloadData.startTime) + }; + + console.log(`🔄 Hydrated download: ${downloadData.name} (${downloadData.status})`); + + // Start monitoring for any in-progress downloads + if (downloadData.status === 'in_progress') { + console.log(`📡 Starting monitoring for: ${downloadData.name}`); + monitorDiscoverDownload(playlistId); + } + } + + // Don't update UI here - it will be updated when user navigates to discover page + // This allows hydration to work even if page loads on a different tab + + const totalDownloads = Object.keys(discoverDownloads).length; + console.log(`✅ Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`); + + } catch (error) { + console.error('❌ Error hydrating discover downloads from snapshot:', error); + } +} + +// Initialize on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar); +} else { + initializeDiscoverDownloadBar(); +} + +// ============================================================================ + diff --git a/webui/static/docs.js b/webui/static/docs.js index c7d8e0dc..38220ffb 100644 --- a/webui/static/docs.js +++ b/webui/static/docs.js @@ -420,7 +420,7 @@ const DOCS_SECTIONS = [

How to: Set Up Auto-Downloads

Goal: Automatically download new releases from your favorite artists without manual intervention.

    -
  1. Add artists to your Watchlist — Search for artists on the Artists page and click the Watch button on each one
  2. +
  3. Add artists to your Watchlist — Find artists via the Search page (or click an artist anywhere in the app), then click the Watch button on the artist detail page
  4. Go to Automations — The built-in "Auto-Scan Watchlist" automation checks for new releases every 24 hours
  5. Enable "Auto-Process Wishlist" — This automation picks up new releases found by the scan and downloads them every 30 minutes
  6. Done! — New releases from watched artists are automatically found, queued, downloaded, tagged, and added to your library
  7. diff --git a/webui/static/downloads.js b/webui/static/downloads.js new file mode 100644 index 00000000..30a9d2bd --- /dev/null +++ b/webui/static/downloads.js @@ -0,0 +1,6039 @@ +// WING IT — Download without metadata discovery +// ================================================================================== + +function _toggleWingItDropdown(btn, urlHash) { + // Remove any existing dropdown + const existing = document.querySelector('.wing-it-dropdown.visible'); + if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; } + + const wrap = btn.closest('.wing-it-wrap'); + if (!wrap) return; + + const dropdown = document.createElement('div'); + dropdown.className = 'wing-it-dropdown'; + dropdown.innerHTML = ` + + + `; + + dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { + item.addEventListener('click', () => { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + const action = item.dataset.action; + if (action === 'download') { + _wingItAction(urlHash, 'download'); + } else { + _wingItAction(urlHash, 'sync'); + } + }); + }); + + // Flip dropdown direction if button is in the top portion of viewport + const btnRect = btn.getBoundingClientRect(); + if (btnRect.top < 200) dropdown.classList.add('flip-down'); + + wrap.appendChild(dropdown); + requestAnimationFrame(() => dropdown.classList.add('visible')); + + // Close on outside click + setTimeout(() => { + const closeHandler = e => { + if (!dropdown.contains(e.target) && e.target !== btn) { + dropdown.classList.remove('visible'); + setTimeout(() => dropdown.remove(), 150); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 50); +} + +function _wingItAction(urlHash, action) { + if (urlHash) { + // Called from a modal — use _wingItFromModal logic + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; + const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; + const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; + const isTidal = state.is_tidal_playlist; + const isLB = state.is_listenbrainz_playlist; + const isBeatport = state.is_beatport_playlist; + const isDeezer = state.is_deezer_playlist; + const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; + + if (!tracks.length) { + showToast('No tracks available for Wing It', 'error'); + return; + } + + if (action === 'sync') { + // Sync inline — keep modal open + _wingItSyncFromModal(urlHash, tracks, name, isLB); + } else { + // Download — close modal, open download modal + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) modal.remove(); + const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); + if (overlay) overlay.remove(); + wingItDownload(tracks, name, source, null, true); + } + } +} + +async function _wingItSyncFromModal(urlHash, tracks, name, isLB) { + showToast('Starting Wing It sync...', 'info'); + updateYouTubeModalButtons(urlHash, 'syncing'); + + try { + const syncTracks = tracks.map((t, i) => { + let artists = t.artists || []; + if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; + return { + id: t.id || t.source_track_id || `wing_it_${i}`, + name: t.name || t.track_name || 'Unknown', + artists: artists.map(a => typeof a === 'string' ? { name: a } : a), + album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, + duration_ms: t.duration_ms || 0, + }; + }); + + const res = await fetch('/api/wing-it/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) + }); + const data = await res.json(); + + if (data.error) { + showToast(`Sync failed: ${data.error}`, 'error'); + updateYouTubeModalButtons(urlHash, 'discovered'); + return; + } + + if (isLB) { + const state = listenbrainzPlaylistStates[urlHash]; + if (state) state.syncPlaylistId = data.sync_playlist_id; + startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); + } else { + startYouTubeSyncPolling(urlHash, data.sync_playlist_id); + } + } catch (e) { + showToast('Sync failed: ' + e.message, 'error'); + updateYouTubeModalButtons(urlHash, 'discovered'); + } +} + +async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null, skipConfirm = false) { + if (!tracks || tracks.length === 0) { + showToast('No tracks to download', 'error'); + return; + } + + if (!skipConfirm) { + // Show choice: Download or Sync (for LB card button which doesn't have dropdown) + const choice = await _showWingItChoiceDialog(tracks.length, source); + if (!choice) return; + + if (choice === 'sync') { + await _wingItSync(tracks, playlistName, source, cardIdentifier); + return; + } + } + + // Normalize tracks to Spotify-compatible format + const formattedTracks = tracks.map(t => { + // Handle various artist formats + let artists = []; + if (t.artists) { + if (Array.isArray(t.artists)) { + artists = t.artists.map(a => typeof a === 'string' ? { name: a } : a); + } else if (typeof t.artists === 'string') { + artists = [{ name: t.artists }]; + } + } else if (t.artist_name) { + artists = [{ name: t.artist_name }]; + } else if (t.artist) { + artists = [{ name: t.artist }]; + } + if (artists.length === 0) artists = [{ name: 'Unknown' }]; + + // Handle album + let album = { name: '' }; + if (t.album) { + album = typeof t.album === 'string' ? { name: t.album } : t.album; + } else if (t.album_name) { + album = { name: t.album_name }; + } + + return { + id: t.id || t.source_track_id || `wing_it_${Date.now()}_${Math.random()}`, + name: t.name || t.track_name || 'Unknown Track', + artists: artists, + duration_ms: t.duration_ms || 0, + album: album, + }; + }); + + const virtualPlaylistId = `wing_it_${Date.now()}`; + + // Store wing_it flag BEFORE opening the modal + youtubePlaylistStates[virtualPlaylistId] = { + wing_it: true, + tracks: formattedTracks, + }; + + await openDownloadMissingModalForYouTube(virtualPlaylistId, `⚡ ${playlistName}`, formattedTracks); + + // Pre-check the Force Download toggle + setTimeout(() => { + const forceToggle = document.getElementById(`force-download-all-${virtualPlaylistId}`); + if (forceToggle && !forceToggle.checked) forceToggle.checked = true; + }, 800); +} + +function _showWingItChoiceDialog(trackCount, source) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = '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;'; + const close = val => { overlay.remove(); resolve(val); }; + overlay.onclick = e => { if (e.target === overlay) close(null); }; + + overlay.innerHTML = ` +
    +
    +

    ⚡ Wing It

    + +
    +

    ${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery — uses raw names. Failed tracks won't be added to wishlist.

    +
    + + +
    +
    + `; + + overlay.querySelectorAll('.smart-delete-option').forEach(btn => { + btn.addEventListener('click', () => close(btn.dataset.choice)); + }); + overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); + const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); close(null); } }; + document.addEventListener('keydown', escH); + document.body.appendChild(overlay); + }); +} + +async function _wingItSync(tracks, playlistName, source, cardIdentifier = null) { + try { + showToast('Syncing playlist to server...', 'info'); + + // Format tracks for the sync endpoint + const syncTracks = tracks.map((t, i) => { + let artists = t.artists || []; + if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; + return { + id: t.id || t.source_track_id || `wing_it_${i}`, + name: t.name || t.track_name || 'Unknown', + artists: artists.map(a => typeof a === 'string' ? { name: a } : a), + album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, + duration_ms: t.duration_ms || 0, + artist_name: t.artist_name, + }; + }); + + const res = await fetch('/api/wing-it/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName }) + }); + const data = await res.json(); + + if (data.error) { + showToast(`Sync failed: ${data.error}`, 'error'); + return; + } + + // Show inline sync status on the card (same display as normal sync) + const playlistId = cardIdentifier ? `discover-lb-playlist-${cardIdentifier}` : null; + if (playlistId) { + const statusDisplay = document.getElementById(`${playlistId}-sync-status`); + if (statusDisplay) statusDisplay.style.display = 'block'; + // Disable sync/wing-it buttons during sync + const syncBtn = document.getElementById(`${playlistId}-sync-btn`); + if (syncBtn) { syncBtn.disabled = true; syncBtn.style.opacity = '0.5'; } + } + + // Poll for sync progress — update inline display + if (data.sync_playlist_id) { + _pollWingItSyncProgress(data.sync_playlist_id, playlistName, playlistId); + } + + } catch (e) { + showToast('Sync failed: ' + e.message, 'error'); + } +} + +function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) { + const poll = setInterval(async () => { + try { + const res = await fetch(`/api/sync/status/${syncPlaylistId}`); + const data = await res.json(); + + // Update inline status display if we have a card + if (cardPlaylistId && data.progress) { + const p = data.progress; + const total = p.total_tracks || p.total || 0; + const matched = p.matched_tracks || p.matched || 0; + const failed = p.failed_tracks || p.failed || 0; + const totalEl = document.getElementById(`${cardPlaylistId}-sync-total`); + const matchedEl = document.getElementById(`${cardPlaylistId}-sync-matched`); + const failedEl = document.getElementById(`${cardPlaylistId}-sync-failed`); + const pctEl = document.getElementById(`${cardPlaylistId}-sync-percentage`); + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + if (pctEl) pctEl.textContent = total > 0 ? Math.round((matched / total) * 100) : 0; + } + + if (data.status === 'finished' || data.status === 'complete' || data.status === 'error') { + clearInterval(poll); + const matched = data.progress?.matched_tracks || data.progress?.matched || 0; + const total = data.progress?.total_tracks || data.progress?.total || 0; + + if (data.status === 'error') { + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } else { + showToast(`⚡ Wing It sync complete — "${playlistName}" created on server (${matched}/${total} tracks matched)`, 'success'); + } + + // Update card status display to show completion + if (cardPlaylistId) { + const statusLabel = document.querySelector(`#${cardPlaylistId}-sync-status .sync-status-label span:last-child`); + if (statusLabel) statusLabel.textContent = `Sync complete — ${matched}/${total} matched`; + const syncIcon = document.querySelector(`#${cardPlaylistId}-sync-status .sync-icon`); + if (syncIcon) syncIcon.textContent = '✓'; + } + } + } catch (e) { /* ignore poll errors */ } + }, 2000); + + // Safety timeout + setTimeout(() => clearInterval(poll), 180000); +} + +async function _wingItFromModal(urlHash) { + // Extract tracks from the discovery modal state — tracks can be in various locations + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; + const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; + const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; + const isTidal = state.is_tidal_playlist; + const isLB = state.is_listenbrainz_playlist; + const isBeatport = state.is_beatport_playlist; + const isDeezer = state.is_deezer_playlist; + const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; + + if (!tracks.length) { + showToast('No tracks available for Wing It', 'error'); + return; + } + + const choice = await _showWingItChoiceDialog(tracks.length, source); + if (!choice) return; + + if (choice === 'sync') { + // Sync inline — keep modal open, show progress in modal + showToast('Starting Wing It sync...', 'info'); + updateYouTubeModalButtons(urlHash, 'syncing'); + + try { + // Format and send sync request + const syncTracks = tracks.map((t, i) => { + let artists = t.artists || []; + if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; + return { + id: t.id || t.source_track_id || `wing_it_${i}`, + name: t.name || t.track_name || 'Unknown', + artists: artists.map(a => typeof a === 'string' ? { name: a } : a), + album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, + duration_ms: t.duration_ms || 0, + }; + }); + + const res = await fetch('/api/wing-it/sync', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) + }); + const data = await res.json(); + + if (data.error) { + showToast(`Sync failed: ${data.error}`, 'error'); + updateYouTubeModalButtons(urlHash, 'discovered'); + return; + } + + // Use the same sync polling as normal sync — works for any source + if (isLB) { + if (state) state.syncPlaylistId = data.sync_playlist_id; + startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); + } else { + startYouTubeSyncPolling(urlHash, data.sync_playlist_id); + } + } catch (e) { + showToast('Sync failed: ' + e.message, 'error'); + updateYouTubeModalButtons(urlHash, 'discovered'); + } + return; + } + + // choice === 'download' — close modal and open download modal + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) modal.remove(); + const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); + if (overlay) overlay.remove(); + + wingItDownload(tracks, name, source); +} + +async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) { + showLoadingOverlay('Loading YouTube playlist...'); + // Check if a process is already active for this virtual playlist + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + } + hideLoadingOverlay(); // Hide overlay when reopening existing modal + return; + } + + console.log(`📥 Opening Download Missing Tracks modal for YouTube playlist: ${virtualPlaylistId}`); + + // Create virtual playlist object for compatibility with existing modal logic + const virtualPlaylist = { + id: virtualPlaylistId, + name: playlistName, + track_count: spotifyTracks.length + }; + + // Store the tracks in the cache for the modal to use + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + currentPlaylistTracks = spotifyTracks; + currentModalPlaylistId = virtualPlaylistId; + + let modal = document.createElement('div'); + modal.id = `download-missing-modal-${virtualPlaylistId}`; + modal.className = 'download-missing-modal'; + modal.style.display = 'none'; + document.body.appendChild(modal); + + // Register the new process in our global state tracker using the same structure as Spotify + activeDownloadProcesses[virtualPlaylistId] = { + status: 'idle', + modalElement: modal, + poller: null, + batchId: null, + playlist: virtualPlaylist, + tracks: spotifyTracks, + artist: artist, // ✅ Store artist context + album: album // ✅ Store album context + }; + + // Generate hero section with dynamic source detection + const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : + virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : + virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : + virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : + virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : + virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : + virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : + virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : + virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : + virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : + virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : + 'YouTube'; + + // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) + if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_') || virtualPlaylistId.startsWith('wing_it_')) { + // Extract image URL from album context or first track's album cover + let imageUrl = null; + if (album && album.images && album.images.length > 0) { + imageUrl = album.images[0].url; + } else if (spotifyTracks && spotifyTracks.length > 0) { + const firstTrack = spotifyTracks[0]; + if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { + imageUrl = firstTrack.album.images[0].url; + } + } + // Store in process for later use when Begin Analysis is clicked + activeDownloadProcesses[virtualPlaylistId].discoverMetadata = { + imageUrl: imageUrl, + type: album ? 'album' : 'playlist' // ✅ Use 'album' if album context provided + }; + } + + // CRITICAL FIX: Use album context for discover_album playlists + const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('discover_cache_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_'); + const heroContext = isDiscoverAlbum && album && artist ? { + type: 'album', + artist: { + name: artist.name, + image_url: artist.image_url || null + }, + album: { + name: album.name, + album_type: album.album_type || 'album', + images: album.images || [] + }, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + } : { + type: 'playlist', + playlist: { name: playlistName, owner: source }, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + }; + + // Use the exact same modal HTML structure as the existing Spotify modal + modal.innerHTML = ` +
    +
    + ${generateDownloadModalHeroSection(heroContext)} +
    + +
    +
    +
    +
    + 🔍 Library Analysis + Ready to start +
    +
    +
    +
    +
    +
    +
    + ⏬ Downloads + Waiting for analysis +
    +
    +
    +
    +
    +
    + +
    +
    +

    📋 Track Analysis & Download Status

    + ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected +
    +
    + + + + + + + + + + + + + + + ${spotifyTracks.map((track, index) => ` + + + + + + + + + + + `).join('')} + +
    + + #TrackArtistDurationLibrary MatchDownload StatusActions
    + + ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
    +
    +
    +
    + + +
    + `; + + applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); + modal.style.display = 'flex'; + hideLoadingOverlay(); +} + +function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, playlistId) { + if (!artistName) return; + // Close the download modal + if (playlistId) closeDownloadMissingModal(playlistId); + navigateToArtistDetail(artistId || artistName, artistName, source || null); +} + +async function closeDownloadMissingModal(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) { + // If somehow called without a process, try to find and remove the element + const modal = document.getElementById(`download-missing-modal-${playlistId}`); + if (modal && modal.parentElement) { + modal.parentElement.removeChild(modal); + } + return; + } + + // If the process is running, just hide the modal. + // If it's idle, complete, or cancelled, perform a full cleanup. + if (process.status === 'running') { + console.log(`Hiding active download modal for playlist ${playlistId}.`); + process.modalElement.style.display = 'none'; + + // Track wishlist modal state changes + if (playlistId === 'wishlist') { + WishlistModalState.setUserClosed(); // User manually closed during processing + console.log('📱 [Modal State] User manually closed wishlist modal during processing'); + } + } else { + console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`); + + // Reset YouTube playlist phase to 'discovered' when modal is closed after completion + if (playlistId.startsWith('youtube_')) { + const urlHash = playlistId.replace('youtube_', ''); + updateYouTubeCardPhase(urlHash, 'discovered'); + // Also update mirrored playlist card if applicable + if (urlHash.startsWith('mirrored_')) { + updateMirroredCardPhase(urlHash, 'discovered'); + } + + // Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix) + try { + const response = await fetch(`/api/youtube/update_phase/${urlHash}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + phase: 'discovered' + }) + }); + + if (response.ok) { + console.log(`✅ [Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`); + } else { + console.warn(`⚠️ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`); + } + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error); + } + } + + // Reset Beatport chart phase to 'discovered' when modal is closed + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + + if (state && state.is_beatport_playlist) { + console.log(`🧹 [Modal Close] Processing Beatport chart close: playlistId="${playlistId}", urlHash="${urlHash}"`); + + const chartHash = state.beatport_chart_hash || urlHash; + + // Reset to discovered phase (unless download actually started and completed) + if (state.phase !== 'download_complete') { + updateBeatportCardPhase(chartHash, 'discovered'); + state.phase = 'discovered'; + + // Update Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'discovered'; + } + + // Update backend state + try { + await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`✅ [Modal Close] Updated backend phase for Beatport chart ${chartHash} to 'discovered'`); + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for Beatport chart ${chartHash}:`, error); + } + } + } + } + + // Enhanced Tidal playlist state management (based on GUI sync.py patterns) + if (playlistId.startsWith('tidal_')) { + const tidalPlaylistId = playlistId.replace('tidal_', ''); + + console.log(`🧹 [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`); + console.log(`🧹 [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]); + + // Clear download-specific state but preserve discovery results (like GUI closeEvent) + if (tidalPlaylistStates[tidalPlaylistId]) { + const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase; + console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); + + // Preserve discovery data for future use (like GUI modal behavior) + const preservedData = { + playlist: tidalPlaylistStates[tidalPlaylistId].playlist, + discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results, + spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches, + discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, + convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId + }; + + // Clear download-specific state + delete tidalPlaylistStates[tidalPlaylistId].download_process_id; + delete tidalPlaylistStates[tidalPlaylistId].phase; + + // Restore preserved data and set to discovered phase + Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); + tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; + + console.log(`🧹 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`); + console.log(`🧹 [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`); + } else { + console.error(`❌ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`); + } + + updateTidalCardPhase(tidalPlaylistId, 'discovered'); + console.log(`🔄 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`); + console.log(`📝 [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`); + + // Update backend state to prevent rehydration issues on page refresh + try { + const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + phase: 'discovered' + }) + }); + + if (response.ok) { + console.log(`✅ [Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`); + } else { + console.warn(`⚠️ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`); + } + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error); + } + } + + // Reset ListenBrainz playlist phase to 'discovered' when modal is closed + if (playlistId.startsWith('listenbrainz_')) { + const playlistMbid = playlistId.replace('listenbrainz_', ''); + + console.log(`🧹 [Modal Close] Processing ListenBrainz playlist close: playlistId="${playlistId}", mbid="${playlistMbid}"`); + + // Clear download-specific state but preserve discovery results + if (listenbrainzPlaylistStates[playlistMbid]) { + const currentPhase = listenbrainzPlaylistStates[playlistMbid].phase; + console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); + + // Reset to discovered phase (unless download actually completed successfully) + if (currentPhase !== 'download_complete') { + // Clear download-specific fields + delete listenbrainzPlaylistStates[playlistMbid].download_process_id; + delete listenbrainzPlaylistStates[playlistMbid].convertedSpotifyPlaylistId; + + // Set back to discovered + listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; + + // Update backend state + try { + await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`✅ [Modal Close] Updated backend phase for ListenBrainz playlist ${playlistMbid} to 'discovered'`); + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for ListenBrainz playlist ${playlistMbid}:`, error); + } + + console.log(`🔄 [Modal Close] Reset ListenBrainz playlist ${playlistMbid} to discovered phase`); + } + } else { + console.error(`❌ [Modal Close] No ListenBrainz state found for mbid: ${playlistMbid}`); + } + } + + // Reset Spotify Public playlist phase to 'discovered' when modal is closed + if (playlistId.startsWith('spotify_public_')) { + const spUrlHash = playlistId.replace('spotify_public_', ''); + + console.log(`🧹 [Modal Close] Processing Spotify Public playlist close: playlistId="${playlistId}", urlHash="${spUrlHash}"`); + + if (spotifyPublicPlaylistStates[spUrlHash]) { + const currentPhase = spotifyPublicPlaylistStates[spUrlHash].phase; + console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); + + const preservedData = { + playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, + discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, + spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, + discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, + convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId + }; + + delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; + delete spotifyPublicPlaylistStates[spUrlHash].phase; + + Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); + spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; + + console.log(`🧹 [Modal Close] Reset Spotify Public playlist ${spUrlHash} - cleared download state, preserved discovery data`); + } + + updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); + + try { + await fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`✅ [Modal Close] Updated backend phase for Spotify Public playlist ${spUrlHash} to 'discovered'`); + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for Spotify Public playlist ${spUrlHash}:`, error); + } + } + + // Reset Deezer playlist phase to 'discovered' when modal is closed + if (playlistId.startsWith('deezer_')) { + const deezerPlaylistId = playlistId.replace('deezer_', ''); + + console.log(`🧹 [Modal Close] Processing Deezer playlist close: playlistId="${playlistId}", deezerPlaylistId="${deezerPlaylistId}"`); + + if (deezerPlaylistStates[deezerPlaylistId]) { + const currentPhase = deezerPlaylistStates[deezerPlaylistId].phase; + console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); + + const preservedData = { + playlist: deezerPlaylistStates[deezerPlaylistId].playlist, + discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results, + spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches, + discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress, + convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId + }; + + delete deezerPlaylistStates[deezerPlaylistId].download_process_id; + delete deezerPlaylistStates[deezerPlaylistId].phase; + + Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); + deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; + + console.log(`🧹 [Modal Close] Reset Deezer playlist ${deezerPlaylistId} - cleared download state, preserved discovery data`); + } + + updateDeezerCardPhase(deezerPlaylistId, 'discovered'); + + try { + await fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log(`✅ [Modal Close] Updated backend phase for Deezer playlist ${deezerPlaylistId} to 'discovered'`); + } catch (error) { + console.error(`❌ [Modal Close] Error updating backend phase for Deezer playlist ${deezerPlaylistId}:`, error); + } + } + + // Clear wishlist modal state when modal is fully closed + if (playlistId === 'wishlist') { + WishlistModalState.clear(); // Clear all tracking since modal is fully closed + console.log('📱 [Modal State] Cleared wishlist modal state on full close'); + } + + // Clean up artist download if this is an artist album playlist + if (playlistId.startsWith('artist_album_')) { + console.log(`🧹 [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`); + cleanupArtistDownload(playlistId); + console.log(`✅ [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`); + } + + // Clean up search download if this is an enhanced search playlist + if (playlistId.startsWith('enhanced_search_')) { + console.log(`🧹 [MODAL CLOSE] Cleaning up search download for completed modal: ${playlistId}`); + cleanupSearchDownload(playlistId); + console.log(`✅ [MODAL CLOSE] Search download cleanup completed for: ${playlistId}`); + } + + // Clean up Beatport download if this is a beatport chart or release playlist + if (playlistId.startsWith('beatport_chart_') || playlistId.startsWith('beatport_release_')) { + console.log(`🧹 [MODAL CLOSE] Cleaning up Beatport download for completed modal: ${playlistId}`); + cleanupBeatportDownload(playlistId); + console.log(`✅ [MODAL CLOSE] Beatport download cleanup completed for: ${playlistId}`); + } + + // Remove from discover download sidebar if this is a discover page download + if (discoverDownloads && discoverDownloads[playlistId]) { + console.log(`🧹 [MODAL CLOSE] Removing discover download bubble: ${playlistId}`); + removeDiscoverDownload(playlistId); + console.log(`✅ [MODAL CLOSE] Discover download bubble removed for: ${playlistId}`); + } + + // Automatic cleanup and server operations after successful downloads + await handlePostDownloadAutomation(playlistId, process); + + cleanupDownloadProcess(playlistId); + } +} + +/** + * Extract unique album cover images from tracks + */ +function extractUniqueCoverImages(tracks, maxCovers = 20) { + const uniqueCovers = new Set(); + const covers = []; + + for (const track of tracks) { + if (covers.length >= maxCovers) break; + + let coverUrl = null; + let spotifyData = track.spotify_data; + + // Parse spotify_data if it's a string + if (typeof spotifyData === 'string') { + try { + spotifyData = JSON.parse(spotifyData); + } catch (e) { + continue; + } + } + + // Extract cover URL + coverUrl = spotifyData?.album?.images?.[0]?.url; + + // Add to list if unique and valid + if (coverUrl && !uniqueCovers.has(coverUrl)) { + uniqueCovers.add(coverUrl); + covers.push(coverUrl); + } + } + + return covers; +} + +/** + * Shuffle array using Fisher-Yates algorithm + */ +function shuffleArray(array) { + const shuffled = [...array]; + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; + } + return shuffled; +} + +/** + * Generate mosaic grid background HTML with continuous scrolling rows + */ +function generateMosaicBackground(coverUrls) { + // If less than 3 covers, use gradient fallback + if (!coverUrls || coverUrls.length < 3) { + return ` +
    +
    + `; + } + + // Cap covers per row to 15 for GPU performance (avoids hundreds of tiles) + if (coverUrls.length > 15) { + coverUrls = coverUrls.slice(0, 15); + } + + const rows = 4; + let mosaicHTML = '
    '; + + // Calculate scroll speed based on number of images + // More images = longer duration to maintain consistent visual speed + // Minimum 40s to prevent scrolling too fast + const scrollSpeed = Math.max(40, coverUrls.length * 2); + + for (let row = 0; row < rows; row++) { + const isEvenRow = row % 2 === 0; + const direction = isEvenRow ? 'left' : 'right'; + + // Randomize order for each row + const shuffledCovers = shuffleArray(coverUrls); + + // Create row wrapper + mosaicHTML += `
    `; + mosaicHTML += `
    `; + + // Generate tiles - duplicate 2 times for smooth infinite scroll + for (let duplicate = 0; duplicate < 2; duplicate++) { + for (let i = 0; i < shuffledCovers.length; i++) { + const coverUrl = shuffledCovers[i]; + mosaicHTML += ` +
    +
    +
    + `; + } + } + + mosaicHTML += '
    '; // Close row + mosaicHTML += '
    '; // Close wrapper + } + + mosaicHTML += '
    '; + mosaicHTML += '
    '; // Dark overlay for readability + + return mosaicHTML; +} + +/** + * Open wishlist overview modal showing category breakdown + * This is the NEW entry point for wishlist from dashboard + */ +async function openWishlistOverviewModal() { + try { + showLoadingOverlay('Loading wishlist...'); + + // Fetch wishlist stats + const statsResponse = await fetch('/api/wishlist/stats'); + const statsData = await statsResponse.json(); + + if (!statsResponse.ok) { + throw new Error(statsData.error || 'Failed to fetch wishlist stats'); + } + + const { singles, albums, total } = statsData; + + if (total === 0) { + hideLoadingOverlay(); + showToast('Wishlist is empty. No tracks to process.', 'info'); + return; + } + + // Fetch album covers for mosaic backgrounds + // Limit to 50 tracks per category (enough to get 20 unique covers while being efficient) + const albumCoversPromise = fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json()); + const singleCoversPromise = fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json()); + + const [albumTracksData, singleTracksData] = await Promise.all([albumCoversPromise, singleCoversPromise]); + + // Extract unique album covers (max 20 per category) + const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20); + const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20); + + // Create modal if it doesn't exist + let modal = document.getElementById('wishlist-overview-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'wishlist-overview-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + // Fetch current cycle + const cycleResponse = await fetch('/api/wishlist/cycle'); + const cycleData = await cycleResponse.json(); + const currentCycle = cycleData.cycle || 'albums'; + + // Format countdown timer + const nextRunSeconds = statsData.next_run_in_seconds || 0; + const countdownText = formatCountdownTime(nextRunSeconds); + const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; + + modal.innerHTML = ` + + `; + + modal.style.display = 'flex'; + hideLoadingOverlay(); + + // Start countdown timer update interval + startWishlistCountdownTimer(currentCycle, nextRunSeconds); + + } catch (error) { + console.error('Error opening wishlist overview:', error); + showToast(`Failed to load wishlist: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +function startWishlistCountdownTimer(currentCycle, initialSeconds) { + // Clear any existing interval + if (wishlistCountdownInterval) { + clearInterval(wishlistCountdownInterval); + } + + let remainingSeconds = initialSeconds; + const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; + + wishlistCountdownInterval = setInterval(async () => { + remainingSeconds--; + + // Check if auto-processing has started (every 2 seconds to avoid overwhelming backend) + if (remainingSeconds % 2 === 0 || remainingSeconds <= 0) { + // Use WebSocket data if available, otherwise fall back to HTTP + if (socketConnected && _lastWishlistStats) { + const data = _lastWishlistStats; + if (data.is_auto_processing) { + if (!_wishlistAutoProcessingNotified) { + navigateToPage('active-downloads'); + showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); + _wishlistAutoProcessingNotified = true; + } + return; + } + if (remainingSeconds <= 0) { + remainingSeconds = data.next_run_in_seconds || 0; + const timerElement = document.getElementById('wishlist-next-auto-timer'); + if (timerElement) { + const countdownText = formatCountdownTime(remainingSeconds); + timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; + } + } + } else { + try { + const response = await fetch('/api/wishlist/stats'); + const data = await response.json(); + + // AUTO-CLOSE DETECTION: If auto-processing started, close modal and notify user (once) + if (data.is_auto_processing) { + if (!_wishlistAutoProcessingNotified) { + console.log('🤖 [Wishlist] Auto-processing detected, closing overview modal'); + closeWishlistOverviewModal(); + showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); + _wishlistAutoProcessingNotified = true; + } + return; // Exit interval + } + + // Update remaining seconds if timer expired + if (remainingSeconds <= 0) { + remainingSeconds = data.next_run_in_seconds || 0; + + // Also update cycle in case it changed + const newCycle = data.current_cycle || 'albums'; + const newCycleText = newCycle === 'albums' ? 'Albums/EPs' : 'Singles'; + + const timerElement = document.getElementById('wishlist-next-auto-timer'); + if (timerElement) { + const countdownText = formatCountdownTime(remainingSeconds); + timerElement.textContent = `Next Auto: ${newCycleText}${countdownText ? ' in ' + countdownText : ''}`; + } + } + } catch (error) { + console.debug('Error updating wishlist countdown:', error); + } + } // end else (HTTP fallback) + } + + // Always update the display countdown + const timerElement = document.getElementById('wishlist-next-auto-timer'); + if (timerElement) { + const countdownText = formatCountdownTime(remainingSeconds); + timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; + } + }, 1000); // Update every second +} + +function closeWishlistOverviewModal() { + console.log('🚪 closeWishlistOverviewModal() called'); + + // Stop countdown timer + if (wishlistCountdownInterval) { + clearInterval(wishlistCountdownInterval); + wishlistCountdownInterval = null; + } + + const modal = document.getElementById('wishlist-overview-modal'); + console.log('Modal element:', modal); + if (modal) { + modal.style.display = 'none'; + console.log('Modal display set to none'); + // Also remove from DOM to ensure clean state + modal.remove(); + console.log('Modal removed from DOM'); + } else { + console.warn('Modal element not found'); + } + window.selectedWishlistCategory = null; + console.log('✅ Modal closed'); +} + +async function cleanupWishlistOverview() { + console.log('🧹 cleanupWishlistOverview() called'); + + if (!await showConfirmDialog({ title: 'Cleanup Wishlist', message: 'This will remove all tracks from the wishlist that already exist in your library. Continue?' })) { + return; + } + + try { + showLoadingOverlay('Cleaning up wishlist...'); + + const response = await fetch('/api/wishlist/cleanup', { + method: 'POST' + }); + + const result = await response.json(); + + if (result.success) { + const removedCount = result.removed_count || 0; + + if (removedCount > 0) { + showToast(`Cleanup complete! Removed ${removedCount} tracks that already exist in your library`, 'success'); + } else { + showToast('No tracks needed to be removed', 'info'); + } + + // Check if wishlist is now empty + const statsResponse = await fetch('/api/wishlist/stats'); + const statsData = await statsResponse.json(); + + if (statsData.total === 0) { + // Wishlist is empty, refresh the page to show empty state + wishlistPageState.isInitialized = false; + await initializeWishlistPage(); + await updateWishlistCount(); + } else { + // Wishlist still has items, refresh the page to show updated counts + wishlistPageState.isInitialized = false; + await initializeWishlistPage(); + } + } else { + showToast(`Failed to cleanup wishlist: ${result.error || 'Unknown error'}`, 'error'); + } + + hideLoadingOverlay(); + + } catch (error) { + console.error('Error cleaning up wishlist:', error); + showToast(`Failed to cleanup wishlist: ${error.message}`, 'error'); + hideLoadingOverlay(); + } +} + +async function clearEntireWishlist() { + console.log('🗑️ clearEntireWishlist() called'); + + if (!await showConfirmDialog({ title: 'Clear Wishlist', message: 'WARNING: This will permanently delete ALL tracks from your wishlist.\n\nThis action cannot be undone.\n\nAre you sure you want to continue?', confirmText: 'Clear All', destructive: true })) { + console.log('User cancelled confirmation'); + return; + } + + console.log('User confirmed, proceeding with clear...'); + + try { + showLoadingOverlay('Clearing wishlist...'); + console.log('Loading overlay shown'); + + const response = await fetch('/api/wishlist/clear', { + method: 'POST' + }); + console.log('API response received:', response.status); + + const result = await response.json(); + console.log('Clear wishlist response:', result); + + hideLoadingOverlay(); + console.log('Loading overlay hidden'); + + if (result.success) { + console.log('Clear was successful, showing toast...'); + showToast('Wishlist cleared successfully', 'success'); + + console.log('Updating wishlist button count...'); + await updateWishlistCount(); + + console.log('Refreshing wishlist page...'); + wishlistPageState.isInitialized = false; + await initializeWishlistPage(); + } else { + console.error('Clear failed:', result.error); + showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error'); + } + + } catch (error) { + console.error('Error clearing wishlist:', error); + hideLoadingOverlay(); + showToast(`Failed to clear wishlist: ${error.message}`, 'error'); + } +} + +async function selectWishlistCategory(category) { + try { + window.selectedWishlistCategory = category; + + const tracksList = document.getElementById('wishlist-tracks-list'); + const categoryTracksSection = document.getElementById('wishlist-category-tracks'); + const nebulaEl = document.getElementById('wishlist-nebula'); + const downloadBtn = document.getElementById('wishlist-download-btn'); + const categoryName = document.getElementById('wishlist-category-name'); + + if (nebulaEl) nebulaEl.style.display = 'none'; + categoryTracksSection.style.display = 'block'; + downloadBtn.style.display = 'inline-block'; + categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles'; + + tracksList.innerHTML = '
    Loading tracks...
    '; + + const _wlPageSize = window._wlNextLimit || 200; + window._wlNextLimit = null; + const response = await fetch(`/api/wishlist/tracks?category=${category}&limit=${_wlPageSize}`); + const data = await response.json(); + + if (!response.ok) throw new Error(data.error || 'Failed to fetch tracks'); + + const tracks = data.tracks || []; + const totalAvailable = data.total || tracks.length; + window._wlCategory = category; + window._wlOffset = tracks.length; + window._wlTotal = totalAvailable; + + if (tracks.length === 0) { + tracksList.innerHTML = '
    No tracks in this category
    '; + return; + } + + // For Albums/EPs, group by album + if (category === 'albums') { + const albumGroups = {}; + + tracks.forEach(track => { + let spotifyData = track.spotify_data; + if (typeof spotifyData === 'string') { + try { + spotifyData = JSON.parse(spotifyData); + } catch (e) { + spotifyData = null; + } + } + + const rawAlbum = spotifyData?.album; + const albumName = (typeof rawAlbum === 'string' ? rawAlbum : rawAlbum?.name) || 'Unknown Album'; + + // Handle both object format {name: '...'} and sanitized string format + let artistName = 'Unknown Artist'; + let artistId = null; + if (spotifyData?.artists?.[0]?.name) { + // Object format from Spotify API + artistName = spotifyData.artists[0].name; + artistId = spotifyData.artists[0].id; + } else if (spotifyData?.artists?.[0] && typeof spotifyData.artists[0] === 'string') { + // Sanitized string format + artistName = spotifyData.artists[0]; + } else if (Array.isArray(track.artists) && track.artists.length > 0) { + // Fallback to track.artists + if (typeof track.artists[0] === 'string') { + artistName = track.artists[0]; + } else if (track.artists[0]?.name) { + artistName = track.artists[0].name; + artistId = track.artists[0].id; + } + } + + const albumImage = spotifyData?.album?.images?.[0]?.url || ''; + + // Use album ID if available, otherwise create unique key from album + artist + // Sanitize the ID to remove all special characters that could break DOM IDs or CSS selectors + const albumId = spotifyData?.album?.id || `${albumName}_${artistName}` + .replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove all special chars except spaces, underscores, hyphens + .replace(/\s+/g, '_') // Replace spaces with underscores + .toLowerCase(); + + if (!albumGroups[albumId]) { + albumGroups[albumId] = { + albumName, + artistName, + artistId, + albumImage, + tracks: [] + }; + } + + const spotifyTrackId = track.spotify_track_id || track.id || ''; + + albumGroups[albumId].tracks.push({ + name: track.name || 'Unknown Track', + artistName, + trackNumber: spotifyData?.track_number || 0, + spotifyTrackId + }); + }); + + // Render album cards + let albumsHTML = '
    '; + Object.entries(albumGroups).forEach(([albumId, albumData]) => { + // Sort tracks by track number + albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber); + + const tracksListHTML = albumData.tracks.map(track => ` +
    + + ${track.name} + +
    + `).join(''); + + // Handle missing album images with a placeholder + const albumImageStyle = albumData.albumImage + ? `background-image: url('${albumData.albumImage}')` + : `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`; + const albumImageContent = albumData.albumImage ? '' : '💿'; + + albumsHTML += ` +
    +
    + +
    ${albumImageContent}
    +
    +
    ${albumData.albumName}
    +
    ${albumData.artistName}
    +
    ${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}
    +
    + +
    +
    + +
    + `; + }); + albumsHTML += '
    '; + + tracksList.innerHTML = albumsHTML; + if (totalAvailable > tracks.length) { + tracksList.insertAdjacentHTML('beforeend', + ``); + } + _attachWishlistDelegation(tracksList); + + } else { + // For Singles, show list with album images + let tracksHTML = ''; + tracks.forEach((track, index) => { + const trackName = track.name || 'Unknown Track'; + + let spotifyData = track.spotify_data; + if (typeof spotifyData === 'string') { + try { + spotifyData = JSON.parse(spotifyData); + } catch (e) { + spotifyData = null; + } + } + + let artistName = 'Unknown Artist'; + if (spotifyData?.artists?.[0]?.name) { + artistName = spotifyData.artists[0].name; + } else if (Array.isArray(track.artists) && track.artists.length > 0) { + if (typeof track.artists[0] === 'string') { + artistName = track.artists[0]; + } else if (track.artists[0]?.name) { + artistName = track.artists[0].name; + } + } + + let albumName = 'Unknown Album'; + if (spotifyData?.album?.name) { + albumName = spotifyData.album.name; + } else if (typeof track.album === 'string') { + albumName = track.album; + } else if (track.album?.name) { + albumName = track.album.name; + } + + const albumImage = spotifyData?.album?.images?.[0]?.url || ''; + const spotifyTrackId = track.spotify_track_id || track.id || ''; + + tracksHTML += ` +
    + +
    +
    +
    ${trackName}
    +
    ${artistName} • ${albumName}
    +
    + +
    + `; + }); + + tracksList.innerHTML = tracksHTML; + if (totalAvailable > tracks.length) { + tracksList.insertAdjacentHTML('beforeend', + ``); + } + _attachWishlistDelegation(tracksList); + } + + } catch (error) { + console.error('Error loading category tracks:', error); + showToast(`Failed to load tracks: ${error.message}`, 'error'); + } +} + +async function loadMoreWishlistTracks() { + const btn = document.querySelector('.wishlist-load-more-btn'); + if (btn) { btn.textContent = 'Loading...'; btn.disabled = true; } + // Increase page size and reload + window._wlOffset = (window._wlOffset || 200) + 200; + // Override the page size for this reload + window._wlNextLimit = window._wlOffset; + selectWishlistCategory(window._wlCategory); +} + +function _attachWishlistDelegation(container) { + // Single click handler for all wishlist album/track interactions + container.addEventListener('click', (e) => { + const target = e.target; + + // Skip checkbox wrapper clicks — handled by change listener + if (target.closest('.wishlist-checkbox-wrapper')) return; + + // Album header click (expand/collapse) + const header = target.closest('.wishlist-album-header'); + if (header && !target.closest('.wishlist-delete-album-btn')) { + toggleAlbumTracks(header.dataset.albumId); + return; + } + + // Album delete button + const albumDelBtn = target.closest('.wishlist-delete-album-btn'); + if (albumDelBtn) { + e.stopPropagation(); + removeAlbumFromWishlist(albumDelBtn.dataset.albumId, e); + return; + } + + // Track delete button + const trackDelBtn = target.closest('.wishlist-delete-btn'); + if (trackDelBtn && trackDelBtn.dataset.trackId) { + e.stopPropagation(); + removeTrackFromWishlist(trackDelBtn.dataset.trackId, e); + return; + } + }); + + // Separate change handler for checkboxes (more reliable than click for inputs) + container.addEventListener('change', (e) => { + const target = e.target; + if (target.classList.contains('wishlist-album-select-all-cb')) { + toggleWishlistAlbumSelection(target.dataset.albumId, target.checked); + } else if (target.classList.contains('wishlist-select-cb')) { + updateWishlistBatchBar(); + } + }); +} + +function backToCategories() { + _nebulaBack(); +} + +function toggleAlbumTracks(albumId) { + const tracksElement = document.getElementById(`tracks-${albumId}`); + const expandIcon = document.getElementById(`expand-icon-${albumId}`); + + if (tracksElement.style.display === 'none') { + tracksElement.style.display = 'block'; + expandIcon.textContent = '▲'; + } else { + tracksElement.style.display = 'none'; + expandIcon.textContent = '▼'; + } +} + +/** + * Get all checked wishlist track checkboxes + */ +function getCheckedWishlistTracks() { + return Array.from(document.querySelectorAll('.wishlist-select-cb:checked')); +} + +/** + * Toggle select all / deselect all tracks in the current wishlist category + */ +function toggleWishlistSelectAll() { + const allCheckboxes = document.querySelectorAll('.wishlist-select-cb'); + const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb'); + const btn = document.getElementById('wishlist-select-all-btn'); + const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); + + const newState = !allChecked; + + allCheckboxes.forEach(cb => { cb.checked = newState; }); + albumCheckboxes.forEach(cb => { cb.checked = newState; }); + + // Expand all albums when selecting all + if (newState) { + document.querySelectorAll('.wishlist-album-tracks').forEach(el => { + el.style.display = 'block'; + }); + document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => { + icon.textContent = '▲'; + }); + } + + if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All'; + updateWishlistBatchBar(); +} + +/** + * Update the wishlist batch action bar based on checkbox selection + */ +function updateWishlistBatchBar() { + const checked = getCheckedWishlistTracks(); + const bar = document.getElementById('wishlist-batch-bar'); + const countEl = document.getElementById('wishlist-batch-count'); + + if (!bar || !countEl) return; + + if (checked.length > 0) { + bar.style.display = 'flex'; + countEl.textContent = `${checked.length} selected`; + } else { + bar.style.display = 'none'; + } + + // Sync the Select All button text + const btn = document.getElementById('wishlist-select-all-btn'); + if (btn) { + const allCheckboxes = document.querySelectorAll('.wishlist-select-cb'); + const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); + btn.textContent = allChecked ? 'Deselect All' : 'Select All'; + } +} + +/** + * Toggle all track checkboxes within an album when album header checkbox is clicked + */ +function toggleWishlistAlbumSelection(albumId, checked) { + const tracksContainer = document.getElementById(`tracks-${albumId}`); + if (tracksContainer) { + // Expand the album tracks if selecting + if (checked) { + tracksContainer.style.display = 'block'; + const expandIcon = document.getElementById(`expand-icon-${albumId}`); + if (expandIcon) expandIcon.textContent = '▲'; + } + tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => { + cb.checked = checked; + }); + } + updateWishlistBatchBar(); +} + +/** + * Batch remove selected tracks from wishlist + */ +async function batchRemoveFromWishlist() { + const checked = getCheckedWishlistTracks(); + if (checked.length === 0) return; + + const count = checked.length; + const confirmed = await showConfirmationModal( + 'Remove Tracks', + `Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`, + '🗑️' + ); + + if (!confirmed) return; + + const trackIds = checked.map(cb => cb.getAttribute('data-track-id')); + + try { + const response = await fetch('/api/wishlist/remove-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spotify_track_ids: trackIds }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Removed ${data.removed} track(s) from wishlist`, 'success'); + + // Reload the current category to refresh the list + if (window.selectedWishlistCategory) { + await selectWishlistCategory(window.selectedWishlistCategory); + } + + // Update wishlist count in sidebar + await updateWishlistCount(); + } else { + showToast(`Failed to remove tracks: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error batch removing from wishlist:', error); + showToast('Failed to remove tracks from wishlist', 'error'); + } +} + +function showConfirmationModal(title, message, icon = '⚠️') { + return new Promise((resolve) => { + // Create modal if it doesn't exist + let modal = document.getElementById('confirmation-modal-overlay'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'confirmation-modal-overlay'; + modal.className = 'confirmation-modal-overlay'; + document.body.appendChild(modal); + } + + // Set modal content + modal.innerHTML = ` +
    +
    ${icon}
    +
    ${title}
    +
    ${message}
    +
    + + +
    +
    + `; + + // Show modal with animation + setTimeout(() => { + modal.classList.add('show'); + }, 10); + + // Escape key handler - defined outside so we can remove it + const handleEscape = (e) => { + if (e.key === 'Escape') { + handleCancel(); + } + }; + + // Handle button clicks + const handleCancel = () => { + document.removeEventListener('keydown', handleEscape); + modal.classList.remove('show'); + setTimeout(() => { + modal.remove(); + }, 200); + resolve(false); + }; + + const handleConfirm = () => { + document.removeEventListener('keydown', handleEscape); + modal.classList.remove('show'); + setTimeout(() => { + modal.remove(); + }, 200); + resolve(true); + }; + + document.getElementById('confirm-cancel').addEventListener('click', handleCancel); + document.getElementById('confirm-yes').addEventListener('click', handleConfirm); + + // Close on overlay click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + handleCancel(); + } + }); + + // Add Escape key listener + document.addEventListener('keydown', handleEscape); + }); +} + +async function removeTrackFromWishlist(spotifyTrackId, event) { + // Stop event propagation to prevent triggering parent click handlers + if (event) { + event.stopPropagation(); + } + + const confirmed = await showConfirmationModal( + 'Remove Track', + 'Are you sure you want to remove this track from your wishlist?', + '🗑️' + ); + + if (!confirmed) { + return; + } + + try { + const response = await fetch('/api/wishlist/remove-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ spotify_track_id: spotifyTrackId }) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Track removed from wishlist', 'success'); + + // Reload the current category to refresh the list + if (window.selectedWishlistCategory) { + await selectWishlistCategory(window.selectedWishlistCategory); + } + + // Update wishlist count in sidebar + await updateWishlistCount(); + } else { + showToast(`Failed to remove track: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error removing track from wishlist:', error); + showToast('Failed to remove track from wishlist', 'error'); + } +} + +async function removeAlbumFromWishlist(albumId, event) { + // Stop event propagation to prevent triggering parent click handlers + if (event) { + event.stopPropagation(); + } + + const confirmed = await showConfirmationModal( + 'Remove Album', + 'Are you sure you want to remove all tracks from this album from your wishlist?', + '💿' + ); + + if (!confirmed) { + return; + } + + try { + const response = await fetch('/api/wishlist/remove-album', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_id: albumId }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Removed ${data.removed_count} track(s) from wishlist`, 'success'); + + // Reload the current category to refresh the list + if (window.selectedWishlistCategory) { + await selectWishlistCategory(window.selectedWishlistCategory); + } + + // Update wishlist count in sidebar + await updateWishlistCount(); + } else { + showToast(`Failed to remove album: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error removing album from wishlist:', error); + showToast('Failed to remove album from wishlist', 'error'); + } +} + +async function downloadSelectedCategory() { + const category = window.selectedWishlistCategory; + if (!category) { + showToast('No category selected', 'error'); + return; + } + + // Collect checked track IDs + const checkedBoxes = document.querySelectorAll('.wishlist-select-cb:checked'); + const selectedTrackIds = new Set(Array.from(checkedBoxes).map(cb => cb.dataset.trackId).filter(Boolean)); + + await openDownloadMissingWishlistModal(category, selectedTrackIds.size > 0 ? selectedTrackIds : null); +} + +async function openDownloadMissingWishlistModal(category = null, selectedTrackIds = null) { + showLoadingOverlay('Loading wishlist...'); + const playlistId = "wishlist"; // Use a consistent ID for wishlist + + // Check if a process is already active for the wishlist + if (activeDownloadProcesses[playlistId]) { + console.log(`Modal for wishlist already exists. Showing it.`); + const process = activeDownloadProcesses[playlistId]; + if (process.modalElement) { + // Show helpful message if it's a completed process + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + WishlistModalState.setVisible(); // Track that modal is now visible + } + hideLoadingOverlay(); // Always hide overlay before returning + return; // Don't create a new one + } + + console.log(`📥 Opening Download Missing Tracks modal for wishlist${category ? ' (' + category + ')' : ''}`); + + // Store category in global state for when process starts + window.currentWishlistCategory = category; + + // Fetch actual wishlist tracks from the server + let tracks; + try { + // Build API URL with optional category filter + const apiUrl = category ? `/api/wishlist/tracks?category=${category}` : '/api/wishlist/tracks'; + + const response = await fetch('/api/wishlist/count'); + const countData = await response.json(); + if (countData.count === 0) { + showToast('Wishlist is empty. No tracks to download.', 'info'); + hideLoadingOverlay(); + return; + } + + // Fetch the actual wishlist tracks for display (filtered by category if specified) + const tracksResponse = await fetch(apiUrl); + if (!tracksResponse.ok) { + throw new Error('Failed to fetch wishlist tracks'); + } + const tracksData = await tracksResponse.json(); + tracks = tracksData.tracks || []; + + // Filter to only selected tracks if user made a selection + if (selectedTrackIds && selectedTrackIds.size > 0) { + tracks = tracks.filter(t => selectedTrackIds.has(t.id) || selectedTrackIds.has(t.spotify_track_id)); + console.log(`📥 Filtered to ${tracks.length} selected tracks (from ${tracksData.tracks?.length || 0} total)`); + } + + } catch (error) { + showToast(`Failed to fetch wishlist data: ${error.message}`, 'error'); + hideLoadingOverlay(); + return; + } + + currentPlaylistTracks = tracks; + currentModalPlaylistId = playlistId; + + let modal = document.createElement('div'); + modal.id = `download-missing-modal-${playlistId}`; // Unique ID + modal.className = 'download-missing-modal'; // Use class for styling + modal.style.display = 'none'; // Start hidden + document.body.appendChild(modal); + + // Register the new process in our global state tracker + activeDownloadProcesses[playlistId] = { + status: 'idle', // idle, running, complete, cancelled + modalElement: modal, + poller: null, + batchId: null, + playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object + tracks: tracks + }; + + // Generate hero section for wishlist context + const heroContext = { + type: 'wishlist', + trackCount: tracks.length, + playlistId: playlistId + }; + + modal.innerHTML = ` +
    +
    + ${generateDownloadModalHeroSection(heroContext)} +
    + +
    +
    +
    +
    + 🔍 Library Analysis + Ready to start +
    +
    +
    +
    +
    +
    +
    + ⏬ Downloads + Waiting for analysis +
    +
    +
    +
    +
    +
    + +
    +
    +

    📋 Track Analysis & Download Status

    +
    +
    + + + + + + + + + + + + + ${tracks.map((track, index) => ` + + + + + + + + + `).join('')} + +
    #TrackArtistLibrary MatchDownload StatusActions
    ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}🔍 Pending--
    +
    +
    +
    + + +
    + `; + + applyProgressiveTrackRendering(playlistId, tracks.length); + modal.style.display = 'flex'; + hideLoadingOverlay(); + WishlistModalState.setVisible(); // Track that new wishlist modal is now visible +} + +async function startWishlistMissingTracksProcess(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) return; + + console.log(`🚀 Kicking off wishlist missing tracks process`); + try { + process.status = 'running'; + // Note: Wishlist processes don't affect sync page refresh button state + document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; + document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; + + // Check if force download toggle is enabled + const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); + const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; + + // Hide the force download toggle during processing + const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; + if (forceToggleContainer) { + forceToggleContainer.style.display = 'none'; + } + + // Extract track IDs from what the user is currently seeing in the modal + // This prevents race conditions where wishlist changes between modal open and analysis start + const trackIds = process.tracks ? process.tracks.map(t => t.spotify_track_id || t.id).filter(id => id) : null; + console.log(`🎯 [Wishlist] Sending ${trackIds ? trackIds.length : 'all'} specific track IDs to prevent race condition`); + + const response = await fetch('/api/wishlist/download_missing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + force_download_all: forceDownloadAll, + category: window.currentWishlistCategory, // Keep for backward compat + track_ids: trackIds // NEW: Send exact tracks to process + }) + }); + + const data = await response.json(); + if (!data.success) { + // Special handling for auto-processing conflict + if (response.status === 409) { + console.log('🤖 [Wishlist] Auto-processing is running, redirecting to download manager'); + showToast('Wishlist auto-processing is already running. Opening Download Manager...', 'info'); + + // Close wishlist modal and show download manager + const wishlistModal = document.getElementById('download-modal-wishlist'); + if (wishlistModal) { + wishlistModal.remove(); + } + delete activeDownloadProcesses[playlistId]; + + // Open download manager to show active batch + setTimeout(() => { + const downloadManager = document.getElementById('download-manager-modal'); + if (downloadManager) { + downloadManager.style.display = 'flex'; + } else { + openDownloadManagerModal(); + } + }, 300); + return; + } + // Special handling for rate limit + if (response.status === 429) { + throw new Error(`${data.error} Try closing some other download processes first.`); + } + throw new Error(data.error); + } + + process.batchId = data.batch_id; + console.log(`✅ Wishlist process started successfully. Batch ID: ${data.batch_id}`); + + // Start polling for updates + startModalDownloadPolling(playlistId); + + } catch (error) { + console.error('Error starting wishlist missing tracks process:', error); + showToast(`Error: ${error.message}`, 'error'); + + // Reset UI state on error + process.status = 'idle'; + // Note: Wishlist processes don't affect sync page refresh button state + document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block'; + document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; + + // Show the force download toggle again + const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); + if (forceToggleContainer) { + forceToggleContainer.style.display = 'flex'; + } + } +} + +async function startMissingTracksProcess(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) return; + + console.log(`🚀 Kicking off unified missing tracks process for playlist: ${playlistId}`); + try { + process.status = 'running'; + updatePlaylistCardUI(playlistId); + updateRefreshButtonState(); + + // Set album to downloading status if this is an artist album + if (playlistId.startsWith('artist_album_')) { + // Format: artist_album_{artist.id}_{album.id} + const parts = playlistId.split('_'); + if (parts.length >= 4) { + const albumId = parts.slice(3).join('_'); // In case album ID has underscores + const totalTracks = process.tracks ? process.tracks.length : 0; + setAlbumDownloadingStatus(albumId, 0, totalTracks); + console.log(`🔄 Set album ${albumId} to downloading status (0/${totalTracks} tracks)`); + console.log(`🔍 Virtual playlist ID: ${playlistId} → Album ID: ${albumId}`); + } + } + + // Update YouTube playlist phase to 'downloading' if this is a YouTube playlist + if (playlistId.startsWith('youtube_')) { + const urlHash = playlistId.replace('youtube_', ''); + updateYouTubeCardPhase(urlHash, 'downloading'); + // Also update mirrored playlist card if applicable + if (urlHash.startsWith('mirrored_')) { + updateMirroredCardPhase(urlHash, 'downloading'); + } + } + + // Update Tidal playlist phase to 'downloading' if this is a Tidal playlist + if (playlistId.startsWith('tidal_')) { + const tidalPlaylistId = playlistId.replace('tidal_', ''); + if (tidalPlaylistStates[tidalPlaylistId]) { + tidalPlaylistStates[tidalPlaylistId].phase = 'downloading'; + updateTidalCardPhase(tidalPlaylistId, 'downloading'); + console.log(`🔄 Updated Tidal playlist ${tidalPlaylistId} to downloading phase`); + } + } + + // Update Beatport chart phase to 'downloading' if this is a Beatport chart + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + + if (state && state.is_beatport_playlist) { + const chartHash = state.beatport_chart_hash || urlHash; + + // Update frontend states + state.phase = 'downloading'; + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'downloading'; + } + + // Update card UI + updateBeatportCardPhase(chartHash, 'downloading'); + + // Update backend state + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading' }) + }); + } catch (error) { + console.warn('⚠️ Error updating backend Beatport phase to downloading:', error); + } + + console.log(`🔄 Updated Beatport chart ${chartHash} to downloading phase`); + } + } + + // Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist + if (playlistId.startsWith('spotify_public_')) { + const urlHash = playlistId.replace('spotify_public_', ''); + if (spotifyPublicPlaylistStates[urlHash]) { + spotifyPublicPlaylistStates[urlHash].phase = 'downloading'; + spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId; + updateSpotifyPublicCardPhase(urlHash, 'downloading'); + + try { + fetch(`/api/spotify-public/update_phase/${urlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) + }); + } catch (error) { + console.warn('Error updating backend Spotify Public phase to downloading:', error); + } + + console.log(`🔄 Updated Spotify Public playlist ${urlHash} to downloading phase`); + } + } + + // Update Deezer playlist phase to 'downloading' if this is a Deezer playlist + if (playlistId.startsWith('deezer_')) { + const deezerPlaylistId = playlistId.replace('deezer_', ''); + if (deezerPlaylistStates[deezerPlaylistId]) { + deezerPlaylistStates[deezerPlaylistId].phase = 'downloading'; + deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId; + updateDeezerCardPhase(deezerPlaylistId, 'downloading'); + + try { + fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) + }); + } catch (error) { + console.warn('Error updating backend Deezer phase to downloading:', error); + } + + console.log(`🔄 Updated Deezer playlist ${deezerPlaylistId} to downloading phase`); + } + } + + // Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist + if (playlistId.startsWith('listenbrainz_')) { + const playlistMbid = playlistId.replace('listenbrainz_', ''); + const state = listenbrainzPlaylistStates[playlistMbid]; + + if (state) { + // Update frontend state + state.phase = 'downloading'; + + // Update backend state + try { + fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'downloading' }) + }); + } catch (error) { + console.warn('⚠️ Error updating backend ListenBrainz phase to downloading:', error); + } + + console.log(`🔄 Updated ListenBrainz playlist ${playlistMbid} to downloading phase`); + } + } + document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; + document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; + + // Hide wishlist button if it exists (only for non-wishlist modals) + const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); + if (wishlistBtn) { + wishlistBtn.style.display = 'none'; + } + + // Add to discover download sidebar if this is a discover page download + if (process.discoverMetadata) { + const playlistName = process.playlist.name; + const imageUrl = process.discoverMetadata.imageUrl; + const type = process.discoverMetadata.type; + addDiscoverDownload(playlistId, playlistName, type, imageUrl); + console.log(`📥 [BEGIN ANALYSIS] Added discover download: ${playlistName}`); + } + + // Check if force download toggle is enabled + const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); + const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; + + // Check if playlist folder mode toggle is enabled (only for sync page playlists) + const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`); + const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false; + + // Hide the force download toggle during processing + const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; + if (forceToggleContainer) { + forceToggleContainer.style.display = 'none'; + } + + // Filter tracks based on checkbox selection (if checkboxes exist in this modal) + const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); + let selectedTracks = process.tracks; + if (tbody) { + const allCbs = tbody.querySelectorAll('.track-select-cb'); + if (allCbs.length > 0) { + // Checkboxes exist — filter to only checked tracks + const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked'); + const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex))); + console.log(`🔲 [Track Selection] Total checkboxes: ${allCbs.length}, Checked: ${checkedCbs.length}`); + console.log(`🔲 [Track Selection] Checked indices:`, [...selectedIndices]); + console.log(`🔲 [Track Selection] process.tracks has ${process.tracks.length} items, first: "${process.tracks[0]?.name}", last: "${process.tracks[process.tracks.length - 1]?.name}"`); + // Stamp each selected track with its original table index so the backend + // maps status updates back to the correct modal row + selectedTracks = process.tracks + .map((track, i) => ({ ...track, _original_index: i })) + .filter(track => selectedIndices.has(track._original_index)); + console.log(`🔲 [Track Selection] Filtered to ${selectedTracks.length} tracks:`, selectedTracks.map(t => `[${t._original_index}] ${t.name}`)); + // Disable checkboxes once analysis starts + allCbs.forEach(cb => { cb.disabled = true; }); + } + } + const selectAllCb = document.getElementById(`select-all-${playlistId}`); + if (selectAllCb) selectAllCb.disabled = true; + + // Prepare request body - add album/artist context for artist album downloads + const wingItState = youtubePlaylistStates[playlistId] || {}; + const isWingIt = wingItState.wing_it || false; + const requestBody = { + tracks: selectedTracks, + force_download_all: forceDownloadAll || isWingIt, + wing_it: isWingIt, + }; + + // If this is an artist album download, use album name and include full context + // Match 'artist_album_', 'enhanced_search_album_', 'discover_album_', and 'seasonal_album_' prefixes + // Note: 'enhanced_search_track_' is excluded — single track search results use singles context + const _isAlbumContext = playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('spotify_library_') || playlistId.startsWith('issue_download_') || playlistId.startsWith('library_redownload_') || playlistId.startsWith('beatport_release_'); + const _isSearchTrack = playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('gsearch_track_'); + if (_isAlbumContext || _isSearchTrack) { + requestBody.playlist_name = process.album?.name || process.playlist.name; + requestBody.is_album_download = _isAlbumContext; // false for single track search results + requestBody.album_context = process.album; // Full Spotify album object + requestBody.artist_context = process.artist; // Full Spotify artist object + console.log(`🎵 [${_isAlbumContext ? 'Album' : 'Single Track'}] Sending context: ${process.album?.name} by ${process.artist?.name}`); + } else { + // For playlists/wishlists, use the virtual playlist name + requestBody.playlist_name = process.playlist.name; + // Add playlist folder mode flag for sync page playlists + requestBody.playlist_folder_mode = playlistFolderMode; + if (playlistFolderMode) { + console.log(`📁 [Playlist Folder] Enabled for playlist: ${process.playlist.name}`); + } + } + + const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + if (!data.success) { + // Special handling for rate limit + if (response.status === 429) { + throw new Error(`${data.error} Try closing some other download processes first.`); + } + throw new Error(data.error); + } + + process.batchId = data.batch_id; + + // Update Beatport backend state with download_process_id now that we have the batchId + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + if (state && state.is_beatport_playlist) { + const chartHash = state.beatport_chart_hash || urlHash; + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: 'downloading', + download_process_id: data.batch_id + }) + }); + console.log(`🔄 Updated Beatport backend with download_process_id: ${data.batch_id}`); + } catch (error) { + console.warn('⚠️ Error updating Beatport backend with download_process_id:', error); + } + } + } + + // Update ListenBrainz backend state with download_process_id and convertedSpotifyPlaylistId + if (playlistId.startsWith('listenbrainz_')) { + const playlistMbid = playlistId.replace('listenbrainz_', ''); + const state = listenbrainzPlaylistStates[playlistMbid]; + if (state) { + // Store in frontend state + state.download_process_id = data.batch_id; + state.convertedSpotifyPlaylistId = playlistId; + + // Update backend state + try { + fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: 'downloading', + download_process_id: data.batch_id, + converted_spotify_playlist_id: playlistId + }) + }); + console.log(`🔄 Updated ListenBrainz backend with download_process_id: ${data.batch_id}`); + } catch (error) { + console.warn('⚠️ Error updating ListenBrainz backend with download_process_id:', error); + } + } + } + + startModalDownloadPolling(playlistId); + } catch (error) { + showToast(`Failed to start process: ${error.message}`, 'error'); + process.status = 'cancelled'; + + // Reset button states on error + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); + if (beginBtn) beginBtn.style.display = 'inline-block'; + if (cancelBtn) cancelBtn.style.display = 'none'; + if (wishlistBtn) wishlistBtn.style.display = 'inline-block'; + + // Show the force download toggle again + const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); + if (forceToggleContainer) { + forceToggleContainer.style.display = 'flex'; + } + + cleanupDownloadProcess(playlistId); + } +} + + +function updateTrackAnalysisResults(playlistId, results) { + // Update match results for all rows (tracks are now pre-populated) + for (const result of results) { + const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`); + if (matchElement) { + matchElement.textContent = result.found ? '✅ Found' : '❌ Missing'; + matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`; + } + } +} + + + +// ============================================================================ +// GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals +// ============================================================================ + +let globalDownloadStatusPoller = null; +let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff +let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly + +function startGlobalDownloadPolling() { + // Always run HTTP polling as a fallback — WebSocket connections can silently + // stop delivering messages (room subscription lost, server emit error, proxy + // timeout) without triggering a disconnect event. The 2-second poll is cheap + // (single batched request) and ensures modals never go stale. + if (globalDownloadStatusPoller) { + console.debug('🔄 [Global Polling] Already running, skipping start'); + return; // Prevent duplicate pollers + } + + console.log('🔄 [Global Polling] Starting batched download status polling'); + + globalDownloadStatusPoller = setInterval(async () => { + if (document.hidden) return; // Skip polling when tab is not visible + // Get all active processes that need polling + const activeBatchIds = []; + const batchToPlaylistMap = {}; + let hasOpenWishlistModal = false; + + Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { + // Include running AND recently-completed batches — ensures late task + // status updates still reach the modal so rows don't freeze mid-download + if (process.batchId && (process.status === 'running' || process.status === 'complete')) { + activeBatchIds.push(process.batchId); + batchToPlaylistMap[process.batchId] = playlistId; + } + + // Check if there's an open wishlist modal (visible and idle/waiting) + if (playlistId === 'wishlist' && process.modalElement && + process.modalElement.style.display === 'flex' && + (!process.batchId || process.status !== 'running')) { + hasOpenWishlistModal = true; + } + }); + + // Special handling for open wishlist modal - check for new auto-processing + if (hasOpenWishlistModal) { + try { + const response = await fetch('/api/active-processes'); + if (response.ok) { + const data = await response.json(); + const processes = data.active_processes || []; + const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); + + if (serverWishlistProcess) { + console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); + await rehydrateModal(serverWishlistProcess, false); // false = not user-requested + } + } + } catch (error) { + console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error); + } + } + + if (activeBatchIds.length === 0) { + console.debug('📊 [Global Polling] No active processes, continuing polling'); + return; + } + + try { + // Single batched API call for all active processes + const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); + const response = await fetch(`/api/download_status/batch?${queryParams}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); + + // Process each batch's status data using existing logic + Object.entries(data.batches).forEach(([batchId, statusData]) => { + const playlistId = batchToPlaylistMap[batchId]; + if (!playlistId || statusData.error) { + if (statusData.error) { + console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error); + } + return; + } + + // Use existing modal update logic - zero changes needed! + processModalStatusUpdate(playlistId, statusData); + }); + + // ENHANCED: Reset failure count on successful polling + globalPollingFailureCount = 0; + + } catch (error) { + console.error('❌ [Global Polling] Batched request failed:', error); + + // ENHANCED: Implement exponential backoff on failure + globalPollingFailureCount++; + + if (globalPollingFailureCount >= 5) { + console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`); + // Don't stop polling - just continue with exponential backoff + } + + // Exponential backoff: increase interval temporarily + const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000); + console.warn(`⚠️ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`); + + // Temporarily adjust the polling interval + if (globalDownloadStatusPoller) { + clearInterval(globalDownloadStatusPoller); + globalDownloadStatusPoller = null; + + // Restart with backoff interval + setTimeout(() => { + if (Object.keys(activeDownloadProcesses).length > 0) { + startGlobalDownloadPollingWithInterval(backoffInterval); + } + }, backoffInterval); + } + } + }, globalPollingBaseInterval); // Use base interval initially +} + +function startGlobalDownloadPollingWithInterval(interval) { + if (globalDownloadStatusPoller) { + console.debug('🔄 [Global Polling] Already running, skipping start with interval'); + return; + } + + console.log(`🔄 [Global Polling] Starting with interval ${interval}ms`); + + // Use the exact same logic as startGlobalDownloadPolling but with custom interval + globalDownloadStatusPoller = setInterval(async () => { + const activeBatchIds = []; + const batchToPlaylistMap = {}; + let hasOpenWishlistModal = false; + + Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { + if (process.batchId && (process.status === 'running' || process.status === 'complete')) { + activeBatchIds.push(process.batchId); + batchToPlaylistMap[process.batchId] = playlistId; + } + + // Check if there's an open wishlist modal (visible and idle/waiting) + if (playlistId === 'wishlist' && process.modalElement && + process.modalElement.style.display === 'flex' && + (!process.batchId || process.status !== 'running')) { + hasOpenWishlistModal = true; + } + }); + + // Special handling for open wishlist modal - check for new auto-processing + if (hasOpenWishlistModal) { + try { + const response = await fetch('/api/active-processes'); + if (response.ok) { + const data = await response.json(); + const processes = data.active_processes || []; + const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); + + if (serverWishlistProcess) { + console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); + await rehydrateModal(serverWishlistProcess, false); // false = not user-requested + } + } + } catch (error) { + console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error); + } + } + + if (activeBatchIds.length === 0) { + console.debug('📊 [Global Polling] No active processes, continuing polling'); + return; + } + + try { + const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); + const response = await fetch(`/api/download_status/batch?${queryParams}`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); + + Object.entries(data.batches).forEach(([batchId, statusData]) => { + const playlistId = batchToPlaylistMap[batchId]; + if (!playlistId || statusData.error) { + if (statusData.error) { + console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error); + } + return; + } + processModalStatusUpdate(playlistId, statusData); + }); + + // Success - reset to normal interval if we were backing off + globalPollingFailureCount = 0; + if (interval !== globalPollingBaseInterval) { + console.log('✅ [Global Polling] Recovered from backoff, returning to normal interval'); + clearInterval(globalDownloadStatusPoller); + globalDownloadStatusPoller = null; + startGlobalDownloadPolling(); // Restart with normal interval + } + + } catch (error) { + console.error('❌ [Global Polling] Request failed:', error); + globalPollingFailureCount++; + + if (globalPollingFailureCount >= 5) { + console.error(`🚨 [Global Polling] Too many failures, continuing with backoff`); + // Don't stop polling - just continue with exponential backoff + } + } + }, interval); +} + +function stopGlobalDownloadPolling() { + if (globalDownloadStatusPoller) { + console.log('🛑 [Global Polling] Stopping batched download status polling'); + clearInterval(globalDownloadStatusPoller); + globalDownloadStatusPoller = null; + } +} + +// --- Error tooltip for failed/cancelled downloads (fixed-position, escapes overflow) --- +function _getErrorTooltipPopup() { + let el = document.getElementById('error-tooltip-popup'); + if (!el) { + el = document.createElement('div'); + el.id = 'error-tooltip-popup'; + document.body.appendChild(el); + } + return el; +} + +function _hideErrorTooltip() { + const popup = document.getElementById('error-tooltip-popup'); + if (popup) popup.classList.remove('visible'); +} + +function _ensureErrorTooltipListeners(statusEl) { + if (statusEl._errorTooltipBound) return; + statusEl._errorTooltipBound = true; + statusEl.addEventListener('mouseenter', function () { + const msg = this.dataset.errorMsg; + if (!msg || !this.offsetParent) return; // skip if element is hidden + const popup = _getErrorTooltipPopup(); + popup.textContent = msg; + popup.classList.add('visible'); + const rect = this.getBoundingClientRect(); + const popupRect = popup.getBoundingClientRect(); + let left = rect.left + rect.width / 2 - popupRect.width / 2; + let top = rect.top - popupRect.height - 10; + // Keep within viewport + if (left < 8) left = 8; + if (left + popupRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - popupRect.width; + if (top < 8) { top = rect.bottom + 10; } // flip below if no room above + popup.style.left = left + 'px'; + popup.style.top = top + 'px'; + }); + statusEl.addEventListener('mouseleave', _hideErrorTooltip); + + // Dismiss tooltip when the scrollable modal body scrolls + const scrollParent = statusEl.closest('.download-missing-modal-body'); + if (scrollParent && !scrollParent._errorTooltipScrollBound) { + scrollParent._errorTooltipScrollBound = true; + scrollParent.addEventListener('scroll', _hideErrorTooltip, { passive: true }); + } +} + +function _ensureCandidatesClickListener(statusEl) { + if (statusEl._candidatesClickBound) return; + statusEl._candidatesClickBound = true; + statusEl.addEventListener('click', function (e) { + e.stopPropagation(); + _hideErrorTooltip(); + const taskId = this.dataset.taskId; + if (taskId) showCandidatesModal(taskId); + }); +} + +async function showCandidatesModal(taskId) { + try { + const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`); + if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; } + const data = await resp.json(); + _renderCandidatesModal(data); + } catch (err) { + console.error('Error fetching candidates:', err); + } +} + +function _renderCandidatesModal(data) { + let overlay = document.getElementById('candidates-modal-overlay'); + if (overlay) overlay.remove(); + + const trackName = data.track_info?.name || 'Unknown Track'; + const trackArtist = data.track_info?.artist || 'Unknown Artist'; + const candidates = data.candidates || []; + const errorMsg = data.error_message || ''; + + const fmtSize = (bytes) => { + if (!bytes) return '-'; + const units = ['B', 'KB', 'MB', 'GB']; + let s = bytes, u = 0; + while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; } + return `${s.toFixed(1)} ${units[u]}`; + }; + const fmtDur = (ms) => { + if (!ms) return '-'; + const sec = Math.floor(ms / 1000); + return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`; + }; + + let tableRows = ''; + if (candidates.length === 0) { + tableRows = ` + No candidates were found during search.`; + } else { + candidates.forEach((c, i) => { + const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-'; + const qBadge = c.quality + ? `${c.quality.toUpperCase()}` + : ''; + tableRows += ` + ${i + 1} + ${escapeHtml(shortFile)} + ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} + ${fmtSize(c.size)} + ${fmtDur(c.duration)} + ${escapeHtml(c.username || '-')} + + `; + }); + } + + overlay = document.createElement('div'); + overlay.id = 'candidates-modal-overlay'; + overlay.className = 'candidates-modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); }; + overlay.innerHTML = ` +
    +
    +
    +

    Search Results

    +
    ${escapeHtml(trackName)} — ${escapeHtml(trackArtist)}
    +
    + +
    +
    + ${errorMsg ? `
    ${escapeHtml(errorMsg)}
    ` : ''} +
    ${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
    +
    + + + + + ${tableRows} +
    #FileQualitySizeDurationUser
    +
    +
    +
    `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + + // Bind download buttons + overlay.querySelectorAll('.candidates-download-btn').forEach(btn => { + btn.addEventListener('click', () => { + const idx = parseInt(btn.dataset.index); + const c = candidates[idx]; + if (c) downloadCandidate(data.task_id, c, trackName); + }); + }); +} + +async function downloadCandidate(taskId, candidate, trackName) { + if (!await showConfirmDialog({ title: 'Download File', message: `Download this file as "${trackName}"?\n\n${candidate.filename?.split(/[/\\]/).pop() || 'Unknown file'}\nfrom ${candidate.username || 'Unknown user'}`, confirmText: 'Download' })) return; + try { + const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/download-candidate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(candidate) + }); + const result = await resp.json(); + if (result.success) { + closeCandidatesModal(); + showToast(result.message || 'Download initiated', 'success'); + } else { + showToast(`Failed: ${result.error}`, 'error'); + } + } catch (err) { + console.error('Error initiating manual download:', err); + showToast('Failed to initiate download', 'error'); + } +} + +function closeCandidatesModal() { + const overlay = document.getElementById('candidates-modal-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 300); + } +} + +function processModalStatusUpdate(playlistId, data) { + // This function contains ALL the existing polling logic from startModalDownloadPolling + // Extracted so it can be called from both individual and batched polling + const process = activeDownloadProcesses[playlistId]; + if (!process) { + console.debug(`⚠️ [Status Update] No process found for ${playlistId}, skipping update`); + return; + } + + if (data.error) { + console.error(`❌ [Status Update] Error for ${playlistId}: ${data.error}`); + return; + } + + // ENHANCED: Validate response data to prevent UI corruption + if (!data || typeof data !== 'object') { + console.error(`❌ [Status Update] Invalid data for ${playlistId}:`, data); + return; + } + + // ENHANCED: Validate task data structure + if (data.tasks && !Array.isArray(data.tasks)) { + console.error(`❌ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks); + return; + } + + console.debug(`📊 [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`); + + // Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only + // Auto-show logic has been simplified to prevent conflicts + + if (data.phase === 'analysis') { + const progress = data.analysis_progress; + const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0; + document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`; + document.getElementById(`analysis-progress-text-${playlistId}`).textContent = + `${progress.processed}/${progress.total} tracks analyzed`; + if (data.analysis_results) { + updateTrackAnalysisResults(playlistId, data.analysis_results); + // Update stats when we first get analysis results + const foundCount = data.analysis_results.filter(r => r.found).length; + const missingCount = data.analysis_results.filter(r => !r.found).length; + document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; + document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; + + // Auto-save M3U file for playlists after analysis + autoSavePlaylistM3U(playlistId); + } + } else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') { + console.debug(`📊 [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`); + + if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') { + document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%'; + document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!'; + if (data.analysis_results) { + updateTrackAnalysisResults(playlistId, data.analysis_results); + const foundCount = data.analysis_results.filter(r => r.found).length; + const missingCount = data.analysis_results.filter(r => !r.found).length; + document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; + document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; + } + } + const missingTracks = (data.analysis_results || []).filter(r => !r.found); + const missingCount = missingTracks.length; + let completedCount = 0; + let failedOrCancelledCount = 0; + let notFoundCount = 0; + + // Verify modal exists before processing tasks + const modal = document.getElementById(`download-missing-modal-${playlistId}`); + if (!modal) { + console.error(`❌ [Status Update] Modal not found: download-missing-modal-${playlistId}`); + return; + } + + // Update download progress text immediately when entering downloading phase + // This handles the case where tasks array is empty or still being populated + const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`); + if (data.phase === 'downloading' && missingCount > 0 && (!data.tasks || data.tasks.length === 0)) { + // No tasks yet, but we're in downloading phase with missing tracks + if (downloadProgressText) { + downloadProgressText.textContent = 'Preparing downloads...'; + console.log(`📥 [Download Phase] Preparing ${missingCount} downloads...`); + } + } + + (data.tasks || []).forEach(task => { + const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`); + if (!row) { + console.debug(`❌ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`); + return; + } + + // V2 SYSTEM: Check for persistent cancel state from backend + const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id + const cancelRequested = task.cancel_requested || false; + const uiState = task.ui_state || 'normal'; + + // Legacy protection for old system compatibility + if (row.dataset.locallyCancelled === 'true' && !isV2Task) { + failedOrCancelledCount++; + return; // Only skip for legacy system tasks + } + + // Mark row with V2 system info + if (isV2Task) { + row.dataset.useV2System = 'true'; + row.dataset.cancelRequested = cancelRequested.toString(); + row.dataset.uiState = uiState; + } + + row.dataset.taskId = task.task_id; + const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); + const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); + + let statusText = ''; + // V2 SYSTEM: Handle UI state override for cancelling tasks + if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') { + statusText = '🔄 Cancelling...'; + } else { + switch (task.status) { + case 'pending': statusText = '⏸️ Pending'; break; + case 'searching': statusText = '🔍 Searching...'; break; + case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break; + case 'post_processing': statusText = '⌛ Processing...'; break; + case 'completed': statusText = '✅ Completed'; completedCount++; break; + case 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break; + case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; + case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; + default: statusText = `⚪ ${task.status}`; break; + } + } + + if (statusEl) { + statusEl.classList.remove('has-error-tooltip'); + statusEl.removeAttribute('title'); + statusEl.removeAttribute('data-error-msg'); + statusEl.textContent = statusText; + + if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) { + statusEl.classList.add('has-error-tooltip'); + statusEl.dataset.errorMsg = task.error_message; + _ensureErrorTooltipListeners(statusEl); + } + // Make not_found and failed cells clickable to review search candidates + if ((task.status === 'not_found' || task.status === 'failed') && task.has_candidates) { + statusEl.classList.add('has-candidates'); + statusEl.dataset.taskId = task.task_id; + _ensureCandidatesClickListener(statusEl); + } + console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`); + } else { + console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`); + } + + // V2 SYSTEM: Smart button management with persistent state awareness + if (actionsEl && !['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) { + // Check if we're in a cancelling state + if (isV2Task && uiState === 'cancelling') { + actionsEl.innerHTML = 'Cancelling...'; + } else { + // Create V2 cancel button for all active tasks + const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload'; + actionsEl.innerHTML = ``; + } + } else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) { + actionsEl.innerHTML = '-'; // No actions available for terminal or processing states + } + }); + + // ENHANCED: Validate worker counts from server data + const serverActiveWorkers = data.active_count || 0; + const maxWorkers = data.max_concurrent || 3; + + // V2 SYSTEM: Simplified worker counting - backend is authoritative + // Count active tasks, excluding locally cancelled legacy tasks only + const clientActiveWorkers = (data.tasks || []).filter(task => { + const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`); + const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System; + return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled; + }).length; + + // Log discrepancies for debugging + if (serverActiveWorkers !== clientActiveWorkers) { + console.warn(`🔍 [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`); + + // If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed + if (serverActiveWorkers === 0 && clientActiveWorkers > 0) { + console.warn(`🚨 [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`); + } + } + + console.debug(`📊 [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`); + + const totalFinished = completedCount + failedOrCancelledCount + notFoundCount; + const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0; + document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`; + document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; + document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount; + + // Auto-save M3U file once when all downloads finish (not on every poll cycle). + // Previously this fired on EVERY 2-second poll when completedCount > 0, flooding + // the server with heavyweight M3U generation requests that exhausted Flask threads + // and caused the batch status endpoint to hang — killing the poller. + + // CLIENT-SIDE COMPLETION: Only complete when ALL task rows in the UI reflect a terminal state. + // Using totalFinished (derived from DOM updates in THIS render pass) prevents premature + // completion when the server sends phase='complete' before all rows have been updated. + const allTracksFinished = totalFinished >= missingCount && missingCount > 0 && totalFinished > 0; + // Extra guard: require the server to also report no active tasks + const serverHasActiveWork = (data.tasks || []).some(t => + ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(t.status)); + if (allTracksFinished && !serverHasActiveWork && process.status !== 'complete') { + console.log(`🎯 [Client Completion] All ${totalFinished}/${missingCount} tracks finished - completing modal locally`); + + // Hide cancel button and mark as complete + document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; + process.status = 'complete'; + updatePlaylistCardUI(playlistId); + + // Save M3U once on completion (not during progress polling) + if (completedCount > 0) { + autoSavePlaylistM3U(playlistId); + } + + // Show the force download toggle again + const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); + if (forceToggleContainer) { + forceToggleContainer.style.display = 'flex'; + } + + // Set album to downloaded status if this is an artist album + if (playlistId.startsWith('artist_album_')) { + const parts = playlistId.split('_'); + if (parts.length >= 4) { + const albumId = parts.slice(3).join('_'); + setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates + } + } + + // Update mirrored playlist card phase on client-side completion + if (playlistId.startsWith('youtube_')) { + const urlHash = playlistId.replace('youtube_', ''); + if (urlHash.startsWith('mirrored_')) { + updateMirroredCardPhase(urlHash, 'download_complete'); + } + } + + // Auto-save final M3U file for playlists + autoSavePlaylistM3U(playlistId); + + // Show completion message + let completionParts = [`${completedCount} downloaded`]; + if (notFoundCount > 0) completionParts.push(`${notFoundCount} not found`); + if (failedOrCancelledCount > 0) completionParts.push(`${failedOrCancelledCount} failed`); + const completionMessage = `Download complete! ${completionParts.join(', ')}.`; + showToast(completionMessage, 'success'); + + // Refresh server playlists tab so it reflects newly synced tracks + if (typeof loadServerPlaylists === 'function') { + setTimeout(() => loadServerPlaylists(), 2000); + } + + // Auto-close wishlist modal when completed (for auto-processing) + if (playlistId === 'wishlist') { + console.log('🔄 [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle'); + setTimeout(() => { + closeDownloadMissingModal(playlistId); + }, 3000); // 3-second delay to show completion message + } + + // Check if any other processes still need polling + checkAndCleanupGlobalPolling(); + + return; // Skip waiting for backend signal + } + + // FIXED: Only trigger completion logic when backend actually reports batch as complete + // Don't assume completion based on task counts - let backend determine when truly complete + if (data.phase === 'complete' || data.phase === 'error') { + // Enhanced check for background auto-processing for wishlist + const isWishlist = (playlistId === 'wishlist'); + const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none'); + const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started + const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated); + + // Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only + + if (data.phase === 'cancelled') { + process.status = 'cancelled'; + + // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel + if (playlistId.startsWith('youtube_')) { + const urlHash = playlistId.replace('youtube_', ''); + updateYouTubeCardPhase(urlHash, 'discovered'); + if (urlHash.startsWith('mirrored_')) { + updateMirroredCardPhase(urlHash, 'discovered'); + } + } + + showToast(`Process cancelled for ${process.playlist.name}.`, 'info'); + } else if (data.phase === 'error') { + process.status = 'complete'; // Treat as complete to allow cleanup + updatePlaylistCardUI(playlistId); // Update card to show ready for review + + // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error + if (playlistId.startsWith('youtube_')) { + const urlHash = playlistId.replace('youtube_', ''); + updateYouTubeCardPhase(urlHash, 'discovered'); + if (urlHash.startsWith('mirrored_')) { + updateMirroredCardPhase(urlHash, 'discovered'); + } + } + + showToast(`Process for ${process.playlist.name} failed!`, 'error'); + } else { + process.status = 'complete'; + updatePlaylistCardUI(playlistId); // Update card to show ready for review + + // Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist + if (playlistId.startsWith('youtube_')) { + const urlHash = playlistId.replace('youtube_', ''); + updateYouTubeCardPhase(urlHash, 'download_complete'); + if (urlHash.startsWith('mirrored_')) { + updateMirroredCardPhase(urlHash, 'download_complete'); + } + } + + // Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist + if (playlistId.startsWith('tidal_')) { + const tidalPlaylistId = playlistId.replace('tidal_', ''); + if (tidalPlaylistStates[tidalPlaylistId]) { + tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete'; + // Store the download process ID for potential modal rehydration + tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId; + updateTidalCardPhase(tidalPlaylistId, 'download_complete'); + console.log(`✅ [Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`); + } + } + + // Update Beatport chart phase to 'download_complete' if this is a Beatport chart + if (playlistId.startsWith('beatport_')) { + const urlHash = playlistId.replace('beatport_', ''); + const state = youtubePlaylistStates[urlHash]; + + if (state && state.is_beatport_playlist) { + const chartHash = state.beatport_chart_hash || urlHash; + + // Update frontend states + state.phase = 'download_complete'; + state.download_process_id = process.batchId; + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'download_complete'; + } + + // Update card UI + updateBeatportCardPhase(chartHash, 'download_complete'); + + // Update backend state + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: 'download_complete', + download_process_id: process.batchId + }) + }); + } catch (error) { + console.warn('⚠️ Error updating backend Beatport phase to download_complete:', error); + } + + console.log(`✅ [Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`); + } + } + + // Handle background wishlist processing completion specially + if (isBackgroundWishlist) { + console.log(`🎉 Background wishlist processing complete: ${completedCount} downloaded, ${notFoundCount} not found, ${failedOrCancelledCount} failed`); + + // Reset modal to idle state to prevent "complete" phase disruption + setTimeout(() => { + resetWishlistModalToIdleState(); + // Server-side auto-processing will handle next cycle automatically + }, 500); + + return; // Skip normal completion handling + } + + // Show completion summary with wishlist stats (matching sync.py behavior) + let completionMessage = `Process complete for ${process.playlist.name}!`; + let messageType = 'success'; + + // Check for wishlist summary from backend (added when failed/cancelled tracks are processed) + if (data.wishlist_summary) { + const summary = data.wishlist_summary; + let summaryParts = [`Downloaded: ${completedCount}`]; + if (notFoundCount > 0) summaryParts.push(`Not Found: ${notFoundCount}`); + if (failedOrCancelledCount > 0) summaryParts.push(`Failed: ${failedOrCancelledCount}`); + completionMessage = `Download process complete! ${summaryParts.join(', ')}.`; + + if (summary.tracks_added > 0) { + completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`; + } else if (summary.total_failed > 0) { + completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`; + messageType = 'warning'; + } + } + + showToast(completionMessage, messageType); + } + + document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; + + // Mark process as complete and trigger cleanup check + process.status = 'complete'; + updatePlaylistCardUI(playlistId); + + // Check if any other processes still need polling + checkAndCleanupGlobalPolling(); + } + } +} + +function checkAndCleanupGlobalPolling() { + // Check if any processes still need polling + const hasActivePolling = Object.values(activeDownloadProcesses) + .some(p => p.batchId && p.status === 'running'); + + if (!hasActivePolling) { + console.debug('🧹 [Cleanup] No more active processes, continuing polling'); + // Keep polling active - no need to stop + } +} + +// LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling +function startModalDownloadPolling(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process || !process.batchId) return; + + console.log(`🔄 [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`); + + // Clear any existing individual poller (cleanup) + if (process.poller) { + clearInterval(process.poller); + process.poller = null; + } + + // Mark process as running to be picked up by global poller + process.status = 'running'; + + // Start global polling if not already running + startGlobalDownloadPolling(); + + // Create dummy poller for backward compatibility with cleanup functions + ensureLegacyCompatibility(playlistId); +} + +// For backward compatibility with cleanup functions that expect process.poller +// Creates a dummy poller that will be cleaned up by the existing cleanup logic +function createLegacyPoller(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) return; + + // Create a dummy interval that just checks if the process is still active + // This ensures existing cleanup logic that calls clearInterval(process.poller) works + process.poller = setInterval(() => { + // This dummy poller doesn't do anything - global poller handles updates + if (!activeDownloadProcesses[playlistId] || process.status === 'complete') { + clearInterval(process.poller); + process.poller = null; + return; + } + }, 5000); // Very infrequent check, just for cleanup compatibility +} + +// Call this to create the legacy poller after starting global polling +function ensureLegacyCompatibility(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (process && !process.poller) { + createLegacyPoller(playlistId); + } +} +async function updateModalWithLiveDownloadProgress() { + try { + if (!currentDownloadBatchId) return; + + // Fetch live download data from the downloads API + const response = await fetch('/api/downloads/status'); + const downloadData = await response.json(); + + if (downloadData.error) return; + + // Get all active and finished downloads + const allDownloads = { ...(downloadData.active || {}), ...(downloadData.finished || {}) }; + + // Update modal tracks that have active downloads + const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]'); + + for (const row of modalRows) { + const taskId = row.dataset.taskId; + if (!taskId) continue; + + // Find corresponding download by checking if filename/title matches + const trackName = row.querySelector('.track-name')?.textContent?.trim(); + if (!trackName) continue; + + // Search for matching download + for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) { + // Extract display title from filename (handle YouTube encoding) + let downloadTitle = ''; + if (downloadInfo.filename) { + if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal' || downloadInfo.username === 'qobuz' || downloadInfo.username === 'hifi') && downloadInfo.filename.includes('||')) { + const parts = downloadInfo.filename.split('||'); + downloadTitle = parts[1] || parts[0]; + } else { + downloadTitle = downloadInfo.filename.split(/[\\/]/).pop(); + } + } + + // Simple matching - could be improved with better logic + if (downloadTitle && trackName && ( + downloadTitle.toLowerCase().includes(trackName.toLowerCase()) || + trackName.toLowerCase().includes(downloadTitle.toLowerCase()) + )) { + // Update the track with live download progress + const statusElement = row.querySelector('.track-download-status'); + const progress = downloadInfo.percentComplete || 0; + const state = downloadInfo.state || ''; + + if (statusElement && state.includes('InProgress') && progress > 0) { + statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`; + statusElement.className = 'track-download-status download-downloading'; + } else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) { + statusElement.textContent = '✅ Completed'; + statusElement.className = 'track-download-status download-complete'; + } + + break; // Found a match, stop searching + } + } + } + + } catch (error) { + // Silent fail - don't spam console during normal operation + } +} + +function toggleAllTrackSelections(playlistId, checked) { + const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); + if (!tbody) return; + const checkboxes = tbody.querySelectorAll('.track-select-cb'); + checkboxes.forEach(cb => { cb.checked = checked; }); + updateTrackSelectionCount(playlistId); +} + +function updateTrackSelectionCount(playlistId) { + const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); + if (!tbody) return; + const allCbs = tbody.querySelectorAll('.track-select-cb'); + const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked'); + const total = allCbs.length; + const selected = checkedCbs.length; + + // Update selection count label + const countLabel = document.getElementById(`track-selection-count-${playlistId}`); + if (countLabel) { + countLabel.textContent = `${selected} / ${total} tracks selected`; + } + + // Update select-all checkbox state + const selectAll = document.getElementById(`select-all-${playlistId}`); + if (selectAll) { + selectAll.checked = selected === total; + selectAll.indeterminate = selected > 0 && selected < total; + } + + // Update row dimming + allCbs.forEach(cb => { + const row = cb.closest('tr'); + if (row) row.classList.toggle('track-deselected', !cb.checked); + }); + + // Disable Begin Analysis and Add to Wishlist buttons when 0 selected + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + if (beginBtn) { + beginBtn.disabled = selected === 0; + } + const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); + if (wishlistBtn) { + wishlistBtn.disabled = selected === 0; + } +} + +async function cancelAllOperations(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) return; + + // Prevent multiple cancel all operations + if (process.cancellingAll) { + console.log(`⚠️ Cancel All already in progress for ${playlistId}`); + return; + } + process.cancellingAll = true; + + console.log(`🚫 Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`); + + showToast('Cancelling all operations and closing modal...', 'info'); + + // Mark process as complete immediately so polling stops + process.status = 'complete'; + + // Stop any active polling + if (process.poller) { + clearInterval(process.poller); + process.poller = null; + } + + // Tell server to stop starting new downloads and clean up the batch + if (process.batchId) { + try { + // Cancel the batch (stops new downloads from starting) + const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, { + method: 'POST' + }); + if (cancelResponse.ok) { + const cancelData = await cancelResponse.json(); + console.log(`✅ Server stopped new downloads for batch ${process.batchId}`); + } + } catch (error) { + console.warn('Error during server batch cancel:', error); + } + } + + // Close the modal immediately - this will handle cleanup + closeDownloadMissingModal(playlistId); + + showToast('Modal closed. Active downloads will finish in background.', 'success'); +} + +function resetToInitialState() { + // Reset UI + document.getElementById('begin-analysis-btn').style.display = 'inline-block'; + document.getElementById('start-downloads-btn').style.display = 'none'; + document.getElementById('cancel-all-btn').style.display = 'none'; + + // Reset progress bars + document.getElementById('analysis-progress-fill').style.width = '0%'; + document.getElementById('download-progress-fill').style.width = '0%'; + document.getElementById('analysis-progress-text').textContent = 'Ready to start'; + document.getElementById('download-progress-text').textContent = 'Waiting for analysis'; + + // Reset stats + document.getElementById('stat-found').textContent = '-'; + document.getElementById('stat-missing').textContent = '-'; + document.getElementById('stat-downloaded').textContent = '0'; + + // Reset track table + const tbody = document.getElementById('download-tracks-tbody'); + if (tbody) { + const rows = tbody.querySelectorAll('tr'); + rows.forEach((row, index) => { + const matchElement = row.querySelector('.track-match-status'); + const downloadElement = row.querySelector('.track-download-status'); + const actionsElement = row.querySelector('.track-actions'); + + if (matchElement) { + matchElement.textContent = '🔍 Pending'; + matchElement.className = 'track-match-status match-checking'; + } + if (downloadElement) { + downloadElement.textContent = '-'; + downloadElement.className = 'track-download-status'; + } + if (actionsElement) { + actionsElement.textContent = '-'; + } + }); + } + + // Reset state + activeAnalysisTaskId = null; + analysisResults = []; + missingTracks = []; +} + +// =============================== +// NEW ATOMIC CANCEL SYSTEM V2 +// =============================== + +async function cancelTrackDownloadV2(playlistId, trackIndex) { + /** + * NEW ATOMIC CANCEL SYSTEM V2 + * + * - No optimistic UI updates + * - Single API call handles everything atomically + * - Backend is single source of truth for all state + * - No race conditions or dual state management + */ + const process = activeDownloadProcesses[playlistId]; + if (!process) { + console.warn(`❌ [Cancel V2] No process found for playlist: ${playlistId}`); + return; + } + + const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`); + if (!row) { + console.warn(`❌ [Cancel V2] No row found for track index: ${trackIndex}`); + return; + } + + // Check if already in cancelling state + const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`); + const currentStatus = statusEl ? statusEl.textContent : ''; + + if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) { + console.log(`⚠️ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`); + return; + } + + console.log(`🎯 [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`); + + // V2 SYSTEM: Set temporary UI state - will be confirmed by server + row.dataset.uiState = 'cancelling'; + + // Show loading state only - no optimistic "cancelled" state + if (statusEl) { + statusEl.textContent = '🔄 Cancelling...'; + } + + // Disable the cancel button to prevent double-clicks + const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`); + if (actionsEl) { + actionsEl.innerHTML = 'Cancelling...'; + } + + try { + const response = await fetch('/api/downloads/cancel_task_v2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: playlistId, + track_index: trackIndex + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`✅ [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`); + showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success'); + + // Let the status polling system update the UI with server truth + // No manual UI updates - backend is authoritative + + } else { + console.error(`❌ [Cancel V2] Cancel failed: ${data.error}`); + showToast(`Cancel failed: ${data.error}`, 'error'); + + // Reset UI to previous state on failure + row.dataset.uiState = 'normal'; // Reset UI state + if (statusEl) { + statusEl.textContent = '❌ Cancel Failed'; + } + if (actionsEl) { + actionsEl.innerHTML = ``; + } + } + + } catch (error) { + console.error(`❌ [Cancel V2] Network/API error:`, error); + showToast(`Cancel request failed: ${error.message}`, 'error'); + + // Reset UI on network error + row.dataset.uiState = 'normal'; // Reset UI state + if (statusEl) { + statusEl.textContent = '❌ Cancel Failed'; + } + if (actionsEl) { + actionsEl.innerHTML = ``; + } + } +} + +// =============================== +// LEGACY CANCEL SYSTEM (OLD) +// =============================== + +async function cancelTrackDownload(playlistId, trackIndex) { + const process = activeDownloadProcesses[playlistId]; + if (!process) return; + + const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`); + if (!row) return; + + // Prevent double cancellation + if (row.dataset.locallyCancelled === 'true') { + return; // Already cancelled locally + } + + const taskId = row.dataset.taskId; + if (!taskId) { + showToast('Task not started yet, cannot cancel.', 'warning'); + return; + } + + // UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions + row.dataset.locallyCancelled = 'true'; + document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelling...'; + document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-'; + + try { + const response = await fetch('/api/downloads/cancel_task', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ task_id: taskId }) + }); + const data = await response.json(); + if (data.success) { + // Update final UI state after successful cancellation + document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelled'; + showToast('Download cancelled and added to wishlist.', 'info'); + } else { + throw new Error(data.error); + } + } catch (error) { + // Reset UI state if cancellation failed + row.dataset.locallyCancelled = 'false'; + document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '❌ Cancel Failed'; + showToast(`Could not cancel task: ${error.message}`, 'error'); + } +} + +// Find and REPLACE the old startPlaylistSyncFromModal function +async function startPlaylistSync(playlistId) { + const startTime = Date.now(); + console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`); + const playlist = spotifyPlaylists.find(p => p.id === playlistId); + if (!playlist) { + console.error(`❌ Could not find playlist data for ID: ${playlistId}`); + showToast('Could not find playlist data.', 'error'); + return; + } + console.log(`✅ Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`); + + // Check if already syncing to prevent duplicate syncs + if (activeSyncPollers[playlistId]) { + showToast('Sync already in progress for this playlist', 'warning'); + return; + } + + // Update button state immediately for user feedback + const syncBtn = document.getElementById(`sync-btn-${playlistId}`); + if (syncBtn) { + syncBtn.disabled = true; + syncBtn.textContent = '⏳ Syncing...'; + } + + // Ensure we have the full track list before starting + let tracks = playlistTrackCache[playlistId]; + if (!tracks) { + const trackFetchStart = Date.now(); + console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`); + try { + // Use the right endpoint based on playlist source + const fetchUrl = playlistId.startsWith('deezer_arl_') + ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` + : `/api/spotify/playlist/${playlistId}`; + const response = await fetch(fetchUrl); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + tracks = fullPlaylist.tracks; + playlistTrackCache[playlistId] = tracks; // Cache it + const trackFetchTime = Date.now() - trackFetchStart; + console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`); + } catch (error) { + console.error(`❌ Failed to fetch tracks:`, error); + showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error'); + return; + } + } else { + console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`); + } + + // DON'T close the modal - let it show live progress like the GUI + + try { + const syncStartTime = Date.now(); + console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`); + const response = await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: playlist.id, + playlist_name: playlist.name, + tracks: tracks, // Send the full track list + image_url: playlist.image_url || '' + }) + }); + + const syncRequestTime = Date.now() - syncStartTime; + console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`); + const data = await response.json(); + console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response data:`, data); + + if (!data.success) throw new Error(data.error); + + const totalTime = Date.now() - startTime; + console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`); + showToast(`Sync started for "${playlist.name}"`, 'success'); + + // Show initial sync state in modal if open + const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal'); + if (modal && modal.style.display !== 'none') { + const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`); + if (statusDisplay) { + statusDisplay.style.display = 'flex'; + console.log(`📊 [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`); + } + } + + updateCardToSyncing(playlist.id, 0); // Initial state + startSyncPolling(playlist.id); + + } catch (error) { + console.error(`❌ Failed to start sync:`, error); + showToast(`Failed to start sync: ${error.message}`, 'error'); + updateCardToDefault(playlist.id); + } +} + +// Add these new helper functions to script.js + +function startSyncPolling(playlistId) { + // Clear any existing poller for this playlist + if (activeSyncPollers[playlistId]) { + clearInterval(activeSyncPollers[playlistId]); + } + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); + _syncProgressCallbacks[playlistId] = (data) => { + if (data.status === 'syncing') { + const progress = data.progress; + updateCardToSyncing(playlistId, progress.progress, progress); + updateModalSyncProgress(playlistId, progress); + } else if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') { + stopSyncPolling(playlistId); + updateCardToDefault(playlistId, data); + closePlaylistDetailsModal(); + } + }; + } + + // Start a new poller that checks every 2 seconds + console.log(`🔄 Starting sync polling for playlist: ${playlistId}`); + activeSyncPollers[playlistId] = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + console.log(`📊 Polling sync status for: ${playlistId}`); + const response = await fetch(`/api/sync/status/${playlistId}`); + const state = await response.json(); + console.log(`📊 Poll response:`, state); + + if (state.status === 'syncing') { + const progress = state.progress; + console.log(`📊 Sync progress:`, progress); + console.log(` 📊 Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`); + console.log(` 📊 Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`); + + // Use the actual progress percentage from the sync service + updateCardToSyncing(playlistId, progress.progress, progress); + // Also update the modal if it's open + updateModalSyncProgress(playlistId, progress); + } else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') { + console.log(`🏁 Sync completed with status: ${state.status}`); + stopSyncPolling(playlistId); + updateCardToDefault(playlistId, state); + // Also update the modal if it's open + closePlaylistDetailsModal(); closeDeezerArlPlaylistDetailsModal(); // Close modal on completion/error + } + } catch (error) { + console.error(`❌ Error polling sync status for ${playlistId}:`, error); + stopSyncPolling(playlistId); + updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' }); + } + }, 2000); // Poll every 2 seconds + updateRefreshButtonState(); +} + +function stopSyncPolling(playlistId) { + if (activeSyncPollers[playlistId]) { + clearInterval(activeSyncPollers[playlistId]); + delete activeSyncPollers[playlistId]; + } + // Phase 5: Unsubscribe and clean up callback + if (_syncProgressCallbacks[playlistId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); + delete _syncProgressCallbacks[playlistId]; + } + updateRefreshButtonState(); +} + +// Sync sidebar visibility helpers +function showSyncSidebar() { + const sidebar = document.querySelector('.sync-sidebar'); + const contentArea = document.querySelector('.sync-content-area'); + if (sidebar && contentArea && window.innerWidth > 1300) { + sidebar.style.display = ''; + contentArea.style.gridTemplateColumns = '2.5fr 0.75fr'; + } +} + +function hideSyncSidebar() { + const sidebar = document.querySelector('.sync-sidebar'); + const contentArea = document.querySelector('.sync-content-area'); + if (sidebar && contentArea) { + sidebar.style.display = 'none'; + contentArea.style.gridTemplateColumns = '1fr'; + } +} + +// Sequential Sync Functions +function startSequentialSync() { + // Initialize manager if needed + if (!sequentialSyncManager) { + sequentialSyncManager = new SequentialSyncManager(); + } + + // Check if already running - if so, cancel + if (sequentialSyncManager.isRunning) { + sequentialSyncManager.cancel(); + return; + } + + // Validate selection + if (selectedPlaylists.size === 0) { + showToast('No playlists selected for sync', 'error'); + return; + } + + // Get playlist order from DOM to maintain display order + const playlistCards = document.querySelectorAll('.playlist-card'); + const orderedPlaylistIds = []; + + playlistCards.forEach(card => { + const playlistId = card.dataset.playlistId; + if (selectedPlaylists.has(playlistId)) { + orderedPlaylistIds.push(playlistId); + } + }); + + console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists`); + + // Show sidebar for sync progress + showSyncSidebar(); + + // Start sequential sync + sequentialSyncManager.start(orderedPlaylistIds); + + // Disable playlist selection during sync + disablePlaylistSelection(true); +} + +function disablePlaylistSelection(disabled) { + const checkboxes = document.querySelectorAll('.playlist-checkbox'); + checkboxes.forEach(checkbox => { + checkbox.disabled = disabled; + }); +} + +function hasActiveOperations() { + const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; + // Only check non-wishlist download processes for sync page refresh button + const hasActiveDownloads = Object.entries(activeDownloadProcesses) + .filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist + .some(([_, process]) => process.status === 'running'); + const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; + return hasActiveSyncs || hasActiveDownloads || hasSequentialSync; +} + + +function updateRefreshButtonState() { + const refreshBtn = document.getElementById('spotify-refresh-btn'); + if (!refreshBtn) return; + + if (hasActiveOperations()) { + refreshBtn.disabled = true; + // Provide context-specific text + const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; + const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; + if (hasActiveSyncs || hasSequentialSync) { + refreshBtn.textContent = '🔄 Syncing...'; + } else { + refreshBtn.textContent = '📥 Downloading...'; + } + } else { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } +} + +function updateCardToSyncing(playlistId, percent, progress = null) { + const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); + if (!card) return; + + const progressBar = card.querySelector('.sync-progress-indicator'); + progressBar.style.display = 'block'; + + let progressText = 'Starting...'; + let actualPercent = percent || 0; + + if (progress) { + // Create detailed progress text like the GUI + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const currentStep = progress.current_step || 'Processing'; + + // Calculate actual progress as processed/total, not just successful/total + if (total > 0) { + const processed = matched + failed; + actualPercent = Math.round((processed / total) * 100); + progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`; + } else { + progressText = currentStep; + } + + // If there's a current track being processed, show it + if (progress.current_track) { + progressText += ` - ${progress.current_track}`; + } + } + + // Build live status counter HTML (same as modal) + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
    + ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
    + `; + } + + progressBar.innerHTML = ` + ${statusCounterHTML} +
    +
    +
    +
    ${progressText}
    + `; +} + +function updateCardToDefault(playlistId, finalState = null) { + const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); + if (!card) return; + + const progressBar = card.querySelector('.sync-progress-indicator'); + progressBar.style.display = 'none'; + progressBar.innerHTML = ''; + + const statusEl = card.querySelector('.playlist-card-status'); + if (finalState) { + if (finalState.status === 'finished') { + statusEl.textContent = `Synced: Just now`; + statusEl.className = 'playlist-card-status status-synced'; + + // Check if any tracks were added to wishlist + const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0; + const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || []; + const playlistName = card.querySelector('.playlist-card-name').textContent; + + if (wishlistCount > 0 && unmatchedTracks.length > 0) { + const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', '); + showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning'); + } else if (wishlistCount > 0) { + showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success'); + } else { + showToast(`Sync complete for "${playlistName}"`, 'success'); + } + } else { + statusEl.textContent = `Sync Failed`; + statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class + showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error'); + } + } +} + +// Update the modal's sync progress display (matches GUI functionality) +function updateModalSyncProgress(playlistId, progress) { + const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal'); + if (modal && modal.style.display !== 'none') { + console.log(`📊 Updating modal sync progress for ${playlistId}:`, progress); + + // Show sync status display + const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`); + if (statusDisplay) { + statusDisplay.style.display = 'flex'; + + // Update counters (matching GUI exactly) + const totalEl = document.getElementById(`modal-total-${playlistId}`); + const matchedEl = document.getElementById(`modal-matched-${playlistId}`); + const failedEl = document.getElementById(`modal-failed-${playlistId}`); + const percentageEl = document.getElementById(`modal-percentage-${playlistId}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + // Calculate percentage like GUI + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } + + console.log(`📊 Modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); + } else { + console.warn(`❌ Modal sync status display not found for ${playlistId}`); + } + } else { + console.log(`📊 Modal not open for ${playlistId}, skipping update`); + } +} + + +// Raw Soulseek file search (used by the 'Soulseek (raw files)' source picker option). +async function performDownloadsSearch() { + const query = document.getElementById('downloads-search-input').value.trim(); + if (!query) { + showToast('Please enter a search term', 'error'); + return; + } + + // --- UI Element References --- + const searchInput = document.getElementById('downloads-search-input'); + const searchButton = document.getElementById('downloads-search-btn'); + const cancelButton = document.getElementById('downloads-cancel-btn'); + const statusText = document.getElementById('search-status-text'); + const spinner = document.querySelector('.spinner-animation'); + const dots = document.querySelector('.dots-animation'); + + // --- Start a new AbortController for this search --- + searchAbortController = new AbortController(); + + try { + // --- 1. Update UI to "Searching" State --- + searchInput.disabled = true; + searchButton.disabled = true; + cancelButton.classList.remove('hidden'); + spinner.classList.remove('hidden'); + dots.classList.remove('hidden'); + statusText.textContent = `Searching for '${query}'...`; + displayDownloadsResults([]); // Clear previous results + + // --- 2. Perform the Fetch Request --- + const response = await fetch('/api/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal: searchAbortController.signal // Link fetch to the AbortController + }); + + const data = await response.json(); + + if (data.error) { + throw new Error(data.error); + } + + const results = data.results || []; + allSearchResults = results; + resetFilters(); + applyFiltersAndSort(); + + // --- 3. Update UI with Success State --- + if (results.length === 0) { + statusText.textContent = `No results found for '${query}'`; + showToast('No results found', 'error'); + } else { + document.getElementById('filters-container').classList.remove('hidden'); + + // Count albums and singles like the GUI app + let totalAlbums = 0; + let totalTracks = 0; + + results.forEach(result => { + if (result.result_type === 'album') { + totalAlbums++; + } else { + totalTracks++; + } + }); + + statusText.textContent = `✨ Found ${results.length} results • ${totalAlbums} albums, ${totalTracks} singles`; + showToast(`Found ${results.length} results`, 'success'); + } + + } catch (error) { + // --- 4. Handle Errors, Including Cancellation --- + if (error.name === 'AbortError') { + // This specific error is thrown when the user clicks "Cancel" + statusText.textContent = 'Search was cancelled.'; + showToast('Search cancelled', 'info'); + displayDownloadsResults([]); // Clear any partial results + } else { + console.error('Search failed:', error); + statusText.textContent = `Search failed: ${error.message}`; + showToast('Search failed', 'error'); + } + } finally { + // --- 5. Clean Up UI Regardless of Outcome --- + searchInput.disabled = false; + searchButton.disabled = false; + cancelButton.classList.add('hidden'); + spinner.classList.add('hidden'); + dots.classList.add('hidden'); + searchAbortController = null; // Clear the controller + } +} + +function displayDownloadsResults(results) { + const resultsArea = document.getElementById('search-results-area'); + if (!resultsArea) return; + + if (!results.length) { + resultsArea.innerHTML = '

    No search results found.

    '; + return; + } + + let html = ''; + results.forEach((result, index) => { + const isAlbum = result.result_type === 'album'; + + if (isAlbum) { + const trackCount = result.tracks ? result.tracks.length : 0; + const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; + + // Generate individual track items + let trackListHtml = ''; + if (result.tracks && result.tracks.length > 0) { + // Detect disc boundaries from track number resets for multi-disc albums + let currentDisc = 1; + let lastTrackNum = 0; + let discBreaks = new Set(); + result.tracks.forEach((track, trackIndex) => { + const tn = track.track_number || 0; + if (trackIndex > 0 && tn > 0 && tn <= lastTrackNum) { + currentDisc++; + discBreaks.add(trackIndex); + } + if (tn > 0) lastTrackNum = tn; + }); + const isMultiDisc = discBreaks.size > 0; + if (isMultiDisc) { + trackListHtml += `
    Disc 1
    `; + } + let discNum = 1; + result.tracks.forEach((track, trackIndex) => { + if (discBreaks.has(trackIndex)) { + discNum++; + trackListHtml += `
    Disc ${discNum}
    `; + } + const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; + const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : ''; + trackListHtml += ` +
    +
    +
    ${escapeHtml(track.title || `Track ${trackIndex + 1}`)}
    +
    + ${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} • ${trackSize} • ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate} +
    +
    +
    + + + +
    +
    + `; + }); + } + + html += ` +
    +
    +
    +
    💿
    +
    +
    ${escapeHtml(result.album_title || result.title || 'Unknown Album')}
    +
    by ${escapeHtml(result.artist || 'Unknown Artist')}
    +
    + ${trackCount} tracks • ${totalSize} • ${escapeHtml(result.quality || 'Mixed')} +
    +
    Shared by ${escapeHtml(result.username || 'Unknown')}
    +
    +
    + + +
    +
    + +
    + `; + } else { + const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; + const bitrateText = result.bitrate ? `${result.bitrate}kbps` : ''; + html += ` +
    +
    🎵
    +
    +
    ${escapeHtml(result.title || 'Unknown Title')}
    +
    by ${escapeHtml(result.artist || 'Unknown Artist')}
    +
    + ${sizeText} • ${escapeHtml(result.quality || 'Unknown')} ${bitrateText} +
    +
    Shared by ${escapeHtml(result.username || 'Unknown')}
    +
    +
    + + + +
    +
    + `; + } + }); + + resultsArea.innerHTML = html; + // Store results globally for download functions + window.currentSearchResults = results; +} + +async function downloadTrack(index) { + const results = window.currentSearchResults; + if (!results || !results[index]) return; + + const track = results[index]; + + try { + const response = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(track) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Download started: ${track.title}`, 'success'); + } else { + showToast(`Download failed: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Download error:', error); + showToast('Failed to start download', 'error'); + } +} + +async function downloadAlbum(index) { + const results = window.currentSearchResults; + if (!results || !results[index]) return; + + const album = results[index]; + + try { + const response = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(album) + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + } else { + showToast(`Album download failed: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Album download error:', error); + showToast('Failed to start album download', 'error'); + } +} + +// Matched download functions +function matchedDownloadTrack(index) { + const results = window.currentSearchResults; + if (!results || !results[index]) return; + + const track = results[index]; + console.log('🎯 Starting matched download for single track:', track); + + // Open matching modal for single track + openMatchingModal(track, false, null); +} + +function matchedDownloadAlbum(index) { + const results = window.currentSearchResults; + if (!results || !results[index]) return; + + const album = results[index]; + console.log('🎯 Starting matched download for album:', album); + + // Open matching modal for album download + openMatchingModal(album, true, album); +} + +function matchedDownloadAlbumTrack(albumIndex, trackIndex) { + const results = window.currentSearchResults; + if (!results || !results[albumIndex]) return; + + const album = results[albumIndex]; + if (!album.tracks || !album.tracks[trackIndex]) return; + + const track = album.tracks[trackIndex]; + + // Ensure track has necessary properties from parent album + track.username = album.username; + track.artist = track.artist || album.artist; + track.album = album.album_title || album.title; + + console.log('🎯 Starting matched download for album track:', track); + + // Open matching modal for single track (from album context) + openMatchingModal(track, false, null); +} + +function toggleAlbumExpansion(albumIndex) { + const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`); + if (!albumCard) return; + + const trackList = albumCard.querySelector('.album-track-list'); + const indicator = albumCard.querySelector('.album-expand-indicator'); + + if (trackList.style.display === 'none' || !trackList.style.display) { + // Expand + trackList.style.display = 'block'; + indicator.textContent = '▼'; + albumCard.classList.add('expanded'); + } else { + // Collapse + trackList.style.display = 'none'; + indicator.textContent = '▶'; + albumCard.classList.remove('expanded'); + } +} + +async function downloadAlbumTrack(albumIndex, trackIndex) { + const results = window.currentSearchResults; + if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return; + + const track = results[albumIndex].tracks[trackIndex]; + + try { + const response = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...track, + result_type: 'track' + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Download started: ${track.title}`, 'success'); + } else { + showToast(`Track download failed: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Track download error:', error); + showToast('Failed to start track download', 'error'); + } +} + +// =============================== +// STREAMING WRAPPER FUNCTIONS +// =============================== + +async function streamTrack(index) { + // Stream a single track from search results + try { + console.log(`🎵 streamTrack called with index: ${index}`); + console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults); + + if (!window.currentSearchResults || !window.currentSearchResults[index]) { + console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); + showToast('Track not found', 'error'); + return; + } + + const result = window.currentSearchResults[index]; + console.log(`🎵 Streaming track:`, result); + + // Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check) + const isStreamingSource = result.username === 'youtube' || result.username === 'tidal' || result.username === 'qobuz' || result.username === 'hifi'; + if (!isStreamingSource && result.filename) { + const format = getFileExtension(result.filename); + console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`); + + const isSupported = isAudioFormatSupported(result.filename); + console.log(`🎵 [STREAM CHECK] Format ${format} supported: ${isSupported}`); + + if (!isSupported) { + showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); + return; + } + } + + await startStream(result); + + } catch (error) { + console.error('Track streaming error:', error); + showToast('Failed to start track stream', 'error'); + } +} + + +async function streamAlbumTrack(albumIndex, trackIndex) { + // Stream a specific track from an album + try { + console.log(`🎵 streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`); + console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults); + + if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) { + console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); + showToast('Album not found', 'error'); + return; + } + + const album = window.currentSearchResults[albumIndex]; + console.log(`🎵 Album data:`, album); + + // Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array) + if (album.username === 'youtube' || album.username === 'tidal' || album.username === 'qobuz' || album.username === 'hifi') { + // For YouTube/Tidal results, the "album" is actually the track itself + const track = album; + const trackData = { + ...track, + username: track.username, + filename: track.filename, + artist: track.artist, + album: track.title, // Use title as album name for player + title: track.title + }; + console.log(`🎵 Streaming YouTube track directly:`, trackData); + await startStream(trackData); + return; + } + + if (!album.tracks || !album.tracks[trackIndex]) { + console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`); + showToast('Track not found in album', 'error'); + return; + } + + const track = album.tracks[trackIndex]; + console.log(`🎵 Streaming album track:`, track); + + // Ensure album tracks have required fields + const trackData = { + ...track, + username: track.username || album.username, + filename: track.filename || track.path, + artist: track.artist || album.artist, + album: track.album || album.title || album.album + }; + + console.log(`🎵 Enhanced track data:`, trackData); + + // Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check) + const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal' || trackData.username === 'qobuz' || trackData.username === 'hifi'; + if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) { + const format = getFileExtension(trackData.filename); + showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error'); + return; + } + + await startStream(trackData); + + } catch (error) { + console.error('Album track streaming error:', error); + showToast('Failed to start track stream', 'error'); + } +} + +async function loadArtistsData() { + try { + const response = await fetch(API.artists); + const data = await response.json(); + + const artistsGrid = document.getElementById('artists-grid'); + if (data.artists && data.artists.length) { + artistsGrid.innerHTML = data.artists.map(artist => ` +
    +
    + ${artist.image ? + `${escapeHtml(artist.name)}` : + '
    🎵
    ' + } +
    +
    +
    ${escapeHtml(artist.name)}
    +
    ${artist.album_count || 0} albums
    +
    +
    + `).join(''); + } else { + artistsGrid.innerHTML = '
    No artists found
    '; + } + } catch (error) { + console.error('Error loading artists data:', error); + document.getElementById('artists-grid').innerHTML = '
    Error loading artists
    '; + } +} + +// =============================== +// UTILITY FUNCTIONS +// =============================== + +function showLoadingOverlay(message = 'Loading...') { + const overlay = document.getElementById('loading-overlay'); + const messageElement = overlay.querySelector('.loading-message'); + messageElement.textContent = message; + overlay.classList.remove('hidden'); +} + +function hideLoadingOverlay() { + document.getElementById('loading-overlay').classList.add('hidden'); +} + +// ================================================================================== +// NOTIFICATION SYSTEM — Compact toasts + bell button + notification history panel +// ================================================================================== + +const _notifState = { + history: [], + unreadCount: 0, + panelOpen: false, + currentToast: null, + toastTimer: null, + maxHistory: 50, +}; +const _recentToastKeys = new Map(); + +const _notifIcons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; + +function showToast(message, type = 'success', helpSection = null) { + const toastKey = `${type}:${message}`; + const now = Date.now(); + + // Deduplication — suppress identical toasts within 5 seconds + if (_recentToastKeys.has(toastKey) && now - _recentToastKeys.get(toastKey) < 5000) return; + _recentToastKeys.set(toastKey, now); + for (const [k, t] of _recentToastKeys) { if (now - t > 10000) _recentToastKeys.delete(k); } + + // Add to notification history + const entry = { id: now + Math.random(), message, type, helpSection, timestamp: now, read: false }; + _notifState.history.unshift(entry); + if (_notifState.history.length > _notifState.maxHistory) _notifState.history.pop(); + _notifState.unreadCount++; + _updateNotifBadge(); + + // Show compact toast — dismiss current if showing + const container = document.getElementById('toast-container'); + if (!container) return; + + if (_notifState.currentToast && container.contains(_notifState.currentToast)) { + _notifState.currentToast.classList.add('toast-exit'); + const old = _notifState.currentToast; + setTimeout(() => { if (container.contains(old)) container.removeChild(old); }, 200); + } + if (_notifState.toastTimer) clearTimeout(_notifState.toastTimer); + + const icon = _notifIcons[type] || 'ℹ'; + const toast = document.createElement('div'); + toast.className = `toast-compact toast-${type}`; + toast.innerHTML = `${icon}${_escToast(message)}`; + if (helpSection) { + const link = document.createElement('span'); + link.className = 'toast-compact-link'; + link.textContent = 'Learn more →'; + link.onclick = e => { e.stopPropagation(); if (typeof navigateToDocsSection === 'function') navigateToDocsSection(helpSection); }; + toast.appendChild(link); + } + toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); }; + + container.appendChild(toast); + requestAnimationFrame(() => toast.classList.add('toast-enter')); + _notifState.currentToast = toast; + + _notifState.toastTimer = setTimeout(() => { + if (container.contains(toast)) { + toast.classList.add('toast-exit'); + setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300); + } + _notifState.currentToast = null; + }, helpSection ? 5000 : 3500); +} + +function _escToast(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } +function _escAttr(s) { return _escToast(s).replace(/'/g, "\\'").replace(/\n/g, ' ').replace(/\r/g, ''); } + +function _updateNotifBadge() { + const badge = document.getElementById('notif-bell-badge'); + if (badge) { + badge.textContent = _notifState.unreadCount > 99 ? '99+' : _notifState.unreadCount; + badge.style.display = _notifState.unreadCount > 0 ? '' : 'none'; + } +} + +function toggleNotifPanel() { + if (_notifState.panelOpen) { + _closeNotifPanel(); + } else { + _openNotifPanel(); + } +} + +function _openNotifPanel() { + _closeNotifPanel(); // Remove existing + + _notifState.panelOpen = true; + _notifState.unreadCount = 0; + _notifState.history.forEach(e => e.read = true); + _updateNotifBadge(); + + const btn = document.getElementById('notif-bell-btn'); + const panel = document.createElement('div'); + panel.id = 'notif-panel'; + panel.className = 'notif-panel'; + + const entries = _notifState.history; + + panel.innerHTML = ` +
    + Notifications + ${entries.length > 0 ? '' : ''} +
    +
    + ${entries.length === 0 ? '
    No notifications yet
    ' : + entries.map(e => { + const icon = _notifIcons[e.type] || 'ℹ'; + const ago = _notifTimeAgo(e.timestamp); + const unreadDot = e.read ? '' : ''; + const learnMore = e.helpSection ? `Learn more →` : ''; + return ` +
    + ${unreadDot} + ${icon} +
    +
    ${_escToast(e.message)}
    + +
    +
    `; + }).join('')} +
    + `; + + document.body.appendChild(panel); + + // Position above the bell button + if (btn) { + const rect = btn.getBoundingClientRect(); + panel.style.right = (window.innerWidth - rect.right) + 'px'; + panel.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; + } + + requestAnimationFrame(() => panel.classList.add('visible')); + + // Close on outside click + setTimeout(() => { + const closeHandler = e => { + if (!panel.contains(e.target) && e.target.id !== 'notif-bell-btn') { + _closeNotifPanel(); + document.removeEventListener('click', closeHandler); + } + }; + document.addEventListener('click', closeHandler); + }, 100); +} + +function _closeNotifPanel() { + _notifState.panelOpen = false; + const panel = document.getElementById('notif-panel'); + if (panel) { + panel.classList.remove('visible'); + setTimeout(() => panel.remove(), 200); + } +} + +function _clearNotifHistory() { + _notifState.history = []; + _notifState.unreadCount = 0; + _updateNotifBadge(); + _closeNotifPanel(); +} + +function _notifTimeAgo(ts) { + const s = Math.floor((Date.now() - ts) / 1000); + if (s < 5) return 'just now'; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + return `${Math.floor(h / 24)}d ago`; +} + +// ================================================================================== +// Music video download handler — defined at top level so both enhanced and global search can use it +function _downloadMusicVideo(cardEl, video) { + if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return; + cardEl.classList.add('downloading'); + cardEl.onclick = null; + + const playBtn = cardEl.querySelector('.enh-video-play'); + const progressRing = cardEl.querySelector('.enh-video-progress-ring'); + const progressBar = cardEl.querySelector('.enh-video-progress-bar'); + const doneIcon = cardEl.querySelector('.enh-video-done'); + const errorIcon = cardEl.querySelector('.enh-video-error'); + + if (playBtn) playBtn.classList.add('hidden'); + if (progressRing) progressRing.classList.remove('hidden'); + + fetch('/api/music-video/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ video_id: video.video_id, url: video.url, title: video.title, channel: video.channel }), + }).then(res => { + if (!res.ok) throw new Error('Download request failed'); + const circumference = 97.4; + const pollInterval = setInterval(async () => { + try { + const statusRes = await fetch(`/api/music-video/status/${video.video_id}`); + const status = await statusRes.json(); + if (progressBar && status.progress > 0) { + progressBar.style.strokeDashoffset = circumference - (status.progress / 100) * circumference; + } + if (status.status === 'completed') { + clearInterval(pollInterval); + cardEl.classList.remove('downloading'); + cardEl.classList.add('completed'); + if (progressRing) progressRing.classList.add('hidden'); + if (doneIcon) doneIcon.classList.remove('hidden'); + } else if (status.status === 'error') { + clearInterval(pollInterval); + cardEl.classList.remove('downloading'); + cardEl.classList.add('errored'); + if (progressRing) progressRing.classList.add('hidden'); + if (errorIcon) errorIcon.classList.remove('hidden'); + cardEl.onclick = () => _downloadMusicVideo(cardEl, video); + } + } catch (e) { } + }, 500); + }).catch(e => { + cardEl.classList.remove('downloading'); + if (progressRing) progressRing.classList.add('hidden'); + if (playBtn) playBtn.classList.remove('hidden'); + if (errorIcon) errorIcon.classList.remove('hidden'); + cardEl.onclick = () => _downloadMusicVideo(cardEl, video); + }); +} + +// Global search video click — decodes base64 video data and delegates to _downloadMusicVideo +function _gsClickVideo(cardEl) { + try { + const encoded = cardEl.dataset.video; + const video = JSON.parse(decodeURIComponent(escape(atob(encoded)))); + _downloadMusicVideo(cardEl, video); + } catch (e) { + console.error('Failed to parse video data:', e); + } +} + +// GLOBAL SEARCH BAR — Spotlight-style search from anywhere +// ================================================================================== + +// Popover-only state. Query/source/cache/config all live in `_gsController` +// (shared with the Search page via createSearchController in shared-helpers.js). +const _gsState = { + active: false, + _lastInteraction: 0, + debounceTimer: null, +}; + +// Shared source-picker controller — built on DOM-ready in `_doInit`. +let _gsController = null; + +(function initGlobalSearch() { + // Defer init until DOM is ready + const _doInit = () => { + const bar = document.getElementById('gsearch-bar'); + const input = document.getElementById('gsearch-input'); + const results = document.getElementById('gsearch-results'); + if (!input || !bar || !results) return; + + // Build the stable results-panel structure up front so the controller + // has a sourceRow element to render into on its first _notify(). + results.innerHTML = ` +
    + +
    + `; + + _gsController = createSearchController({ + sourceRowElement: document.getElementById('gsearch-source-row'), + iconClassPrefix: 'gsearch', + onStateChange: _gsRenderFromState, + onSoulseekSelected: (query) => _gsNavigateToSearchPage(query, 'soulseek'), + onUnconfiguredClick: (src) => { + _gsDeactivate(); + openSettingsForSource(src); + }, + }); + + bar.addEventListener('click', () => input.focus()); + + input.addEventListener('focus', () => { + bar.classList.add('active'); + const aura = document.getElementById('gsearch-aura'); + if (aura) aura.classList.add('active'); + _gsState.active = true; + const shortcut = document.getElementById('gsearch-shortcut'); + if (shortcut) shortcut.style.display = 'none'; + // Always redraw on focus so the source icon row is current + // (cache dots, active state, etc.). init() is a no-op after the + // first call — safe to invoke on every focus. + _gsController.init().then(() => _gsRenderFromState(_gsController.state)); + }); + + // No blur handler — closing is handled by click-outside and Escape only + // This prevents tab switching and result clicks from closing the panel + + const clearBtn = document.getElementById('gsearch-clear'); + + input.addEventListener('input', () => { + const q = input.value.trim(); + if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none'; + if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); + if (q.length < 2) { _gsHideResults(); return; } + _gsState.debounceTimer = setTimeout(() => _gsController.submitQuery(q), 300); + }); + + if (clearBtn) { + clearBtn.addEventListener('click', e => { + e.stopPropagation(); + input.value = ''; + clearBtn.style.display = 'none'; + // Drop cache so the next search starts clean, but don't + // auto-fire a fetch for an empty query. + if (_gsController) { + _gsController.state.query = ''; + _gsController.state.sources = {}; + _gsController.state.fallbacks = {}; + _gsController.state.loadingSources = new Set(); + _gsController.renderSourceRow(); + } + _gsHideResults(); + input.focus(); + }); + } + + input.addEventListener('keydown', e => { + if (e.key === 'Enter') { + e.preventDefault(); + if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); + const q = input.value.trim(); + if (q.length >= 2) _gsController.submitQuery(q); + } else if (e.key === 'Escape') { + _gsDeactivate(); + input.blur(); + } + }); + + // Keyboard shortcuts + document.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); input.focus(); return; } + if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); input.focus(); } + }); + + // Click outside to close — uses delayed check because tab clicks replace DOM + document.addEventListener('click', e => { + if (!_gsState.active) return; + // Skip if click was recent interaction with search system (within 100ms of a switch) + if (_gsState._lastInteraction && Date.now() - _gsState._lastInteraction < 200) return; + setTimeout(() => { + if (!_gsState.active) return; + const freshBar = document.getElementById('gsearch-bar'); + const freshResults = document.getElementById('gsearch-results'); + const target = e.target; + if (freshBar?.contains(target) || freshResults?.contains(target)) return; + _gsDeactivate(); + }, 100); + }); + + // Collapse on sidebar navigation + hide on downloads page + document.addEventListener('click', e => { + if (e.target.closest('.sidebar-link, .nav-item, .back-btn')) { + if (_gsState.active) _gsDeactivate(); + // Check after navigation which page we're on + setTimeout(_gsUpdateVisibility, 200); + } + }); + }; + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { _doInit(); _gsUpdateVisibility(); }); + else { _doInit(); setTimeout(_gsUpdateVisibility, 500); } +})(); + +function _gsUpdateVisibility() { + const bar = document.getElementById('gsearch-bar'); + const aura = document.getElementById('gsearch-aura'); + if (!bar) return; + // Hide on the Search page where the unified search already exists. Accept the + // legacy 'downloads' id for callers that predate the page rename. + const onSearchPage = typeof currentPage !== 'undefined' && (currentPage === 'search' || currentPage === 'downloads'); + bar.style.display = onSearchPage ? 'none' : ''; + if (aura) aura.classList.toggle('hidden', onSearchPage); + if (onSearchPage && _gsState.active) _gsDeactivate(); +} + +function _gsDeactivate() { + const bar = document.getElementById('gsearch-bar'); + const aura = document.getElementById('gsearch-aura'); + const shortcut = document.getElementById('gsearch-shortcut'); + if (bar) bar.classList.remove('active'); + if (aura) aura.classList.remove('active'); + if (shortcut) shortcut.style.display = ''; + _gsState.active = false; + _gsHideResults(); +} + +function _gsHideResults() { + const r = document.getElementById('gsearch-results'); + if (r) r.classList.remove('visible'); +} + +function _gsShowResults() { + const r = document.getElementById('gsearch-results'); + if (r && r.innerHTML.trim()) r.classList.add('visible'); +} + +function _gsNavigateToSearchPage(query, src) { + _gsDeactivate(); + if (typeof navigateToPage !== 'function') return; + navigateToPage('search'); + // After the page mounts, mirror the query into whichever input drives the + // requested source. Soulseek goes through the basic-search file flow, not + // the enhanced metadata flow — without this branch the Search page would + // run /api/enhanced-search instead of /api/search and the user would get + // metadata results when they clicked the Soulseek icon. + setTimeout(() => { + if (src === 'soulseek') { + const basicInput = document.getElementById('downloads-search-input'); + if (basicInput && query) basicInput.value = query; + + // Sync the Search page controller's state.query to the widget's + // query BEFORE clicking the Soulseek icon. Otherwise the icon + // click fires onSoulseekSelected(state.query) where state.query + // is whatever the user last typed on /search (often stale), and + // the callback would overwrite basicInput.value with that stale + // value before running performDownloadsSearch. + if (typeof _searchPageController !== 'undefined' && _searchPageController) { + _searchPageController.state.query = query || ''; + } + + const soulseekIcon = document.querySelector('#enh-source-row [data-source="soulseek"]'); + if (soulseekIcon) { + soulseekIcon.click(); + return; + } + // Fallback: controller hasn't initialized yet (slow /api/settings + // fetches at first /search visit). Run the search directly + swap + // sections so the user still gets results. Icon row will catch up + // visually on the next render. + const basicSection = document.getElementById('basic-search-section'); + const enhancedSection = document.getElementById('enhanced-search-section'); + if (basicSection) basicSection.classList.add('active'); + if (enhancedSection) enhancedSection.classList.remove('active'); + if (basicInput && basicInput.value && typeof performDownloadsSearch === 'function') { + performDownloadsSearch(); + } + return; + } + const input = document.getElementById('enhanced-search-input'); + if (input && query) { + input.value = query; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); +} + +// Re-render the results body + fallback banner whenever the controller's +// state changes (cache hit, fetch settle, query reset). The icon row itself +// is rendered by the controller into `#gsearch-source-row`. +function _gsRenderFromState(state) { + const results = document.getElementById('gsearch-results'); + const body = document.getElementById('gsearch-body'); + if (!results || !body) return; + + // Fallback banner — independent of body content. + const banner = document.getElementById('gsearch-fallback-banner'); + const activeSrc = state.activeSource; + const actual = state.fallbacks[activeSrc]; + if (banner) { + if (actual && actual !== activeSrc) { + const clicked = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc; + const served = (SOURCE_LABELS[actual] || {}).text || actual; + banner.textContent = `${clicked} unavailable — showing ${served}.`; + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + } + + // Soulseek has its own dedicated handler (navigate to /search); there's + // nothing to render in the popover. + if (activeSrc === 'soulseek') return; + + const cached = state.sources[activeSrc]; + const isLoading = state.loadingSources.has(activeSrc); + const query = state.query; + + // No query yet — prompt. + if (!query) { + body.innerHTML = '
    Type to search…
    '; + results.classList.add('visible'); + return; + } + + // In-flight, nothing cached yet — loading state. + if (isLoading && !cached) { + const info = SOURCE_LABELS[activeSrc]; + body.innerHTML = `
    Searching ${_escToast((info && info.text) || activeSrc)}...
    `; + results.classList.add('visible'); + return; + } + + // No cache, not loading — source switch before fetch fired (e.g. empty query). + if (!cached) { + body.innerHTML = '
    Click the source above to search.
    '; + results.classList.add('visible'); + return; + } + + // Music Videos — video grid instead of regular sections. + if (activeSrc === 'youtube_videos') { + const videos = cached.videos || []; + let h = `
    Results${videos.length} videos
    `; + h += '
    '; + if (videos.length === 0) { + h += `
    No music videos found for "${_escToast(query)}"
    `; + } else { + h += '
    🎬 Music Videos
    '; + h += '
    '; + h += videos.map(v => { + const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; + const views = v.view_count >= 1000000 ? `${(v.view_count / 1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count / 1000).toFixed(1)}K` : (v.view_count || ''); + const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v)))); + return `
    +
    + + + ${dur ? `${dur}` : ''}
    +
    ${_escToast(v.title)}
    ${_escToast(v.channel)}${views ? ` · ${views} views` : ''}
    +
    `; + }).join(''); + h += '
    '; + } + h += '
    '; + body.innerHTML = h; + results.classList.add('visible'); + return; + } + + // Standard metadata source — library + artists + albums + singles + tracks. + const dbArtists = cached.db_artists || []; + const artists = cached.artists || []; + const allAlbums = cached.albums || []; + const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); + const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); + const tracks = cached.tracks || []; + const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length; + + if (total === 0) { + body.innerHTML = `
    No results for "${_escToast(query)}"
    Try different keywords or check spelling
    `; + results.classList.add('visible'); + return; + } + + const srcLabel = (SOURCE_LABELS[activeSrc] || {}).text || activeSrc || ''; + + let h = ''; + h += `
    Results${total} items
    `; + h += '
    '; + + if (dbArtists.length) { + h += '
    📚 In Your Library
    '; + h += dbArtists.map(a => `
    ${a.image_url ? `` : '🎤'}
    ${_escToast(a.name)}
    Library
    `).join(''); + h += '
    '; + } + + if (artists.length) { + h += `
    🎤 Artists ${srcLabel}
    `; + h += artists.map(a => `
    ${a.image_url ? `` : '🎤'}
    ${_escToast(a.name)}
    `).join(''); + h += '
    '; + } + + if (albums.length) { + h += `
    💿 Albums ${srcLabel}
    `; + h += albums.map(a => { + const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); + const yr = a.release_date ? a.release_date.substring(0, 4) : ''; + const img = (a.image_url || '').replace(/'/g, "\\'"); + return `
    ${a.image_url ? `` : '💿'}
    ${_escToast(a.name)}
    ${_escToast(ar)}${yr ? ` · ${yr}` : ''}
    `; + }).join(''); + h += '
    '; + } + + if (singles.length) { + h += `
    🎶 Singles & EPs ${srcLabel}
    `; + h += singles.map(a => { + const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); + const img = (a.image_url || '').replace(/'/g, "\\'"); + return `
    ${a.image_url ? `` : '🎶'}
    ${_escToast(a.name)}
    ${_escToast(ar)}
    `; + }).join(''); + h += '
    '; + } + + if (tracks.length) { + h += `
    🎵 Tracks ${srcLabel}
    `; + h += tracks.map(t => { + const ar = t.artist || (t.artists ? t.artists.join(', ') : ''); + const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + return `
    ${t.image_url ? `` : '🎵'}
    ${_escToast(t.name)}
    ${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}
    ${dur}
    `; + }).join(''); + h += '
    '; + } + + h += '
    '; + body.innerHTML = h; + results.classList.add('visible'); + + // Lazy load artist images for sources that don't provide them (iTunes/Deezer). + _gsLazyLoadArtistImages(); + + // Library ownership check — adds "In Library" badges + swaps play buttons. + // Idempotent enough to run on every render with a cache hit; the old flow + // also fired it on both cache-hit and fetch-settle. + setTimeout(() => _gsLibraryCheck(), 200); +} + +async function _gsLazyLoadArtistImages() { + const grid = document.getElementById('gsearch-artists-grid'); + if (!grid) return; + const cards = grid.querySelectorAll('[data-needs-image="true"]'); + if (cards.length === 0) return; + const activeSrc = (_gsController && _gsController.state.activeSource) || 'spotify'; + + for (const card of cards) { + const artistId = card.dataset.artistId; + if (!artistId) continue; + try { + // Pass the artist name so MusicBrainz lookups (which have no + // artist art) can resolve the image by name on a fallback source. + const params = new URLSearchParams({ source: activeSrc }); + if (card.dataset.artistName) params.set('name', card.dataset.artistName); + const res = await fetch(`/api/artist/${artistId}/image?${params}`); + const data = await res.json(); + if (data.success && data.image_url) { + const artDiv = card.querySelector('.gsearch-item-art'); + if (artDiv) artDiv.innerHTML = ``; + card.removeAttribute('data-needs-image'); + } + } catch (e) { /* ignore */ } + } +} + +function _gsClickArtist(id, name, isLibrary) { + _gsDeactivate(); + const activeSource = _gsController && _gsController.state.activeSource; + const source = isLibrary ? null : (activeSource || null); + navigateToArtistDetail(id, name, source); +} + +async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { + _gsDeactivate(); + // Same flow as handleEnhancedSearchAlbumClick — fetch album, open download modal + showLoadingOverlay('Loading album...'); + try { + const params = new URLSearchParams({ name: albumName, artist: artistName }); + if (source && source !== 'spotify') params.set('source', source); + const response = await fetch(`/api/spotify/album/${albumId}?${params}`); + if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); + const albumData = await response.json(); + + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + hideLoadingOverlay(); + showToast(`No tracks available for "${albumName}"`, 'warning'); + return; + } + + const enrichedTracks = albumData.tracks.map(t => ({ + ...t, + album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks } + })); + + const virtualPlaylistId = `enhanced_search_album_${albumId}`; + const firstArtist = (albumData.artists || [])[0] || {}; + const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' }; + const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] }; + + await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false); + + // Register download bubble (same pattern as enhanced search) + registerSearchDownload( + { + id: albumData.id, + name: albumData.name, + artist: artistName, + image_url: albumData.images?.[0]?.url || imageUrl || null, + images: albumData.images || [] + }, + 'album', + virtualPlaylistId, + artistName + ); + + } catch (e) { + hideLoadingOverlay(); + showToast('Failed to load album: ' + e.message, 'error'); + } +} + +async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl, durationMs) { + _gsDeactivate(); + + // Build enriched track + open download modal directly (same as enhanced search) + const virtualPlaylistId = `gsearch_track_${trackId || (artistName + '_' + trackName).replace(/\s/g, '_')}`; + const enrichedTrack = { + id: trackId || '', + name: trackName, + artists: [artistName], + album: { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], total_tracks: 1 }, + duration_ms: durationMs || 0, + image_url: imageUrl || '', + }; + const albumObject = { + name: albumName || '', id: null, album_type: 'single', + images: imageUrl ? [{ url: imageUrl }] : [], + artists: [{ name: artistName }], total_tracks: 1, + }; + const artistObject = { id: null, name: artistName }; + const playlistName = `${artistName} - ${trackName}`; + + try { + showLoadingOverlay('Loading track...'); + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false + ); + + // Register download bubble (same pattern as enhanced search) + registerSearchDownload( + { + id: trackId || '', + name: trackName, + artist: artistName, + image_url: imageUrl || null, + images: imageUrl ? [{ url: imageUrl }] : [] + }, + 'track', + virtualPlaylistId, + artistName + ); + } catch (e) { + console.error('Error opening track download:', e); + // Fallback: navigate to the unified Search page + navigateToPage('search'); + setTimeout(() => { + const input = document.getElementById('enhanced-search-input'); + if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); } + }, 300); + } finally { + hideLoadingOverlay(); + } +} + +async function _gsPlayTrack(trackName, artistName, albumName) { + try { + showToast('Searching for stream...', 'info'); + const res = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName }) + }); + const data = await res.json(); + if (data.success && data.result) { + if (typeof startStream === 'function') { + startStream(data.result); + } else { + showToast('Streaming not available', 'error'); + } + } else { + showToast(data.error || 'No stream found', 'error'); + } + } catch (e) { + showToast('Stream failed: ' + e.message, 'error'); + } +} + +// Async library check for global search results — adds badges + swaps play buttons +async function _gsLibraryCheck() { + try { + if (!_gsController) return; + const src = _gsController.state.sources[_gsController.state.activeSource] || {}; + const allAlbums = src.albums || []; + const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); + const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); + const tracks = src.tracks || []; + if (!allAlbums.length && !tracks.length) return; + + const res = await fetch('/api/enhanced-search/library-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })), + tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })), + }) + }); + const checkData = await res.json(); + + // Add "In Library" badges to albums — match by index against allAlbums order + const albumResults = checkData.albums || []; + let albumIdx = 0; + // Albums section + document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => { + // Find all gsearch-item elements and tag ones that are albums + const sections = body.querySelectorAll('.gsearch-section-header'); + sections.forEach(header => { + const text = header.textContent; + const isAlbumSection = text.includes('Albums') || text.includes('Singles'); + if (!isAlbumSection) return; + const grid = header.nextElementSibling; + if (!grid) return; + const items = grid.querySelectorAll('.gsearch-item'); + items.forEach(item => { + if (albumIdx < albumResults.length && albumResults[albumIdx]) { + if (!item.querySelector('.gsearch-item-badge')) { + const badge = document.createElement('span'); + badge.className = 'gsearch-item-badge'; + badge.textContent = 'In Library'; + item.appendChild(badge); + } + } + albumIdx++; + }); + }); + }); + + // Tag tracks + swap play buttons for library playback + const trackResults = checkData.tracks || []; + const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track'); + trackEls.forEach((el, i) => { + const tr = trackResults[i]; + if (tr && tr.in_library) { + // Add badge + if (!el.querySelector('.gsearch-item-badge')) { + const badge = document.createElement('span'); + badge.className = 'gsearch-item-badge'; + badge.textContent = 'In Library'; + badge.style.marginRight = '4px'; + el.querySelector('.gsearch-track-dur')?.before(badge); + } + + // Swap play button to library playback + if (tr.file_path) { + const playBtn = el.querySelector('.gsearch-play-btn'); + if (playBtn) { + const newBtn = playBtn.cloneNode(true); + newBtn.removeAttribute('onclick'); + newBtn.title = 'Play from library'; + newBtn.style.background = 'rgba(76,175,80,0.15)'; + newBtn.style.color = '#4caf50'; + newBtn.addEventListener('click', e => { + e.stopPropagation(); + playLibraryTrack( + { id: tr.track_id, title: tr.title, file_path: tr.file_path, _stats_image: tr.album_thumb_url || null }, + tr.album_title || '', + tr.artist_name || '' + ); + }); + playBtn.replaceWith(newBtn); + } + } + } else if (tr && tr.in_wishlist) { + if (!el.querySelector('.gsearch-item-badge')) { + const badge = document.createElement('span'); + badge.className = 'gsearch-item-badge gsearch-wishlist-badge'; + badge.textContent = 'In Wishlist'; + badge.style.marginRight = '4px'; + el.querySelector('.gsearch-track-dur')?.before(badge); + } + } + }); + } catch (e) { + // Non-critical + } +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Escape a value for safe use inside a single-quoted JS string literal + * within a double-quoted HTML attribute (e.g. onclick="fn('${val}')"). + * + * Layer 1 (JS): escape \ and ' so the JS string parses correctly. + * Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly. + * The browser applies these in reverse: HTML-decode first, then JS-execute. + */ +function escapeForInlineJs(str) { + if (str == null) return ''; + return String(str) + .replace(/\\/g, '\\\\') // JS: literal backslash + .replace(/'/g, "\\'") // JS: single quote + .replace(/&/g, '&') // HTML: ampersand + .replace(/"/g, '"') // HTML: double quote + .replace(//g, '>'); // HTML: greater-than +} + +function formatArtists(artists) { + if (!artists || !Array.isArray(artists)) { + return 'Unknown Artist'; + } + + // Handle both string arrays and object arrays with 'name' property + const artistNames = artists.map(artist => { + let artistName; + if (typeof artist === 'string') { + artistName = artist; + } else if (artist && typeof artist === 'object' && artist.name) { + artistName = artist.name; + } else { + artistName = 'Unknown Artist'; + } + + // Clean featured artists from the name + return cleanArtistName(artistName); + }); + + return artistNames.join(', ') || 'Unknown Artist'; +} + +async function checkForUpdates() { + try { + const res = await fetch('/api/update-check'); + if (!res.ok) return; + const data = await res.json(); + const btn = document.querySelector('.version-button'); + if (!btn) return; + if (data.update_available) { + const dismissed = localStorage.getItem('soulsync-update-dismissed'); + if (dismissed !== data.latest_sha) { + // Add glow class + btn.classList.add('update-available'); + // Add UPDATE badge if not already present + if (!btn.querySelector('.update-badge')) { + const badge = document.createElement('span'); + badge.className = 'update-badge'; + badge.textContent = 'UPDATE'; + btn.appendChild(badge); + } + // Show toast on first detection (not if already notified this session) + const notified = sessionStorage.getItem('soulsync-update-notified'); + if (notified !== data.latest_sha) { + sessionStorage.setItem('soulsync-update-notified', data.latest_sha); + showToast(data.is_docker + ? 'A new SoulSync update has been pushed to the repo — Docker image will be updated soon!' + : 'A new SoulSync update is available!', 'info'); + } + } + } else { + btn.classList.remove('update-available'); + const badge = btn.querySelector('.update-badge'); + if (badge) badge.remove(); + } + } catch (e) { + console.debug('Update check failed:', e); + } +} + +async function showVersionInfo() { + // Check update status before dismissing so we can pass it to the modal + let updateInfo = null; + const btn = document.querySelector('.version-button'); + const hadUpdate = btn && btn.classList.contains('update-available'); + + // Dismiss update glow when user opens the modal + if (hadUpdate) { + btn.classList.remove('update-available'); + const badge = btn.querySelector('.update-badge'); + if (badge) badge.remove(); + try { + const updateRes = await fetch('/api/update-check'); + if (updateRes.ok) { + updateInfo = await updateRes.json(); + if (updateInfo.latest_sha) { + localStorage.setItem('soulsync-update-dismissed', updateInfo.latest_sha); + } + } + } catch (e) { /* ignore */ } + } + + try { + console.log('Fetching version info...'); + + // Fetch version data from API + const response = await fetch('/api/version-info'); + if (!response.ok) { + throw new Error('Failed to fetch version info'); + } + + const versionData = await response.json(); + console.log('Version data received:', versionData); + + // Populate modal content + populateVersionModal(versionData, hadUpdate ? updateInfo : null); + + // Show modal + const modalOverlay = document.getElementById('version-modal-overlay'); + modalOverlay.classList.remove('hidden'); + + console.log('Version modal opened'); + + } catch (error) { + console.error('Error showing version info:', error); + showToast('Failed to load version information', 'error'); + } +} + +function closeVersionModal() { + const modalOverlay = document.getElementById('version-modal-overlay'); + modalOverlay.classList.add('hidden'); + console.log('Version modal closed'); +} + +function populateVersionModal(versionData, updateInfo) { + const container = document.getElementById('version-content-container'); + if (!container) { + console.error('Version content container not found'); + return; + } + + // Update header with dynamic data + const titleElement = document.querySelector('.version-modal-title'); + const subtitleElement = document.querySelector('.version-modal-subtitle'); + + if (titleElement) titleElement.textContent = versionData.title; + if (subtitleElement) subtitleElement.textContent = versionData.subtitle; + + // Clear existing content + container.innerHTML = ''; + + // Show update banner if an update was available when modal was opened + if (updateInfo && updateInfo.update_available) { + const banner = document.createElement('div'); + banner.className = 'version-update-banner'; + const isDocker = updateInfo.is_docker; + banner.innerHTML = ` +
    +
    + ${isDocker ? 'Repo update detected' : 'New update available'} + ${isDocker + ? 'A new update has been pushed to the repo. The Docker image will be updated soon — no action needed yet.' + : `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`} +
    + `; + container.appendChild(banner); + } + + // Create sections + versionData.sections.forEach(section => { + const sectionDiv = document.createElement('div'); + sectionDiv.className = 'version-feature-section'; + + // Section title + const titleDiv = document.createElement('div'); + titleDiv.className = 'version-section-title'; + titleDiv.textContent = section.title; + sectionDiv.appendChild(titleDiv); + + // Section description + const descDiv = document.createElement('div'); + descDiv.className = 'version-section-description'; + descDiv.textContent = section.description; + sectionDiv.appendChild(descDiv); + + // Features list + const featuresList = document.createElement('ul'); + featuresList.className = 'version-feature-list'; + + section.features.forEach(feature => { + const featureItem = document.createElement('li'); + featureItem.className = 'version-feature-item'; + featureItem.textContent = feature; + featuresList.appendChild(featureItem); + }); + + sectionDiv.appendChild(featuresList); + + // Usage note (if present) + if (section.usage_note) { + const usageDiv = document.createElement('div'); + usageDiv.className = 'version-usage-note'; + usageDiv.textContent = `💡 ${section.usage_note}`; + sectionDiv.appendChild(usageDiv); + } + + container.appendChild(sectionDiv); + }); + + console.log('Version modal content populated'); +} + +// =============================== +// ADDITIONAL STYLES FOR SEARCH RESULTS +// =============================== + +// Add dynamic styles for search results (since they're created dynamically) +const additionalStyles = ` + +`; + +// Inject additional styles +document.head.insertAdjacentHTML('beforeend', additionalStyles); + +// ============================================================================ + diff --git a/webui/static/enrichment.js b/webui/static/enrichment.js new file mode 100644 index 00000000..3a361baf --- /dev/null +++ b/webui/static/enrichment.js @@ -0,0 +1,3552 @@ +// MUSICBRAINZ ENRICHMENT UI - PHASE 5 WEB UI +// ============================================================================ + +/** + * Poll MusicBrainz status every 2 seconds and update UI + */ +async function updateMusicBrainzStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/musicbrainz/status'); + if (!response.ok) { console.warn('MusicBrainz status endpoint unavailable'); return; } + const data = await response.json(); + updateMusicBrainzStatusFromData(data); + } catch (error) { + console.error('Error updating MusicBrainz status:', error); + } +} + +function updateMusicBrainzStatusFromData(data) { + const button = document.getElementById('musicbrainz-button'); + if (!button) return; + + // Update button state classes + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + // Update tooltip content + const tooltipStatus = document.getElementById('mb-tooltip-status'); + const tooltipCurrent = document.getElementById('mb-tooltip-current'); + const tooltipProgress = document.getElementById('mb-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { + tooltipStatus.textContent = 'Complete'; + } else if (data.running && !data.paused) { + tooltipStatus.textContent = 'Running'; + } else if (data.paused) { + tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; + } else { + tooltipStatus.textContent = 'Idle'; + } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + const type = data.current_item.type || 'item'; + const name = data.current_item.name; + tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + } else { + tooltipCurrent.textContent = 'No active matches'; + } + } + + if (tooltipProgress && data.progress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + +/** + * Toggle MusicBrainz enrichment pause/resume + */ +async function toggleMusicBrainzEnrichment() { + try { + const button = document.getElementById('musicbrainz-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/musicbrainz/pause' : '/api/musicbrainz/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} MusicBrainz enrichment`); + } + + // Immediately update UI + await updateMusicBrainzStatus(); + + console.log(`✅ MusicBrainz enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling MusicBrainz enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize MusicBrainz UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('musicbrainz-button'); + if (button) { + button.addEventListener('click', toggleMusicBrainzEnrichment); + // Start polling + updateMusicBrainzStatus(); + setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds + console.log('✅ MusicBrainz UI initialized'); + } + }); +} else { + const button = document.getElementById('musicbrainz-button'); + if (button) { + button.addEventListener('click', toggleMusicBrainzEnrichment); + // Start polling + updateMusicBrainzStatus(); + setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds + console.log('✅ MusicBrainz UI initialized'); + } +} + +// ============================================================================ +// AUDIODB ENRICHMENT UI +// ============================================================================ + +/** + * Poll AudioDB status every 2 seconds and update UI + */ +async function updateAudioDBStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/audiodb/status'); + if (!response.ok) { console.warn('AudioDB status endpoint unavailable'); return; } + const data = await response.json(); + updateAudioDBStatusFromData(data); + } catch (error) { + console.error('Error updating AudioDB status:', error); + } +} + +function updateAudioDBStatusFromData(data) { + const button = document.getElementById('audiodb-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('audiodb-tooltip-status'); + const tooltipCurrent = document.getElementById('audiodb-tooltip-current'); + const tooltipProgress = document.getElementById('audiodb-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + const type = data.current_item.type || 'item'; + const name = data.current_item.name; + tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + } else { + tooltipCurrent.textContent = 'No active matches'; + } + } + + if (tooltipProgress && data.progress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + +function updateDiscogsStatusFromData(data) { + const button = document.getElementById('discogs-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('discogs-tooltip-status'); + const tooltipCurrent = document.getElementById('discogs-tooltip-current'); + const tooltipProgress = document.getElementById('discogs-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) tooltipStatus.textContent = 'Complete'; + else if (data.running && !data.paused) tooltipStatus.textContent = 'Running'; + else if (data.paused) tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; + else tooltipStatus.textContent = 'Idle'; + } + + if (tooltipCurrent) { + if (data.idle) tooltipCurrent.textContent = 'All items processed'; + else if (data.current_item) tooltipCurrent.textContent = `Processing: "${data.current_item}"`; + else tooltipCurrent.textContent = 'No active matches'; + } + + if (tooltipProgress && data.stats) { + const s = data.stats; + tooltipProgress.textContent = `Matched: ${s.matched || 0} | Not found: ${s.not_found || 0} | Pending: ${s.pending || 0}`; + } +} + +async function toggleDiscogsEnrichment() { + try { + const button = document.getElementById('discogs-button'); + if (!button) return; + const isPaused = button.classList.contains('paused') || button.classList.contains('complete'); + const endpoint = isPaused ? '/api/discogs/resume' : '/api/discogs/pause'; + const response = await fetch(endpoint, { method: 'POST' }); + if (response.ok) { + showToast(isPaused ? 'Discogs enrichment resumed' : 'Discogs enrichment paused', 'info'); + } + } catch (e) { + showToast('Failed to toggle Discogs enrichment', 'error'); + } +} + +/** + * Toggle AudioDB enrichment pause/resume + */ +async function toggleAudioDBEnrichment() { + try { + const button = document.getElementById('audiodb-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/audiodb/pause' : '/api/audiodb/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} AudioDB enrichment`); + } + + // Immediately update UI + await updateAudioDBStatus(); + + console.log(`✅ AudioDB enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling AudioDB enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize AudioDB UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('audiodb-button'); + if (button) { + button.addEventListener('click', toggleAudioDBEnrichment); + updateAudioDBStatus(); + setInterval(updateAudioDBStatus, 2000); + console.log('✅ AudioDB UI initialized'); + } + }); +} else { + const button = document.getElementById('audiodb-button'); + if (button) { + button.addEventListener('click', toggleAudioDBEnrichment); + updateAudioDBStatus(); + setInterval(updateAudioDBStatus, 2000); + console.log('✅ AudioDB UI initialized'); + } +} + +// =================================================================== +// DEEZER ENRICHMENT STATUS +// =================================================================== + +async function updateDeezerStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/deezer/status'); + if (!response.ok) { console.warn('Deezer status endpoint unavailable'); return; } + const data = await response.json(); + updateDeezerStatusFromData(data); + } catch (error) { + console.error('Error updating Deezer status:', error); + } +} + +function updateDeezerStatusFromData(data) { + const button = document.getElementById('deezer-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('deezer-tooltip-status'); + const tooltipCurrent = document.getElementById('deezer-tooltip-current'); + const tooltipProgress = document.getElementById('deezer-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + +async function toggleDeezerEnrichment() { + try { + const button = document.getElementById('deezer-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/deezer/pause' : '/api/deezer/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Deezer enrichment`); + } + + // Immediately update UI + await updateDeezerStatus(); + + console.log(`✅ Deezer enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling Deezer enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize Deezer UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('deezer-button'); + if (button) { + button.addEventListener('click', toggleDeezerEnrichment); + updateDeezerStatus(); + setInterval(updateDeezerStatus, 2000); + console.log('✅ Deezer UI initialized'); + } + }); +} else { + const button = document.getElementById('deezer-button'); + if (button) { + button.addEventListener('click', toggleDeezerEnrichment); + updateDeezerStatus(); + setInterval(updateDeezerStatus, 2000); + console.log('✅ Deezer UI initialized'); + } +} + +// =================================================================== +// SPOTIFY ENRICHMENT STATUS +// =================================================================== + +async function updateSpotifyEnrichmentStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/spotify-enrichment/status'); + if (!response.ok) { console.warn('Spotify enrichment status endpoint unavailable'); return; } + const data = await response.json(); + updateSpotifyEnrichmentStatusFromData(data); + } catch (error) { + console.error('Error updating Spotify enrichment status:', error); + } +} + +function updateSpotifyEnrichmentStatusFromData(data) { + const button = document.getElementById('spotify-enrich-button'); + if (!button) return; + + const notAuthenticated = data.authenticated === false; + const isRateLimited = data.rate_limited === true; + const budgetExhausted = data.daily_budget && data.daily_budget.exhausted; + + button.classList.remove('active', 'paused', 'complete', 'no-auth'); + if (data.paused) { + button.classList.add('paused'); + } else if (notAuthenticated) { + button.classList.add('no-auth'); + } else if (isRateLimited || budgetExhausted) { + button.classList.add('paused'); + } else if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } + + const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } + else if (isRateLimited) { tooltipStatus.textContent = 'Rate Limited'; } + else if (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; } + else if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running) { tooltipStatus.textContent = 'Running'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.paused) { + tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume'; + } else if (notAuthenticated) { + tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich'; + } else if (isRateLimited) { + const info = data.rate_limit || {}; + const remaining = info.remaining_seconds || 0; + tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear'; + } else if (budgetExhausted) { + const resets = data.daily_budget.resets_in_seconds || 0; + const hours = Math.floor(resets / 3600); + const mins = Math.floor((resets % 3600) / 60); + tooltipCurrent.textContent = `Resets in ${hours}h ${mins}m`; + } else if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } else { + tooltipCurrent.textContent = 'Waiting for next item...'; + } + } + + if (data.progress && tooltipProgress) { + if (notAuthenticated) { + tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; + } else { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type || ''; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist') { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType.includes('album')) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType.includes('track')) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else if (!artistsComplete) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (!albumsComplete) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + } +} + +async function toggleSpotifyEnrichment() { + try { + const button = document.getElementById('spotify-enrich-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/spotify-enrichment/pause' : '/api/spotify-enrichment/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + if (data.rate_limited) { + showToast('Cannot resume — Spotify is rate limited', 'warning'); + return; + } + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Spotify enrichment`); + } + + await updateSpotifyEnrichmentStatus(); + console.log(`Spotify enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling Spotify enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize Spotify Enrichment UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('spotify-enrich-button'); + if (button) { + button.addEventListener('click', toggleSpotifyEnrichment); + updateSpotifyEnrichmentStatus(); + setInterval(updateSpotifyEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('spotify-enrich-button'); + if (button) { + button.addEventListener('click', toggleSpotifyEnrichment); + updateSpotifyEnrichmentStatus(); + setInterval(updateSpotifyEnrichmentStatus, 2000); + } +} + +// =================================================================== +// ITUNES ENRICHMENT STATUS +// =================================================================== + +async function updateiTunesEnrichmentStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/itunes-enrichment/status'); + if (!response.ok) { console.warn('iTunes enrichment status endpoint unavailable'); return; } + const data = await response.json(); + updateiTunesEnrichmentStatusFromData(data); + } catch (error) { + console.error('Error updating iTunes enrichment status:', error); + } +} + +function updateiTunesEnrichmentStatusFromData(data) { + const button = document.getElementById('itunes-enrich-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type || ''; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist') { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType.includes('album')) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType.includes('track')) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else if (!artistsComplete) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (!albumsComplete) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } +} + +async function toggleiTunesEnrichment() { + try { + const button = document.getElementById('itunes-enrich-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/itunes-enrichment/pause' : '/api/itunes-enrichment/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} iTunes enrichment`); + } + + await updateiTunesEnrichmentStatus(); + console.log(`iTunes enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling iTunes enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// Initialize iTunes Enrichment UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('itunes-enrich-button'); + if (button) { + button.addEventListener('click', toggleiTunesEnrichment); + updateiTunesEnrichmentStatus(); + setInterval(updateiTunesEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('itunes-enrich-button'); + if (button) { + button.addEventListener('click', toggleiTunesEnrichment); + updateiTunesEnrichmentStatus(); + setInterval(updateiTunesEnrichmentStatus, 2000); + } +} + +// =================================================================== +// LAST.FM ENRICHMENT STATUS +// =================================================================== + +async function updateLastFMEnrichmentStatus() { + if (socketConnected) return; + if (document.hidden) return; + try { + const response = await fetch('/api/lastfm-enrichment/status'); + if (!response.ok) { console.warn('Last.fm status endpoint unavailable'); return; } + const data = await response.json(); + updateLastFMEnrichmentStatusFromData(data); + } catch (error) { + console.error('Error updating Last.fm status:', error); + } +} + +function updateLastFMEnrichmentStatusFromData(data) { + const button = document.getElementById('lastfm-enrich-button'); + if (!button) return; + + const notAuthenticated = data.authenticated === false; + + button.classList.remove('active', 'paused', 'complete', 'no-auth'); + if (data.paused) { + button.classList.add('paused'); + } else if (notAuthenticated) { + button.classList.add('no-auth'); + } else if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } + + const tooltipStatus = document.getElementById('lastfm-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('lastfm-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('lastfm-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } + else if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running) { tooltipStatus.textContent = 'Running'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.paused) { + tooltipCurrent.textContent = notAuthenticated ? 'Add Last.fm API key in Settings to enrich' : 'Click to resume'; + } else if (notAuthenticated) { + tooltipCurrent.textContent = 'Add Last.fm API key in Settings to enrich'; + } else if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + if (notAuthenticated) { + tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; + } else { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } else { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + } +} + +async function toggleLastFMEnrichment() { + try { + const button = document.getElementById('lastfm-enrich-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/lastfm-enrichment/pause' : '/api/lastfm-enrichment/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Last.fm enrichment`); + } + + await updateLastFMEnrichmentStatus(); + console.log(`Last.fm enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling Last.fm enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('lastfm-enrich-button'); + if (button) { + button.addEventListener('click', toggleLastFMEnrichment); + updateLastFMEnrichmentStatus(); + setInterval(updateLastFMEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('lastfm-enrich-button'); + if (button) { + button.addEventListener('click', toggleLastFMEnrichment); + updateLastFMEnrichmentStatus(); + setInterval(updateLastFMEnrichmentStatus, 2000); + } +} + +// =================================================================== +// GENIUS ENRICHMENT STATUS +// =================================================================== + +async function updateGeniusEnrichmentStatus() { + if (socketConnected) return; + if (document.hidden) return; + try { + const response = await fetch('/api/genius-enrichment/status'); + if (!response.ok) { console.warn('Genius status endpoint unavailable'); return; } + const data = await response.json(); + updateGeniusEnrichmentStatusFromData(data); + } catch (error) { + console.error('Error updating Genius status:', error); + } +} + +function updateGeniusEnrichmentStatusFromData(data) { + const button = document.getElementById('genius-enrich-button'); + if (!button) return; + + const notAuthenticated = data.authenticated === false; + + button.classList.remove('active', 'paused', 'complete', 'no-auth'); + if (data.paused) { + button.classList.add('paused'); + } else if (notAuthenticated) { + button.classList.add('no-auth'); + } else if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } + + const tooltipStatus = document.getElementById('genius-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('genius-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('genius-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } + else if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running) { tooltipStatus.textContent = 'Running'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.paused) { + tooltipCurrent.textContent = notAuthenticated ? 'Add Genius access token in Settings to enrich' : 'Click to resume'; + } else if (notAuthenticated) { + tooltipCurrent.textContent = 'Add Genius access token in Settings to enrich'; + } else if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + if (notAuthenticated) { + tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; + } else { + const artists = data.progress.artists || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + } +} + +async function toggleGeniusEnrichment() { + try { + const button = document.getElementById('genius-enrich-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/genius-enrichment/pause' : '/api/genius-enrichment/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Genius enrichment`); + } + + await updateGeniusEnrichmentStatus(); + console.log(`Genius enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling Genius enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('genius-enrich-button'); + if (button) { + button.addEventListener('click', toggleGeniusEnrichment); + updateGeniusEnrichmentStatus(); + setInterval(updateGeniusEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('genius-enrich-button'); + if (button) { + button.addEventListener('click', toggleGeniusEnrichment); + updateGeniusEnrichmentStatus(); + setInterval(updateGeniusEnrichmentStatus, 2000); + } +} + +// =================================================================== +// TIDAL ENRICHMENT WORKER +// =================================================================== + +async function updateTidalEnrichmentStatus() { + if (socketConnected) return; + if (document.hidden) return; + try { + const response = await fetch('/api/tidal-enrichment/status'); + if (!response.ok) { console.warn('Tidal status endpoint unavailable'); return; } + const data = await response.json(); + updateTidalEnrichmentStatusFromData(data); + } catch (error) { + console.error('Error updating Tidal status:', error); + } +} + +function updateTidalEnrichmentStatusFromData(data) { + const button = document.getElementById('tidal-enrich-button'); + if (!button) return; + + const notAuthenticated = data.authenticated === false; + + button.classList.remove('active', 'paused', 'complete', 'no-auth'); + if (data.paused) { + button.classList.add('paused'); + } else if (notAuthenticated) { + button.classList.add('no-auth'); + } else if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } + + const tooltipStatus = document.getElementById('tidal-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('tidal-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('tidal-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } + else if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running) { tooltipStatus.textContent = 'Running'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.paused) { + tooltipCurrent.textContent = notAuthenticated ? 'Connect Tidal in Settings to enrich' : 'Click to resume'; + } else if (notAuthenticated) { + tooltipCurrent.textContent = 'Connect Tidal in Settings to enrich'; + } else if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + if (notAuthenticated) { + tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; + } else { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (!albumsComplete && !currentType)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + } +} + +async function toggleTidalEnrichment() { + try { + const button = document.getElementById('tidal-enrich-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/tidal-enrichment/pause' : '/api/tidal-enrichment/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Tidal enrichment`); + } + + await updateTidalEnrichmentStatus(); + console.log(`Tidal enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling Tidal enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('tidal-enrich-button'); + if (button) { + button.addEventListener('click', toggleTidalEnrichment); + updateTidalEnrichmentStatus(); + setInterval(updateTidalEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('tidal-enrich-button'); + if (button) { + button.addEventListener('click', toggleTidalEnrichment); + updateTidalEnrichmentStatus(); + setInterval(updateTidalEnrichmentStatus, 2000); + } +} + +// =================================================================== +// QOBUZ ENRICHMENT WORKER +// =================================================================== + +async function updateQobuzEnrichmentStatus() { + if (socketConnected) return; + if (document.hidden) return; + try { + const response = await fetch('/api/qobuz-enrichment/status'); + if (!response.ok) { console.warn('Qobuz status endpoint unavailable'); return; } + const data = await response.json(); + updateQobuzEnrichmentStatusFromData(data); + } catch (error) { + console.error('Error updating Qobuz status:', error); + } +} + +function updateQobuzEnrichmentStatusFromData(data) { + const button = document.getElementById('qobuz-enrich-button'); + if (!button) return; + + const notAuthenticated = data.authenticated === false; + + button.classList.remove('active', 'paused', 'complete', 'no-auth'); + if (data.paused) { + button.classList.add('paused'); + } else if (notAuthenticated) { + button.classList.add('no-auth'); + } else if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } + + const tooltipStatus = document.getElementById('qobuz-enrich-tooltip-status'); + const tooltipCurrent = document.getElementById('qobuz-enrich-tooltip-current'); + const tooltipProgress = document.getElementById('qobuz-enrich-tooltip-progress'); + + if (tooltipStatus) { + if (data.paused) { tooltipStatus.textContent = 'Paused'; } + else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } + else if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running) { tooltipStatus.textContent = 'Running'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.paused) { + tooltipCurrent.textContent = notAuthenticated ? 'Connect Qobuz in Settings to enrich' : 'Click to resume'; + } else if (notAuthenticated) { + tooltipCurrent.textContent = 'Connect Qobuz in Settings to enrich'; + } else if (data.idle) { + tooltipCurrent.textContent = 'All items processed'; + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Now: ${data.current_item.name}`; + } + } + + if (data.progress && tooltipProgress) { + if (notAuthenticated) { + tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; + } else { + const artists = data.progress.artists || {}; + const albums = data.progress.albums || {}; + const tracks = data.progress.tracks || {}; + + const currentType = data.current_item?.type; + let progressText = ''; + + const artistsComplete = artists.matched >= artists.total; + const albumsComplete = albums.matched >= albums.total; + + if (currentType === 'artist' || (!artistsComplete && !currentType)) { + progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; + } else if (currentType === 'album' || (!albumsComplete && !currentType)) { + progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; + } else { + progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; + } + + tooltipProgress.textContent = progressText; + } + } +} + +async function toggleQobuzEnrichment() { + try { + const button = document.getElementById('qobuz-enrich-button'); + if (!button) return; + + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/qobuz-enrichment/pause' : '/api/qobuz-enrichment/resume'; + + const response = await fetch(endpoint, { method: 'POST' }); + if (!response.ok) { + throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Qobuz enrichment`); + } + + await updateQobuzEnrichmentStatus(); + console.log(`Qobuz enrichment ${isRunning ? 'paused' : 'resumed'}`); + + } catch (error) { + console.error('Error toggling Qobuz enrichment:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('qobuz-enrich-button'); + if (button) { + button.addEventListener('click', toggleQobuzEnrichment); + updateQobuzEnrichmentStatus(); + setInterval(updateQobuzEnrichmentStatus, 2000); + } + }); +} else { + const button = document.getElementById('qobuz-enrich-button'); + if (button) { + button.addEventListener('click', toggleQobuzEnrichment); + updateQobuzEnrichmentStatus(); + setInterval(updateQobuzEnrichmentStatus, 2000); + } +} + +// =================================================================== +// HYDRABASE P2P MIRROR WORKER +// =================================================================== + +async function updateHydrabaseStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/hydrabase-worker/status'); + if (!response.ok) return; + const data = await response.json(); + updateHydrabaseStatusFromData(data); + } catch (error) { + // Silently ignore — worker may not be available + } +} + +function updateHydrabaseStatusFromData(data) { + const button = document.getElementById('hydrabase-button'); + if (!button) return; + + button.classList.remove('active', 'paused'); + if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const statusEl = document.getElementById('hydrabase-tooltip-status'); + if (statusEl) { + if (data.paused) { + statusEl.textContent = 'Paused'; + statusEl.style.color = '#ffc107'; + } else if (data.running) { + statusEl.textContent = 'Active'; + statusEl.style.color = '#ffffff'; + } else { + statusEl.textContent = 'Stopped'; + statusEl.style.color = '#ff5252'; + } + } +} + +async function toggleHydrabaseWorker() { + const button = document.getElementById('hydrabase-button'); + if (!button) return; + const isRunning = button.classList.contains('active'); + const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume'; + try { + await fetch(endpoint, { method: 'POST' }); + await updateHydrabaseStatus(); + } catch (error) { + console.error('Error toggling Hydrabase worker:', error); + } +} + +// Initialize Hydrabase UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('hydrabase-button'); + if (button) { + button.addEventListener('click', toggleHydrabaseWorker); + updateHydrabaseStatus(); + setInterval(updateHydrabaseStatus, 2000); + } + }); +} else { + const button = document.getElementById('hydrabase-button'); + if (button) { + button.addEventListener('click', toggleHydrabaseWorker); + updateHydrabaseStatus(); + setInterval(updateHydrabaseStatus, 2000); + } +} + +// =================================================================== +// LIBRARY REPAIR WORKER +// =================================================================== + +async function updateRepairStatus() { + if (socketConnected) return; // WebSocket handles this + if (document.hidden) return; // Skip polling when tab is not visible + try { + const response = await fetch('/api/repair/status'); + if (!response.ok) { console.warn('Repair status endpoint unavailable'); return; } + const data = await response.json(); + updateRepairStatusFromData(data); + } catch (error) { + console.error('Error updating repair status:', error); + } +} + +function updateRepairStatusFromData(data) { + const button = document.getElementById('repair-button'); + if (!button) return; + + button.classList.remove('active', 'paused', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } else if (data.paused) { + button.classList.add('paused'); + } + + const tooltipStatus = document.getElementById('repair-tooltip-status'); + const tooltipCurrent = document.getElementById('repair-tooltip-current'); + const tooltipProgress = document.getElementById('repair-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) { tooltipStatus.textContent = 'Complete'; } + else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } + else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } + else { tooltipStatus.textContent = 'Idle'; } + } + + if (tooltipCurrent) { + if (data.idle) { + tooltipCurrent.textContent = 'All jobs complete — waiting for next schedule'; + } else if (data.current_job && data.current_job.display_name) { + const jobName = data.current_job.display_name; + const jobProgress = data.progress && data.progress.current_job; + if (jobProgress && jobProgress.total > 0) { + tooltipCurrent.textContent = `${jobName}: ${jobProgress.scanned} / ${jobProgress.total} (${jobProgress.percent}%)`; + } else { + tooltipCurrent.textContent = `Running: ${jobName}`; + } + } else if (data.current_item && data.current_item.name) { + tooltipCurrent.textContent = `Running: ${data.current_item.name}`; + } else { + tooltipCurrent.textContent = 'No active repairs'; + } + } + + if (tooltipProgress && data.progress) { + const tracks = data.progress.tracks || {}; + const parts = []; + if (tracks.total > 0) parts.push(`Checked: ${tracks.checked || 0} / ${tracks.total || 0}`); + if (tracks.repaired > 0) parts.push(`Repaired: ${tracks.repaired}`); + const pending = data.findings_pending || 0; + if (pending > 0) parts.push(`Findings: ${pending}`); + tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; + } + + // Update findings badge + const badge = document.getElementById('repair-findings-badge'); + const findingsPending = data.findings_pending || 0; + if (badge) { + badge.textContent = findingsPending; + badge.style.display = findingsPending > 0 ? '' : 'none'; + } + const tabBadge = document.getElementById('repair-findings-tab-badge'); + if (tabBadge) { + tabBadge.textContent = findingsPending; + tabBadge.style.display = findingsPending > 0 ? '' : 'none'; + } + + // Update master toggle in modal if open + const masterToggle = document.getElementById('repair-master-toggle'); + const masterLabel = document.getElementById('repair-master-label'); + if (masterToggle) masterToggle.checked = data.enabled || false; + if (masterLabel) masterLabel.textContent = data.enabled ? 'Enabled' : 'Disabled'; + + // Update button state + if (!data.enabled) { + button.classList.add('paused'); + button.classList.remove('active', 'complete'); + } +} + +// ── SoulID Worker Status ── + +function updateSoulIDStatusFromData(data) { + const button = document.getElementById('soulid-button'); + if (!button) return; + + button.classList.remove('active', 'complete'); + if (data.idle) { + button.classList.add('complete'); + } else if (data.running && !data.paused) { + button.classList.add('active'); + } + + const tooltipStatus = document.getElementById('soulid-tooltip-status'); + const tooltipCurrent = document.getElementById('soulid-tooltip-current'); + const tooltipProgress = document.getElementById('soulid-tooltip-progress'); + + if (tooltipStatus) { + if (data.idle) tooltipStatus.textContent = 'Complete'; + else if (data.running && !data.paused) tooltipStatus.textContent = 'Running'; + else if (data.paused) tooltipStatus.textContent = 'Paused'; + else tooltipStatus.textContent = 'Idle'; + } + + if (tooltipCurrent) { + if (data.current_item) { + tooltipCurrent.textContent = data.current_item; + } else if (data.idle) { + tooltipCurrent.textContent = 'All entities have soul IDs'; + } else { + tooltipCurrent.textContent = 'No items processing'; + } + } + + if (tooltipProgress && data.stats) { + const s = data.stats; + const parts = []; + if (s.artists_processed) parts.push(`Artists: ${s.artists_processed}`); + if (s.albums_processed) parts.push(`Albums: ${s.albums_processed}`); + if (s.tracks_processed) parts.push(`Tracks: ${s.tracks_processed}`); + if (s.pending > 0) parts.push(`Pending: ${s.pending}`); + tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; + } +} + +// ── Repair Modal State ── +let _repairCurrentTab = 'jobs'; +let _repairFindingsPage = 0; +let _repairSelectedFindings = new Set(); +let _repairFindingsTotal = 0; +const REPAIR_FINDINGS_PAGE_SIZE = 30; +let _repairJobsCache = {}; // Cache job data for help modal + +/** + * Open the Library Maintenance modal + */ +async function openRepairModal() { + navigateToPage('tools'); + // Scroll to maintenance section + setTimeout(() => { + const section = document.querySelector('.tools-maintenance-section'); + if (section) section.scrollIntoView({ behavior: 'smooth' }); + }, 100); + _repairCurrentTab = 'jobs'; + switchRepairTab('jobs'); + // Load master toggle state + updateRepairStatus(); + // Load any active job progress + try { + const resp = await fetch('/api/repair/progress'); + if (resp.ok) { + const data = await resp.json(); + if (Object.keys(data).length > 0) { + // Brief delay so job cards are rendered first + setTimeout(() => updateRepairJobProgressFromData(data), 300); + } + } + } catch (e) { /* ignore */ } +} + +function closeRepairModal() { + // No-op — repair content now lives on the tools page, no modal to close +} + +async function toggleRepairMaster() { + try { + const response = await fetch('/api/repair/toggle', { method: 'POST' }); + if (!response.ok) throw new Error('Failed to toggle'); + const data = await response.json(); + const label = document.getElementById('repair-master-label'); + const toggle = document.getElementById('repair-master-toggle'); + if (label) label.textContent = data.enabled ? 'Enabled' : 'Disabled'; + if (toggle) toggle.checked = data.enabled; + await updateRepairStatus(); + } catch (error) { + console.error('Error toggling repair master:', error); + showToast('Error toggling maintenance worker', 'error'); + } +} + +function switchRepairTab(tab) { + _repairCurrentTab = tab; + document.querySelectorAll('.repair-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + document.querySelectorAll('.repair-tab-content').forEach(c => { + c.style.display = 'none'; + }); + const content = document.getElementById(`repair-tab-${tab}`); + if (content) content.style.display = ''; + + if (tab === 'jobs') loadRepairJobs(); + else if (tab === 'findings') { loadRepairFindingsDashboard(); loadRepairFindings(); } + else if (tab === 'history') loadRepairHistory(); +} + +// Turn a snake_case setting key into a human label. Handles acronym fix-ups +// (EP, ID, URL, MB, AC, OS) that the naive Title-Case would otherwise botch. +function _prettifyRepairSettingKey(key) { + const words = key.replace(/^_+/, '').split('_'); + const acronyms = { 'eps': 'EPs', 'id': 'ID', 'url': 'URL', 'mb': 'MB', + 'ac': 'AC', 'os': 'OS', 'api': 'API', 'mp3': 'MP3', + 'flac': 'FLAC', 'cd': 'CD' }; + return words.map(w => acronyms[w.toLowerCase()] || (w.charAt(0).toUpperCase() + w.slice(1))).join(' '); +} + +async function loadRepairJobs() { + const container = document.getElementById('repair-jobs-list'); + if (!container) return; + + try { + const response = await fetch('/api/repair/jobs'); + if (!response.ok) throw new Error('Failed to fetch jobs'); + const data = await response.json(); + const jobs = data.jobs || []; + + // Cache job data for help modal + _repairJobsCache = {}; + jobs.forEach(j => { _repairJobsCache[j.job_id] = j; }); + + if (jobs.length === 0) { + container.innerHTML = `
    +
    🔧
    +
    No Maintenance Jobs
    +
    Library maintenance jobs will appear here once available.
    +
    `; + return; + } + + // Populate findings job filter dropdown + const jobFilter = document.getElementById('repair-findings-job-filter'); + if (jobFilter && jobFilter.options.length <= 1) { + jobs.forEach(job => { + const opt = document.createElement('option'); + opt.value = job.job_id; + opt.textContent = job.display_name; + jobFilter.appendChild(opt); + }); + } + + container.innerHTML = jobs.map(job => { + const lastRunText = job.last_run ? formatCacheAge(job.last_run.finished_at) : 'Never'; + const nextRunText = job.next_run ? formatCacheAge(job.next_run) : (job.enabled ? 'Pending' : '-'); + const statusClass = job.is_running ? 'running' : (job.enabled ? 'idle' : 'disabled'); + const dotClass = job.is_running ? 'running' : (job.enabled ? 'enabled' : 'disabled'); + const cardClass = job.is_running ? 'running' : (!job.enabled ? 'disabled' : ''); + + // Build flow badges + const flowParts = []; + flowParts.push(`${job.is_running ? '▶ Running' : 'Scan'}`); + if (job.auto_fix) { + flowParts.push(''); + const isDryRun = job.settings && job.settings.dry_run === true; + if (isDryRun) { + flowParts.push('Dry Run'); + } else { + flowParts.push('Auto-fix'); + } + } + // Show pending findings count + const findingsCount = job.last_run ? (job.last_run.findings_created || 0) : 0; + if (findingsCount > 0) { + flowParts.push(''); + flowParts.push(`${findingsCount} finding${findingsCount !== 1 ? 's' : ''}`); + } + + // Build meta parts + const metaParts = []; + metaParts.push('Last: ' + lastRunText); + metaParts.push('Next: ' + nextRunText); + if (job.last_run) { + metaParts.push(`Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}`); + if (job.last_run.auto_fixed) metaParts.push(`Fixed: ${job.last_run.auto_fixed}`); + } + if (job.last_run && job.last_run.duration_seconds) { + metaParts.push(`${job.last_run.duration_seconds.toFixed(1)}s`); + } + + // Build settings HTML + let settingsHtml = ''; + if (job.settings && Object.keys(job.settings).length > 0) { + const settingsRows = Object.entries(job.settings).map(([key, val]) => { + // Section header: keys starting with `_section_` render as a + // group divider + title instead of a setting row. The value + // is the human-readable title. + if (key.startsWith('_section_')) { + return `
    ${val}
    `; + } + const label = _prettifyRepairSettingKey(key); + const inputType = typeof val === 'boolean' ? 'checkbox' : + typeof val === 'number' ? 'number' : 'text'; + const inputVal = inputType === 'checkbox' ? + (val ? ' checked' : '') : + ` value="${val}"`; + return `
    + + +
    `; + }).join(''); + + settingsHtml = ` + `; + } + + return `
    +
    +
    +
    +
    ${job.display_name}
    +
    ${job.description || ''}
    +
    ${flowParts.join('')}
    +
    ${metaParts.join(' · ')}
    +
    +
    + + + ${Object.keys(job.settings || {}).length > 0 ? + `` : ''} + +
    +
    + ${settingsHtml} +
    `; + }).join(''); + + } catch (error) { + console.error('Error loading repair jobs:', error); + container.innerHTML = '
    Error loading jobs
    '; + } +} + +async function toggleRepairJob(jobId, enabled) { + try { + await fetch(`/api/repair/jobs/${jobId}/toggle`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }); + // Update card visuals immediately + const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`); + if (card) { + card.classList.toggle('disabled', !enabled); + const dot = card.querySelector('.repair-job-status'); + if (dot) dot.className = 'repair-job-status ' + (enabled ? 'enabled' : 'disabled'); + } + } catch (error) { + console.error('Error toggling job:', error); + showToast('Error toggling job', 'error'); + } +} + +function expandRepairJobSettings(jobId) { + const el = document.getElementById(`repair-settings-${jobId}`); + if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; +} + +function showRepairJobHelp(jobId) { + const job = _repairJobsCache[jobId]; + if (!job) return; + + // Remove existing overlay if present + let overlay = document.getElementById('repair-help-overlay'); + if (overlay) overlay.remove(); + + // Build settings summary (skip `_section_` group-header sentinels) + let settingsHtml = ''; + if (job.settings && Object.keys(job.settings).length > 0) { + const rows = Object.entries(job.settings) + .filter(([key]) => !key.startsWith('_section_')) + .map(([key, val]) => { + const label = _prettifyRepairSettingKey(key); + const display = typeof val === 'boolean' ? (val ? 'Yes' : 'No') : val; + return `
    ${label}${display}
    `; + }).join(''); + settingsHtml = `
    +
    Current Settings
    + ${rows} +
    `; + } + + // Build info badges + const badges = []; + if (job.auto_fix) { + const isDryRun = job.settings && job.settings.dry_run === true; + badges.push(isDryRun + ? 'Dry Run' + : 'Auto-fix'); + } else { + badges.push('Scan Only'); + } + badges.push(`Every ${job.interval_hours}h`); + if (job.enabled) { + badges.push('Enabled'); + } else { + badges.push('Disabled'); + } + + // Format help text paragraphs + const helpBody = (job.help_text || job.description || '').split('\n\n').map(p => { + if (p.startsWith('Settings:\n')) { + const lines = p.split('\n').slice(1); + return '
    ' + + lines.map(l => `
    ${l.replace(/^- /, '')}
    `).join('') + + '
    '; + } + return `

    ${p.replace(/\n/g, '
    ')}

    `; + }).join(''); + + overlay = document.createElement('div'); + overlay.id = 'repair-help-overlay'; + overlay.className = 'repair-help-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + overlay.innerHTML = ` +
    +
    +

    ${job.display_name}

    + +
    +
    ${badges.join('')}
    +
    ${helpBody}
    + ${settingsHtml} +
    + `; + document.body.appendChild(overlay); +} + +async function saveRepairJobSettings(jobId) { + try { + const inputs = document.querySelectorAll(`.repair-setting-input[data-job="${jobId}"]`); + let intervalHours = null; + const settings = {}; + + inputs.forEach(input => { + const key = input.dataset.key; + if (key === '_interval_hours') { + intervalHours = parseInt(input.value) || 24; + } else { + if (input.type === 'checkbox') settings[key] = input.checked; + else if (input.type === 'number') settings[key] = parseFloat(input.value); + else settings[key] = input.value; + } + }); + + await fetch(`/api/repair/jobs/${jobId}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interval_hours: intervalHours, settings }) + }); + + showToast('Settings saved', 'success'); + } catch (error) { + console.error('Error saving job settings:', error); + showToast('Error saving settings', 'error'); + } +} + +async function runRepairJobNow(jobId) { + try { + await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' }); + showToast('Job started', 'success'); + setTimeout(() => loadRepairJobs(), 1000); + } catch (error) { + console.error('Error running job:', error); + showToast('Error starting job', 'error'); + } +} + +// ── Repair Job Live Progress ── +const _repairProgressLogCounts = {}; +const _repairProgressHideTimers = {}; + +function updateRepairJobProgressFromData(data) { + for (const [jobId, state] of Object.entries(data)) { + const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`); + if (!card) continue; + + // Update status dot + const statusDot = card.querySelector('.repair-job-status'); + if (statusDot) { + if (state.status === 'running') statusDot.className = 'repair-job-status running'; + else if (state.status === 'finished') statusDot.className = 'repair-job-status enabled'; + else if (state.status === 'error') statusDot.className = 'repair-job-status enabled'; + } + + // Update flow badge to show running state + const firstBadge = card.querySelector('.repair-flow-badge.scan'); + if (firstBadge) { + if (state.status === 'running') firstBadge.innerHTML = '▶ Running'; + else if (state.status === 'finished') firstBadge.innerHTML = '✓ Complete'; + else if (state.status === 'error') firstBadge.innerHTML = '✗ Error'; + } + + // Add/update card running class + card.classList.toggle('running', state.status === 'running'); + card.classList.remove('disabled'); + + // Create or find progress panel (bar-first layout like automation) + let panel = card.querySelector('.repair-job-progress'); + if (!panel) { + panel = document.createElement('div'); + panel.className = 'repair-job-progress'; + panel.innerHTML = ` +
    +
    +
    +
    +
    + `; + card.appendChild(panel); + } + + // Show panel + panel.classList.add('visible'); + panel.classList.toggle('finished', state.status === 'finished'); + panel.classList.toggle('error', state.status === 'error'); + + if (state.status === 'running') { + panel.classList.remove('finished', 'error'); + if (_repairProgressHideTimers[jobId]) { + clearTimeout(_repairProgressHideTimers[jobId]); + delete _repairProgressHideTimers[jobId]; + } + // Reset log for re-run + if (_repairProgressLogCounts[jobId] > 0 && state.log && state.log.length < _repairProgressLogCounts[jobId]) { + const existingLog = panel.querySelector('.repair-progress-log'); + if (existingLog) existingLog.innerHTML = ''; + _repairProgressLogCounts[jobId] = 0; + } + } + + // Update progress bar + const bar = panel.querySelector('.repair-progress-bar'); + if (bar) bar.style.width = (state.progress || 0) + '%'; + + // Update phase + const phaseEl = panel.querySelector('.repair-progress-phase'); + if (phaseEl && state.phase) phaseEl.textContent = state.phase; + + // Update log + const logEl = panel.querySelector('.repair-progress-log'); + if (logEl && state.log) { + const prevCount = _repairProgressLogCounts[jobId] || 0; + if (state.log.length > prevCount) { + const newLines = state.log.slice(prevCount); + for (const line of newLines) { + const div = document.createElement('div'); + div.className = 'repair-log-line ' + (line.type || 'info'); + div.textContent = line.text; + logEl.appendChild(div); + } + logEl.scrollTop = logEl.scrollHeight; + } + _repairProgressLogCounts[jobId] = state.log.length; + } + + // Auto-hide panel after completion + if (state.status === 'finished' || state.status === 'error') { + if (!_repairProgressHideTimers[jobId]) { + _repairProgressHideTimers[jobId] = setTimeout(() => { + panel.classList.remove('visible'); + card.classList.remove('running'); + delete _repairProgressHideTimers[jobId]; + delete _repairProgressLogCounts[jobId]; + // Reload to get updated stats + loadRepairJobs(); + }, 30000); + } + } else { + // Clear any existing hide timer if job restarts + if (_repairProgressHideTimers[jobId]) { + clearTimeout(_repairProgressHideTimers[jobId]); + delete _repairProgressHideTimers[jobId]; + } + } + } +} + +async function loadRepairFindingsDashboard() { + const dashboard = document.getElementById('repair-findings-dashboard'); + if (!dashboard) return; + + try { + const response = await fetch('/api/repair/findings/counts'); + if (!response.ok) throw new Error('Failed to fetch counts'); + const data = await response.json(); + + const pending = data.pending || 0; + const resolved = data.resolved || 0; + const dismissed = data.dismissed || 0; + const autoFixed = data.auto_fixed || 0; + const byJob = data.by_job || {}; + + // Summary stats row + let html = '
    '; + html += `
    + ${pending.toLocaleString()} pending +
    `; + html += `
    + ${resolved.toLocaleString()} resolved +
    `; + html += `
    + ${dismissed.toLocaleString()} dismissed +
    `; + if (autoFixed > 0) { + html += `
    + ${autoFixed.toLocaleString()} auto-fixed +
    `; + } + html += '
    '; + + // Per-job chips (only if there are pending findings) + const jobIds = Object.keys(byJob).sort((a, b) => byJob[b].total - byJob[a].total); + if (jobIds.length > 0) { + html += '
    '; + const jobFilter = document.getElementById('repair-findings-job-filter'); + const activeJob = jobFilter ? jobFilter.value : ''; + + for (const jid of jobIds) { + const job = byJob[jid]; + const isActive = activeJob === jid; + const severityDots = []; + if (job.warning > 0) severityDots.push(``); + if (job.info > 0) severityDots.push(``); + + html += `
    + ${job.total.toLocaleString()} + ${_escFinding(job.display_name || jid.replace(/_/g, ' '))} + ${severityDots.length ? `${severityDots.join('')}` : ''} +
    `; + } + html += '
    '; + } + + dashboard.innerHTML = html; + + // Load cache health stats + _loadCacheHealthStats(dashboard); + } catch (error) { + console.error('Error loading findings dashboard:', error); + dashboard.innerHTML = ''; + } +} + +async function _loadCacheHealthStats(dashboard) { + try { + const response = await fetch('/api/repair/cache-health'); + if (!response.ok) return; + const stats = await response.json(); + if (!stats.total_entities && !stats.total_searches) return; + + const healthScore = stats.junk_entities === 0 && stats.stale_mb_nulls === 0 ? 'healthy' : stats.junk_entities > 50 ? 'poor' : 'fair'; + const healthLabel = healthScore === 'healthy' ? 'Healthy' : healthScore === 'fair' ? 'Needs Cleanup' : 'Needs Attention'; + + // Remove any existing cache-health bar before appending — prevents + // stacking when multiple dashboard refreshes race and each resolved + // fetch appends its own section. + dashboard.querySelectorAll('.repair-cache-health').forEach(el => el.remove()); + + const section = document.createElement('div'); + section.className = 'repair-cache-health'; + section.innerHTML = ` +
    + + Metadata Cache + ${stats.total_entities.toLocaleString()} entities · ${healthLabel} + View Details › +
    + `; + dashboard.appendChild(section); + } catch (error) { + console.error('Error loading cache health:', error); + } +} + +async function openCacheHealthModal() { + if (document.getElementById('cache-health-modal-overlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'cache-health-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
    +
    +
    +
    📊
    +
    +

    Cache Health

    +

    Metadata cache status across all sources

    +
    +
    + +
    +
    +
    +
    +
    Loading cache stats...
    +
    +
    + +
    + `; + document.body.appendChild(overlay); + + try { + const response = await fetch('/api/repair/cache-health'); + if (!response.ok) throw new Error('Failed to load'); + const s = await response.json(); + + const body = overlay.querySelector('.cache-health-body'); + const healthScore = s.junk_entities === 0 && s.stale_mb_nulls === 0 ? 'healthy' : s.junk_entities > 50 ? 'poor' : 'fair'; + const healthEmoji = healthScore === 'healthy' ? '✓' : healthScore === 'fair' ? '⚠' : '❌'; + const healthLabel = healthScore === 'healthy' ? 'Cache is healthy' : healthScore === 'fair' ? 'Minor issues detected' : 'Cleanup recommended'; + + body.innerHTML = ` +
    +
    ${healthEmoji}
    +
    ${healthLabel}
    +
    + +
    +
    +
    ${s.total_entities.toLocaleString()}
    +
    Total Entities
    +
    +
    +
    ${s.total_searches.toLocaleString()}
    +
    Search Results
    +
    +
    +
    ${s.junk_entities}
    +
    Junk Entries
    +
    +
    0 ? 'onclick="openFailedMBLookupsModal()"' : ''}> +
    ${s.stale_mb_nulls}
    +
    Failed MB Lookups
    + ${s.stale_mb_nulls > 0 ? '
    Manage ›
    ' : ''} +
    +
    + +
    +
    By Source
    +
    + ${(() => { + const allSources = { ...(s.by_source || {}) }; + if (s.total_musicbrainz) allSources['musicbrainz'] = s.total_musicbrainz; + const maxCount = Math.max(...Object.values(allSources), 1); + return Object.entries(allSources).map(([src, count]) => { + const pct = Math.round(count / maxCount * 100); + const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : src === 'musicbrainz' ? '#BA478F' : '#666'; + return `
    + ${src === 'musicbrainz' ? 'MusicBrainz' : src} +
    + ${count.toLocaleString()} +
    `; + }).join(''); + })()} +
    +
    + +
    +
    By Type
    +
    + ${Object.entries(s.by_type || {}).map(([type, count]) => `${type}s ${count.toLocaleString()}`).join('')} +
    +
    + +
    +
    Metrics
    +
    +
    Average Age${s.avg_age_days} days
    +
    Total Cache Hits${s.total_access_hits.toLocaleString()}
    +
    Expiring in 24h${s.expiring_24h}
    +
    Expiring in 7 days${s.expiring_7d}
    +
    +
    + `; + } catch (error) { + const body = overlay.querySelector('.cache-health-body'); + body.innerHTML = '
    Failed to load cache stats
    '; + } +} + +// ── Failed MB Lookups Management Modal ── +let _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; + +async function openFailedMBLookupsModal() { + if (document.getElementById('failed-mb-modal-overlay')) return; + + const overlay = document.createElement('div'); + overlay.id = 'failed-mb-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
    +
    +
    +

    Failed MusicBrainz Lookups

    +

    Tracks, albums, and artists that couldn't be matched automatically

    +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    Loading...
    +
    + +
    + `; + document.body.appendChild(overlay); + + // Search debounce + const searchInput = overlay.querySelector('#failed-mb-search'); + let searchTimer = null; + searchInput.addEventListener('input', () => { + clearTimeout(searchTimer); + searchTimer = setTimeout(() => { + _failedMBState.filter = searchInput.value; + _failedMBState.page = 1; + _loadFailedMBLookups(); + }, 300); + }); + + _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; + await _loadFailedMBLookups(); +} + +async function _loadFailedMBLookups() { + const body = document.getElementById('failed-mb-body'); + if (!body) return; + + // Only fetch type_counts on first load — cache them for tab switches + const needCounts = Object.keys(_failedMBState.typeCounts).length === 0; + const params = new URLSearchParams({ + page: _failedMBState.page, + limit: 50, + }); + if (needCounts) params.set('counts', 'true'); + if (_failedMBState.typeFilter) params.set('entity_type', _failedMBState.typeFilter); + if (_failedMBState.filter) params.set('search', _failedMBState.filter); + + try { + const resp = await fetch(`/api/metadata-cache/failed-mb-lookups?${params}`); + if (!resp.ok) throw new Error('Failed to load'); + const data = await resp.json(); + _failedMBState.items = data.items; + _failedMBState.total = data.total; + if (data.type_counts) _failedMBState.typeCounts = data.type_counts; + + // Render type filter tabs + const tabsEl = document.getElementById('failed-mb-tabs'); + if (tabsEl) { + const allCount = Object.values(_failedMBState.typeCounts).reduce((a, b) => a + b, 0); + let tabsHTML = ``; + const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' }; + for (const [type, count] of Object.entries(_failedMBState.typeCounts)) { + tabsHTML += ``; + } + tabsEl.innerHTML = tabsHTML; + } + + // Render items + if (data.items.length === 0) { + body.innerHTML = `
    ${_failedMBState.filter ? 'No matches for your search' : 'No failed lookups — cache is clean!'}
    `; + } else { + const typeIcons = { artist: '🎤', release: '💿', recording: '🎵' }; + body.innerHTML = data.items.map(item => ` +
    +
    ${typeIcons[item.entity_type] || '?'}
    +
    +
    ${escapeHtml(item.entity_name)}
    + ${item.artist_name ? `
    ${escapeHtml(item.artist_name)}
    ` : ''} +
    +
    + ${item.entity_type} + ${item.last_updated ? new Date(item.last_updated).toLocaleDateString() : ''} +
    +
    + + +
    +
    + `).join(''); + } + + // Pagination footer + const footer = document.getElementById('failed-mb-footer'); + if (footer) { + const totalPages = Math.ceil(data.total / 50); + footer.innerHTML = totalPages > 1 ? ` +
    + + Page ${_failedMBState.page} of ${totalPages} (${data.total} total) + +
    + ` : `
    ${data.total} entries
    `; + } + } catch (err) { + body.innerHTML = '
    Failed to load data
    '; + } +} + +function _failedMBSetType(type) { + _failedMBState.typeFilter = type; + _failedMBState.page = 1; + _loadFailedMBLookups(); +} + +function _failedMBPage(page) { + _failedMBState.page = page; + _loadFailedMBLookups(); +} + +async function _failedMBDelete(entryId) { + try { + const resp = await fetch(`/api/metadata-cache/mb-entry/${entryId}`, { method: 'DELETE' }); + if (resp.ok) { + const row = document.querySelector(`.failed-mb-item[data-id="${entryId}"]`); + if (row) { + row.style.opacity = '0'; + setTimeout(() => { + row.remove(); + _failedMBState.typeCounts = {}; // Force refresh counts + _loadFailedMBLookups(); + }, 200); + } + } + } catch (err) { + showToast('Failed to delete entry', 'error'); + } +} + +async function _failedMBClearAll() { + if (!confirm(`Clear all ${_failedMBState.total} failed lookups? They will be retried on next enrichment run.`)) return; + try { + const resp = await fetch('/api/metadata-cache/clear-musicbrainz?failed_only=true', { method: 'DELETE' }); + const data = await resp.json(); + if (data.success) { + showToast(`Cleared ${data.cleared} failed lookups`, 'success'); + _failedMBState.page = 1; + _failedMBState.typeCounts = {}; // Force refresh counts + _loadFailedMBLookups(); + } + } catch (err) { + showToast('Failed to clear lookups', 'error'); + } +} + +// ── MusicBrainz Search Sub-Modal ── +async function _failedMBSearch(entryId, entityType, entityName, artistName) { + // Remove existing search modal if any + const existing = document.getElementById('mb-search-modal-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'mb-search-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.style.zIndex = '10001'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const typeLabels = { artist: 'Artist', release: 'Album', recording: 'Track' }; + overlay.innerHTML = ` +
    +
    +
    +

    Search MusicBrainz

    +

    Find a match for: ${escapeHtml(entityName)}${artistName ? ` by ${escapeHtml(artistName)}` : ''}

    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    Enter a search query and click Search
    +
    +
    + `; + document.body.appendChild(overlay); + + // Toggle artist row visibility based on type + const typeSelect = overlay.querySelector('#mb-search-type'); + typeSelect.addEventListener('change', () => { + const artistRow = overlay.querySelector('#mb-search-artist-row'); + artistRow.style.display = typeSelect.value === 'artist' ? 'none' : ''; + }); + + // Enter to search + overlay.querySelectorAll('.mb-search-input').forEach(input => { + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') _runMBSearch(entryId); }); + }); + + // Auto-search on open + _runMBSearch(entryId); +} + +async function _runMBSearch(entryId) { + const resultsEl = document.getElementById('mb-search-results'); + const typeEl = document.getElementById('mb-search-type'); + const queryEl = document.getElementById('mb-search-query'); + const artistEl = document.getElementById('mb-search-artist'); + const goBtn = document.getElementById('mb-search-go-btn'); + if (!resultsEl || !queryEl) return; + + const type = typeEl.value; + const query = queryEl.value.trim(); + const artist = artistEl ? artistEl.value.trim() : ''; + if (!query) return; + + goBtn.disabled = true; + goBtn.textContent = 'Searching...'; + resultsEl.innerHTML = '
    Searching MusicBrainz...
    '; + + try { + const params = new URLSearchParams({ type, q: query, limit: 10 }); + if (artist && type !== 'artist') params.set('artist', artist); + + const resp = await fetch(`/api/musicbrainz/search?${params}`); + if (!resp.ok) throw new Error('Search failed'); + const data = await resp.json(); + + if (!data.results || data.results.length === 0) { + resultsEl.innerHTML = '
    No results found. Try adjusting your search.
    '; + return; + } + + resultsEl.innerHTML = data.results.map((r, i) => { + const scoreColor = r.score >= 90 ? '#4ade80' : r.score >= 70 ? '#fbbf24' : '#f87171'; + let detail = ''; + if (type === 'release') detail = [r.artist, r.date, r.track_count ? `${r.track_count} tracks` : ''].filter(Boolean).join(' · '); + else if (type === 'recording') detail = [r.artist, r.album].filter(Boolean).join(' · '); + else detail = [r.type, r.country].filter(Boolean).join(' · '); + + return ` +
    +
    ${r.score}%
    +
    +
    ${escapeHtml(r.name)}
    + ${r.disambiguation ? `
    ${escapeHtml(r.disambiguation)}
    ` : ''} + ${detail ? `
    ${escapeHtml(detail)}
    ` : ''} +
    +
    ${r.mbid.substring(0, 8)}...
    +
    + `; + }).join(''); + } catch (err) { + resultsEl.innerHTML = `
    Search error: ${err.message}
    `; + } finally { + goBtn.disabled = false; + goBtn.textContent = 'Search'; + } +} + +async function _selectMBMatch(entryId, mbid, mbName) { + try { + const resp = await fetch('/api/metadata-cache/mb-match', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entry_id: entryId, mbid, mb_name: mbName }) + }); + const data = await resp.json(); + if (data.success) { + showToast(`Matched to: ${mbName}`, 'success'); + // Close search modal, refresh list with fresh counts + const searchOverlay = document.getElementById('mb-search-modal-overlay'); + if (searchOverlay) searchOverlay.remove(); + _failedMBState.typeCounts = {}; + _loadFailedMBLookups(); + } else { + showToast(data.error || 'Failed to save match', 'error'); + } + } catch (err) { + showToast('Failed to save match', 'error'); + } +} + +function filterFindingsByJob(jobId) { + const jobFilter = document.getElementById('repair-findings-job-filter'); + if (!jobFilter) return; + + // Toggle: click same chip again to clear filter + if (jobFilter.value === jobId) { + jobFilter.value = ''; + } else { + jobFilter.value = jobId; + } + _repairFindingsPage = 0; + loadRepairFindingsDashboard(); + loadRepairFindings(); +} + +async function loadRepairFindings() { + const container = document.getElementById('repair-findings-list'); + if (!container) return; + + const jobFilter = document.getElementById('repair-findings-job-filter'); + const severityFilter = document.getElementById('repair-findings-severity-filter'); + const statusFilter = document.getElementById('repair-findings-status-filter'); + + const params = new URLSearchParams(); + if (jobFilter && jobFilter.value) params.set('job_id', jobFilter.value); + if (severityFilter && severityFilter.value) params.set('severity', severityFilter.value); + if (statusFilter && statusFilter.value) params.set('status', statusFilter.value); + params.set('page', _repairFindingsPage); + params.set('limit', REPAIR_FINDINGS_PAGE_SIZE); + + try { + const response = await fetch(`/api/repair/findings?${params}`); + if (!response.ok) throw new Error('Failed to fetch findings'); + const data = await response.json(); + const items = data.items || []; + + _repairSelectedFindings.clear(); + _repairFindingsTotal = data.total || 0; + const bulkBar = document.getElementById('repair-findings-bulk'); + if (bulkBar) bulkBar.style.display = 'none'; + const selectAllCb = document.getElementById('repair-select-all-cb'); + if (selectAllCb) { selectAllCb.checked = false; selectAllCb.indeterminate = false; } + + if (items.length === 0) { + container.innerHTML = `
    +
    +
    All Clear
    +
    No findings match your filters. Your library is looking good!
    +
    `; + document.getElementById('repair-findings-pagination').innerHTML = ''; + return; + } + + const severityIcons = { info: 'ℹ️', warning: '⚠️', critical: '🔴' }; + const typeLabels = { + dead_file: 'Dead File', orphan_file: 'Orphan', acoustid_mismatch: 'Wrong Song', + acoustid_no_match: 'No Match', fake_lossless: 'Fake Lossless', + duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete', + path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', + missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', + missing_lossy_copy: 'No Lossy Copy' + }; + + // Finding types that have an automated fix action + const fixableTypes = { + dead_file: 'Re-download', + orphan_file: 'Resolve', + track_number_mismatch: 'Fix', + missing_cover_art: 'Apply Art', + metadata_gap: 'Apply', + duplicate_tracks: 'Keep Best', + incomplete_album: 'Auto-Fill', + missing_lossy_copy: 'Convert', + acoustid_mismatch: 'Fix', + missing_discography_track: 'Add to Wishlist', + }; + + container.innerHTML = items.map(f => { + const icon = severityIcons[f.severity] || 'ℹ️'; + const age = formatCacheAge(f.created_at); + const actionLabels = { + removed_db_entry: 'Entry Removed', added_to_wishlist: 'Wishlisted', deleted_file: 'File Deleted', + already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed', + applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied', + removed_duplicates: 'Duplicates Removed', + }; + let statusBadge = ''; + if (f.status !== 'pending') { + const actionText = actionLabels[f.user_action] || f.status; + statusBadge = `${actionText}`; + } + const typeLabel = typeLabels[f.finding_type] || f.finding_type.replace(/_/g, ' '); + const d = f.details || {}; + const filePath = f.file_path || d.original_path || d.file_path || ''; + const fixLabel = fixableTypes[f.finding_type]; + + return `
    +
    +
    + +
    +
    +
    + ${icon} + ${_escFinding(f.title)} + ${typeLabel} + ${statusBadge} +
    +
    ${_escFinding(f.description || '')}
    + ${filePath ? `
    ${_escFinding(filePath)}
    ` : ''} +
    + ${f.job_id.replace(/_/g, ' ')} + · + ${f.entity_type || 'file'} + ${f.entity_id ? `·ID: ${f.entity_id}` : ''} + · + ${age} +
    +
    +
    + ${f.status === 'pending' ? ` + ${fixLabel ? `` : ''} + + ` : ''} + +
    +
    +
    +
    + ${_renderFindingDetail(f)} +
    +
    +
    `; + }).join(''); + + // Pagination + renderRepairFindingsPagination(data.total, data.page); + + } catch (error) { + console.error('Error loading findings:', error); + container.innerHTML = '
    Error loading findings
    '; + } +} + +function _escFinding(s) { + if (!s) return ''; + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function _renderScoreBar(value, label) { + const pct = Math.round((value || 0) * 100); + const cls = pct >= 80 ? 'good' : pct >= 50 ? 'warn' : 'bad'; + return `
    + ${label} +
    + ${pct}% +
    `; +} + +function _formatFileSize(bytes) { + if (!bytes) return '-'; + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / 1048576).toFixed(1) + ' MB'; +} + +function _renderPlayButton(f) { + const d = f.details || {}; + const filePath = f.file_path || d.file_path || d.original_path; + if (!filePath) return ''; + const title = d.expected_title || d.title || d.file_title || d.matched_title || ''; + const artist = d.expected_artist || d.artist || d.artist_name || ''; + const album = d.album || d.album_title || ''; + const albumArt = d.album_thumb_url || ''; + return ``; +} + +function playFindingTrack(btn) { + const track = { + file_path: btn.dataset.path, + title: btn.dataset.title || 'Unknown Track', + id: btn.dataset.entityId || null + }; + const albumTitle = btn.dataset.album || ''; + const artistName = btn.dataset.artist || ''; + playLibraryTrack(track, albumTitle, artistName); +} + +function _renderFindingMedia(d) { + const albumUrl = d.album_thumb_url; + const artistUrl = d.artist_thumb_url; + if (!albumUrl && !artistUrl) return ''; + let html = '
    '; + if (albumUrl) { + const albumLabel = d.album_title || 'Album'; + html += `
    + Album art + ${_escFinding(albumLabel)} +
    `; + } + if (artistUrl) { + const artistLabel = d.artist_name || d.artist || 'Artist'; + html += `
    + Artist + ${_escFinding(artistLabel)} +
    `; + } + html += '
    '; + return html; +} + +function _renderFindingDetail(f) { + const d = f.details || {}; + const rows = []; + const media = _renderFindingMedia(d); + + switch (f.finding_type) { + case 'dead_file': + if (d.artist) rows.push(['Artist', d.artist]); + if (d.album) rows.push(['Album', d.album]); + if (d.title) rows.push(['Title', d.title]); + if (d.track_id) rows.push(['Track ID', d.track_id]); + if (d.original_path) rows.push(['Original Path', d.original_path, 'path']); + return media + _gridRows(rows) + _renderPlayButton(f); + + case 'orphan_file': + if (d.folder) rows.push(['Folder', d.folder, 'path']); + if (d.format) rows.push(['Format', d.format.toUpperCase()]); + if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]); + if (d.modified) rows.push(['Last Modified', d.modified]); + if (f.file_path) rows.push(['Full Path', f.file_path, 'path']); + return _gridRows(rows) + _renderPlayButton(f); + + case 'acoustid_mismatch': { + let html = media + '
    '; + html += _renderScoreBar(d.fingerprint_score, 'Fingerprint'); + html += _renderScoreBar(d.title_similarity, 'Title Match'); + html += _renderScoreBar(d.artist_similarity, 'Artist Match'); + html += '
    '; + rows.push(['Expected Title', d.expected_title || '-']); + rows.push(['Expected Artist', d.expected_artist || '-']); + rows.push(['AcoustID Title', d.acoustid_title || '-', 'highlight']); + rows.push(['AcoustID Artist', d.acoustid_artist || '-', 'highlight']); + if (f.file_path) rows.push(['File', f.file_path, 'path']); + return html + _gridRows(rows) + _renderPlayButton(f); + } + + case 'acoustid_no_match': + if (d.expected_title) rows.push(['Expected Title', d.expected_title]); + if (d.expected_artist) rows.push(['Expected Artist', d.expected_artist]); + if (f.file_path) rows.push(['File', f.file_path, 'path']); + return media + _gridRows(rows) + _renderPlayButton(f); + + case 'fake_lossless': { + const cutoff = d.detected_cutoff_khz || 0; + const expectedMin = d.expected_min_khz || 0; + const nyquist = d.nyquist_khz || (d.sample_rate ? d.sample_rate / 2000 : 22.05); + let flHtml = ''; + if (cutoff && expectedMin) { + const cutoffPct = Math.min(100, Math.round((cutoff / nyquist) * 100)); + const expectedPct = Math.min(100, Math.round((expectedMin / nyquist) * 100)); + flHtml += `
    +
    Spectral Analysis
    +
    +
    +
    +
    +
    + ${cutoff} kHz detected + ${expectedMin} kHz expected min +
    +
    `; + } + if (d.format) rows.push(['Format', d.format.toUpperCase()]); + if (d.sample_rate) rows.push(['Sample Rate', `${d.sample_rate} Hz`]); + if (d.bit_depth) rows.push(['Bit Depth', `${d.bit_depth}-bit`]); + if (d.bitrate) rows.push(['Bitrate', `${d.bitrate} kbps`]); + if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]); + if (f.file_path) rows.push(['File', f.file_path, 'path']); + return flHtml + _gridRows(rows) + _renderPlayButton(f); + } + + case 'duplicate_tracks': + if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]); + // Determine best copy (same logic as backend: highest bitrate, then duration, then track number) + const bestDup = d.tracks.reduce((best, t) => { + const bBr = best.bitrate || 0, tBr = t.bitrate || 0; + const bDur = best.duration || 0, tDur = t.duration || 0; + const bTn = best.track_number || 0, tTn = t.track_number || 0; + return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best; + }, d.tracks[0]); + const findingId = f.id; + return media + `
    ${d.tracks.map((t, i) => { + const tid = t.track_id || t.id; + const isBest = (t.id === bestDup.id); + return `
    + + ${isBest ? 'KEEP' : 'REMOVE'} + ${_escFinding(t.title)} by ${_escFinding(t.artist)} + + Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}${t.track_number ? ` · Track #${t.track_number}` : ''} + ${t.file_path ? `${_escFinding(t.file_path)}` : ''} +
    `; + }).join('')}
    +
    Click on a version to keep it, or use "Keep Best" for auto-selection
    `; + + case 'incomplete_album': + if (d.artist) rows.push(['Artist', d.artist]); + if (d.album_title) rows.push(['Album', d.album_title]); + if (d.primary_source && d.primary_album_id) { + const primaryLabel = d.primary_source.charAt(0).toUpperCase() + d.primary_source.slice(1); + rows.push([`${primaryLabel} ID`, d.primary_album_id]); + if (d.spotify_album_id && d.primary_source !== 'spotify') { + rows.push(['Spotify ID', d.spotify_album_id]); + } + } else if (d.spotify_album_id) { + rows.push(['Spotify ID', d.spotify_album_id]); + } + let incHtml = media + _gridRows(rows); + const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0; + if (expected > 0) { + const pct = Math.round((actual / expected) * 100); + incHtml += `
    +
    ${actual} of ${expected} tracks (${pct}%)
    +
    +
    `; + } + if (d.missing_tracks && d.missing_tracks.length) { + incHtml += `
    ${d.missing_tracks.map(t => ` +
    + #${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')} + ${t.source && t.source !== 'spotify' ? `Source: ${_escFinding(t.source)}${t.source_track_id ? ` · ID: ${_escFinding(t.source_track_id)}` : ''}` : ''} + ${t.duration_ms ? `Duration: ${Math.round(t.duration_ms / 1000)}s` : ''} +
    `).join('')}
    `; + } + return incHtml; + + case 'path_mismatch': + if (d.from) rows.push(['Current Path', d.from, 'path']); + if (d.to) rows.push(['Expected Path', d.to, 'success']); + return _gridRows(rows); + + case 'metadata_gap': + if (d.artist) rows.push(['Artist', d.artist]); + if (d.album) rows.push(['Album', d.album]); + if (d.title) rows.push(['Title', d.title]); + if (d.spotify_track_id) rows.push(['Spotify ID', d.spotify_track_id]); + if (d.resolved_source) rows.push(['Resolved Source', d.resolved_source]); + if (d.resolved_track_id) rows.push(['Resolved Track ID', d.resolved_track_id]); + if (d.found_fields && typeof d.found_fields === 'object') { + Object.entries(d.found_fields).forEach(([k, v]) => { + rows.push([`Found: ${k}`, String(v), 'success']); + }); + } + return media + _gridRows(rows); + + case 'missing_cover_art': + if (d.artist) rows.push(['Artist', d.artist]); + if (d.album_title) rows.push(['Album', d.album_title]); + if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]); + let artHtml = ''; + // Show artist image + found artwork side by side + if (d.artist_thumb_url || d.found_artwork_url) { + artHtml += '
    '; + if (d.artist_thumb_url) { + artHtml += `
    + Artist + ${_escFinding(d.artist || 'Artist')} +
    `; + } + if (d.found_artwork_url) { + artHtml += `
    + Found artwork + Found Artwork +
    `; + } + artHtml += '
    '; + } + artHtml += _gridRows(rows); + return artHtml; + + case 'track_number_mismatch': + if (d.album_title) rows.push(['Album', d.album_title]); + if (d.artist_name) rows.push(['Artist', d.artist_name]); + if (d.matched_title) rows.push(['Matched To', d.matched_title]); + if (d.file_title) rows.push(['File Title', d.file_title]); + if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]); + if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']); + if (f.file_path) rows.push(['File', f.file_path, 'path']); + let tnHtml = media; + if (d.match_score) { + tnHtml += '
    '; + tnHtml += _renderScoreBar(d.match_score, 'Title Match'); + tnHtml += '
    '; + } + tnHtml += _gridRows(rows); + if (d.changes && d.changes.length) { + tnHtml += `
    ${d.changes.map(c => ` +
    ${_escFinding(c)}
    `).join('')}
    `; + } + tnHtml += _renderPlayButton(f); + return tnHtml; + + default: + // Generic: render all detail keys + Object.entries(d).forEach(([k, v]) => { + if (typeof v !== 'object' && !k.endsWith('_thumb_url')) { + rows.push([k.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), String(v)]); + } + }); + if (f.file_path) rows.push(['File', f.file_path, 'path']); + return (media || '') + (rows.length ? _gridRows(rows) : 'No additional details available'); + } +} + +function _gridRows(rows) { + if (!rows.length) return ''; + return `
    ${rows.map(([k, v, cls]) => + `${_escFinding(k)}${_escFinding(v)}` + ).join('')}
    `; +} + +function toggleFindingDetail(id) { + const panel = document.getElementById(`repair-detail-${id}`); + const btn = document.querySelector(`.repair-finding-expand-btn[data-finding="${id}"]`); + if (!panel) return; + const isOpen = panel.classList.toggle('open'); + if (btn) btn.classList.toggle('open', isOpen); +} + +function toggleFindingSelect(id, checked) { + if (checked) _repairSelectedFindings.add(id); + else _repairSelectedFindings.delete(id); + + _updateFindingsBulkBar(); +} + +function _updateFindingsBulkBar() { + const bulkBar = document.getElementById('repair-findings-bulk'); + const count = _repairSelectedFindings.size; + if (bulkBar) bulkBar.style.display = count > 0 ? '' : 'none'; + const countEl = document.getElementById('repair-bulk-count'); + if (countEl) countEl.textContent = count > 0 ? `${count} selected` : ''; + + // Show "Fix All (N)" when all on page are selected and there are more pages + const fixAllBtn = document.getElementById('repair-fix-all-btn'); + if (fixAllBtn && _repairFindingsTotal > 0) { + const allPageSelected = count > 0 && count >= document.querySelectorAll('.repair-finding-card').length; + fixAllBtn.style.display = (allPageSelected && _repairFindingsTotal > count) ? '' : 'none'; + fixAllBtn.textContent = `Fix All ${_repairFindingsTotal}`; + } + + // Sync "Select All" checkbox + const selectAllCb = document.getElementById('repair-select-all-cb'); + if (selectAllCb) { + const totalOnPage = document.querySelectorAll('.repair-finding-card').length; + selectAllCb.checked = totalOnPage > 0 && count >= totalOnPage; + selectAllCb.indeterminate = count > 0 && count < totalOnPage; + } +} + +function toggleSelectAllFindings(checked) { + const checkboxes = document.querySelectorAll('.repair-finding-select input[type="checkbox"]'); + checkboxes.forEach(cb => { + cb.checked = checked; + const card = cb.closest('.repair-finding-card'); + if (card) { + const id = parseInt(card.dataset.id); + if (checked) _repairSelectedFindings.add(id); + else _repairSelectedFindings.delete(id); + } + }); + _updateFindingsBulkBar(); +} + +async function fixAllMatchingFindings() { + const jobFilter = document.getElementById('repair-findings-job-filter'); + const severityFilter = document.getElementById('repair-findings-severity-filter'); + const jobId = jobFilter ? jobFilter.value : ''; + const severity = severityFilter ? severityFilter.value : ''; + + // If fixing orphan files or dead files, prompt for action FIRST + let fixAction = null; + // Discography backfill: 3-option prompt (Add to Wishlist / Just Clear / Cancel). + // "Just Clear" bypasses bulk-fix entirely and goes through the clear endpoint, + // which is why it's handled inline and returns early. + if (jobId === 'discography_backfill') { + const choice = await _promptDiscographyBackfillAction(_repairFindingsTotal); + if (!choice) return; + if (choice === 'dismiss') { + if (!await showConfirmDialog({ + title: 'Clear All Discography Findings', + message: `Clear all ${_repairFindingsTotal} discography backfill findings without adding any to the wishlist? Tracks can be re-detected next scan.`, + confirmText: 'Clear All', + destructive: false + })) return; + showToast(`Clearing ${_repairFindingsTotal} findings...`, 'info'); + try { + const resp = await fetch('/api/repair/findings/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ job_id: 'discography_backfill', status: 'pending' }) + }); + const result = await resp.json(); + if (result.success) { + showToast(`Cleared ${result.deleted} findings`, 'success'); + } else { + showToast(result.error || 'Clear failed', 'error'); + } + } catch (err) { + console.error('Error clearing findings:', err); + showToast('Error clearing findings', 'error'); + } + _repairSelectedFindings.clear(); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + return; + } + // 'add_to_wishlist' falls through to bulk-fix. No destructive warning — + // the backend handler only adds tracks to the wishlist. + } else if (jobId === 'dead_file_cleaner') { + fixAction = await _promptDeadFileAction(); + if (!fixAction) return; + } else if (jobId === 'orphan_file_detector' || _isMassOrphanFix(jobId, _repairFindingsTotal)) { + fixAction = await _promptOrphanAction(); + if (!fixAction) return; + // Confirm before proceeding + if (fixAction === 'delete' && _repairFindingsTotal > 50) { + if (!await showWitnessMeDialog(_repairFindingsTotal)) return; + } else if (fixAction === 'delete') { + if (!await showConfirmDialog({ + title: 'Delete Orphan Files', + message: `Permanently delete ${_repairFindingsTotal} orphan files from disk? This cannot be undone.`, + confirmText: 'Delete', + destructive: true + })) return; + } else if (fixAction === 'staging') { + if (!await showConfirmDialog({ + title: 'Move to Staging', + message: `Move ${_repairFindingsTotal} orphan files to the import folder? Files are NOT deleted — you can review and import them.`, + confirmText: 'Move All to Staging', + destructive: false + })) return; + } + } else { + const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; + if (!await showConfirmDialog({ + title: 'Fix All Findings', + message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`, + confirmText: 'Fix All', + destructive: true + })) return; + } + + showToast(`Fixing ${_repairFindingsTotal} findings...`, 'info'); + + try { + const body = {}; + if (jobId) body.job_id = jobId; + if (severity) body.severity = severity; + if (fixAction) body.fix_action = fixAction; + + const response = await fetch('/api/repair/findings/bulk-fix', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const result = await response.json(); + if (result.success) { + let msg = `Fixed ${result.fixed}${result.failed ? `, ${result.failed} failed` : ''} of ${result.total}`; + if (result.errors && result.errors.length > 0) { + msg += `: ${result.errors[0].error}`; + } + showToast(msg, result.fixed > 0 ? 'success' : 'error'); + } else { + showToast(result.error || 'Bulk fix failed', 'error'); + } + } catch (error) { + console.error('Error in bulk fix:', error); + showToast('Error applying bulk fix', 'error'); + } + + _repairSelectedFindings.clear(); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); +} + +function renderRepairFindingsPagination(total, currentPage) { + const container = document.getElementById('repair-findings-pagination'); + if (!container) return; + + const totalPages = Math.ceil(total / REPAIR_FINDINGS_PAGE_SIZE); + if (totalPages <= 1) { container.innerHTML = ''; return; } + + let html = ''; + if (currentPage > 0) { + html += ``; + } + + // Smart page range + let startPage = Math.max(0, currentPage - 3); + let endPage = Math.min(totalPages, startPage + 7); + if (endPage - startPage < 7) startPage = Math.max(0, endPage - 7); + + if (startPage > 0) { + html += ``; + if (startPage > 1) html += '...'; + } + for (let i = startPage; i < endPage; i++) { + html += ``; + } + if (endPage < totalPages) { + if (endPage < totalPages - 1) html += '...'; + html += ``; + } + + if (currentPage < totalPages - 1) { + html += ``; + } + html += `${total.toLocaleString()} total`; + container.innerHTML = html; +} + +async function selectDuplicateToKeep(findingId, keepTrackId) { + if (!await showConfirmDialog({ title: 'Keep This Version', message: 'Keep this version and remove the other duplicate(s)?', confirmText: 'Keep', destructive: true })) return; + try { + const response = await fetch(`/api/repair/findings/${findingId}/fix`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ fix_action: keepTrackId }), + }); + const result = await response.json(); + if (result.success) { + showToast(result.message || 'Duplicate resolved', 'success'); + } else { + showToast(result.error || 'Failed to resolve duplicate', 'error'); + } + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error fixing duplicate:', error); + showToast('Error resolving duplicate', 'error'); + } +} + +async function fixRepairFinding(id, findingType) { + // Orphan files require user to choose an action + let fixAction = null; + if (findingType === 'orphan_file') { + fixAction = await _promptOrphanAction(); + if (!fixAction) return; // User cancelled + } + // Dead files: re-download or just remove from DB + if (findingType === 'dead_file') { + fixAction = await _promptDeadFileAction(); + if (!fixAction) return; + } + // AcoustID mismatch: retag, redownload, or delete + if (findingType === 'acoustid_mismatch') { + fixAction = await _promptAcoustidAction(); + if (!fixAction) return; + } + // Discography backfill: add to wishlist or just clear the finding + if (findingType === 'missing_discography_track') { + const choice = await _promptDiscographyBackfillAction(1); + if (!choice) return; // cancel + if (choice === 'dismiss') { + // User just wants to remove the finding without adding to wishlist + await dismissRepairFinding(id); + return; + } + // 'add_to_wishlist' — fall through to the fix endpoint. The handler + // already defaults to adding to wishlist, so no fix_action is needed. + } + + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + const fixBtn = card ? card.querySelector('.repair-finding-btn.fix') : null; + let originalText = ''; + if (fixBtn) { + originalText = fixBtn.textContent; + fixBtn.disabled = true; + fixBtn.textContent = '...'; + } + try { + const body = fixAction ? { fix_action: fixAction } : {}; + const response = await fetch(`/api/repair/findings/${id}/fix`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const result = await response.json(); + if (result.success) { + showToast(result.message || 'Fixed successfully', 'success'); + } else { + showToast(result.error || 'Fix failed', 'error'); + } + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error fixing finding:', error); + showToast('Error applying fix', 'error'); + if (fixBtn) { + fixBtn.disabled = false; + fixBtn.textContent = originalText; + } + } +} + +function _promptOrphanAction() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; + overlay.innerHTML = ` +
    +
    Orphan File Action
    +
    + Choose how to handle orphan files. Staging is safe and reversible. +
    +
    + + +
    + +
    + `; + document.body.appendChild(overlay); + + overlay.querySelector('#_orphan-staging').onclick = () => { overlay.remove(); resolve('staging'); }; + overlay.querySelector('#_orphan-delete').onclick = () => { overlay.remove(); resolve('delete'); }; + overlay.querySelector('#_orphan-cancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + }); +} + +function _promptDeadFileAction() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; + overlay.innerHTML = ` +
    +
    Dead File Action
    +
    + This track's file no longer exists on disk. Choose how to handle it. +
    +
    + + +
    + +
    + `; + document.body.appendChild(overlay); + + overlay.querySelector('#_dead-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; + overlay.querySelector('#_dead-remove').onclick = () => { overlay.remove(); resolve('remove'); }; + overlay.querySelector('#_dead-cancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + }); +} + +function _promptAcoustidAction() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; + overlay.innerHTML = ` +
    +
    AcoustID Mismatch
    +
    + The audio fingerprint doesn't match the expected track. Choose how to fix it. +
    +
    + + + +
    +
    + Retag = update metadata to match actual audio • Re-download = add correct track to wishlist & delete wrong file • Delete = remove file and DB entry +
    + +
    + `; + document.body.appendChild(overlay); + + overlay.querySelector('#_acid-retag').onclick = () => { overlay.remove(); resolve('retag'); }; + overlay.querySelector('#_acid-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; + overlay.querySelector('#_acid-delete').onclick = () => { overlay.remove(); resolve('delete'); }; + overlay.querySelector('#_acid-cancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + }); +} + +function _promptDiscographyBackfillAction(count = 1) { + const isSingle = count <= 1; + const headerText = isSingle ? 'Missing Discography Track' : `Missing Discography Tracks (${count})`; + const bodyText = isSingle + ? 'Add this track to the wishlist for automatic download, or just clear the finding?' + : `Add all ${count} selected tracks to the wishlist for automatic download, or just clear the findings?`; + const addLabel = isSingle ? 'Add to Wishlist' : `Add All ${count} to Wishlist`; + const clearLabel = isSingle ? 'Just Clear Finding' : 'Just Clear Findings'; + + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; + overlay.innerHTML = ` +
    +
    +
    +
    + + +
    + +
    + `; + // Assign text content (avoids HTML-escaping gotchas with dynamic values) + overlay.querySelector('#_dbf-header').textContent = headerText; + overlay.querySelector('#_dbf-body').textContent = bodyText; + overlay.querySelector('#_dbf-add').textContent = addLabel; + overlay.querySelector('#_dbf-dismiss').textContent = clearLabel; + document.body.appendChild(overlay); + + overlay.querySelector('#_dbf-add').onclick = () => { overlay.remove(); resolve('add_to_wishlist'); }; + overlay.querySelector('#_dbf-dismiss').onclick = () => { overlay.remove(); resolve('dismiss'); }; + overlay.querySelector('#_dbf-cancel').onclick = () => { overlay.remove(); resolve(null); }; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + }); +} + +async function resolveRepairFinding(id) { + try { + await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' }); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error resolving finding:', error); + } +} + +async function dismissRepairFinding(id) { + try { + await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error dismissing finding:', error); + } +} + +async function bulkRepairAction(action) { + if (_repairSelectedFindings.size === 0) return; + try { + await fetch('/api/repair/findings/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: Array.from(_repairSelectedFindings), action }) + }); + showToast(`${_repairSelectedFindings.size} findings ${action === 'dismiss' ? 'dismissed' : 'resolved'}`, 'success'); + _repairSelectedFindings.clear(); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error bulk updating findings:', error); + showToast('Error updating findings', 'error'); + } +} + +async function bulkFixFindings() { + if (_repairSelectedFindings.size === 0) return; + const ids = Array.from(_repairSelectedFindings); + + // If any selected findings are orphan files, prompt for action FIRST + const selectedOrphanCards = ids.filter(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.jobId === 'orphan_file_detector'; + }); + let orphanFixAction = null; + if (selectedOrphanCards.length > 0) { + orphanFixAction = await _promptOrphanAction(); + if (!orphanFixAction) return; + // Only show scary dialog for mass deletion, not staging + if (orphanFixAction === 'delete' && selectedOrphanCards.length > MASS_ORPHAN_THRESHOLD) { + const hasMassFlag = ids.some(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.massOrphan === 'true'; + }); + if (hasMassFlag && !await showWitnessMeDialog(selectedOrphanCards.length)) return; + } + } + + // If any selected findings are dead files, prompt for action + const selectedDeadCards = ids.filter(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.jobId === 'dead_file_cleaner'; + }); + let deadFixAction = null; + if (selectedDeadCards.length > 0) { + deadFixAction = await _promptDeadFileAction(); + if (!deadFixAction) return; + } + + // If any selected findings are AcoustID mismatches, prompt for action + const selectedAcoustidCards = ids.filter(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.jobId === 'acoustid_scanner'; + }); + let acoustidFixAction = null; + if (selectedAcoustidCards.length > 0) { + acoustidFixAction = await _promptAcoustidAction(); + if (!acoustidFixAction) return; + } + + // If any selected findings are discography backfill, prompt once (add-to-wishlist vs clear) + const selectedBackfillCards = ids.filter(id => { + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + return card && card.dataset.jobId === 'discography_backfill'; + }); + let backfillAction = null; + if (selectedBackfillCards.length > 0) { + backfillAction = await _promptDiscographyBackfillAction(selectedBackfillCards.length); + if (!backfillAction) return; + } + + let fixed = 0, failed = 0, lastError = ''; + showToast(`Fixing ${ids.length} findings...`, 'info'); + + for (const id of ids) { + try { + // Determine if this finding needs a specific action + const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); + const isOrphan = card && card.dataset.jobId === 'orphan_file_detector'; + const isDead = card && card.dataset.jobId === 'dead_file_cleaner'; + const isAcoustid = card && card.dataset.jobId === 'acoustid_scanner'; + const isBackfill = card && card.dataset.jobId === 'discography_backfill'; + + // Discography backfill "Just Clear" path uses the dismiss endpoint, + // not the fix endpoint — so handle it inline before the fix call. + if (isBackfill && backfillAction === 'dismiss') { + try { + const resp = await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); + if (resp.ok) fixed++; + else { failed++; lastError = 'dismiss failed'; } + } catch { + failed++; + } + continue; + } + + let body = {}; + if (isOrphan && orphanFixAction) body = { fix_action: orphanFixAction }; + else if (isDead && deadFixAction) body = { fix_action: deadFixAction }; + else if (isAcoustid && acoustidFixAction) body = { fix_action: acoustidFixAction }; + // Discography backfill "Add to Wishlist" falls through with empty body + // — the fix handler already adds to wishlist by default. + + const response = await fetch(`/api/repair/findings/${id}/fix`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const result = await response.json(); + if (result.success) fixed++; + else { failed++; lastError = result.error || 'unknown error'; } + } catch { + failed++; + } + } + + _repairSelectedFindings.clear(); + let fixMsg = `Fixed ${fixed}${failed ? `, ${failed} failed` : ''}`; + if (failed && lastError) fixMsg += `: ${lastError}`; + showToast(fixMsg, fixed > 0 ? 'success' : 'error'); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); +} + +async function clearRepairFindings() { + const jobFilter = document.getElementById('repair-findings-job-filter'); + const statusFilter = document.getElementById('repair-findings-status-filter'); + const jobId = jobFilter ? jobFilter.value : ''; + const status = statusFilter ? statusFilter.value : ''; + + const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; + const statusLabel = status ? ` (${status})` : ''; + if (!await showConfirmDialog({ + title: 'Clear Findings', + message: `Delete all findings for ${scopeLabel}${statusLabel}? This cannot be undone.`, + confirmText: 'Clear', + destructive: true + })) return; + + try { + const body = {}; + if (jobId) body.job_id = jobId; + if (status) body.status = status; + + const response = await fetch('/api/repair/findings/clear', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const result = await response.json(); + if (result.success) { + showToast(`Cleared ${result.deleted} findings`, 'success'); + } else { + showToast(result.error || 'Failed to clear findings', 'error'); + } + _repairSelectedFindings.clear(); + loadRepairFindingsDashboard(); + loadRepairFindings(); + updateRepairStatus(); + } catch (error) { + console.error('Error clearing findings:', error); + showToast('Error clearing findings', 'error'); + } +} + +async function loadRepairHistory() { + const container = document.getElementById('repair-history-list'); + if (!container) return; + + try { + const response = await fetch('/api/repair/history?limit=50'); + if (!response.ok) throw new Error('Failed to fetch history'); + const data = await response.json(); + const runs = data.runs || []; + + if (runs.length === 0) { + container.innerHTML = `
    +
    🕑
    +
    No History Yet
    +
    Job run history will appear here after maintenance jobs complete their first scan.
    +
    `; + return; + } + + container.innerHTML = runs.map(run => { + const duration = run.duration_seconds ? `${run.duration_seconds.toFixed(1)}s` : '-'; + const age = formatCacheAge(run.started_at); + const statusClass = run.status === 'completed' ? 'success' : + run.status === 'failed' ? 'error' : 'running'; + + // Build stat pills + const stats = []; + stats.push(`${(run.items_scanned || 0).toLocaleString()} scanned`); + if (run.findings_created) stats.push(`${run.findings_created} findings`); + if (run.auto_fixed) stats.push(`${run.auto_fixed} fixed`); + if (run.errors) stats.push(`${run.errors} errors`); + + // Format timestamps + const startTime = run.started_at ? new Date(run.started_at).toLocaleString() : '-'; + const endTime = run.finished_at ? new Date(run.finished_at).toLocaleString() : 'In progress'; + + return `
    +
    +
    + ${run.display_name || run.job_id} + ${run.status} + ${duration} +
    +
    ${stats.join('')}
    +
    ${age} · ${startTime} → ${endTime}
    +
    `; + }).join(''); + + } catch (error) { + console.error('Error loading repair history:', error); + container.innerHTML = '
    Error loading history
    '; + } +} + +// Initialize Repair Worker UI on page load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => { + const button = document.getElementById('repair-button'); + if (button) { + button.addEventListener('click', openRepairModal); + updateRepairStatus(); + setInterval(updateRepairStatus, 5000); + } + }); +} else { + const button = document.getElementById('repair-button'); + if (button) { + button.addEventListener('click', openRepairModal); + updateRepairStatus(); + setInterval(updateRepairStatus, 5000); + } +} + +// =================================================================== + diff --git a/webui/static/helper.js b/webui/static/helper.js index 2b831555..663fa00c 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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 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'); diff --git a/webui/static/init.js b/webui/static/init.js new file mode 100644 index 00000000..a92a010f --- /dev/null +++ b/webui/static/init.js @@ -0,0 +1,2361 @@ +// INITIALIZATION +// =============================== + +// ---- Accent Color System ---- + +function getAccentFallbackColors() { + let accent = localStorage.getItem('soulsync-accent') || '#1db954'; + if (!/^#[0-9a-fA-F]{6}$/.test(accent)) accent = '#1db954'; + // Compute a lighter variant for the second color + const r = parseInt(accent.slice(1, 3), 16), g = parseInt(accent.slice(3, 5), 16), b = parseInt(accent.slice(5, 7), 16); + const lighter = '#' + [Math.min(r + 20, 255), Math.min(g + 30, 255), Math.min(b + 12, 255)] + .map(v => v.toString(16).padStart(2, '0')).join(''); + return [accent, lighter]; +} + +function applyAccentColor(hex) { + // Validate hex format — reject corrupt values + if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) { + hex = '#1db954'; // fallback to default + } + // Convert hex to RGB + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + + // Convert RGB to HSL + const rn = r / 255, gn = g / 255, bn = b / 255; + const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + let h = 0, s = 0; + if (max !== min) { + const d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + } + + // Compute light variant: +16% lightness + const lightL = Math.min(l + 0.16, 0.95); + // Compute neon variant: high lightness + boosted saturation + const neonL = Math.min(l + 0.30, 0.95); + const neonS = Math.min(s + 0.1, 1.0); + + function hslToRgb(h, s, l) { + if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; } + const hue2rgb = (p, q, t) => { + if (t < 0) t += 1; if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + const q = l < 0.5 ? l * (1 + s) : l + s - l * s; + const p = 2 * l - q; + return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255), + Math.round(hue2rgb(p, q, h) * 255), + Math.round(hue2rgb(p, q, h - 1 / 3) * 255)]; + } + + const light = hslToRgb(h, s, lightL); + const neon = hslToRgb(h, neonS, neonL); + + const root = document.documentElement.style; + root.setProperty('--accent-rgb', `${r}, ${g}, ${b}`); + root.setProperty('--accent-light-rgb', `${light[0]}, ${light[1]}, ${light[2]}`); + root.setProperty('--accent-neon-rgb', `${neon[0]}, ${neon[1]}, ${neon[2]}`); + + // Store for instant restore on next page load + localStorage.setItem('soulsync-accent', hex); + + // Update preview swatch if it exists + const swatch = document.getElementById('accent-preview-swatch'); + if (swatch) swatch.style.background = hex; +} + +function applyParticlesSetting(enabled) { + const canvas = document.getElementById('page-particles-canvas'); + if (canvas) canvas.style.display = enabled ? '' : 'none'; + if (window.pageParticles) { + if (enabled) { + const activePage = document.querySelector('.page.active'); + if (activePage) { + window.pageParticles.setPage(activePage.id.replace('-page', '')); + } + } else { + window.pageParticles.stop(); + } + } + window._particlesEnabled = enabled; + localStorage.setItem('soulsync-particles', String(enabled)); +} + +function applyWorkerOrbsSetting(enabled) { + window._workerOrbsEnabled = enabled; + localStorage.setItem('soulsync-worker-orbs', String(enabled)); + if (window.workerOrbs) { + if (enabled) { + const activePage = document.querySelector('.page.active'); + if (activePage && activePage.id === 'dashboard-page') { + window.workerOrbs.setPage('dashboard'); + } + } else { + window.workerOrbs.setPage('_disabled'); + } + } +} + +function initAccentColorListeners() { + const presetSelect = document.getElementById('accent-preset'); + const customGroup = document.getElementById('custom-color-group'); + const customPicker = document.getElementById('accent-custom-color'); + if (!presetSelect) return; + + presetSelect.addEventListener('change', () => { + const val = presetSelect.value; + if (val === 'custom') { + if (customGroup) customGroup.style.display = ''; + if (customPicker) applyAccentColor(customPicker.value); + } else { + if (customGroup) customGroup.style.display = 'none'; + applyAccentColor(val); + } + }); + + if (customPicker) { + customPicker.addEventListener('input', () => { + applyAccentColor(customPicker.value); + }); + } + + // Particles toggle — apply immediately on change + const particlesCheckbox = document.getElementById('particles-enabled'); + if (particlesCheckbox) { + particlesCheckbox.addEventListener('change', () => { + applyParticlesSetting(particlesCheckbox.checked); + }); + } + + // Worker orbs toggle — apply immediately on change + const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); + if (workerOrbsCheckbox) { + workerOrbsCheckbox.addEventListener('change', () => { + applyWorkerOrbsSetting(workerOrbsCheckbox.checked); + }); + } + + // Reduce effects toggle — apply immediately on change + const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled'); + if (reduceEffectsCheckbox) { + reduceEffectsCheckbox.addEventListener('change', () => { + applyReduceEffects(reduceEffectsCheckbox.checked); + }); + } +} + +function applyReduceEffects(enabled) { + if (enabled) { + document.body.classList.add('reduce-effects'); + } else { + document.body.classList.remove('reduce-effects'); + } + localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0'); +} + +// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash) +(function () { + if (localStorage.getItem('soulsync-reduce-effects') === '1') { + document.body.classList.add('reduce-effects'); + } + const saved = localStorage.getItem('soulsync-accent'); + if (saved) applyAccentColor(saved); + // Bootstrap particles setting from localStorage + const particlesSaved = localStorage.getItem('soulsync-particles'); + if (particlesSaved === 'false') { + window._particlesEnabled = false; + const canvas = document.getElementById('page-particles-canvas'); + if (canvas) canvas.style.display = 'none'; + } + // Bootstrap worker orbs setting from localStorage + const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs'); + if (workerOrbsSaved === 'false') { + window._workerOrbsEnabled = false; + } +})(); + +// ── Profile System ───────────────────────────────────────────── +let currentProfile = null; + +// Normalize legacy allowed_pages entries so the profile edit forms (which are +// built from the new pageLabels map containing 'search') don't drop access +// when saving a profile that was stored before the Search page rename. +// 'downloads' was the old Search id, 'artists' was the retired inline page. +function _normalizeLegacyAllowedPages(pages) { + if (!Array.isArray(pages)) return pages; + const mapped = pages.map(id => (id === 'downloads' || id === 'artists') ? 'search' : id); + return [...new Set(mapped)]; +} + +function _normalizeLegacyHomePage(homePage) { + if (homePage === 'downloads' || homePage === 'artists') return 'search'; + return homePage; +} + +function getProfileHomePage() { + if (!currentProfile) return 'dashboard'; + // Legacy profiles stored the Search page as either 'downloads' (the old id + // before the rename) or 'artists' (the retired inline Artists page). + // Both fold into the unified Search page now. + let home = currentProfile.home_page; + if (home === 'downloads' || home === 'artists') home = 'search'; + if (home) return home; + return currentProfile.is_admin ? 'dashboard' : 'discover'; +} + +function isPageAllowed(pageId) { + if (!currentProfile) return true; + if (currentProfile.id === 1) return true; + if (pageId === 'help' || pageId === 'issues') return true; + if (pageId === 'settings') return currentProfile.is_admin; + if (pageId === 'artist-detail') { + // artist-detail is reachable from both Library and Search results, so + // either grant unlocks it. Without this, a Search-only profile couldn't + // open a source artist, and a legacy artists-only profile would hit the + // home-redirect recursion path below. + const ap = currentProfile.allowed_pages; + if (!ap) return true; + return ap.includes('library') || ap.includes('search') + || ap.includes('downloads') || ap.includes('artists'); + } + const ap = currentProfile.allowed_pages; + if (!ap) return true; // null = all pages + if (ap.includes(pageId)) return true; + // Legacy compat: 'downloads' (old Search id) and 'artists' (retired inline + // Artists page) both fold into the unified 'search' page. + if (pageId === 'search' && (ap.includes('downloads') || ap.includes('artists'))) return true; + if ((pageId === 'downloads' || pageId === 'artists') && ap.includes('search')) return true; + return false; +} + +function canDownload() { + if (!currentProfile) return true; + if (currentProfile.id === 1) return true; + return currentProfile.can_download !== false && currentProfile.can_download !== 0; +} + +function renderProfileAvatar(el, profile) { + // Renders avatar as image (if avatar_url set) or colored initial fallback + // Preserves existing classes, ensures 'profile-avatar' is present + if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) { + el.className = 'profile-avatar'; + } + el.style.background = profile.avatar_color || '#6366f1'; + el.textContent = ''; + if (profile.avatar_url) { + const img = document.createElement('img'); + img.src = profile.avatar_url; + img.alt = profile.name; + img.className = 'profile-avatar-img'; + img.onerror = () => { + img.remove(); + el.textContent = profile.name.charAt(0).toUpperCase(); + }; + el.appendChild(img); + } else { + el.textContent = profile.name.charAt(0).toUpperCase(); + } +} + +async function initProfileSystem() { + try { + // Check if a session already has a profile selected + const currentRes = await fetch('/api/profiles/current'); + const currentData = await currentRes.json(); + if (currentData.success && currentData.profile) { + currentProfile = currentData.profile; + updateProfileIndicator(); + + // Check if launch PIN is required + if (currentData.launch_pin_required) { + showLaunchPinScreen(); + return false; // Defer app init until PIN verified + } + + return true; // Profile already selected, skip picker + } + + // Fetch all profiles + const res = await fetch('/api/profiles'); + const data = await res.json(); + const profiles = data.profiles || []; + + if (profiles.length === 0) { + // No profiles yet — auto-select admin profile 1 + await selectProfile(1); + return true; + } + + if (profiles.length === 1) { + // Only one profile — always auto-select (PIN only matters with multiple profiles) + await selectProfile(profiles[0].id); + + // Re-check for launch PIN after auto-select + const recheck = await fetch('/api/profiles/current'); + const recheckData = await recheck.json(); + if (recheckData.launch_pin_required) { + showLaunchPinScreen(); + return false; + } + + return true; + } + + // Multiple profiles or PIN required — show picker + showProfilePicker(profiles); + return false; // App init deferred until profile selected + } catch (e) { + console.error('Profile init error:', e); + return true; // Fall through to normal init + } +} + +// ── Launch PIN Lock Screen ───────────────────────────────────────────── + +function showLaunchPinScreen() { + const overlay = document.getElementById('launch-pin-overlay'); + if (!overlay) return; + overlay.style.display = 'flex'; + + const input = document.getElementById('launch-pin-input'); + const submit = document.getElementById('launch-pin-submit'); + const error = document.getElementById('launch-pin-error'); + + input.value = ''; + error.style.display = 'none'; + setTimeout(() => input.focus(), 100); + + const doSubmit = async () => { + const pin = input.value.trim(); + if (!pin) return; + + submit.disabled = true; + submit.textContent = 'Verifying...'; + + try { + const res = await fetch('/api/profiles/verify-launch-pin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }) + }); + const data = await res.json(); + + if (data.success) { + // Server session flag set by verify endpoint — consumed on next /api/profiles/current call + overlay.style.display = 'none'; + initApp(); // Now safe to load the full app + } else { + error.textContent = data.error || 'Invalid PIN'; + error.style.display = 'block'; + input.value = ''; + input.focus(); + // Shake animation + overlay.querySelector('.launch-pin-container').classList.add('shake'); + setTimeout(() => overlay.querySelector('.launch-pin-container').classList.remove('shake'), 500); + } + } catch (e) { + error.textContent = 'Connection error'; + error.style.display = 'block'; + } + + submit.disabled = false; + submit.textContent = 'Unlock'; + }; + + // Remove old listeners to prevent stacking + const newSubmit = submit.cloneNode(true); + submit.parentNode.replaceChild(newSubmit, submit); + newSubmit.addEventListener('click', doSubmit); + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doSubmit(); + }); +} + +// ── Security Settings Helpers ────────────────────────────────────────── + +async function saveSecurityPin() { + const pin = document.getElementById('security-new-pin').value; + const confirm = document.getElementById('security-confirm-pin').value; + const msg = document.getElementById('security-pin-msg'); + + if (!pin || pin.length < 4) { + msg.textContent = 'PIN must be at least 4 characters'; + msg.style.display = 'block'; + msg.style.color = '#ff5252'; + return; + } + if (pin !== confirm) { + msg.textContent = 'PINs do not match'; + msg.style.display = 'block'; + msg.style.color = '#ff5252'; + return; + } + + try { + const res = await fetch('/api/profiles/1/set-pin', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }) + }); + const data = await res.json(); + + if (data.success) { + msg.textContent = 'PIN saved! You can now enable the lock screen.'; + msg.style.color = '#4caf50'; + msg.style.display = 'block'; + + // Update UI — hide setup, show change, enable toggle + document.getElementById('security-pin-setup').style.display = 'none'; + document.getElementById('security-change-pin-section').style.display = 'block'; + document.getElementById('security-require-pin').disabled = false; + + // Clear inputs + document.getElementById('security-new-pin').value = ''; + document.getElementById('security-confirm-pin').value = ''; + } else { + msg.textContent = data.error || 'Failed to save PIN'; + msg.style.color = '#ff5252'; + msg.style.display = 'block'; + } + } catch (e) { + msg.textContent = 'Connection error'; + msg.style.color = '#ff5252'; + msg.style.display = 'block'; + } +} + +function handleSecurityPinToggle(checkbox) { + // If trying to enable but no PIN, show the setup section + if (checkbox.checked) { + const setupSection = document.getElementById('security-pin-setup'); + if (setupSection.style.display !== 'none' || checkbox.disabled) { + checkbox.checked = false; + setupSection.style.display = 'block'; + document.getElementById('security-new-pin').focus(); + return; + } + } + // Auto-save this setting + saveSettings(true); +} + +function showChangeSecurityPin() { + document.getElementById('security-pin-setup').style.display = 'block'; + document.getElementById('security-new-pin').focus(); +} + +// ── Forgot PIN Recovery ──────────────────────────────────────────────── + +function showForgotPinView() { + document.getElementById('launch-pin-entry').style.display = 'none'; + document.getElementById('launch-pin-recovery').style.display = 'block'; + document.getElementById('launch-recovery-input').value = ''; + document.getElementById('launch-recovery-error').style.display = 'none'; + setTimeout(() => document.getElementById('launch-recovery-input').focus(), 100); +} + +function showPinEntryView() { + document.getElementById('launch-pin-recovery').style.display = 'none'; + document.getElementById('launch-pin-entry').style.display = 'block'; + setTimeout(() => document.getElementById('launch-pin-input').focus(), 100); +} + +async function submitRecoveryCredential() { + const input = document.getElementById('launch-recovery-input'); + const error = document.getElementById('launch-recovery-error'); + const btn = document.getElementById('launch-recovery-submit'); + const credential = input.value.trim(); + + if (!credential) return; + + btn.disabled = true; + btn.textContent = 'Verifying...'; + error.style.display = 'none'; + + try { + const res = await fetch('/api/profiles/reset-pin-via-credential', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential }) + }); + const data = await res.json(); + + if (data.success) { + sessionStorage.setItem('soulsync_pin_ok', '1'); + document.getElementById('launch-pin-overlay').style.display = 'none'; + initApp(); + setTimeout(() => showToast('PIN cleared. You can set a new one in Settings → Advanced.', 'success'), 1000); + } else { + error.textContent = data.error || 'Credential not recognized'; + error.style.display = 'block'; + input.value = ''; + input.focus(); + document.getElementById('launch-pin-container').classList.add('shake'); + setTimeout(() => document.getElementById('launch-pin-container').classList.remove('shake'), 500); + } + } catch (e) { + error.textContent = 'Connection error'; + error.style.display = 'block'; + } + + btn.disabled = false; + btn.textContent = 'Verify & Reset PIN'; +} + +// ── Profile PIN Forgot Recovery ──────────────────────────────────────── +function showProfileForgotPin() { + const dialog = document.getElementById('profile-pin-dialog'); + const content = dialog.querySelector('.profile-pin-content'); + + // Store the profile ID we're recovering for + const profileName = document.getElementById('profile-pin-name').textContent; + + // Replace dialog content with recovery form + content.dataset.prevHtml = content.innerHTML; + content.innerHTML = ` +

    Reset PIN for ${profileName}

    +

    Enter any configured API credential
    (Spotify secret, Plex token, etc.)

    + +
    + + +
    + + `; + setTimeout(() => document.getElementById('profile-recovery-input').focus(), 100); + + document.getElementById('profile-recovery-cancel').onclick = () => { + content.innerHTML = content.dataset.prevHtml; + }; + + document.getElementById('profile-recovery-submit').onclick = async () => { + const input = document.getElementById('profile-recovery-input'); + const error = document.getElementById('profile-recovery-error'); + const credential = input.value.trim(); + if (!credential) return; + + const btn = document.getElementById('profile-recovery-submit'); + btn.disabled = true; + btn.textContent = 'Verifying...'; + error.style.display = 'none'; + + try { + const res = await fetch('/api/profiles/reset-pin-via-credential', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ credential, profile_id: dialog._profileId || 1 }) + }); + const data = await res.json(); + if (data.success) { + dialog.style.display = 'none'; + content.innerHTML = content.dataset.prevHtml; + showToast('PIN cleared. You can set a new one in Settings.', 'success'); + // Re-try selecting the profile (now PIN-free) + if (dialog._profileId) selectProfile(dialog._profileId); + } else { + error.textContent = data.error || 'Credential not recognized'; + error.style.display = 'block'; + input.value = ''; + input.focus(); + } + } catch (e) { + error.textContent = 'Connection error'; + error.style.display = 'block'; + } + btn.disabled = false; + btn.textContent = 'Verify & Reset'; + }; + + document.getElementById('profile-recovery-input').onkeydown = (e) => { + if (e.key === 'Enter') document.getElementById('profile-recovery-submit').click(); + }; +} + +function showProfilePicker(profiles, canCancel = false) { + const overlay = document.getElementById('profile-picker-overlay'); + const grid = document.getElementById('profile-picker-grid'); + const actions = document.getElementById('profile-picker-actions'); + + grid.innerHTML = ''; + profiles.forEach(p => { + const card = document.createElement('div'); + card.className = 'profile-picker-card'; + const avatarEl = document.createElement('div'); + renderProfileAvatar(avatarEl, p); + card.appendChild(avatarEl); + const nameEl = document.createElement('span'); + nameEl.className = 'profile-name'; + nameEl.textContent = p.name; + card.appendChild(nameEl); + if (p.is_admin) { + const badge = document.createElement('span'); + badge.className = 'profile-badge'; + badge.textContent = 'Admin'; + card.appendChild(badge); + } + card.onclick = () => handleProfileClick(p); + grid.appendChild(card); + }); + + // Show actions: admin sees "Manage Profiles", non-admin sees "My Profile" (when they have a profile selected) + const isAdmin = currentProfile ? currentProfile.is_admin : false; + const manageBtn = document.getElementById('manage-profiles-btn'); + if (isAdmin) { + actions.style.display = ''; + if (manageBtn) { + manageBtn.textContent = 'Manage Profiles'; + // Reset onclick to admin handler (initProfileManagement sets this, but re-affirm here) + manageBtn.onclick = () => { + document.getElementById('profile-manage-panel').style.display = 'flex'; + loadProfileManageList(); + }; + } + } else if (currentProfile && canCancel) { + // Non-admin with an active profile: show "My Profile" to edit own settings + actions.style.display = ''; + if (manageBtn) { + manageBtn.textContent = 'My Profile'; + manageBtn.onclick = () => showSelfEditForm(); + } + } else { + actions.style.display = 'none'; + } + + // Show/remove cancel button when opened from sidebar indicator + let cancelBtn = overlay.querySelector('.profile-picker-cancel'); + if (cancelBtn) cancelBtn.remove(); + if (canCancel) { + cancelBtn = document.createElement('button'); + cancelBtn.className = 'profile-picker-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => hideProfilePicker(); + actions.parentElement.appendChild(cancelBtn); + } + + overlay.style.display = 'flex'; + document.querySelector('.main-container').style.display = 'none'; +} + +async function handleProfileClick(profile) { + // Fetch profile count — PIN only matters with multiple profiles + let profileCount = 1; + try { + const r = await fetch('/api/profiles'); + const d = await r.json(); + profileCount = (d.profiles || []).length; + } catch (e) { } + + if (profile.has_pin && profileCount > 1) { + showPinDialog(profile); + } else { + const wasSwitching = !!currentProfile; + await selectProfile(profile.id); + if (wasSwitching) { + window.location.reload(); + return; + } + hideProfilePicker(); + initApp(); + } +} + +function showPinDialog(profile) { + const dialog = document.getElementById('profile-pin-dialog'); + const avatar = document.getElementById('profile-pin-avatar'); + const nameEl = document.getElementById('profile-pin-name'); + const input = document.getElementById('profile-pin-input'); + const errorEl = document.getElementById('profile-pin-error'); + + renderProfileAvatar(avatar, profile); + nameEl.textContent = profile.name; + input.value = ''; + errorEl.style.display = 'none'; + dialog._profileId = profile.id; + dialog.style.display = 'flex'; + setTimeout(() => input.focus(), 100); + + const submit = document.getElementById('profile-pin-submit'); + const cancel = document.getElementById('profile-pin-cancel'); + + const wasSwitching = !!currentProfile; + const handleSubmit = async () => { + const pin = input.value; + if (!pin) return; + try { + const res = await fetch('/api/profiles/select', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profile.id, pin }) + }); + const data = await res.json(); + if (data.success) { + cleanup(); + if (wasSwitching) { + window.location.reload(); + return; + } + currentProfile = data.profile; + dialog.style.display = 'none'; + hideProfilePicker(); + updateProfileIndicator(); + initApp(); + return; + } else { + errorEl.textContent = data.error || 'Invalid PIN'; + errorEl.style.display = ''; + input.value = ''; + input.focus(); + } + } catch (e) { + errorEl.textContent = 'Connection error'; + errorEl.style.display = ''; + } + cleanup(); + }; + + const handleCancel = () => { + dialog.style.display = 'none'; + cleanup(); + }; + + const handleKeydown = (e) => { + if (e.key === 'Enter') handleSubmit(); + if (e.key === 'Escape') handleCancel(); + }; + + const cleanup = () => { + submit.removeEventListener('click', handleSubmit); + cancel.removeEventListener('click', handleCancel); + input.removeEventListener('keydown', handleKeydown); + }; + + submit.addEventListener('click', handleSubmit); + cancel.addEventListener('click', handleCancel); + input.addEventListener('keydown', handleKeydown); +} + +async function selectProfile(profileId) { + try { + const oldProfileId = currentProfile ? currentProfile.id : null; + const res = await fetch('/api/profiles/select', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ profile_id: profileId }) + }); + const data = await res.json(); + if (data.success) { + currentProfile = data.profile; + updateProfileIndicator(); + // Join profile-scoped WebSocket room for watchlist/wishlist count updates + if (socket && socket.connected) { + socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId }); + } + // Invalidate ListenBrainz cache on profile switch (each profile has their own playlists) + _invalidateListenBrainzCache(); + } + return data.success; + } catch (e) { + console.error('Error selecting profile:', e); + return false; + } +} + +function hideProfilePicker() { + document.getElementById('profile-picker-overlay').style.display = 'none'; + document.querySelector('.main-container').style.display = 'flex'; +} + +function updateProfileIndicator() { + const indicator = document.getElementById('profile-indicator'); + if (!currentProfile || !indicator) return; + + const avatar = document.getElementById('profile-indicator-avatar'); + const name = document.getElementById('profile-indicator-name'); + + renderProfileAvatar(avatar, currentProfile); + name.textContent = currentProfile.name; + indicator.style.display = 'flex'; + + indicator.onclick = async () => { + const res = await fetch('/api/profiles'); + const data = await res.json(); + if (data.profiles && data.profiles.length > 0) { + showProfilePicker(data.profiles, true); + } + }; + + // Filter sidebar pages based on profile permissions + document.querySelectorAll('.nav-button[data-page]').forEach(btn => { + const page = btn.getAttribute('data-page'); + if (page === 'hydrabase') return; // Managed by dev mode toggle + if (page === 'settings') { + // Settings always gated by is_admin + btn.style.display = currentProfile.is_admin ? '' : 'none'; + } else if (page === 'help' || page === 'issues') { + btn.style.display = ''; // Always visible + } else if (currentProfile.id === 1) { + btn.style.display = ''; // Root admin sees all + } else { + const ap = currentProfile.allowed_pages; + btn.style.display = (!ap || ap.includes(page)) ? '' : 'none'; + } + }); + + // Toggle download capability + if (canDownload()) { + document.body.classList.remove('downloads-disabled'); + } else { + document.body.classList.add('downloads-disabled'); + } +} + +// ===================== +// PERSONAL SETTINGS MODAL +// ===================== + +async function openPersonalSettings() { + const overlay = document.getElementById('personal-settings-overlay'); + if (!overlay) return; + overlay.style.display = 'flex'; + + const body = document.getElementById('personal-settings-body'); + body.innerHTML = '
    Loading...
    '; + + try { + // Load all per-profile service data in parallel + const [lbRes, spotifyRes] = await Promise.all([ + fetch('/api/profiles/me/listenbrainz'), + fetch('/api/profiles/me/spotify'), + ]); + const lbData = await lbRes.json(); + const spotifyData = await spotifyRes.json(); + + body.innerHTML = ''; + const isNonAdmin = currentProfile && !currentProfile.is_admin; + + if (isNonAdmin) { + // Tabbed layout for non-admin with multiple sections + const tabs = [ + { id: 'music', label: 'Music Services' }, + { id: 'server', label: 'Server' }, + { id: 'scrobble', label: 'Scrobbling' }, + ]; + const tabBar = document.createElement('div'); + tabBar.className = 'ps-tabbar'; + tabs.forEach((t, i) => { + const btn = document.createElement('button'); + btn.className = 'ps-tab' + (i === 0 ? ' active' : ''); + btn.textContent = t.label; + btn.onclick = () => { + tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active')); + const target = document.getElementById(`ps-tab-${t.id}`); + if (target) target.classList.add('active'); + }; + tabBar.appendChild(btn); + }); + body.appendChild(tabBar); + + // Music Services tab + const musicTab = document.createElement('div'); + musicTab.id = 'ps-tab-music'; + musicTab.className = 'ps-tab-content active'; + renderPersonalSettingsSpotify(musicTab, spotifyData); + renderPersonalSettingsTidal(musicTab); + body.appendChild(musicTab); + + // Server tab + const serverTab = document.createElement('div'); + serverTab.id = 'ps-tab-server'; + serverTab.className = 'ps-tab-content'; + serverTab.innerHTML = '
    Loading libraries...
    '; + body.appendChild(serverTab); + // Load server libraries async (don't block modal) + fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => { + serverTab.innerHTML = ''; + renderPersonalSettingsServerLibrary(serverTab, libData); + }).catch(() => { + serverTab.innerHTML = ''; + renderPersonalSettingsServerLibrary(serverTab, {}); + }); + + // Scrobbling tab + const scrobbleTab = document.createElement('div'); + scrobbleTab.id = 'ps-tab-scrobble'; + scrobbleTab.className = 'ps-tab-content'; + body.appendChild(scrobbleTab); + // Render LB into the scrobble tab + const origBody = body; + renderPersonalSettingsLB(lbData, scrobbleTab); + } else { + // Admin: just ListenBrainz, no tabs + const content = document.createElement('div'); + content.style.padding = '18px 22px 22px'; + body.appendChild(content); + renderPersonalSettingsLB(lbData, content); + } + } catch (e) { + body.innerHTML = '
    Failed to load settings
    '; + } +} + +function closePersonalSettings() { + const overlay = document.getElementById('personal-settings-overlay'); + if (overlay) overlay.style.display = 'none'; +} + +function renderPersonalSettingsSpotify(body, data) { + const hasCreds = data.has_credentials; + const clientId = data.client_id || ''; + + let contentHtml; + if (hasCreds) { + contentHtml = ` +
    +
    🟢
    +
    +
    Credentials configured
    +
    Client ID: ${escapeHtml(clientId.substring(0, 8))}...
    +
    Personal Spotify app
    +
    +
    +
    + + +
    + `; + } else { + contentHtml = ` +
    + + +
    +
    + + +
    +
    + + +
    + Create an app at developer.spotify.com and add the redirect URI +
    +
    +
    +
    + +
    + `; + } + + const section = document.createElement('div'); + section.id = 'ps-spotify-section'; + section.innerHTML = ` +
    +
    +

    Spotify

    + + + ${hasCreds ? 'Configured' : 'Not configured'} + +
    +
    + Connect your own Spotify account to see your playlists instead of the admin's. +
    + ${contentHtml} +
    + `; + + const existing = document.getElementById('ps-spotify-section'); + if (existing) existing.replaceWith(section); + else body.appendChild(section); +} + +async function savePersonalSpotify() { + const clientId = document.getElementById('ps-spotify-client-id')?.value?.trim(); + const clientSecret = document.getElementById('ps-spotify-client-secret')?.value?.trim(); + const redirectUri = document.getElementById('ps-spotify-redirect-uri')?.value?.trim(); + const resultEl = document.getElementById('ps-spotify-result'); + + if (!clientId || !clientSecret) { + if (resultEl) resultEl.innerHTML = '
    Client ID and Secret are required
    '; + return; + } + + try { + const res = await fetch('/api/profiles/me/spotify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri }) + }); + const data = await res.json(); + if (data.success) { + showToast('Spotify credentials saved', 'success'); + openPersonalSettings(); // Reload to show connected state + } else { + if (resultEl) resultEl.innerHTML = `
    ${data.error || 'Failed to save'}
    `; + } + } catch (e) { + if (resultEl) resultEl.innerHTML = '
    Network error
    '; + } +} + +async function authenticatePersonalSpotify() { + // Trigger OAuth flow with profile_id in state so callback knows which profile + window.open('/auth/spotify?profile_id=' + (currentProfile?.id || ''), '_blank'); +} + +function renderPersonalSettingsTidal(body) { + const section = document.createElement('div'); + section.id = 'ps-tidal-section'; + section.innerHTML = ` +
    +
    +

    Tidal

    +
    +
    + Connect your own Tidal account to see your playlists. Uses the admin's Tidal app credentials. +
    +
    + +
    +
    + `; + const existing = document.getElementById('ps-tidal-section'); + if (existing) existing.replaceWith(section); + else body.appendChild(section); +} + +function authenticatePersonalTidal() { + window.open('/auth/tidal?profile_id=' + (currentProfile?.id || ''), '_blank'); +} + +async function renderPersonalSettingsServerLibrary(container, profileData) { + const section = document.createElement('div'); + section.id = 'ps-server-library-section'; + + // Detect which server is active + let serverType = 'none'; + let libraries = []; + let users = []; + const currentLib = profileData || {}; + + try { + // Try each server type to find the active one + const plexRes = await fetch('/api/plex/music-libraries'); + if (plexRes.ok) { + const plexData = await plexRes.json(); + if (plexData.libraries && plexData.libraries.length > 0) { + serverType = 'plex'; + libraries = plexData.libraries; + } + } + } catch (e) { } + + if (serverType === 'none') { + try { + const jellyRes = await fetch('/api/jellyfin/music-libraries'); + if (jellyRes.ok) { + const jellyData = await jellyRes.json(); + if (jellyData.libraries && jellyData.libraries.length > 0) { + serverType = 'jellyfin'; + libraries = jellyData.libraries; + users = jellyData.users || []; + } + } + } catch (e) { } + } + + if (serverType === 'none') { + section.innerHTML = ` +
    +
    +

    Media Server

    +
    +
    No media server connected. Ask your admin to configure Plex, Jellyfin, or Navidrome in Settings.
    +
    + `; + } else if (serverType === 'plex') { + const selectedLib = currentLib.plex_library_id || ''; + const optionsHtml = libraries.map(lib => { + const name = lib.name || lib.title || lib; + const val = typeof lib === 'string' ? lib : (lib.name || lib.title); + return ``; + }).join(''); + + section.innerHTML = ` +
    +
    +

    Plex Library

    + + + ${selectedLib ? 'Custom' : 'Default'} + +
    +
    Choose which Plex music library your playlists sync to.
    +
    + + +
    +
    + +
    +
    + `; + } else if (serverType === 'jellyfin') { + const selectedUser = currentLib.jellyfin_user_id || ''; + const selectedLib = currentLib.jellyfin_library_id || ''; + + const userOpts = users.map(u => { + const uid = u.id || u.Id; + const uname = u.name || u.Name; + return ``; + }).join(''); + + const libOpts = libraries.map(lib => { + const lid = lib.key || lib.id || lib.Id; + const lname = lib.name || lib.Name || lib.title; + return ``; + }).join(''); + + section.innerHTML = ` +
    +
    +

    Jellyfin

    + + + ${selectedUser || selectedLib ? 'Custom' : 'Default'} + +
    +
    Choose which Jellyfin user and library your playlists sync to.
    + ${users.length ? `
    ` : ''} +
    + + +
    +
    + +
    +
    + `; + } + + const existing = document.getElementById('ps-server-library-section'); + if (existing) existing.replaceWith(section); + else container.appendChild(section); +} + +async function savePersonalServerLibrary() { + try { + const plexSelect = document.getElementById('ps-plex-library-select'); + const jellyUserSelect = document.getElementById('ps-jellyfin-user-select'); + const jellyLibSelect = document.getElementById('ps-jellyfin-library-select'); + + if (plexSelect) { + await fetch('/api/profiles/me/server-library', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_type: 'plex', library_id: plexSelect.value || null }) + }); + } + if (jellyUserSelect || jellyLibSelect) { + await fetch('/api/profiles/me/server-library', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + server_type: 'jellyfin', + user_id: jellyUserSelect?.value || null, + library_id: jellyLibSelect?.value || null + }) + }); + } + + showToast('Server library settings saved', 'success'); + } catch (e) { + showToast('Error saving settings', 'error'); + } +} + +async function disconnectPersonalSpotify() { + try { + const res = await fetch('/api/profiles/me/spotify', { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + showToast('Spotify credentials removed — using shared config', 'info'); + openPersonalSettings(); // Reload + } + } catch (e) { + showToast('Error removing credentials', 'error'); + } +} + +function renderPersonalSettingsLB(data, container) { + const body = container || document.getElementById('personal-settings-body'); + const connected = data.connected; + const username = data.username || ''; + const baseUrl = data.base_url || ''; + const source = data.source || 'global'; + + const tokenFormHtml = ` +
    + + +
    +
    + + +
    + Get your token from listenbrainz.org/profile +
    +
    +
    +
    + + +
    + `; + + let contentHtml; + if (connected && source === 'profile') { + // Personal token — show connected state with Disconnect + const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org'; + contentHtml = ` +
    +
    🧠
    +
    +
    Connected as ${escapeHtml(username)}
    +
    ${escapeHtml(serverDisplay)}
    +
    Personal token
    +
    +
    +
    + +
    + `; + } else if (connected && source === 'global') { + // Using admin's shared token — show status + option to set own token + const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org'; + contentHtml = ` +
    +
    🧠
    +
    +
    Connected as ${escapeHtml(username)}
    +
    ${escapeHtml(serverDisplay)}
    +
    Using shared token from Settings
    +
    +
    +
    +
    Set your own token to use a different ListenBrainz account:
    + ${tokenFormHtml} +
    + `; + } else { + // Not connected at all + contentHtml = tokenFormHtml; + } + + const section = document.createElement('div'); + section.id = 'ps-listenbrainz-section'; + section.innerHTML = ` +
    +
    +

    ListenBrainz

    + + + ${connected ? 'Connected' : 'Not connected'} + +
    + ${contentHtml} +
    + `; + // Replace existing or append + const existing = document.getElementById('ps-listenbrainz-section'); + if (existing) existing.replaceWith(section); + else body.appendChild(section); +} + +async function testPersonalListenBrainz() { + const token = document.getElementById('ps-lb-token')?.value?.trim(); + const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || ''; + const resultEl = document.getElementById('ps-lb-result'); + if (!token) { + if (resultEl) resultEl.innerHTML = '
    Please enter a token
    '; + return; + } + if (resultEl) resultEl.innerHTML = '
    Testing...
    '; + try { + const res = await fetch('/api/profiles/me/listenbrainz/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, base_url: baseUrl }) + }); + const data = await res.json(); + if (data.success) { + resultEl.innerHTML = `
    Valid token — ${escapeHtml(data.username)}
    `; + } else { + resultEl.innerHTML = `
    ${escapeHtml(data.error || 'Invalid token')}
    `; + } + } catch (e) { + resultEl.innerHTML = '
    Connection failed
    '; + } +} + +async function connectPersonalListenBrainz() { + const token = document.getElementById('ps-lb-token')?.value?.trim(); + const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || ''; + const resultEl = document.getElementById('ps-lb-result'); + if (!token) { + if (resultEl) resultEl.innerHTML = '
    Please enter a token
    '; + return; + } + // Disable buttons during connect + document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = true); + if (resultEl) resultEl.innerHTML = '
    Connecting...
    '; + try { + const res = await fetch('/api/profiles/me/listenbrainz', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, base_url: baseUrl }) + }); + const data = await res.json(); + if (data.success) { + showToast(`Connected to ListenBrainz as ${data.username}`, 'success'); + // Re-render as connected + renderPersonalSettingsLB({ connected: true, username: data.username, base_url: baseUrl, source: 'profile' }); + // Refresh LB playlists on discover page + _invalidateListenBrainzCache(); + if (typeof initializeListenBrainzTabs === 'function') { + initializeListenBrainzTabs(); + } + } else { + resultEl.innerHTML = `
    ${escapeHtml(data.error || 'Connection failed')}
    `; + document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false); + } + } catch (e) { + resultEl.innerHTML = '
    Connection failed
    '; + document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false); + } +} + +async function disconnectPersonalListenBrainz() { + try { + await fetch('/api/profiles/me/listenbrainz', { method: 'DELETE' }); + showToast('ListenBrainz disconnected', 'info'); + // Re-render as disconnected — re-fetch to check if global fallback exists + const res = await fetch('/api/profiles/me/listenbrainz'); + const data = await res.json(); + renderPersonalSettingsLB(data); + // Refresh LB playlists on discover page + _invalidateListenBrainzCache(); + if (typeof initializeListenBrainzTabs === 'function') { + initializeListenBrainzTabs(); + } + } catch (e) { + showToast('Failed to disconnect', 'error'); + } +} + +function _invalidateListenBrainzCache() { + if (typeof listenbrainzPlaylistsLoaded !== 'undefined') listenbrainzPlaylistsLoaded = false; + if (typeof listenbrainzPlaylistsCache !== 'undefined') { + try { Object.keys(listenbrainzPlaylistsCache).forEach(k => delete listenbrainzPlaylistsCache[k]); } catch (e) { } + } + if (typeof listenbrainzTracksCache !== 'undefined') { + try { Object.keys(listenbrainzTracksCache).forEach(k => delete listenbrainzTracksCache[k]); } catch (e) { } + } +} + +function initProfileManagement() { + const manageBtn = document.getElementById('manage-profiles-btn'); + const closeBtn = document.getElementById('profile-manage-close'); + const createBtn = document.getElementById('create-profile-btn'); + const adminPinBtn = document.getElementById('set-admin-pin-btn'); + + if (manageBtn) { + manageBtn.onclick = () => { + document.getElementById('profile-manage-panel').style.display = 'flex'; + loadProfileManageList(); + }; + } + + if (closeBtn) { + closeBtn.onclick = () => { + document.getElementById('profile-manage-panel').style.display = 'none'; + // Refresh picker — keep cancel button if user already has a profile selected + const hasCancel = !!currentProfile; + fetch('/api/profiles').then(r => r.json()).then(d => { + showProfilePicker(d.profiles || [], hasCancel); + }); + }; + } + + // Color picker + let selectedColor = '#6366f1'; + document.querySelectorAll('.profile-color-swatch').forEach(swatch => { + swatch.onclick = () => { + document.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected')); + swatch.classList.add('selected'); + selectedColor = swatch.dataset.color; + }; + }); + // Select first by default + const firstSwatch = document.querySelector('.profile-color-swatch'); + if (firstSwatch) firstSwatch.classList.add('selected'); + + if (createBtn) { + createBtn.onclick = async () => { + const name = document.getElementById('new-profile-name').value.trim(); + const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim(); + const pin = document.getElementById('new-profile-pin').value; + if (!name) return; + + // Collect profile settings + const homePage = document.getElementById('new-profile-home-page').value || null; + const pageCheckboxes = document.querySelectorAll('#new-profile-allowed-pages input[type="checkbox"]:not(:disabled)'); + const allChecked = Array.from(pageCheckboxes).every(cb => cb.checked); + const allowedPages = allChecked ? null : Array.from(pageCheckboxes).filter(cb => cb.checked).map(cb => cb.value); + const canDl = document.getElementById('new-profile-can-download').checked; + + const res = await fetch('/api/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, avatar_color: selectedColor, + avatar_url: avatarUrl || undefined, + pin: pin || undefined, + home_page: homePage, + allowed_pages: allowedPages, + can_download: canDl + }) + }); + const data = await res.json(); + if (data.success) { + document.getElementById('new-profile-name').value = ''; + document.getElementById('new-profile-avatar-url').value = ''; + document.getElementById('new-profile-pin').value = ''; + document.getElementById('new-profile-home-page').value = ''; + pageCheckboxes.forEach(cb => cb.checked = true); + document.getElementById('new-profile-can-download').checked = true; + loadProfileManageList(); + // Show admin PIN section if >1 profiles and admin has no PIN + checkAdminPinRequired(); + } else { + alert(data.error || 'Failed to create profile'); + } + }; + } + + if (adminPinBtn) { + adminPinBtn.onclick = async () => { + const pin = document.getElementById('admin-pin-input').value; + if (!pin || pin.length < 1) return; + // Find admin profile + const res = await fetch('/api/profiles'); + const data = await res.json(); + const admin = (data.profiles || []).find(p => p.is_admin); + if (!admin) return; + + try { + const pinRes = await fetch(`/api/profiles/${admin.id}/set-pin`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ pin }) + }); + const pinData = await pinRes.json(); + if (!pinData.success) { + alert(pinData.error || 'Failed to set PIN'); + return; + } + } catch (e) { + alert('Connection error'); + return; + } + document.getElementById('admin-pin-input').value = ''; + document.getElementById('admin-pin-section').style.display = 'none'; + loadProfileManageList(); + }; + } +} + +async function loadProfileManageList() { + const list = document.getElementById('profile-manage-list'); + const res = await fetch('/api/profiles'); + const data = await res.json(); + const profiles = data.profiles || []; + + list.innerHTML = ''; + profiles.forEach(p => { + const item = document.createElement('div'); + item.className = 'profile-manage-item'; + + const av = document.createElement('div'); + renderProfileAvatar(av, p); + item.appendChild(av); + + const info = document.createElement('div'); + info.className = 'profile-info'; + const nameDiv = document.createElement('div'); + nameDiv.className = 'name'; + nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : ''); + info.appendChild(nameDiv); + const roleTags = []; + if (p.is_admin) roleTags.push('Admin'); + if (p.can_download === false) roleTags.push('No Downloads'); + if (p.allowed_pages) roleTags.push(`${p.allowed_pages.length} pages`); + if (roleTags.length) { + const roleDiv = document.createElement('div'); + roleDiv.className = 'role'; + roleDiv.textContent = roleTags.join(' · '); + info.appendChild(roleDiv); + } + item.appendChild(info); + + const actions = document.createElement('div'); + actions.className = 'profile-manage-actions'; + + const editBtn = document.createElement('button'); + editBtn.className = 'profile-edit-btn'; + editBtn.dataset.id = p.id; + editBtn.dataset.name = p.name; + editBtn.dataset.color = p.avatar_color || '#6366f1'; + editBtn.dataset.avatarUrl = p.avatar_url || ''; + editBtn.dataset.homePage = p.home_page || ''; + editBtn.dataset.allowedPages = p.allowed_pages ? JSON.stringify(p.allowed_pages) : ''; + editBtn.dataset.canDownload = p.can_download !== false ? '1' : '0'; + editBtn.dataset.isAdmin = p.is_admin ? '1' : '0'; + editBtn.title = 'Edit profile'; + editBtn.textContent = '✏️'; + actions.appendChild(editBtn); + + if (!p.is_admin) { + const delBtn = document.createElement('button'); + delBtn.className = 'profile-delete-btn'; + delBtn.dataset.id = p.id; + delBtn.title = 'Delete profile'; + delBtn.textContent = '🗑️'; + actions.appendChild(delBtn); + } + + item.appendChild(actions); + list.appendChild(item); + }); + + // Bind edit buttons + list.querySelectorAll('.profile-edit-btn').forEach(btn => { + btn.onclick = () => { + showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl, { + home_page: btn.dataset.homePage || '', + allowed_pages: btn.dataset.allowedPages ? JSON.parse(btn.dataset.allowedPages) : null, + can_download: btn.dataset.canDownload !== '0', + is_admin: btn.dataset.isAdmin === '1' + }); + }; + }); + + // Bind delete buttons + list.querySelectorAll('.profile-delete-btn').forEach(btn => { + btn.onclick = async () => { + if (!await showConfirmDialog({ title: 'Delete Profile', message: 'Delete this profile and all its data?', confirmText: 'Delete', destructive: true })) return; + try { + const res = await fetch(`/api/profiles/${btn.dataset.id}`, { method: 'DELETE' }); + const data = await res.json(); + if (!data.success) { + alert(data.error || 'Failed to delete profile'); + } + } catch (e) { + alert('Connection error'); + } + loadProfileManageList(); + }; + }); + + checkAdminPinRequired(); +} + +function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) { + const list = document.getElementById('profile-manage-list'); + // Remove any existing edit form + const existing = document.getElementById('profile-edit-form'); + if (existing) existing.remove(); + + const isAdmin = currentProfile && currentProfile.is_admin; + const isEditingAdmin = profileSettings.is_admin; + const editColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6']; + // 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are + // normalized on read below so saving a legacy profile upgrades it. + const pageLabels = { + dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover', + automations: 'Automations', library: 'Library', stats: 'Listening Stats', + 'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs' + }; + + const form = document.createElement('div'); + form.id = 'profile-edit-form'; + form.className = 'profile-edit-form'; + + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'profile-input'; + nameInput.value = currentName; + nameInput.maxLength = 20; + nameInput.placeholder = 'Profile name'; + form.appendChild(nameInput); + + const urlInput = document.createElement('input'); + urlInput.type = 'url'; + urlInput.className = 'profile-input'; + urlInput.value = currentAvatarUrl || ''; + urlInput.placeholder = 'Avatar image URL (optional)'; + form.appendChild(urlInput); + + const colorRow = document.createElement('div'); + colorRow.className = 'profile-color-picker'; + let editColor = currentColor; + editColors.forEach(c => { + const swatch = document.createElement('span'); + swatch.className = 'profile-color-swatch' + (c === currentColor ? ' selected' : ''); + swatch.style.background = c; + swatch.dataset.color = c; + swatch.onclick = () => { + colorRow.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected')); + swatch.classList.add('selected'); + editColor = c; + }; + colorRow.appendChild(swatch); + }); + form.appendChild(colorRow); + + // Home page selector — visible to everyone (self-edit or admin editing others) + const homeLabel = document.createElement('label'); + homeLabel.className = 'profile-settings-label'; + homeLabel.textContent = 'Home Page'; + form.appendChild(homeLabel); + + const homeSelect = document.createElement('select'); + homeSelect.className = 'profile-input'; + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)'; + homeSelect.appendChild(defaultOpt); + // Normalize legacy ids so the form shows the right options/selection for + // profiles saved before the Search page rename. + const allowedSet = _normalizeLegacyAllowedPages(profileSettings.allowed_pages); + const normalizedHome = _normalizeLegacyHomePage(profileSettings.home_page); + Object.entries(pageLabels).forEach(([id, label]) => { + if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = label; + if (id === normalizedHome) opt.selected = true; + homeSelect.appendChild(opt); + }); + form.appendChild(homeSelect); + + // Admin-only settings: allowed pages & can_download + let pageCheckboxes = []; + let canDlCheckbox = null; + if (isAdmin && !isEditingAdmin) { + const apLabel = document.createElement('label'); + apLabel.className = 'profile-settings-label'; + apLabel.textContent = 'Page Access'; + form.appendChild(apLabel); + + const apContainer = document.createElement('div'); + apContainer.className = 'profile-page-checkboxes'; + Object.entries(pageLabels).forEach(([id, label]) => { + const lbl = document.createElement('label'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.value = id; + cb.checked = !allowedSet || allowedSet.includes(id); + lbl.appendChild(cb); + lbl.appendChild(document.createTextNode(' ' + label)); + apContainer.appendChild(lbl); + pageCheckboxes.push(cb); + }); + // Always-on help + const helpLbl = document.createElement('label'); + const helpCb = document.createElement('input'); + helpCb.type = 'checkbox'; + helpCb.checked = true; + helpCb.disabled = true; + helpLbl.appendChild(helpCb); + helpLbl.appendChild(document.createTextNode(' Help & Docs')); + apContainer.appendChild(helpLbl); + form.appendChild(apContainer); + + const dlLabel = document.createElement('label'); + dlLabel.className = 'profile-checkbox-label'; + canDlCheckbox = document.createElement('input'); + canDlCheckbox.type = 'checkbox'; + canDlCheckbox.checked = profileSettings.can_download !== false; + dlLabel.appendChild(canDlCheckbox); + dlLabel.appendChild(document.createTextNode(' Can download music')); + form.appendChild(dlLabel); + } + + const btnRow = document.createElement('div'); + btnRow.className = 'profile-edit-buttons'; + + const saveBtn = document.createElement('button'); + saveBtn.className = 'profile-create-btn'; + saveBtn.textContent = 'Save'; + saveBtn.onclick = async () => { + const newName = nameInput.value.trim(); + if (!newName) { alert('Name cannot be empty'); return; } + const newAvatarUrl = urlInput.value.trim() || null; + const payload = { name: newName, avatar_color: editColor, avatar_url: newAvatarUrl }; + + // Home page + payload.home_page = homeSelect.value || null; + + // Admin-only fields + if (isAdmin && !isEditingAdmin && pageCheckboxes.length) { + const allChecked = pageCheckboxes.every(cb => cb.checked); + payload.allowed_pages = allChecked ? null : pageCheckboxes.filter(cb => cb.checked).map(cb => cb.value); + payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true; + } + + try { + const res = await fetch(`/api/profiles/${profileId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (data.success) { + // Update sidebar indicator if editing current profile + if (currentProfile && currentProfile.id == profileId) { + currentProfile.name = newName; + currentProfile.avatar_color = editColor; + currentProfile.avatar_url = newAvatarUrl; + if (payload.home_page !== undefined) currentProfile.home_page = payload.home_page; + if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages; + if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download; + updateProfileIndicator(); + } + loadProfileManageList(); + } else { + alert(data.error || 'Failed to update profile'); + } + } catch (e) { + alert('Connection error'); + } + }; + btnRow.appendChild(saveBtn); + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'profile-picker-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => form.remove(); + btnRow.appendChild(cancelBtn); + + form.appendChild(btnRow); + list.appendChild(form); + nameInput.focus(); + nameInput.select(); +} + +function showSelfEditForm() { + if (!currentProfile) return; + const overlay = document.getElementById('profile-picker-overlay'); + const container = overlay.querySelector('.profile-picker-container'); + + // Hide the picker grid and show self-edit form + const grid = document.getElementById('profile-picker-grid'); + const actions = document.getElementById('profile-picker-actions'); + grid.style.display = 'none'; + actions.style.display = 'none'; + + // Remove any existing self-edit form + const existing = document.getElementById('self-edit-form'); + if (existing) existing.remove(); + + // 'search' replaces the legacy 'downloads'/'artists' ids; legacy values are + // normalized on read below so saving a legacy profile upgrades it. + const pageLabels = { + dashboard: 'Dashboard', sync: 'Sync', search: 'Search', discover: 'Discover', + automations: 'Automations', library: 'Library', stats: 'Listening Stats', + 'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs' + }; + + const form = document.createElement('div'); + form.id = 'self-edit-form'; + form.className = 'profile-edit-form'; + form.style.marginTop = '16px'; + + const title = document.createElement('h3'); + title.textContent = 'My Profile'; + title.style.cssText = 'color: #fff; margin: 0 0 12px; font-size: 18px;'; + form.appendChild(title); + + // Name + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.className = 'profile-input'; + nameInput.value = currentProfile.name; + nameInput.maxLength = 20; + nameInput.placeholder = 'Profile name'; + form.appendChild(nameInput); + + // Home page + const homeLabel = document.createElement('label'); + homeLabel.className = 'profile-settings-label'; + homeLabel.textContent = 'Home Page'; + form.appendChild(homeLabel); + + const homeSelect = document.createElement('select'); + homeSelect.className = 'profile-input'; + const defaultOpt = document.createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = 'Default (Discover)'; + homeSelect.appendChild(defaultOpt); + // Normalize legacy ids so the form shows the right options/selection for + // profiles saved before the Search page rename. + const ap = _normalizeLegacyAllowedPages(currentProfile.allowed_pages); + const normalizedHome = _normalizeLegacyHomePage(currentProfile.home_page); + Object.entries(pageLabels).forEach(([id, label]) => { + if (ap && !ap.includes(id)) return; + const opt = document.createElement('option'); + opt.value = id; + opt.textContent = label; + if (id === normalizedHome) opt.selected = true; + homeSelect.appendChild(opt); + }); + form.appendChild(homeSelect); + + // Buttons + const btnRow = document.createElement('div'); + btnRow.className = 'profile-edit-buttons'; + btnRow.style.marginTop = '12px'; + + const saveBtn = document.createElement('button'); + saveBtn.className = 'profile-create-btn'; + saveBtn.textContent = 'Save'; + saveBtn.onclick = async () => { + const newName = nameInput.value.trim(); + if (!newName) { alert('Name cannot be empty'); return; } + try { + const res = await fetch(`/api/profiles/${currentProfile.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newName, home_page: homeSelect.value || null }) + }); + const data = await res.json(); + if (data.success) { + currentProfile.name = newName; + currentProfile.home_page = homeSelect.value || null; + updateProfileIndicator(); + closeSelfEdit(); + hideProfilePicker(); + } else { + alert(data.error || 'Failed to update'); + } + } catch (e) { + alert('Connection error'); + } + }; + btnRow.appendChild(saveBtn); + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'profile-picker-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.onclick = () => closeSelfEdit(); + btnRow.appendChild(cancelBtn); + + form.appendChild(btnRow); + container.appendChild(form); + + function closeSelfEdit() { + form.remove(); + grid.style.display = ''; + actions.style.display = ''; + } +} + +async function checkAdminPinRequired() { + const res = await fetch('/api/profiles'); + const data = await res.json(); + const profiles = data.profiles || []; + const admin = profiles.find(p => p.is_admin); + const section = document.getElementById('admin-pin-section'); + + if (profiles.length > 1 && admin && !admin.has_pin && section) { + section.style.display = ''; + } else if (section) { + section.style.display = 'none'; + } +} + +document.addEventListener('DOMContentLoaded', async function () { + console.log('SoulSync WebUI initializing...'); + + // Check if first-run setup wizard should be shown + const params = new URLSearchParams(window.location.search); + const forceSetup = params.get('setup') === '1'; + let showWizard = forceSetup; + + if (!forceSetup) { + try { + const setupResp = await fetch('/api/setup/status'); + const setupData = await setupResp.json(); + if (!setupData.setup_complete) { + showWizard = true; + localStorage.removeItem('soulsync_setup_complete'); + } + } catch (e) { + console.warn('Setup status check failed, continuing normal init:', e); + } + } + + if (showWizard && typeof openSetupWizard === 'function') { + window._onSetupWizardComplete = function () { + _continueAppInit(); + }; + openSetupWizard(); + return; // Defer init until wizard closes + } + + _continueAppInit(); +}); + +async function _continueAppInit() { + // Initialize profile management UI handlers + initProfileManagement(); + + // Check profiles first — may show picker instead of app + const profileReady = await initProfileSystem(); + if (!profileReady) { + console.log('Waiting for profile selection...'); + return; // App init deferred until profile is selected via picker + } + + initApp(); +} + +function initApp() { + // Initialize components + initializeNavigation(); + initializeMobileNavigation(); + initializeMediaPlayer(); + initExpandedPlayer(); + initializeSyncPage(); + initializeWatchlist(); + + + // Initialize WebSocket connection (falls back to HTTP polling if unavailable) + initializeWebSocket(); + + // Start global service status polling for sidebar (works on all pages) + // Initial fetch for immediate data, then setInterval as fallback when WebSocket is disconnected + fetchAndUpdateServiceStatus(); + setInterval(fetchAndUpdateServiceStatus, 5000); // Every 5 seconds (no-op when WebSocket active) + + // Check for updates on load and every hour + checkForUpdates(); + setInterval(checkForUpdates, 3600000); + + // Refresh key data immediately when user returns to this tab + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + fetchAndUpdateServiceStatus(); + // Refresh dashboard-specific data if on dashboard + const dashboardPage = document.getElementById('dashboard-page'); + if (dashboardPage && dashboardPage.classList.contains('active')) { + fetchAndUpdateSystemStats(); + fetchAndUpdateActivityFeed(); + } + } + }); + + // Start always-on download polling (batched, minimal overhead) + startGlobalDownloadPolling(); + + // Load issues badge count + loadIssuesBadge(); + + // Load initial data + loadInitialData(); + + // Handle window resize to re-check track title scrolling + window.addEventListener('resize', function () { + if (currentTrack) { + const trackTitleElement = document.getElementById('track-title'); + const trackTitle = currentTrack.title || 'Unknown Track'; + setTimeout(() => { + checkAndEnableScrolling(trackTitleElement, trackTitle); + }, 100); // Small delay to allow layout to settle + } + }); + + console.log('SoulSync WebUI initialized successfully!'); +} + +// =============================== +// NAVIGATION SYSTEM +// =============================== + +function initializeNavigation() { + const navButtons = document.querySelectorAll('.nav-button'); + + navButtons.forEach(button => { + button.addEventListener('click', () => { + const page = button.getAttribute('data-page'); + navigateToPage(page); + }); + }); + + window.addEventListener('popstate', (event) => { + const page = (event.state && event.state.page) || _getPageFromPath(); + if (page && page !== currentPage) { + navigateToPage(page, { skipPushState: true }); + } + }); +} + +const _DEEPLINK_VALID_PAGES = new Set([ + 'dashboard', 'sync', 'search', 'downloads', 'discover', 'artists', 'automations', + 'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist', + 'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer', + 'hydrabase', 'tools' +]); + +function _getPageFromPath() { + const path = window.location.pathname.replace(/^\/+|\/+$/g, ''); + if (!path) return 'dashboard'; + const basePage = path.split('/')[0]; + if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; + // Context-dependent pages fall back to a sensible parent + if (basePage === 'artist-detail') return 'library'; + if (basePage === 'playlist-explorer') return 'library'; + return basePage; +} + +// =============================== +// MOBILE NAVIGATION +// =============================== + +function initializeMobileNavigation() { + const hamburgerBtn = document.getElementById('hamburger-btn'); + const sidebar = document.querySelector('.sidebar'); + const overlay = document.getElementById('mobile-overlay'); + + if (!hamburgerBtn || !sidebar || !overlay) return; + + function openMobileNav() { + sidebar.classList.add('mobile-open'); + hamburgerBtn.classList.add('active'); + overlay.classList.add('active'); + document.body.classList.add('mobile-nav-open'); + } + + function closeMobileNav() { + sidebar.classList.remove('mobile-open'); + hamburgerBtn.classList.remove('active'); + overlay.classList.remove('active'); + document.body.classList.remove('mobile-nav-open'); + } + + hamburgerBtn.addEventListener('click', () => { + if (sidebar.classList.contains('mobile-open')) { + closeMobileNav(); + } else { + openMobileNav(); + } + }); + + overlay.addEventListener('click', closeMobileNav); + + // Close sidebar on nav button click (mobile only) + document.querySelectorAll('.nav-button').forEach(btn => { + btn.addEventListener('click', () => { + if (window.innerWidth <= 768) { + closeMobileNav(); + } + }); + }); +} + +function initializeWatchlist() { + // Watchlist button navigates to watchlist page + const watchlistButton = document.getElementById('watchlist-button'); + if (watchlistButton) { + watchlistButton.addEventListener('click', () => navigateToPage('watchlist')); + } + + // Wishlist button: quick check for active download, otherwise navigate to page + const wishlistButton = document.getElementById('wishlist-button'); + if (wishlistButton) { + wishlistButton.addEventListener('click', async () => { + // Fast path: check if we already know about an active wishlist process + const clientProcess = activeDownloadProcesses['wishlist']; + if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { + clientProcess.modalElement.style.display = 'flex'; + WishlistModalState.setVisible(); + return; + } + // Slow path: ask the server (with timeout to prevent button feeling dead) + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 2000); + const resp = await fetch('/api/active-processes', { signal: controller.signal }); + clearTimeout(timeout); + if (resp.ok) { + const data = await resp.json(); + const serverProcess = (data.active_processes || []).find(p => p.playlist_id === 'wishlist'); + if (serverProcess) { + try { + WishlistModalState.clearUserClosed(); + await rehydrateModal(serverProcess, true); + } catch (e) { + console.debug('Rehydration failed, navigating to page:', e); + navigateToPage('wishlist'); + } + return; + } + } + } catch (e) { + // Timeout or network error — just navigate + } + navigateToPage('wishlist'); + }); + } + + // Update watchlist count initially + updateWatchlistButtonCount(); + + // Update count every 10 seconds + setInterval(updateWatchlistButtonCount, 10000); + + console.log('Watchlist system initialized'); +} + +function navigateToPage(pageId, options = {}) { + // Backwards-compat aliases — both legacy ids fold into the unified Search page. + // 'downloads' was the Search page's old id; 'artists' was the retired inline + // Artists page, now replaced by clicking artists from the unified Search. + if (pageId === 'downloads' || pageId === 'artists') pageId = 'search'; + + if (pageId === currentPage) return; + + // Permission guard — redirect to home page if not allowed + if (!isPageAllowed(pageId)) { + const home = getProfileHomePage(); + if (home !== currentPage && isPageAllowed(home)) { + navigateToPage(home); + } + return; + } + + // Update navigation buttons (only if there's a nav button for this page). + // Pages reachable from many surfaces (artist-detail, playlist-explorer) + // intentionally have no [data-page] match here — the sidebar shouldn't + // imply a section the user didn't actually navigate via. + document.querySelectorAll('.nav-button').forEach(btn => { + btn.classList.remove('active'); + }); + const navButton = document.querySelector(`[data-page="${pageId}"]`); + if (navButton) { + navButton.classList.add('active'); + } + + // Update pages + document.querySelectorAll('.page').forEach(page => { + page.classList.remove('active'); + }); + document.getElementById(`${pageId}-page`).classList.add('active'); + + currentPage = pageId; + + if (!options.skipPushState) { + const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId; + if (window.location.pathname !== urlPath) { + history.pushState({ page: pageId }, '', urlPath); + } + } + + // Show/hide global search bar (hide on search page where the unified search lives) + if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility(); + + // Show/hide discover download sidebar based on page + const downloadSidebar = document.getElementById('discover-download-sidebar'); + if (downloadSidebar) { + if (pageId === 'discover') { + // Show sidebar on discover page if there are active downloads + const activeDownloads = Object.keys(discoverDownloads || {}).length; + console.log(`📊 [NAVIGATE] Discover page - ${activeDownloads} active downloads`); + if (activeDownloads > 0) { + // Update the sidebar UI to render the bubbles + console.log(`🔄 [NAVIGATE] Updating discover download bar UI`); + updateDiscoverDownloadBar(); + } + } else { + // Always hide sidebar on other pages + downloadSidebar.classList.add('hidden'); + } + } + + // Load page-specific data + loadPageData(pageId); + + // Update page background particles + if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId); + + // Update worker orbs + if (window.workerOrbs) window.workerOrbs.setPage(pageId); +} + +// REPLACE your old loadPageData function with this one: +// REPLACE your old loadPageData function with this corrected one + +async function loadPageData(pageId) { + try { + // Stop any active polling when navigating away + stopDbStatsPolling(); + stopDbUpdatePolling(); + stopWishlistCountPolling(); + stopLogPolling(); + // Stop watchlist/wishlist page timers when navigating away + if (watchlistCountdownInterval) { clearInterval(watchlistCountdownInterval); watchlistCountdownInterval = null; } + if (wishlistCountdownInterval) { clearInterval(wishlistCountdownInterval); wishlistCountdownInterval = null; } + if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling(); + if (pageId !== 'sync') { + cleanupBeatportContent(); + } + switch (pageId) { + case 'dashboard': + await loadDashboardData(); + loadDashboardSyncHistory(); + break; + case 'sync': + initializeSyncPage(); + await loadSyncData(); + break; + case 'search': + initializeSearch(); + initializeSearchModeToggle(); + initializeFilters(); + break; + // 'artists' page retired — aliased to 'search' at the top of navigateToPage + case 'active-downloads': + loadActiveDownloadsPage(); + break; + case 'library': + // Check if we should return to artist detail view instead of list + if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { + navigateToPage('artist-detail'); + if (!artistDetailPageState.isInitialized) { + initializeArtistDetailPage(); + loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); + } + // Already initialized — DOM content persists, no reload needed + } else { + if (!libraryPageState.isInitialized) { + initializeLibraryPage(); + } + // Already initialized — DOM content persists, no reload needed + } + break; + case 'artist-detail': + // Artist detail page is handled separately by navigateToArtistDetail() + break; + case 'discover': + if (!discoverPageInitialized) { + await loadDiscoverPage(); + discoverPageInitialized = true; + } + // Already initialized — DOM content persists, no reload needed + break; + case 'playlist-explorer': + initExplorer(); + break; + case 'settings': + initializeSettings(); + switchSettingsTab('connections'); + await loadSettingsData(); + await loadQualityProfile(); + loadApiKeys(); + loadBlacklistCount(); + break; + case 'stats': + initializeStatsPage(); + break; + case 'import': + initializeImportPage(); + break; + case 'hydrabase': + // Check connection status and pre-fill saved credentials + try { + const hsResp = await fetch('/api/hydrabase/status'); + const hsData = await hsResp.json(); + _hydrabaseConnected = hsData.connected; + document.getElementById('hydra-connection-status').textContent = hsData.connected ? 'Connected' : 'Disconnected'; + document.getElementById('hydra-connection-status').style.color = hsData.connected ? 'rgb(var(--accent-light-rgb))' : '#888'; + document.getElementById('hydra-connect-btn').textContent = hsData.connected ? 'Disconnect' : 'Connect'; + // Pre-fill saved credentials + if (hsData.saved_url) { + document.getElementById('hydra-ws-url').value = hsData.saved_url; + } + if (hsData.saved_api_key) { + document.getElementById('hydra-api-key').value = hsData.saved_api_key; + } + // Update peer count + if (hsData.peer_count !== null && hsData.peer_count !== undefined) { + document.getElementById('hydra-peer-count').textContent = `Peers: ${hsData.peer_count}`; + } + } catch (e) { } + // Load comparisons + loadHydrabaseComparisons(); + break; + case 'tools': + await initializeToolsPage(); + break; + case 'watchlist': + await initializeWatchlistPage(); + break; + case 'wishlist': + await initializeWishlistPage(); + break; + case 'automations': + await loadAutomations(); + break; + case 'issues': + await loadIssuesPage(); + break; + case 'help': + initializeDocsPage(); + break; + } + } catch (error) { + console.error(`Error loading ${pageId} data:`, error); + showToast(`Failed to load ${pageId} data`, 'error'); + } +} + +// =============================== +// SERVICE STATUS MONITORING +// =============================== + +// Legacy function - now handled by fetchAndUpdateServiceStatus +// Keeping this for compatibility but it's no longer actively used + +// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus + +// =============================== + diff --git a/webui/static/library.js b/webui/static/library.js new file mode 100644 index 00000000..55b1892c --- /dev/null +++ b/webui/static/library.js @@ -0,0 +1,7285 @@ +// LIBRARY PAGE FUNCTIONALITY +// =============================== + +// Library page state +const libraryPageState = { + isInitialized: false, + currentSearch: "", + currentLetter: "all", + currentPage: 1, + limit: 75, + debounceTimer: null, + watchlistFilter: "all", + sourceFilter: "" +}; + +function initializeLibraryPage() { + console.log("🔧 Initializing Library page..."); + + try { + // Initialize search functionality + initializeLibrarySearch(); + + // Initialize watchlist filter + initializeWatchlistFilter(); + + // Initialize metadata source filter + initializeSourceFilter(); + + // Initialize alphabet selector + initializeAlphabetSelector(); + + // Initialize pagination + initializeLibraryPagination(); + + // Load initial data + loadLibraryArtists(); + + // Show download bubbles if any exist + showLibraryDownloadsSection(); + + libraryPageState.isInitialized = true; + console.log("✅ Library page initialized successfully"); + + } catch (error) { + console.error("❌ Error initializing Library page:", error); + showToast("Failed to initialize Library page", "error"); + } +} + +function initializeLibrarySearch() { + const searchInput = document.getElementById("library-search-input"); + if (!searchInput) return; + + searchInput.addEventListener("input", (e) => { + const query = e.target.value.trim(); + + // Clear existing debounce timer + if (libraryPageState.debounceTimer) { + clearTimeout(libraryPageState.debounceTimer); + } + + // Debounce search requests + libraryPageState.debounceTimer = setTimeout(() => { + libraryPageState.currentSearch = query; + libraryPageState.currentPage = 1; // Reset to first page + loadLibraryArtists(); + }, 300); + }); + + // Clear search on Escape key + searchInput.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + searchInput.value = ""; + libraryPageState.currentSearch = ""; + libraryPageState.currentPage = 1; + loadLibraryArtists(); + } + }); +} + +function initializeWatchlistFilter() { + const filterButtons = document.querySelectorAll(".watchlist-filter-btn"); + const watchAllBtn = document.getElementById("library-watchlist-all-btn"); + + filterButtons.forEach(button => { + button.addEventListener("click", () => { + const filter = button.getAttribute("data-filter"); + + // Update active state + filterButtons.forEach(btn => btn.classList.remove("active")); + button.classList.add("active"); + + // Show/hide "Watch All Unwatched" button + if (watchAllBtn) { + if (filter === "unwatched") { + watchAllBtn.classList.remove("hidden"); + } else { + watchAllBtn.classList.add("hidden"); + } + } + + // Update state and reload + libraryPageState.watchlistFilter = filter; + libraryPageState.currentPage = 1; + loadLibraryArtists(); + }); + }); +} + +function initializeSourceFilter() { + const select = document.getElementById('library-source-filter'); + if (!select) return; + select.addEventListener('change', () => { + libraryPageState.sourceFilter = select.value; + libraryPageState.currentPage = 1; + loadLibraryArtists(); + }); +} + +function initializeAlphabetSelector() { + const alphabetButtons = document.querySelectorAll(".alphabet-btn"); + + alphabetButtons.forEach(button => { + button.addEventListener("click", () => { + const letter = button.getAttribute("data-letter"); + + // Update active state + alphabetButtons.forEach(btn => btn.classList.remove("active")); + button.classList.add("active"); + + // Update state and load data + libraryPageState.currentLetter = letter; + libraryPageState.currentPage = 1; // Reset to first page + loadLibraryArtists(); + }); + }); +} + +function initializeLibraryPagination() { + const prevBtn = document.getElementById("prev-page-btn"); + const nextBtn = document.getElementById("next-page-btn"); + + if (prevBtn) { + prevBtn.addEventListener("click", () => { + if (libraryPageState.currentPage > 1) { + libraryPageState.currentPage--; + loadLibraryArtists(); + } + }); + } + + if (nextBtn) { + nextBtn.addEventListener("click", () => { + libraryPageState.currentPage++; + loadLibraryArtists(); + }); + } +} + +async function loadLibraryArtists() { + try { + // Show loading state + showLibraryLoading(true); + + // Build query parameters + const params = new URLSearchParams({ + search: libraryPageState.currentSearch, + letter: libraryPageState.currentLetter, + page: libraryPageState.currentPage, + limit: libraryPageState.limit, + watchlist: libraryPageState.watchlistFilter + }); + if (libraryPageState.sourceFilter) params.set('source_filter', libraryPageState.sourceFilter); + + // Fetch artists from API + const response = await fetch(`/api/library/artists?${params}`); + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || "Failed to load artists"); + } + + // Update UI with artists + displayLibraryArtists(data.artists); + updateLibraryPagination(data.pagination); + updateLibraryStats(data.pagination.total_count); + + // Hide loading state + showLibraryLoading(false); + + // Show empty state if no artists + if (data.artists.length === 0) { + showLibraryEmpty(true); + } else { + showLibraryEmpty(false); + } + + } catch (error) { + console.error("❌ Error loading library artists:", error); + showToast("Failed to load artists", "error"); + showLibraryLoading(false); + showLibraryEmpty(true); + } +} + +function displayLibraryArtists(artists) { + const grid = document.getElementById("library-artists-grid"); + if (!grid) return; + + // Build all cards as HTML string for single DOM write (much faster than createElement loop) + grid.innerHTML = artists.map((artist, i) => { + try { return buildLibraryArtistCardHTML(artist, i); } + catch (e) { console.error('Failed to render artist card:', artist.name, e); return ''; } + }).join(''); + + // Attach click handlers via event delegation (single listener vs 75+ individual) + grid.onclick = (e) => { + // Ignore clicks on badge icons (they open external links / toggle watchlist) + const badge = e.target.closest('.source-card-icon'); + if (badge) { + e.stopPropagation(); + const url = badge.dataset.url; + if (url) { window.open(url, '_blank'); return; } + // Watchlist toggle + if (badge.classList.contains('watch-card-icon') && badge.dataset.unwatched) { + const card = badge.closest('.library-artist-card'); + if (card) { + const artistId = card.dataset.artistId; + const artistName = card.dataset.artistName; + const artist = artists.find(a => String(a.id) === artistId); + if (artist) toggleLibraryCardWatchlist(badge, artist); + } + } + return; + } + const card = e.target.closest('.library-artist-card'); + if (card) { + navigateToArtistDetail(card.dataset.artistId, card.dataset.artistName); + } + }; +} + +function buildLibraryArtistCardHTML(artist, index) { + const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + const delay = Math.min(index * 20, 600); // Cap at 600ms so last cards don't wait too long + + // Build badge icons + const badges = []; + if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify', url: `https://open.spotify.com/artist/${artist.spotify_artist_id}` }); + if (artist.musicbrainz_id) badges.push({ logo: MUSICBRAINZ_LOGO_URL, fb: 'MB', title: 'MusicBrainz', url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}` }); + if (artist.deezer_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer', url: `https://www.deezer.com/artist/${artist.deezer_id}` }); + if (artist.audiodb_id) { + const slug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : ''; + badges.push({ logo: typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', fb: 'ADB', title: 'AudioDB', url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${slug}` }); + } + if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music', url: `https://music.apple.com/artist/${artist.itunes_artist_id}` }); + if (artist.lastfm_url) badges.push({ logo: LASTFM_LOGO_URL, fb: 'LFM', title: 'Last.fm', url: artist.lastfm_url }); + if (artist.genius_url) badges.push({ logo: GENIUS_LOGO_URL, fb: 'GEN', title: 'Genius', url: artist.genius_url }); + if (artist.tidal_id) badges.push({ logo: TIDAL_LOGO_URL, fb: 'TD', title: 'Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` }); + if (artist.qobuz_id) badges.push({ logo: QOBUZ_LOGO_URL, fb: 'Qz', title: 'Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` }); + if (artist.discogs_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs', url: `https://www.discogs.com/artist/${artist.discogs_id}` }); + if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push({ logo: '/static/trans2.png', fb: 'SS', title: `SoulID: ${artist.soul_id}`, url: null }); + + // Watchlist badge + const hasActiveSourceId = currentMusicSourceName === 'iTunes' + ? (artist.itunes_artist_id || artist.spotify_artist_id) + : (artist.spotify_artist_id || artist.itunes_artist_id); + let watchBadgeHTML = ''; + if (artist.is_watched) { + watchBadgeHTML = `
    👁️Watching
    `; + } else if (hasActiveSourceId) { + watchBadgeHTML = `
    👁️Watch
    `; + } + + const maxPerColumn = 6; + const needsOverflow = badges.length > maxPerColumn; + const badgeIcon = (b) => `
    ${b.logo ? `` : `${b.fb}`}
    `; + + let badgeContainerHTML = ''; + if (badges.length > 0 || watchBadgeHTML) { + if (needsOverflow) { + badgeContainerHTML = `
    +
    ${watchBadgeHTML}${badges.slice(maxPerColumn).map(badgeIcon).join('')}
    +
    ${badges.slice(0, maxPerColumn).map(badgeIcon).join('')}
    +
    `; + } else { + badgeContainerHTML = `
    ${badges.map(badgeIcon).join('')}${watchBadgeHTML}
    `; + } + } + + // Image + const hasImage = artist.image_url && artist.image_url.trim() !== ''; + const deezerFallback = artist.deezer_id ? `if(!this.dataset.triedDeezer){this.dataset.triedDeezer='true';this.src='https://api.deezer.com/artist/${artist.deezer_id}/image?size=big'}else{this.parentNode.innerHTML='
    🎵
    '}` : `this.parentNode.innerHTML='
    🎵
    '`; + const imageHTML = hasImage + ? `
    ${_esc(artist.name)}
    ` + : `
    🎵
    `; + + // Track stats + const trackStat = artist.track_count > 0 ? `${artist.track_count} track${artist.track_count !== 1 ? 's' : ''}` : ''; + + return `
    + ${badgeContainerHTML} + ${imageHTML} +
    +

    ${_esc(artist.name)}

    +
    ${trackStat}
    +
    +
    `; +} + +function updateLibraryPagination(pagination) { + const prevBtn = document.getElementById("prev-page-btn"); + const nextBtn = document.getElementById("next-page-btn"); + const pageInfo = document.getElementById("page-info"); + const paginationContainer = document.getElementById("library-pagination"); + + if (!paginationContainer) return; + + // Update button states + if (prevBtn) { + prevBtn.disabled = !pagination.has_prev; + } + + if (nextBtn) { + nextBtn.disabled = !pagination.has_next; + } + + // Update page info + if (pageInfo) { + pageInfo.textContent = `Page ${pagination.page} of ${pagination.total_pages}`; + } + + // Show/hide pagination based on total pages + if (pagination.total_pages > 1) { + paginationContainer.classList.remove("hidden"); + } else { + paginationContainer.classList.add("hidden"); + } +} + +function updateLibraryStats(totalCount) { + const countElement = document.getElementById("library-artist-count"); + if (countElement) { + countElement.textContent = totalCount; + } +} + +function showLibraryLoading(show) { + const loadingElement = document.getElementById("library-loading"); + if (loadingElement) { + if (show) { + loadingElement.classList.remove("hidden"); + } else { + loadingElement.classList.add("hidden"); + } + } +} + +function showLibraryEmpty(show) { + const emptyElement = document.getElementById("library-empty"); + if (!emptyElement) return; + if (!show) { + emptyElement.classList.add("hidden"); + return; + } + + // When a search query is active and returned zero library hits, swap the + // generic "no artists" copy for a CTA that hands the query off to /search + // so the user can look the artist up across metadata sources without + // retyping. + const query = (libraryPageState.currentSearch || '').trim(); + const iconEl = document.getElementById('library-empty-icon'); + const titleEl = document.getElementById('library-empty-title'); + const subtitleEl = document.getElementById('library-empty-subtitle'); + const ctaEl = document.getElementById('library-empty-search-cta'); + const ctaQueryEl = document.getElementById('library-empty-search-cta-query'); + + if (query) { + if (iconEl) iconEl.textContent = '🔎'; + if (titleEl) titleEl.textContent = `"${query}" isn't in your library`; + if (subtitleEl) subtitleEl.textContent = 'They might be available on a connected metadata source.'; + if (ctaQueryEl) ctaQueryEl.textContent = `"${query}"`; + if (ctaEl) { + ctaEl.classList.remove('hidden'); + // Rebind cleanly — onclick avoids duplicate listeners across renders. + ctaEl.onclick = () => _handoffLibrarySearchToEnhancedSearch(query); + } + } else { + if (iconEl) iconEl.textContent = '🎵'; + if (titleEl) titleEl.textContent = 'No artists found'; + if (subtitleEl) subtitleEl.textContent = 'Try adjusting your search or filters'; + if (ctaEl) { + ctaEl.classList.add('hidden'); + ctaEl.onclick = null; + } + } + + emptyElement.classList.remove("hidden"); +} + +// Navigate to /search and pre-fill the enhanced search input with the query +// the user had typed into the library search. Uses the same hand-off pattern +// the global widget uses for Soulseek — navigate, then dispatch an `input` +// event so the Search page's existing debounced search kicks in. +function _handoffLibrarySearchToEnhancedSearch(query) { + if (typeof navigateToPage !== 'function') return; + navigateToPage('search'); + setTimeout(() => { + const input = document.getElementById('enhanced-search-input'); + if (input && query) { + input.value = query; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }, 300); +} + +async function openWatchAllUnwatchedModal() { + if (document.getElementById('watch-all-modal-overlay')) return; + + const sourceIdField = currentMusicSourceName === 'iTunes' ? 'itunes_artist_id' + : currentMusicSourceName === 'Deezer' ? 'deezer_id' : 'spotify_artist_id'; + const sourceName = currentMusicSourceName || 'Spotify'; + + const overlay = document.createElement('div'); + overlay.id = 'watch-all-modal-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeWatchAllUnwatchedModal(); }; + + overlay.innerHTML = ` +
    +
    +
    +
    👁
    +
    +

    Watch All Unwatched

    +

    Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist

    +
    +
    + +
    +
    +
    +
    +
    Loading unwatched artists...
    +
    +
    +
    + +
    + `; + document.body.appendChild(overlay); + + // Fetch all unwatched artists paginated (SQLite variable limit safe) + try { + const eligible = []; + const ineligible = []; + let page = 1; + const pageSize = 400; + const countEl = document.getElementById('watch-all-load-count'); + + while (true) { + if (!document.getElementById('watch-all-modal-overlay')) return; + if (countEl) countEl.textContent = `${eligible.length + ineligible.length} artists loaded...`; + + const params = new URLSearchParams({ search: '', letter: 'all', page, limit: pageSize, watchlist: 'unwatched' }); + const response = await fetch(`/api/library/artists?${params}`); + const data = await response.json(); + if (!data.success) throw new Error(data.error || 'Failed to load artists'); + + for (const a of (data.artists || [])) { + if (a[sourceIdField]) eligible.push(a); + else ineligible.push(a); + } + + if (!data.pagination.has_next) break; + page++; + } + + _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName); + } catch (error) { + console.error('Error loading unwatched artists:', error); + const body = overlay.querySelector('.watch-all-body'); + if (body) body.innerHTML = `
    Failed to load artists
    Retry
    `; + } +} + +function _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName) { + const body = overlay.querySelector('.watch-all-body'); + const confirmBtn = overlay.querySelector('#watch-all-confirm-btn'); + + if (eligible.length === 0 && ineligible.length === 0) { + body.innerHTML = '
    🎵
    No unwatched artists found
    '; + return; + } + + // Store data for search filtering + overlay._watchAllEligible = eligible; + overlay._watchAllIneligible = ineligible; + + let html = ''; + + // Summary bar (sticky) + html += '
    '; + html += `
    ${eligible.length}
    Ready to watch
    `; + html += `
    ${ineligible.length}
    No ${_esc(sourceName)} ID
    `; + html += `
    ${eligible.length + ineligible.length}
    Total unwatched
    `; + html += '
    '; + + // Search filter + if (eligible.length > 10) { + html += '
    '; + } + + // Eligible grid + if (eligible.length > 0) { + html += ''; + html += '
    '; + html += _buildWatchAllRows(eligible, false); + html += '
    '; + } + + // Ineligible section + if (ineligible.length > 0) { + html += `
    +
    +
    + + ${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID +
    + +
    +
    +
    These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time.
    +
    ${_buildWatchAllRows(ineligible, true)}
    +
    +
    `; + } + + if (eligible.length === 0) { + html += `
    🔌
    None of your unwatched artists have a ${_esc(sourceName)} ID yet
    The background enrichment worker will match them over time.
    `; + } + + body.innerHTML = html; + + if (eligible.length > 0 && confirmBtn) { + confirmBtn.textContent = `Watch All (${eligible.length})`; + confirmBtn.disabled = false; + confirmBtn.onclick = () => _confirmWatchAllUnwatched(overlay, eligible.length); + } +} + +function _buildWatchAllRows(artists, dimmed) { + let html = ''; + for (const a of artists) { + const img = a.image_url + ? `` + : `
    🎵
    `; + html += `
    +
    ${img}
    +
    ${_esc(a.name)}
    +
    ${a.track_count || 0} tracks
    +
    `; + } + return html; +} + +function _filterWatchAllList(query) { + const q = query.toLowerCase().trim(); + document.querySelectorAll('#watch-all-eligible-grid .watch-all-cell').forEach(cell => { + cell.style.display = !q || cell.dataset.name.includes(q) ? '' : 'none'; + }); +} + +async function _confirmWatchAllUnwatched(overlay, expectedCount) { + const confirmBtn = overlay.querySelector('#watch-all-confirm-btn'); + const cancelBtn = overlay.querySelector('.watch-all-btn-cancel'); + if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Adding...'; } + if (cancelBtn) cancelBtn.disabled = true; + + try { + const response = await fetch('/api/library/watchlist-all-unwatched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + + if (data.success) { + const body = overlay.querySelector('.watch-all-body'); + body.innerHTML = `
    +
    +
    Added ${data.added} artist${data.added !== 1 ? 's' : ''} to watchlist
    + ${data.skipped_already > 0 ? `
    ${data.skipped_already} already watched
    ` : ''} + ${data.skipped_no_id > 0 ? `
    ${data.skipped_no_id} skipped (no external ID)
    ` : ''} +
    `; + + if (confirmBtn) confirmBtn.style.display = 'none'; + if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; } + overlay.dataset.needsRefresh = 'true'; + } else { + throw new Error(data.error || 'Failed to add artists'); + } + } catch (error) { + console.error('Error in watch all:', error); + if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = `Watch All (${expectedCount})`; } + if (cancelBtn) cancelBtn.disabled = false; + showToast('Failed to add artists to watchlist', 'error'); + } +} + +function closeWatchAllUnwatchedModal() { + const overlay = document.getElementById('watch-all-modal-overlay'); + if (!overlay) return; + const needsRefresh = overlay.dataset.needsRefresh === 'true'; + overlay.remove(); + if (needsRefresh) loadLibraryArtists(); +} + +async function toggleLibraryCardWatchlist(btn, artist) { + if (btn.disabled) return; + btn.disabled = true; + + // Support both badge-style (.watch-icon-label) and button-style (.watchlist-text) + const label = btn.querySelector('.watch-icon-label') || btn.querySelector('.watchlist-text'); + const isWatching = btn.classList.contains('watched') || btn.classList.contains('watching'); + + if (label) label.textContent = '...'; + + try { + // Use the ID matching the active metadata source + const artistId = currentMusicSourceName === 'iTunes' + ? (artist.itunes_artist_id || artist.spotify_artist_id) + : (artist.spotify_artist_id || artist.itunes_artist_id); + if (!artistId) throw new Error('No iTunes or Spotify ID available for this artist'); + + if (isWatching) { + const response = await fetch('/api/watchlist/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + btn.classList.remove('watched', 'watching'); + btn.style.opacity = '0.4'; + btn.title = 'Add to Watchlist'; + if (label) label.textContent = 'Watch'; + showToast(`Removed ${artist.name} from watchlist`, 'success'); + } else { + const response = await fetch('/api/watchlist/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId, artist_name: artist.name }) + }); + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + btn.classList.add('watched'); + btn.style.opacity = ''; + btn.title = 'Remove from Watchlist'; + if (label) label.textContent = 'Watching'; + showToast(`Added ${artist.name} to watchlist`, 'success'); + } + + if (typeof updateWatchlistCount === 'function') { + updateWatchlistCount(); + } + } catch (error) { + console.error('Error toggling library card watchlist:', error); + if (label) label.textContent = isWatching ? 'Watching' : 'Watch'; + showToast(`Error: ${error.message}`, 'error'); + } finally { + btn.disabled = false; + } +} + +// =============================================== +// Artist Detail Page Functions +// =============================================== + +// Artist detail page state +let artistDetailPageState = { + isInitialized: false, + currentArtistId: null, + currentArtistName: null, + currentArtistSource: null, + // Stack of origins captured by navigateToArtistDetail for the back button. + // Each entry is either {type:'page', pageId} or {type:'artist', id, name, source} + // so chained navigation (Search → A → similar B → similar C) walks back one + // step at a time instead of jumping straight to Search. + originStack: [], + enhancedView: false, + enhancedData: null, + expandedAlbums: new Set(), + selectedTracks: new Set(), + editingCell: null, + enhancedTrackSort: {} +}; + +// Discography filter state +let discographyFilterState = { + categories: { albums: true, eps: true, singles: true }, + content: { live: true, compilations: true, featured: true }, + ownership: 'all' // 'all', 'owned', 'missing' +}; + +// Friendly labels for the dynamic "← Back to X" button on the artist-detail page. +// Page id (the value of currentPage) -> button label. +const _ARTIST_DETAIL_BACK_LABELS = { + library: 'Back to Library', + search: 'Back to Search', + discover: 'Back to Discover', + watchlist: 'Back to Watchlist', + wishlist: 'Back to Wishlist', + stats: 'Back to Stats', + 'playlist-explorer': 'Back to Explorer', + automations: 'Back to Automations', + dashboard: 'Back to Dashboard', + sync: 'Back to Sync', + 'active-downloads': 'Back to Downloads', +}; + +function navigateToArtistDetail(artistId, artistName, sourceOverride = null, options = {}) { + console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId}${sourceOverride ? `, source: ${sourceOverride}` : ''})`); + + // Capture the current location on the origin stack BEFORE navigateToPage + // flips currentPage. The back button walks this stack one step at a time, + // so a chain like Search → A → similar B → similar C steps back through + // C → B → A → Search instead of jumping straight home. `skipOriginPush` + // lets the back button re-enter a prior artist without re-pushing. + if (!options.skipOriginPush) { + // Fresh entry (from a non-artist page) starts a new chain; any stale + // entries from a prior artist-detail visit are dropped. + if (currentPage !== 'artist-detail') { + artistDetailPageState.originStack = []; + } + + let entry; + if (currentPage === 'artist-detail' && artistDetailPageState.currentArtistId) { + entry = { + type: 'artist', + id: artistDetailPageState.currentArtistId, + name: artistDetailPageState.currentArtistName, + source: artistDetailPageState.currentArtistSource, + }; + } else { + const pageId = (typeof currentPage === 'string' && currentPage && currentPage !== 'artist-detail') + ? currentPage : 'library'; + entry = { type: 'page', pageId }; + } + + // Avoid pushing a duplicate top entry on repeated clicks of the same target. + const top = artistDetailPageState.originStack[artistDetailPageState.originStack.length - 1]; + const isDuplicate = top && top.type === entry.type && ( + (entry.type === 'page' && top.pageId === entry.pageId) || + (entry.type === 'artist' && String(top.id) === String(entry.id)) + ); + if (!isDuplicate) { + artistDetailPageState.originStack.push(entry); + } + } + + // Abort any in-progress completion stream + if (artistDetailPageState.completionController) { + artistDetailPageState.completionController.abort(); + artistDetailPageState.completionController = null; + } + + // Cancel any active inline edit and close manual match modal before resetting state + cancelInlineEdit(); + const existingMatchOverlay = document.getElementById('enhanced-manual-match-overlay'); + if (existingMatchOverlay) existingMatchOverlay.remove(); + + // Store current artist info and reset enhanced view state + artistDetailPageState.currentArtistId = artistId; + artistDetailPageState.currentArtistName = artistName; + artistDetailPageState.currentArtistSource = sourceOverride || null; + artistDetailPageState.enhancedData = null; + artistDetailPageState.expandedAlbums = new Set(); + artistDetailPageState.selectedTracks = new Set(); + artistDetailPageState.enhancedTrackSort = {}; + artistDetailPageState.enhancedView = false; + + // Reset enhanced view toggle to standard + const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); + toggleBtns.forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard'); + }); + const enhancedContainer = document.getElementById('enhanced-view-container'); + if (enhancedContainer) enhancedContainer.classList.add('hidden'); + const standardSections = document.querySelector('.discography-sections'); + if (standardSections) standardSections.classList.remove('hidden'); + // Restore standard view filter groups + const filterGroups = document.querySelectorAll('#discography-filters .filter-group'); + filterGroups.forEach(group => { + const label = group.querySelector('.filter-label'); + if (label && label.textContent !== 'View') group.style.display = ''; + }); + const dividers = document.querySelectorAll('#discography-filters .filter-divider'); + dividers.forEach(d => d.style.display = ''); + // Hide bulk bar + const bulkBar = document.getElementById('enhanced-bulk-bar'); + if (bulkBar) bulkBar.classList.remove('visible'); + + // Navigate to artist detail page + navigateToPage('artist-detail'); + + // Update back-button label to reflect where the next pop will land. + _updateArtistDetailBackButtonLabel(); + + // Initialize if needed and load data + if (!artistDetailPageState.isInitialized) { + initializeArtistDetailPage(); + } + + // Load artist data + loadArtistDetailData(artistId, artistName); +} + +function _updateArtistDetailBackButtonLabel() { + const backBtnLabel = document.querySelector('#artist-detail-back-btn span'); + if (!backBtnLabel) return; + const stack = artistDetailPageState.originStack || []; + const top = stack[stack.length - 1]; + if (!top) { + backBtnLabel.textContent = `← ${_ARTIST_DETAIL_BACK_LABELS.library}`; + } else if (top.type === 'artist') { + backBtnLabel.textContent = `← Back to ${top.name}`; + } else { + const friendly = _ARTIST_DETAIL_BACK_LABELS[top.pageId] || _ARTIST_DETAIL_BACK_LABELS.library; + backBtnLabel.textContent = `← ${friendly}`; + } +} + +function initializeArtistDetailPage() { + console.log("🔧 Initializing Artist Detail page..."); + + // Initialize back button — pops the origin stack one step at a time so a + // chain like Search → A → B → C walks back through C → B → A → Search + // instead of jumping straight to the original entry page. + const backBtn = document.getElementById("artist-detail-back-btn"); + if (backBtn) { + backBtn.addEventListener("click", () => { + // Abort any in-progress completion stream regardless of destination + if (artistDetailPageState.completionController) { + artistDetailPageState.completionController.abort(); + artistDetailPageState.completionController = null; + } + + const stack = artistDetailPageState.originStack || []; + if (stack.length > 0) { + const target = stack.pop(); + if (target.type === 'artist') { + // Re-enter a prior artist in the chain without re-pushing, + // so the stack keeps shrinking as the user steps back. + navigateToArtistDetail(target.id, target.name, target.source, { skipOriginPush: true }); + return; + } + // target.type === 'page' — fully exit the artist-detail chain + artistDetailPageState.currentArtistId = null; + artistDetailPageState.currentArtistName = null; + artistDetailPageState.originStack = []; + navigateToPage(target.pageId); + return; + } + + // No history — default to library + artistDetailPageState.currentArtistId = null; + artistDetailPageState.currentArtistName = null; + artistDetailPageState.originStack = []; + navigateToPage('library'); + }); + } + + // Initialize retry button + const retryBtn = document.getElementById("artist-detail-retry-btn"); + if (retryBtn) { + retryBtn.addEventListener("click", () => { + if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { + loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); + } + }); + } + + // Initialize discography filter buttons + initializeDiscographyFilters(); + + artistDetailPageState.isInitialized = true; + console.log("✅ Artist Detail page initialized successfully"); +} + +async function loadArtistDetailData(artistId, artistName) { + console.log(`🔄 Loading artist detail data for: ${artistName} (ID: ${artistId})`); + + // Reset discography filters to defaults + resetDiscographyFilters(); + + // Show loading state and hide all content + showArtistDetailLoading(true); + showArtistDetailError(false); + showArtistDetailMain(false); + showArtistDetailHero(false); + + // Don't update header until data loads to avoid showing stale data + + try { + // Call API to get artist discography data. If this artist came from a + // metadata source (not the library), pass source + name so the backend + // can synthesize a response from that source instead of 404ing on the + // local DB lookup. + const params = new URLSearchParams(); + if (artistDetailPageState.currentArtistSource) { + params.set('source', artistDetailPageState.currentArtistSource); + } + if (artistName) { + params.set('name', artistName); + } + const qs = params.toString(); + const response = await fetch( + `/api/artist-detail/${encodeURIComponent(artistId)}${qs ? '?' + qs : ''}` + ); + + if (!response.ok) { + throw new Error(`Failed to load artist data: ${response.statusText}`); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to load artist data'); + } + + console.log(`✅ Loaded artist detail data:`, data); + + // Hide loading and show all content + showArtistDetailLoading(false); + showArtistDetailMain(true); + showArtistDetailHero(true); + + console.log(`🎨 Main content visibility:`, document.getElementById('artist-detail-main')); + console.log(`🎨 Albums section:`, document.getElementById('albums-section')); + + // Populate the page with data (which updates the hero section and sets textContent) + populateArtistDetailPage(data); + + // Library upgrade — if the backend resolved this source-artist click to + // an existing library record (e.g. clicking a Deezer result for an + // artist already in your Plex), data.artist.id is the library PK. + // Update currentArtistId so subsequent library-only API calls (Enhanced + // view, completion checks, server sync) hit the right id. Also flip + // the body source flag from 'source' back to 'library' so the + // library-only UI re-shows. + if (data.artist && data.artist.id && String(data.artist.id) !== String(artistDetailPageState.currentArtistId)) { + console.log(`📚 Library upgrade: ${artistDetailPageState.currentArtistId} → ${data.artist.id}`); + artistDetailPageState.currentArtistId = data.artist.id; + } + + // Keep the resolved metadata source for album-track lookups. + artistDetailPageState.currentArtistSource = data.discography?.source || data.artist?.source || null; + + // Update header with artist name and MusicBrainz link LAST to avoid overwrite + updateArtistDetailPageHeaderWithData(data.artist); + + // Render per-artist enrichment coverage + renderArtistEnrichmentCoverage(data.enrichment_coverage); + + // Start streaming ownership checks if we have Spotify discography with checking state + if (data.discography && data.discography.albums) { + const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])] + .some(r => r.owned === null); + if (hasChecking) { + // Store discography for stream updates + artistDetailPageState.currentDiscography = data.discography; + checkLibraryCompletion(data.artist.name, data.discography); + } + } + + // Check if artist has tracks eligible for quality enhancement. + // Use currentArtistId (not the closure arg) because the library-upgrade + // branch above may have rewritten it from the source ID to the library PK, + // and /api/library/artist//quality-analysis only works on library PKs. + checkArtistEnhanceEligibility(artistDetailPageState.currentArtistId); + + } catch (error) { + console.error(`❌ Error loading artist detail data:`, error); + + // Show error state (keep hero section hidden) + showArtistDetailLoading(false); + showArtistDetailError(true, error.message); + showArtistDetailHero(false); + + showToast(`Failed to load artist details: ${error.message}`, "error"); + } +} + +function updateArtistDetailPageHeader(artistName) { + // Update header title + const headerTitle = document.getElementById("artist-detail-name"); + if (headerTitle) { + headerTitle.textContent = artistName; + } + + // Update main artist name + const mainTitle = document.getElementById("artist-info-name"); + if (mainTitle) { + mainTitle.textContent = artistName; + } +} + +function updateArtistDetailPageHeaderWithData(artist) { + // Update name + const mainTitle = document.getElementById("artist-detail-name"); + if (mainTitle) { + mainTitle.textContent = artist.name; + // Remove any old source links that were appended to the h1 + mainTitle.querySelectorAll('.source-link-btn').forEach(el => el.remove()); + } + + // Render badges in dedicated container + const badgesContainer = document.getElementById("artist-hero-badges"); + if (badgesContainer) { + const _hb = (logo, fallback, title, url) => { + const inner = logo + ? `${fallback}` + : `${fallback}`; + if (url) return `${inner}`; + return `
    ${inner}
    `; + }; + + const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : ''; + const badges = []; + if (artist.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${artist.spotify_artist_id}`)); + if (artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${artist.musicbrainz_id}`)); + if (artist.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${artist.deezer_id}`)); + if (artist.audiodb_id) badges.push(_hb(typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', 'ADB', 'AudioDB', `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`)); + if (artist.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${artist.itunes_artist_id}`)); + if (artist.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', artist.lastfm_url)); + if (artist.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', artist.genius_url)); + if (artist.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${artist.tidal_id}`)); + if (artist.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${artist.qobuz_id}`)); + if (artist.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${artist.discogs_id}`)); + if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push(_hb('/static/trans2.png', 'SS', `SoulID: ${artist.soul_id}`, null)); + + badgesContainer.innerHTML = badges.join(''); + } +} + +function renderArtistEnrichmentCoverage(enrichment) { + const el = document.getElementById('artist-enrichment-coverage'); + if (!el) return; + + if (!enrichment || !enrichment.total_tracks) { + el.style.display = 'none'; + return; + } + + const services = [ + { name: 'Spotify', key: 'spotify', color: '#1db954' }, + { name: 'MusicBrainz', key: 'musicbrainz', color: '#ba55d3' }, + { name: 'Deezer', key: 'deezer', color: '#a238ff' }, + { name: 'Last.fm', key: 'lastfm', color: '#d51007' }, + { name: 'iTunes', key: 'itunes', color: '#fc3c44' }, + { name: 'AudioDB', key: 'audiodb', color: '#1a9fff' }, + { name: 'Discogs', key: 'discogs', color: '#D4A574' }, + { name: 'Genius', key: 'genius', color: '#ffff64' }, + { name: 'Tidal', key: 'tidal', color: '#00ffff' }, + { name: 'Qobuz', key: 'qobuz', color: '#4285f4' }, + ]; + + const r = 20, circ = 2 * Math.PI * r; + + el.style.display = ''; + el.innerHTML = ` +
    Enrichment Coverage
    +
    + ${services.map((s, i) => { + const pct = enrichment[s.key] || 0; + const offset = circ - (circ * pct / 100); + const delay = (i * 0.08).toFixed(2); + return `
    +
    + + + + + ${Math.round(pct)} +
    + ${s.name} +
    `; + }).join('')} +
    + `; +} + +function populateArtistDetailPage(data) { + const artist = data.artist; + const discography = data.discography; + + console.log(`🎨 Populating artist detail page for: ${artist.name}`); + console.log(`📀 Discography data:`, discography); + console.log(`📀 Albums:`, discography.albums); + console.log(`📀 EPs:`, discography.eps); + console.log(`📀 Singles:`, discography.singles); + + // Tag the body so CSS can hide library-only UI for source artists (e.g. + // the Enhanced view toggle, the Status filter, completion bars). Set + // BEFORE rendering so any layout-dependent code sees the right state. + document.body.dataset.artistSource = (artist && artist.server_source) ? 'library' : 'source'; + + // Update hero section with image, name, and stats + updateArtistHeroSection(artist, discography); + + // Update genres (if element exists) + updateArtistGenres(artist.genres); + + // Update summary stats (if element exists) + updateArtistSummaryStats(discography); + + // Populate discography sections + populateDiscographySections(discography); + + // Initialize the watchlist button. Library artists that have been enriched + // get the canonical Spotify identity; source artists fall back to the id + // they came in with (Deezer/iTunes/Discogs/etc.). + const libraryWatchlistBtn = document.getElementById('library-artist-watchlist-btn'); + if (libraryWatchlistBtn) { + const watchlistId = (data.spotify_artist && data.spotify_artist.spotify_artist_id) + || artist.id; + const watchlistName = (data.spotify_artist && data.spotify_artist.spotify_artist_name) + || artist.name; + if (watchlistId && watchlistName) { + initializeLibraryWatchlistButton(watchlistId, watchlistName); + } + } + + // Load Similar Artists section (works for both library + source artists via + // MusicMap name lookup). Fire-and-forget — the function handles its own + // loading state and errors. + if (artist && artist.name && typeof loadSimilarArtists === 'function') { + loadSimilarArtists(artist.name); + } +} + +function updateArtistDetailImage(imageUrl, artistName) { + const imageElement = document.getElementById("artist-detail-image"); + const fallbackElement = document.getElementById("artist-image-fallback"); + + if (imageUrl && imageUrl.trim() !== "") { + imageElement.src = imageUrl; + imageElement.alt = artistName; + imageElement.classList.remove("hidden"); + fallbackElement.classList.add("hidden"); + + imageElement.onerror = () => { + console.log(`Failed to load artist image for ${artistName}: ${imageUrl}`); + // Replace with fallback on error + imageElement.classList.add("hidden"); + fallbackElement.classList.remove("hidden"); + }; + + imageElement.onload = () => { + console.log(`Successfully loaded artist image for ${artistName}: ${imageUrl}`); + }; + } else { + console.log(`No image URL for ${artistName}: '${imageUrl}'`); + imageElement.classList.add("hidden"); + fallbackElement.classList.remove("hidden"); + } +} + +function updateArtistGenres(genres) { + const genresContainer = document.getElementById("artist-genres"); + if (!genresContainer) return; + + genresContainer.innerHTML = ""; + + // Clear any previous artist format tags (they arrive later via streaming) + const oldFormats = genresContainer.parentElement?.querySelector('.artist-formats'); + if (oldFormats) oldFormats.remove(); + + if (genres && genres.length > 0) { + genres.forEach(genre => { + const genreTag = document.createElement("span"); + genreTag.className = "genre-tag"; + genreTag.textContent = genre; + genresContainer.appendChild(genreTag); + }); + } +} + +function updateArtistSummaryStats(discography) { + const allReleases = [...discography.albums, ...discography.eps, ...discography.singles]; + const hasChecking = allReleases.some(r => r.owned === null); + + const ownedAlbums = discography.albums.filter(album => album.owned === true).length; + const missingAlbums = discography.albums.filter(album => album.owned === false).length; + const totalAlbums = discography.albums.length; + const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0; + + // Update owned albums count + const ownedElement = document.getElementById("owned-albums-count"); + if (ownedElement) { + ownedElement.textContent = hasChecking ? '...' : ownedAlbums; + } + + // Update missing albums count + const missingElement = document.getElementById("missing-albums-count"); + if (missingElement) { + missingElement.textContent = hasChecking ? '...' : missingAlbums; + } + + // Update completion percentage + const completionElement = document.getElementById("completion-percentage"); + if (completionElement) { + completionElement.textContent = hasChecking ? 'Checking...' : `${completionPercentage}%`; + } +} + +function updateArtistHeaderStats(albumCount, trackCount) { + // This function is deprecated - now using updateArtistHeroSection + console.log("📊 Using new hero section instead of old header stats"); +} + +function updateArtistHeroSection(artist, discography) { + console.log("🖼️ Updating artist hero section"); + + // Blurred background image (inline-Artists hero treatment) — set whenever + // we have an image_url; falls back to clearing the bg if not. + const heroBg = document.getElementById("artist-detail-hero-bg"); + if (heroBg) { + if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { + heroBg.style.backgroundImage = `url('${artist.image_url}')`; + } else { + heroBg.style.backgroundImage = ''; + } + } + + // Update artist image with detailed debugging + const imageElement = document.getElementById("artist-detail-image"); + const fallbackElement = document.getElementById("artist-detail-image-fallback"); + + console.log(`🖼️ Debug Artist image info:`); + console.log(` - URL: '${artist.image_url}'`); + console.log(` - Type: ${typeof artist.image_url}`); + console.log(` - Full artist object:`, artist); + console.log(` - Image element:`, imageElement); + console.log(` - Fallback element:`, fallbackElement); + + if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { + console.log(`✅ Setting image src to: ${artist.image_url}`); + imageElement.src = artist.image_url; + imageElement.alt = artist.name; + imageElement.style.display = "block"; + if (fallbackElement) { + fallbackElement.style.display = "none"; + } + + imageElement.onload = () => { + console.log(`✅ Successfully loaded artist image: ${artist.image_url}`); + }; + + imageElement.onerror = () => { + console.error(`❌ Failed to load artist image: ${artist.image_url}`); + // Try Deezer fallback before emoji + if (artist.deezer_id && !imageElement.dataset.triedDeezer) { + imageElement.dataset.triedDeezer = 'true'; + imageElement.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`; + } else { + imageElement.style.display = "none"; + if (fallbackElement) { + fallbackElement.style.display = "flex"; + } + } + }; + } else { + console.log(`🖼️ No valid image URL - showing fallback for ${artist.name}`); + imageElement.style.display = "none"; + if (fallbackElement) { + fallbackElement.style.display = "flex"; + } + } + + // Update artist name + const nameElement = document.getElementById("artist-detail-name"); + if (nameElement) { + nameElement.textContent = artist.name; + } + + // Calculate and update stats for each category + updateCategoryStats('albums', discography.albums); + updateCategoryStats('eps', discography.eps); + updateCategoryStats('singles', discography.singles); + + // Show Download Discography button(s) if there are any releases + const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); + const _discogWrap = document.getElementById('discog-download-wrap'); + if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; + const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); + if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; + + // Last.fm stats (listeners / playcount) + const _fmtNum = (n) => { + if (!n || n <= 0) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + return n.toLocaleString(); + }; + + const listenersEl = document.getElementById('artist-hero-listeners'); + if (listenersEl) { + if (artist.lastfm_listeners) { + listenersEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_listeners); + listenersEl.style.display = ''; + } else { + listenersEl.style.display = 'none'; + } + } + + const playcountEl = document.getElementById('artist-hero-playcount'); + if (playcountEl) { + if (artist.lastfm_playcount) { + playcountEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_playcount); + playcountEl.style.display = ''; + } else { + playcountEl.style.display = 'none'; + } + } + + // Last.fm bio + const bioEl = document.getElementById('artist-hero-bio'); + if (bioEl) { + const bio = artist.lastfm_bio; + if (bio && bio.trim()) { + // Strip HTML tags and "Read more on Last.fm" links + let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); + if (cleanBio) { + bioEl.innerHTML = `${cleanBio} + Read more`; + bioEl.style.display = ''; + } else { + bioEl.style.display = 'none'; + } + } else { + bioEl.style.display = 'none'; + } + } + + // Last.fm tags — merge with existing genres (deduplicate) + if (artist.lastfm_tags) { + try { + let lfmTags = typeof artist.lastfm_tags === 'string' ? JSON.parse(artist.lastfm_tags) : artist.lastfm_tags; + if (Array.isArray(lfmTags) && lfmTags.length > 0) { + const existingGenres = new Set((artist.genres || []).map(g => g.toLowerCase())); + const newTags = lfmTags.filter(t => !existingGenres.has(t.toLowerCase())).slice(0, 5); + if (newTags.length > 0) { + const genresContainer = document.getElementById('artist-genres'); + if (genresContainer) { + newTags.forEach(tag => { + const el = document.createElement('span'); + el.className = 'genre-tag'; + el.textContent = tag; + el.style.opacity = '0.6'; + genresContainer.appendChild(el); + }); + } + } + } + } catch (e) { + console.debug('Failed to parse Last.fm tags:', e); + } + } + + // Lazy-load top tracks sidebar + if (artist.lastfm_url || artist.lastfm_listeners) { + _loadArtistTopTracks(artist.name); + } +} + +async function _loadArtistTopTracks(artistName) { + const sidebar = document.getElementById('artist-hero-sidebar'); + const container = document.getElementById('hero-top-tracks'); + if (!sidebar || !container) return; + + try { + const resp = await fetch(`/api/artist/0/lastfm-top-tracks?name=${encodeURIComponent(artistName)}`); + const data = await resp.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + sidebar.style.display = 'none'; + return; + } + + const _fmtNum = (n) => { + if (!n || n <= 0) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + return n.toLocaleString(); + }; + + const _escAttr = (s) => (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + container.innerHTML = data.tracks.map((t, i) => ` +
    + ${i + 1} + + ${_escAttr(t.name)} + ${_fmtNum(t.playcount)} +
    + `).join(''); + + // Attach play handlers via delegation (avoids inline JS escaping issues) + container.onclick = (e) => { + const btn = e.target.closest('.hero-top-track-play'); + if (btn) { + e.stopPropagation(); + playStatsTrack(btn.dataset.track, btn.dataset.artist, ''); + } + }; + sidebar.style.display = ''; + } catch (e) { + console.debug('Failed to load top tracks:', e); + sidebar.style.display = 'none'; + } +} + +function updateCategoryStats(category, releases) { + const hasChecking = releases.some(r => r.owned === null); + const owned = releases.filter(r => r.owned === true).length; + const total = releases.length; + const completion = total > 0 ? Math.round((owned / total) * 100) : 100; + + // Update stats text (compact: "3/12") + const statsElement = document.getElementById(`${category}-stats`); + if (statsElement) { + statsElement.textContent = hasChecking ? '...' : `${owned}/${total}`; + } + + // Update completion bar + const fillElement = document.getElementById(`${category}-completion-fill`); + if (fillElement) { + if (hasChecking) { + fillElement.style.width = '100%'; + fillElement.classList.add('checking'); + } else { + fillElement.style.width = `${completion}%`; + fillElement.classList.remove('checking'); + } + } +} + +function populateDiscographySections(discography) { + // Populate albums + populateReleaseSection('albums', discography.albums); + + // Populate EPs + populateReleaseSection('eps', discography.eps); + + // Populate singles + populateReleaseSection('singles', discography.singles); + + // Apply any active filters after populating + applyDiscographyFilters(); +} + +function populateReleaseSection(sectionType, releases) { + const gridId = `${sectionType}-grid`; + const ownedCountId = `${sectionType}-owned-count`; + const missingCountId = `${sectionType}-missing-count`; + + const grid = document.getElementById(gridId); + if (!grid) return; + + // Clear existing content + grid.innerHTML = ""; + + const hasChecking = releases.some(r => r.owned === null); + const ownedCount = releases.filter(release => release.owned === true).length; + const missingCount = releases.filter(release => release.owned === false).length; + + // Update section stats + const ownedElement = document.getElementById(ownedCountId); + const missingElement = document.getElementById(missingCountId); + + if (ownedElement) { + ownedElement.textContent = hasChecking ? 'Checking...' : `${ownedCount} owned`; + } + + if (missingElement) { + missingElement.textContent = hasChecking ? '' : `${missingCount} missing`; + } + + // Create release cards + releases.forEach((release, index) => { + const card = createReleaseCard(release); + grid.appendChild(card); + }); + + // Trigger lazy background-image loading on the new cards + if (typeof observeLazyBackgrounds === 'function') { + observeLazyBackgrounds(grid); + } + + console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`); +} + +function createReleaseCard(release) { + const card = document.createElement("div"); + const isChecking = release.owned === null; + // .release-card keeps existing filter/state CSS + JS queries working; + // .album-card adopts the big-photo visual treatment from the retired + // inline Artists page (full-bleed image, gradient overlay, info pinned). + let stateCls = ''; + if (isChecking) stateCls = ' checking'; + else if (release.owned === false) stateCls = ' missing'; + card.className = `release-card album-card${stateCls}`; + + const releaseId = release.id || ""; + card.setAttribute("data-release-id", releaseId); + card.setAttribute("data-album-id", releaseId); + card.setAttribute("data-album-name", release.title || ""); + card.setAttribute("data-album-type", release.album_type || "album"); + // Store mutable reference so stream updates propagate to click handler + card._releaseData = release; + + // Tag card for content-type filtering + const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i; + const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i; + const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i; + const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || '')); + const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || ''); + const isFeatured = featuredPattern.test(release.title || ''); + card.setAttribute("data-is-live", isLive ? "true" : "false"); + card.setAttribute("data-is-compilation", isCompilation ? "true" : "false"); + card.setAttribute("data-is-featured", isFeatured ? "true" : "false"); + + // Background image — use data-bg-src for IntersectionObserver lazy loading + // (observeLazyBackgrounds is called by the caller after appending the grid). + const imageDiv = document.createElement("div"); + imageDiv.className = "album-card-image"; + if (release.image_url && release.image_url.trim() !== "") { + imageDiv.dataset.bgSrc = release.image_url; + } + card.appendChild(imageDiv); + + // Completion overlay — top-right floating badge. For library artists this + // shows the ownership state; for source artists (no library data) the + // overlay is omitted entirely so the card just shows the artwork + title. + const isSourceContext = (document.body.dataset.artistSource === 'source'); + if (!isSourceContext) { + const overlay = document.createElement("div"); + let overlayCls = ''; + let overlayLabel = ''; + + if (isChecking || release.track_completion === 'checking') { + overlayCls = 'checking'; + overlayLabel = 'Checking...'; + } else if (release.owned) { + const tc = release.track_completion; + if (tc && typeof tc === 'object') { + const ownedTracks = tc.owned_tracks || 0; + const totalTracks = tc.total_tracks || 0; + const missingTracks = tc.missing_tracks || 0; + if (missingTracks === 0) { + overlayCls = 'completed'; + overlayLabel = '✓ Owned'; + } else { + const pct = totalTracks > 0 ? Math.round((ownedTracks / totalTracks) * 100) : 0; + overlayCls = pct >= 75 ? 'nearly_complete' : 'partial'; + overlayLabel = `${ownedTracks}/${totalTracks}`; + } + } else { + const pct = release.track_completion || 100; + if (pct === 100) { + overlayCls = 'completed'; + overlayLabel = '✓ Owned'; + } else { + overlayCls = pct >= 75 ? 'nearly_complete' : 'partial'; + overlayLabel = `${pct}%`; + } + } + } else { + overlayCls = 'missing'; + overlayLabel = 'Missing'; + } + + overlay.className = `completion-overlay ${overlayCls}`; + overlay.innerHTML = `${overlayLabel}`; + card.appendChild(overlay); + } + + // Year — extract from release_date or fall back to year field + let yearText = ""; + if (release.release_date) { + try { + const yearMatch = release.release_date.match(/^(\d{4})/); + if (yearMatch) { + const ry = parseInt(yearMatch[1]); + if (ry && ry > 1900 && ry <= new Date().getFullYear() + 1) yearText = ry.toString(); + } else { + const ry = new Date(release.release_date).getFullYear(); + if (ry && !isNaN(ry) && ry > 1900 && ry <= new Date().getFullYear() + 1) yearText = ry.toString(); + } + } catch (e) { /* fall through */ } + } + if (!yearText && release.year) yearText = release.year.toString(); + + // Content (bottom-pinned over gradient) + const content = document.createElement("div"); + content.className = "album-card-content"; + const _esc = (s) => String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); + content.innerHTML = ` +
    ${_esc(release.title)}
    + ${yearText ? `
    ${_esc(yearText)}
    ` : ''} + `; + card.appendChild(content); + + // Add MusicBrainz icon LAST so it sits above the gradient overlay + if (release.musicbrainz_release_id) { + const mbIcon = document.createElement("div"); + mbIcon.className = "mb-card-icon"; + mbIcon.title = "View on MusicBrainz"; + mbIcon.innerHTML = ``; + mbIcon.onclick = (e) => { + e.stopPropagation(); + window.open(`https://musicbrainz.org/release/${release.musicbrainz_release_id}`, '_blank'); + }; + card.appendChild(mbIcon); + } + + // Add click handler for release card (uses card._releaseData for mutable reference) + card.addEventListener("click", async () => { + const rel = card._releaseData; + console.log(`Clicked on release: ${rel.title} (Owned: ${rel.owned})`); + + // Still checking - ignore click + if (rel.owned === null) { + showToast(`Still checking ownership for ${rel.title}...`, "info"); + return; + } + + showLoadingOverlay('Loading album...'); + + // For missing or incomplete releases, open wishlist modal + try { + // Convert release object to album format expected by our function + const albumData = { + id: rel.id, + name: rel.title, + image_url: rel.image_url, + release_date: rel.year ? `${rel.year}-01-01` : '', + album_type: rel.album_type || rel.type || 'album', + total_tracks: (rel.track_completion && typeof rel.track_completion === 'object') + ? rel.track_completion.total_tracks : (rel.track_count || 1) + }; + + // Get current artist from artist detail page state + const currentArtist = artistDetailPageState.currentArtistName ? { + id: artistDetailPageState.currentArtistId, + name: artistDetailPageState.currentArtistName, + image_url: getArtistImageFromPage() || '', // Get artist image from page + source: artistDetailPageState.currentArtistSource || null + } : null; + + if (!currentArtist) { + console.error('❌ No current artist found for release click'); + showToast('Error: No artist information available', 'error'); + return; + } + + // Load tracks for the album (pass name/artist for Hydrabase support) + const _aat2 = new URLSearchParams({ name: albumData.name || '', artist: currentArtist.name || '' }); + if (currentArtist.source) { + _aat2.set('source', currentArtist.source); + } + const response = await fetch(`/api/album/${albumData.id}/tracks?${_aat2}`); + if (!response.ok) { + throw new Error(`Failed to load album tracks: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found for this release'); + } + + // Use the actual album type from release data + const albumType = rel.album_type || rel.type || 'album'; + + // Open the Add to Wishlist modal immediately (no waiting for ownership check) + hideLoadingOverlay(); + await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType); + + // Always lazy-load track ownership + metadata (non-blocking) + lazyLoadTrackOwnership(currentArtist.name, data.tracks, card, albumData.name); + + } catch (error) { + hideLoadingOverlay(); + console.error('❌ Error handling release click:', error); + showToast(`Error opening wishlist modal: ${error.message}`, 'error'); + } + }); + + return card; +} + +/** + * Helper function to get artist image from the current artist detail page + */ +function getArtistImageFromPage() { + try { + // Try to get from artist detail image element + const artistDetailImage = document.getElementById('artist-detail-image'); + if (artistDetailImage && artistDetailImage.src && artistDetailImage.src !== window.location.href) { + return artistDetailImage.src; + } + + // Try to get from artist hero image + const artistImage = document.getElementById('artist-image'); + if (artistImage) { + const bgImage = window.getComputedStyle(artistImage).backgroundImage; + if (bgImage && bgImage !== 'none') { + // Extract URL from CSS background-image + const urlMatch = bgImage.match(/url\(["']?(.*?)["']?\)/); + if (urlMatch && urlMatch[1]) { + return urlMatch[1]; + } + } + } + + return null; + } catch (error) { + console.warn('Error getting artist image from page:', error); + return null; + } +} + +// ================================================================================================ +// LIBRARY COMPLETION STREAMING - Two-phase lazy-load pattern +// ================================================================================================ + +async function checkLibraryCompletion(artistName, discography) { + // Abort any in-progress check + if (artistDetailPageState.completionController) { + artistDetailPageState.completionController.abort(); + } + artistDetailPageState.completionController = new AbortController(); + + const payload = { + artist_name: artistName, + albums: discography.albums || [], + eps: discography.eps || [], + singles: discography.singles || [], + source: discography?.source || null + }; + + try { + const response = await fetch('/api/library/completion-stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + signal: artistDetailPageState.completionController.signal + }); + + if (!response.ok) { + console.error(`❌ Completion stream failed: ${response.status}`); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let ownedCounts = { albums: 0, eps: 0, singles: 0 }; + let totalCounts = { albums: 0, eps: 0, singles: 0 }; + const artistFormatSet = new Set(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + try { + const eventData = JSON.parse(line.slice(6)); + if (eventData.type === 'completion') { + updateLibraryReleaseCard(eventData); + totalCounts[eventData.category]++; + if (eventData.status !== 'missing' && eventData.status !== 'error') { + ownedCounts[eventData.category]++; + // Accumulate formats for artist-level summary + if (eventData.formats) { + eventData.formats.forEach(f => artistFormatSet.add(f)); + } + } + // Update stats incrementally + updateCategoryStatsFromStream( + eventData.category, + ownedCounts[eventData.category], + totalCounts[eventData.category] - ownedCounts[eventData.category] + ); + } else if (eventData.type === 'complete') { + console.log(`✅ Library completion stream done: ${eventData.processed_count} items`); + // Final stats recalculation + recalculateSummaryStats(artistFormatSet); + } + } catch (parseError) { + console.warn('Error parsing SSE event:', parseError, line); + } + } + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('🛑 Library completion stream aborted (navigation)'); + } else { + console.error('❌ Error in library completion stream:', error); + } + } +} + +function updateLibraryReleaseCard(data) { + const releaseId = data.id || ""; + const card = document.querySelector(`[data-release-id="${releaseId}"]`); + if (!card) return; + + const isOwned = data.status !== 'missing' && data.status !== 'error'; + + // Update card class + card.classList.remove('checking', 'missing'); + if (!isOwned) { + card.classList.add('missing'); + } + + // Use real numbers — no rounding or overrides + const isComplete = data.owned_tracks >= data.expected_tracks && data.owned_tracks > 0; + const effectiveMissing = data.expected_tracks - data.owned_tracks; + + // Update the mutable release data on the card + if (card._releaseData) { + card._releaseData.owned = isOwned; + if (isOwned && data.expected_tracks > 0) { + card._releaseData.track_completion = { + owned_tracks: data.owned_tracks, + total_tracks: isComplete ? data.owned_tracks : data.expected_tracks, + percentage: isComplete ? 100 : data.completion_percentage, + missing_tracks: effectiveMissing + }; + } else if (isOwned) { + card._releaseData.track_completion = { + owned_tracks: data.owned_tracks, + total_tracks: data.owned_tracks, + percentage: 100, + missing_tracks: 0 + }; + } else { + card._releaseData.track_completion = 0; + } + } + + // Update the floating completion-overlay badge (new big-photo card markup). + const overlay = card.querySelector('.completion-overlay'); + const overlayStatus = overlay && overlay.querySelector('.completion-status'); + if (overlay && overlayStatus) { + overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'error'); + let badgeCls = ''; + let badgeText = ''; + let badgeTitle = ''; + if (isOwned) { + if (effectiveMissing <= 0) { + badgeCls = 'completed'; + badgeText = '✓ Owned'; + badgeTitle = `Complete (${data.owned_tracks} tracks)`; + } else { + const pct = data.completion_percentage || Math.round((data.owned_tracks / data.expected_tracks) * 100); + badgeCls = pct >= 75 ? 'nearly_complete' : 'partial'; + badgeText = `${data.owned_tracks}/${data.expected_tracks}`; + badgeTitle = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`; + } + } else { + badgeCls = 'missing'; + badgeText = 'Missing'; + badgeTitle = data.expected_tracks > 0 + ? `${data.expected_tracks} track${data.expected_tracks !== 1 ? 's' : ''} not in library` + : 'Not in library'; + } + overlay.classList.add(badgeCls); + overlayStatus.textContent = badgeText; + overlay.title = badgeTitle; + } + + // Display format tags on owned releases + if (isOwned && data.formats && data.formats.length > 0) { + // Store formats on release data for modal use + if (card._releaseData) { + card._releaseData.formats = data.formats; + } + // Remove any existing format tags + const existingFormats = card.querySelector('.release-formats'); + if (existingFormats) existingFormats.remove(); + + const formatsDiv = document.createElement('div'); + formatsDiv.className = 'release-formats'; + formatsDiv.innerHTML = data.formats.map(f => `${f}`).join(''); + card.appendChild(formatsDiv); + } + + // Re-apply filters so newly resolved cards respect active filters + applyDiscographyFilters(); +} + +function updateCategoryStatsFromStream(category, ownedCount, missingCount) { + const total = ownedCount + missingCount; + const completion = total > 0 ? Math.round((ownedCount / total) * 100) : 100; + + const statsElement = document.getElementById(`${category}-stats`); + if (statsElement) { + statsElement.textContent = `${ownedCount}/${total}`; + } + + const fillElement = document.getElementById(`${category}-completion-fill`); + if (fillElement) { + fillElement.classList.remove('checking'); + fillElement.style.width = `${completion}%`; + } +} + +function recalculateSummaryStats(artistFormatSet) { + const disc = artistDetailPageState.currentDiscography; + if (!disc) return; + + // Recalculate from the live card data + const categories = ['albums', 'eps', 'singles']; + for (const cat of categories) { + const grid = document.getElementById(`${cat}-grid`); + if (!grid) continue; + let owned = 0, missing = 0; + grid.querySelectorAll('.release-card').forEach(card => { + if (card._releaseData) { + if (card._releaseData.owned === true) owned++; + else if (card._releaseData.owned === false) missing++; + } + }); + updateCategoryStatsFromStream(cat, owned, missing); + } + + // Update summary stats (albums only, matches original behavior) + const albumGrid = document.getElementById('albums-grid'); + if (albumGrid) { + let ownedAlbums = 0, missingAlbums = 0; + albumGrid.querySelectorAll('.release-card').forEach(card => { + if (card._releaseData) { + if (card._releaseData.owned === true) ownedAlbums++; + else if (card._releaseData.owned === false) missingAlbums++; + } + }); + const total = ownedAlbums + missingAlbums; + const pct = total > 0 ? Math.round((ownedAlbums / total) * 100) : 0; + + const ownedEl = document.getElementById("owned-albums-count"); + if (ownedEl) ownedEl.textContent = ownedAlbums; + const missingEl = document.getElementById("missing-albums-count"); + if (missingEl) missingEl.textContent = missingAlbums; + const completionEl = document.getElementById("completion-percentage"); + if (completionEl) completionEl.textContent = `${pct}%`; + } + + // Display artist-level format summary + if (artistFormatSet && artistFormatSet.size > 0) { + const heroInfo = document.querySelector('.artist-hero-section .artist-info'); + if (heroInfo) { + // Remove any existing artist format tag + const existing = heroInfo.querySelector('.artist-formats'); + if (existing) existing.remove(); + + const formatsDiv = document.createElement('div'); + formatsDiv.className = 'artist-formats'; + formatsDiv.innerHTML = [...artistFormatSet].sort() + .map(f => `${f}`) + .join(''); + // Insert after genres container + const genresContainer = heroInfo.querySelector('.artist-genres-container'); + if (genresContainer && genresContainer.nextSibling) { + heroInfo.insertBefore(formatsDiv, genresContainer.nextSibling); + } else { + heroInfo.appendChild(formatsDiv); + } + } + } +} + +// =============================================== +// Discography Filter Functions +// =============================================== + +function initializeDiscographyFilters() { + const container = document.getElementById('discography-filters'); + if (!container) return; + + container.addEventListener('click', (e) => { + const btn = e.target.closest('.discography-filter-btn'); + if (!btn) return; + + const filterType = btn.dataset.filter; + const value = btn.dataset.value; + + if (filterType === 'category') { + // Multi-toggle: toggle this category on/off + btn.classList.toggle('active'); + discographyFilterState.categories[value] = btn.classList.contains('active'); + } else if (filterType === 'content') { + // Multi-toggle: toggle this content type on/off + btn.classList.toggle('active'); + discographyFilterState.content[value] = btn.classList.contains('active'); + } else if (filterType === 'ownership') { + // Single-select: deactivate siblings, activate this one + container.querySelectorAll('[data-filter="ownership"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + discographyFilterState.ownership = value; + } + + applyDiscographyFilters(); + }); +} + +function resetDiscographyFilters() { + discographyFilterState.categories = { albums: true, eps: true, singles: true }; + discographyFilterState.content = { live: true, compilations: true, featured: true }; + discographyFilterState.ownership = 'all'; + + // Reset button visual states + const container = document.getElementById('discography-filters'); + if (!container) return; + container.querySelectorAll('.discography-filter-btn').forEach(btn => { + const filterType = btn.dataset.filter; + const value = btn.dataset.value; + if (filterType === 'ownership') { + btn.classList.toggle('active', value === 'all'); + } else { + btn.classList.add('active'); + } + }); +} + +function applyDiscographyFilters() { + const categories = ['albums', 'eps', 'singles']; + + for (const cat of categories) { + const section = document.getElementById(`${cat}-section`); + if (!section) continue; + + // Category toggle — hide entire section + if (!discographyFilterState.categories[cat]) { + section.style.display = 'none'; + continue; + } + section.style.display = ''; + + // Filter individual cards within the section + const grid = document.getElementById(`${cat}-grid`); + if (!grid) continue; + + let visibleOwned = 0; + let visibleMissing = 0; + let visibleCount = 0; + + grid.querySelectorAll('.release-card').forEach(card => { + let hidden = false; + + // Content filters + if (!discographyFilterState.content.live && card.getAttribute('data-is-live') === 'true') { + hidden = true; + } + if (!discographyFilterState.content.compilations && card.getAttribute('data-is-compilation') === 'true') { + hidden = true; + } + if (!discographyFilterState.content.featured && card.getAttribute('data-is-featured') === 'true') { + hidden = true; + } + + // Ownership filter (only apply if card is not still checking) + if (!hidden && discographyFilterState.ownership !== 'all' && card._releaseData) { + const owned = card._releaseData.owned; + if (owned !== null) { // Don't hide cards still being checked + if (discographyFilterState.ownership === 'owned' && !owned) hidden = true; + if (discographyFilterState.ownership === 'missing' && owned) hidden = true; + } + } + + card.style.display = hidden ? 'none' : ''; + + // Count visible cards for stats + if (!hidden && card._releaseData) { + visibleCount++; + if (card._releaseData.owned === true) visibleOwned++; + else if (card._releaseData.owned === false) visibleMissing++; + } + }); + + // Update section stats to reflect filtered view + const ownedEl = document.getElementById(`${cat}-owned-count`); + const missingEl = document.getElementById(`${cat}-missing-count`); + if (ownedEl) ownedEl.textContent = `${visibleOwned} owned`; + if (missingEl) missingEl.textContent = `${visibleMissing} missing`; + + // Hide section entirely if all cards are hidden + section.style.display = visibleCount === 0 ? 'none' : ''; + } +} + +// ==================== Download Discography Modal ==================== + +async function openDiscographyModal() { + // Support both Artists search page and Library artist detail page + let artist = artistsPageState.selectedArtist; + let discography = artistsPageState.artistDiscography; + let completionCache = artistsPageState.cache.completionData; + + // Fallback to Library page state if Artists page has no data for THIS artist + const libId = artistDetailPageState.currentArtistId; + const libName = artistDetailPageState.currentArtistName; + const isLibraryPage = libId && libName; + const artistsPageMatchesLibrary = artist && isLibraryPage && artist.name?.toLowerCase() === libName?.toLowerCase(); + + if (isLibraryPage && (!artist || !discography || !artistsPageMatchesLibrary)) { + // On library page — don't trust stale artistsPageState from a previous Artists page search + artist = { id: libId, name: libName, image_url: document.getElementById('artist-detail-image')?.src || '' }; + discography = null; + + let metadataArtistId = null; + try { + showToast('Loading discography...', 'info'); + + // Fetch the artist's metadata IDs from the DB (enhanced view may not be loaded) + let lookupId = libId; + try { + const idRes = await fetch(`/api/library/artist/${libId}/enhanced`); + const idData = await idRes.json(); + if (idData.success && idData.artist) { + const a = idData.artist; + metadataArtistId = a.spotify_artist_id || a.itunes_artist_id || a.deezer_id || null; + lookupId = metadataArtistId || libId; + } + } catch (e) { + console.debug('[Discography] Could not fetch artist IDs, using DB id'); + } + + const res = await fetch(`/api/artist/${encodeURIComponent(lookupId)}/discography?artist_name=${encodeURIComponent(libName)}`); + const data = await res.json(); + + if (!data.error) { + discography = { albums: data.albums || [], singles: data.singles || [] }; + if (discography.albums.length > 0 || discography.singles.length > 0) { + artistsPageState.artistDiscography = discography; + artistsPageState.sourceOverride = data.source || artistsPageState.sourceOverride || null; + // Use metadata source ID for the modal (needed for download API calls) + if (metadataArtistId) artist.id = metadataArtistId; + artist.source = data.source || null; + artistsPageState.selectedArtist = artist; + } else { + discography = null; + } + } + } catch (e) { + console.error('Failed to load discography:', e); + } + } + + if (!artist || !discography) { + showToast('No discography found. Try searching this artist from the Search page instead.', 'error'); + return; + } + + const completionData = (completionCache || {})[artist.id] || {}; + const allReleases = [ + ...(discography.albums || []).map(a => ({ ...a, _type: 'album' })), + ...(discography.eps || []).map(a => ({ ...a, _type: 'ep' })), + ...(discography.singles || []).map(a => ({ ...a, _type: 'single' })), + ]; + + // Build modal + const overlay = document.createElement('div'); + overlay.className = 'discog-modal-overlay'; + overlay.id = 'discog-modal-overlay'; + + const artistImg = artist.image_url || ''; + + overlay.innerHTML = ` +
    +
    +
    +
    +

    Download Discography

    +

    ${_esc(artist.name)}

    +
    + +
    +
    +
    + + + +
    +
    + + +
    +
    +
    + ${allReleases.map((r, i) => _renderDiscogCard(r, i, completionData)).join('')} +
    + + +
    + `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + _updateDiscogFooterCount(); + + // Bind submit button (avoids onclick being intercepted by helper system) + document.getElementById('discog-submit-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + startDiscographyDownload(); + }); +} + +function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } + +function _renderDiscogCard(release, index, completionData) { + const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id); + const status = comp?.status || 'unknown'; + const isOwned = status === 'completed'; + const isPartial = status === 'partial' || status === 'nearly_complete'; + const year = release.release_date ? release.release_date.substring(0, 4) : ''; + const tracks = release.total_tracks || 0; + const img = release.image_url || ''; + const checked = !isOwned; + const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : ''; + const statusIcon = isOwned ? '✓' : isPartial ? '◐' : ''; + + return ` + + `; +} + +function toggleDiscogFilter(btn) { + btn.classList.toggle('active'); + const type = btn.dataset.type; + document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => { + card.style.display = btn.classList.contains('active') ? '' : 'none'; + }); + _updateDiscogFooterCount(); +} + +function discogSelectAll(select) { + document.querySelectorAll('.discog-card-cb').forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + cb.checked = select; + } + }); + _updateDiscogFooterCount(); +} + +function _updateDiscogFooterCount() { + const checked = document.querySelectorAll('.discog-card-cb:checked'); + let releases = 0, tracks = 0; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + releases++; + tracks += parseInt(cb.dataset.tracks) || 0; + } + }); + const info = document.getElementById('discog-footer-info'); + const btn = document.getElementById('discog-submit-text'); + if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} · ${tracks} tracks`; + if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases'; + const submitBtn = document.getElementById('discog-submit-btn'); + if (submitBtn) submitBtn.disabled = releases === 0; +} + +async function startDiscographyDownload() { + let artist = artistsPageState.selectedArtist; + // Fallback to library page state + if (!artist && artistDetailPageState.currentArtistId) { + artist = { id: artistDetailPageState.currentArtistId, name: artistDetailPageState.currentArtistName || 'Unknown' }; + } + if (!artist || !artist.id) { + showToast('No artist data available', 'error'); + return; + } + + const checked = document.querySelectorAll('.discog-card-cb:checked'); + const albumEntries = []; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + albumEntries.push({ + id: cb.dataset.albumId, + tracks: parseInt(cb.dataset.tracks) || 0 + }); + } + }); + // Sort by track count descending — process Deluxe/expanded editions first + // so their tracks get added before standard editions (which then get deduped) + albumEntries.sort((a, b) => b.tracks - a.tracks); + const albumIds = albumEntries.map(e => e.id); + + if (albumIds.length === 0) return; + + // Switch to progress view + const grid = document.getElementById('discog-grid'); + const progress = document.getElementById('discog-progress'); + const footer = document.getElementById('discog-footer'); + const filterBar = document.querySelector('.discog-filter-bar'); + + if (grid) grid.style.display = 'none'; + if (filterBar) filterBar.style.display = 'none'; + if (progress) { + progress.style.display = ''; + progress.innerHTML = ''; + } + + // Build progress items + const albumMap = {}; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + const card = cb.closest('.discog-card'); + const id = cb.dataset.albumId; + const title = card.querySelector('.discog-card-title')?.textContent || ''; + const img = card.querySelector('.discog-card-art img')?.src || ''; + albumMap[id] = { title, img }; + + const item = document.createElement('div'); + item.className = 'discog-progress-item'; + item.id = `discog-prog-${id}`; + item.innerHTML = ` +
    ${img ? `` : '🎵'}
    +
    +
    ${_esc(title)}
    +
    Waiting...
    +
    +
    + `; + progress.appendChild(item); + } + }); + + // Update footer + const submitBtn = document.getElementById('discog-submit-btn'); + if (submitBtn) submitBtn.style.display = 'none'; + if (footer) { + const info = document.getElementById('discog-footer-info'); + if (info) info.textContent = 'Processing... this may take a moment'; + } + + // Mark all items as active + document.querySelectorAll('.discog-progress-item').forEach(item => item.classList.add('active')); + + try { + const response = await fetch(`/api/artist/${artist.id}/download-discography`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_ids: albumIds, artist_name: artist.name }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + + if (data.status === 'complete') { + _handleDiscogProgress({ type: 'complete', total_added: data.total_added, total_skipped: data.total_skipped }); + } else { + // Per-album update + const item = document.getElementById(`discog-prog-${data.album_id}`); + if (!item) continue; + + const statusEl = item.querySelector('.discog-prog-status'); + const iconEl = item.querySelector('.discog-prog-icon'); + item.classList.remove('active'); + + if (data.status === 'done') { + const parts = []; + if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`); + if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`); + statusEl.textContent = parts.join(', ') || 'No new tracks'; + iconEl.innerHTML = data.tracks_added > 0 ? '' : ''; + item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped'); + } else if (data.status === 'error') { + statusEl.textContent = data.message || 'Error'; + iconEl.innerHTML = ''; + item.classList.add('error'); + } + } + } catch (e) { /* skip malformed line */ } + } + } + } catch (err) { + showToast(`Discography download failed: ${err.message}`, 'error'); + } +} + +function _handleDiscogProgress(data) { + if (data.type === 'album') { + const item = document.getElementById(`discog-prog-${data.album_id}`); + if (!item) return; + + const statusEl = item.querySelector('.discog-prog-status'); + const iconEl = item.querySelector('.discog-prog-icon'); + + if (data.status === 'processing') { + statusEl.textContent = `Processing ${data.tracks_total} tracks...`; + item.classList.add('active'); + } else if (data.status === 'done') { + const parts = []; + if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`); + if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`); + statusEl.textContent = parts.join(', ') || 'No new tracks'; + iconEl.innerHTML = data.tracks_added > 0 ? '' : ''; + item.classList.remove('active'); + item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped'); + } else if (data.status === 'error') { + statusEl.textContent = data.message || 'Error'; + iconEl.innerHTML = ''; + item.classList.add('error'); + } + } else if (data.type === 'complete') { + const info = document.getElementById('discog-footer-info'); + if (info) info.textContent = `Done — ${data.total_added} tracks added, ${data.total_skipped} skipped`; + + // Show "Process Wishlist" button + const footer = document.querySelector('.discog-footer-actions'); + if (footer && data.total_added > 0) { + footer.innerHTML = ` + + + `; + } else if (footer) { + footer.innerHTML = ''; + } + } +} + +function closeDiscographyModal() { + const overlay = document.getElementById('discog-modal-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + setTimeout(() => overlay.remove(), 300); + } +} + +// ==================== Enhanced Library Management View ==================== + +function isEnhancedAdmin() { + return currentProfile && currentProfile.is_admin; +} + +function toggleEnhancedView(enabled) { + + const standardSections = document.querySelector('.discography-sections'); + const enhancedContainer = document.getElementById('enhanced-view-container'); + const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); + + if (!standardSections || !enhancedContainer) return; + + artistDetailPageState.enhancedView = enabled; + + // Update toggle button states + toggleBtns.forEach(btn => { + const view = btn.getAttribute('data-view'); + btn.classList.toggle('active', (view === 'enhanced') === enabled); + }); + + // Hide/show standard filter groups (not relevant in enhanced view) + const filterGroups = document.querySelectorAll('#discography-filters .filter-group'); + filterGroups.forEach(group => { + const label = group.querySelector('.filter-label'); + if (label && label.textContent !== 'View') { + group.style.display = enabled ? 'none' : ''; + } + }); + const dividers = document.querySelectorAll('#discography-filters .filter-divider'); + dividers.forEach((d, i) => { + if (i < dividers.length - 1) d.style.display = enabled ? 'none' : ''; + }); + + // Similar Artists is part of the standard view — hide it in Enhanced. + const similarSection = document.getElementById('ad-similar-artists-section'); + if (similarSection) similarSection.style.display = enabled ? 'none' : ''; + + if (enabled) { + standardSections.classList.add('hidden'); + enhancedContainer.classList.remove('hidden'); + + if (!artistDetailPageState.enhancedData) { + loadEnhancedViewData(artistDetailPageState.currentArtistId); + } else { + renderEnhancedView(); + } + } else { + standardSections.classList.remove('hidden'); + enhancedContainer.classList.add('hidden'); + const bulkBar = document.getElementById('enhanced-bulk-bar'); + if (bulkBar) bulkBar.classList.remove('visible'); + } +} + +async function loadEnhancedViewData(artistId) { + const container = document.getElementById('enhanced-view-container'); + if (!container) return; + + container.innerHTML = '
    Loading library data...
    '; + + try { + const response = await fetch(`/api/library/artist/${artistId}/enhanced`); + const data = await response.json(); + + if (!data.success) throw new Error(data.error || 'Failed to load enhanced data'); + + artistDetailPageState.enhancedData = data; + artistDetailPageState.expandedAlbums = new Set(); + artistDetailPageState.selectedTracks = new Set(); + artistDetailPageState.enhancedTrackSort = {}; + artistDetailPageState.serverType = data.server_type || null; + _tagPreviewServerType = data.server_type || null; + _rebuildAlbumMap(); + renderEnhancedView(); + + } catch (error) { + console.error('Error loading enhanced view data:', error); + container.innerHTML = `
    Failed to load: ${escapeHtml(error.message)}
    `; + } +} + +function renderEnhancedView() { + const container = document.getElementById('enhanced-view-container'); + const data = artistDetailPageState.enhancedData; + if (!container || !data) return; + + container.innerHTML = ''; + + // Artist metadata card (visual + editable) + container.appendChild(renderArtistMetaPanel(data.artist)); + + // Library stats summary bar + container.appendChild(renderEnhancedStatsBar(data)); + + // Group albums by type + const grouped = { album: [], ep: [], single: [] }; + (data.albums || []).forEach(album => { + const type = (album.record_type || 'album').toLowerCase(); + if (grouped[type]) grouped[type].push(album); + else grouped[type] = [album]; + }); + + const sectionLabels = { album: 'Albums', ep: 'EPs', single: 'Singles' }; + for (const [type, label] of Object.entries(sectionLabels)) { + const albums = grouped[type] || []; + if (albums.length === 0) continue; + container.appendChild(renderEnhancedSection(type, label, albums)); + } +} + +function renderEnhancedStatsBar(data) { + const bar = document.createElement('div'); + bar.className = 'enhanced-stats-bar'; + + const albums = data.albums || []; + const totalAlbums = albums.filter(a => (a.record_type || 'album') === 'album').length; + const totalEps = albums.filter(a => a.record_type === 'ep').length; + const totalSingles = albums.filter(a => a.record_type === 'single').length; + const totalTracks = albums.reduce((s, a) => s + (a.tracks ? a.tracks.length : 0), 0); + + // Calculate total duration + let totalDurationMs = 0; + albums.forEach(a => (a.tracks || []).forEach(t => { totalDurationMs += (t.duration || 0); })); + const totalHours = Math.floor(totalDurationMs / 3600000); + const totalMins = Math.floor((totalDurationMs % 3600000) / 60000); + + // Calculate format breakdown + const formatCounts = {}; + albums.forEach(a => (a.tracks || []).forEach(t => { + const fmt = extractFormat(t.file_path); + if (fmt !== '-') formatCounts[fmt] = (formatCounts[fmt] || 0) + 1; + })); + + const statsItems = [ + { value: totalAlbums, label: 'Albums', icon: '💿' }, + { value: totalEps, label: 'EPs', icon: '📀' }, + { value: totalSingles, label: 'Singles', icon: '♪' }, + { value: totalTracks, label: 'Tracks', icon: '🎵' }, + { value: totalHours > 0 ? `${totalHours}h ${totalMins}m` : `${totalMins}m`, label: 'Duration', icon: '⏲' }, + ]; + + let statsHtml = statsItems.map(s => + `
    + ${s.value} + ${s.label} +
    ` + ).join(''); + + // Format badges + const formatBadges = Object.entries(formatCounts) + .sort((a, b) => b[1] - a[1]) + .map(([fmt, count]) => { + const cls = fmt === 'FLAC' ? 'flac' : (fmt === 'MP3' ? 'mp3' : 'other'); + return `${fmt} (${count})`; + }).join(''); + + bar.innerHTML = ` +
    ${statsHtml}
    +
    ${formatBadges}
    + `; + + return bar; +} + +function renderArtistMetaPanel(artist) { + const panel = document.createElement('div'); + panel.className = 'enhanced-artist-meta'; + panel.id = 'enhanced-artist-meta'; + + // Build using DOM to avoid innerHTML escaping issues + const header = document.createElement('div'); + header.className = 'enhanced-artist-meta-header'; + + // Left side: artist image + name display + const headerLeft = document.createElement('div'); + headerLeft.className = 'enhanced-artist-meta-header-left'; + + if (artist.thumb_url) { + const img = document.createElement('img'); + img.className = 'enhanced-artist-meta-image'; + img.src = artist.thumb_url; + img.alt = artist.name || ''; + img.onerror = function () { this.style.display = 'none'; }; + headerLeft.appendChild(img); + } + + const headerInfo = document.createElement('div'); + headerInfo.className = 'enhanced-artist-meta-info'; + const artistTitle = document.createElement('div'); + artistTitle.className = 'enhanced-artist-meta-name'; + artistTitle.textContent = artist.name || 'Unknown Artist'; + headerInfo.appendChild(artistTitle); + + // ID badges row (clickable links) + const idBadges = document.createElement('div'); + idBadges.className = 'enhanced-artist-id-badges'; + const idSources = [ + { key: 'spotify_artist_id', label: 'Spotify', svc: 'spotify' }, + { key: 'musicbrainz_id', label: 'MusicBrainz', svc: 'musicbrainz' }, + { key: 'deezer_id', label: 'Deezer', svc: 'deezer' }, + { key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' }, + { key: 'discogs_id', label: 'Discogs', svc: 'discogs' }, + { key: 'itunes_artist_id', label: 'iTunes', svc: 'itunes' }, + { key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' }, + { key: 'genius_url', label: 'Genius', svc: 'genius' }, + { key: 'tidal_id', label: 'Tidal', svc: 'tidal' }, + { key: 'qobuz_id', label: 'Qobuz', svc: 'qobuz' }, + ]; + idSources.forEach(src => { + if (artist[src.key]) { + idBadges.appendChild(makeClickableBadge(src.svc, 'artist', artist[src.key], src.label)); + } + }); + headerInfo.appendChild(idBadges); + headerLeft.appendChild(headerInfo); + header.appendChild(headerLeft); + + // Right side: admin actions + const headerRight = document.createElement('div'); + headerRight.className = 'enhanced-artist-meta-actions'; + + // Live reorganize-queue status — sits first so the user sees what's + // happening before any of the action buttons. + mountReorganizeStatusPanel(headerRight, String(artist.id)); + + if (isEnhancedAdmin()) { + const editToggle = document.createElement('button'); + editToggle.className = 'enhanced-meta-edit-toggle'; + editToggle.textContent = 'Edit Metadata'; + editToggle.onclick = () => { + const form = document.getElementById('enhanced-artist-meta-form'); + if (form) { + const isVisible = !form.classList.contains('hidden'); + form.classList.toggle('hidden'); + editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor'; + editToggle.classList.toggle('active', !isVisible); + } + }; + headerRight.appendChild(editToggle); + + // Enrich dropdown button + const enrichWrap = document.createElement('div'); + enrichWrap.className = 'enhanced-enrich-wrap'; + const enrichBtn = document.createElement('button'); + enrichBtn.className = 'enhanced-enrich-btn'; + enrichBtn.textContent = 'Enrich ▾'; + enrichBtn.onclick = (e) => { + e.stopPropagation(); + enrichMenu.classList.toggle('visible'); + }; + enrichWrap.appendChild(enrichBtn); + + const enrichMenu = document.createElement('div'); + enrichMenu.className = 'enhanced-enrich-menu'; + const services = [ + { id: 'spotify', label: 'Spotify', icon: '🟢' }, + { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, + { id: 'deezer', label: 'Deezer', icon: '🟣' }, + { id: 'discogs', label: 'Discogs', icon: '🟤' }, + { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, + { id: 'itunes', label: 'iTunes', icon: '🔴' }, + { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, + { id: 'genius', label: 'Genius', icon: '🟡' }, + { id: 'tidal', label: 'Tidal', icon: '⬛' }, + { id: 'qobuz', label: 'Qobuz', icon: '🔷' }, + ]; + services.forEach(svc => { + const item = document.createElement('div'); + item.className = 'enhanced-enrich-menu-item'; + item.textContent = `${svc.icon} ${svc.label}`; + item.onclick = (e) => { + e.stopPropagation(); + enrichMenu.classList.remove('visible'); + runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id); + }; + enrichMenu.appendChild(item); + }); + enrichWrap.appendChild(enrichMenu); + headerRight.appendChild(enrichWrap); + } + + // Sync / Validate button + const syncBtn = document.createElement('button'); + syncBtn.className = 'enhanced-sync-btn'; + syncBtn.innerHTML = '🔄 Sync'; + syncBtn.title = 'Validate files — removes stale entries for tracks no longer on disk'; + syncBtn.onclick = async (e) => { + e.stopPropagation(); + syncBtn.disabled = true; + syncBtn.textContent = 'Syncing...'; + try { + const res = await fetch(`/api/library/artist/${artist.id}/sync`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { + const parts = []; + if (data.new_albums > 0) parts.push(`+${data.new_albums} albums`); + if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`); + if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale removed`); + if (data.empty_albums_removed > 0) parts.push(`${data.empty_albums_removed} empty albums cleaned`); + if (data.name_updated) parts.push('name updated'); + if (parts.length === 0) parts.push('Already in sync'); + showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success'); + // Refresh enhanced view if anything changed + if (data.stale_removed > 0 || data.empty_albums_removed > 0) { + loadEnhancedViewData(artist.id); + } + } else { + showToast(`Sync failed: ${data.error}`, 'error'); + } + } catch (err) { + showToast(`Sync failed: ${err.message}`, 'error'); + } + syncBtn.disabled = false; + syncBtn.innerHTML = '🔄 Sync'; + }; + headerRight.appendChild(syncBtn); + + const reorgAllBtn = document.createElement('button'); + reorgAllBtn.className = 'enhanced-sync-btn'; + reorgAllBtn.innerHTML = '📁 Reorganize All'; + reorgAllBtn.title = 'Reorganize all albums for this artist using your configured download template'; + reorgAllBtn.onclick = () => _showReorganizeAllModal(); + headerRight.appendChild(reorgAllBtn); + + header.appendChild(headerRight); + + panel.appendChild(header); + + // Match status row (clickable to rematch) + const statusRow = document.createElement('div'); + statusRow.className = 'enhanced-match-status-row'; + const statusServices = [ + { key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' }, + { key: 'musicbrainz_match_status', label: 'MusicBrainz', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' }, + { key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' }, + { key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' }, + { key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' }, + { key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' }, + { key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' }, + { key: 'genius_match_status', label: 'Genius', attempted: 'genius_last_attempted', svc: 'genius' }, + { key: 'tidal_match_status', label: 'Tidal', attempted: 'tidal_last_attempted', svc: 'tidal' }, + { key: 'qobuz_match_status', label: 'Qobuz', attempted: 'qobuz_last_attempted', svc: 'qobuz' }, + ]; + statusServices.forEach(s => { + const status = artist[s.key]; + const attempted = artist[s.attempted]; + const chip = document.createElement('span'); + chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`; + chip.textContent = `${s.label}: ${status || 'pending'}`; + const tipParts = []; + if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`); + tipParts.push('Click to rematch'); + chip.title = tipParts.join(' · '); + chip.onclick = () => openManualMatchModal('artist', artist.id, s.svc, artist.name, artist.id); + statusRow.appendChild(chip); + }); + panel.appendChild(statusRow); + + // Collapsible edit form (hidden by default) + const form = document.createElement('div'); + form.className = 'enhanced-artist-meta-form hidden'; + form.id = 'enhanced-artist-meta-form'; + + const editableFields = [ + { key: 'name', label: 'Artist Name', type: 'text' }, + { key: 'genres', label: 'Genres (comma separated)', type: 'text', isArray: true }, + { key: 'label', label: 'Label', type: 'text' }, + { key: 'style', label: 'Style', type: 'text' }, + { key: 'mood', label: 'Mood', type: 'text' }, + { key: 'summary', label: 'Summary / Bio', type: 'textarea', wide: true }, + ]; + + const grid = document.createElement('div'); + grid.className = 'enhanced-artist-meta-grid'; + + editableFields.forEach(f => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'enhanced-meta-field' + (f.wide ? ' wide' : ''); + + const label = document.createElement('label'); + label.className = 'enhanced-meta-field-label'; + label.textContent = f.label; + fieldDiv.appendChild(label); + + const val = f.isArray + ? (Array.isArray(artist[f.key]) ? artist[f.key].join(', ') : (artist[f.key] || '')) + : (artist[f.key] || ''); + + if (f.type === 'textarea') { + const ta = document.createElement('textarea'); + ta.className = 'enhanced-meta-field-input'; + ta.dataset.field = f.key; + ta.placeholder = f.label + '...'; + ta.textContent = val; + fieldDiv.appendChild(ta); + } else { + const inp = document.createElement('input'); + inp.type = 'text'; + inp.className = 'enhanced-meta-field-input'; + inp.dataset.field = f.key; + inp.value = val; + inp.placeholder = f.label + '...'; + fieldDiv.appendChild(inp); + } + + grid.appendChild(fieldDiv); + }); + + form.appendChild(grid); + + // Save/revert buttons + const formActions = document.createElement('div'); + formActions.className = 'enhanced-artist-form-actions'; + const revertBtn = document.createElement('button'); + revertBtn.className = 'enhanced-meta-cancel-btn'; + revertBtn.textContent = 'Revert'; + revertBtn.onclick = () => revertArtistMetadata(); + const saveBtn = document.createElement('button'); + saveBtn.className = 'enhanced-meta-save-btn'; + saveBtn.textContent = 'Save Changes'; + saveBtn.onclick = () => saveArtistMetadata(); + formActions.appendChild(revertBtn); + formActions.appendChild(saveBtn); + form.appendChild(formActions); + + panel.appendChild(form); + + return panel; +} + +function renderEnhancedSection(type, label, albums) { + const section = document.createElement('div'); + section.className = 'enhanced-section'; + + const totalTracks = albums.reduce((sum, a) => sum + (a.tracks ? a.tracks.length : 0), 0); + + const sectionHeader = document.createElement('div'); + sectionHeader.className = 'enhanced-section-header'; + sectionHeader.innerHTML = ` + ${label} + ${albums.length} release${albums.length !== 1 ? 's' : ''} · ${totalTracks} tracks + `; + section.appendChild(sectionHeader); + + const grid = document.createElement('div'); + grid.className = 'enhanced-album-grid'; + + albums.forEach(album => { + const wrapper = document.createElement('div'); + wrapper.className = 'enhanced-album-wrapper'; + wrapper.id = `enhanced-album-wrapper-${album.id}`; + const isExpanded = artistDetailPageState.expandedAlbums.has(album.id); + if (isExpanded) wrapper.classList.add('expanded'); + + wrapper.appendChild(renderAlbumRow(album, type)); + + const tracksPanel = document.createElement('div'); + tracksPanel.className = 'enhanced-tracks-panel'; + tracksPanel.id = `enhanced-tracks-panel-${album.id}`; + if (isExpanded) tracksPanel.classList.add('visible'); + const inner = document.createElement('div'); + inner.className = 'enhanced-tracks-panel-inner'; + if (isExpanded) { + inner.dataset.rendered = 'true'; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + } + tracksPanel.appendChild(inner); + wrapper.appendChild(tracksPanel); + + grid.appendChild(wrapper); + }); + section.appendChild(grid); + + return section; +} + +function renderAlbumRow(album, type) { + const row = document.createElement('div'); + row.className = 'enhanced-album-row'; + row.id = `enhanced-album-row-${album.id}`; + + if (artistDetailPageState.expandedAlbums.has(album.id)) row.classList.add('expanded'); + + const trackCount = album.tracks ? album.tracks.length : 0; + const typeClass = (type || 'album').toLowerCase(); + + // Total duration for this album + let albumDurMs = 0; + (album.tracks || []).forEach(t => { albumDurMs += (t.duration || 0); }); + const albumDur = formatDurationMs(albumDurMs); + + // Format breakdown for this album + const fmts = {}; + (album.tracks || []).forEach(t => { + const f = extractFormat(t.file_path); + if (f !== '-') fmts[f] = (fmts[f] || 0) + 1; + }); + const primaryFormat = Object.keys(fmts).sort((a, b) => fmts[b] - fmts[a])[0] || ''; + + // Build with DOM for safety + const expandIcon = document.createElement('span'); + expandIcon.className = 'enhanced-album-expand-icon'; + expandIcon.innerHTML = '▶'; + row.appendChild(expandIcon); + + // Album art - larger, prominent + const artWrap = document.createElement('div'); + artWrap.className = 'enhanced-album-art-wrap'; + if (album.thumb_url) { + const img = document.createElement('img'); + img.className = 'enhanced-album-thumb'; + img.src = album.thumb_url; + img.alt = ''; + img.loading = 'lazy'; + img.onerror = function () { + const fallback = document.createElement('div'); + fallback.className = 'enhanced-album-thumb-fallback'; + fallback.innerHTML = '🎵'; + this.replaceWith(fallback); + }; + artWrap.appendChild(img); + } else { + const fallback = document.createElement('div'); + fallback.className = 'enhanced-album-thumb-fallback'; + fallback.innerHTML = '🎵'; + artWrap.appendChild(fallback); + } + row.appendChild(artWrap); + + // Info block (title + meta line) + const infoBlock = document.createElement('div'); + infoBlock.className = 'enhanced-album-info-block'; + + const titleEl = document.createElement('span'); + titleEl.className = 'enhanced-album-title'; + titleEl.textContent = album.title || 'Unknown'; + titleEl.title = album.title || ''; + infoBlock.appendChild(titleEl); + + const metaLine = document.createElement('span'); + metaLine.className = 'enhanced-album-meta-line'; + const metaParts = []; + if (album.year) metaParts.push(String(album.year)); + metaParts.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`); + if (albumDur !== '-') metaParts.push(albumDur); + if (album.label) metaParts.push(album.label); + metaLine.textContent = metaParts.join(' \u00B7 '); + infoBlock.appendChild(metaLine); + + row.appendChild(infoBlock); + + // Type badge + const badge = document.createElement('span'); + badge.className = `enhanced-album-type-badge ${typeClass}`; + badge.textContent = type; + row.appendChild(badge); + + // Format badge inline + if (primaryFormat) { + const fmtBadge = document.createElement('span'); + const fmtClass = primaryFormat === 'FLAC' ? 'flac' : (primaryFormat === 'MP3' ? 'mp3' : 'other'); + fmtBadge.className = `enhanced-format-badge ${fmtClass}`; + fmtBadge.textContent = primaryFormat; + row.appendChild(fmtBadge); + } + + row.addEventListener('click', () => toggleAlbumExpand(album.id)); + + return row; +} + +function toggleAlbumExpand(albumId) { + const row = document.getElementById(`enhanced-album-row-${albumId}`); + const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); + const wrapper = document.getElementById(`enhanced-album-wrapper-${albumId}`); + if (!row || !panel) return; + + const isExpanded = artistDetailPageState.expandedAlbums.has(albumId); + + if (isExpanded) { + artistDetailPageState.expandedAlbums.delete(albumId); + row.classList.remove('expanded'); + panel.classList.remove('visible'); + if (wrapper) wrapper.classList.remove('expanded'); + } else { + artistDetailPageState.expandedAlbums.add(albumId); + row.classList.add('expanded'); + panel.classList.add('visible'); + if (wrapper) wrapper.classList.add('expanded'); + + // Lazy render + const inner = panel.querySelector('.enhanced-tracks-panel-inner'); + if (inner && !inner.dataset.rendered) { + const album = findEnhancedAlbum(albumId); + if (album) { + inner.innerHTML = ''; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + inner.dataset.rendered = 'true'; + } + } + } +} + +function findEnhancedAlbum(albumId) { + // Use cached map for O(1) lookups instead of O(n) array scan + if (artistDetailPageState._albumMap) { + return artistDetailPageState._albumMap.get(String(albumId)) || null; + } + const data = artistDetailPageState.enhancedData; + if (!data || !data.albums) return null; + return data.albums.find(a => String(a.id) === String(albumId)); +} + +function _rebuildAlbumMap() { + const data = artistDetailPageState.enhancedData; + if (!data || !data.albums) { artistDetailPageState._albumMap = null; return; } + const map = new Map(); + data.albums.forEach(a => map.set(String(a.id), a)); + artistDetailPageState._albumMap = map; +} + +function renderExpandedAlbumHeader(album) { + const header = document.createElement('div'); + header.className = 'enhanced-expanded-header'; + + // Large album art + if (album.thumb_url) { + const img = document.createElement('img'); + img.className = 'enhanced-expanded-art'; + img.src = album.thumb_url; + img.alt = album.title || ''; + img.onerror = function () { this.style.display = 'none'; }; + header.appendChild(img); + } + + const info = document.createElement('div'); + info.className = 'enhanced-expanded-info'; + + const title = document.createElement('div'); + title.className = 'enhanced-expanded-title'; + title.textContent = album.title || 'Unknown'; + info.appendChild(title); + + const meta = document.createElement('div'); + meta.className = 'enhanced-expanded-meta'; + + const details = []; + if (album.year) details.push(String(album.year)); + const trackCount = album.tracks ? album.tracks.length : 0; + details.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`); + let durMs = 0; + (album.tracks || []).forEach(t => { durMs += (t.duration || 0); }); + if (durMs > 0) details.push(formatDurationMs(durMs)); + if (album.label) details.push(album.label); + if (album.record_type) details.push(album.record_type.toUpperCase()); + + meta.textContent = details.join(' \u00B7 '); + info.appendChild(meta); + + // Genre tags + const genres = Array.isArray(album.genres) ? album.genres : []; + if (genres.length > 0) { + const genreRow = document.createElement('div'); + genreRow.className = 'enhanced-expanded-genres'; + genres.forEach(g => { + const tag = document.createElement('span'); + tag.className = 'enhanced-genre-tag'; + tag.textContent = g; + genreRow.appendChild(tag); + }); + info.appendChild(genreRow); + } + + // External ID badges (clickable links) + const ids = document.createElement('div'); + ids.className = 'enhanced-expanded-ids'; + const idFields = [ + { key: 'spotify_album_id', label: 'Spotify', svc: 'spotify' }, + { key: 'musicbrainz_release_id', label: 'MusicBrainz', svc: 'musicbrainz' }, + { key: 'deezer_id', label: 'Deezer', svc: 'deezer' }, + { key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' }, + { key: 'discogs_id', label: 'Discogs', svc: 'discogs' }, + { key: 'itunes_album_id', label: 'iTunes', svc: 'itunes' }, + { key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' }, + ]; + idFields.forEach(f => { + if (album[f.key]) { + ids.appendChild(makeClickableBadge(f.svc, 'album', album[f.key], f.label)); + } + }); + if (ids.children.length > 0) info.appendChild(ids); + + // Resolve artist name for enrichment calls + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + + // Match status chips (clickable to rematch) + const statusRow = document.createElement('div'); + statusRow.className = 'enhanced-match-status-row compact'; + const statusSvcs = [ + { key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' }, + { key: 'musicbrainz_match_status', label: 'MB', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' }, + { key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' }, + { key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' }, + { key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' }, + { key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' }, + { key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' }, + ]; + statusSvcs.forEach(s => { + const status = album[s.key]; + const attempted = album[s.attempted]; + const chip = document.createElement('span'); + chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`; + chip.textContent = `${s.label}: ${status || '—'}`; + const tipParts = []; + if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`); + tipParts.push('Click to rematch'); + chip.title = tipParts.join(' · '); + chip.onclick = (e) => { + e.stopPropagation(); + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; + openManualMatchModal('album', album.id, s.svc, album.title || '', aId); + }; + statusRow.appendChild(chip); + }); + info.appendChild(statusRow); + + // Action buttons row + const enrichRow = document.createElement('div'); + enrichRow.className = 'enhanced-expanded-actions'; + + if (isEnhancedAdmin()) { + const albumEnrichWrap = document.createElement('div'); + albumEnrichWrap.className = 'enhanced-enrich-wrap'; + const albumEnrichBtn = document.createElement('button'); + albumEnrichBtn.className = 'enhanced-enrich-btn small'; + albumEnrichBtn.textContent = 'Enrich Album ▾'; + albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); }; + albumEnrichWrap.appendChild(albumEnrichBtn); + const albumEnrichMenu = document.createElement('div'); + albumEnrichMenu.className = 'enhanced-enrich-menu'; + [ + { id: 'spotify', label: 'Spotify', icon: '🟢' }, + { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, + { id: 'deezer', label: 'Deezer', icon: '🟣' }, + { id: 'discogs', label: 'Discogs', icon: '🟤' }, + { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, + { id: 'itunes', label: 'iTunes', icon: '🔴' }, + { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, + { id: 'genius', label: 'Genius', icon: '🟡' }, + ].forEach(svc => { + const item = document.createElement('div'); + item.className = 'enhanced-enrich-menu-item'; + item.textContent = `${svc.icon} ${svc.label}`; + item.onclick = (e) => { + e.stopPropagation(); + albumEnrichMenu.classList.remove('visible'); + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; + runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId); + }; + albumEnrichMenu.appendChild(item); + }); + albumEnrichWrap.appendChild(albumEnrichMenu); + enrichRow.appendChild(albumEnrichWrap); + + const writeTagsBtn = document.createElement('button'); + writeTagsBtn.className = 'enhanced-write-tags-album-btn'; + writeTagsBtn.innerHTML = '✎ Write All Tags'; + writeTagsBtn.title = 'Write DB metadata to file tags for all tracks in this album'; + writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); }; + enrichRow.appendChild(writeTagsBtn); + + const rgAlbumBtn = document.createElement('button'); + rgAlbumBtn.className = 'enhanced-rg-album-btn'; + rgAlbumBtn.innerHTML = '♫ ReplayGain'; + rgAlbumBtn.title = 'Analyze ReplayGain for all tracks in this album (writes track + album gain)'; + rgAlbumBtn.dataset.albumId = album.id; + rgAlbumBtn.onclick = (e) => { e.stopPropagation(); analyzeAlbumReplayGain(album.id, rgAlbumBtn); }; + enrichRow.appendChild(rgAlbumBtn); + + const reorganizeBtn = document.createElement('button'); + reorganizeBtn.className = 'enhanced-reorganize-album-btn'; + reorganizeBtn.innerHTML = '📁 Reorganize'; + reorganizeBtn.title = 'Reorganize album files using your configured download template'; + reorganizeBtn.dataset.albumId = String(album.id); + reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); }; + enrichRow.appendChild(reorganizeBtn); + + const redownloadBtn = document.createElement('button'); + redownloadBtn.className = 'enhanced-redownload-album-btn'; + redownloadBtn.innerHTML = '↻ Redownload'; + redownloadBtn.title = 'Redownload this album (opens Download Missing modal with force-download)'; + redownloadBtn.onclick = (e) => { + e.stopPropagation(); + const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + redownloadLibraryAlbum(album, aName, redownloadBtn); + }; + enrichRow.appendChild(redownloadBtn); + + const deleteAlbumBtn = document.createElement('button'); + deleteAlbumBtn.className = 'enhanced-delete-album-btn'; + deleteAlbumBtn.textContent = 'Delete Album'; + deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); }; + enrichRow.appendChild(deleteAlbumBtn); + } + + // Report Issue button (available to all users) + const reportBtn = document.createElement('button'); + reportBtn.className = 'enhanced-report-issue-btn'; + reportBtn.innerHTML = '⚑ Report Issue'; + reportBtn.title = 'Report a problem with this album'; + reportBtn.onclick = (e) => { + e.stopPropagation(); + const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + showReportIssueModal('album', album.id, album.title || '', aName); + }; + enrichRow.appendChild(reportBtn); + + info.appendChild(enrichRow); + + header.appendChild(info); + return header; +} + +function renderAlbumMetaRow(album) { + const row = document.createElement('div'); + row.className = 'enhanced-album-meta-row'; + row.id = `enhanced-album-meta-${album.id}`; + + const fields = [ + { key: 'title', label: 'Title', value: album.title || '' }, + { key: 'year', label: 'Year', value: album.year || '', type: 'number' }, + { key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') }, + { key: 'label', label: 'Label', value: album.label || '' }, + { key: 'style', label: 'Style', value: album.style || '' }, + { key: 'mood', label: 'Mood', value: album.mood || '' }, + { key: 'record_type', label: 'Type', value: album.record_type || 'album' }, + { key: 'explicit', label: 'Explicit', value: album.explicit ? '1' : '0' }, + ]; + + const admin = isEnhancedAdmin(); + fields.forEach(f => { + const fieldDiv = document.createElement('div'); + fieldDiv.className = 'enhanced-album-meta-field'; + const label = document.createElement('label'); + label.className = 'enhanced-album-meta-label'; + label.textContent = f.label; + fieldDiv.appendChild(label); + if (admin) { + const input = document.createElement('input'); + input.className = 'enhanced-album-meta-input'; + input.type = f.type || 'text'; + input.dataset.albumId = album.id; + input.dataset.field = f.key; + input.value = String(f.value); + input.addEventListener('click', e => e.stopPropagation()); + fieldDiv.appendChild(input); + } else { + const span = document.createElement('span'); + span.className = 'enhanced-album-meta-value'; + span.textContent = String(f.value) || '—'; + fieldDiv.appendChild(span); + } + row.appendChild(fieldDiv); + }); + + if (admin) { + const saveDiv = document.createElement('div'); + saveDiv.className = 'enhanced-album-meta-field'; + const spacer = document.createElement('label'); + spacer.className = 'enhanced-album-meta-label'; + spacer.innerHTML = ' '; + saveDiv.appendChild(spacer); + const saveBtn = document.createElement('button'); + saveBtn.className = 'enhanced-album-save-btn'; + saveBtn.textContent = 'Save Album'; + saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); }; + saveDiv.appendChild(saveBtn); + row.appendChild(saveDiv); + } + + return row; +} + +function _buildTrackRow(track, album, admin) { + const tr = document.createElement('tr'); + tr.dataset.trackId = track.id; + tr.dataset.albumId = album.id; + if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected'); + + // Checkbox (admin only) + if (admin) { + const cbTd = document.createElement('td'); + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'enhanced-track-checkbox'; + cb.checked = artistDetailPageState.selectedTracks.has(String(track.id)); + cbTd.appendChild(cb); + tr.appendChild(cbTd); + } + + // Play button + const playTd = document.createElement('td'); + playTd.className = 'col-play'; + const playBtn = document.createElement('button'); + playBtn.className = 'enhanced-play-btn'; + playBtn.innerHTML = '▶'; + playBtn.title = track.file_path ? 'Play track' : 'No file available'; + if (!track.file_path) playBtn.disabled = true; + playTd.appendChild(playBtn); + tr.appendChild(playTd); + + // Track number + const numTd = document.createElement('td'); + numTd.className = 'col-num' + (admin ? ' editable' : ''); + numTd.textContent = track.track_number || '-'; + tr.appendChild(numTd); + + // Disc number + const discTd = document.createElement('td'); + discTd.className = 'col-disc'; + discTd.textContent = track.disc_number || '-'; + tr.appendChild(discTd); + + // Title + const titleTd = document.createElement('td'); + titleTd.className = 'col-title' + (admin ? ' editable' : ''); + titleTd.textContent = track.title || 'Unknown'; + tr.appendChild(titleTd); + + // Duration + const durTd = document.createElement('td'); + durTd.className = 'col-duration'; + durTd.textContent = formatDurationMs(track.duration); + tr.appendChild(durTd); + + // Format + const fmtTd = document.createElement('td'); + fmtTd.className = 'col-format'; + const format = extractFormat(track.file_path); + const fmtSpan = document.createElement('span'); + const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other'); + fmtSpan.className = `enhanced-format-badge ${fmtClass}`; + fmtSpan.textContent = format; + fmtTd.appendChild(fmtSpan); + tr.appendChild(fmtTd); + + // Bitrate + const brTd = document.createElement('td'); + brTd.className = 'col-bitrate'; + const brSpan = document.createElement('span'); + const brClass = (track.bitrate || 0) >= 320 ? 'high' : ((track.bitrate || 0) >= 192 ? 'medium' : 'low'); + brSpan.className = `enhanced-bitrate ${brClass}`; + brSpan.textContent = track.bitrate ? track.bitrate + ' kbps' : '-'; + brTd.appendChild(brSpan); + tr.appendChild(brTd); + + // BPM + const bpmTd = document.createElement('td'); + bpmTd.className = 'col-bpm' + (admin ? ' editable' : ''); + bpmTd.textContent = track.bpm || '-'; + tr.appendChild(bpmTd); + + // File path + const pathTd = document.createElement('td'); + pathTd.className = 'col-path'; + const filePath = track.file_path || '-'; + const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-'; + pathTd.textContent = fileName; + pathTd.title = filePath; + tr.appendChild(pathTd); + + // Match status chips + const matchTd = document.createElement('td'); + matchTd.className = 'col-match'; + const matchCell = document.createElement('div'); + matchCell.className = 'enhanced-track-match-cell'; + const trackServices = [ + { svc: 'spotify', col: 'spotify_track_id', label: 'SP' }, + { svc: 'musicbrainz', col: 'musicbrainz_recording_id', label: 'MB' }, + { svc: 'deezer', col: 'deezer_id', label: 'Dz' }, + { svc: 'audiodb', col: 'audiodb_id', label: 'ADB' }, + { svc: 'itunes', col: 'itunes_track_id', label: 'iT' }, + { svc: 'lastfm', col: 'lastfm_url', label: 'LFM' }, + { svc: 'genius', col: 'genius_id', label: 'Gen' }, + ]; + trackServices.forEach(s => { + const hasId = !!track[s.col]; + const chip = document.createElement('span'); + chip.className = 'enhanced-track-match-chip' + (hasId ? ' matched' : ' not-found'); + chip.textContent = s.label; + chip.title = hasId ? `${s.svc}: ${track[s.col]}` : `${s.svc}: no match`; + chip.dataset.service = s.svc; + matchCell.appendChild(chip); + }); + matchTd.appendChild(matchCell); + tr.appendChild(matchTd); + + // Add to Queue button + const queueTd = document.createElement('td'); + queueTd.className = 'col-queue'; + if (track.file_path) { + const queueBtn = document.createElement('button'); + queueBtn.className = 'enhanced-queue-btn'; + queueBtn.innerHTML = '+'; + queueBtn.title = 'Add to queue'; + queueTd.appendChild(queueBtn); + } + tr.appendChild(queueTd); + + if (admin) { + // Write Tags button (admin only) + const tagTd = document.createElement('td'); + tagTd.className = 'col-writetag'; + if (track.file_path) { + const tagBtn = document.createElement('button'); + tagBtn.className = 'enhanced-write-tag-btn'; + tagBtn.innerHTML = '✎'; + tagBtn.title = 'Write tags to file'; + tagTd.appendChild(tagBtn); + + const rgBtn = document.createElement('button'); + rgBtn.className = 'enhanced-rg-btn'; + rgBtn.textContent = 'RG'; + rgBtn.title = 'Analyze & write ReplayGain (track gain)'; + tagTd.appendChild(rgBtn); + } + tr.appendChild(tagTd); + + // Track actions cell — source info, redownload, delete (admin only) + const actionsTd = document.createElement('td'); + actionsTd.className = 'col-track-actions'; + actionsTd.innerHTML = ` +
    + + + +
    + `; + tr.appendChild(actionsTd); + } else { + // Report Issue button per track (non-admin) + const reportTd = document.createElement('td'); + reportTd.className = 'col-report'; + const reportBtn = document.createElement('button'); + reportBtn.className = 'enhanced-track-report-btn'; + reportBtn.innerHTML = '⚑'; + reportBtn.title = 'Report issue with this track'; + reportTd.appendChild(reportBtn); + tr.appendChild(reportTd); + } + + // Mobile actions column (visible only on mobile via CSS) + const mobileTd = document.createElement('td'); + mobileTd.className = 'col-mobile-actions'; + const mobileBtn = document.createElement('button'); + mobileBtn.className = 'enhanced-mobile-actions-btn'; + mobileBtn.innerHTML = '⋯'; + mobileBtn.title = 'Actions'; + mobileTd.appendChild(mobileBtn); + tr.appendChild(mobileTd); + + return tr; +} + +function _getTrackDataFromRow(tr) { + const trackId = tr.dataset.trackId; + const albumId = tr.dataset.albumId; + const album = findEnhancedAlbum(albumId); + if (!album) return null; + const track = (album.tracks || []).find(t => String(t.id) === String(trackId)); + return track ? { track, album, trackId, albumId } : null; +} + +function _attachTableDelegation(table, album) { + // Single click handler for the entire table — replaces 12-16 per-row handlers + const admin = isEnhancedAdmin(); + table.addEventListener('click', (e) => { + const target = e.target; + const tr = target.closest('tr[data-track-id]'); + + // Header checkbox (select all) + if (target.closest('thead') && target.classList.contains('enhanced-track-checkbox')) { + toggleSelectAllTracks(album.id, target.checked); + return; + } + + // Sort header click + const th = target.closest('th[data-sort-field]'); + if (th) { + cancelInlineEdit(); + const sortField = th.dataset.sortField; + const current = artistDetailPageState.enhancedTrackSort[album.id]; + const ascending = current && current.field === sortField ? !current.ascending : true; + artistDetailPageState.enhancedTrackSort[album.id] = { field: sortField, ascending }; + sortEnhancedTracks(album, sortField, ascending); + _rebuildTbody(table, album); + // Update header sort indicators + table.querySelectorAll('th[data-sort-field]').forEach(h => { + const sf = h.dataset.sortField; + const baseLabel = h.dataset.label || ''; + const sort = artistDetailPageState.enhancedTrackSort[album.id]; + h.textContent = sort && sort.field === sf ? baseLabel + (sort.ascending ? ' \u25B2' : ' \u25BC') : baseLabel; + }); + return; + } + + if (!tr) return; + const info = _getTrackDataFromRow(tr); + if (!info) return; + const { track, trackId } = info; + + // Checkbox + if (target.classList.contains('enhanced-track-checkbox')) { + toggleTrackSelection(String(trackId)); + return; + } + + // Play button + if (target.closest('.enhanced-play-btn')) { + e.stopPropagation(); + if (track.file_path) { + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + playLibraryTrack(track, album.title || '', artistName); + } + return; + } + + // Inline editable cells (admin) + if (admin) { + const cell = target.closest('td.editable'); + if (cell) { + e.stopPropagation(); + if (cell.classList.contains('col-num')) { + startInlineEdit(cell, 'track', track.id, 'track_number', track.track_number || ''); + } else if (cell.classList.contains('col-title')) { + startInlineEdit(cell, 'track', track.id, 'title', track.title || ''); + } else if (cell.classList.contains('col-bpm')) { + startInlineEdit(cell, 'track', track.id, 'bpm', track.bpm || ''); + } + return; + } + } + + // Match chip click (admin — open manual match modal) + if (admin) { + const chip = target.closest('.enhanced-track-match-chip'); + if (chip) { + e.stopPropagation(); + const svc = chip.dataset.service; + const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null; + openManualMatchModal('track', track.id, svc, track.title || '', aId); + return; + } + } + + // Queue button + if (target.closest('.enhanced-queue-btn')) { + e.stopPropagation(); + if (track.file_path) { + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + let albumArt = album.thumb_url || null; + if (!albumArt && artistDetailPageState.enhancedData) { + albumArt = artistDetailPageState.enhancedData.artist?.thumb_url; + } + addToQueue({ + title: track.title || 'Unknown Track', + artist: artistName || 'Unknown Artist', + album: album.title || 'Unknown Album', + file_path: track.file_path, + filename: track.file_path, + is_library: true, + image_url: albumArt, + id: track.id, + artist_id: artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null, + album_id: album.id, + bitrate: track.bitrate, + sample_rate: track.sample_rate + }); + } + return; + } + + // Write tags button (admin) + if (target.closest('.enhanced-write-tag-btn')) { + e.stopPropagation(); + showTagPreview(track.id); + return; + } + + // ReplayGain analyze button (admin) + if (target.closest('.enhanced-rg-btn')) { + e.stopPropagation(); + analyzeTrackReplayGain(track.id, target.closest('.enhanced-rg-btn')); + return; + } + + // Source info button (admin) + if (target.closest('.enhanced-source-info-btn')) { + e.stopPropagation(); + showTrackSourceInfo(track, target.closest('.enhanced-source-info-btn')); + return; + } + + // Redownload button (admin) + if (target.closest('.enhanced-redownload-btn')) { + e.stopPropagation(); + showTrackRedownloadModal(track, album); + return; + } + + // Delete button (admin) + if (target.closest('.enhanced-delete-btn')) { + e.stopPropagation(); + deleteLibraryTrack(track.id, album.id); + return; + } + + // Report button (non-admin) + if (target.closest('.enhanced-track-report-btn')) { + e.stopPropagation(); + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + showReportIssueModal('track', track.id, track.title || 'Unknown', artistName, album.title || ''); + return; + } + + // Mobile actions button (⋯) + if (target.closest('.enhanced-mobile-actions-btn')) { + e.stopPropagation(); + _showMobileTrackActions(track, album); + return; + } + }); +} + +function _showMobileTrackActions(track, album) { + // Remove any existing popover + document.querySelectorAll('.mobile-popover-overlay, .enhanced-mobile-actions-popover').forEach(el => el.remove()); + + const overlay = document.createElement('div'); + overlay.className = 'mobile-popover-overlay'; + + const popover = document.createElement('div'); + popover.className = 'enhanced-mobile-actions-popover'; + + const title = document.createElement('div'); + title.className = 'popover-title'; + title.textContent = track.title || 'Track'; + popover.appendChild(title); + + const admin = isEnhancedAdmin(); + const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; + const albumArt = album.thumb_url || (artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist?.thumb_url : null); + + const actions = []; + if (track.file_path) { + actions.push({ + icon: '▶', label: 'Play', action: () => { + playLibraryTrack({ id: track.id, title: track.title, file_path: track.file_path, bitrate: track.bitrate, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id }, album.title || '', artistName); + } + }); + actions.push({ + icon: '+', label: 'Add to Queue', action: () => { + addToQueue({ title: track.title || 'Unknown', artist: artistName, album: album.title || '', file_path: track.file_path, filename: track.file_path, is_library: true, image_url: albumArt, id: track.id, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id, bitrate: track.bitrate }); + } + }); + } + if (admin && track.file_path) { + actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) }); + } + if (admin) { + actions.push({ icon: 'ℹ', label: 'Source Info', action: () => showTrackSourceInfo(track, null) }); + actions.push({ icon: '↻', label: 'Redownload Track', action: () => showTrackRedownloadModal(track, album) }); + actions.push({ icon: '✕', label: 'Delete Track', cls: 'popover-delete', action: () => deleteLibraryTrack(track.id, album.id) }); + } + + actions.forEach(a => { + const btn = document.createElement('button'); + if (a.cls) btn.className = a.cls; + btn.innerHTML = `${a.icon}${a.label}`; + btn.addEventListener('click', () => { close(); a.action(); }); + popover.appendChild(btn); + }); + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'popover-cancel'; + cancelBtn.textContent = 'Cancel'; + cancelBtn.addEventListener('click', close); + popover.appendChild(cancelBtn); + + function close() { + overlay.remove(); + popover.remove(); + } + overlay.addEventListener('click', close); + + document.body.appendChild(overlay); + document.body.appendChild(popover); +} + +function _rebuildTbody(table, album) { + // Replace only the tbody — keeps thead and event delegation intact + const admin = isEnhancedAdmin(); + const oldTbody = table.querySelector('tbody'); + const newTbody = document.createElement('tbody'); + (album.tracks || []).forEach(track => { + newTbody.appendChild(_buildTrackRow(track, album, admin)); + }); + if (oldTbody) table.replaceChild(newTbody, oldTbody); + else table.appendChild(newTbody); +} + +function renderTrackTable(album) { + const wrapper = document.createElement('div'); + const tracks = album.tracks || []; + + // Re-apply stored sort order if any + const activeSort = artistDetailPageState.enhancedTrackSort[album.id]; + if (activeSort) { + sortEnhancedTracks(album, activeSort.field, activeSort.ascending); + } + + if (tracks.length === 0) { + wrapper.innerHTML = '
    No tracks in database
    '; + return wrapper; + } + + const table = document.createElement('table'); + table.className = 'enhanced-track-table'; + table.dataset.albumId = album.id; + + const admin = isEnhancedAdmin(); + // Clear stale selections for non-admin to prevent ghost state + if (!admin) { + artistDetailPageState.selectedTracks.clear(); + } + + // Header + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + if (admin) { + const selectAllTh = document.createElement('th'); + const selectAllCb = document.createElement('input'); + selectAllCb.type = 'checkbox'; + selectAllCb.className = 'enhanced-track-checkbox'; + selectAllTh.appendChild(selectAllCb); + headRow.appendChild(selectAllTh); + } + + const columns = [ + { label: '', cls: 'col-play' }, + { label: '#', cls: 'col-num', sortField: 'track_number' }, + { label: 'Disc', cls: 'col-disc', sortField: 'disc_number' }, + { label: 'Title', cls: 'col-title', sortField: 'title' }, + { label: 'Duration', cls: 'col-duration', sortField: 'duration' }, + { label: 'Format', cls: 'col-format', sortField: 'format' }, + { label: 'Bitrate', cls: 'col-bitrate', sortField: 'bitrate' }, + { label: 'BPM', cls: 'col-bpm', sortField: 'bpm' }, + { label: 'File', cls: 'col-path' }, + { label: 'Match', cls: 'col-match' }, + { label: '', cls: 'col-queue' }, + ...(admin ? [ + { label: '', cls: 'col-writetag' }, + { label: '', cls: 'col-delete' }, + ] : [ + { label: '', cls: 'col-report' }, + ]), + { label: '', cls: 'col-mobile-actions' }, + ]; + const currentSort = artistDetailPageState.enhancedTrackSort[album.id]; + columns.forEach(col => { + const th = document.createElement('th'); + th.className = col.cls; + if (col.sortField) { + let headerText = col.label; + if (currentSort && currentSort.field === col.sortField) { + headerText += currentSort.ascending ? ' \u25B2' : ' \u25BC'; + } + th.textContent = headerText; + th.style.cursor = 'pointer'; + th.dataset.sortField = col.sortField; + th.dataset.label = col.label; + } else { + th.textContent = col.label; + } + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + + // Body + const tbody = document.createElement('tbody'); + tracks.forEach(track => { + tbody.appendChild(_buildTrackRow(track, album, admin)); + }); + table.appendChild(tbody); + + // Single delegated event listener for the whole table + _attachTableDelegation(table, album); + + wrapper.appendChild(table); + return wrapper; +} + +function sortEnhancedTracks(album, field, ascending) { + const tracks = album.tracks || []; + tracks.sort((a, b) => { + let valA, valB; + if (field === 'format') { + valA = extractFormat(a.file_path); + valB = extractFormat(b.file_path); + } else { + valA = a[field]; + valB = b[field]; + } + if (valA == null) return 1; + if (valB == null) return -1; + if (['track_number', 'disc_number', 'bpm', 'bitrate', 'duration'].includes(field)) { + return ascending ? (Number(valA) - Number(valB)) : (Number(valB) - Number(valA)); + } + valA = String(valA).toLowerCase(); + valB = String(valB).toLowerCase(); + return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); + }); +} + +async function deleteLibraryTrack(trackId, albumId) { + cancelInlineEdit(); + + // Smart delete dialog — three options + const choice = await _showSmartDeleteDialog(); + if (!choice) return; + + const params = new URLSearchParams(); + if (choice === 'delete_file') params.set('delete_file', 'true'); + + try { + const response = await fetch(`/api/library/track/${trackId}?${params}`, { method: 'DELETE' }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + let msg = 'Track removed from library'; + let toastType = 'success'; + if (result.file_deleted) { + msg = 'Track deleted from library and disk'; + } else if (result.file_error) { + msg = 'Track removed from library but file could not be deleted'; + toastType = 'warning'; + } + if (result.blacklisted) msg += ' (source blacklisted)'; + showToast(msg, toastType); + if (result.file_error) { + showToast(result.file_error, 'error', 8000); + } + + if (artistDetailPageState.enhancedData) { + const albums = artistDetailPageState.enhancedData.albums || []; + const album = albums.find(a => a.id === albumId); + if (album) { + album.tracks = (album.tracks || []).filter(t => t.id !== trackId); + } + } + artistDetailPageState.selectedTracks.delete(String(trackId)); + renderEnhancedView(); + } catch (error) { + showToast(`Delete failed: ${error.message}`, 'error'); + } +} + +function _showSmartDeleteDialog() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = '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;'; + + const close = (val) => { overlay.remove(); resolve(val); }; + overlay.onclick = e => { if (e.target === overlay) close(null); }; + + overlay.innerHTML = ` +
    +
    +

    Delete Track

    + +
    +

    How should this track be deleted?

    +
    + + + +
    +
    + `; + + overlay.querySelectorAll('.smart-delete-option').forEach(btn => { + btn.addEventListener('click', () => close(btn.dataset.choice)); + }); + overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); + + // Escape to close + const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } }; + document.addEventListener('keydown', escHandler); + + document.body.appendChild(overlay); + }); +} + +// ================================================================================== +// TRACK SOURCE INFO — View download provenance and blacklist sources +// ================================================================================== + +async function showTrackSourceInfo(track, anchorEl) { + // Remove existing popover + const existing = document.getElementById('source-info-popover'); + if (existing) existing.remove(); + + const popover = document.createElement('div'); + popover.id = 'source-info-popover'; + popover.className = 'source-info-popover'; + popover.innerHTML = '
    Loading source info...
    '; + + document.body.appendChild(popover); + + // Position near the button or center on mobile + if (anchorEl) { + const rect = anchorEl.getBoundingClientRect(); + const popW = 360; + let left = rect.left - popW - 8; + if (left < 10) left = rect.right + 8; + let top = rect.top - 20; + if (top + 300 > window.innerHeight) top = window.innerHeight - 310; + popover.style.left = `${left}px`; + popover.style.top = `${Math.max(10, top)}px`; + } else { + popover.style.left = '50%'; + popover.style.top = '50%'; + popover.style.transform = 'translate(-50%, -50%)'; + } + + requestAnimationFrame(() => popover.classList.add('visible')); + + // Close on outside click + const closeHandler = e => { + if (!popover.contains(e.target) && e.target !== anchorEl) { + popover.remove(); + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 100); + + // Escape to close + const escH = e => { if (e.key === 'Escape') { popover.remove(); document.removeEventListener('keydown', escH); document.removeEventListener('click', closeHandler); } }; + document.addEventListener('keydown', escH); + + try { + const res = await fetch(`/api/library/track/${track.id}/source-info`); + const data = await res.json(); + + if (!data.success || !data.downloads || data.downloads.length === 0) { + popover.innerHTML = ` +
    + Source Info + +
    +
    No download source data available for this track. Source tracking starts with new downloads.
    + `; + return; + } + + const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer: '💜' }; + const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer: 'Deezer' }; + + const dl = data.downloads[0]; // Most recent download + const icon = serviceIcons[dl.source_service] || '📦'; + const label = serviceLabels[dl.source_service] || dl.source_service; + const displayFile = dl.source_filename ? dl.source_filename.replace(/\\/g, '/').split('/').pop() : 'Unknown'; + const sizeStr = dl.source_size ? `${(dl.source_size / 1048576).toFixed(1)} MB` : ''; + const dateStr = dl.created_at ? timeAgo(dl.created_at) : ''; + + popover.innerHTML = ` +
    + Source Info + +
    +
    +
    + Service + ${icon} ${label} +
    + ${dl.source_service === 'soulseek' && dl.source_username ? `
    + User + ${_esc(dl.source_username)} +
    ` : ''} +
    + Original File + ${_esc(displayFile)} +
    + ${sizeStr ? `
    + Size + ${sizeStr} +
    ` : ''} + ${dl.audio_quality ? `
    + Quality + ${_esc(dl.audio_quality)} +
    ` : ''} + ${dl.bit_depth || dl.sample_rate || dl.bitrate ? `
    + Audio + ${[dl.bit_depth ? `${dl.bit_depth}-bit` : '', dl.sample_rate ? `${(dl.sample_rate / 1000).toFixed(1)}kHz` : '', dl.bitrate ? `${Math.round(dl.bitrate / 1000)}kbps` : ''].filter(Boolean).join(' · ')} +
    ` : ''} + ${dateStr ? `
    + Downloaded + ${dateStr} +
    ` : ''} + ${dl.status !== 'completed' ? `
    + Status + ${dl.status} +
    ` : ''} +
    + ${dl.source_username && dl.source_filename ? ` +
    + +
    ` : ''} + ${data.downloads.length > 1 ? `
    ${data.downloads.length} download records for this track
    ` : ''} + `; + + // Blacklist button handler + const blBtn = document.getElementById('source-info-blacklist-btn'); + if (blBtn) { + blBtn.addEventListener('click', async () => { + if (!await showConfirmDialog({ title: 'Blacklist Source', message: `Blacklist "${displayFile}" from ${dl.source_service === 'soulseek' ? dl.source_username : label}? This source will be skipped in future downloads.`, confirmText: 'Blacklist', destructive: true })) return; + + try { + const db_res = await fetch('/api/library/blacklist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_title: dl.track_title || track.title, + track_artist: dl.track_artist || '', + blocked_filename: dl.source_filename, + blocked_username: dl.source_username, + reason: 'user_rejected' + }) + }); + const result = await db_res.json(); + if (result.success) { + showToast('Source blacklisted', 'success'); + blBtn.disabled = true; + blBtn.textContent = '⛔ Blacklisted'; + } else { + showToast(result.error || 'Failed to blacklist', 'error'); + } + } catch (e) { + showToast('Error: ' + e.message, 'error'); + } + }); + } + + } catch (e) { + popover.innerHTML = `
    Error loading source info: ${_esc(e.message)}
    `; + } +} + + +// ================================================================================== +// TRACK REDOWNLOAD MODAL — Multi-step: metadata selection → source selection → download +// ================================================================================== + +async function showTrackRedownloadModal(track, album) { + const overlay = document.createElement('div'); + overlay.id = 'redownload-overlay'; + overlay.className = 'redownload-overlay'; + overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; + + const artistName = artistDetailPageState.enhancedData?.artist?.name || ''; + const ext = (track.file_path || '').split('.').pop().toUpperCase(); + const fmt = ['FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV'].includes(ext) ? ext : ''; + + overlay.innerHTML = ` +
    +
    +
    +

    Redownload Track

    +

    Find the correct version and download from your preferred source

    +
    + +
    +
    +
    +
    🎵
    +
    +
    +
    ${_esc(track.title)}
    +
    ${_esc(artistName)} · ${_esc(album?.title || '')}
    +
    +
    + ${fmt ? `${fmt}` : ''} + ${track.bitrate ? `${track.bitrate}k` : ''} +
    +
    +
    +
    1 Choose Metadata
    +
    +
    2 Choose Source
    +
    +
    3 Downloading
    +
    +
    +
    +
    + Searching metadata sources... +
    +
    +
    + `; + + // Escape to close + const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); overlay.remove(); } }; + document.addEventListener('keydown', escH); + + document.body.appendChild(overlay); + + // Auto-search metadata + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/search-metadata`, { method: 'POST' }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + + // Set album art in header if available + const artEl = document.getElementById('redownload-current-art'); + if (artEl && data.current_track?.thumb_url) { + artEl.innerHTML = ``; + } + + _renderRedownloadStep1(overlay, track, data); + } catch (e) { + document.getElementById('redownload-body').innerHTML = `
    Error: ${_esc(e.message)}
    `; + } +} + +function _renderRedownloadStep1(overlay, track, data) { + const body = document.getElementById('redownload-body'); + if (!body) return; + + const sources = Object.keys(data.metadata_results); + if (sources.length === 0) { + body.innerHTML = '
    No metadata sources available. Check your Spotify/iTunes/Deezer connections.
    '; + return; + } + + const bestSource = data.best_match?.source || sources[0]; + const sourceIcons = { spotify: '🟢', itunes: '🍎', deezer: '🟣', hydrabase: '🔷' }; + const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; + + // Build columns — one per source, side by side + const columnsHtml = sources.map(source => { + const results = data.metadata_results[source] || []; + const icon = sourceIcons[source] || '📋'; + const label = sourceLabels[source] || source; + + let itemsHtml; + if (results.length === 0) { + itemsHtml = `
    No results
    `; + } else { + itemsHtml = results.slice(0, 8).map((r, i) => { + const pct = Math.round((r.match_score || 0) * 100); + const cls = pct >= 90 ? 'high' : pct >= 70 ? 'medium' : 'low'; + const dur = r.duration_ms ? `${Math.floor(r.duration_ms / 60000)}:${String(Math.floor((r.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + const checked = (source === bestSource && i === 0) ? 'checked' : ''; + return ` + `; + }).join(''); + } + + return ` +
    +
    + ${icon} + ${label} + ${results.length} +
    +
    ${itemsHtml}
    +
    `; + }).join(''); + + body.innerHTML = `
    ${columnsHtml}
    `; + + // Add sticky footer for Step 1 + const modal = overlay.querySelector('.redownload-modal'); + const oldFooter = modal.querySelector('.redownload-sticky-footer'); + if (oldFooter) oldFooter.remove(); + const footer = document.createElement('div'); + footer.className = 'redownload-sticky-footer'; + footer.innerHTML = ` +
    + + +
    + `; + modal.appendChild(footer); + + // Next button + document.getElementById('redownload-next-btn').addEventListener('click', async () => { + const checked = body.querySelector('input[name="metadata-choice"]:checked'); + if (!checked) { showToast('Select a metadata source first', 'error'); return; } + const [source, idx] = checked.value.split('|'); + selectedMeta = data.metadata_results[source][parseInt(idx)]; + selectedMeta._source = source; + + // Update step indicator + overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); + overlay.querySelector('.redownload-step[data-step="2"]').classList.add('active'); + + // Stream results from all download sources — columns appear as each source responds + // Body gets the scrollable content, footer is sticky outside the scroll + body.innerHTML = ` +
    +
    Searching download sources...
    +
    + `; + // Add sticky footer outside the scrollable body + const existingFooter = overlay.querySelector('.redownload-sticky-footer'); + if (existingFooter) existingFooter.remove(); + const modal = overlay.querySelector('.redownload-modal'); + const footer = document.createElement('div'); + footer.className = 'redownload-sticky-footer'; + footer.innerHTML = ` + +
    + + +
    + `; + modal.appendChild(footer); + + // Wire up download button IMMEDIATELY (before streaming starts) + // so it works as soon as results appear + window._redownloadCandidates = []; + window._redownloadMetadata = selectedMeta; + document.getElementById('redownload-start-btn').addEventListener('click', async () => { + const checked = document.querySelector('input[name="source-choice"]:checked'); + if (!checked) { showToast('Select a download source', 'error'); return; } + const cand = window._redownloadCandidates[parseInt(checked.value)]; + if (!cand) { showToast('Invalid selection', 'error'); return; } + const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; + + overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); + overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); + + // Remove sticky footer for step 3 + const ft = overlay.querySelector('.redownload-sticky-footer'); + if (ft) ft.remove(); + + const body = document.getElementById('redownload-body'); + body.innerHTML = ` +
    +
    Downloading: ${_esc(cand.display_name)}
    +
    from ${_esc(cand.source_service === 'soulseek' ? cand.username : (cand.source_service || 'unknown'))}
    +
    +
    Starting download...
    +
    + `; + + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata: window._redownloadMetadata, candidate: cand, delete_old_file: deleteOld }) + }); + const startData = await res.json(); + if (!startData.success) throw new Error(startData.error); + _pollRedownloadProgress(startData.task_id, overlay); + } catch (e) { + body.innerHTML = `
    Download failed: ${_esc(e.message)}
    `; + } + }); + + _streamRedownloadSources(overlay, track, selectedMeta); + }); +} + +async function _streamRedownloadSources(overlay, track, metadata) { + const columnsEl = document.getElementById('rdl-src-columns'); + const loadingEl = document.getElementById('rdl-src-loading'); + const startBtn = document.getElementById('redownload-start-btn'); + if (!columnsEl) return; + + const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' }; + const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' }; + + let allCandidates = []; + let firstResult = true; + let bestGlobalIdx = -1; + + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/search-sources`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata }) + }); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + const lines = buffer.split('\n'); + buffer = lines.pop(); // keep incomplete line + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + if (data.done) continue; + + const svc = data.source; + const candidates = data.candidates || []; + + // Remove loading spinner on first result + if (firstResult && loadingEl) { loadingEl.remove(); firstResult = false; } + + // Assign global indices + const startIdx = allCandidates.length; + candidates.forEach((c, i) => { c._globalIdx = startIdx + i; }); + allCandidates.push(...candidates); + window._redownloadCandidates = allCandidates; // Keep global ref updated for button handler + + // Find best overall candidate + bestGlobalIdx = -1; + let bestConf = 0; + allCandidates.forEach((c, i) => { + if (!c.blacklisted && c.confidence > bestConf) { bestConf = c.confidence; bestGlobalIdx = i; } + }); + + // Render column for this source + const icon = serviceIcons[svc] || '📦'; + const label = serviceLabels[svc] || svc; + + const itemsHtml = candidates.length === 0 + ? '
    No results
    ' + : candidates.slice(0, 10).map(c => { + const confPct = Math.round((c.confidence || 0) * 100); + const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; + const isRec = c._globalIdx === bestGlobalIdx; + const blClass = c.blacklisted ? ' blacklisted' : ''; + const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : ''; + return ` + `; + }).join(''); + + const colEl = document.createElement('div'); + colEl.className = 'rdl-src-col'; + colEl.style.animation = 'fadeSlideUp 0.3s ease both'; + colEl.innerHTML = ` +
    + ${icon} + ${label} + ${candidates.length} +
    +
    ${itemsHtml}
    + `; + columnsEl.appendChild(colEl); + + // Enable the download button + if (startBtn && allCandidates.some(c => !c.blacklisted)) { + startBtn.disabled = false; + startBtn.textContent = 'Download Selected'; + } + + } catch (e) { /* skip malformed lines */ } + } + } + } catch (e) { + if (loadingEl) loadingEl.innerHTML = `
    Error: ${_esc(e.message)}
    `; + } + + // If no results at all + if (allCandidates.length === 0 && loadingEl) { + loadingEl.innerHTML = '
    No download sources found for this track.
    '; + } + + // Update the shared candidates array (button handler reads from window._redownloadCandidates) + window._redownloadCandidates = allCandidates; +} + +/* _renderRedownloadStep2 removed — replaced by _streamRedownloadSources above */ +if (false) { + const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' }; + const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' }; + + // Group candidates by source service + const grouped = {}; + candidates.forEach((c, i) => { + c._origIdx = i; // preserve original index for radio value + const svc = c.source_service || 'unknown'; + if (!grouped[svc]) grouped[svc] = []; + grouped[svc].push(c); + }); + + // Build columns — one per source + const sourceColumnsHtml = Object.entries(grouped).map(([svc, items]) => { + const icon = serviceIcons[svc] || '📦'; + const label = serviceLabels[svc] || svc; + + const itemsHtml = items.slice(0, 10).map(c => { + const confPct = Math.round((c.confidence || 0) * 100); + const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; + const isRecommended = c._origIdx === bestIdx && !c.blacklisted; + const checked = isRecommended ? 'checked' : ''; + const blClass = c.blacklisted ? ' blacklisted' : ''; + const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : ''; + + return ` + `; + }).join(''); + + return ` +
    +
    + ${icon} + ${label} + ${items.length} +
    +
    ${itemsHtml}
    +
    `; + }).join(''); + + body.innerHTML = ` +
    ${sourceColumnsHtml}
    + +
    + + +
    + `; + + document.getElementById('redownload-start-btn').addEventListener('click', async () => { + const checked = body.querySelector('input[name="source-choice"]:checked'); + if (!checked) { showToast('Select a download source', 'error'); return; } + const candidate = candidates[parseInt(checked.value)]; + const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; + + // Update step indicator + overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); + overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); + + body.innerHTML = ` +
    +
    Downloading: ${_esc(candidate.display_name)}
    +
    from ${_esc(candidate.username)}
    +
    +
    Starting download...
    +
    + `; + + try { + const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ metadata, candidate, delete_old_file: deleteOld }) + }); + const startData = await res.json(); + if (!startData.success) throw new Error(startData.error); + + // Poll for progress + _pollRedownloadProgress(startData.task_id, overlay); + } catch (e) { + body.innerHTML = `
    Download failed: ${_esc(e.message)}
    `; + } + }); +} + +function _pollRedownloadProgress(taskId, overlay) { + let completed = false; + + const poll = setInterval(async () => { + if (completed) return; + + // Get fresh DOM references every tick (in case DOM was rebuilt) + const bar = document.getElementById('redownload-progress-bar'); + const status = document.getElementById('redownload-progress-status'); + + try { + // Poll real download progress from /api/downloads/status + const dlRes = await fetch('/api/downloads/status'); + const dlData = await dlRes.json(); + const transfers = dlData.transfers || []; + + // Find any active transfer + let bestTransfer = null; + for (const t of transfers) { + const st = (t.state || '').toLowerCase(); + if (st.includes('inprogress') || st.includes('queued') || st.includes('initializing')) { + bestTransfer = t; + break; + } + } + + if (bestTransfer) { + const pct = bestTransfer.percentComplete || 0; + const transferred = bestTransfer.bytesTransferred || 0; + const total = bestTransfer.size || 0; + const transferredMB = (transferred / 1048576).toFixed(1); + const totalMB = (total / 1048576).toFixed(1); + + if (bar) bar.style.width = `${Math.min(95, pct)}%`; + if (status) { + status.textContent = total > 0 + ? `Downloading... ${Math.round(pct)}% (${transferredMB} / ${totalMB} MB)` + : `Downloading... ${Math.round(pct)}%`; + } + } else { + // No active slskd transfer — streaming source or post-processing + if (bar) bar.style.width = '80%'; + if (status) status.textContent = 'Processing...'; + } + + // Check for batch completion + const procRes = await fetch('/api/active-processes'); + const procData = await procRes.json(); + const procs = procData.active_processes || []; + const ourBatch = procs.find(p => p.batch_id && p.batch_id.includes('redownload_batch_')); + + if (!ourBatch) { + completed = true; + clearInterval(poll); + if (bar) bar.style.width = '100%'; + if (status) status.textContent = 'Complete! File replaced successfully.'; + showToast('Track redownloaded successfully', 'success'); + setTimeout(() => { + overlay.remove(); + if (artistDetailPageState.enhancedData?.artist?.id) { + loadEnhancedViewData(artistDetailPageState.enhancedData.artist.id); + } + }, 2000); + } + } catch (e) { /* ignore poll errors */ } + }, 1500); + + // Safety timeout — 5 minutes + setTimeout(() => { + if (!completed) { + clearInterval(poll); + const status = document.getElementById('redownload-progress-status'); + if (status) status.textContent = 'Download may still be in progress. Check the dashboard.'; + } + }, 300000); +} + +async function deleteLibraryAlbum(albumId) { + const choice = await _showAlbumDeleteDialog(); + if (!choice) return; + + const deleteFiles = choice === 'delete_files'; + const params = deleteFiles ? '?delete_files=true' : ''; + + try { + const response = await fetch(`/api/library/album/${albumId}${params}`, { method: 'DELETE' }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + let msg = `Album removed from library (${result.tracks_deleted || 0} tracks)`; + let toastType = 'success'; + if (deleteFiles) { + if (result.files_deleted > 0) { + msg = `Album deleted — ${result.files_deleted} files removed from disk`; + } + if (result.files_failed > 0) { + msg += ` (${result.files_failed} files could not be deleted)`; + toastType = 'warning'; + } + } + showToast(msg, toastType); + + if (artistDetailPageState.enhancedData) { + const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId); + if (album && album.tracks) { + album.tracks.forEach(t => artistDetailPageState.selectedTracks.delete(String(t.id))); + } + artistDetailPageState.enhancedData.albums = (artistDetailPageState.enhancedData.albums || []).filter(a => a.id !== albumId); + _rebuildAlbumMap(); + } + artistDetailPageState.expandedAlbums.delete(albumId); + delete artistDetailPageState.enhancedTrackSort[albumId]; + renderEnhancedView(); + } catch (error) { + showToast(`Delete failed: ${error.message}`, 'error'); + } +} + +function _showAlbumDeleteDialog() { + return new Promise(resolve => { + const overlay = document.createElement('div'); + overlay.className = '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;'; + + const close = (val) => { overlay.remove(); resolve(val); }; + overlay.onclick = e => { if (e.target === overlay) close(null); }; + + overlay.innerHTML = ` +
    +
    +

    Delete Album

    + +
    +

    How should this album be deleted?

    +
    + + +
    +
    + `; + + overlay.querySelectorAll('.smart-delete-option').forEach(btn => { + btn.addEventListener('click', () => close(btn.dataset.choice)); + }); + overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); + + const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } }; + document.addEventListener('keydown', escHandler); + + document.body.appendChild(overlay); + }); +} + +function extractFormat(filePath) { + if (!filePath) return '-'; + const ext = filePath.split('.').pop().toLowerCase(); + const formatMap = { mp3: 'MP3', flac: 'FLAC', m4a: 'AAC', ogg: 'OGG', opus: 'OPUS', wav: 'WAV', wma: 'WMA', aac: 'AAC' }; + return formatMap[ext] || ext.toUpperCase(); +} + +function formatDurationMs(ms) { + if (!ms) return '-'; + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +function getServiceUrl(service, entityType, id) { + if (!id) return null; + const urls = { + spotify: { + artist: `https://open.spotify.com/artist/${id}`, + album: `https://open.spotify.com/album/${id}`, + track: `https://open.spotify.com/track/${id}`, + }, + musicbrainz: { + artist: `https://musicbrainz.org/artist/${id}`, + album: `https://musicbrainz.org/release/${id}`, + track: `https://musicbrainz.org/recording/${id}`, + }, + deezer: { + artist: `https://www.deezer.com/artist/${id}`, + album: `https://www.deezer.com/album/${id}`, + track: `https://www.deezer.com/track/${id}`, + }, + audiodb: { + artist: `https://www.theaudiodb.com/artist/${id}`, + album: `https://www.theaudiodb.com/album/${id}`, + track: `https://www.theaudiodb.com/track/${id}`, + }, + itunes: { + artist: `https://music.apple.com/artist/${id}`, + album: `https://music.apple.com/album/${id}`, + track: `https://music.apple.com/song/${id}`, + }, + lastfm: { + artist: id, // lastfm_url is already a full URL + album: id, + track: id, + }, + genius: { + artist: id, // genius_url is already a full URL + track: id, // genius_url on tracks is already a full URL + }, + tidal: { + artist: `https://tidal.com/browse/artist/${id}`, + album: `https://tidal.com/browse/album/${id}`, + track: `https://tidal.com/browse/track/${id}`, + }, + qobuz: { + artist: `https://www.qobuz.com/artist/${id}`, + album: `https://www.qobuz.com/album/${id}`, + track: `https://www.qobuz.com/track/${id}`, + }, + }; + return urls[service] && urls[service][entityType] || null; +} + +function makeClickableBadge(service, entityType, id, label) { + const url = getServiceUrl(service, entityType, id); + if (url) { + const a = document.createElement('a'); + a.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`; + a.href = url; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.textContent = label; + a.title = `${label}: ${id} (click to open)`; + a.onclick = (e) => e.stopPropagation(); + return a; + } + const span = document.createElement('span'); + span.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`; + span.textContent = label; + span.title = `${label}: ${id}`; + return span; +} + +// ---- Inline Editing ---- + +function startInlineEdit(cell, type, id, field, currentValue) { + if (cell.querySelector('.enhanced-inline-input')) return; + cancelInlineEdit(); + + const isNumeric = ['track_number', 'bpm'].includes(field); + const originalContent = cell.innerHTML; + cell.dataset.originalContent = originalContent; + + const input = document.createElement('input'); + input.type = isNumeric ? 'number' : 'text'; + input.className = 'enhanced-inline-input' + (isNumeric ? ' num' : ''); + input.value = currentValue || ''; + if (field === 'bpm') input.step = '0.1'; + if (field === 'track_number') { input.min = '1'; input.step = '1'; } + + cell.innerHTML = ''; + cell.appendChild(input); + input.focus(); + input.select(); + + artistDetailPageState.editingCell = { cell, type, id, field, originalContent }; + + input.addEventListener('click', e => e.stopPropagation()); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveInlineEdit(type, id, field, input.value); + } else if (e.key === 'Escape') { + cancelInlineEdit(); + } + e.stopPropagation(); + }); + input.addEventListener('blur', () => { + setTimeout(() => { + if (artistDetailPageState.editingCell && artistDetailPageState.editingCell.cell === cell) { + saveInlineEdit(type, id, field, input.value); + } + }, 150); + }); +} + +async function saveInlineEdit(type, id, field, newValue) { + const editInfo = artistDetailPageState.editingCell; + if (!editInfo) return; + artistDetailPageState.editingCell = null; + + let parsedValue = newValue; + if (field === 'track_number') parsedValue = parseInt(newValue) || null; + else if (field === 'bpm') parsedValue = parseFloat(newValue) || null; + else if (field === 'explicit') parsedValue = parseInt(newValue) || 0; + + const url = type === 'track' ? `/api/library/track/${id}` : `/api/library/album/${id}`; + + try { + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ [field]: parsedValue }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + const displayValue = parsedValue !== null && parsedValue !== '' ? String(parsedValue) : '-'; + editInfo.cell.textContent = displayValue; + updateLocalEnhancedData(type, id, field, parsedValue); + showToast(`Updated ${field}`, 'success'); + } catch (error) { + console.error('Failed to save inline edit:', error); + editInfo.cell.innerHTML = editInfo.originalContent; + showToast(`Failed to update: ${error.message}`, 'error'); + } +} + +function cancelInlineEdit() { + const editInfo = artistDetailPageState.editingCell; + if (!editInfo) return; + editInfo.cell.innerHTML = editInfo.originalContent; + artistDetailPageState.editingCell = null; +} + +function updateLocalEnhancedData(type, id, field, value) { + const data = artistDetailPageState.enhancedData; + if (!data) return; + + if (type === 'track') { + for (const album of data.albums) { + const track = (album.tracks || []).find(t => String(t.id) === String(id)); + if (track) { track[field] = value; break; } + } + } else if (type === 'album') { + const album = data.albums.find(a => String(a.id) === String(id)); + if (album) album[field] = value; + } else if (type === 'artist') { + data.artist[field] = value; + } +} + +// ---- Track Selection & Bulk Operations ---- + +function toggleTrackSelection(trackId) { + trackId = String(trackId); + if (artistDetailPageState.selectedTracks.has(trackId)) { + artistDetailPageState.selectedTracks.delete(trackId); + } else { + artistDetailPageState.selectedTracks.add(trackId); + } + const row = document.querySelector(`tr[data-track-id="${trackId}"]`); + if (row) row.classList.toggle('selected', artistDetailPageState.selectedTracks.has(trackId)); + updateBulkBar(); +} + +function toggleSelectAllTracks(albumId, checked) { + const album = findEnhancedAlbum(albumId); + if (!album || !album.tracks) return; + + // Batch update state + album.tracks.forEach(track => { + const tid = String(track.id); + if (checked) artistDetailPageState.selectedTracks.add(tid); + else artistDetailPageState.selectedTracks.delete(tid); + }); + + // Scoped DOM query — only search within this album's panel, not entire document + const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); + if (panel) { + panel.querySelectorAll('tr[data-track-id]').forEach(row => { + row.classList.toggle('selected', checked); + const cb = row.querySelector('.enhanced-track-checkbox'); + if (cb) cb.checked = checked; + }); + } + updateBulkBar(); +} + +function clearTrackSelection() { + // Scoped batch clear — query the container once instead of per-track + const container = document.getElementById('enhanced-view-container'); + if (container) { + container.querySelectorAll('tr[data-track-id].selected').forEach(row => { + row.classList.remove('selected'); + const cb = row.querySelector('.enhanced-track-checkbox'); + if (cb) cb.checked = false; + }); + container.querySelectorAll('.enhanced-track-table thead .enhanced-track-checkbox').forEach(cb => cb.checked = false); + } + artistDetailPageState.selectedTracks.clear(); + updateBulkBar(); +} + +function updateBulkBar() { + const bar = document.getElementById('enhanced-bulk-bar'); + const count = document.getElementById('enhanced-bulk-count'); + if (!bar || !count) return; + if (!isEnhancedAdmin()) { + bar.classList.remove('visible'); + return; + } + const n = artistDetailPageState.selectedTracks.size; + count.textContent = n; + bar.classList.toggle('visible', n > 0); +} + +function showBulkEditModal() { + const overlay = document.getElementById('enhanced-bulk-edit-overlay'); + const body = document.getElementById('enhanced-bulk-modal-body'); + const title = document.getElementById('enhanced-bulk-modal-title'); + if (!overlay || !body) return; + + const count = artistDetailPageState.selectedTracks.size; + title.textContent = `Batch Edit ${count} Track${count !== 1 ? 's' : ''}`; + + body.innerHTML = ` +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + `; + + overlay.classList.remove('hidden'); +} + +function closeBulkEditModal() { + const overlay = document.getElementById('enhanced-bulk-edit-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +async function executeBulkEdit() { + const trackIds = Array.from(artistDetailPageState.selectedTracks); + if (trackIds.length === 0) return; + + const updates = {}; + const trackNum = document.getElementById('bulk-edit-track-number'); + const bpm = document.getElementById('bulk-edit-bpm'); + const style = document.getElementById('bulk-edit-style'); + const mood = document.getElementById('bulk-edit-mood'); + const explicit = document.getElementById('bulk-edit-explicit'); + + if (trackNum && trackNum.value !== '') updates.track_number = parseInt(trackNum.value); + if (bpm && bpm.value !== '') updates.bpm = parseFloat(bpm.value); + if (style && style.value !== '') updates.style = style.value; + if (mood && mood.value !== '') updates.mood = mood.value; + if (explicit && explicit.value !== '') updates.explicit = parseInt(explicit.value); + + if (Object.keys(updates).length === 0) { + showToast('No changes to apply', 'error'); + return; + } + + try { + const response = await fetch('/api/library/tracks/batch', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds, updates }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + showToast(`Updated ${result.updated_count} tracks`, 'success'); + closeBulkEditModal(); + + for (const [field, val] of Object.entries(updates)) { + trackIds.forEach(tid => updateLocalEnhancedData('track', tid, field, val)); + } + + reRenderExpandedPanels(); + clearTrackSelection(); + + } catch (error) { + console.error('Bulk edit failed:', error); + showToast(`Bulk edit failed: ${error.message}`, 'error'); + } +} + +// ---- Save Artist / Album Metadata ---- + +async function saveArtistMetadata() { + const form = document.getElementById('enhanced-artist-meta-form'); + if (!form) return; + + const inputs = form.querySelectorAll('.enhanced-meta-field-input'); + const updates = {}; + const original = artistDetailPageState.enhancedData.artist; + + inputs.forEach(input => { + const field = input.dataset.field; + if (!field) return; + let value = (input.tagName === 'TEXTAREA' ? input.value : input.value).trim(); + + let origVal = original[field]; + if (field === 'genres') { + const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : []; + const origGenres = Array.isArray(origVal) ? origVal : []; + if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres; + } else { + if ((value || '') !== (origVal || '')) updates[field] = value || null; + } + }); + + if (Object.keys(updates).length === 0) { + showToast('No changes to save', 'error'); + return; + } + + try { + const response = await fetch(`/api/library/artist/${original.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + for (const [field, value] of Object.entries(updates)) { + artistDetailPageState.enhancedData.artist[field] = value; + } + + // Update the display name in the header + if (updates.name) { + const nameEl = document.querySelector('.enhanced-artist-meta-name'); + if (nameEl) nameEl.textContent = updates.name; + } + + showToast(`Artist metadata saved (${(result.updated_fields || []).join(', ')})`, 'success'); + } catch (error) { + console.error('Failed to save artist metadata:', error); + showToast(`Failed to save: ${error.message}`, 'error'); + } +} + +function revertArtistMetadata() { + const data = artistDetailPageState.enhancedData; + if (!data) return; + + const panel = document.getElementById('enhanced-artist-meta'); + if (!panel) return; + + const parent = panel.parentNode; + const newPanel = renderArtistMetaPanel(data.artist); + parent.replaceChild(newPanel, panel); + showToast('Reverted to saved values', 'success'); +} + +async function saveAlbumMetadata(albumId) { + const metaRow = document.getElementById(`enhanced-album-meta-${albumId}`); + if (!metaRow) return; + + const album = findEnhancedAlbum(albumId); + if (!album) return; + + const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input'); + const updates = {}; + + inputs.forEach(input => { + const field = input.dataset.field; + if (!field) return; + let value = input.value.trim(); + + if (field === 'genres') { + const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : []; + const origGenres = Array.isArray(album.genres) ? album.genres : []; + if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres; + } else if (field === 'year' || field === 'explicit' || field === 'track_count') { + const numVal = value !== '' ? parseInt(value) : null; + if (numVal !== (album[field] || null)) updates[field] = numVal; + } else { + if ((value || '') !== (album[field] || '')) updates[field] = value || null; + } + }); + + if (Object.keys(updates).length === 0) { + showToast('No album changes to save', 'error'); + return; + } + + try { + const response = await fetch(`/api/library/album/${albumId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + for (const [field, value] of Object.entries(updates)) { + album[field] = value; + } + + // Update album row display + const albumRow = document.getElementById(`enhanced-album-row-${albumId}`); + if (albumRow) { + if (updates.title) { + const titleEl = albumRow.querySelector('.enhanced-album-title'); + if (titleEl) { titleEl.textContent = updates.title; titleEl.title = updates.title; } + } + if (updates.year !== undefined) { + const yearEl = albumRow.querySelector('.enhanced-album-year'); + if (yearEl) yearEl.textContent = updates.year || '-'; + } + } + + showToast(`Album metadata saved (${(result.updated_fields || []).join(', ')})`, 'success'); + } catch (error) { + console.error('Failed to save album metadata:', error); + showToast(`Failed to save: ${error.message}`, 'error'); + } +} + +function reRenderExpandedPanels() { + artistDetailPageState.expandedAlbums.forEach(albumId => { + const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); + if (!panel) return; + const inner = panel.querySelector('.enhanced-tracks-panel-inner'); + if (!inner) return; + + const album = findEnhancedAlbum(albumId); + if (album) { + inner.innerHTML = ''; + inner.appendChild(renderExpandedAlbumHeader(album)); + inner.appendChild(renderAlbumMetaRow(album)); + inner.appendChild(renderTrackTable(album)); + } + }); +} + +// ---- Manual Match Modal ---- + +function openManualMatchModal(entityType, entityId, service, defaultQuery, artistId) { + // Remove existing modal if any + const existing = document.getElementById('enhanced-manual-match-overlay'); + if (existing) existing.remove(); + + const serviceLabels = { + spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer', + audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius' + }; + + const overlay = document.createElement('div'); + overlay.id = 'enhanced-manual-match-overlay'; + overlay.className = 'modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; + + const modal = document.createElement('div'); + modal.className = 'enhanced-manual-match-modal'; + + // Header + const header = document.createElement('div'); + header.className = 'enhanced-bulk-modal-header'; + const title = document.createElement('h3'); + title.textContent = `Match ${entityType} on ${serviceLabels[service] || service}`; + header.appendChild(title); + const closeBtn = document.createElement('button'); + closeBtn.className = 'enhanced-bulk-modal-close'; + closeBtn.innerHTML = '×'; + closeBtn.onclick = () => overlay.remove(); + header.appendChild(closeBtn); + modal.appendChild(header); + + // Search bar + const searchRow = document.createElement('div'); + searchRow.className = 'enhanced-match-search-row'; + const searchInput = document.createElement('input'); + searchInput.type = 'text'; + searchInput.className = 'enhanced-match-search-input'; + searchInput.placeholder = `Search ${serviceLabels[service] || service}...`; + searchInput.value = defaultQuery; + searchRow.appendChild(searchInput); + const searchBtn = document.createElement('button'); + searchBtn.className = 'enhanced-enrich-btn'; + searchBtn.textContent = 'Search'; + searchBtn.onclick = () => doManualMatchSearch(service, entityType, searchInput.value, resultsContainer, entityId, artistId); + searchRow.appendChild(searchBtn); + + // Clear Match button — lets user revert a wrong match to not_found + const clearBtn = document.createElement('button'); + clearBtn.className = 'enhanced-enrich-btn'; + clearBtn.style.cssText = 'background:rgba(255,80,80,0.12);color:#ff6b6b;margin-left:6px'; + clearBtn.textContent = 'Clear Match'; + clearBtn.title = 'Remove the current match — reverts to Not Found'; + clearBtn.onclick = async () => { + if (!confirm(`Clear ${serviceLabels[service] || service} match for this ${entityType}? It will revert to "Not Found".`)) return; + try { + const res = await fetch('/api/library/clear-match', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service, artist_id: artistId }) + }); + const data = await res.json(); + if (data.success) { + showToast(`Cleared ${serviceLabels[service] || service} match`, 'success'); + overlay.remove(); + if (data.updated_data) { + artistDetailPageState.enhancedData = data.updated_data; + renderEnhancedArtistView(data.updated_data, true); + } + } else { + showToast(data.error || 'Failed to clear match', 'error'); + } + } catch (e) { + showToast('Error clearing match', 'error'); + } + }; + searchRow.appendChild(clearBtn); + + modal.appendChild(searchRow); + + // Handle Enter key + searchInput.onkeydown = (e) => { + if (e.key === 'Enter') searchBtn.click(); + }; + + // Results container + const resultsContainer = document.createElement('div'); + resultsContainer.className = 'enhanced-match-results'; + resultsContainer.innerHTML = '
    Press Search or Enter to find matches
    '; + modal.appendChild(resultsContainer); + + overlay.appendChild(modal); + document.body.appendChild(overlay); + + // Auto-search on open + searchInput.focus(); + searchBtn.click(); +} + +async function doManualMatchSearch(service, entityType, query, container, entityId, artistId) { + if (!query.trim()) { + container.innerHTML = '
    Enter a search term
    '; + return; + } + + container.innerHTML = '
    Searching...
    '; + + try { + const response = await fetch('/api/library/search-service', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service, entity_type: entityType, query: query.trim() }) + }); + + const data = await response.json(); + if (!data.success) throw new Error(data.error); + + const results = data.results || []; + container.innerHTML = ''; + + if (results.length === 0) { + container.innerHTML = '
    No results found. Try a different search.
    '; + return; + } + + results.forEach(result => { + const row = document.createElement('div'); + row.className = 'enhanced-match-result-row'; + + if (result.image) { + const img = document.createElement('img'); + img.className = 'enhanced-match-result-img'; + img.src = result.image; + img.alt = ''; + img.onerror = function () { this.style.display = 'none'; }; + row.appendChild(img); + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'enhanced-match-result-img-placeholder'; + placeholder.innerHTML = '🎵'; + row.appendChild(placeholder); + } + + const info = document.createElement('div'); + info.className = 'enhanced-match-result-info'; + const name = document.createElement('div'); + name.className = 'enhanced-match-result-name'; + name.textContent = result.name || 'Unknown'; + info.appendChild(name); + if (result.extra) { + const extra = document.createElement('div'); + extra.className = 'enhanced-match-result-extra'; + extra.textContent = result.extra; + info.appendChild(extra); + } + const idLine = document.createElement('div'); + idLine.className = 'enhanced-match-result-id'; + const providerLabel = result.provider && result.provider !== service ? ` (${result.provider})` : ''; + idLine.textContent = `ID: ${result.id}${providerLabel}`; + info.appendChild(idLine); + row.appendChild(info); + + const matchBtn = document.createElement('button'); + matchBtn.className = 'enhanced-meta-save-btn'; + matchBtn.textContent = 'Match'; + matchBtn.onclick = () => applyManualMatch(entityType, entityId, result.provider || service, result.id, artistId); + row.appendChild(matchBtn); + + container.appendChild(row); + }); + + } catch (error) { + container.innerHTML = `
    Error: ${escapeHtml(error.message)}
    `; + } +} + +async function applyManualMatch(entityType, entityId, service, serviceId, artistId) { + try { + showToast(`Matching ${entityType} to ${service}...`, 'info'); + + const response = await fetch('/api/library/manual-match', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + entity_type: entityType, + entity_id: entityId, + service: service, + service_id: serviceId, + artist_id: artistId + }) + }); + + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + showToast(`Manually matched to ${service} ID: ${serviceId}`, 'success'); + + // Close modal + const overlay = document.getElementById('enhanced-manual-match-overlay'); + if (overlay) overlay.remove(); + + // Update view with fresh data + if (result.updated_data && result.updated_data.success) { + artistDetailPageState.enhancedData = result.updated_data; + _rebuildAlbumMap(); + renderEnhancedView(); + } else if (artistDetailPageState.currentArtistId) { + await loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + + } catch (error) { + showToast(`Match failed: ${error.message}`, 'error'); + } +} + +// ---- Enrichment ---- + +let _enrichmentInFlight = false; + +async function runEnrichment(entityType, entityId, service, name, artistName, artistId) { + if (_enrichmentInFlight) { + showToast('An enrichment is already in progress', 'error'); + return; + } + + _enrichmentInFlight = true; + + // Add loading class to all match chips for this service + const chipPrefixes = { + 'spotify': ['spotify', 'sp'], + 'musicbrainz': ['musicbrainz', 'mb'], + 'deezer': ['deezer', 'dz'], + 'audiodb': ['audiodb', 'adb'], + 'itunes': ['itunes', 'it'], + 'lastfm': ['last.fm', 'lfm'], + 'genius': ['genius', 'gen'], + }; + const prefixes = chipPrefixes[service] || [service]; + document.querySelectorAll('.enhanced-match-chip').forEach(chip => { + const chipText = chip.textContent.toLowerCase(); + if (prefixes.some(p => chipText.startsWith(p))) { + chip.classList.add('loading'); + } + }); + + showToast(`Enriching ${entityType} from ${service}...`, 'info'); + + try { + const response = await fetch('/api/library/enrich', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + entity_type: entityType, + entity_id: entityId, + service: service, + name: name, + artist_name: artistName, + artist_id: artistId + }) + }); + + const result = await response.json(); + + if (response.status === 429) { + showToast(result.error || 'Another enrichment is in progress', 'error'); + return; + } + + if (!result.success) { + throw new Error(result.error || 'Enrichment failed'); + } + + // Show per-service results + const results = result.results || {}; + const successes = Object.entries(results).filter(([, r]) => r.success).map(([s]) => s); + const failures = Object.entries(results).filter(([, r]) => !r.success).map(([s, r]) => `${s}: ${r.error}`); + + if (successes.length > 0) { + showToast(`Enriched from: ${successes.join(', ')}`, 'success'); + } + if (failures.length > 0) { + showToast(`Failed: ${failures.join('; ')}`, 'error'); + } + + // Update local data with fresh response and re-render (preserves expanded state) + if (result.updated_data && result.updated_data.success) { + artistDetailPageState.enhancedData = result.updated_data; + _rebuildAlbumMap(); + renderEnhancedView(); + } else if (artistDetailPageState.currentArtistId) { + await loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + + } catch (error) { + console.error('Enrichment error:', error); + showToast(`Enrichment error: ${error.message}`, 'error'); + } finally { + _enrichmentInFlight = false; + document.querySelectorAll('.enhanced-match-chip.loading').forEach(c => c.classList.remove('loading')); + } +} + +// Close enrich dropdowns when clicking outside (early bail when enhanced view isn't active) +document.addEventListener('click', (e) => { + if (!artistDetailPageState.enhancedView) return; + if (!e.target.closest('.enhanced-enrich-wrap')) { + document.querySelectorAll('.enhanced-enrich-menu.visible').forEach(m => m.classList.remove('visible')); + } +}); + +// ---- Write Tags to File ---- + +let _tagPreviewTrackId = null; +let _tagPreviewServerType = null; + +async function showTagPreview(trackId) { + _tagPreviewTrackId = trackId; + _tagPreviewServerType = null; + const overlay = document.getElementById('tag-preview-overlay'); + const body = document.getElementById('tag-preview-body'); + const title = document.getElementById('tag-preview-title'); + if (!overlay || !body) return; + + title.textContent = 'Write Tags to File'; + body.innerHTML = '
    Loading tag comparison...
    '; + overlay.classList.remove('hidden'); + + // Hide sync checkbox until we know server type + const syncLabel = document.getElementById('tag-preview-sync-label'); + if (syncLabel) syncLabel.classList.add('hidden'); + + try { + const response = await fetch(`/api/library/track/${trackId}/tag-preview`); + const result = await response.json(); + if (!result.success) { + body.innerHTML = `
    ${escapeHtml(result.error)}
    `; + return; + } + + const diff = result.diff || []; + const hasChanges = result.has_changes; + + // Show server sync checkbox if a server is connected (not navidrome — it auto-detects) + _tagPreviewServerType = result.server_type || null; + if (syncLabel && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome') { + const syncText = document.getElementById('tag-preview-sync-text'); + if (syncText) syncText.textContent = `Sync to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`; + syncLabel.classList.remove('hidden'); + } + + let html = ''; + html += ''; + html += ''; + + diff.forEach(d => { + const rowClass = d.changed ? 'tag-diff-changed' : 'tag-diff-same'; + const arrow = d.changed ? '' : ''; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + + html += '
    FieldCurrent File TagDB Value
    ${d.field}${escapeHtml(d.file_value) || 'empty'}${arrow}${escapeHtml(d.db_value) || 'empty'}
    '; + + if (!hasChanges) { + html += '
    File tags already match DB metadata
    '; + } + + body.innerHTML = html; + + const writeBtn = document.getElementById('tag-preview-write-btn'); + if (writeBtn) { + writeBtn.disabled = !hasChanges && !document.getElementById('tag-preview-embed-cover')?.checked; + } + + } catch (error) { + body.innerHTML = `
    Failed to load preview: ${escapeHtml(error.message)}
    `; + } +} + +function closeTagPreviewModal() { + const overlay = document.getElementById('tag-preview-overlay'); + if (overlay) overlay.classList.add('hidden'); + _tagPreviewTrackId = null; +} + +async function executeWriteTags() { + if (!_tagPreviewTrackId) return; + + const writeBtn = document.getElementById('tag-preview-write-btn'); + if (writeBtn) { + writeBtn.disabled = true; + writeBtn.textContent = 'Writing...'; + } + + const embedCover = document.getElementById('tag-preview-embed-cover')?.checked ?? true; + const syncToServer = document.getElementById('tag-preview-sync-server')?.checked && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome'; + + try { + const response = await fetch(`/api/library/track/${_tagPreviewTrackId}/write-tags`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ embed_cover: embedCover, sync_to_server: syncToServer }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + const fieldCount = (result.written_fields || []).length; + let msg = `Tags written successfully (${fieldCount} fields)`; + if (result.server_sync) { + const ss = result.server_sync; + if (ss.synced > 0) msg += ` — synced to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`; + else if (ss.failed > 0) msg += ` — server sync failed`; + } + showToast(msg, 'success'); + closeTagPreviewModal(); + + } catch (error) { + showToast(`Failed to write tags: ${error.message}`, 'error'); + } finally { + if (writeBtn) { + writeBtn.disabled = false; + writeBtn.textContent = 'Write Tags'; + } + } +} + +async function writeAlbumTags(albumId) { + const album = findEnhancedAlbum(albumId); + if (!album) return; + + const tracks = (album.tracks || []).filter(t => t.file_path); + if (tracks.length === 0) { + showToast('No tracks with files in this album', 'error'); + return; + } + + await showBatchTagPreview(tracks.map(t => t.id), album.title); +} + +async function batchWriteTagsSelected() { + const trackIds = Array.from(artistDetailPageState.selectedTracks); + if (trackIds.length === 0) return; + + await showBatchTagPreview(trackIds, null); +} + +async function showBatchTagPreview(trackIds, albumTitle) { + const overlay = document.getElementById('batch-tag-preview-overlay'); + const body = document.getElementById('batch-tag-preview-body'); + const titleEl = document.getElementById('batch-tag-preview-title'); + const summary = document.getElementById('batch-tag-preview-summary'); + const writeBtn = document.getElementById('batch-tag-preview-write-btn'); + if (!overlay || !body) return; + + titleEl.textContent = albumTitle ? `Write Tags — ${albumTitle}` : `Write Tags — ${trackIds.length} Tracks`; + body.innerHTML = '
    Loading tag previews...
    '; + summary.innerHTML = ''; + writeBtn.disabled = true; + overlay.classList.remove('hidden'); + + // Hide sync checkbox until we know server type + const syncLabel = document.getElementById('batch-tag-preview-sync-label'); + if (syncLabel) syncLabel.classList.add('hidden'); + + try { + const response = await fetch('/api/library/tracks/tag-preview-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds }) + }); + const result = await response.json(); + if (!result.success) { + body.innerHTML = `
    ${escapeHtml(result.error)}
    `; + return; + } + + const tracks = result.tracks || []; + const serverType = result.server_type || null; + + // Show sync checkbox if server connected + if (syncLabel && serverType && serverType !== 'navidrome') { + const syncText = document.getElementById('batch-tag-preview-sync-text'); + if (syncText) syncText.textContent = `Sync to ${serverType === 'plex' ? 'Plex' : 'Jellyfin'}`; + syncLabel.classList.remove('hidden'); + } + + // Categorize tracks + const withChanges = tracks.filter(t => t.has_changes); + const noChanges = tracks.filter(t => !t.error && !t.has_changes); + const errors = tracks.filter(t => t.error); + + // Summary bar + let summaryHtml = '
    '; + if (withChanges.length > 0) summaryHtml += `${withChanges.length} with changes`; + if (noChanges.length > 0) summaryHtml += `${noChanges.length} unchanged`; + if (errors.length > 0) summaryHtml += `${errors.length} unavailable`; + summaryHtml += '
    '; + summary.innerHTML = summaryHtml; + + // Build track accordion + let html = ''; + + // Tracks with changes (expanded by default) + withChanges.forEach(track => { + html += _renderBatchTrackDiff(track, true); + }); + + // Errors + errors.forEach(track => { + html += `
    `; + html += `
    `; + html += `${track.track_number || '—'}`; + html += `${escapeHtml(track.title)}`; + html += `${escapeHtml(track.error)}`; + html += `
    `; + }); + + // Unchanged tracks (collapsed) + if (noChanges.length > 0) { + html += `
    `; + html += `
    `; + html += `${noChanges.length} track${noChanges.length !== 1 ? 's' : ''} already up to date`; + html += ``; + html += `
    `; + html += `
    `; + noChanges.forEach(track => { + html += `
    `; + html += `${track.track_number || '—'}`; + html += `${escapeHtml(track.title)}`; + html += `✓ Tags match`; + html += `
    `; + }); + html += `
    `; + } + + if (withChanges.length === 0 && errors.length === 0) { + html += '
    All file tags already match DB metadata
    '; + } + + body.innerHTML = html; + + // Store state for write action + overlay._batchTrackIds = trackIds; + overlay._batchServerType = serverType; + writeBtn.disabled = withChanges.length === 0; + + } catch (error) { + body.innerHTML = `
    Failed to load previews: ${escapeHtml(error.message)}
    `; + } +} + +function _renderBatchTrackDiff(track, expanded) { + let html = `
    `; + html += `
    `; + html += `${track.track_number || '—'}`; + html += `${escapeHtml(track.title)}`; + html += `${track.changed_count} field${track.changed_count !== 1 ? 's' : ''} changed`; + html += ``; + html += `
    `; + html += `
    `; + html += ''; + html += ''; + html += ''; + + (track.diff || []).forEach(d => { + if (!d.changed) return; // Only show changed fields in batch view + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + + html += '
    FieldCurrent FileNew Value
    ${d.field}${escapeHtml(d.file_value) || 'empty'}${escapeHtml(d.db_value) || 'empty'}
    '; + return html; +} + +function closeBatchTagPreviewModal() { + const overlay = document.getElementById('batch-tag-preview-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + overlay._batchTrackIds = null; + overlay._batchServerType = null; + } +} + +async function executeBatchWriteTags() { + const overlay = document.getElementById('batch-tag-preview-overlay'); + const trackIds = overlay?._batchTrackIds; + if (!trackIds || trackIds.length === 0) return; + + const writeBtn = document.getElementById('batch-tag-preview-write-btn'); + if (writeBtn) { + writeBtn.disabled = true; + writeBtn.textContent = 'Writing...'; + } + + const embedCover = document.getElementById('batch-tag-preview-embed-cover')?.checked ?? true; + const serverType = overlay._batchServerType; + const syncToServer = document.getElementById('batch-tag-preview-sync-server')?.checked && serverType && serverType !== 'navidrome'; + + closeBatchTagPreviewModal(); + await _startBatchWriteTags(trackIds, embedCover, syncToServer); + + if (writeBtn) { + writeBtn.disabled = false; + writeBtn.textContent = 'Write Tags'; + } +} + +async function _startBatchWriteTags(trackIds, embedCover, syncToServer = false) { + try { + const response = await fetch('/api/library/tracks/write-tags-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds, embed_cover: embedCover, sync_to_server: syncToServer }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + showToast(`Writing tags for ${trackIds.length} tracks...`, 'info'); + _pollBatchWriteTagsStatus(); + + } catch (error) { + showToast(`Failed to start tag write: ${error.message}`, 'error'); + } +} + +let _batchWriteTagsPollTimer = null; + +function _pollBatchWriteTagsStatus() { + if (_batchWriteTagsPollTimer) clearTimeout(_batchWriteTagsPollTimer); + + async function poll() { + try { + const response = await fetch('/api/library/tracks/write-tags-batch/status'); + const state = await response.json(); + + if (state.status === 'running') { + if (state.sync_phase === 'syncing') { + const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server; + showToast(`Syncing to ${serverName}...`, 'info'); + } else { + const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; + showToast(`Writing tags: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); + } + _batchWriteTagsPollTimer = setTimeout(poll, 1000); + } else if (state.status === 'done') { + let msg = `Tags written: ${state.written} succeeded, ${state.failed} failed`; + if (state.sync_phase === 'done') { + const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server; + if (state.sync_synced > 0 && state.sync_failed === 0) { + msg += ` — synced to ${serverName}`; + } else if (state.sync_failed > 0) { + msg += ` — ${serverName} sync: ${state.sync_synced} synced, ${state.sync_failed} failed`; + } + } + // Surface the first error reason so users can diagnose (e.g. "File not found") + if (state.failed > 0 && state.errors && state.errors.length > 0) { + const firstErr = state.errors[0].error || 'Unknown error'; + msg += ` (${firstErr})`; + } + showToast(msg, state.failed > 0 || state.sync_failed > 0 ? 'warning' : 'success'); + _batchWriteTagsPollTimer = null; + } + } catch (error) { + console.error('Poll write-tags status failed:', error); + _batchWriteTagsPollTimer = null; + } + } + + _batchWriteTagsPollTimer = setTimeout(poll, 800); +} + +// ── ReplayGain Analysis ── + +let _rgBatchPollTimer = null; +let _rgAlbumPollTimer = null; + +/** + * Analyze a single track and write track-level ReplayGain tags. + * Synchronous on the server side (~1–3 s). Shows spinner on the button. + */ +async function analyzeTrackReplayGain(trackId, btn) { + if (btn) { + btn.disabled = true; + btn.textContent = '…'; + } + try { + const res = await fetch(`/api/library/track/${trackId}/analyze-replaygain`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { + showToast(`ReplayGain written: ${data.track_gain} (${data.lufs} LUFS)`, 'success'); + } else { + showToast(`ReplayGain failed: ${data.error}`, 'error'); + } + } catch (err) { + showToast('ReplayGain analysis failed', 'error'); + } finally { + if (btn) { + btn.disabled = false; + btn.textContent = 'RG'; + } + } +} + +/** + * Analyze all tracks in an album and write track + album ReplayGain tags. + * Kicks off a background job; polls for progress. + */ +async function analyzeAlbumReplayGain(albumId, btn) { + if (btn) { + btn.disabled = true; + btn.innerHTML = '♫ Analyzing…'; + } + try { + const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain`, { method: 'POST' }); + const data = await res.json(); + if (!data.success) { + showToast(`ReplayGain: ${data.error}`, 'error'); + if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } + return; + } + showToast('Album ReplayGain analysis started…', 'info'); + _pollAlbumRgStatus(albumId, btn); + } catch (err) { + showToast('Failed to start album ReplayGain analysis', 'error'); + if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } + } +} + +function _pollAlbumRgStatus(albumId, btn) { + if (_rgAlbumPollTimer) clearTimeout(_rgAlbumPollTimer); + + async function poll() { + try { + const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain/status`); + const state = await res.json(); + + if (state.status === 'running') { + const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; + showToast(`ReplayGain: ${state.processed}/${state.total} tracks (${pct}%)`, 'info'); + _rgAlbumPollTimer = setTimeout(poll, 1200); + } else if (state.status === 'done') { + const msg = `ReplayGain done: ${state.analyzed} analyzed, ${state.failed} failed`; + showToast(msg, state.failed > 0 ? 'warning' : 'success'); + if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } + _rgAlbumPollTimer = null; + } + } catch (err) { + console.error('ReplayGain album poll failed:', err); + if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } + _rgAlbumPollTimer = null; + } + } + + _rgAlbumPollTimer = setTimeout(poll, 1000); +} + +/** + * Analyze selected tracks (track gain only — they may span albums). + */ +async function batchAnalyzeReplayGainSelected() { + const trackIds = Array.from(artistDetailPageState.selectedTracks); + if (trackIds.length === 0) return; + + try { + const res = await fetch('/api/library/tracks/analyze-replaygain-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds }), + }); + const data = await res.json(); + if (!data.success) { + showToast(`ReplayGain: ${data.error}`, 'error'); + return; + } + showToast(`ReplayGain analysis started for ${trackIds.length} tracks…`, 'info'); + _pollBatchRgStatus(); + } catch (err) { + showToast('Failed to start batch ReplayGain analysis', 'error'); + } +} + +function _pollBatchRgStatus() { + if (_rgBatchPollTimer) clearTimeout(_rgBatchPollTimer); + + async function poll() { + try { + const res = await fetch('/api/library/tracks/analyze-replaygain-batch/status'); + const state = await res.json(); + + if (state.status === 'running') { + const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; + showToast(`ReplayGain: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); + _rgBatchPollTimer = setTimeout(poll, 1000); + } else if (state.status === 'done') { + const msg = `ReplayGain done: ${state.analyzed} written, ${state.failed} failed`; + showToast(msg, state.failed > 0 ? 'warning' : 'success'); + _rgBatchPollTimer = null; + } + } catch (err) { + console.error('ReplayGain batch poll failed:', err); + _rgBatchPollTimer = null; + } + } + + _rgBatchPollTimer = setTimeout(poll, 800); +} + +// ── Reorganize Album Files ── +// +// Click → enqueue → close modal. The reorganize queue worker (server- +// side) processes items FIFO. The Reorganize Status panel mounted at +// the top of the artist's enhanced-actions section is what surfaces +// live progress — buttons no longer wait or lock. + +let _reorganizeAlbumId = null; + +async function showReorganizeModal(albumId) { + // Short-circuit if this album is already queued or running — opening + // the modal would be misleading (the apply click would just dedupe). + const queuedState = _reorganizeStateForAlbum(albumId); + if (queuedState) { + const label = queuedState === 'running' ? 'Reorganize already running for this album' : 'Album already queued for reorganize'; + showToast(label, 'info'); + if (typeof refreshReorganizeStatusPanel === 'function') { + refreshReorganizeStatusPanel(); + } + return; + } + + _reorganizeAlbumId = albumId; + const overlay = document.getElementById('reorganize-overlay'); + const body = document.getElementById('reorganize-modal-body'); + const title = document.getElementById('reorganize-modal-title'); + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (!overlay || !body) return; + + // Find album data from enhanced view state + let albumData = null; + let artistName = ''; + if (artistDetailPageState.enhancedData) { + artistName = artistDetailPageState.enhancedData.artist.name || ''; + const allAlbums = artistDetailPageState.enhancedData.albums || []; + albumData = allAlbums.find(a => String(a.id) === String(albumId)); + } + + title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`; + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = 'Apply'; + applyBtn.onclick = () => executeReorganize(); + } + + let html = '
    '; + + // Metadata source picker — populated from /reorganize/sources. + // Empty value = use configured primary (with fallback chain). + // Specific source = strict mode, that source only. + html += '
    '; + html += ''; + html += '
    Pick which source to read the album\'s tracklist from. Defaults to your configured primary. Reorganize uses your global download template, same as fresh downloads.
    '; + html += ''; + html += '
    '; + + // Preview area + html += '
    '; + html += '
    '; + html += ''; + html += ''; + html += '
    '; + html += '
    '; + html += '
    Click "Generate Preview" to see how files will be reorganized.
    '; + html += '
    '; + + html += '
    '; + body.innerHTML = html; + overlay.classList.remove('hidden'); + + // Populate source picker after the modal mounts + setTimeout(() => _populateReorganizeSources(_reorganizeAlbumId), 50); +} + +async function _populateReorganizeSources(albumId) { + const select = document.getElementById('reorganize-source-select'); + if (!select || !albumId) return; + try { + const resp = await fetch(`/api/library/album/${albumId}/reorganize/sources`); + if (!resp.ok) return; + const data = await resp.json(); + const sources = data.sources || []; + // Keep the "auto" default option, append concrete sources beneath it. + sources.forEach(s => { + const opt = document.createElement('option'); + opt.value = s.source; + opt.textContent = s.label || s.source; + select.appendChild(opt); + }); + if (sources.length === 0) { + const opt = document.createElement('option'); + opt.disabled = true; + opt.textContent = 'No sources available — run enrichment first'; + select.appendChild(opt); + } + } catch (err) { + console.error('Failed to load reorganize sources:', err); + } +} + +function closeReorganizeModal() { + const overlay = document.getElementById('reorganize-overlay'); + if (overlay) overlay.classList.add('hidden'); + _reorganizeAlbumId = null; +} + +async function loadReorganizePreview() { + const previewBody = document.getElementById('reorganize-preview-body'); + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (!previewBody || !_reorganizeAlbumId) return; + + if (applyBtn) applyBtn.disabled = true; + previewBody.innerHTML = '
    Loading preview...
    '; + + // Final apply-button state: only enable when the preview actually + // produced movable tracks AND no collisions blocked it. Any error + // path or empty result keeps it disabled. We compute it as we go and + // commit it in finally so an early return / throw can't leave the + // button stuck disabled forever. + let canApply = false; + + try { + const chosenSource = document.getElementById('reorganize-source-select')?.value || ''; + const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: chosenSource }) + }); + const result = await response.json(); + if (!result.success) { + previewBody.innerHTML = `
    ${escapeHtml(result.error || 'Preview failed')}
    `; + return; + } + + const tracks = result.tracks || []; + if (tracks.length === 0) { + previewBody.innerHTML = '
    No tracks found.
    '; + return; + } + + let hasChanges = false; + let hasCollisions = false; + let html = ''; + html += ''; + html += ''; + + tracks.forEach(t => { + const unchanged = t.unchanged; + const noFile = !t.file_exists; + const collision = t.collision; + const unmatched = (t.matched === false); + const missingPath = !unmatched && !noFile && !t.new_path; // matched but path-build failed + if (!unchanged && t.file_exists && !unmatched && !missingPath) hasChanges = true; + if (collision) hasCollisions = true; + + let rowClass; + if (collision) rowClass = 'reorganize-row-collision'; + else if (noFile || unmatched || missingPath) rowClass = 'reorganize-row-missing'; + else if (unchanged) rowClass = 'reorganize-row-unchanged'; + else rowClass = 'reorganize-row-changed'; + + const arrow = collision ? '!!' + : unchanged ? '=' + : (noFile || unmatched || missingPath) ? '⊘' + : '→'; + + const newCell = noFile ? '' + : unmatched ? `${escapeHtml(t.reason || 'Not in selected source\'s tracklist')}` + : missingPath ? `${escapeHtml(t.reason || 'Couldn\'t compute destination path')}` + : (escapeHtml(t.new_path) + (collision ? ' (collision)' : '')); + + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ``; + html += ''; + }); + + html += '
    #TitleCurrent PathNew Path
    ${t.track_number || ''}${escapeHtml(t.title)}${noFile ? 'File not found' : escapeHtml(t.current_path)}${arrow}${newCell}
    '; + + const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision && t.matched !== false && t.new_path).length; + const skippedCount = tracks.filter(t => t.unchanged).length; + const missingCount = tracks.filter(t => !t.file_exists).length; + const collisionCount = tracks.filter(t => t.collision).length; + const unmatchedCount = tracks.filter(t => t.file_exists && t.matched === false).length; + const noPathCount = tracks.filter(t => t.file_exists && t.matched !== false && !t.new_path && !t.collision).length; + + let summary = `
    `; + if (changedCount > 0) summary += `${changedCount} will move`; + if (skippedCount > 0) summary += `${skippedCount} unchanged`; + if (unmatchedCount > 0) summary += `${unmatchedCount} not in source — try a different source`; + if (noPathCount > 0) summary += `${noPathCount} couldn't compute destination`; + if (missingCount > 0) summary += `${missingCount} missing on disk`; + if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — likely a source data issue`; + summary += '
    '; + + previewBody.innerHTML = summary + html; + + canApply = hasChanges && !hasCollisions; + + } catch (error) { + previewBody.innerHTML = `
    Error: ${escapeHtml(error.message)}
    `; + } finally { + if (applyBtn) applyBtn.disabled = !canApply; + } +} + +async function executeReorganize() { + if (!_reorganizeAlbumId) return; + + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (applyBtn) { + applyBtn.disabled = true; + applyBtn.textContent = 'Queueing...'; + } + + const albumTitle = document.getElementById('reorganize-modal-title')?.textContent + ?.replace(/^Reorganize:\s*/, '') || 'album'; + + try { + const chosenSource = document.getElementById('reorganize-source-select')?.value || ''; + const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: chosenSource }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error); + + closeReorganizeModal(); + + if (result.queued) { + const posLabel = result.position && result.position > 1 ? ` (#${result.position} in queue)` : ''; + showToast(`Queued: ${albumTitle}${posLabel}`, 'info'); + } else if (result.reason === 'already_queued') { + showToast(`Already queued: ${albumTitle}`, 'info'); + } else { + showToast('Reorganize queued', 'info'); + } + + // Wake the status panel so the user sees the new item land + // immediately rather than waiting for the next poll tick. + if (typeof refreshReorganizeStatusPanel === 'function') { + refreshReorganizeStatusPanel(); + } + } catch (error) { + showToast(`Reorganize failed: ${error.message}`, 'error'); + if (applyBtn) { + applyBtn.disabled = false; + applyBtn.textContent = 'Apply'; + } + } +} + +// kettui PR #377 review: distinguish 'completed' from non-completed +// outcomes so zero-failure skips (no_source_id, no_album, no_tracks, +// setup_failed, error) don't get a green checkmark. +function _classifyReorganizeOutcome(state) { + const status = state.result_status; + if (status && status !== 'completed') return 'warning'; + if (state.failed && state.failed > 0) return 'warning'; + return 'success'; +} + +function _formatReorganizeResultMessage(state) { + const status = state.result_status; + if (status === 'no_source_id') { + return 'Reorganize skipped — album has no metadata source ID. Run enrichment first.'; + } + if (status === 'no_album') { + return 'Reorganize skipped — album not found in DB.'; + } + if (status === 'no_tracks') { + return 'Reorganize skipped — album has no tracks.'; + } + if (status === 'setup_failed') { + return 'Reorganize failed — couldn\'t create staging directory.'; + } + if (status === 'error') { + return 'Reorganize failed — see server logs for details.'; + } + let msg = `Reorganized: ${state.moved || 0} moved`; + if (state.skipped > 0) msg += `, ${state.skipped} skipped`; + if (state.failed > 0) msg += `, ${state.failed} failed`; + if (state.failed > 0 && state.errors && state.errors.length > 0) { + msg += ` (${state.errors[0].error})`; + } + return msg; +} + +// ── Reorganize All Albums for Artist ── + +async function _showReorganizeAllModal() { + if (!artistDetailPageState.enhancedData) { + showToast('No album data loaded', 'error'); + return; + } + const albums = artistDetailPageState.enhancedData.albums || []; + const artistName = artistDetailPageState.enhancedData.artist.name || 'Artist'; + + if (albums.length === 0) { + showToast('No albums to reorganize', 'error'); + return; + } + + const overlay = document.getElementById('reorganize-overlay'); + const body = document.getElementById('reorganize-modal-body'); + const title = document.getElementById('reorganize-modal-title'); + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (!overlay || !body) return; + + title.textContent = `Reorganize All Albums — ${artistName}`; + + let html = '
    '; + + // Source picker — applies to ALL albums in this run. Albums without + // an ID for the chosen source will be skipped at the backend with + // a clear status. Auto = use configured primary with fallback chain. + html += '
    '; + html += ''; + html += '
    Pick which source to read tracklists from. Albums without an ID for that source will be skipped. Reorganize uses your global download template, same as fresh downloads.
    '; + html += ''; + html += '
    '; + + // Album list + html += '
    '; + html += ``; + html += '
    '; + albums.forEach((a, i) => { + const trackCount = a.tracks ? a.tracks.length : '?'; + html += `
    `; + html += `${escapeHtml(a.title)} (${trackCount} tracks)`; + html += '
    '; + }); + html += '
    '; + + html += '
    '; + body.innerHTML = html; + + // Wire apply button for bulk mode + if (applyBtn) { + applyBtn.disabled = false; + applyBtn.textContent = 'Reorganize All'; + applyBtn.onclick = () => _executeReorganizeAll(); + } + + overlay.classList.remove('hidden'); + + // Populate the source dropdown from the global authed-sources endpoint + setTimeout(async () => { + const select = document.getElementById('reorganize-source-select'); + if (!select) return; + try { + const resp = await fetch('/api/library/reorganize/sources'); + if (!resp.ok) return; + const data = await resp.json(); + (data.sources || []).forEach(s => { + const opt = document.createElement('option'); + opt.value = s.source; + opt.textContent = s.label || s.source; + select.appendChild(opt); + }); + } catch (err) { + console.error('Failed to load reorganize sources:', err); + } + }, 50); +} + +async function _executeReorganizeAll() { + const albums = artistDetailPageState.enhancedData?.albums || []; + const total = albums.length; + const artistName = artistDetailPageState.enhancedData?.artist?.name || 'this artist'; + const artistId = artistDetailPageState.currentArtistId; + if (!artistId) return; + + const confirmed = await showConfirmDialog({ + title: 'Reorganize All Albums', + message: `This will queue ${total} album${total !== 1 ? 's' : ''} for ${artistName} using your configured download template. Files will be moved and renamed. This cannot be undone.`, + confirmText: 'Queue All', + destructive: false, + }); + if (!confirmed) return; + + const applyBtn = document.getElementById('reorganize-apply-btn'); + if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Queueing...'; } + + const overlay = document.getElementById('reorganize-overlay'); + if (overlay) overlay.classList.add('hidden'); + + // One source pick applies to every album in the batch. + const chosenSource = document.getElementById('reorganize-source-select')?.value || ''; + + try { + const resp = await fetch(`/api/library/artist/${artistId}/reorganize-all`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ source: chosenSource }), + }); + const result = await resp.json(); + if (!result.success) throw new Error(result.error || 'Queue request failed'); + + const enqueued = result.enqueued || 0; + const already = result.already_queued || 0; + if (enqueued > 0 && already > 0) { + showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''}; ${already} already in queue`, 'info'); + } else if (enqueued > 0) { + showToast(`Queued ${enqueued} album${enqueued !== 1 ? 's' : ''} for ${artistName}`, 'info'); + } else if (already > 0) { + showToast(`All ${already} album${already !== 1 ? 's' : ''} already in queue`, 'info'); + } else { + showToast('No albums to queue', 'warning'); + } + + if (typeof refreshReorganizeStatusPanel === 'function') { + refreshReorganizeStatusPanel(); + } + } catch (err) { + showToast(`Reorganize-all failed: ${err.message}`, 'error'); + } finally { + if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; } + } +} + + +// ── Reorganize Status Panel ── +// +// Lives at the start of `.enhanced-artist-meta-actions`. Polls the +// queue snapshot endpoint and renders an at-a-glance summary plus an +// expandable card list. Only visible when there's something to show +// (active item, queued items, or recent completions). +// +// Cross-artist hint: items belonging to a different artist than the +// page's current one are flagged so the user understands progress they +// see refers to a separate batch. + +let _reorgPanelEl = null; +let _reorgPanelArtistId = null; +let _reorgPanelExpanded = false; +let _reorgPanelTimer = null; +let _reorgPanelLastSnapshot = null; +let _reorgPanelInflight = false; + +const _REORG_PANEL_FAST_MS = 1500; +const _REORG_PANEL_SLOW_MS = 8000; + +function mountReorganizeStatusPanel(container, artistId) { + if (!container) return; + // Tear down any panel left over from a previous artist view. + _stopReorganizeStatusPolling(); + + const panel = document.createElement('div'); + panel.className = 'reorganize-status-panel hidden'; + panel.id = 'reorganize-status-panel'; + container.insertBefore(panel, container.firstChild); + + _reorgPanelEl = panel; + _reorgPanelArtistId = artistId || null; + _reorgPanelExpanded = false; + _reorgPanelLastSnapshot = null; + + // Defer the initial refresh: the caller (renderArtistMetaPanel) is + // still building the header in memory, so neither this panel nor + // its ancestor headerRight has been attached to document.body yet. + // refreshReorganizeStatusPanel guards on document.body.contains, + // so a synchronous call here would bail and kill polling forever. + // setTimeout 0 lets the call stack unwind so the parent appendChild + // runs before we check connectivity. + setTimeout(() => { + if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) return; + refreshReorganizeStatusPanel(); + }, 0); +} + +function _stopReorganizeStatusPolling() { + if (_reorgPanelTimer) { + clearTimeout(_reorgPanelTimer); + _reorgPanelTimer = null; + } + _reorgPanelEl = null; + _reorgPanelLastSnapshot = null; +} + +function _scheduleReorganizeStatusPoll(delayMs) { + if (_reorgPanelTimer) clearTimeout(_reorgPanelTimer); + _reorgPanelTimer = setTimeout(() => { + _reorgPanelTimer = null; + refreshReorganizeStatusPanel(); + }, delayMs); +} + +async function refreshReorganizeStatusPanel() { + // The panel may have been unmounted (user navigated away from + // enhanced view); detect by checking it's still in the document. + if (!_reorgPanelEl || !document.body.contains(_reorgPanelEl)) { + _stopReorganizeStatusPolling(); + return; + } + if (_reorgPanelInflight) return; + _reorgPanelInflight = true; + + let snapshot = null; + try { + const resp = await fetch('/api/library/reorganize/queue'); + if (resp.ok) { + const data = await resp.json(); + if (data.success !== false) snapshot = data; + } else { + console.warn('Reorganize queue snapshot HTTP', resp.status); + } + } catch (err) { + // Network blip — keep showing the last snapshot, retry slowly. + console.warn('Reorganize queue snapshot failed:', err); + } finally { + _reorgPanelInflight = false; + } + + if (snapshot) _reorgPanelLastSnapshot = snapshot; + _renderReorganizeStatusPanel(_reorgPanelLastSnapshot); + + // Reschedule. Fast cadence while there's actually work in flight, + // slow when the queue is empty so we're not hammering the endpoint. + if (_reorgPanelEl && document.body.contains(_reorgPanelEl)) { + const active = _reorgPanelLastSnapshot?.active; + const queued = _reorgPanelLastSnapshot?.queued?.length || 0; + const next = (active || queued > 0) ? _REORG_PANEL_FAST_MS : _REORG_PANEL_SLOW_MS; + _scheduleReorganizeStatusPoll(next); + } +} + +function _renderReorganizeStatusPanel(snapshot) { + const panel = _reorgPanelEl; + if (!panel) return; + if (!snapshot) { + panel.classList.add('hidden'); + return; + } + const active = snapshot.active; + const queued = snapshot.queued || []; + const recent = snapshot.recent || []; + + // Show if anything is active/queued, OR a recent completion landed + // within the last 20 seconds (so the user sees the result). + const cutoffSec = (Date.now() / 1000) - 20; + const recentVisible = recent.filter(r => (r.finished_at || 0) >= cutoffSec); + + if (!active && queued.length === 0 && recentVisible.length === 0) { + panel.classList.add('hidden'); + panel.innerHTML = ''; + _paintQueuedAlbumButtons(snapshot); + return; + } + panel.classList.remove('hidden'); + + // Compact summary (always visible). Click to toggle expand. + let html = '
    '; + html += '
    '; + + if (active) { + const total = active.progress_total || 0; + const done = active.progress_processed || 0; + const pct = total > 0 ? Math.round((done / total) * 100) : 0; + const trackBit = active.current_track ? ` — ${escapeHtml(active.current_track)}` : ''; + const albumLabel = _reorgPanelDisplayLabel(active); + html += ``; + html += `Reorganizing ${escapeHtml(albumLabel)}`; + if (total > 0) html += ` (${done}/${total} · ${pct}%)`; + html += `${trackBit}`; + } else if (queued.length > 0) { + html += ``; + html += `Reorganize queue starting…`; + } else { + // Only recent items remain — give a quick wrap-up summary. + const failed = recentVisible.filter(r => r.status === 'failed').length; + const done = recentVisible.filter(r => r.status === 'done').length; + const cls = failed > 0 ? 'recent-warn' : 'recent-ok'; + html += ``; + const parts = []; + if (done > 0) parts.push(`${done} reorganized`); + if (failed > 0) parts.push(`${failed} failed`); + html += `${parts.join(', ') || 'Recent activity'}`; + } + html += '
    '; + + // Right: queue count badge + expand chevron. + html += '
    '; + if (queued.length > 0) { + html += `+${queued.length} queued`; + } + const chev = _reorgPanelExpanded ? '▾' : '▸'; + html += `${chev}`; + html += '
    '; + html += '
    '; + + if (_reorgPanelExpanded) { + html += '
    '; + + // Active card + if (active) { + html += _reorgPanelRenderActiveCard(active); + } + + // Queued list + if (queued.length > 0) { + html += '
    '; + html += `Queued (${queued.length})`; + html += ``; + html += '
    '; + html += '
    '; + queued.forEach((item, idx) => { + html += _reorgPanelRenderQueuedRow(item, idx + 1); + }); + html += '
    '; + } + + // Recent + if (recentVisible.length > 0) { + html += `
    Recent
    `; + html += '
    '; + recentVisible.slice(0, 6).forEach(item => { + html += _reorgPanelRenderRecentRow(item); + }); + html += '
    '; + } + + html += '
    '; + } + + panel.innerHTML = html; + + // Mark per-album reorganize buttons so users see at-a-glance which + // albums are already in the queue without opening the modal. + _paintQueuedAlbumButtons(snapshot); + + // If the active item just transitioned to a recent done/failed + // entry, refresh the enhanced view so the new on-disk paths show. + _maybeReloadEnhancedAfterCompletion(snapshot); +} + +function _reorganizeStateForAlbum(albumId) { + const snap = _reorgPanelLastSnapshot; + if (!snap) return null; + const id = String(albumId); + if (snap.active && String(snap.active.album_id) === id) return 'running'; + if ((snap.queued || []).some(q => String(q.album_id) === id)) return 'queued'; + return null; +} + +function _paintQueuedAlbumButtons(snapshot) { + const queuedIds = new Set(); + const runningIds = new Set(); + if (snapshot?.active) runningIds.add(String(snapshot.active.album_id)); + (snapshot?.queued || []).forEach(q => queuedIds.add(String(q.album_id))); + + document.querySelectorAll('.enhanced-reorganize-album-btn[data-album-id]').forEach(btn => { + const id = btn.dataset.albumId; + if (runningIds.has(id)) { + btn.classList.add('reorg-state-running'); + btn.classList.remove('reorg-state-queued'); + btn.title = 'Reorganize already running for this album'; + } else if (queuedIds.has(id)) { + btn.classList.add('reorg-state-queued'); + btn.classList.remove('reorg-state-running'); + btn.title = 'Album already queued for reorganize'; + } else { + btn.classList.remove('reorg-state-queued', 'reorg-state-running'); + btn.title = 'Reorganize album files using your configured download template'; + } + }); +} + +function _reorgPanelDisplayLabel(item) { + if (!item) return ''; + if (_reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId) { + return `${item.album_title || 'Unknown album'} (${item.artist_name || 'other artist'})`; + } + return item.album_title || 'Unknown album'; +} + +function _reorgPanelRenderActiveCard(active) { + const total = active.progress_total || 0; + const done = active.progress_processed || 0; + const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; + const crossArtist = _reorgPanelArtistId && active.artist_id && String(active.artist_id) !== _reorgPanelArtistId; + + let h = '
    '; + h += `
    ${escapeHtml(active.album_title || 'Unknown album')}`; + if (crossArtist) { + h += ` ${escapeHtml(active.artist_name || 'other artist')}`; + } + h += '
    '; + h += '
    '; + h += `
    `; + h += '
    '; + h += '
    '; + if (total > 0) { + h += `${done}/${total}`; + } + if (active.current_track) { + h += `${escapeHtml(active.current_track)}`; + } + h += ''; + h += `${active.moved || 0} moved`; + if ((active.skipped || 0) > 0) h += `${active.skipped} skipped`; + if ((active.failed || 0) > 0) h += `${active.failed} failed`; + h += ''; + h += '
    '; + h += '
    '; + return h; +} + +function _reorgPanelRenderQueuedRow(item, position) { + const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId; + let h = '
    '; + h += `#${position}`; + h += '
    '; + h += `
    ${escapeHtml(item.album_title || 'Unknown album')}
    `; + if (crossArtist) { + h += `
    ${escapeHtml(item.artist_name || 'other artist')}
    `; + } else if (item.source) { + h += `
    via ${escapeHtml(item.source)}
    `; + } + h += '
    '; + h += ``; + h += '
    '; + return h; +} + +function _reorgPanelRenderRecentRow(item) { + const crossArtist = _reorgPanelArtistId && item.artist_id && String(item.artist_id) !== _reorgPanelArtistId; + const tone = _classifyReorganizeOutcome({ + result_status: item.result_status, + failed: item.failed, + }); + const cls = item.status === 'cancelled' ? 'cancelled' : tone; + let h = `
    `; + h += ``; + h += '
    '; + h += `
    ${escapeHtml(item.album_title || 'Unknown album')}
    `; + let sub; + if (item.status === 'cancelled') { + sub = 'Cancelled'; + } else { + sub = _formatReorganizeResultMessage({ + result_status: item.result_status, + moved: item.moved, + skipped: item.skipped, + failed: item.failed, + errors: item.error ? [{ error: item.error }] : [], + }); + } + if (crossArtist) sub = `${escapeHtml(item.artist_name || 'other artist')} — ${sub}`; + h += `
    ${escapeHtml(sub)}
    `; + h += '
    '; + return h; +} + +function toggleReorganizeStatusPanel() { + _reorgPanelExpanded = !_reorgPanelExpanded; + _renderReorganizeStatusPanel(_reorgPanelLastSnapshot); +} + +async function cancelReorganizeQueueItem(queueId, event) { + if (event) event.stopPropagation(); + if (!queueId) return; + try { + const resp = await fetch(`/api/library/reorganize/queue/${encodeURIComponent(queueId)}/cancel`, { + method: 'POST', + }); + const data = await resp.json(); + if (data.cancelled) { + showToast('Cancelled queued item', 'info'); + } else if (data.reason === 'running_cant_cancel') { + showToast('Already running — too late to cancel', 'warning'); + } else { + showToast('Could not cancel item', 'warning'); + } + } catch (err) { + showToast(`Cancel failed: ${err.message}`, 'error'); + } + refreshReorganizeStatusPanel(); +} + +async function clearReorganizeQueue(event) { + if (event) event.stopPropagation(); + const queued = _reorgPanelLastSnapshot?.queued?.length || 0; + if (queued === 0) return; + const confirmed = await showConfirmDialog({ + title: 'Cancel All Queued', + message: `Cancel ${queued} queued reorganize${queued !== 1 ? 's' : ''}? The currently-running item will continue.`, + confirmText: 'Cancel All', + destructive: true, + }); + if (!confirmed) return; + try { + const resp = await fetch('/api/library/reorganize/queue/clear', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + showToast(`Cancelled ${data.cancelled} queued item${data.cancelled !== 1 ? 's' : ''}`, 'info'); + } + } catch (err) { + showToast(`Clear failed: ${err.message}`, 'error'); + } + refreshReorganizeStatusPanel(); +} + +let _reorgPanelLastActiveId = null; +let _reorgPanelPendingReload = false; +let _reorgPanelReloadTimer = null; + +function _maybeReloadEnhancedAfterCompletion(snapshot) { + // When an item completes for the artist on screen, the moved file + // paths need to be re-rendered in the enhanced view. Two failure + // modes to avoid: + // 1. Reloading mid-batch — a 20-album "Reorganize All" would + // otherwise fire 20 sequential /api/library/artist/X/enhanced + // calls + 20 full re-renders, hammering the server. + // 2. Never reloading — if we wait for queue idle but more items + // keep arriving, the user never sees the freshly-moved paths. + // + // Strategy: mark a reload as pending whenever a completion lands + // for our artist. Defer the reload until the queue is fully idle + // for that artist (no active item, nothing queued) — that's the + // natural "batch finished" boundary. Use a 1.5s timer reset on + // every snapshot so we don't fire while the worker is still + // between items. + const active = snapshot?.active; + const recent = snapshot?.recent || []; + const queued = snapshot?.queued || []; + + // Detect a fresh completion (recent-top is a new queue_id we + // hadn't seen as 'active' before) for our artist. + if (active) { + _reorgPanelLastActiveId = active.queue_id; + } else if (_reorgPanelLastActiveId && recent.length > 0) { + const recentTop = recent[0]; + if (recentTop.queue_id === _reorgPanelLastActiveId) { + const finishedRecently = (recentTop.finished_at || 0) >= ((Date.now() / 1000) - 10); + const sameArtist = _reorgPanelArtistId && + recentTop.artist_id && String(recentTop.artist_id) === _reorgPanelArtistId; + if (finishedRecently && sameArtist) { + _reorgPanelPendingReload = true; + } + _reorgPanelLastActiveId = null; + } + } + + if (!_reorgPanelPendingReload) return; + + // Hold the reload until the queue is fully idle for our artist. + const stillBusyForOurArtist = active && + _reorgPanelArtistId && + active.artist_id && String(active.artist_id) === _reorgPanelArtistId; + const queuedForOurArtist = queued.some(q => + _reorgPanelArtistId && q.artist_id && String(q.artist_id) === _reorgPanelArtistId + ); + + if (stillBusyForOurArtist || queuedForOurArtist) { + // More work coming for this artist — keep the pending flag, + // don't reload yet. Cancel any already-armed timer. + if (_reorgPanelReloadTimer) { + clearTimeout(_reorgPanelReloadTimer); + _reorgPanelReloadTimer = null; + } + return; + } + + // Queue is idle for our artist. Arm a debounced reload — the + // 1.5s gap absorbs the brief window between worker items so a + // back-to-back batch doesn't trigger mid-flight. + if (_reorgPanelReloadTimer) clearTimeout(_reorgPanelReloadTimer); + _reorgPanelReloadTimer = setTimeout(() => { + _reorgPanelReloadTimer = null; + _reorgPanelPendingReload = false; + if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { + loadEnhancedViewData(artistDetailPageState.currentArtistId); + } + }, 1500); +} + + +async function playLibraryTrack(track, albumTitle, artistName) { + if (!track.file_path) { + showToast('No file available for this track', 'error'); + return; + } + + try { + // Stop any current playback first + if (audioPlayer && !audioPlayer.paused) { + audioPlayer.pause(); + } + + // Get album art from enhanced data if available + let albumArt = null; + if (artistDetailPageState.enhancedData) { + const albums = artistDetailPageState.enhancedData.albums || []; + for (const a of albums) { + if ((a.tracks || []).some(t => t.id === track.id)) { + albumArt = a.thumb_url; + break; + } + } + if (!albumArt) albumArt = artistDetailPageState.enhancedData.artist?.thumb_url; + } + if (!albumArt && track._stats_image) albumArt = track._stats_image; + + // Set track info in the media player UI + setTrackInfo({ + title: track.title || 'Unknown Track', + artist: artistName || 'Unknown Artist', + album: albumTitle || 'Unknown Album', + filename: track.file_path, + is_library: true, + image_url: albumArt, + id: track.id, + artist_id: track.artist_id, + album_id: track.album_id, + bitrate: track.bitrate, + sample_rate: track.sample_rate + }); + + // Show loading state + showLoadingAnimation(); + const loadingText = document.querySelector('.loading-text'); + if (loadingText) { + loadingText.textContent = 'Loading library track...'; + } + + // POST to library play endpoint + const response = await fetch('/api/library/play', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: track.file_path, + title: track.title || '', + artist: artistName || '', + album: albumTitle || '' + }) + }); + + const result = await response.json(); + if (!result.success) { + // File not on disk — fall back to streaming from configured source + console.warn('Library file not found, falling back to stream source'); + hideLoadingAnimation(); + const streamRes = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_name: track.title || '', + artist_name: artistName || '', + album_name: albumTitle || '', + }) + }); + const streamData = await streamRes.json(); + if (streamData.success && streamData.result) { + streamData.result.artist = artistName; + streamData.result.title = track.title; + streamData.result.album = albumTitle; + streamData.result.image_url = track._stats_image || null; + startStream(streamData.result); + return; + } + throw new Error(result.error || 'Failed to start library playback'); + } + + // Re-apply repeat-one loop property + if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one'); + // Stream state is already "ready" — start audio playback directly + await startAudioPlayback(); + + } catch (error) { + console.error('Library playback error:', error); + showToast(`Playback error: ${error.message}`, 'error'); + hideLoadingAnimation(); + clearTrack(); + } +} + +// ==================== End Enhanced Library Management View ==================== + +// UI state management functions +function showArtistDetailLoading(show) { + const loadingElement = document.getElementById("artist-detail-loading"); + if (loadingElement) { + if (show) { + loadingElement.classList.remove("hidden"); + } else { + loadingElement.classList.add("hidden"); + } + } +} + +function showArtistDetailError(show, message = "") { + const errorElement = document.getElementById("artist-detail-error"); + const errorMessageElement = document.getElementById("artist-detail-error-message"); + + if (errorElement) { + if (show) { + errorElement.classList.remove("hidden"); + if (errorMessageElement && message) { + errorMessageElement.textContent = message; + } + } else { + errorElement.classList.add("hidden"); + } + } +} + +function showArtistDetailMain(show) { + const mainElement = document.getElementById("artist-detail-main"); + if (mainElement) { + if (show) { + mainElement.classList.remove("hidden"); + } else { + mainElement.classList.add("hidden"); + } + } +} + +function showArtistDetailHero(show) { + const heroElement = document.getElementById("artist-hero-section"); + if (heroElement) { + if (show) { + heroElement.classList.remove("hidden"); + } else { + heroElement.classList.add("hidden"); + } + } +} + +/** + * Initialize the library page watchlist button + */ +async function initializeLibraryWatchlistButton(artistId, artistName) { + const button = document.getElementById('library-artist-watchlist-btn'); + if (!button) return; + + console.log(`🔧 Initializing library watchlist button for: ${artistName} (${artistId})`); + + // Reset button state + button.disabled = false; + button.classList.remove('watching'); + + // Set up click handler + button.onclick = (e) => toggleLibraryWatchlist(e, artistId, artistName); + + // Check and update current status + await updateLibraryWatchlistButtonStatus(artistId); +} + +/** + * Toggle watchlist status for library page + */ +async function toggleLibraryWatchlist(event, artistId, artistName) { + event.preventDefault(); + + const button = document.getElementById('library-artist-watchlist-btn'); + const icon = button.querySelector('.watchlist-icon'); + const text = button.querySelector('.watchlist-text'); + + // Show loading state + const originalText = text.textContent; + text.textContent = 'Loading...'; + button.disabled = true; + + try { + // Check current status + const checkResponse = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const checkData = await checkResponse.json(); + if (!checkData.success) { + throw new Error(checkData.error || 'Failed to check watchlist status'); + } + + const isWatching = checkData.is_watching; + + // Toggle watchlist status + const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; + const payload = isWatching ? + { artist_id: artistId } : + { artist_id: artistId, artist_name: artistName }; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to update watchlist'); + } + + // Update button state based on new status + if (isWatching) { + // Was watching, now removed + icon.textContent = '👁️'; + text.textContent = 'Add to Watchlist'; + button.classList.remove('watching'); + console.log(`❌ Removed ${artistName} from watchlist`); + } else { + // Was not watching, now added + icon.textContent = '👁️'; + text.textContent = 'Watching...'; + button.classList.add('watching'); + console.log(`✅ Added ${artistName} to watchlist`); + } + + // Update dashboard watchlist count if function exists + if (typeof updateWatchlistCount === 'function') { + updateWatchlistCount(); + } + + showToast(data.message, 'success'); + + } catch (error) { + console.error('Error toggling library watchlist:', error); + + // Restore button state + text.textContent = originalText; + showToast(`Error: ${error.message}`, 'error'); + + } finally { + button.disabled = false; + } +} + +/** + * Update library watchlist button status based on current state + */ +async function updateLibraryWatchlistButtonStatus(artistId) { + const button = document.getElementById('library-artist-watchlist-btn'); + if (!button) return; + + try { + const response = await fetch('/api/watchlist/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: artistId }) + }); + + const data = await response.json(); + + if (data.success) { + const icon = button.querySelector('.watchlist-icon'); + const text = button.querySelector('.watchlist-text'); + + if (data.is_watching) { + icon.textContent = '👁️'; + text.textContent = 'Watching...'; + button.classList.add('watching'); + } else { + icon.textContent = '👁️'; + text.textContent = 'Add to Watchlist'; + button.classList.remove('watching'); + } + } + } catch (error) { + console.warn('Failed to check library watchlist status:', error); + } +} + +// ================================= diff --git a/webui/static/media-player.js b/webui/static/media-player.js new file mode 100644 index 00000000..85f25796 --- /dev/null +++ b/webui/static/media-player.js @@ -0,0 +1,2399 @@ +// MEDIA PLAYER FUNCTIONALITY +// =============================== + +function initializeMediaPlayer() { + const trackTitle = document.getElementById('track-title'); + const playButton = document.getElementById('play-button'); + const stopButton = document.getElementById('stop-button'); + const volumeSlider = document.getElementById('volume-slider'); + + // Start in idle state (no track playing) + const player = document.getElementById('media-player'); + if (player && !currentTrack) player.classList.add('idle'); + + // Initialize HTML5 audio player + audioPlayer = document.getElementById('audio-player'); + if (audioPlayer) { + // Set up audio event listeners + audioPlayer.addEventListener('timeupdate', updateAudioProgress); + audioPlayer.addEventListener('ended', onAudioEnded); + audioPlayer.addEventListener('error', onAudioError); + audioPlayer.addEventListener('loadstart', onAudioLoadStart); + audioPlayer.addEventListener('canplay', onAudioCanPlay); + + // Set initial volume + audioPlayer.volume = 0.7; // 70% + if (volumeSlider) volumeSlider.value = 70; + } + + // Track title click handled by initExpandedPlayer's media-player click handler + + // Media controls + playButton.addEventListener('click', handlePlayPause); + stopButton.addEventListener('click', handleStop); + if (volumeSlider) volumeSlider.addEventListener('input', handleVolumeChange); + + // Progress bar controls + const progressBar = document.getElementById('progress-bar'); + if (progressBar) { + // Handle seeking + progressBar.addEventListener('input', handleProgressBarChange); + progressBar.addEventListener('mousedown', () => { + progressBar.dataset.seeking = 'true'; + }); + progressBar.addEventListener('mouseup', () => { + delete progressBar.dataset.seeking; + }); + } + + // Update volume slider styling + if (volumeSlider) volumeSlider.addEventListener('input', updateVolumeSliderAppearance); + + // Mini player prev / next buttons + const miniPrevBtn = document.getElementById('mini-prev-btn'); + const miniNextBtn = document.getElementById('mini-next-btn'); + if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); }); + if (miniNextBtn) miniNextBtn.addEventListener('click', (e) => { e.stopPropagation(); playNextInQueue(); }); +} + +function toggleMediaPlayerExpansion() { + // No-op: controls are always visible in the new layout. + // Kept for backward compatibility with any callers. +} + +function extractTrackTitle(filename) { + if (!filename) return null; + + // Remove file extension + let title = filename.replace(/\.[^/.]+$/, ''); + + // Remove path components, keep only the filename + title = title.split('/').pop().split('\\').pop(); + + // Clean up common filename patterns + title = title + .replace(/^\d+\.?\s*/, '') // Remove track numbers at start + .replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns + .replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end + .replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info + .replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end + .trim(); + + return title || null; +} + +function setTrackInfo(track) { + currentTrack = track; + + const trackTitleElement = document.getElementById('track-title'); + const trackTitle = track.title || 'Unknown Track'; + + // Set up the HTML structure for scrolling + trackTitleElement.innerHTML = `${escapeHtml(trackTitle)}`; + + document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist'; + document.getElementById('album-name').textContent = track.album || 'Unknown Album'; + + // Check if title needs scrolling (similar to GUI app) + setTimeout(() => { + checkAndEnableScrolling(trackTitleElement, trackTitle); + }, 100); // Allow DOM to settle + + // Enable controls + document.getElementById('play-button').disabled = false; + document.getElementById('stop-button').disabled = false; + + // Hide no track message and expand player + document.getElementById('no-track-message').classList.add('hidden'); + document.getElementById('media-player').classList.remove('idle'); + + // Sync expanded player and media session + updateNpTrackInfo(); + updateMediaSessionMetadata(); + updateMediaSessionPlaybackState(); +} + +function checkAndEnableScrolling(element, text) { + // Remove any existing scrolling class and reset styles + element.classList.remove('scrolling'); + element.style.removeProperty('--scroll-distance'); + + // Force a layout to get accurate measurements + element.offsetWidth; + + // Get the inner text element + const titleTextElement = element.querySelector('.title-text'); + if (!titleTextElement) return; + + // Check if text is wider than container + const containerWidth = element.offsetWidth; + const textWidth = titleTextElement.scrollWidth; + + // Enable scrolling if text is significantly wider than container + if (textWidth > containerWidth + 15) { + const scrollDistance = containerWidth - textWidth; + element.style.setProperty('--scroll-distance', `${scrollDistance}px`); + element.classList.add('scrolling'); + console.log(`📜 Enabled scrolling for title: "${text}"`); + console.log(`📜 Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`); + } +} + + +function clearTrack() { + // Clear track state + currentTrack = null; + isPlaying = false; + + const trackTitleElement = document.getElementById('track-title'); + trackTitleElement.innerHTML = 'No track'; + trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation + trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable + + document.getElementById('artist-name').textContent = 'Unknown Artist'; + document.getElementById('album-name').textContent = 'Unknown Album'; + // Reset play button SVGs (don't use textContent — it destroys SVG children) + const clearPlayBtn = document.getElementById('play-button'); + const clearPlayIcon = clearPlayBtn.querySelector('.play-icon'); + const clearPauseIcon = clearPlayBtn.querySelector('.pause-icon'); + if (clearPlayIcon) clearPlayIcon.style.display = ''; + if (clearPauseIcon) clearPauseIcon.style.display = 'none'; + clearPlayBtn.disabled = true; + document.getElementById('stop-button').disabled = true; + + // Reset progress bar and time displays + const progressBar = document.getElementById('progress-bar'); + const progressFill = document.getElementById('progress-fill'); + if (progressBar) { + progressBar.value = 0; + delete progressBar.dataset.seeking; + } + if (progressFill) { + progressFill.style.width = '0%'; + } + + const currentTimeElement = document.getElementById('current-time'); + const totalTimeElement = document.getElementById('total-time'); + if (currentTimeElement) currentTimeElement.textContent = '0:00'; + if (totalTimeElement) totalTimeElement.textContent = '0:00'; + + // Hide loading animation + hideLoadingAnimation(); + + // Show no track message and collapse player + document.getElementById('no-track-message').classList.remove('hidden'); + document.getElementById('media-player').classList.add('idle'); + + // Reset queue state + npQueue = []; + npQueueIndex = -1; + + // Sync expanded player and media session + updateNpTrackInfo(); + updateNpPlayButton(); + updateNpProgress(); + renderNpQueue(); + updateNpPrevNextButtons(); + updateMediaSessionPlaybackState(); + stopSidebarVisualizer(); + if (npModalOpen) closeNowPlayingModal(); + + console.log('🧹 Track cleared and media player reset'); +} + +function setPlayingState(playing) { + isPlaying = playing; + const playButton = document.getElementById('play-button'); + // Toggle SVG icons (don't use textContent — it destroys SVG children) + const playIcon = playButton.querySelector('.play-icon'); + const pauseIcon = playButton.querySelector('.pause-icon'); + if (playIcon) playIcon.style.display = playing ? 'none' : ''; + if (pauseIcon) pauseIcon.style.display = playing ? '' : 'none'; + updateNpPlayButton(); + updateMediaSessionPlaybackState(); + + // Sidebar audio visualizer + if (playing) { + npInitVisualizer(); + startSidebarVisualizer(); + } else { + stopSidebarVisualizer(); + } +} + +async function handlePlayPause() { + // Use new streaming system toggle function + togglePlayback(); +} + +async function handleStop() { + // Use new streaming system stop function + await stopStream(); + clearTrack(); +} + +function handleVolumeChange(event) { + const volume = event.target.value; + updateVolumeSliderAppearance(); + + // Update HTML5 audio player volume + if (audioPlayer) { + audioPlayer.volume = volume / 100; + } + + // Sync modal volume and clear mute state + npMuted = false; + const npVol = document.getElementById('np-volume-slider'); + const npFill = document.getElementById('np-volume-fill'); + if (npVol) npVol.value = volume; + if (npFill) npFill.style.width = volume + '%'; + updateNpMuteIcon(); +} + +function handleProgressBarChange(event) { + // Handle seeking in the audio track + if (!audioPlayer || !audioPlayer.duration) return; + + const progress = parseFloat(event.target.value); + const newTime = (progress / 100) * audioPlayer.duration; + + console.log(`🎯 Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`); + + try { + audioPlayer.currentTime = newTime; + + // Update visual progress immediately + const progressFill = document.getElementById('progress-fill'); + if (progressFill) { + progressFill.style.width = `${progress}%`; + } + + // Update time displays immediately + const currentTimeElement = document.getElementById('current-time'); + if (currentTimeElement) { + currentTimeElement.textContent = formatTime(newTime); + } + + // Sync modal progress + const npBar = document.getElementById('np-progress-bar'); + const npFill = document.getElementById('np-progress-fill'); + const npTime = document.getElementById('np-current-time'); + if (npBar) npBar.value = progress; + if (npFill) npFill.style.width = progress + '%'; + if (npTime) npTime.textContent = formatTime(newTime); + } catch (error) { + console.warn('⚠️ Seek failed:', error.message); + // Reset progress bar to current position + const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100; + event.target.value = actualProgress; + const progressFill = document.getElementById('progress-fill'); + if (progressFill) { + progressFill.style.width = `${actualProgress}%`; + } + } +} + +function updateVolumeSliderAppearance() { + const slider = document.getElementById('volume-slider'); + if (!slider) return; + const value = slider.value; + slider.style.setProperty('--volume-percent', `${value}%`); +} + +function showLoadingAnimation() { + document.getElementById('loading-animation').classList.remove('hidden'); +} + +function hideLoadingAnimation() { + document.getElementById('loading-animation').classList.add('hidden'); +} + +function setLoadingProgress(percentage) { + const loadingAnimation = document.getElementById('loading-animation'); + const progressBar = loadingAnimation.querySelector('.loading-progress'); + const loadingText = loadingAnimation.querySelector('.loading-text'); + + loadingAnimation.classList.remove('hidden'); + progressBar.style.width = `${percentage}%`; + loadingText.textContent = `${Math.round(percentage)}%`; +} + +// =============================== +// STREAMING FUNCTIONALITY +// =============================== + +let _streamLock = false; + +async function startStream(searchResult) { + // Start streaming a track - handles same track toggle and new track streaming + try { + // Prevent multiple concurrent stream starts (rapid clicking) + if (_streamLock) { + console.log('⏳ Stream already starting, ignoring duplicate click'); + return; + } + + console.log(`🎮 startStream() called with data:`, searchResult); + + // Check if this is the same track that's currently playing/loading + const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null; + const newTrackId = `${searchResult.username}:${searchResult.filename}`; + + console.log(`🎮 startStream() called for: ${searchResult.filename}`); + console.log(`🎮 Current track ID: ${currentTrackId}`); + console.log(`🎮 New track ID: ${newTrackId}`); + + if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) { + // Same track clicked while playing - toggle pause + console.log("🔄 Toggling playback for same track"); + togglePlayback(); + return; + } + + // Lock to prevent duplicate stream starts + _streamLock = true; + + // Different track or no current track - start new stream + console.log("🎵 Starting new stream"); + + // Stop current streaming/playback if any + await stopStream(); + + // Set track info and show loading state + setTrackInfo({ + title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track', + artist: searchResult.artist || searchResult.username || 'Unknown Artist', + album: searchResult.album || 'Unknown Album', + username: searchResult.username, + filename: searchResult.filename, + image_url: searchResult.image_url || searchResult.album_cover_url || null + }); + + showLoadingAnimation(); + setLoadingProgress(0); + + // Start streaming request + const response = await fetch(API.stream.start, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(searchResult) + }); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + if (!data.success) { + throw new Error(data.error || 'Failed to start streaming'); + } + + console.log("✅ Stream started successfully"); + + // Start status polling + startStreamStatusPolling(); + + } catch (error) { + console.error('Error starting stream:', error); + showToast(`Failed to start stream: ${error.message}`, 'error'); + hideLoadingAnimation(); + clearTrack(); + } finally { + _streamLock = false; + } +} + +function startStreamStatusPolling() { + // Start polling for stream status updates with retry logic + if (streamStatusPoller) { + clearInterval(streamStatusPoller); + } + + // Reset polling state + streamPollingRetries = 0; + streamPollingInterval = 1000; // Reset to 1-second interval + + console.log('🔄 Starting enhanced stream status polling'); + updateStreamStatus(); // Initial check + streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); +} + +function stopStreamStatusPolling() { + // Stop polling for stream status updates + if (streamStatusPoller) { + clearInterval(streamStatusPoller); + streamStatusPoller = null; + streamPollingRetries = 0; + streamPollingInterval = 1000; // Reset interval + console.log('⏹️ Stopped stream status polling'); + } +} + +// Phase 4: Track last known tool statuses to prevent repeated toasts on terminal states +let _lastToolStatus = {}; + +// Phase 5: Sync/Discovery/Scan WebSocket router functions +function updateSyncProgressFromData(data) { + const pid = data.playlist_id; + const callback = _syncProgressCallbacks[pid]; + if (callback) callback(data); +} + +function updateDiscoveryProgressFromData(data) { + const id = data.id; + const callback = _discoveryProgressCallbacks[id]; + if (callback) callback(data); +} + +function updateWatchlistScanFromData(data) { + if (!data.success) return; + if (_lastWatchlistScanStatus === data.status && data.status !== 'scanning') return; + _lastWatchlistScanStatus = data.status; + handleWatchlistScanData(data); +} + +function updateMediaScanFromData(data) { + if (!data.success || !data.status) return; + const status = data.status; + const statusKey = status.is_scanning ? 'scanning' : (status.status || 'unknown'); + if (_lastMediaScanStatus === statusKey && statusKey !== 'scanning') return; + _lastMediaScanStatus = statusKey; + + const phaseLabel = document.getElementById('media-scan-phase-label'); + const progressLabel = document.getElementById('media-scan-progress-label'); + const button = document.getElementById('media-scan-btn'); + const progressBar = document.getElementById('media-scan-progress-bar'); + const statusValue = document.getElementById('media-scan-status'); + + if (status.is_scanning) { + if (phaseLabel) phaseLabel.textContent = 'Media server scanning...'; + if (progressLabel) progressLabel.textContent = status.progress_message || 'Scan in progress'; + } else if (status.status === 'idle') { + if (button) button.disabled = false; + if (phaseLabel) phaseLabel.textContent = 'Scan completed successfully'; + if (progressBar) progressBar.style.width = '0%'; + if (progressLabel) progressLabel.textContent = 'Ready for next scan'; + if (statusValue) { + statusValue.textContent = 'Idle'; + statusValue.style.color = '#b3b3b3'; + } + showToast('✅ Media scan completed', 'success', 3000); + } +} + +let _wishlistAutoProcessingNotified = false; +function updateWishlistStatsFromData(data) { + // Auto-processing detection: close modal and notify (once only) + if (data.is_auto_processing) { + if (!_wishlistAutoProcessingNotified) { + if (currentPage === 'wishlist') navigateToPage('active-downloads'); + showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); + _wishlistAutoProcessingNotified = true; + } + return; + } + // Reset flag when auto-processing ends + _wishlistAutoProcessingNotified = false; + // Store latest stats for countdown timer refresh + _lastWishlistStats = data; +} + +async function updateStreamStatus() { + if (socketConnected) return; // WebSocket handles this + // Poll server for streaming progress and handle state changes with enhanced error recovery + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout + + const response = await fetch(API.stream.status, { + signal: controller.signal + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + + // Reset retry count on successful response + streamPollingRetries = 0; + streamPollingInterval = 1000; // Reset to normal interval + + // Update current stream state + currentStream.status = data.status; + currentStream.progress = data.progress; + + switch (data.status) { + case 'loading': + setLoadingProgress(data.progress); + // Update loading text with progress + const loadingText = document.querySelector('.loading-text'); + if (loadingText && data.progress > 0) { + loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; + } + break; + + case 'queued': + // Show queue status with better messaging + const queueText = document.querySelector('.loading-text'); + if (queueText) { + queueText.textContent = 'Queuing with uploader...'; + } + setLoadingProgress(0); // Reset progress for queue state + break; + + case 'ready': + // Stream is ready - start audio playback + console.log('🎵 Stream ready, starting audio playback'); + stopStreamStatusPolling(); + // Restore player UI if JS state was wiped (e.g. page refresh) + if (!currentTrack && data.track_info) { + const ti = data.track_info; + setTrackInfo({ + title: ti.name || ti.title || 'Unknown Track', + artist: ti.artist || 'Unknown Artist', + album: ti.album || 'Unknown Album', + filename: ti.filename || '', + is_library: !!ti.is_library, + image_url: ti.image_url || null, + id: ti.id || null, + artist_id: ti.artist_id || null, + album_id: ti.album_id || null, + }); + } + await startAudioPlayback(); + break; + + case 'error': + console.error('❌ Streaming error:', data.error_message); + stopStreamStatusPolling(); + hideLoadingAnimation(); + showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); + clearTrack(); + break; + + case 'stopped': + // Handle stopped state — do NOT clear track here; explicit stop (handleStop) + // calls clearTrack() directly. Clearing here collapses the player mid-playback + // when the backend transitions to 'stopped' after audio naturally ends or during + // queue track transitions. + console.log('🛑 Stream stopped'); + stopStreamStatusPolling(); + hideLoadingAnimation(); + break; + } + + } catch (error) { + streamPollingRetries++; + console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message); + + if (streamPollingRetries >= maxStreamPollingRetries) { + // Too many consecutive failures - give up + console.error('❌ Stream status polling failed after maximum retries'); + stopStreamStatusPolling(); + hideLoadingAnimation(); + showToast('Lost connection to streaming server', 'error'); + clearTrack(); + } else { + // Implement exponential backoff for retries + const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff + streamPollingInterval = 1000 * backoffMultiplier; + + // Restart polling with new interval + if (streamStatusPoller) { + clearInterval(streamStatusPoller); + streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); + console.log(`🔄 Retrying stream status polling with ${streamPollingInterval}ms interval`); + } + } + } +} + +function updateStreamStatusFromData(data) { + const prev = _lastToolStatus['stream']; + _lastToolStatus['stream'] = data.status; + // Skip repeated terminal states to avoid duplicate toasts/actions + if (prev !== undefined && data.status === prev && data.status !== 'loading' && data.status !== 'queued') return; + + currentStream.status = data.status; + currentStream.progress = data.progress; + + switch (data.status) { + case 'loading': + setLoadingProgress(data.progress); + const loadingText = document.querySelector('.loading-text'); + if (loadingText && data.progress > 0) { + loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; + } + break; + case 'queued': + const queueText = document.querySelector('.loading-text'); + if (queueText) { + queueText.textContent = 'Queuing with uploader...'; + } + setLoadingProgress(0); + break; + case 'ready': + console.log('🎵 Stream ready, starting audio playback'); + stopStreamStatusPolling(); + // Restore player UI if JS state was wiped (e.g. page refresh) + if (!currentTrack && data.track_info) { + const ti = data.track_info; + setTrackInfo({ + title: ti.name || ti.title || 'Unknown Track', + artist: ti.artist || 'Unknown Artist', + album: ti.album || 'Unknown Album', + filename: ti.filename || '', + is_library: !!ti.is_library, + image_url: ti.image_url || null, + id: ti.id || null, + artist_id: ti.artist_id || null, + album_id: ti.album_id || null, + }); + } + startAudioPlayback(); + break; + case 'error': + console.error('❌ Streaming error:', data.error_message); + stopStreamStatusPolling(); + hideLoadingAnimation(); + showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); + clearTrack(); + break; + case 'stopped': + // Do NOT clear track here — explicit stop (handleStop) calls clearTrack() directly. + // Clearing here collapses the player after audio naturally ends or during queue transitions. + console.log('🛑 Stream stopped'); + stopStreamStatusPolling(); + hideLoadingAnimation(); + break; + } +} + +async function startAudioPlayback() { + // Start HTML5 audio playback of the streamed file with enhanced state management + try { + if (!audioPlayer) { + throw new Error('Audio player not initialized'); + } + + // Show loading state while preparing audio + const loadingText = document.querySelector('.loading-text'); + if (loadingText) { + loadingText.textContent = 'Preparing playback...'; + } + + // Set audio source with cache-busting timestamp + const audioUrl = `/stream/audio?t=${new Date().getTime()}`; + console.log(`🎵 Loading audio from: ${audioUrl}`); + + // Clear any existing source first + audioPlayer.pause(); + audioPlayer.currentTime = 0; + audioPlayer.src = ''; + + // Set new source + audioPlayer.src = audioUrl; + audioPlayer.load(); // Force reload + + // Wait for audio to be ready with promise-based approach + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Audio loading timeout')); + }, 15000); // 15-second timeout + + const onCanPlay = () => { + clearTimeout(timeout); + audioPlayer.removeEventListener('canplay', onCanPlay); + audioPlayer.removeEventListener('error', onError); + resolve(); + }; + + const onError = (event) => { + clearTimeout(timeout); + audioPlayer.removeEventListener('canplay', onCanPlay); + audioPlayer.removeEventListener('error', onError); + const error = event.target.error || new Error('Audio loading failed'); + reject(error); + }; + + audioPlayer.addEventListener('canplay', onCanPlay); + audioPlayer.addEventListener('error', onError); + + // If already ready, resolve immediately + if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA + onCanPlay(); + } + }); + + console.log('✅ Audio loaded and ready for playback'); + + // Try to start playback with retry logic + let retryCount = 0; + const maxRetries = 3; + + while (retryCount < maxRetries) { + try { + await audioPlayer.play(); + console.log('✅ Audio playback started successfully'); + + // Update UI to playing state + hideLoadingAnimation(); + setPlayingState(true); + + // Show media player if hidden + const noTrackMessage = document.getElementById('no-track-message'); + if (noTrackMessage) { + noTrackMessage.classList.add('hidden'); + } + + // Update volume to current slider value + const volumeSlider = document.getElementById('volume-slider'); + if (volumeSlider) { + audioPlayer.volume = volumeSlider.value / 100; + } + + // Enable play/stop buttons + const playButton = document.getElementById('play-button'); + const stopButton = document.getElementById('stop-button'); + if (playButton) playButton.disabled = false; + if (stopButton) stopButton.disabled = false; + + return; // Success! + + } catch (playError) { + retryCount++; + console.warn(`⚠️ Audio play attempt ${retryCount} failed:`, playError.message); + + if (retryCount >= maxRetries) { + throw playError; // Re-throw after max retries + } + + // Wait before retry with exponential backoff + await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); + } + } + + } catch (error) { + console.error('❌ Error starting audio playback:', error); + hideLoadingAnimation(); + + // Provide user-friendly error messages + let userMessage = 'Playback failed'; + + if (error.message.includes('no supported source') || + error.message.includes('Not supported') || + error.message.includes('MEDIA_ELEMENT_ERROR')) { + userMessage = 'Audio format not supported by your browser. Try downloading instead.'; + } else if (error.message.includes('network') || error.message.includes('fetch')) { + userMessage = 'Network error - please check your connection'; + } else if (error.message.includes('decode')) { + userMessage = 'Audio file is corrupted or incompatible'; + } else if (error.message.includes('timeout')) { + userMessage = 'Audio loading timeout - file may be too large'; + } else if (error.message.includes('AbortError')) { + userMessage = 'Playback was interrupted'; + } + + showToast(userMessage, 'error'); + // Only clear track if not in queue playback mode — queue handles its own error recovery + if (npQueue.length === 0) { + clearTrack(); + } + } +} + +async function stopStream() { + // Stop streaming and clean up all state + try { + // Stop status polling + stopStreamStatusPolling(); + + // Stop audio playback + if (audioPlayer) { + audioPlayer.pause(); + audioPlayer.src = ''; + } + + // Call backend stop endpoint + const response = await fetch(API.stream.stop, { method: 'POST' }); + if (response.ok) { + const data = await response.json(); + console.log('🛑 Stream stopped:', data.message); + } + + // Reset UI state + hideLoadingAnimation(); + setPlayingState(false); + + // Reset stream state + currentStream = { + status: 'stopped', + progress: 0, + track: null + }; + + } catch (error) { + console.error('Error stopping stream:', error); + } +} + +function togglePlayback() { + // Toggle play/pause for currently loaded audio + if (!audioPlayer || !currentTrack) { + console.log('⚠️ No audio player or track to toggle'); + return; + } + + if (audioPlayer.paused) { + audioPlayer.play() + .then(() => { + setPlayingState(true); + console.log('▶️ Resumed playback'); + }) + .catch(error => { + console.error('Error resuming playback:', error); + showToast('Failed to resume playback', 'error'); + }); + } else { + audioPlayer.pause(); + setPlayingState(false); + console.log('⏸️ Paused playback'); + } +} + +// =============================== +// AUDIO EVENT HANDLERS +// =============================== + +function updateAudioProgress() { + // Update progress bar based on audio playback time + if (!audioPlayer || !audioPlayer.duration) return; + + const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100; + + // Update progress bar + const progressBar = document.getElementById('progress-bar'); + const progressFill = document.getElementById('progress-fill'); + if (progressBar && !progressBar.dataset.seeking) { + progressBar.value = progress; + // Update visual progress fill + if (progressFill) { + progressFill.style.width = `${progress}%`; + } + } + + // Update time display + const currentTimeElement = document.getElementById('current-time'); + const totalTimeElement = document.getElementById('total-time'); + + if (currentTimeElement) { + currentTimeElement.textContent = formatTime(audioPlayer.currentTime); + } + if (totalTimeElement) { + totalTimeElement.textContent = formatTime(audioPlayer.duration); + } + + // Sync expanded player modal + if (npModalOpen) updateNpProgress(); +} + +function onAudioEnded() { + // Handle audio playback completion + console.log('🏁 Audio playback ended'); + setPlayingState(false); + + // Reset progress to beginning + const progressBar = document.getElementById('progress-bar'); + const progressFill = document.getElementById('progress-fill'); + if (progressBar) { + progressBar.value = 0; + } + if (progressFill) { + progressFill.style.width = '0%'; + } + + const currentTimeElement = document.getElementById('current-time'); + if (currentTimeElement) { + currentTimeElement.textContent = '0:00'; + } + + // Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat) + // Auto-advance to next track if queue has a next item (guard against race conditions) + if (npQueue.length > 0 && !npLoadingQueueItem) { + const hasNext = npShuffleOn + ? npQueue.length > 1 + : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'); + if (hasNext) { playNextInQueue(); return; } + } + + // Radio mode: auto-fetch similar tracks when queue is exhausted + if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) { + npFetchRadioTracks(); + } +} + +function onAudioError(event) { + // Handle audio playback errors + const error = event.target.error; + console.error('❌ Audio error:', error); + + // Don't show error toast if it's just a format/codec issue and retrying + if (error && error.code) { + console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`); + + // Only show user-facing errors for serious issues + if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported + console.warn('⚠️ Media format not supported by browser, but streaming may still work'); + // Don't clear track or show error - let retry logic handle it + return; + } + } + + hideLoadingAnimation(); + + // Only clear track after a short delay to allow for recovery + setTimeout(() => { + if (audioPlayer && audioPlayer.error) { + let userMessage = 'Audio format not supported by your browser. Try downloading instead.'; + + if (error && error.code) { + switch (error.code) { + case 1: // MEDIA_ERR_ABORTED + userMessage = 'Playback was stopped'; + break; + case 2: // MEDIA_ERR_NETWORK + userMessage = 'Network error - please try again'; + break; + case 3: // MEDIA_ERR_DECODE + userMessage = 'Audio file is corrupted or incompatible'; + break; + case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED + userMessage = 'Audio format not supported by your browser. Try downloading instead.'; + break; + } + } + + showToast(userMessage, 'error'); + // Only clear track if not in queue playback — queue handles its own recovery + if (npQueue.length === 0) { + clearTrack(); + } + } + }, 2000); +} + +function onAudioLoadStart() { + // Handle audio load start + console.log('🔄 Audio loading started'); +} + +function onAudioCanPlay() { + // Handle when audio can start playing + console.log('✅ Audio ready to play'); +} + +function formatTime(seconds) { + // Format seconds as MM:SS + if (!seconds || !isFinite(seconds)) return '0:00'; + + const minutes = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${minutes}:${secs.toString().padStart(2, '0')}`; +} + +function formatCountdownTime(seconds) { + // Format seconds as countdown timer (e.g., "24m 13s", "2h 15m", "23h 59m") + if (seconds === null || seconds === undefined || seconds < 0) return ''; + if (seconds === 0) return '0s'; // Show "0s" instead of hiding timer + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } else if (minutes > 0) { + return `${minutes}m ${secs}s`; + } else { + return `${secs}s`; + } +} + +// =============================== +// AUDIO FORMAT SUPPORT DETECTION +// =============================== + +function getFileExtension(filename) { + if (!filename) return ''; + const ext = filename.toLowerCase().match(/\.([^.]+)$/); + return ext ? ext[1] : ''; +} + +function isAudioFormatSupported(filename) { + const ext = getFileExtension(filename); + const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats + const partialSupport = ['flac', 'aac', 'm4a', 'opus', 'webm']; // Test browser support + const unsupported = ['wma', 'ape', 'aiff']; // Generally problematic + + if (supportedFormats.includes(ext)) { + return true; + } + + if (partialSupport.includes(ext)) { + // Test if browser can actually play this format + return canPlayAudioFormat(ext); + } + + return false; // Unsupported formats +} + +function canPlayAudioFormat(extension) { + const audio = document.createElement('audio'); + + const mimeTypes = { + 'mp3': 'audio/mpeg', + 'ogg': 'audio/ogg; codecs="vorbis"', + 'wav': 'audio/wav', + 'flac': 'audio/flac', + 'aac': 'audio/aac', + 'm4a': 'audio/mp4; codecs="mp4a.40.2"', // More specific M4A MIME type + 'opus': 'audio/ogg; codecs="opus"', + 'webm': 'audio/webm; codecs="opus"', + 'wma': 'audio/x-ms-wma' + }; + + const mimeType = mimeTypes[extension]; + if (!mimeType) { + console.warn(`🎵 [FORMAT CHECK] No MIME type found for extension: ${extension}`); + return false; + } + + const canPlay = audio.canPlayType(mimeType); + console.log(`🎵 [FORMAT CHECK] ${extension} (${mimeType}): ${canPlay}`); + + let isSupported = canPlay === 'probably' || canPlay === 'maybe'; + + // Special handling for M4A - try fallback MIME types if first one fails + if (!isSupported && extension === 'm4a') { + const fallbackMimeTypes = ['audio/mp4', 'audio/x-m4a', 'audio/aac']; + console.log(`🎵 [FORMAT CHECK] M4A failed with primary MIME type, trying fallbacks...`); + + for (const fallbackMime of fallbackMimeTypes) { + const fallbackResult = audio.canPlayType(fallbackMime); + console.log(`🎵 [FORMAT CHECK] M4A fallback (${fallbackMime}): ${fallbackResult}`); + if (fallbackResult === 'probably' || fallbackResult === 'maybe') { + isSupported = true; + console.log(`🎵 [FORMAT CHECK] M4A supported with fallback MIME type: ${fallbackMime}`); + break; + } + } + } + + console.log(`🎵 [FORMAT CHECK] ${extension} final support result: ${isSupported}`); + return isSupported; +} + +// =============================== +// EXPANDED NOW PLAYING MODAL +// =============================== + +let npModalOpen = false; +let npRepeatMode = 'off'; // 'off' | 'all' | 'one' +let npShuffleOn = false; +let npQueue = []; +let npQueueIndex = -1; +let npMuted = false; +let npPreMuteVolume = 70; +let npMediaSessionThrottle = 0; +let npLoadingQueueItem = false; +let npRadioMode = false; +let npRecentlyPlayedIds = []; +let npAudioContext = null; +let npAnalyser = null; +let npMediaSource = null; +let npVizAnimFrame = null; +let npVizInitialized = false; + +function initExpandedPlayer() { + const closeBtn = document.getElementById('np-close-btn'); + const overlay = document.getElementById('np-modal-overlay'); + const playBtn = document.getElementById('np-play-btn'); + const stopBtn = document.getElementById('np-stop-btn'); + const shuffleBtn = document.getElementById('np-shuffle-btn'); + const repeatBtn = document.getElementById('np-repeat-btn'); + const muteBtn = document.getElementById('np-mute-btn'); + const npProgressBar = document.getElementById('np-progress-bar'); + const npVolumeSlider = document.getElementById('np-volume-slider'); + + if (!overlay) return; + + // Close handlers + closeBtn.addEventListener('click', closeNowPlayingModal); + overlay.addEventListener('click', (e) => { if (e.target === overlay) closeNowPlayingModal(); }); + + // Control handlers + playBtn.addEventListener('click', () => { togglePlayback(); }); + stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); }); + shuffleBtn.addEventListener('click', handleNpShuffle); + repeatBtn.addEventListener('click', handleNpRepeat); + muteBtn.addEventListener('click', handleNpMuteToggle); + + // Progress bar (mouse) + npProgressBar.addEventListener('input', handleNpProgressBarChange); + npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; }); + npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; }); + + // Progress bar (touch) + npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true }); + npProgressBar.addEventListener('touchmove', (e) => { + const touch = e.touches[0]; + const rect = npProgressBar.getBoundingClientRect(); + const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100)); + npProgressBar.value = pct; + npProgressBar.dispatchEvent(new Event('input')); + }, { passive: true }); + npProgressBar.addEventListener('touchend', () => { delete npProgressBar.dataset.seeking; }, { passive: true }); + + // Volume slider + npVolumeSlider.addEventListener('input', handleNpVolumeChange); + + // Keyboard shortcuts (global) + document.addEventListener('keydown', handlePlayerKeyboardShortcuts); + + // Make sidebar media player clickable to open modal + const mediaPlayer = document.getElementById('media-player'); + if (mediaPlayer) { + mediaPlayer.style.cursor = 'pointer'; + mediaPlayer.addEventListener('click', (e) => { + // Don't open modal when clicking controls (let expand-hint through) + if (e.target.closest('.play-button, .stop-button, .volume-slider, .volume-control, .progress-bar, .volume-icon, .mini-nav-btn') && !e.target.closest('.expand-hint')) return; + if (currentTrack) openNowPlayingModal(); + }); + } + + // Prev / Next buttons + const prevBtn = document.getElementById('np-prev-btn'); + const nextBtn = document.getElementById('np-next-btn'); + if (prevBtn) prevBtn.addEventListener('click', () => { playPreviousInQueue(); }); + if (nextBtn) nextBtn.addEventListener('click', () => { playNextInQueue(); }); + + // Queue panel toggle + clear + const queueToggle = document.getElementById('np-queue-toggle'); + if (queueToggle) { + queueToggle.addEventListener('click', () => { + const body = document.getElementById('np-queue-body'); + if (body) body.classList.toggle('hidden'); + queueToggle.classList.toggle('active'); + }); + } + const queueClearBtn = document.getElementById('np-queue-clear'); + if (queueClearBtn) queueClearBtn.addEventListener('click', () => { clearQueue(); }); + + // Radio mode button + const radioBtn = document.getElementById('np-radio-btn'); + if (radioBtn) { + radioBtn.addEventListener('click', () => { + npRadioMode = !npRadioMode; + radioBtn.classList.toggle('active', npRadioMode); + showToast(npRadioMode ? 'Radio mode on — similar tracks will auto-queue' : 'Radio mode off', 'success'); + // Immediately fetch radio tracks if turned on while playing with empty/exhausted queue + if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) { + const hasNext = npQueue.length > 0 && (npShuffleOn + ? npQueue.length > 1 + : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all')); + if (!hasNext) { + // Add current track to queue first so it appears as "now playing" in context + if (npQueue.length === 0 && currentTrack.is_library) { + npQueue.push({ + title: currentTrack.title, + artist: currentTrack.artist, + album: currentTrack.album, + file_path: currentTrack.filename || currentTrack.file_path, + filename: currentTrack.filename || currentTrack.file_path, + is_library: true, + image_url: currentTrack.image_url, + id: currentTrack.id, + artist_id: currentTrack.artist_id, + album_id: currentTrack.album_id, + bitrate: currentTrack.bitrate + }); + npQueueIndex = 0; + renderNpQueue(); + updateNpPrevNextButtons(); + } + npFetchRadioTracks(); + } + } + }); + } + + // Action button (Go to Artist) + const gotoArtistBtn = document.getElementById('np-goto-artist'); + if (gotoArtistBtn) { + gotoArtistBtn.addEventListener('click', () => { + if (currentTrack && currentTrack.artist_id) { + closeNowPlayingModal(); + navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || ''); + } + }); + } + // Buffering state listeners on audioPlayer + if (audioPlayer) { + audioPlayer.addEventListener('waiting', () => { + const ring = document.getElementById('np-buffering-ring'); + if (ring) ring.classList.remove('hidden'); + }); + audioPlayer.addEventListener('canplay', () => { + const ring = document.getElementById('np-buffering-ring'); + if (ring) ring.classList.add('hidden'); + }); + audioPlayer.addEventListener('playing', () => { + const ring = document.getElementById('np-buffering-ring'); + if (ring) ring.classList.add('hidden'); + }); + } + + // Init Media Session API + initMediaSession(); +} + +function openNowPlayingModal() { + const overlay = document.getElementById('np-modal-overlay'); + if (!overlay) return; + npModalOpen = true; + overlay.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + syncExpandedPlayerUI(); + // Start visualizer if already playing + if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); } +} + +function closeNowPlayingModal() { + const overlay = document.getElementById('np-modal-overlay'); + if (!overlay) return; + npModalOpen = false; + overlay.classList.add('hidden'); + document.body.style.overflow = ''; + npStopVisualizerLoop(); +} + +function syncExpandedPlayerUI() { + if (!npModalOpen) return; + + // Track info + updateNpTrackInfo(); + + // Play state + updateNpPlayButton(); + + // Progress + updateNpProgress(); + + // Volume + const sidebarVol = document.getElementById('volume-slider'); + const npVol = document.getElementById('np-volume-slider'); + const npVolFill = document.getElementById('np-volume-fill'); + if (sidebarVol && npVol) { + npVol.value = sidebarVol.value; + if (npVolFill) npVolFill.style.width = sidebarVol.value + '%'; + } + + // Visualizer + const viz = document.getElementById('np-visualizer'); + if (viz) viz.classList.toggle('playing', isPlaying); + + // Queue + renderNpQueue(); + updateNpPrevNextButtons(); +} + +function updateNpTrackInfo() { + const titleEl = document.getElementById('np-track-title'); + const artistEl = document.getElementById('np-artist-name'); + const albumEl = document.getElementById('np-album-name'); + const artImg = document.getElementById('np-album-art'); + const artPlaceholder = document.getElementById('np-album-art-placeholder'); + const badgesEl = document.getElementById('np-format-badges'); + const actionBtns = document.getElementById('np-action-buttons'); + + if (!titleEl) return; + + // Sidebar album art + const sidebarArt = document.getElementById('sidebar-album-art'); + + if (currentTrack) { + // Track text transition animation + const textEls = [titleEl, artistEl, albumEl]; + const oldTitle = titleEl.textContent; + const newTitle = currentTrack.title || 'Unknown Track'; + const trackChanged = oldTitle !== newTitle && oldTitle !== 'No track'; + + titleEl.textContent = newTitle; + artistEl.textContent = currentTrack.artist || 'Unknown Artist'; + albumEl.textContent = currentTrack.album || 'Unknown Album'; + + if (trackChanged) { + textEls.forEach(el => { + el.classList.remove('np-text-transition'); + void el.offsetWidth; // force reflow + el.classList.add('np-text-transition'); + }); + } + + // Album art (modal + sidebar) + ambient glow extraction + const artUrl = getNpAlbumArtUrl(); + if (artUrl && artImg) { + // Only set crossOrigin for external URLs — local paths break with CORS headers + if (artUrl.startsWith('http')) { + artImg.crossOrigin = 'anonymous'; + } else { + artImg.removeAttribute('crossOrigin'); + } + artImg.src = artUrl; + artImg.classList.remove('hidden'); + artImg.onerror = () => { artImg.classList.add('hidden'); npResetAmbientGlow(); }; + artImg.onload = () => { npExtractAmbientColor(artImg); }; + } else if (artImg) { + artImg.classList.add('hidden'); + npResetAmbientGlow(); + } + if (sidebarArt) { + if (artUrl) { + sidebarArt.src = artUrl; + sidebarArt.style.display = ''; + sidebarArt.onerror = () => { sidebarArt.src = '/static/trans2.png'; }; + } else { + sidebarArt.src = '/static/trans2.png'; + } + } + + // Format badges (richer: include bitrate/sample_rate) + if (badgesEl) { + badgesEl.innerHTML = ''; + const filename = currentTrack.filename || ''; + if (filename) { + const ext = getFileExtension(filename); + if (ext) { + let label = ext.toUpperCase(); + if (currentTrack.sample_rate) { + const khz = (currentTrack.sample_rate / 1000); + label += ' ' + (khz % 1 === 0 ? khz.toFixed(0) : khz.toFixed(1)) + 'kHz'; + } + const badge = document.createElement('span'); + badge.className = 'np-format-badge' + (ext === 'flac' ? ' flac' : ''); + badge.textContent = label; + badgesEl.appendChild(badge); + } + if (currentTrack.bitrate) { + const brBadge = document.createElement('span'); + brBadge.className = 'np-format-badge'; + brBadge.textContent = currentTrack.bitrate + 'k'; + badgesEl.appendChild(brBadge); + } + } + } + + // Action buttons visibility + if (actionBtns) { + const hasArtist = currentTrack.artist_id; + actionBtns.classList.toggle('hidden', !hasArtist); + } + + // Track recently played for radio mode + if (currentTrack.id && !npRecentlyPlayedIds.includes(currentTrack.id)) { + npRecentlyPlayedIds.push(currentTrack.id); + if (npRecentlyPlayedIds.length > 50) npRecentlyPlayedIds.shift(); + } + } else { + titleEl.textContent = 'No track'; + artistEl.textContent = 'Unknown Artist'; + albumEl.textContent = 'Unknown Album'; + if (artImg) artImg.classList.add('hidden'); + if (sidebarArt) sidebarArt.src = '/static/trans2.png'; + if (badgesEl) badgesEl.innerHTML = ''; + if (actionBtns) actionBtns.classList.add('hidden'); + npResetAmbientGlow(); + } +} + +function npExtractAmbientColor(imgEl) { + try { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 50; + canvas.height = 50; + ctx.drawImage(imgEl, 0, 0, 50, 50); + const data = ctx.getImageData(0, 0, 50, 50).data; + let rSum = 0, gSum = 0, bSum = 0, count = 0; + for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel + const r = data[i], g = data[i + 1], b = data[i + 2]; + const brightness = (r + g + b) / 3; + if (brightness > 20 && brightness < 230) { + rSum += r; gSum += g; bSum += b; count++; + } + } + if (count > 0) { + const modal = document.querySelector('.np-modal'); + if (modal) { + modal.style.setProperty('--np-ambient-r', Math.round(rSum / count)); + modal.style.setProperty('--np-ambient-g', Math.round(gSum / count)); + modal.style.setProperty('--np-ambient-b', Math.round(bSum / count)); + } + } + } catch (e) { + // Cross-origin or canvas error — ignore silently + } +} + +function npResetAmbientGlow() { + const modal = document.querySelector('.np-modal'); + if (modal) { + modal.style.setProperty('--np-ambient-r', '29'); + modal.style.setProperty('--np-ambient-g', '185'); + modal.style.setProperty('--np-ambient-b', '84'); + } +} + +function updateNpPlayButton() { + const playIcon = document.querySelector('.np-icon-play'); + const pauseIcon = document.querySelector('.np-icon-pause'); + if (playIcon && pauseIcon) { + playIcon.classList.toggle('hidden', isPlaying); + pauseIcon.classList.toggle('hidden', !isPlaying); + } + + const viz = document.getElementById('np-visualizer'); + if (viz) viz.classList.toggle('playing', isPlaying); + + // Drive Web Audio visualizer (only when modal is open to save CPU) + if (isPlaying && npModalOpen) { + npInitVisualizer(); + npStartVisualizerLoop(); + } else { + npStopVisualizerLoop(); + } +} + +function updateNpProgress() { + if (!npModalOpen || !audioPlayer) return; + + const npProgressBar = document.getElementById('np-progress-bar'); + const npProgressFill = document.getElementById('np-progress-fill'); + const npCurrentTime = document.getElementById('np-current-time'); + const npTotalTime = document.getElementById('np-total-time'); + + if (audioPlayer.duration) { + const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100; + if (npProgressBar && !npProgressBar.dataset.seeking) { + npProgressBar.value = progress; + } + if (npProgressFill) npProgressFill.style.width = progress + '%'; + if (npCurrentTime) npCurrentTime.textContent = formatTime(audioPlayer.currentTime); + if (npTotalTime) npTotalTime.textContent = formatTime(audioPlayer.duration); + } else { + if (npProgressBar) npProgressBar.value = 0; + if (npProgressFill) npProgressFill.style.width = '0%'; + if (npCurrentTime) npCurrentTime.textContent = '0:00'; + if (npTotalTime) npTotalTime.textContent = '0:00'; + } +} + +function handleNpProgressBarChange(event) { + if (!audioPlayer || !audioPlayer.duration) return; + const progress = parseFloat(event.target.value); + const newTime = (progress / 100) * audioPlayer.duration; + + try { + audioPlayer.currentTime = newTime; + + // Sync sidebar progress + const sidebarBar = document.getElementById('progress-bar'); + const sidebarFill = document.getElementById('progress-fill'); + if (sidebarBar) sidebarBar.value = progress; + if (sidebarFill) sidebarFill.style.width = progress + '%'; + + // Sync modal progress fill + const npFill = document.getElementById('np-progress-fill'); + if (npFill) npFill.style.width = progress + '%'; + + // Update time displays + const sidebarTime = document.getElementById('current-time'); + const npTime = document.getElementById('np-current-time'); + if (sidebarTime) sidebarTime.textContent = formatTime(newTime); + if (npTime) npTime.textContent = formatTime(newTime); + } catch (error) { + console.warn('Seek failed:', error.message); + } +} + +function handleNpVolumeChange(event) { + const volume = parseInt(event.target.value); + if (audioPlayer) audioPlayer.volume = volume / 100; + + // Sync sidebar volume slider + const sidebarVol = document.getElementById('volume-slider'); + if (sidebarVol) { + sidebarVol.value = volume; + sidebarVol.style.setProperty('--volume-percent', volume + '%'); + } + + // Update modal volume fill + const npFill = document.getElementById('np-volume-fill'); + if (npFill) npFill.style.width = volume + '%'; + + // Update mute state + npMuted = volume === 0; + updateNpMuteIcon(); +} + +function handleNpMuteToggle() { + const npVol = document.getElementById('np-volume-slider'); + if (!npVol) return; + + if (npMuted) { + // Unmute — restore previous volume + npVol.value = npPreMuteVolume; + npVol.dispatchEvent(new Event('input')); + npMuted = false; + } else { + // Mute — save current volume, set to 0 + npPreMuteVolume = parseInt(npVol.value) || 70; + npVol.value = 0; + npVol.dispatchEvent(new Event('input')); + npMuted = true; + } + updateNpMuteIcon(); +} + +function updateNpMuteIcon() { + const muteBtn = document.getElementById('np-mute-btn'); + const volIcon = muteBtn ? muteBtn.querySelector('.np-icon-vol') : null; + const mutedIcon = muteBtn ? muteBtn.querySelector('.np-icon-muted') : null; + if (volIcon && mutedIcon) { + volIcon.classList.toggle('hidden', npMuted); + mutedIcon.classList.toggle('hidden', !npMuted); + } + if (muteBtn) muteBtn.classList.toggle('muted', npMuted); +} + +function handleNpShuffle() { + npShuffleOn = !npShuffleOn; + const btn = document.getElementById('np-shuffle-btn'); + if (btn) btn.classList.toggle('active', npShuffleOn); + updateNpPrevNextButtons(); +} + +function handleNpRepeat() { + const badge = document.getElementById('np-repeat-one-badge'); + if (npRepeatMode === 'off') { + npRepeatMode = 'all'; + if (audioPlayer) audioPlayer.loop = false; + } else if (npRepeatMode === 'all') { + npRepeatMode = 'one'; + if (audioPlayer) audioPlayer.loop = true; + } else { + npRepeatMode = 'off'; + if (audioPlayer) audioPlayer.loop = false; + } + const btn = document.getElementById('np-repeat-btn'); + if (btn) btn.classList.toggle('active', npRepeatMode !== 'off'); + if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one'); + updateNpPrevNextButtons(); +} + +// =============================== +// QUEUE MANAGEMENT +// =============================== + +function addToQueue(track) { + npQueue.push(track); + showToast('Added to queue', 'success'); + renderNpQueue(); + updateNpPrevNextButtons(); + // If nothing is currently playing, auto-play the first queued track + if (!currentTrack) { + playQueueItem(npQueue.length - 1); + } +} + +function removeFromQueue(index) { + if (index < 0 || index >= npQueue.length) return; + const wasCurrentTrack = (index === npQueueIndex); + npQueue.splice(index, 1); + // Adjust current index + if (npQueue.length === 0) { + npQueueIndex = -1; + // Current track keeps playing but queue is now empty — that's OK + } else if (index < npQueueIndex) { + npQueueIndex--; + } else if (wasCurrentTrack) { + // Removed the currently playing item + if (npQueueIndex >= npQueue.length) { + npQueueIndex = npQueue.length - 1; + } + // Play the next track at the adjusted index + playQueueItem(npQueueIndex); + } + renderNpQueue(); + updateNpPrevNextButtons(); +} + +function clearQueue() { + npQueue = []; + npQueueIndex = -1; + renderNpQueue(); + updateNpPrevNextButtons(); +} + +function playNextInQueue() { + if (npQueue.length === 0) return; + if (npShuffleOn) { + // Pick a random index that is not the current one + const candidates = []; + for (let i = 0; i < npQueue.length; i++) { + if (i !== npQueueIndex) candidates.push(i); + } + if (candidates.length === 0) return; + const next = candidates[Math.floor(Math.random() * candidates.length)]; + playQueueItem(next); + } else { + const next = npQueueIndex + 1; + if (next >= npQueue.length) { + // End of queue — repeat-all wraps to start + if (npRepeatMode === 'all') { + playQueueItem(0); + } + return; + } + playQueueItem(next); + } +} + +function playPreviousInQueue() { + // If more than 3 seconds in, restart current track + if (audioPlayer && audioPlayer.currentTime > 3) { + audioPlayer.currentTime = 0; + if (audioPlayer.paused) audioPlayer.play(); + return; + } + if (npQueue.length === 0) return; + const prev = npQueueIndex - 1; + if (prev < 0) { + // At start — restart current track + if (audioPlayer) { + audioPlayer.currentTime = 0; + if (audioPlayer.paused) audioPlayer.play(); + } + return; + } + playQueueItem(prev); +} + +async function playQueueItem(index) { + if (index < 0 || index >= npQueue.length) return; + if (npLoadingQueueItem) return; // Prevent race condition from double-advance + npLoadingQueueItem = true; + npQueueIndex = index; + const track = npQueue[index]; + + try { + if (track.is_library) { + // Library track playback flow + await stopStream(); + setTrackInfo({ + title: track.title, + artist: track.artist, + album: track.album, + filename: track.file_path, + is_library: true, + image_url: track.image_url, + id: track.id, + artist_id: track.artist_id, + album_id: track.album_id, + bitrate: track.bitrate, + sample_rate: track.sample_rate + }); + showLoadingAnimation(); + + const response = await fetch('/api/library/play', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + file_path: track.file_path, + title: track.title || '', + artist: track.artist || '', + album: track.album || '' + }) + }); + const result = await response.json(); + if (!result.success) throw new Error(result.error || 'Failed to start playback'); + // Re-apply repeat-one loop property + if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one'); + await startAudioPlayback(); + } else { + // Non-library (stream) tracks cannot be queued for auto-advance + // Just show track info — the stream flow handles its own playback + setTrackInfo({ + title: track.title, + artist: track.artist, + album: track.album, + filename: track.filename || track.file_path, + is_library: false, + image_url: track.image_url, + id: track.id, + artist_id: track.artist_id, + album_id: track.album_id, + bitrate: track.bitrate, + sample_rate: track.sample_rate + }); + } + } catch (error) { + console.error('Queue playback error:', error); + showToast(`Skipping track: ${error.message}`, 'error'); + hideLoadingAnimation(); + // Auto-skip to next track on failure instead of stopping the queue + npLoadingQueueItem = false; + const nextIdx = npQueueIndex + 1; + if (nextIdx < npQueue.length) { + setTimeout(() => playQueueItem(nextIdx), 500); + } + return; + } finally { + npLoadingQueueItem = false; + } + + renderNpQueue(); + updateNpPrevNextButtons(); +} + +function renderNpQueue() { + const listEl = document.getElementById('np-queue-list'); + const emptyEl = document.getElementById('np-queue-empty'); + const countEl = document.getElementById('np-queue-count'); + if (!listEl) return; + + if (countEl) countEl.textContent = npQueue.length > 0 ? `(${npQueue.length})` : ''; + + if (npQueue.length === 0) { + listEl.innerHTML = ''; + if (emptyEl) emptyEl.classList.remove('hidden'); + return; + } + + if (emptyEl) emptyEl.classList.add('hidden'); + listEl.innerHTML = ''; + + npQueue.forEach((track, i) => { + const item = document.createElement('div'); + item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : ''); + item.onclick = () => playQueueItem(i); + + const info = document.createElement('div'); + info.className = 'np-queue-item-info'; + + const title = document.createElement('div'); + title.className = 'np-queue-item-title'; + title.textContent = track.title || 'Unknown Track'; + + const artist = document.createElement('div'); + artist.className = 'np-queue-item-artist'; + artist.textContent = track.artist || 'Unknown Artist'; + + info.appendChild(title); + info.appendChild(artist); + item.appendChild(info); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'np-queue-item-remove'; + removeBtn.innerHTML = '✕'; + removeBtn.title = 'Remove from queue'; + removeBtn.onclick = (e) => { + e.stopPropagation(); + removeFromQueue(i); + }; + item.appendChild(removeBtn); + + listEl.appendChild(item); + }); +} + +function updateNpPrevNextButtons() { + const canPrev = npQueueIndex > 0 || (audioPlayer && audioPlayer.currentTime > 3); + const canNext = npQueue.length > 0 && (npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all')); + + // Full Now Playing modal buttons + const prevBtn = document.getElementById('np-prev-btn'); + const nextBtn = document.getElementById('np-next-btn'); + if (prevBtn) prevBtn.disabled = !canPrev; + if (nextBtn) nextBtn.disabled = !canNext; + + // Mini player buttons + const miniPrevBtn = document.getElementById('mini-prev-btn'); + const miniNextBtn = document.getElementById('mini-next-btn'); + if (miniPrevBtn) miniPrevBtn.disabled = !canPrev; + if (miniNextBtn) miniNextBtn.disabled = !canNext; +} + +function handlePlayerKeyboardShortcuts(event) { + // Don't intercept when typing in inputs or when non-player modals are open + const tag = document.activeElement.tagName.toLowerCase(); + if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement.isContentEditable) return; + + // Only handle when player modal is open OR when no other modal is visible + const otherModals = document.querySelectorAll('.modal-overlay:not(.hidden):not(#np-modal-overlay)'); + if (otherModals.length > 0 && !npModalOpen) return; + + switch (event.key) { + case ' ': + if (!currentTrack) return; + event.preventDefault(); + togglePlayback(); + break; + case 'ArrowLeft': + if (!audioPlayer || !audioPlayer.duration) return; + event.preventDefault(); + audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 5); + break; + case 'ArrowRight': + if (!audioPlayer || !audioPlayer.duration) return; + event.preventDefault(); + audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 5); + break; + case 'ArrowUp': + event.preventDefault(); + if (audioPlayer) { + const newVol = Math.min(1, audioPlayer.volume + 0.05); + audioPlayer.volume = newVol; + syncVolumeUI(Math.round(newVol * 100)); + } + break; + case 'ArrowDown': + event.preventDefault(); + if (audioPlayer) { + const newVol = Math.max(0, audioPlayer.volume - 0.05); + audioPlayer.volume = newVol; + syncVolumeUI(Math.round(newVol * 100)); + } + break; + case 'm': + case 'M': + if (npModalOpen) handleNpMuteToggle(); + break; + case 'Escape': + if (npModalOpen) closeNowPlayingModal(); + break; + default: + return; // Don't prevent default for unhandled keys + } +} + +function syncVolumeUI(volumePercent) { + // Sync both sidebar and modal volume UIs + const sidebarVol = document.getElementById('volume-slider'); + const npVol = document.getElementById('np-volume-slider'); + const npFill = document.getElementById('np-volume-fill'); + + if (sidebarVol) { + sidebarVol.value = volumePercent; + sidebarVol.style.setProperty('--volume-percent', volumePercent + '%'); + } + if (npVol) npVol.value = volumePercent; + if (npFill) npFill.style.width = volumePercent + '%'; +} + +function getNpAlbumArtUrl() { + if (!currentTrack) return null; + return currentTrack.image_url || currentTrack.album_cover_url || currentTrack.thumb_url || null; +} + +// =============================== +// WEB AUDIO VISUALIZER +// =============================== + +function npInitVisualizer() { + if (npVizInitialized || !audioPlayer) return; + try { + if (!npAudioContext) { + npAudioContext = new (window.AudioContext || window.webkitAudioContext)(); + } + if (!npMediaSource) { + npMediaSource = npAudioContext.createMediaElementSource(audioPlayer); + npAnalyser = npAudioContext.createAnalyser(); + npAnalyser.fftSize = 64; + npAnalyser.smoothingTimeConstant = 0.8; + npMediaSource.connect(npAnalyser); + npAnalyser.connect(npAudioContext.destination); + } + npVizInitialized = true; + } catch (e) { + console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message); + // Mark as CSS fallback + const viz = document.getElementById('np-visualizer'); + if (viz) viz.classList.add('np-viz-css-fallback'); + npVizInitialized = true; // don't retry + } +} + +function npStartVisualizerLoop() { + if (npVizAnimFrame) return; // Already running + if (!npAnalyser) return; // No analyser — CSS fallback handles it + + if (npAudioContext && npAudioContext.state === 'suspended') { + npAudioContext.resume(); + } + + const bars = document.querySelectorAll('.np-viz-bar'); + if (bars.length === 0) return; + const bufferLength = npAnalyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + + function draw() { + npVizAnimFrame = requestAnimationFrame(draw); + npAnalyser.getByteFrequencyData(dataArray); + + // Map 7 bars to frequency bins (skip bin 0 which is DC offset) + const binCount = Math.min(bufferLength - 1, 7); + for (let i = 0; i < bars.length; i++) { + const binIndex = Math.min(i + 1, bufferLength - 1); + const value = dataArray[binIndex] / 255; // 0..1 + const scale = Math.max(0.08, value); // minimum visible height + bars[i].style.transform = `scaleY(${scale})`; + } + } + draw(); +} + +function npStopVisualizerLoop() { + if (npVizAnimFrame) { + cancelAnimationFrame(npVizAnimFrame); + npVizAnimFrame = null; + } + // Reset bars to min + const bars = document.querySelectorAll('.np-viz-bar'); + bars.forEach(bar => { bar.style.transform = 'scaleY(0.125)'; }); +} + +// =============================== +// SIDEBAR AUDIO VISUALIZER +// =============================== + +let sidebarVizAnimFrame = null; +let sidebarVisualizerType = 'bars'; // bars | wave | spectrum | mirror | equalizer | none +const SIDEBAR_VIZ_BAR_COUNT = 32; + +let _sidebarVizBuiltType = null; + +function buildSidebarVizElements(type) { + const container = document.getElementById('sidebar-visualizer'); + if (!container) return; + if (_sidebarVizBuiltType === type && container.children.length > 0) return; + _sidebarVizBuiltType = type; + container.innerHTML = ''; + container.className = 'sidebar-visualizer'; + + if (type === 'bars') { + container.classList.add('viz-bars'); + for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) { + const bar = document.createElement('div'); + bar.className = 'sidebar-viz-bar'; + container.appendChild(bar); + } + } else if (type === 'wave' || type === 'spectrum') { + container.classList.add('viz-canvas'); + const canvas = document.createElement('canvas'); + canvas.className = 'sidebar-viz-canvas'; + canvas.width = 10; + canvas.height = 600; + container.appendChild(canvas); + } else if (type === 'mirror') { + container.classList.add('viz-mirror'); + for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) { + const bar = document.createElement('div'); + bar.className = 'sidebar-viz-mirror-bar'; + container.appendChild(bar); + } + } else if (type === 'equalizer') { + container.classList.add('viz-equalizer'); + for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) { + const wrap = document.createElement('div'); + wrap.className = 'sidebar-viz-eq-wrap'; + const bar = document.createElement('div'); + bar.className = 'sidebar-viz-eq-bar'; + const peak = document.createElement('div'); + peak.className = 'sidebar-viz-eq-peak'; + wrap.appendChild(bar); + wrap.appendChild(peak); + container.appendChild(wrap); + } + } +} + +function startSidebarVisualizer() { + const type = sidebarVisualizerType; + if (type === 'none') return; + + const container = document.getElementById('sidebar-visualizer'); + if (!container) return; + + buildSidebarVizElements(type); + container.classList.add('active'); + + if (sidebarVizAnimFrame) return; + if (!npAnalyser) return; + + const bufferLength = npAnalyser.frequencyBinCount; + const dataArray = new Uint8Array(bufferLength); + const hueStart = 200, hueRange = 160; + + // Helper: average frequency bins for a given segment index + function getBinValue(i, count) { + const binsPerSeg = Math.max(1, Math.floor((bufferLength - 1) / count)); + let sum = 0; + const start = i * binsPerSeg + 1; + for (let b = 0; b < binsPerSeg; b++) sum += dataArray[Math.min(start + b, bufferLength - 1)]; + return (sum / binsPerSeg) / 255; + } + + // ── Bars ── + if (type === 'bars') { + const bars = container.querySelectorAll('.sidebar-viz-bar'); + if (bars.length === 0) return; + function drawBars() { + sidebarVizAnimFrame = requestAnimationFrame(drawBars); + npAnalyser.getByteFrequencyData(dataArray); + for (let i = 0; i < bars.length; i++) { + const value = getBinValue(i, bars.length); + const scale = Math.max(0.08, value); + const hue = (hueStart + (i / bars.length) * hueRange + value * 30) % 360; + bars[i].style.transform = `scaleX(${scale})`; + bars[i].style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`; + } + } + drawBars(); + + // ── Wave ── + } else if (type === 'wave') { + const canvas = container.querySelector('.sidebar-viz-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let hueOffset = 0; + function drawWave() { + sidebarVizAnimFrame = requestAnimationFrame(drawWave); + const ch = container.clientHeight; + if (ch > 0 && canvas.height !== ch) canvas.height = ch; + npAnalyser.getByteFrequencyData(dataArray); + const w = canvas.width, h = canvas.height; + if (h === 0) return; + ctx.clearRect(0, 0, w, h); + + let totalEnergy = 0; + for (let i = 1; i < bufferLength; i++) totalEnergy += dataArray[i]; + const avgEnergy = totalEnergy / (bufferLength - 1) / 255; + hueOffset = (hueOffset + 0.5) % 360; + + const segments = 64; + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.beginPath(); + for (let i = 0; i <= segments; i++) { + const y = (i / segments) * h; + const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1); + const value = dataArray[binIdx] / 255; + const x = (w / 2) + Math.sin((i / segments) * Math.PI * 4 + Date.now() * 0.003) * value * (w - 2) * 0.4; + if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); + } + const grad = ctx.createLinearGradient(0, 0, 0, h); + grad.addColorStop(0, `hsla(${hueOffset + 200}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`); + grad.addColorStop(0.5, `hsla(${hueOffset + 280}, 80%, 55%, ${0.3 + avgEnergy * 0.7})`); + grad.addColorStop(1, `hsla(${hueOffset + 360}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`); + ctx.strokeStyle = grad; + ctx.stroke(); + ctx.lineWidth = 6; + ctx.globalAlpha = 0.15 + avgEnergy * 0.2; + ctx.stroke(); + ctx.globalAlpha = 1; + } + drawWave(); + + // ── Spectrum (mountain/terrain fill) ── + } else if (type === 'spectrum') { + const canvas = container.querySelector('.sidebar-viz-canvas'); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let hueOffset = 0; + // Smoothed values for fluid motion + const smoothed = new Float32Array(64); + + function drawSpectrum() { + sidebarVizAnimFrame = requestAnimationFrame(drawSpectrum); + const ch = container.clientHeight; + if (ch > 0 && canvas.height !== ch) canvas.height = ch; + npAnalyser.getByteFrequencyData(dataArray); + const w = canvas.width, h = canvas.height; + if (h === 0) return; + ctx.clearRect(0, 0, w, h); + + hueOffset = (hueOffset + 0.3) % 360; + const segments = smoothed.length; + + // Smooth the frequency data + for (let i = 0; i < segments; i++) { + const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1); + const target = dataArray[binIdx] / 255; + smoothed[i] += (target - smoothed[i]) * 0.25; + } + + // Draw filled mountain shape from left edge + ctx.beginPath(); + ctx.moveTo(0, 0); + for (let i = 0; i <= segments; i++) { + const y = (i / segments) * h; + const value = i < segments ? smoothed[i] : smoothed[segments - 1]; + const x = value * w * 0.95; + ctx.lineTo(x, y); + } + ctx.lineTo(0, h); + ctx.closePath(); + + // Gradient fill + const fillGrad = ctx.createLinearGradient(0, 0, 0, h); + fillGrad.addColorStop(0, `hsla(${hueOffset + 200}, 85%, 55%, 0.7)`); + fillGrad.addColorStop(0.25, `hsla(${hueOffset + 240}, 80%, 50%, 0.6)`); + fillGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 85%, 50%, 0.65)`); + fillGrad.addColorStop(0.75, `hsla(${hueOffset + 330}, 80%, 50%, 0.6)`); + fillGrad.addColorStop(1, `hsla(${hueOffset + 360}, 85%, 55%, 0.7)`); + ctx.fillStyle = fillGrad; + ctx.fill(); + + // Bright edge line + ctx.beginPath(); + ctx.moveTo(0, 0); + for (let i = 0; i <= segments; i++) { + const y = (i / segments) * h; + const value = i < segments ? smoothed[i] : smoothed[segments - 1]; + ctx.lineTo(value * w * 0.95, y); + } + const lineGrad = ctx.createLinearGradient(0, 0, 0, h); + lineGrad.addColorStop(0, `hsla(${hueOffset + 200}, 90%, 70%, 0.9)`); + lineGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 90%, 65%, 0.9)`); + lineGrad.addColorStop(1, `hsla(${hueOffset + 360}, 90%, 70%, 0.9)`); + ctx.strokeStyle = lineGrad; + ctx.lineWidth = 1.5; + ctx.stroke(); + + // Outer glow + ctx.lineWidth = 4; + ctx.globalAlpha = 0.2; + ctx.stroke(); + ctx.globalAlpha = 1; + } + drawSpectrum(); + + // ── Mirror (bars from center outward) ── + } else if (type === 'mirror') { + const bars = container.querySelectorAll('.sidebar-viz-mirror-bar'); + if (bars.length === 0) return; + function drawMirror() { + sidebarVizAnimFrame = requestAnimationFrame(drawMirror); + npAnalyser.getByteFrequencyData(dataArray); + const half = Math.floor(bars.length / 2); + for (let i = 0; i < half; i++) { + const value = getBinValue(i, half); + const scale = Math.max(0.06, value); + const hue = (hueStart + (i / half) * hueRange + value * 30) % 360; + const color = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`; + // Top half — mirror index from center + const topIdx = half - 1 - i; + const bottomIdx = half + i; + bars[topIdx].style.transform = `scaleX(${scale})`; + bars[topIdx].style.backgroundColor = color; + if (bottomIdx < bars.length) { + bars[bottomIdx].style.transform = `scaleX(${scale})`; + bars[bottomIdx].style.backgroundColor = color; + } + } + } + drawMirror(); + + // ── Equalizer (bars with falling peak indicators) ── + } else if (type === 'equalizer') { + const wraps = container.querySelectorAll('.sidebar-viz-eq-wrap'); + if (wraps.length === 0) return; + const peaks = new Float32Array(wraps.length); + const peakVelocity = new Float32Array(wraps.length); + + function drawEqualizer() { + sidebarVizAnimFrame = requestAnimationFrame(drawEqualizer); + npAnalyser.getByteFrequencyData(dataArray); + for (let i = 0; i < wraps.length; i++) { + const value = getBinValue(i, wraps.length); + const scale = Math.max(0.06, value); + const hue = (hueStart + (i / wraps.length) * hueRange + value * 30) % 360; + const barEl = wraps[i].querySelector('.sidebar-viz-eq-bar'); + const peakEl = wraps[i].querySelector('.sidebar-viz-eq-peak'); + + barEl.style.transform = `scaleX(${scale})`; + barEl.style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`; + + // Peak hold with gravity + if (value > peaks[i]) { + peaks[i] = value; + peakVelocity[i] = 0; + } else { + peakVelocity[i] += 0.002; // gravity + peaks[i] = Math.max(0, peaks[i] - peakVelocity[i]); + } + const peakPos = Math.max(0.06, peaks[i]); + peakEl.style.left = `${peakPos * 100}%`; + peakEl.style.backgroundColor = `hsla(${hue}, 90%, 75%, ${0.6 + peaks[i] * 0.4})`; + peakEl.style.boxShadow = `0 0 4px hsla(${hue}, 90%, 70%, ${peaks[i] * 0.5})`; + } + } + drawEqualizer(); + } +} + +function stopSidebarVisualizer() { + if (sidebarVizAnimFrame) { + cancelAnimationFrame(sidebarVizAnimFrame); + sidebarVizAnimFrame = null; + } + const container = document.getElementById('sidebar-visualizer'); + if (container) { + container.classList.remove('active'); + } +} + +// Listen for visualizer type changes in settings — use isPlaying (not wasRunning) +// so switching from 'none' to a real type while music plays starts the visualizer +document.addEventListener('change', (e) => { + if (e.target.id === 'sidebar-visualizer-type') { + const newType = e.target.value; + stopSidebarVisualizer(); + _sidebarVizBuiltType = null; // force rebuild for new type + sidebarVisualizerType = newType; + if (isPlaying && newType !== 'none') { + npInitVisualizer(); + startSidebarVisualizer(); + } + } +}); + +// =============================== +// RADIO MODE +// =============================== + +async function npFetchRadioTracks() { + if (!currentTrack || !currentTrack.id) return; + try { + npLoadingQueueItem = true; + const excludeIds = npRecentlyPlayedIds.join(','); + const resp = await fetch(`/api/library/radio?track_id=${currentTrack.id}&limit=50&exclude=${encodeURIComponent(excludeIds)}`); + if (!resp.ok) { + console.warn('Radio endpoint returned', resp.status); + npLoadingQueueItem = false; + return; + } + const data = await resp.json(); + // Bail if radio was toggled off during the fetch + if (!npRadioMode) { npLoadingQueueItem = false; return; } + if (data.tracks && data.tracks.length > 0) { + data.tracks.forEach(t => { + npQueue.push({ + title: t.title || 'Unknown Track', + artist: t.artist || 'Unknown Artist', + album: t.album || 'Unknown Album', + file_path: t.file_path, + filename: t.file_path, + is_library: true, + image_url: t.image_url || null, + id: t.id, + artist_id: t.artist_id, + album_id: t.album_id, + bitrate: t.bitrate, + sample_rate: t.sample_rate + }); + }); + showToast(`Radio: Added ${data.tracks.length} similar tracks`, 'success'); + renderNpQueue(); + updateNpPrevNextButtons(); + npLoadingQueueItem = false; + // Only auto-advance if nothing is currently playing (triggered by onAudioEnded) + if (!isPlaying) { + playNextInQueue(); + } + } else { + showToast('Radio: No similar tracks found', 'info'); + npLoadingQueueItem = false; + } + } catch (e) { + console.warn('Radio fetch error:', e); + npLoadingQueueItem = false; + } +} + +// Media Session API +function initMediaSession() { + if (!('mediaSession' in navigator)) return; + + navigator.mediaSession.setActionHandler('play', () => { + if (audioPlayer && currentTrack) { + audioPlayer.play().then(() => setPlayingState(true)); + } + }); + navigator.mediaSession.setActionHandler('pause', () => { + if (audioPlayer) { + audioPlayer.pause(); + setPlayingState(false); + } + }); + navigator.mediaSession.setActionHandler('stop', () => { + handleStop(); + }); + navigator.mediaSession.setActionHandler('seekbackward', () => { + if (audioPlayer && audioPlayer.duration) { + audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10); + } + }); + navigator.mediaSession.setActionHandler('seekforward', () => { + if (audioPlayer && audioPlayer.duration) { + audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10); + } + }); + navigator.mediaSession.setActionHandler('previoustrack', () => { + if (npQueue.length > 0) playPreviousInQueue(); + }); + navigator.mediaSession.setActionHandler('nexttrack', () => { + if (npQueue.length > 0) playNextInQueue(); + }); +} + +function updateMediaSessionMetadata() { + if (!('mediaSession' in navigator) || !currentTrack) return; + const artwork = []; + const artUrl = getNpAlbumArtUrl(); + if (artUrl) artwork.push({ src: artUrl, sizes: '512x512', type: 'image/jpeg' }); + + navigator.mediaSession.metadata = new MediaMetadata({ + title: currentTrack.title || 'Unknown Track', + artist: currentTrack.artist || 'Unknown Artist', + album: currentTrack.album || 'Unknown Album', + artwork: artwork + }); +} + +function updateMediaSessionPlaybackState() { + if (!('mediaSession' in navigator)) return; + if (!currentTrack) { + navigator.mediaSession.playbackState = 'none'; + } else { + navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'; + } +} + +// =============================== + diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 38dba241..75ff8337 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -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; + } } \ No newline at end of file diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js new file mode 100644 index 00000000..333b1de6 --- /dev/null +++ b/webui/static/pages-extra.js @@ -0,0 +1,2875 @@ + +// ================================================================================== +// PLAYLIST EXPLORER — Visual Discovery Tree +// ================================================================================== + +const _explorer = { + initialized: false, + mode: 'albums', + artists: [], + selectedAlbums: new Set(), + expandedArtists: new Set(), + building: false, + playlistId: null, + meta: null, + _resizeTimer: null, +}; + +function initExplorer() { + if (_explorer.initialized) return; + _explorer.initialized = true; + _explorer._playlists = []; + _explorer._activeSource = null; + + _explorerLoadPlaylists(); + + // Listen for discovery completion to auto-refresh playlist cards + if (typeof socket !== 'undefined') { + socket.on('discovery:progress', (data) => { + if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) return; + // Match mirrored playlist discovery events + if (data.phase === 'discovered' || data.phase === 'sync_complete' || data.complete) { + // Discovery finished — refresh playlists after brief delay for DB commit + setTimeout(() => _explorerLoadPlaylists(), 1500); + } + // Live progress update on cards during discovery + if (data.id && data.id.startsWith('mirrored_')) { + const plId = parseInt(data.id.replace('mirrored_', '')); + const card = document.querySelector(`.explorer-picker-card[data-id="${plId}"]`); + if (card) { + const meta = card.querySelector('.explorer-picker-card-meta'); + if (meta && data.progress != null) { + meta.innerHTML = `Discovering... ${Math.round(data.progress)}%`; + } + } + } + }); + } +} + +function _explorerLoadPlaylists() { + fetch('/api/mirrored-playlists') + .then(r => r.json()) + .then(data => { + const playlists = Array.isArray(data) ? data : (data.playlists || []); + _explorer._playlists = playlists; + + if (playlists.length === 0) { + const scroll = document.getElementById('explorer-picker-scroll'); + if (scroll) scroll.innerHTML = '
    No mirrored playlists found. Sync a playlist first.
    '; + return; + } + + // Group by source + const groups = {}; + playlists.forEach(p => { + const src = (p.source || 'other').toLowerCase(); + if (!groups[src]) groups[src] = []; + groups[src].push(p); + }); + + // Render source tabs + const tabsEl = document.getElementById('explorer-picker-tabs'); + if (tabsEl) { + const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' }; + const sources = Object.keys(groups); + if (sources.length <= 1) { + tabsEl.style.display = 'none'; + } else { + tabsEl.innerHTML = sources.map((src, i) => { + const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1); + const count = groups[src].length; + const isActive = _explorer._activeSource === src || (!_explorer._activeSource && i === 0); + return ``; + }).join(''); + } + + // Show active or first source + const activeSource = _explorer._activeSource || sources[0]; + _explorer._activeSource = activeSource; + explorerRenderPickerCards(activeSource); + } + }) + .catch(() => { }); +} + +function explorerSwitchPickerTab(source) { + _explorer._activeSource = source; + document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source)); + explorerRenderPickerCards(source); +} + +function explorerRenderPickerCards(source) { + const scroll = document.getElementById('explorer-picker-scroll'); + if (!scroll) return; + + const filtered = _explorer._playlists.filter(p => (p.source || 'other').toLowerCase() === source); + scroll.innerHTML = filtered.map(p => { + const img = p.image_url || ''; + const total = p.total_count || p.track_count || 0; + const discovered = p.discovered_count || 0; + const pct = total > 0 ? Math.round((discovered / total) * 100) : 0; + const isReady = pct >= 50; + const isActive = _explorer.playlistId === p.id; + const isFullyDiscovered = pct === 100; + const wasExplored = !!(p.explored_at || p.explored); + const wishlisted = p.wishlisted_count || 0; + const inLibrary = p.in_library_count || 0; + + // Status badge: checkmark if explored/in-library, star if ready, % if needs discovery + let statusBadge = ''; + if (inLibrary > 0 && inLibrary >= total * 0.8) { + statusBadge = '
    '; + } else if (wasExplored) { + statusBadge = '
    '; + } else if (wishlisted > 0) { + statusBadge = '
    '; + } else if (isFullyDiscovered) { + statusBadge = '
    '; + } else if (!isReady) { + statusBadge = `
    ${pct}%
    `; + } + + // Meta line with status indicators + let metaHTML; + const statusParts = []; + if (inLibrary > 0) statusParts.push(`${inLibrary} in library`); + if (wishlisted > 0) statusParts.push(`${wishlisted} wishlisted`); + + if (isFullyDiscovered) { + metaHTML = `${total} tracks · Fully discovered`; + } else if (isReady) { + metaHTML = `${total} tracks · ${pct}% discovered`; + } else { + metaHTML = `${total} tracks · ${pct}% discovered`; + } + if (statusParts.length > 0) { + metaHTML += `
    ${statusParts.join(' · ')}`; + } + + // Discover button for undiscovered playlists (replaces redirect to Sync) + const discoverBtn = !isReady ? `` : ''; + + return ` +
    +
    + ${img ? `` : '
    '} +
    +
    +
    +
    ${p.name || 'Untitled'}
    + ${statusBadge} +
    +
    ${metaHTML}
    + ${discoverBtn ? `
    ${discoverBtn}
    ` : ''} +
    +
    + `; + }).join(''); +} + +function explorerSelectPlaylist(id, el) { + _explorer.playlistId = id; + document.querySelectorAll('.explorer-picker-card').forEach(c => c.classList.remove('active')); + if (el) el.classList.add('active'); + // Update hint text + const hint = document.getElementById('explorer-build-hint'); + const pl = _explorer._playlists.find(p => p.id === id); + if (hint && pl) hint.textContent = `Ready: ${pl.name}`; + else if (hint) hint.textContent = ''; +} + +function explorerRedirectToDiscover(playlistId) { + showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info'); + navigateToPage('sync'); + setTimeout(() => { + const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); + if (mirroredBtn) mirroredBtn.click(); + }, 200); +} + +async function explorerStartDiscovery(playlistId) { + const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); + const btn = card?.querySelector('.explorer-picker-discover-btn'); + if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; } + + try { + if (typeof discoverMirroredPlaylist === 'function') { + await discoverMirroredPlaylist(playlistId); + if (btn) { btn.disabled = false; btn.textContent = 'Open'; btn.title = 'Reopen discovery modal'; } + + // Poll for card updates while discovery is in progress + _explorerStartDiscoveryPoller(playlistId); + } else { + explorerRedirectToDiscover(playlistId); + } + } catch (err) { + showToast(`Discovery failed: ${err.message}`, 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Discover'; } + } +} + +function _explorerStartDiscoveryPoller(playlistId) { + // Poll every 5s to refresh playlist cards until this playlist is ready + if (_explorer._discoveryPoller) clearInterval(_explorer._discoveryPoller); + _explorer._discoveryPoller = setInterval(async () => { + // Stop polling if Explorer page isn't active + if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) { + clearInterval(_explorer._discoveryPoller); + _explorer._discoveryPoller = null; + return; + } + // Check if the mirrored playlist state shows discovery is done + const tempHash = `mirrored_${playlistId}`; + const state = youtubePlaylistStates[tempHash]; + const isDone = state && (state.phase === 'discovered' || state.phase === 'sync_complete'); + + // Refresh cards from API + await _explorerLoadPlaylists(); + + // Stop polling once discovery is complete + if (isDone) { + clearInterval(_explorer._discoveryPoller); + _explorer._discoveryPoller = null; + } + }, 5000); +} + +function explorerSetMode(mode) { + _explorer.mode = mode; + document.querySelectorAll('.explorer-mode-btn').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); +} + +async function explorerBuildTree() { + const playlistId = _explorer.playlistId; + if (!playlistId) { + showToast('Select a playlist first', 'error'); + return; + } + if (_explorer.building) return; + + _explorer.building = true; + _explorer.artists = []; + _explorer.selectedAlbums.clear(); + _explorer.expandedArtists.clear(); + _explorer.playlistId = playlistId; + + const tree = document.getElementById('explorer-tree'); + const svg = document.getElementById('explorer-svg'); + const progress = document.getElementById('explorer-progress'); + const actionBar = document.getElementById('explorer-action-bar'); + const empty = document.getElementById('explorer-empty'); + const buildBtn = document.getElementById('explorer-build-btn'); + + if (empty) empty.style.display = 'none'; + if (actionBar) actionBar.style.display = 'none'; + if (progress) progress.style.display = 'flex'; + if (buildBtn) { buildBtn.disabled = true; buildBtn.textContent = 'Building...'; } + // Clear tree but preserve the SVG element (it lives inside the tree) + tree.innerHTML = ''; + _explorer._zoom = 1; + tree.style.transform = ''; + + try { + const response = await fetch('/api/playlist-explorer/build-tree', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_id: parseInt(playlistId), mode: _explorer.mode }) + }); + + if (!response.ok) { + const err = await response.json(); + throw new Error(err.error || 'Failed to build tree'); + } + + // Stream NDJSON + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let artistCount = 0; + let totalArtists = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + + for (const line of lines) { + if (!line.trim()) continue; + try { + const data = JSON.parse(line); + + if (data.type === 'meta') { + _explorer.meta = data; + totalArtists = data.total_artists; + _explorerRenderRoot(data); + } else if (data.type === 'artist') { + artistCount++; + _explorer.artists.push(data); + _explorerRenderArtistNode(data, artistCount); + + // Lines drawn after streaming completes (not during — flex reflow drifts positions) + + // Update progress + const pct = Math.round((artistCount / totalArtists) * 100); + const fill = document.getElementById('explorer-progress-fill'); + const text = document.getElementById('explorer-progress-text'); + if (fill) fill.style.width = pct + '%'; + if (text) text.textContent = `Discovering artists... ${artistCount} of ${totalArtists}`; + } else if (data.type === 'complete') { + // Done + } + } catch (e) { + console.warn('Explorer: failed to parse NDJSON line', e); + } + } + } + + // Tree built — show action bar, hide progress + if (actionBar) actionBar.style.display = 'flex'; + if (progress) progress.style.display = 'none'; + _explorerUpdateCount(); + + // Mark playlist as explored (server persists via explored_at; update local copy too) + const exploredPl = _explorer._playlists.find(p => p.id === playlistId); + if (exploredPl) { + exploredPl.explored_at = new Date().toISOString(); + // Update card badge without full re-render + const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); + if (card) { + card.classList.add('explored'); + const oldBadge = card.querySelector('.explorer-picker-card-badge'); + const badgeHTML = '
    '; + if (oldBadge) { + oldBadge.outerHTML = badgeHTML; + } else { + // Insert badge into the name row + const nameRow = card.querySelector('.explorer-picker-card-name-row'); + if (nameRow) { + nameRow.insertAdjacentHTML('beforeend', badgeHTML); + } + } + // Remove discover button if present (no longer needed) + const discoverBtn = card.querySelector('.explorer-picker-card-actions'); + if (discoverBtn) discoverBtn.remove(); + } + } + + // Draw all connections now that the tree is stable + setTimeout(() => _explorerRedrawAllConnections(true), 100); + + } catch (err) { + showToast('Explorer: ' + err.message, 'error'); + if (empty) { empty.style.display = 'flex'; } + if (progress) progress.style.display = 'none'; + } finally { + _explorer.building = false; + if (buildBtn) { buildBtn.disabled = false; buildBtn.textContent = 'Explore'; } + } +} + +function _explorerRenderRoot(meta) { + const tree = document.getElementById('explorer-tree'); + const rootHtml = ` +
    +
    +
    + ${meta.playlist_image + ? `` + : '
    ' + } +
    +
    SOURCE
    +
    ${meta.playlist_name}
    +
    ${meta.total_tracks} tracks · ${meta.total_artists} artists
    +
    +
    +
    +
    + `; + tree.insertAdjacentHTML('afterbegin', rootHtml); + _explorer._artistRowSizes = []; // Track row capacities: [2, 3, 4, ...] + _explorer._artistCount = 0; + _explorer._currentRowIndex = 0; +} + +function _explorerGetOrCreateRow() { + const container = document.getElementById('explorer-artist-tiers'); + if (!container) return null; + + // Determine row sizes: 2, 3, 4, 5... (tree shape) + const rowCapacity = _explorer._currentRowIndex + 2; + const existingRows = container.querySelectorAll('.explorer-tier-artists'); + let currentRow = existingRows[existingRows.length - 1]; + + if (!currentRow || currentRow.children.length >= (_explorer._currentRowIndex + 2)) { + // Need a new row + _explorer._currentRowIndex = existingRows.length; + const newRow = document.createElement('div'); + newRow.className = 'explorer-tier explorer-tier-artists'; + container.appendChild(newRow); + return newRow; + } + return currentRow; +} + +function _explorerRenderArtistNode(artist, index) { + const row = _explorerGetOrCreateRow(); + if (!row) return; + + _explorer._artistCount++; + const albumCount = artist.albums ? artist.albums.length : 0; + const safeKey = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); + const hasError = !!artist.error; + + const html = ` +
    +
    + ${artist.image_url + ? `` + : '' + } +
    +
    ${artist.name || 'Unknown'}
    +
    ${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}
    +
    + ${!hasError && albumCount > 0 ? '
    ' : ''} + ${hasError ? '
    ' : ''} +
    +
    +
    + `; + row.insertAdjacentHTML('beforeend', html); +} + +function explorerToggleArtist(key) { + const children = document.getElementById(`explorer-children-${key}`); + const node = document.getElementById(`explorer-node-${key}`); + if (!children || !node) return; + + const isExpanded = _explorer.expandedArtists.has(key); + if (isExpanded) { + _explorer.expandedArtists.delete(key); + children.innerHTML = ''; + node.classList.remove('expanded'); + } else { + _explorer.expandedArtists.add(key); + node.classList.add('expanded'); + + const artist = _explorer.artists.find(a => (a.name || '').replace(/[^a-zA-Z0-9]/g, '_') === key); + if (artist && artist.albums) { + const albumsHtml = artist.albums.map((album, i) => { + const id = album.spotify_id || `${key}_${i}`; + const selected = _explorer.selectedAlbums.has(id); + const owned = album.owned; + const inPlaylist = album.in_playlist; + + const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; + return ` +
    +
    + ${album.image_url + ? `` + : '' + } +
    +
    ${album.title || 'Unknown'}
    +
    ${album.year || ''} · ${album.track_count || '?'} tracks
    +
    +
    + +
    + ${owned ? '
    Owned
    ' : ''} + ${inPlaylist ? '
    ' : ''} +
    +
    +
    + `; + }).join(''); + children.innerHTML = albumsHtml; + } + } + + // Redraw SVG after DOM settles + requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); +} + +async function explorerExpandAlbumTracks(spotifyAlbumId, nodeKey) { + if (!spotifyAlbumId) return; + const tracksContainer = document.getElementById(`explorer-tracks-${nodeKey}`); + if (!tracksContainer) return; + + // Toggle: if already has content, collapse + if (tracksContainer.innerHTML) { + tracksContainer.innerHTML = ''; + requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); + return; + } + + try { + const response = await fetch(`/api/playlist-explorer/album-tracks/${spotifyAlbumId}`); + const data = await response.json(); + if (!data.success || !data.tracks) return; + + const tracksHtml = data.tracks.map((t, i) => ` +
    +
    +
    +
    ${t.track_number}. ${t.name}
    +
    ${_formatDuration(t.duration_ms)}
    +
    +
    +
    + `).join(''); + tracksContainer.innerHTML = tracksHtml; + requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); + } catch (e) { + console.error('Failed to load album tracks:', e); + } +} + +function _formatDuration(ms) { + if (!ms) return ''; + const m = Math.floor(ms / 60000); + const s = Math.floor((ms % 60000) / 1000); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +// Track double-click vs single-click on album nodes +let _explorerClickTimer = null; +let _explorerLastClickId = null; + +function explorerToggleAlbum(id) { + // Double-click detection: expand tracks + if (_explorerLastClickId === id && _explorerClickTimer) { + clearTimeout(_explorerClickTimer); + _explorerClickTimer = null; + _explorerLastClickId = null; + // Double-click — expand tracks + const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); + const spotifyId = id.includes('_') ? '' : id; // Only real IDs, not fallback keys + explorerExpandAlbumTracks(spotifyId, id); + return; + } + + _explorerLastClickId = id; + _explorerClickTimer = setTimeout(() => { + _explorerClickTimer = null; + _explorerLastClickId = null; + + // Single click — toggle selection + if (_explorer.selectedAlbums.has(id)) { + _explorer.selectedAlbums.delete(id); + } else { + _explorer.selectedAlbums.add(id); + } + + const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); + if (node) { + const isSelected = _explorer.selectedAlbums.has(id); + node.classList.toggle('selected', isSelected); + const check = node.querySelector('.explorer-node-select'); + if (check) check.classList.toggle('active', isSelected); + } + + _explorerUpdateCount(); + }, 250); + + _explorerUpdateCount(); +} + +function explorerSelectAll() { + _explorer.artists.forEach(a => { + (a.albums || []).forEach(album => { + if (album.spotify_id && !album.owned) _explorer.selectedAlbums.add(album.spotify_id); + }); + }); + _explorerRefreshAllCards(); + _explorerUpdateCount(); +} + +function explorerDeselectAll() { + _explorer.selectedAlbums.clear(); + _explorerRefreshAllCards(); + _explorerUpdateCount(); +} + +function _explorerRefreshAllCards() { + document.querySelectorAll('.explorer-node-album').forEach(node => { + const id = node.dataset.id; + const selected = _explorer.selectedAlbums.has(id); + node.classList.toggle('selected', selected); + const check = node.querySelector('.explorer-node-select'); + if (check) check.classList.toggle('active', selected); + }); +} + +function _explorerUpdateCount() { + const el = document.getElementById('explorer-selection-count'); + const count = _explorer.selectedAlbums.size; + if (el) el.textContent = `${count} album${count !== 1 ? 's' : ''} selected`; + _explorerRefreshArtistIndicators(); +} + +function _explorerRefreshArtistIndicators() { + // For each artist, check if any of their albums are selected — add visual indicator + _explorer.artists.forEach(artist => { + const key = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); + const node = document.getElementById(`explorer-node-${key}`); + if (!node) return; + const hasSelected = (artist.albums || []).some(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); + node.classList.toggle('has-selection', hasSelected); + }); +} + +function explorerAddToWishlist() { + if (_explorer.selectedAlbums.size === 0) { + showToast('No albums selected', 'error'); + return; + } + + // Group selected albums by artist with full metadata + const artistSections = []; + for (const artist of _explorer.artists) { + const artistId = artist.artist_id || artist.spotify_id; + if (!artist.albums) continue; + const selected = artist.albums.filter(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); + if (selected.length === 0) continue; + artistSections.push({ artistId, name: artist.name, image: artist.image_url, albums: selected }); + } + + if (artistSections.length === 0) { showToast('No valid albums selected', 'error'); return; } + + // Build confirmation modal (mirrors discog-modal pattern) + const overlay = document.createElement('div'); + overlay.className = 'discog-modal-overlay'; + overlay.id = 'explorer-wishlist-overlay'; + + const totalAlbums = artistSections.reduce((s, a) => s + a.albums.length, 0); + const totalTracks = artistSections.reduce((s, a) => s + a.albums.reduce((t, al) => t + (al.track_count || 0), 0), 0); + + let cardsHtml = ''; + artistSections.forEach(section => { + cardsHtml += `
    ${_esc(section.name)}
    `; + section.albums.forEach((album, i) => { + const year = album.year || ''; + const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; + cardsHtml += ` + + `; + }); + }); + + overlay.innerHTML = ` +
    +
    +
    +
    +

    Add to Wishlist

    +

    ${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} · ${totalAlbums} releases

    +
    + +
    +
    +
    + + + +
    +
    + + +
    +
    +
    ${cardsHtml}
    + + +
    + `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + _explorerWishlistUpdateCount(); + + document.getElementById('explorer-wishlist-submit')?.addEventListener('click', () => _explorerWishlistSubmit(artistSections)); +} + +function _explorerWishlistToggleFilter(btn) { + btn.classList.toggle('active'); + const type = btn.dataset.type; + // Scoped to explorer wishlist modal only + document.querySelectorAll(`#explorer-wishlist-overlay .discog-card[data-type="${type}"]`).forEach(card => { + card.style.display = btn.classList.contains('active') ? '' : 'none'; + }); + _explorerWishlistUpdateCount(); +} + +function _explorerWishlistUpdateCount() { + const checked = document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked'); + let releases = 0, tracks = 0; + checked.forEach(cb => { + if (cb.closest('.discog-card').style.display !== 'none') { + releases++; + tracks += parseInt(cb.dataset.tracks) || 0; + } + }); + const info = document.getElementById('explorer-wishlist-info'); + const btn = document.getElementById('explorer-wishlist-submit-text'); + if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} · ${tracks} tracks`; + if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases'; + const submitBtn = document.getElementById('explorer-wishlist-submit'); + if (submitBtn) submitBtn.disabled = releases === 0; +} + +async function _explorerWishlistSubmit(artistSections) { + const grid = document.getElementById('explorer-wishlist-grid'); + const progress = document.getElementById('explorer-wishlist-progress'); + const filterBar = document.querySelector('#explorer-wishlist-overlay .discog-filter-bar'); + const submitBtn = document.getElementById('explorer-wishlist-submit'); + + // Collect checked albums grouped by artist + const byArtist = {}; + document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked').forEach(cb => { + if (cb.closest('.discog-card').style.display === 'none') return; + const card = cb.closest('.discog-card'); + const artistId = card.dataset.artistId; + const albumId = cb.dataset.albumId; + const title = card.querySelector('.discog-card-title')?.textContent || ''; + const img = card.querySelector('.discog-card-art img')?.src || ''; + if (!byArtist[artistId]) byArtist[artistId] = { albums: [], name: '' }; + byArtist[artistId].albums.push({ id: albumId, title, img, tracks: parseInt(cb.dataset.tracks) || 0 }); + }); + + // Fill in artist names + artistSections.forEach(s => { if (byArtist[s.artistId]) byArtist[s.artistId].name = s.name; }); + + // Switch to progress view + if (grid) grid.style.display = 'none'; + if (filterBar) filterBar.style.display = 'none'; + if (submitBtn) submitBtn.style.display = 'none'; + if (progress) { + progress.style.display = ''; + progress.innerHTML = ''; + for (const [artistId, data] of Object.entries(byArtist)) { + data.albums.forEach(album => { + const item = document.createElement('div'); + item.className = 'discog-progress-item active'; + item.id = `explorer-prog-${album.id}`; + item.innerHTML = ` +
    ${album.img ? `` : '♫'}
    +
    +
    ${_esc(album.title)}
    +
    Waiting...
    +
    +
    + `; + progress.appendChild(item); + }); + } + } + + const info = document.getElementById('explorer-wishlist-info'); + if (info) info.textContent = 'Processing...'; + + let totalAdded = 0; + + for (const [artistId, data] of Object.entries(byArtist)) { + // Sort by track count descending (deluxe editions first) BEFORE extracting IDs + data.albums.sort((a, b) => b.tracks - a.tracks); + const albumIds = data.albums.map(a => a.id); + + try { + const response = await fetch(`/api/artist/${artistId}/download-discography`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ album_ids: albumIds, artist_name: data.name }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop(); + for (const line of lines) { + if (!line.trim()) continue; + try { + const result = JSON.parse(line); + if (result.status === 'complete') continue; // Summary line, skip + const item = document.getElementById(`explorer-prog-${result.album_id}`); + if (item) { + const statusEl = item.querySelector('.discog-prog-status'); + const iconEl = item.querySelector('.discog-prog-icon'); + if (result.status === 'done') { + const added = result.tracks_added || 0; + const skipped = result.tracks_skipped || 0; + totalAdded += added; + if (statusEl) statusEl.textContent = `Added ${added} track${added !== 1 ? 's' : ''}${skipped > 0 ? `, ${skipped} skipped` : ''}`; + if (iconEl) iconEl.innerHTML = ''; + item.classList.remove('active'); + item.classList.add('done'); + } else if (result.status === 'error') { + if (statusEl) statusEl.textContent = result.message || 'Error'; + if (iconEl) iconEl.innerHTML = ''; + item.classList.remove('active'); + item.classList.add('error'); + } + } + } catch (e) { } + } + } + } catch (e) { + console.error(`Explorer wishlist: failed for ${data.name}:`, e); + } + } + + if (info) info.textContent = `Done — ${totalAdded} tracks added to wishlist`; + // Change cancel button label to "Close" + const cancelBtn = document.querySelector('#explorer-wishlist-overlay .discog-cancel-btn'); + if (cancelBtn) cancelBtn.textContent = 'Close'; + showToast(`Added ${totalAdded} tracks to wishlist`, 'success'); + + // Mark albums as added on the tree + _explorer.selectedAlbums.forEach(id => { + const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); + if (node) { node.classList.add('added'); node.classList.remove('selected'); } + }); + _explorer.selectedAlbums.clear(); + _explorerUpdateCount(); + _explorerRefreshArtistIndicators(); +} + +function _explorerEnsureDefs() { + const svg = document.getElementById('explorer-svg'); + if (!svg || svg.querySelector('defs')) return; + // Read accent color from CSS custom property + const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '100,200,255'; + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + defs.innerHTML = ` + + + + + + + + + `; + svg.appendChild(defs); +} + +function _explorerDrawConnectionToArtist(artistIndex) { + // Incremental: draw ONLY this artist's connection. Don't clear existing. + // Flex reflow within the current row may shift siblings, but the visual drift + // is minor and gets corrected by the final redraw after streaming completes. + const svg = document.getElementById('explorer-svg'); + const root = document.getElementById('explorer-root'); + if (!svg || !root) return; + + _explorerEnsureDefs(); + _explorerSizeSvg(); + + const artistNodes = document.querySelectorAll('.explorer-node-artist'); + const artistNode = artistNodes[artistIndex]; + if (!artistNode) return; + + const rc = _explorerGetPos(root); + const ac = _explorerGetPos(artistNode); + _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', true); +} + +function _explorerRedrawAllConnections(animate = false) { + const svg = document.getElementById('explorer-svg'); + const root = document.getElementById('explorer-root'); + if (!svg || !root) return; + + _explorerEnsureDefs(); + _explorerSizeSvg(); + + // Clear existing lines but keep defs + svg.querySelectorAll('path').forEach(p => p.remove()); + + const rc = _explorerGetPos(root); + + document.querySelectorAll('.explorer-node-artist').forEach(artistNode => { + const ac = _explorerGetPos(artistNode); + _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', animate); + + if (artistNode.classList.contains('expanded')) { + const branch = artistNode.closest('.explorer-branch'); + if (!branch) return; + branch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-album').forEach(albumNode => { + const alc = _explorerGetPos(albumNode); + _explorerDrawCurve(svg, ac.cx, ac.bottom, alc.cx, alc.top, 'album', animate); + + const albumBranch = albumNode.closest('.explorer-branch'); + if (albumBranch) { + albumBranch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-track').forEach(trackNode => { + const tc = _explorerGetPos(trackNode); + _explorerDrawCurve(svg, alc.cx, alc.bottom, tc.cx, tc.top, 'track', animate); + }); + } + }); + } + }); +} + +function _explorerSizeSvg() { + const svg = document.getElementById('explorer-svg'); + const tree = document.getElementById('explorer-tree'); + if (!svg || !tree) return; + // SVG is inside the tree. Use scrollWidth/scrollHeight which are unscaled. + // Add padding to ensure lines near edges aren't clipped. + const w = Math.max(tree.scrollWidth, tree.offsetWidth) + 40; + const h = Math.max(tree.scrollHeight, tree.offsetHeight) + 40; + svg.setAttribute('width', w); + svg.setAttribute('height', h); + svg.setAttribute('viewBox', `0 0 ${w} ${h}`); +} + +function _explorerGetPos(el) { + // SVG is inside the tree — positions are relative to tree, unscaled + const tree = document.getElementById('explorer-tree'); + if (!tree) return { cx: 0, top: 0, bottom: 0 }; + const tRect = tree.getBoundingClientRect(); + const r = el.getBoundingClientRect(); + const scale = _explorer._zoom || 1; + // getBoundingClientRect returns scaled coords; divide by scale to get unscaled tree-space coords + return { + cx: (r.left + r.width / 2 - tRect.left) / scale, + top: (r.top - tRect.top) / scale, + bottom: (r.bottom - tRect.top) / scale, + }; +} + +function _explorerDrawCurve(svg, x1, y1, x2, y2, type, animate) { + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const midY = y1 + (y2 - y1) * 0.45; + path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`); + + if (type === 'root') { + path.setAttribute('stroke', 'url(#explorer-grad-root)'); + path.setAttribute('stroke-width', '1.5'); + } else if (type === 'album') { + path.setAttribute('stroke', 'url(#explorer-grad-album)'); + path.setAttribute('stroke-width', '1'); + } else { + path.setAttribute('stroke', 'rgba(255,255,255,0.05)'); + path.setAttribute('stroke-width', '0.8'); + } + path.setAttribute('fill', 'none'); + + svg.appendChild(path); + + if (animate) { + const len = path.getTotalLength(); + path.setAttribute('class', 'explorer-line explorer-line-animated'); + path.style.strokeDasharray = len; + path.style.strokeDashoffset = len; + } else { + path.setAttribute('class', 'explorer-line'); + } +} + +// ── Zoom & Pan ── +_explorer._zoom = 1; +_explorer._panX = 0; +_explorer._panY = 0; +_explorer._isPanning = false; +_explorer._panStartX = 0; +_explorer._panStartY = 0; +_explorer._panStartScrollX = 0; +_explorer._panStartScrollY = 0; + +function _explorerApplyTransform() { + const tree = document.getElementById('explorer-tree'); + if (tree) { + tree.style.transform = `scale(${_explorer._zoom})`; + tree.style.transformOrigin = 'top center'; + } + _explorerSizeSvg(); + requestAnimationFrame(() => _explorerRedrawAllConnections()); +} + +function explorerZoom(delta) { + _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom + delta)); + _explorerApplyTransform(); +} + +function explorerFitToView() { + const viewport = document.getElementById('explorer-viewport'); + const tree = document.getElementById('explorer-tree'); + if (!viewport || !tree) return; + + // Reset zoom to measure natural size + _explorer._zoom = 1; + tree.style.transform = 'scale(1)'; + + requestAnimationFrame(() => { + const treeW = tree.scrollWidth; + const treeH = tree.scrollHeight; + const vpW = viewport.clientWidth - 40; + const vpH = viewport.clientHeight - 40; + + if (treeW > 0 && treeH > 0) { + _explorer._zoom = Math.min(vpW / treeW, vpH / treeH, 1.5); + _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom)); + } + + _explorerApplyTransform(); + viewport.scrollTop = 0; + viewport.scrollLeft = Math.max(0, (tree.scrollWidth * _explorer._zoom - vpW) / 2); + }); +} + +// Scroll wheel zoom (no modifier needed inside viewport) +document.addEventListener('wheel', (e) => { + const viewport = document.getElementById('explorer-viewport'); + if (!viewport || !viewport.contains(e.target)) return; + // Check if we're on the explorer page + const page = document.getElementById('playlist-explorer-page'); + if (!page || !page.classList.contains('active')) return; + + e.preventDefault(); + const step = e.deltaY > 0 ? -0.08 : 0.08; + explorerZoom(step); +}, { passive: false }); + +// Middle-click / right-click drag to pan +document.addEventListener('mousedown', (e) => { + const viewport = document.getElementById('explorer-viewport'); + if (!viewport || !viewport.contains(e.target)) return; + // Middle click (button 1) or right click (button 2) + if (e.button !== 1 && e.button !== 2) return; + + e.preventDefault(); + _explorer._isPanning = true; + _explorer._panStartX = e.clientX; + _explorer._panStartY = e.clientY; + _explorer._panStartScrollX = viewport.scrollLeft; + _explorer._panStartScrollY = viewport.scrollTop; + viewport.style.cursor = 'grabbing'; +}); + +document.addEventListener('mousemove', (e) => { + if (!_explorer._isPanning) return; + const viewport = document.getElementById('explorer-viewport'); + if (!viewport) return; + const dx = e.clientX - _explorer._panStartX; + const dy = e.clientY - _explorer._panStartY; + viewport.scrollLeft = _explorer._panStartScrollX - dx; + viewport.scrollTop = _explorer._panStartScrollY - dy; +}); + +document.addEventListener('mouseup', (e) => { + if (!_explorer._isPanning) return; + _explorer._isPanning = false; + const viewport = document.getElementById('explorer-viewport'); + if (viewport) viewport.style.cursor = ''; +}); + +// Suppress context menu on right-click inside viewport (for panning) +document.addEventListener('contextmenu', (e) => { + const viewport = document.getElementById('explorer-viewport'); + if (viewport && viewport.contains(e.target)) { + e.preventDefault(); + } +}); + +// Debounced redraw on resize +window.addEventListener('resize', () => { + if (_explorer.artists.length === 0) return; + clearTimeout(_explorer._resizeTimer); + _explorer._resizeTimer = setTimeout(() => _explorerRedrawAllConnections(), 150); +}); + + +// ================================================================================== +// DASHBOARD — Recent Syncs Section +// ================================================================================== + +// ================================================================================== +// SERVER PLAYLIST MANAGER — Sync Page Server Tab +// ================================================================================== + +let _serverPlaylists = []; +let _serverEditorState = { playlistId: null, playlistName: '', tracks: [] }; + +async function loadServerPlaylists() { + const container = document.getElementById('server-playlist-container'); + const editor = document.getElementById('server-editor'); + const btn = document.getElementById('server-refresh-btn'); + + if (editor) editor.style.display = 'none'; + if (container) container.style.display = ''; + if (btn) { btn.disabled = true; btn.textContent = '🔄 Loading...'; } + + // Show skeleton loader + if (container) { + container.innerHTML = `
    ${Array.from({ length: 6 }, (_, i) => ` +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    `).join('')}
    `; + } + + try { + // Fetch server playlists, mirrored playlists, and sync history names in parallel + const [serverRes, mirroredRes, historyNamesRes] = await Promise.all([ + fetch('/api/server/playlists'), + fetch('/api/mirrored-playlists'), + fetch('/api/sync/history/names'), + ]); + const data = await serverRes.json(); + let mirroredAll = []; + try { mirroredAll = await mirroredRes.json(); } catch (_) { } + if (!Array.isArray(mirroredAll)) mirroredAll = []; + let historyNames = []; + try { historyNames = await historyNamesRes.json(); } catch (_) { } + if (!Array.isArray(historyNames)) historyNames = []; + + if (!data.success || !data.playlists) { + if (container) container.innerHTML = `
    ${data.error || 'Could not load server playlists'}
    `; + return; + } + + // Separate synced vs non-synced playlists + const mirroredNames = new Set(mirroredAll.map(p => p.name.trim().toLowerCase())); + const syncedNames = new Set(historyNames.map(n => n.trim().toLowerCase())); + const synced = []; + const unsynced = []; + for (const pl of data.playlists) { + const key = pl.name.trim().toLowerCase(); + if (mirroredNames.has(key) || syncedNames.has(key)) { + pl._synced = true; + synced.push(pl); + } else { + pl._synced = false; + unsynced.push(pl); + } + } + + _serverPlaylists = [...synced, ...unsynced]; + const title = document.getElementById('server-tab-title'); + const serverName = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : ''; + if (title) title.textContent = `Server Playlists (${serverName})`; + + if (synced.length === 0 && unsynced.length === 0) { + if (container) container.innerHTML = '
    No playlists found on your media server.
    '; + return; + } + + // Server type icon SVG + const serverIcons = { + plex: '', + jellyfin: '', + navidrome: '' + }; + const sIcon = serverIcons[data.server_type] || serverIcons.plex; + + function _renderPlCard(pl, i, isSynced) { + const hue = (i * 37 + 200) % 360; + const safeName = _esc(pl.name).replace(/'/g, "\\'"); + const cardClass = isSynced ? 'server-pl-card' : 'server-pl-card server-pl-unsynced'; + const action = isSynced ? 'Open Editor' : 'View Tracks'; + return ` +
    +
    +
    +
    +
    + +
    +
    +
    ${sIcon}
    +
    +
    +
    ${_esc(pl.name)}
    +
    + ${pl.track_count} tracks + ${isSynced ? 'Synced' : ''} +
    +
    + +
    `; + } + + let html = ''; + + if (synced.length > 0) { + html += `
    +
    + 🔗 + Synced Playlists + ${synced.length} +
    +
    ${synced.map((pl, i) => _renderPlCard(pl, i, true)).join('')}
    +
    `; + } + + if (unsynced.length > 0) { + html += `
    +
    + 🎵 + Other Server Playlists + ${unsynced.length} +
    +
    ${unsynced.map((pl, i) => _renderPlCard(pl, i + synced.length, false)).join('')}
    +
    `; + } + + container.innerHTML = html; + + } catch (e) { + if (container) container.innerHTML = `
    Error: ${e.message}
    `; + } finally { + if (btn) { btn.disabled = false; btn.textContent = '🔄 Refresh'; } + } +} + +async function openServerPlaylistEditor(playlistId, playlistName) { + // Step 1: Look up mirrored playlists by name + let mirroredPlaylists = []; + try { + const res = await fetch('/api/mirrored-playlists'); + const all = await res.json(); + mirroredPlaylists = (Array.isArray(all) ? all : []).filter(p => + p.name.trim().toLowerCase() === playlistName.trim().toLowerCase() + ); + } catch (e) { + console.error('Failed to fetch mirrored playlists:', e); + } + + if (mirroredPlaylists.length === 1) { + // Single match — go straight to compare + _openServerCompareView(playlistId, playlistName, mirroredPlaylists[0]); + } else if (mirroredPlaylists.length === 0) { + // No match — server-only view + _openServerCompareView(playlistId, playlistName, null); + } else { + // Multiple — disambiguation + _showServerDisambig(playlistId, playlistName, mirroredPlaylists); + } +} + +// ── Disambiguation ── + +function _showServerDisambig(playlistId, playlistName, candidates) { + const overlay = document.getElementById('server-disambig-overlay'); + const list = document.getElementById('server-disambig-list'); + const subtitle = document.getElementById('server-disambig-subtitle'); + if (!overlay || !list) return; + + if (subtitle) subtitle.textContent = `"${playlistName}" was found on ${candidates.length} sources. Which one do you want to compare against?`; + + const sourceIcons = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' }; + + list.innerHTML = candidates.map((p, i) => { + const icon = sourceIcons[p.source] || '📋'; + const ago = timeAgo(p.mirrored_at || p.updated_at); + return ` +
    +
    ${icon}
    +
    +
    ${_esc(p.name)}
    +
    + ${_esc(p.source)} + ${p.track_count || 0} tracks + ${p.owner ? `by ${_esc(p.owner)}` : ''} + Mirrored ${ago} +
    +
    +
    + +
    +
    `; + }).join(''); + + overlay.classList.remove('hidden'); + requestAnimationFrame(() => overlay.classList.add('visible')); + + // Escape key + click backdrop to close + overlay.onclick = e => { if (e.target === overlay) closeServerDisambig(); }; + window._disambigEsc = e => { if (e.key === 'Escape') closeServerDisambig(); }; + document.addEventListener('keydown', window._disambigEsc); +} + +function closeServerDisambig() { + const overlay = document.getElementById('server-disambig-overlay'); + if (overlay) { + overlay.classList.remove('visible'); + setTimeout(() => overlay.classList.add('hidden'), 250); + } + if (window._disambigEsc) { document.removeEventListener('keydown', window._disambigEsc); window._disambigEsc = null; } +} + +async function selectDisambigPlaylist(playlistId, playlistName, mirroredId) { + closeServerDisambig(); + try { + const res = await fetch(`/api/mirrored-playlists/${mirroredId}`); + const mirrored = await res.json(); + _openServerCompareView(playlistId, playlistName, mirrored); + } catch (e) { + showToast('Failed to load mirrored playlist: ' + e.message, 'error'); + } +} + +// ── Compare View ── + +async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist) { + const container = document.getElementById('server-playlist-container'); + const editor = document.getElementById('server-editor'); + if (!editor) return; + + if (container) container.style.display = 'none'; + editor.style.display = ''; + + const nameEl = document.getElementById('server-editor-name'); + const metaEl = document.getElementById('server-editor-meta'); + const banner = document.getElementById('server-no-source-banner'); + const sourceScroll = document.getElementById('server-col-source-scroll'); + const serverScroll = document.getElementById('server-col-server-scroll'); + + if (nameEl) nameEl.textContent = playlistName; + if (metaEl) metaEl.textContent = 'Loading comparison...'; + if (banner) banner.style.display = 'none'; + if (sourceScroll) sourceScroll.innerHTML = '
    Loading...
    '; + if (serverScroll) serverScroll.innerHTML = '
    Loading...
    '; + + // Store state + _serverEditorState = { + playlistId, + playlistName, + mirroredPlaylist, + tracks: [], + }; + + // Build API URL + let url = `/api/server/playlist/${playlistId}/tracks?name=${encodeURIComponent(playlistName)}`; + if (mirroredPlaylist && mirroredPlaylist.id) { + url += `&mirrored_playlist_id=${mirroredPlaylist.id}`; + } + + try { + const response = await fetch(url); + const data = await response.json(); + if (!data.success) { + if (metaEl) metaEl.textContent = data.error || 'Failed to load'; + return; + } + + _serverEditorState.tracks = data.tracks || []; + _serverEditorState.serverType = data.server_type; + + const tracks = _serverEditorState.tracks; + const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server'; + + // Header metadata + if (metaEl) metaEl.textContent = `${serverLabel} · ${data.server_track_count || 0} server tracks · ${data.source_track_count || 0} source tracks`; + + // Show no-source banner if needed + if (!mirroredPlaylist && banner) { + banner.style.display = ''; + } + + // Stats, filter counts, footer + _updateCompareStats(tracks); + + // Column headers + const sourceLabel = mirroredPlaylist ? (mirroredPlaylist.source || 'source').charAt(0).toUpperCase() + (mirroredPlaylist.source || 'source').slice(1) : 'Source'; + const sourceIconMap = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' }; + const serverIconMap = { plex: '🟠', jellyfin: '🟣', navidrome: '🔵' }; + + const srcIconEl = document.getElementById('server-col-source-icon'); + const srcLabelEl = document.getElementById('server-col-source-label'); + const srcCountEl = document.getElementById('server-col-source-count'); + const svrIconEl = document.getElementById('server-col-server-icon'); + const svrLabelEl = document.getElementById('server-col-server-label'); + const svrCountEl = document.getElementById('server-col-server-count'); + + if (srcIconEl) srcIconEl.textContent = mirroredPlaylist ? (sourceIconMap[mirroredPlaylist.source] || '📋') : '📋'; + if (srcLabelEl) srcLabelEl.textContent = sourceLabel; + if (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`; + if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻'; + if (svrLabelEl) svrLabelEl.textContent = serverLabel; + if (svrCountEl) svrCountEl.textContent = `${data.server_track_count || 0} tracks`; + + // Render columns + _renderCompareColumns(tracks); + + // Scroll linking + _setupScrollLinking(); + + } catch (e) { + if (metaEl) metaEl.textContent = 'Error: ' + e.message; + } +} + +function _updateCompareStats(tracks) { + const matched = tracks.filter(t => t.match_status === 'matched').length; + const missing = tracks.filter(t => t.match_status === 'missing').length; + const extra = tracks.filter(t => t.match_status === 'extra').length; + + const statsEl = document.getElementById('server-editor-stats'); + if (statsEl) { + statsEl.innerHTML = ` +
    ${matched}
    Matched
    +
    ${missing}
    Missing
    + ${extra > 0 ? `
    ${extra}
    Extra
    ` : ''} + `; + } + + const editor = document.getElementById('server-editor'); + if (editor) { + editor.querySelectorAll('.discog-filter').forEach(btn => { + const f = btn.dataset.filter; + if (f === 'all') btn.textContent = `All (${tracks.length})`; + else if (f === 'matched') btn.textContent = `Matched (${matched})`; + else if (f === 'missing') btn.textContent = `Missing (${missing})`; + else if (f === 'extra') btn.textContent = `Extra (${extra})`; + }); + } + + const footer = document.getElementById('server-editor-footer'); + if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`; +} + +function _formatDurationMs(ms) { + if (!ms) return ''; + const s = Math.round(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +function _renderCompareColumns(tracks) { + const sourceScroll = document.getElementById('server-col-source-scroll'); + const serverScroll = document.getElementById('server-col-server-scroll'); + if (!sourceScroll || !serverScroll) return; + + let sourceHTML = ''; + let serverHTML = ''; + + tracks.forEach((t, i) => { + const src = t.source_track; + const svr = t.server_track; + const status = t.match_status; + const pairId = `pair-${i}`; + + // ── Source (left) column ── + if (src) { + const dur = _formatDurationMs(src.duration_ms); + sourceHTML += ` +
    +
    ${src.position != null ? src.position : i + 1}
    +
    + ${src.image_url ? `` : '
    '} +
    +
    +
    ${_esc(src.name)}
    +
    ${_esc(src.artist || '')}
    +
    +
    ${dur}
    +
    +
    `; + } else { + // Extra track — no source + sourceHTML += ` +
    +
    + No source track +
    +
    `; + } + + // ── Server (right) column ── + if (svr) { + const dur = _formatDurationMs(svr.duration); + const conf = t.confidence != null ? t.confidence : null; + let confBadge = ''; + if (status === 'matched' && conf != null) { + const pct = Math.round(conf * 100); + const cls = pct >= 100 ? 'exact' : pct >= 90 ? 'high' : 'fuzzy'; + confBadge = `${pct}%`; + } + serverHTML += ` +
    +
    ${i + 1}
    +
    + ${svr.thumb ? `` : '
    '} +
    +
    +
    ${_esc(svr.title)}
    +
    ${_esc(svr.artist || '')}
    +
    + ${confBadge} +
    ${dur}
    +
    + ${status === 'matched' ? `` : ''} + +
    +
    +
    `; + } else { + // Missing on server — clickable empty slot + const hint = src ? `${src.artist || ''} — ${src.name}` : ''; + serverHTML += ` +
    +
    +
    + +
    + Find & add + ${_esc(hint)} +
    +
    `; + } + }); + + sourceScroll.innerHTML = sourceHTML; + serverScroll.innerHTML = serverHTML; +} + +function _setupScrollLinking() { + const sourceScroll = document.getElementById('server-col-source-scroll'); + const serverScroll = document.getElementById('server-col-server-scroll'); + if (!sourceScroll || !serverScroll) return; + + // Remove old listeners to prevent accumulation on refresh + if (window._serverScrollAC) window._serverScrollAC.abort(); + window._serverScrollAC = new AbortController(); + const signal = window._serverScrollAC.signal; + + let syncing = false; + + const syncScroll = (from, to) => { + if (syncing) return; + syncing = true; + const maxFrom = from.scrollHeight - from.clientHeight; + const maxTo = to.scrollHeight - to.clientHeight; + if (maxFrom > 0 && maxTo > 0) { + to.scrollTop = (from.scrollTop / maxFrom) * maxTo; + } + requestAnimationFrame(() => { syncing = false; }); + }; + + sourceScroll.addEventListener('scroll', () => syncScroll(sourceScroll, serverScroll), { signal }); + serverScroll.addEventListener('scroll', () => syncScroll(serverScroll, sourceScroll), { signal }); +} + +function _compareTrackClick(side, index) { + const otherSide = side === 'source' ? 'server' : 'source'; + const otherScroll = document.getElementById(`server-col-${otherSide}-scroll`); + const pairId = `pair-${index}`; + + // Clear previous highlights + document.querySelectorAll('.server-track-item.highlighted').forEach(el => el.classList.remove('highlighted')); + + // Highlight both paired items + document.querySelectorAll(`[data-pair-id="${pairId}"]`).forEach(el => el.classList.add('highlighted')); + + // Scroll the OTHER column to show the paired item + const target = otherScroll?.querySelector(`[data-pair-id="${pairId}"]`); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +function _serverEditorRefresh() { + _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); +} + +function serverEditorBack() { + const container = document.getElementById('server-playlist-container'); + const editor = document.getElementById('server-editor'); + if (editor) editor.style.display = 'none'; + if (container) container.style.display = ''; +} + +function _serverEditorFilter(btn, filter) { + btn.closest('.server-editor-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Filter both columns simultaneously + ['server-col-source-scroll', 'server-col-server-scroll'].forEach(colId => { + document.querySelectorAll(`#${colId} .server-track-item`).forEach(item => { + const status = item.dataset.status; + item.style.display = (filter === 'all' || status === filter) ? '' : 'none'; + }); + }); +} + +// ── Track Search / Replace ── + +async function serverSearchReplace(trackIndex, mode) { + const track = _serverEditorState.tracks[trackIndex]; + if (!track) return; + + const src = track.source_track || {}; + const svr = track.server_track || {}; + // Search by track name only first (more reliable than "artist trackname" blob) + const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim(); + const contextArtist = src.artist || svr.artist || ''; + const contextName = src.name || svr.title || ''; + + const existing = document.getElementById('server-search-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'server-search-overlay'; + overlay.className = 'server-search-overlay'; + overlay.innerHTML = ` +
    +
    +
    +
    ${mode === 'replace' ? 'Swap Track' : 'Add Track to Server'}
    + ${contextName ? `
    + Source: + ${_esc(contextArtist)} + + ${_esc(contextName)} +
    ` : ''} +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + +
    Searching... +
    +
    +
    + `; + // Click overlay background or press Escape to close + overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); + overlay._escHandler = e => { if (e.key === 'Escape') overlay.remove(); }; + document.addEventListener('keydown', overlay._escHandler); + // Clean up Escape listener when overlay is removed + const obs = new MutationObserver(() => { + if (!document.body.contains(overlay)) { document.removeEventListener('keydown', overlay._escHandler); obs.disconnect(); } + }); + obs.observe(document.body, { childList: true }); + + const popover = overlay.querySelector('.server-search-popover'); + popover.dataset.trackIndex = trackIndex; + popover.dataset.mode = mode; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + document.getElementById('server-search-input')?.focus(); + document.getElementById('server-search-input')?.select(); + + _serverSearchExecute(); +} + +async function _serverSearchExecute() { + const input = document.getElementById('server-search-input'); + const results = document.getElementById('server-search-results'); + const resultsHeader = document.getElementById('server-search-results-header'); + const popover = document.getElementById('server-search-popover'); + if (!input || !results || !popover) return; + + const query = input.value.trim(); + if (!query) { + results.innerHTML = '
    Type a search query
    '; + if (resultsHeader) resultsHeader.textContent = ''; + return; + } + + results.innerHTML = '
    Searching library...
    '; + if (resultsHeader) resultsHeader.textContent = ''; + + try { + const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`); + const data = await response.json(); + + if (!data.success || !data.tracks || data.tracks.length === 0) { + results.innerHTML = `
    + +
    No results found
    Try different keywords or a shorter query +
    `; + return; + } + + const trackIndex = parseInt(popover.dataset.trackIndex); + const mode = popover.dataset.mode; + + if (resultsHeader) resultsHeader.textContent = `${data.tracks.length} result${data.tracks.length !== 1 ? 's' : ''}`; + + results.innerHTML = data.tracks.map((t, i) => { + const ext = (t.file_path || '').split('.').pop().toUpperCase(); + const format = ['FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'AAC', 'WAV'].includes(ext) ? (ext === 'M4A' ? 'AAC' : ext) : ''; + const dur = _formatDurationMs(t.duration); + const bitrateStr = t.bitrate ? `${t.bitrate}k` : ''; + return ` +
    +
    + ${t.album_thumb_url ? `` : '
    '} +
    +
    +
    ${_esc(t.title)}
    +
    ${_esc(t.artist_name)}${t.album_title ? ` · ${_esc(t.album_title)}` : ''}
    +
    +
    + ${format ? `${format}` : ''} + ${bitrateStr ? `${bitrateStr}` : ''} + ${dur ? `${dur}` : ''} +
    + +
    + `; + }).join(''); + + } catch (e) { + results.innerHTML = `
    Error: ${e.message}
    `; + } +} + +async function _serverSelectTrack(trackIndex, mode, newTrackId, el) { + const track = _serverEditorState.tracks[trackIndex]; + if (!track) return; + + const btn = el.querySelector('.server-search-select-btn'); + if (btn) { btn.disabled = true; btn.textContent = '...'; } + + try { + let response; + if (mode === 'replace') { + response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/replace-track`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + old_track_id: track.server_track?.id, + new_track_id: newTrackId, + playlist_name: _serverEditorState.playlistName, + }) + }); + } else { + // Calculate the server-side position for this track + // Count how many server tracks exist before this index + let serverPos = 0; + for (let k = 0; k < trackIndex; k++) { + if (_serverEditorState.tracks[k]?.server_track) serverPos++; + } + response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/add-track`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_id: newTrackId, + playlist_name: _serverEditorState.playlistName, + position: serverPos, + }) + }); + } + + const data = await response.json(); + if (data.success) { + showToast(data.message || 'Track updated', 'success'); + document.getElementById('server-search-overlay')?.remove(); + // Update playlist ID if server recreated it (Plex deletes+recreates) + if (data.new_playlist_id) _serverEditorState.playlistId = data.new_playlist_id; + + // Re-fetch from server so the compare view reflects the actual server state + // and the matching algorithm can correctly wire up the newly added/replaced track + _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); + } else { + showToast(data.error || 'Failed to update track', 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Select'; } + } + } catch (e) { + showToast('Error: ' + e.message, 'error'); + if (btn) { btn.disabled = false; btn.textContent = 'Select'; } + } +} + +async function _serverRemoveTrack(trackIndex, serverTrackId) { + if (!serverTrackId) return; + + const track = _serverEditorState.tracks[trackIndex]; + const trackTitle = track?.server_track?.title || 'this track'; + + if (!await showConfirmDialog({ title: 'Remove Track', message: `Remove "${trackTitle}" from this playlist?`, confirmText: 'Remove', destructive: true })) return; + + try { + const response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/remove-track`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_id: serverTrackId, + playlist_name: _serverEditorState.playlistName, + }) + }); + + const data = await response.json(); + if (data.success) { + showToast(data.message || 'Track removed', 'success'); + const pid = data.new_playlist_id || _serverEditorState.playlistId; + _serverEditorState.playlistId = pid; + _openServerCompareView(pid, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); + } else { + showToast(data.error || 'Failed to remove track', 'error'); + } + } catch (e) { + showToast('Error: ' + e.message, 'error'); + } +} + + +// Auto-refresh sync cards every 30 seconds when on dashboard +setInterval(() => { + if (typeof currentPage !== 'undefined' && currentPage === 'dashboard') { + loadDashboardSyncHistory(); + } +}, 30000); + +async function loadDashboardSyncHistory() { + const container = document.getElementById('sync-history-cards'); + if (!container) return; + + try { + const response = await fetch('/api/sync/history?limit=10'); + if (!response.ok) return; + + const data = await response.json(); + // Filter to only show playlist syncs — not album downloads or wishlist processing + const entries = (data.entries || []).filter(e => e.sync_type === 'playlist' || !e.sync_type); + + if (entries.length === 0) { + container.innerHTML = '
    No syncs yet
    '; + return; + } + + container.innerHTML = entries.map((entry, cardIndex) => { + const found = entry.tracks_found || 0; + const total = entry.total_tracks || 0; + const downloaded = entry.tracks_downloaded || 0; + const failed = entry.tracks_failed || 0; + const pct = total > 0 ? Math.round((found / total) * 100) : 0; + + // Health color + let healthClass = 'health-good'; + if (pct < 50) healthClass = 'health-bad'; + else if (pct < 80) healthClass = 'health-warn'; + + // Source badge + const sourceLabels = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', wishlist: 'Wishlist' }; + const sourceLabel = sourceLabels[entry.source] || entry.source || 'Unknown'; + + // Time + const timeStr = entry.started_at ? _relativeTime(entry.started_at) : ''; + + // Name + const name = entry.artist_name + ? `${entry.artist_name} — ${entry.album_name || entry.playlist_name}` + : entry.playlist_name || 'Unknown'; + + return ` +
    + +
    + ${entry.thumb_url ? `` : '
    '} +
    +
    +
    ${typeof _esc === 'function' ? _esc(name) : name}
    +
    + ${sourceLabel} + ${timeStr} +
    +
    +
    +
    ${pct}%
    +
    +
    +
    +
    ${found}/${total} matched${downloaded > 0 ? ` · ${downloaded} ⬇` : ''}${failed > 0 ? ` · ${failed} ✗` : ''}
    +
    +
    + `; + }).join(''); + + } catch (e) { + console.warn('Failed to load sync history for dashboard:', e); + } +} + +function _relativeTime(dateStr) { + try { + const d = new Date(dateStr); + const now = new Date(); + const diffMs = now - d; + const mins = Math.floor(diffMs / 60000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 7) return `${days}d ago`; + return d.toLocaleDateString(); + } catch (e) { return ''; } +} + +async function openSyncDetailModal(entryId) { + try { + showLoadingOverlay('Loading sync details...'); + const response = await fetch(`/api/sync/history/${entryId}`); + const data = await response.json(); + hideLoadingOverlay(); + + if (!data.success || !data.entry) { + showToast('Could not load sync details', 'error'); + return; + } + + const entry = data.entry; + const trackResults = entry.track_results || []; + const name = entry.artist_name + ? `${entry.artist_name} — ${entry.album_name || entry.playlist_name}` + : entry.playlist_name || 'Unknown'; + + // Build modal + const overlay = document.createElement('div'); + overlay.className = 'discog-modal-overlay'; + overlay.id = 'sync-detail-overlay'; + + const found = entry.tracks_found || 0; + const total = entry.total_tracks || 0; + const downloaded = entry.tracks_downloaded || 0; + + let trackRowsHtml = ''; + if (trackResults.length > 0) { + trackRowsHtml = trackResults.map((t, i) => { + const statusIcon = t.status === 'found' ? '✅' : '❌'; + const statusClass = t.status === 'found' ? 'matched' : 'unmatched'; + const confPct = Math.round((t.confidence || 0) * 100); + const confClass = confPct >= 80 ? 'conf-high' : confPct >= 50 ? 'conf-mid' : 'conf-low'; + let dlIcon = ''; + if (t.download_status === 'completed') dlIcon = '✅'; + else if (t.download_status === 'failed') dlIcon = '❌'; + else if (t.download_status === 'not_found') dlIcon = '🔇'; + else if (t.download_status === 'cancelled') dlIcon = '🚫'; + + let dlDisplay = dlIcon; + if (!dlDisplay && t.download_status === 'wishlist') dlDisplay = '→ Wishlist'; + + return ` + + ${i + 1} + + ${t.image_url ? `` : '
    '} + + ${_esc(t.name || '')} + ${_esc(t.artist || '')} + ${_esc(t.album || '')} + ${statusIcon} + ${confPct}% + ${dlDisplay} + + `; + }).join(''); + } else { + // Fallback to tracks_json if no track_results (old syncs before data caching) + const tracks = entry.tracks || []; + const esc = typeof _esc === 'function' ? _esc : s => s; + trackRowsHtml = ` + +
    Per-track match data not available for this sync.
    Re-sync this playlist to see detailed match results.
    + + ` + tracks.map((t, i) => { + const artists = t.artists || []; + const artistName = artists.length > 0 ? (typeof artists[0] === 'string' ? artists[0] : artists[0]?.name || '') : ''; + const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); + return ` + + ${i + 1} + + ${esc(t.name || '')} + ${esc(artistName)} + ${esc(albumName)} + + + `; + }).join(''); + } + + // Count stats for filter bar + const matchedCount = trackResults.filter(t => t.status === 'found').length; + const unmatchedCount = trackResults.filter(t => t.status !== 'found').length; + const downloadedCount = trackResults.filter(t => t.download_status === 'completed').length; + + overlay.innerHTML = ` +
    +
    +
    +
    +

    Sync Details

    +

    ${_esc(name)}

    +
    + +
    +
    +
    + + + + ${downloadedCount > 0 ? `` : ''} +
    +
    +
    + + + + + + + + + + + + + + + ${trackRowsHtml} + +
    #TrackArtistAlbumMatchConf.Status
    +
    + +
    + `; + + document.body.appendChild(overlay); + requestAnimationFrame(() => overlay.classList.add('visible')); + + } catch (e) { + hideLoadingOverlay(); + showToast('Failed to load sync details', 'error'); + } +} + +async function deleteSyncHistoryCard(entryId, btnEl) { + try { + const card = btnEl.closest('.sync-history-card'); + if (card) { + card.style.opacity = '0'; + card.style.transform = 'scale(0.9)'; + } + const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' }); + if (resp.ok) { + setTimeout(() => { if (card) card.remove(); }, 200); + } + } catch (e) { + console.warn('Failed to delete sync entry:', e); + } +} + +function _syncDetailFilter(btn, filter) { + // Update active button + btn.closest('.discog-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + + // Filter rows + document.querySelectorAll('#sync-detail-tbody .sync-detail-row').forEach(row => { + if (filter === 'all') { + row.style.display = ''; + } else if (filter === 'matched') { + row.style.display = row.classList.contains('matched') ? '' : 'none'; + } else if (filter === 'unmatched') { + row.style.display = row.classList.contains('unmatched') ? '' : 'none'; + } else if (filter === 'downloaded') { + const dlCell = row.querySelector('.sync-detail-dl'); + row.style.display = dlCell && dlCell.textContent.trim() === '✅' ? '' : 'none'; + } + }); +} + + +// ============================================ +// ACTIVE DOWNLOADS PAGE — Centralized Live View +// ============================================ + +let _adlPoller = null; +let _adlFilter = 'all'; +let _adlData = []; +let _adlBatches = []; +let _adlBatchHistory = []; +let _adlExpandedBatches = new Set(); +let _adlBatchHistoryPoller = null; +let _adlFilterBatchId = null; // When set, main list shows only this batch +const _batchColorMap = {}; +const _batchCompletedAt = {}; // batch_id -> timestamp when first seen as complete +let _batchColorNext = 0; + +function _getBatchColor(batchId) { + if (!batchId) return -1; + if (_batchColorMap[batchId] === undefined) { + // Deterministic color from batch_id hash for consistency across reloads + let hash = 0; + for (let i = 0; i < batchId.length; i++) hash = ((hash << 5) - hash + batchId.charCodeAt(i)) | 0; + _batchColorMap[batchId] = Math.abs(hash) % 8; + } + return _batchColorMap[batchId]; +} + +function loadActiveDownloadsPage() { + _adlFetch(); + _adlFetchBatchHistory(); + // Poll downloads every 2 seconds, history every 60 seconds + if (_adlPoller) clearInterval(_adlPoller); + _adlPoller = setInterval(() => { + if (currentPage === 'active-downloads') _adlFetch(); + else { clearInterval(_adlPoller); _adlPoller = null; } + }, 2000); + if (_adlBatchHistoryPoller) clearInterval(_adlBatchHistoryPoller); + _adlBatchHistoryPoller = setInterval(() => { + if (currentPage === 'active-downloads') _adlFetchBatchHistory(); + else { clearInterval(_adlBatchHistoryPoller); _adlBatchHistoryPoller = null; } + }, 60000); +} + +function adlSetFilter(filter) { + _adlFilter = filter; + document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter)); + _adlRender(); +} + +async function _adlFetch() { + try { + const resp = await fetch('/api/downloads/all?limit=300'); + const data = await resp.json(); + if (data.success) { + _adlData = data.downloads || []; + _adlBatches = data.batches || []; + _adlRender(); + _adlRenderBatchPanel(); + // Don't call _adlUpdateBadge() here — it counts the truncated + // 300-item local array. The WebSocket status push already + // maintains the badge with the real server-side active count. + } + } catch (e) { + console.error('Downloads page fetch error:', e); + } +} + +function _adlUpdateBadge() { + const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length; + _updateDlNavBadge(activeCount); +} + +function _updateDlNavBadge(count) { + const badge = document.getElementById('dl-nav-badge'); + if (badge) { + if (count > 0) { + badge.textContent = count; + badge.classList.remove('hidden'); + } else { + badge.classList.add('hidden'); + } + } +} + +function _adlRender() { + const list = document.getElementById('adl-list'); + const empty = document.getElementById('adl-empty'); + const countEl = document.getElementById('adl-count'); + if (!list) return; + + // Apply filter + const activeStatuses = ['downloading', 'searching', 'post_processing']; + const queuedStatuses = ['queued']; + const completedStatuses = ['completed', 'skipped', 'already_owned']; + const failedStatuses = ['failed', 'not_found', 'cancelled']; + + let filtered = _adlData; + + // Batch filter: if a batch card is selected, narrow to that batch first + if (_adlFilterBatchId) { + filtered = filtered.filter(d => d.batch_id === _adlFilterBatchId); + } + + if (_adlFilter === 'active') filtered = filtered.filter(d => activeStatuses.includes(d.status)); + else if (_adlFilter === 'queued') filtered = filtered.filter(d => queuedStatuses.includes(d.status)); + else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status)); + else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); + + const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length; + + if (countEl) { + const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length; + const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length; + const total = _adlData.length; + const parts = []; + if (activeN > 0) parts.push(`${activeN} active`); + if (queuedN > 0) parts.push(`${queuedN} queued`); + parts.push(`${total} total`); + countEl.textContent = parts.join(' / '); + } + + // Show/hide clear button + const clearBtn = document.getElementById('adl-clear-btn'); + if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none'; + + // Show/hide cancel-all button — only visible when there's something to cancel + const cancelAllBtn = document.getElementById('adl-cancel-all-btn'); + if (cancelAllBtn) { + const hasRunningWork = _adlData.some(d => + [...activeStatuses, ...queuedStatuses].includes(d.status) + ); + cancelAllBtn.style.display = hasRunningWork ? '' : 'none'; + } + + // Batch filter indicator banner + let existingBanner = document.getElementById('adl-batch-filter-banner'); + if (_adlFilterBatchId) { + const batchInfo = _adlBatches.find(b => b.batch_id === _adlFilterBatchId); + const batchName = batchInfo ? batchInfo.batch_name : 'Unknown batch'; + const colorIdx = _getBatchColor(_adlFilterBatchId); + const colorDot = colorIdx >= 0 ? `` : ''; + if (!existingBanner) { + existingBanner = document.createElement('div'); + existingBanner.id = 'adl-batch-filter-banner'; + existingBanner.className = 'adl-batch-filter-banner'; + list.parentNode.insertBefore(existingBanner, list); + } + existingBanner.innerHTML = `${colorDot}Showing: ${_adlEsc(batchName)} `; + existingBanner.style.display = ''; + } else if (existingBanner) { + existingBanner.style.display = 'none'; + } + + if (filtered.length === 0) { + if (empty) empty.style.display = ''; + // Clear any existing rows but keep the empty message + list.querySelectorAll('.adl-row').forEach(r => r.remove()); + return; + } + + if (empty) empty.style.display = 'none'; + + // Group by status category for section headers + const groups = { active: [], queued: [], completed: [], failed: [] }; + for (const dl of filtered) { + const cls = _adlStatusClass(dl.status); + if (cls === 'active') groups.active.push(dl); + else if (cls === 'queued') groups.queued.push(dl); + else if (cls === 'completed') groups.completed.push(dl); + else groups.failed.push(dl); + } + + let html = ''; + const sections = [ + { key: 'active', label: 'Active', items: groups.active }, + { key: 'queued', label: 'Queued', items: groups.queued }, + { key: 'completed', label: 'Completed', items: groups.completed }, + { key: 'failed', label: 'Failed', items: groups.failed }, + ]; + + for (const section of sections) { + if (section.items.length === 0) continue; + // Only show section headers in "all" filter mode + if (_adlFilter === 'all') { + html += `
    ${section.label} (${section.items.length})
    `; + } + for (const dl of section.items) { + const statusClass = _adlStatusClass(dl.status); + const statusLabel = _adlStatusLabel(dl.status); + const title = _adlEsc(dl.title || 'Unknown Track'); + const artist = _adlEsc(dl.artist || ''); + const album = _adlEsc(dl.album || ''); + const batchName = _adlEsc(dl.batch_name || ''); + const error = dl.error ? _adlEsc(dl.error) : ''; + + const meta = [artist, album].filter(Boolean).join(' \u00B7 '); + const artHtml = dl.artwork + ? `` + : '
    '; + + // Track position: "3 of 19" + const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : ''; + + const colorIdx = _getBatchColor(dl.batch_id); + const colorBar = colorIdx >= 0 + ? `
    ` + : ''; + + // Per-row cancel only makes sense for in-flight tasks. Terminal + // states (completed/failed/cancelled) have nothing to cancel. + const isCancellable = statusClass === 'active' || statusClass === 'queued'; + const cancelBtnHtml = isCancellable && dl.playlist_id && dl.track_index !== undefined + ? `` + : ''; + + html += `
    + ${colorBar} + ${artHtml} +
    +
    ${title}
    + ${meta ? `
    ${meta}
    ` : ''} + ${batchName ? `
    ${batchName}${posText ? ' · Track ' + posText : ''}
    ` : ''} + ${error ? `
    ${error}
    ` : ''} +
    +
    + + ${statusLabel} +
    + ${cancelBtnHtml} +
    `; + } + } + + // Preserve empty element, inject rows + const emptyEl = document.getElementById('adl-empty'); + const emptyHtml = emptyEl ? emptyEl.outerHTML : ''; + list.innerHTML = emptyHtml + html; + const newEmpty = document.getElementById('adl-empty'); + if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : ''; +} + +function _adlStatusClass(status) { + switch (status) { + case 'downloading': case 'searching': case 'post_processing': return 'active'; + case 'queued': case 'pending': return 'queued'; + case 'completed': case 'skipped': case 'already_owned': return 'completed'; + case 'failed': case 'not_found': return 'failed'; + case 'cancelled': return 'cancelled'; + default: return 'queued'; + } +} + +function _adlStatusLabel(status) { + switch (status) { + case 'downloading': return 'Downloading'; + case 'searching': return 'Searching'; + case 'post_processing': return 'Processing'; + case 'queued': case 'pending': return 'Queued'; + case 'completed': return 'Completed'; + case 'skipped': return 'Skipped'; + case 'already_owned': return 'Owned'; + case 'failed': return 'Failed'; + case 'not_found': return 'Not Found'; + case 'cancelled': return 'Cancelled'; + default: return status; + } +} + +function _adlEsc(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +async function adlClearCompleted() { + try { + const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success'); + _adlFetch(); + } + } catch (e) { + console.error('Error clearing completed downloads:', e); + } +} + +// ---- Batch Context Panel ---- + +const _BATCH_FADE_SECONDS = 15; // Remove completed batches after this many seconds + +function _adlRenderBatchPanel() { + const container = document.getElementById('adl-batch-active'); + const headerTitle = document.querySelector('.adl-batch-panel-title'); + if (!container) return; + + const now = Date.now(); + + // Filter out batches that completed more than FADE seconds ago + const visibleBatches = _adlBatches.filter(batch => { + const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; + if (!isTerminal) { + delete _batchCompletedAt[batch.batch_id]; // Reset if it came back to life + return true; + } + if (!_batchCompletedAt[batch.batch_id]) { + _batchCompletedAt[batch.batch_id] = now; + } + const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; + return elapsed < _BATCH_FADE_SECONDS; + }); + + // Update header with count + if (headerTitle) { + const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length; + headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches'; + } + + if (visibleBatches.length === 0) { + container.innerHTML = `
    + +
    No active batches
    +
    Start a download from Search, Sync, or Wishlist
    +
    `; + return; + } + + let html = ''; + for (const batch of visibleBatches) { + const colorIdx = _getBatchColor(batch.batch_id); + const colorStyle = colorIdx >= 0 ? `border-left-color: rgba(var(--batch-color-${colorIdx}), 0.6)` : ''; + const isExpanded = _adlExpandedBatches.has(batch.batch_id); + const isFiltered = _adlFilterBatchId === batch.batch_id; + const total = batch.total || 1; + const done = batch.completed + batch.failed; + const pct = Math.round((done / total) * 100); + const hasFailed = batch.failed > 0; + const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; + const isActive = batch.phase === 'downloading' && batch.active > 0; + + // Fade progress for completing batches + let fadeStyle = ''; + if (isTerminal && _batchCompletedAt[batch.batch_id]) { + const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; + const fadeStart = _BATCH_FADE_SECONDS * 0.6; + if (elapsed > fadeStart) { + const fadeProgress = Math.min(1, (elapsed - fadeStart) / (_BATCH_FADE_SECONDS - fadeStart)); + fadeStyle = `opacity: ${1 - fadeProgress};`; + } + } + + const sourceBadge = batch.source_page + ? `${_adlEsc(batch.source_page)}` + : ''; + + // Phase label with icon + let phaseText = ''; + let phaseIcon = ''; + if (batch.phase === 'analysis') { + phaseText = 'Analyzing...'; + phaseIcon = ''; + } else if (batch.phase === 'downloading') { + phaseText = `${batch.completed}/${total} tracks`; + if (batch.active > 0) phaseIcon = ''; + } else if (batch.phase === 'complete') { + phaseText = `Done \u2014 ${batch.completed} tracks`; + phaseIcon = '\u2713'; + } else if (batch.phase === 'cancelled') { + phaseText = 'Cancelled'; + } else if (batch.phase === 'error') { + phaseText = 'Error'; + } else { + phaseText = batch.phase; + } + + // Get first track artwork for batch thumbnail, fallback to initial + const batchTracks = _adlData.filter(d => d.batch_id === batch.batch_id); + const artworkTrack = batchTracks.find(t => t.artwork); + let thumbHtml; + if (artworkTrack) { + thumbHtml = ``; + } else { + const initial = (batch.batch_name || 'D')[0].toUpperCase(); + const bgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.15)` : 'rgba(255,255,255,0.05)'; + const fgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.7)` : 'rgba(255,255,255,0.4)'; + thumbHtml = `
    ${initial}
    `; + } + + // Build expanded tracks list with per-track progress + let tracksHtml = ''; + if (isExpanded) { + if (batchTracks.length > 0) { + tracksHtml = batchTracks.map(t => { + const cls = _adlStatusClass(t.status); + const progress = t.progress || 0; + + // Status indicator with detail + let statusHtml = ''; + if (t.status === 'downloading' && progress > 0) { + statusHtml = `${Math.round(progress)}%`; + } else if (t.status === 'searching') { + statusHtml = ``; + } else if (t.status === 'post_processing') { + statusHtml = `proc`; + } else if (cls === 'completed') { + statusHtml = `\u2713`; + } else if (cls === 'failed') { + statusHtml = `\u2717`; + } else { + statusHtml = `\u00B7`; + } + + // Mini progress bar for downloading tracks + const miniBar = t.status === 'downloading' && progress > 0 + ? `
    ` + : ''; + + return `
    + ${_adlEsc(t.title || 'Unknown')} + ${statusHtml} + ${miniBar} +
    `; + }).join(''); + } else { + tracksHtml = '
    No tracks loaded
    '; + } + } + + const cardClasses = ['adl-batch-card']; + if (isExpanded) cardClasses.push('expanded'); + if (isActive) cardClasses.push('active-glow'); + if (isFiltered) cardClasses.push('filtered'); + + const playlistId = _adlEsc(batch.playlist_id || ''); + + html += `
    +
    + ${thumbHtml} +
    + +
    ${phaseIcon}${phaseText}
    +
    + ${sourceBadge} +
    + + ${!isTerminal ? `` : ''} +
    +
    +
    +
    +
    +
    ${tracksHtml}
    +
    `; + } + + container.innerHTML = html; +} + +function _adlToggleBatch(batchId) { + if (_adlExpandedBatches.has(batchId)) { + _adlExpandedBatches.delete(batchId); + } else { + _adlExpandedBatches.add(batchId); + } + _adlRenderBatchPanel(); +} + +function _adlOpenBatchModal(batchId, playlistId, batchName) { + // For wishlist batches, navigate to wishlist and show modal + if (playlistId === 'wishlist') { + const clientProcess = activeDownloadProcesses['wishlist']; + if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { + clientProcess.modalElement.style.display = 'flex'; + if (typeof WishlistModalState !== 'undefined') WishlistModalState.setVisible(); + } else { + rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); + } + return; + } + + // For other batches, try to show existing modal or rehydrate + for (const [pid, process] of Object.entries(activeDownloadProcesses)) { + if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) { + process.modalElement.style.display = 'flex'; + return; + } + } + // Rehydrate from server + rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); +} + +function _adlFilterByBatch(batchId) { + if (_adlFilterBatchId === batchId) { + _adlFilterBatchId = null; // Toggle off + } else { + _adlFilterBatchId = batchId; + } + _adlRender(); + _adlRenderBatchPanel(); +} + +async function adlCancelRow(btnEl, playlistId, trackIndex) { + // Per-row cancel on the Downloads page. Uses the same atomic cancel + // endpoint the modal cancel buttons use, so worker slots free properly. + if (!playlistId || trackIndex === undefined || trackIndex === null) { + showToast('Cannot cancel — missing task coordinates', 'error'); + return; + } + // Lock the button so rapid clicks don't fire duplicate requests + if (btnEl) { + if (btnEl.dataset.cancelling === '1') return; + btnEl.dataset.cancelling = '1'; + btnEl.classList.add('adl-row-cancel-pending'); + } + try { + const resp = await fetch('/api/downloads/cancel_task_v2', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: playlistId, + track_index: trackIndex + }) + }); + const data = await resp.json(); + if (data.success) { + const name = data.task_info && data.task_info.track_name ? data.task_info.track_name : 'Track'; + showToast(`Cancelled "${name}"`, 'info'); + _adlFetch(); + } else { + showToast(data.error || 'Cancel failed', 'error'); + if (btnEl) { + btnEl.dataset.cancelling = '0'; + btnEl.classList.remove('adl-row-cancel-pending'); + } + } + } catch (e) { + console.error('ADL row cancel error:', e); + showToast('Cancel request failed', 'error'); + if (btnEl) { + btnEl.dataset.cancelling = '0'; + btnEl.classList.remove('adl-row-cancel-pending'); + } + } +} + +async function _adlCancelBatch(batchId) { + const batch = _adlBatches.find(b => b.batch_id === batchId); + const batchName = batch ? batch.batch_name : 'this batch'; + const confirmed = await showConfirmDialog({ + title: 'Cancel Batch', + message: `Cancel "${batchName}"? All active and queued downloads in this batch will be stopped.`, + confirmText: 'Cancel Batch', + destructive: true + }); + if (!confirmed) return; + try { + const resp = await fetch(`/api/playlists/${batchId}/cancel_batch`, { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + showToast(`Cancelled ${data.cancelled_tasks} downloads`, 'info'); + _adlFetch(); + } else { + showToast(data.error || 'Failed to cancel batch', 'error'); + } + } catch (e) { + showToast('Failed to cancel batch', 'error'); + } +} + +async function adlCancelAll() { + // Cancel every batch with active/queued work — equivalent to clicking + // "Cancel All" inside each running download modal. Uses the same + // /api/playlists//cancel_batch endpoint the per-batch card + // cancel uses, so worker slots free atomically. + const runningBatches = _adlBatches.filter(b => (b.active || 0) > 0 || (b.queued || 0) > 0); + if (runningBatches.length === 0) { + showToast('No active batches to cancel', 'info'); + return; + } + + const totalTasks = runningBatches.reduce((sum, b) => sum + (b.active || 0) + (b.queued || 0), 0); + const batchWord = runningBatches.length === 1 ? 'batch' : 'batches'; + const taskWord = totalTasks === 1 ? 'task' : 'tasks'; + const confirmed = await showConfirmDialog({ + title: 'Cancel All Downloads', + message: `Cancel ${totalTasks} ${taskWord} across ${runningBatches.length} ${batchWord}? Active and queued downloads will be stopped and added to the wishlist.`, + confirmText: 'Cancel All', + destructive: true + }); + if (!confirmed) return; + + const btn = document.getElementById('adl-cancel-all-btn'); + if (btn) { + btn.disabled = true; + btn.classList.add('adl-cancel-all-pending'); + } + + let cancelled = 0; + let failed = 0; + // Sequential so we don't hammer the backend — cancel_batch takes a lock + // internally and parallel calls would mostly serialize anyway. + for (const batch of runningBatches) { + try { + const resp = await fetch(`/api/playlists/${batch.batch_id}/cancel_batch`, { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + cancelled += (data.cancelled_tasks || 0); + } else { + failed += 1; + console.warn(`cancel_batch failed for ${batch.batch_id}:`, data.error); + } + } catch (e) { + failed += 1; + console.warn(`cancel_batch exception for ${batch.batch_id}:`, e); + } + } + + if (btn) { + btn.disabled = false; + btn.classList.remove('adl-cancel-all-pending'); + } + + if (cancelled > 0 && failed === 0) { + showToast(`Cancelled ${cancelled} downloads`, 'success'); + } else if (cancelled > 0 && failed > 0) { + showToast(`Cancelled ${cancelled} downloads (${failed} batches failed)`, 'info'); + } else { + showToast('Failed to cancel any downloads', 'error'); + } + + _adlFetch(); +} + +// ---- Batch History ---- + +async function _adlFetchBatchHistory() { + try { + const resp = await fetch('/api/downloads/batch-history?days=7&limit=50'); + const data = await resp.json(); + if (data.success) { + _adlBatchHistory = data.history || []; + _adlRenderBatchHistory(); + } + } catch (e) { + console.debug('Batch history fetch error:', e); + } +} + +function _adlRenderBatchHistory() { + const section = document.getElementById('adl-batch-history-section'); + const list = document.getElementById('adl-batch-history-list'); + if (!section || !list) return; + + if (_adlBatchHistory.length === 0) { + section.style.display = 'none'; + return; + } + + section.style.display = ''; + + list.innerHTML = _adlBatchHistory.map(h => { + const name = _adlEsc(h.playlist_name || 'Unknown'); + const downloaded = h.tracks_downloaded || 0; + const failed = h.tracks_failed || 0; + const total = h.total_tracks || 0; + const statsParts = [`${downloaded}/${total}`]; + if (failed > 0) statsParts.push(`${failed} failed`); + + let dateText = ''; + if (h.completed_at) { + try { + const d = new Date(h.completed_at); + const now = new Date(); + const diffMs = now - d; + const diffH = Math.floor(diffMs / 3600000); + if (diffH < 1) dateText = 'just now'; + else if (diffH < 24) dateText = `${diffH}h ago`; + else dateText = `${Math.floor(diffH / 24)}d ago`; + } catch (e) { + dateText = ''; + } + } + + const sourceLabel = h.source_page ? `${_adlEsc(h.source_page)}` : ''; + + // Source type color dot + const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' }; + const dotColor = sourceColors[h.source_page] || '255, 255, 255'; + const histDot = ``; + + return `
    + ${histDot} +
    ${name} ${sourceLabel}
    +
    ${statsParts.join(' ')}
    +
    ${dateText}
    +
    `; + }).join(''); +} + +function adlToggleBatchHistory() { + const section = document.getElementById('adl-batch-history-section'); + if (section) section.classList.toggle('expanded'); +} + +function adlToggleBatchPanel() { + const panel = document.getElementById('adl-batch-panel'); + if (panel) panel.classList.toggle('collapsed'); +} + +window.adlSetFilter = adlSetFilter; +window.adlClearCompleted = adlClearCompleted; +window._adlToggleBatch = _adlToggleBatch; +window._adlOpenBatchModal = _adlOpenBatchModal; +window._adlFilterByBatch = _adlFilterByBatch; +window._adlCancelBatch = _adlCancelBatch; +window.adlCancelRow = adlCancelRow; +window.adlCancelAll = adlCancelAll; +window.adlToggleBatchHistory = adlToggleBatchHistory; +window.adlToggleBatchPanel = adlToggleBatchPanel; + diff --git a/webui/static/script.js b/webui/static/script.js deleted file mode 100644 index a49a7809..00000000 --- a/webui/static/script.js +++ /dev/null @@ -1,77260 +0,0 @@ -// 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 - -// --- 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 = ` -
    -

    Mass Deletion Warning

    -

    - You are about to permanently delete ${orphanCount.toLocaleString()} files from your disk. -

    -

    - 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. -

    -

    - To confirm you understand the risk, type witness me below: -

    - -
    - - -
    -
    - `; - - 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' - } -}; - -// =============================== -// INITIALIZATION -// =============================== - -// ---- Accent Color System ---- - -function getAccentFallbackColors() { - let accent = localStorage.getItem('soulsync-accent') || '#1db954'; - if (!/^#[0-9a-fA-F]{6}$/.test(accent)) accent = '#1db954'; - // Compute a lighter variant for the second color - const r = parseInt(accent.slice(1, 3), 16), g = parseInt(accent.slice(3, 5), 16), b = parseInt(accent.slice(5, 7), 16); - const lighter = '#' + [Math.min(r + 20, 255), Math.min(g + 30, 255), Math.min(b + 12, 255)] - .map(v => v.toString(16).padStart(2, '0')).join(''); - return [accent, lighter]; -} - -function applyAccentColor(hex) { - // Validate hex format — reject corrupt values - if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) { - hex = '#1db954'; // fallback to default - } - // Convert hex to RGB - const r = parseInt(hex.slice(1, 3), 16); - const g = parseInt(hex.slice(3, 5), 16); - const b = parseInt(hex.slice(5, 7), 16); - - // Convert RGB to HSL - const rn = r / 255, gn = g / 255, bn = b / 255; - const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); - const l = (max + min) / 2; - let h = 0, s = 0; - if (max !== min) { - const d = max - min; - s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; - else if (max === gn) h = ((bn - rn) / d + 2) / 6; - else h = ((rn - gn) / d + 4) / 6; - } - - // Compute light variant: +16% lightness - const lightL = Math.min(l + 0.16, 0.95); - // Compute neon variant: high lightness + boosted saturation - const neonL = Math.min(l + 0.30, 0.95); - const neonS = Math.min(s + 0.1, 1.0); - - function hslToRgb(h, s, l) { - if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; } - const hue2rgb = (p, q, t) => { - if (t < 0) t += 1; if (t > 1) t -= 1; - if (t < 1 / 6) return p + (q - p) * 6 * t; - if (t < 1 / 2) return q; - if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; - return p; - }; - const q = l < 0.5 ? l * (1 + s) : l + s - l * s; - const p = 2 * l - q; - return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255), - Math.round(hue2rgb(p, q, h) * 255), - Math.round(hue2rgb(p, q, h - 1 / 3) * 255)]; - } - - const light = hslToRgb(h, s, lightL); - const neon = hslToRgb(h, neonS, neonL); - - const root = document.documentElement.style; - root.setProperty('--accent-rgb', `${r}, ${g}, ${b}`); - root.setProperty('--accent-light-rgb', `${light[0]}, ${light[1]}, ${light[2]}`); - root.setProperty('--accent-neon-rgb', `${neon[0]}, ${neon[1]}, ${neon[2]}`); - - // Store for instant restore on next page load - localStorage.setItem('soulsync-accent', hex); - - // Update preview swatch if it exists - const swatch = document.getElementById('accent-preview-swatch'); - if (swatch) swatch.style.background = hex; -} - -function applyParticlesSetting(enabled) { - const canvas = document.getElementById('page-particles-canvas'); - if (canvas) canvas.style.display = enabled ? '' : 'none'; - if (window.pageParticles) { - if (enabled) { - const activePage = document.querySelector('.page.active'); - if (activePage) { - window.pageParticles.setPage(activePage.id.replace('-page', '')); - } - } else { - window.pageParticles.stop(); - } - } - window._particlesEnabled = enabled; - localStorage.setItem('soulsync-particles', String(enabled)); -} - -function applyWorkerOrbsSetting(enabled) { - window._workerOrbsEnabled = enabled; - localStorage.setItem('soulsync-worker-orbs', String(enabled)); - if (window.workerOrbs) { - if (enabled) { - const activePage = document.querySelector('.page.active'); - if (activePage && activePage.id === 'dashboard-page') { - window.workerOrbs.setPage('dashboard'); - } - } else { - window.workerOrbs.setPage('_disabled'); - } - } -} - -function initAccentColorListeners() { - const presetSelect = document.getElementById('accent-preset'); - const customGroup = document.getElementById('custom-color-group'); - const customPicker = document.getElementById('accent-custom-color'); - if (!presetSelect) return; - - presetSelect.addEventListener('change', () => { - const val = presetSelect.value; - if (val === 'custom') { - if (customGroup) customGroup.style.display = ''; - if (customPicker) applyAccentColor(customPicker.value); - } else { - if (customGroup) customGroup.style.display = 'none'; - applyAccentColor(val); - } - }); - - if (customPicker) { - customPicker.addEventListener('input', () => { - applyAccentColor(customPicker.value); - }); - } - - // Particles toggle — apply immediately on change - const particlesCheckbox = document.getElementById('particles-enabled'); - if (particlesCheckbox) { - particlesCheckbox.addEventListener('change', () => { - applyParticlesSetting(particlesCheckbox.checked); - }); - } - - // Worker orbs toggle — apply immediately on change - const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); - if (workerOrbsCheckbox) { - workerOrbsCheckbox.addEventListener('change', () => { - applyWorkerOrbsSetting(workerOrbsCheckbox.checked); - }); - } - - // Reduce effects toggle — apply immediately on change - const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled'); - if (reduceEffectsCheckbox) { - reduceEffectsCheckbox.addEventListener('change', () => { - applyReduceEffects(reduceEffectsCheckbox.checked); - }); - } -} - -function applyReduceEffects(enabled) { - if (enabled) { - document.body.classList.add('reduce-effects'); - } else { - document.body.classList.remove('reduce-effects'); - } - localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0'); -} - -// Bootstrap accent and reduce-effects from localStorage instantly (prevents flash) -(function () { - if (localStorage.getItem('soulsync-reduce-effects') === '1') { - document.body.classList.add('reduce-effects'); - } - const saved = localStorage.getItem('soulsync-accent'); - if (saved) applyAccentColor(saved); - // Bootstrap particles setting from localStorage - const particlesSaved = localStorage.getItem('soulsync-particles'); - if (particlesSaved === 'false') { - window._particlesEnabled = false; - const canvas = document.getElementById('page-particles-canvas'); - if (canvas) canvas.style.display = 'none'; - } - // Bootstrap worker orbs setting from localStorage - const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs'); - if (workerOrbsSaved === 'false') { - window._workerOrbsEnabled = false; - } -})(); - -// ── Profile System ───────────────────────────────────────────── -let currentProfile = null; - -function getProfileHomePage() { - if (!currentProfile) return 'dashboard'; - if (currentProfile.home_page) return currentProfile.home_page; - return currentProfile.is_admin ? 'dashboard' : 'discover'; -} - -function isPageAllowed(pageId) { - if (!currentProfile) return true; - if (currentProfile.id === 1) return true; - if (pageId === 'help' || pageId === 'issues') return true; - if (pageId === 'artist-detail') { - // artist-detail requires library access - const ap = currentProfile.allowed_pages; - if (!ap) return true; - return ap.includes('library'); - } - if (pageId === 'settings') return currentProfile.is_admin; - const ap = currentProfile.allowed_pages; - if (!ap) return true; // null = all pages - return ap.includes(pageId); -} - -function canDownload() { - if (!currentProfile) return true; - if (currentProfile.id === 1) return true; - return currentProfile.can_download !== false && currentProfile.can_download !== 0; -} - -function renderProfileAvatar(el, profile) { - // Renders avatar as image (if avatar_url set) or colored initial fallback - // Preserves existing classes, ensures 'profile-avatar' is present - if (!el.classList.contains('profile-avatar') && !el.classList.contains('profile-indicator-avatar') && !el.classList.contains('profile-pin-avatar')) { - el.className = 'profile-avatar'; - } - el.style.background = profile.avatar_color || '#6366f1'; - el.textContent = ''; - if (profile.avatar_url) { - const img = document.createElement('img'); - img.src = profile.avatar_url; - img.alt = profile.name; - img.className = 'profile-avatar-img'; - img.onerror = () => { - img.remove(); - el.textContent = profile.name.charAt(0).toUpperCase(); - }; - el.appendChild(img); - } else { - el.textContent = profile.name.charAt(0).toUpperCase(); - } -} - -async function initProfileSystem() { - try { - // Check if a session already has a profile selected - const currentRes = await fetch('/api/profiles/current'); - const currentData = await currentRes.json(); - if (currentData.success && currentData.profile) { - currentProfile = currentData.profile; - updateProfileIndicator(); - - // Check if launch PIN is required - if (currentData.launch_pin_required) { - showLaunchPinScreen(); - return false; // Defer app init until PIN verified - } - - return true; // Profile already selected, skip picker - } - - // Fetch all profiles - const res = await fetch('/api/profiles'); - const data = await res.json(); - const profiles = data.profiles || []; - - if (profiles.length === 0) { - // No profiles yet — auto-select admin profile 1 - await selectProfile(1); - return true; - } - - if (profiles.length === 1) { - // Only one profile — always auto-select (PIN only matters with multiple profiles) - await selectProfile(profiles[0].id); - - // Re-check for launch PIN after auto-select - const recheck = await fetch('/api/profiles/current'); - const recheckData = await recheck.json(); - if (recheckData.launch_pin_required) { - showLaunchPinScreen(); - return false; - } - - return true; - } - - // Multiple profiles or PIN required — show picker - showProfilePicker(profiles); - return false; // App init deferred until profile selected - } catch (e) { - console.error('Profile init error:', e); - return true; // Fall through to normal init - } -} - -// ── Launch PIN Lock Screen ───────────────────────────────────────────── - -function showLaunchPinScreen() { - const overlay = document.getElementById('launch-pin-overlay'); - if (!overlay) return; - overlay.style.display = 'flex'; - - const input = document.getElementById('launch-pin-input'); - const submit = document.getElementById('launch-pin-submit'); - const error = document.getElementById('launch-pin-error'); - - input.value = ''; - error.style.display = 'none'; - setTimeout(() => input.focus(), 100); - - const doSubmit = async () => { - const pin = input.value.trim(); - if (!pin) return; - - submit.disabled = true; - submit.textContent = 'Verifying...'; - - try { - const res = await fetch('/api/profiles/verify-launch-pin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin }) - }); - const data = await res.json(); - - if (data.success) { - // Server session flag set by verify endpoint — consumed on next /api/profiles/current call - overlay.style.display = 'none'; - initApp(); // Now safe to load the full app - } else { - error.textContent = data.error || 'Invalid PIN'; - error.style.display = 'block'; - input.value = ''; - input.focus(); - // Shake animation - overlay.querySelector('.launch-pin-container').classList.add('shake'); - setTimeout(() => overlay.querySelector('.launch-pin-container').classList.remove('shake'), 500); - } - } catch (e) { - error.textContent = 'Connection error'; - error.style.display = 'block'; - } - - submit.disabled = false; - submit.textContent = 'Unlock'; - }; - - // Remove old listeners to prevent stacking - const newSubmit = submit.cloneNode(true); - submit.parentNode.replaceChild(newSubmit, submit); - newSubmit.addEventListener('click', doSubmit); - - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') doSubmit(); - }); -} - -// ── Security Settings Helpers ────────────────────────────────────────── - -async function saveSecurityPin() { - const pin = document.getElementById('security-new-pin').value; - const confirm = document.getElementById('security-confirm-pin').value; - const msg = document.getElementById('security-pin-msg'); - - if (!pin || pin.length < 4) { - msg.textContent = 'PIN must be at least 4 characters'; - msg.style.display = 'block'; - msg.style.color = '#ff5252'; - return; - } - if (pin !== confirm) { - msg.textContent = 'PINs do not match'; - msg.style.display = 'block'; - msg.style.color = '#ff5252'; - return; - } - - try { - const res = await fetch('/api/profiles/1/set-pin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin }) - }); - const data = await res.json(); - - if (data.success) { - msg.textContent = 'PIN saved! You can now enable the lock screen.'; - msg.style.color = '#4caf50'; - msg.style.display = 'block'; - - // Update UI — hide setup, show change, enable toggle - document.getElementById('security-pin-setup').style.display = 'none'; - document.getElementById('security-change-pin-section').style.display = 'block'; - document.getElementById('security-require-pin').disabled = false; - - // Clear inputs - document.getElementById('security-new-pin').value = ''; - document.getElementById('security-confirm-pin').value = ''; - } else { - msg.textContent = data.error || 'Failed to save PIN'; - msg.style.color = '#ff5252'; - msg.style.display = 'block'; - } - } catch (e) { - msg.textContent = 'Connection error'; - msg.style.color = '#ff5252'; - msg.style.display = 'block'; - } -} - -function handleSecurityPinToggle(checkbox) { - // If trying to enable but no PIN, show the setup section - if (checkbox.checked) { - const setupSection = document.getElementById('security-pin-setup'); - if (setupSection.style.display !== 'none' || checkbox.disabled) { - checkbox.checked = false; - setupSection.style.display = 'block'; - document.getElementById('security-new-pin').focus(); - return; - } - } - // Auto-save this setting - saveSettings(true); -} - -function showChangeSecurityPin() { - document.getElementById('security-pin-setup').style.display = 'block'; - document.getElementById('security-new-pin').focus(); -} - -// ── Forgot PIN Recovery ──────────────────────────────────────────────── - -function showForgotPinView() { - document.getElementById('launch-pin-entry').style.display = 'none'; - document.getElementById('launch-pin-recovery').style.display = 'block'; - document.getElementById('launch-recovery-input').value = ''; - document.getElementById('launch-recovery-error').style.display = 'none'; - setTimeout(() => document.getElementById('launch-recovery-input').focus(), 100); -} - -function showPinEntryView() { - document.getElementById('launch-pin-recovery').style.display = 'none'; - document.getElementById('launch-pin-entry').style.display = 'block'; - setTimeout(() => document.getElementById('launch-pin-input').focus(), 100); -} - -async function submitRecoveryCredential() { - const input = document.getElementById('launch-recovery-input'); - const error = document.getElementById('launch-recovery-error'); - const btn = document.getElementById('launch-recovery-submit'); - const credential = input.value.trim(); - - if (!credential) return; - - btn.disabled = true; - btn.textContent = 'Verifying...'; - error.style.display = 'none'; - - try { - const res = await fetch('/api/profiles/reset-pin-via-credential', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ credential }) - }); - const data = await res.json(); - - if (data.success) { - sessionStorage.setItem('soulsync_pin_ok', '1'); - document.getElementById('launch-pin-overlay').style.display = 'none'; - initApp(); - setTimeout(() => showToast('PIN cleared. You can set a new one in Settings → Advanced.', 'success'), 1000); - } else { - error.textContent = data.error || 'Credential not recognized'; - error.style.display = 'block'; - input.value = ''; - input.focus(); - document.getElementById('launch-pin-container').classList.add('shake'); - setTimeout(() => document.getElementById('launch-pin-container').classList.remove('shake'), 500); - } - } catch (e) { - error.textContent = 'Connection error'; - error.style.display = 'block'; - } - - btn.disabled = false; - btn.textContent = 'Verify & Reset PIN'; -} - -// ── Profile PIN Forgot Recovery ──────────────────────────────────────── -function showProfileForgotPin() { - const dialog = document.getElementById('profile-pin-dialog'); - const content = dialog.querySelector('.profile-pin-content'); - - // Store the profile ID we're recovering for - const profileName = document.getElementById('profile-pin-name').textContent; - - // Replace dialog content with recovery form - content.dataset.prevHtml = content.innerHTML; - content.innerHTML = ` -

    Reset PIN for ${profileName}

    -

    Enter any configured API credential
    (Spotify secret, Plex token, etc.)

    - -
    - - -
    - - `; - setTimeout(() => document.getElementById('profile-recovery-input').focus(), 100); - - document.getElementById('profile-recovery-cancel').onclick = () => { - content.innerHTML = content.dataset.prevHtml; - }; - - document.getElementById('profile-recovery-submit').onclick = async () => { - const input = document.getElementById('profile-recovery-input'); - const error = document.getElementById('profile-recovery-error'); - const credential = input.value.trim(); - if (!credential) return; - - const btn = document.getElementById('profile-recovery-submit'); - btn.disabled = true; - btn.textContent = 'Verifying...'; - error.style.display = 'none'; - - try { - const res = await fetch('/api/profiles/reset-pin-via-credential', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ credential, profile_id: dialog._profileId || 1 }) - }); - const data = await res.json(); - if (data.success) { - dialog.style.display = 'none'; - content.innerHTML = content.dataset.prevHtml; - showToast('PIN cleared. You can set a new one in Settings.', 'success'); - // Re-try selecting the profile (now PIN-free) - if (dialog._profileId) selectProfile(dialog._profileId); - } else { - error.textContent = data.error || 'Credential not recognized'; - error.style.display = 'block'; - input.value = ''; - input.focus(); - } - } catch (e) { - error.textContent = 'Connection error'; - error.style.display = 'block'; - } - btn.disabled = false; - btn.textContent = 'Verify & Reset'; - }; - - document.getElementById('profile-recovery-input').onkeydown = (e) => { - if (e.key === 'Enter') document.getElementById('profile-recovery-submit').click(); - }; -} - -function showProfilePicker(profiles, canCancel = false) { - const overlay = document.getElementById('profile-picker-overlay'); - const grid = document.getElementById('profile-picker-grid'); - const actions = document.getElementById('profile-picker-actions'); - - grid.innerHTML = ''; - profiles.forEach(p => { - const card = document.createElement('div'); - card.className = 'profile-picker-card'; - const avatarEl = document.createElement('div'); - renderProfileAvatar(avatarEl, p); - card.appendChild(avatarEl); - const nameEl = document.createElement('span'); - nameEl.className = 'profile-name'; - nameEl.textContent = p.name; - card.appendChild(nameEl); - if (p.is_admin) { - const badge = document.createElement('span'); - badge.className = 'profile-badge'; - badge.textContent = 'Admin'; - card.appendChild(badge); - } - card.onclick = () => handleProfileClick(p); - grid.appendChild(card); - }); - - // Show actions: admin sees "Manage Profiles", non-admin sees "My Profile" (when they have a profile selected) - const isAdmin = currentProfile ? currentProfile.is_admin : false; - const manageBtn = document.getElementById('manage-profiles-btn'); - if (isAdmin) { - actions.style.display = ''; - if (manageBtn) { - manageBtn.textContent = 'Manage Profiles'; - // Reset onclick to admin handler (initProfileManagement sets this, but re-affirm here) - manageBtn.onclick = () => { - document.getElementById('profile-manage-panel').style.display = 'flex'; - loadProfileManageList(); - }; - } - } else if (currentProfile && canCancel) { - // Non-admin with an active profile: show "My Profile" to edit own settings - actions.style.display = ''; - if (manageBtn) { - manageBtn.textContent = 'My Profile'; - manageBtn.onclick = () => showSelfEditForm(); - } - } else { - actions.style.display = 'none'; - } - - // Show/remove cancel button when opened from sidebar indicator - let cancelBtn = overlay.querySelector('.profile-picker-cancel'); - if (cancelBtn) cancelBtn.remove(); - if (canCancel) { - cancelBtn = document.createElement('button'); - cancelBtn.className = 'profile-picker-cancel'; - cancelBtn.textContent = 'Cancel'; - cancelBtn.onclick = () => hideProfilePicker(); - actions.parentElement.appendChild(cancelBtn); - } - - overlay.style.display = 'flex'; - document.querySelector('.main-container').style.display = 'none'; -} - -async function handleProfileClick(profile) { - // Fetch profile count — PIN only matters with multiple profiles - let profileCount = 1; - try { - const r = await fetch('/api/profiles'); - const d = await r.json(); - profileCount = (d.profiles || []).length; - } catch (e) { } - - if (profile.has_pin && profileCount > 1) { - showPinDialog(profile); - } else { - const wasSwitching = !!currentProfile; - await selectProfile(profile.id); - if (wasSwitching) { - window.location.reload(); - return; - } - hideProfilePicker(); - initApp(); - } -} - -function showPinDialog(profile) { - const dialog = document.getElementById('profile-pin-dialog'); - const avatar = document.getElementById('profile-pin-avatar'); - const nameEl = document.getElementById('profile-pin-name'); - const input = document.getElementById('profile-pin-input'); - const errorEl = document.getElementById('profile-pin-error'); - - renderProfileAvatar(avatar, profile); - nameEl.textContent = profile.name; - input.value = ''; - errorEl.style.display = 'none'; - dialog._profileId = profile.id; - dialog.style.display = 'flex'; - setTimeout(() => input.focus(), 100); - - const submit = document.getElementById('profile-pin-submit'); - const cancel = document.getElementById('profile-pin-cancel'); - - const wasSwitching = !!currentProfile; - const handleSubmit = async () => { - const pin = input.value; - if (!pin) return; - try { - const res = await fetch('/api/profiles/select', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile_id: profile.id, pin }) - }); - const data = await res.json(); - if (data.success) { - cleanup(); - if (wasSwitching) { - window.location.reload(); - return; - } - currentProfile = data.profile; - dialog.style.display = 'none'; - hideProfilePicker(); - updateProfileIndicator(); - initApp(); - return; - } else { - errorEl.textContent = data.error || 'Invalid PIN'; - errorEl.style.display = ''; - input.value = ''; - input.focus(); - } - } catch (e) { - errorEl.textContent = 'Connection error'; - errorEl.style.display = ''; - } - cleanup(); - }; - - const handleCancel = () => { - dialog.style.display = 'none'; - cleanup(); - }; - - const handleKeydown = (e) => { - if (e.key === 'Enter') handleSubmit(); - if (e.key === 'Escape') handleCancel(); - }; - - const cleanup = () => { - submit.removeEventListener('click', handleSubmit); - cancel.removeEventListener('click', handleCancel); - input.removeEventListener('keydown', handleKeydown); - }; - - submit.addEventListener('click', handleSubmit); - cancel.addEventListener('click', handleCancel); - input.addEventListener('keydown', handleKeydown); -} - -async function selectProfile(profileId) { - try { - const oldProfileId = currentProfile ? currentProfile.id : null; - const res = await fetch('/api/profiles/select', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile_id: profileId }) - }); - const data = await res.json(); - if (data.success) { - currentProfile = data.profile; - updateProfileIndicator(); - // Join profile-scoped WebSocket room for watchlist/wishlist count updates - if (socket && socket.connected) { - socket.emit('profile:join', { profile_id: profileId, old_profile_id: oldProfileId }); - } - // Invalidate ListenBrainz cache on profile switch (each profile has their own playlists) - _invalidateListenBrainzCache(); - } - return data.success; - } catch (e) { - console.error('Error selecting profile:', e); - return false; - } -} - -function hideProfilePicker() { - document.getElementById('profile-picker-overlay').style.display = 'none'; - document.querySelector('.main-container').style.display = 'flex'; -} - -function updateProfileIndicator() { - const indicator = document.getElementById('profile-indicator'); - if (!currentProfile || !indicator) return; - - const avatar = document.getElementById('profile-indicator-avatar'); - const name = document.getElementById('profile-indicator-name'); - - renderProfileAvatar(avatar, currentProfile); - name.textContent = currentProfile.name; - indicator.style.display = 'flex'; - - indicator.onclick = async () => { - const res = await fetch('/api/profiles'); - const data = await res.json(); - if (data.profiles && data.profiles.length > 0) { - showProfilePicker(data.profiles, true); - } - }; - - // Filter sidebar pages based on profile permissions - document.querySelectorAll('.nav-button[data-page]').forEach(btn => { - const page = btn.getAttribute('data-page'); - if (page === 'hydrabase') return; // Managed by dev mode toggle - if (page === 'settings') { - // Settings always gated by is_admin - btn.style.display = currentProfile.is_admin ? '' : 'none'; - } else if (page === 'help' || page === 'issues') { - btn.style.display = ''; // Always visible - } else if (currentProfile.id === 1) { - btn.style.display = ''; // Root admin sees all - } else { - const ap = currentProfile.allowed_pages; - btn.style.display = (!ap || ap.includes(page)) ? '' : 'none'; - } - }); - - // Toggle download capability - if (canDownload()) { - document.body.classList.remove('downloads-disabled'); - } else { - document.body.classList.add('downloads-disabled'); - } -} - -// ===================== -// PERSONAL SETTINGS MODAL -// ===================== - -async function openPersonalSettings() { - const overlay = document.getElementById('personal-settings-overlay'); - if (!overlay) return; - overlay.style.display = 'flex'; - - const body = document.getElementById('personal-settings-body'); - body.innerHTML = '
    Loading...
    '; - - try { - // Load all per-profile service data in parallel - const [lbRes, spotifyRes] = await Promise.all([ - fetch('/api/profiles/me/listenbrainz'), - fetch('/api/profiles/me/spotify'), - ]); - const lbData = await lbRes.json(); - const spotifyData = await spotifyRes.json(); - - body.innerHTML = ''; - const isNonAdmin = currentProfile && !currentProfile.is_admin; - - if (isNonAdmin) { - // Tabbed layout for non-admin with multiple sections - const tabs = [ - { id: 'music', label: 'Music Services' }, - { id: 'server', label: 'Server' }, - { id: 'scrobble', label: 'Scrobbling' }, - ]; - const tabBar = document.createElement('div'); - tabBar.className = 'ps-tabbar'; - tabs.forEach((t, i) => { - const btn = document.createElement('button'); - btn.className = 'ps-tab' + (i === 0 ? ' active' : ''); - btn.textContent = t.label; - btn.onclick = () => { - tabBar.querySelectorAll('.ps-tab').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - body.querySelectorAll('.ps-tab-content').forEach(c => c.classList.remove('active')); - const target = document.getElementById(`ps-tab-${t.id}`); - if (target) target.classList.add('active'); - }; - tabBar.appendChild(btn); - }); - body.appendChild(tabBar); - - // Music Services tab - const musicTab = document.createElement('div'); - musicTab.id = 'ps-tab-music'; - musicTab.className = 'ps-tab-content active'; - renderPersonalSettingsSpotify(musicTab, spotifyData); - renderPersonalSettingsTidal(musicTab); - body.appendChild(musicTab); - - // Server tab - const serverTab = document.createElement('div'); - serverTab.id = 'ps-tab-server'; - serverTab.className = 'ps-tab-content'; - serverTab.innerHTML = '
    Loading libraries...
    '; - body.appendChild(serverTab); - // Load server libraries async (don't block modal) - fetch('/api/profiles/me/server-library').then(r => r.json()).then(libData => { - serverTab.innerHTML = ''; - renderPersonalSettingsServerLibrary(serverTab, libData); - }).catch(() => { - serverTab.innerHTML = ''; - renderPersonalSettingsServerLibrary(serverTab, {}); - }); - - // Scrobbling tab - const scrobbleTab = document.createElement('div'); - scrobbleTab.id = 'ps-tab-scrobble'; - scrobbleTab.className = 'ps-tab-content'; - body.appendChild(scrobbleTab); - // Render LB into the scrobble tab - const origBody = body; - renderPersonalSettingsLB(lbData, scrobbleTab); - } else { - // Admin: just ListenBrainz, no tabs - const content = document.createElement('div'); - content.style.padding = '18px 22px 22px'; - body.appendChild(content); - renderPersonalSettingsLB(lbData, content); - } - } catch (e) { - body.innerHTML = '
    Failed to load settings
    '; - } -} - -function closePersonalSettings() { - const overlay = document.getElementById('personal-settings-overlay'); - if (overlay) overlay.style.display = 'none'; -} - -function renderPersonalSettingsSpotify(body, data) { - const hasCreds = data.has_credentials; - const clientId = data.client_id || ''; - - let contentHtml; - if (hasCreds) { - contentHtml = ` -
    -
    🟢
    -
    -
    Credentials configured
    -
    Client ID: ${escapeHtml(clientId.substring(0, 8))}...
    -
    Personal Spotify app
    -
    -
    -
    - - -
    - `; - } else { - contentHtml = ` -
    - - -
    -
    - - -
    -
    - - -
    - Create an app at developer.spotify.com and add the redirect URI -
    -
    -
    -
    - -
    - `; - } - - const section = document.createElement('div'); - section.id = 'ps-spotify-section'; - section.innerHTML = ` -
    -
    -

    Spotify

    - - - ${hasCreds ? 'Configured' : 'Not configured'} - -
    -
    - Connect your own Spotify account to see your playlists instead of the admin's. -
    - ${contentHtml} -
    - `; - - const existing = document.getElementById('ps-spotify-section'); - if (existing) existing.replaceWith(section); - else body.appendChild(section); -} - -async function savePersonalSpotify() { - const clientId = document.getElementById('ps-spotify-client-id')?.value?.trim(); - const clientSecret = document.getElementById('ps-spotify-client-secret')?.value?.trim(); - const redirectUri = document.getElementById('ps-spotify-redirect-uri')?.value?.trim(); - const resultEl = document.getElementById('ps-spotify-result'); - - if (!clientId || !clientSecret) { - if (resultEl) resultEl.innerHTML = '
    Client ID and Secret are required
    '; - return; - } - - try { - const res = await fetch('/api/profiles/me/spotify', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_id: clientId, client_secret: clientSecret, redirect_uri: redirectUri }) - }); - const data = await res.json(); - if (data.success) { - showToast('Spotify credentials saved', 'success'); - openPersonalSettings(); // Reload to show connected state - } else { - if (resultEl) resultEl.innerHTML = `
    ${data.error || 'Failed to save'}
    `; - } - } catch (e) { - if (resultEl) resultEl.innerHTML = '
    Network error
    '; - } -} - -async function authenticatePersonalSpotify() { - // Trigger OAuth flow with profile_id in state so callback knows which profile - window.open('/auth/spotify?profile_id=' + (currentProfile?.id || ''), '_blank'); -} - -function renderPersonalSettingsTidal(body) { - const section = document.createElement('div'); - section.id = 'ps-tidal-section'; - section.innerHTML = ` -
    -
    -

    Tidal

    -
    -
    - Connect your own Tidal account to see your playlists. Uses the admin's Tidal app credentials. -
    -
    - -
    -
    - `; - const existing = document.getElementById('ps-tidal-section'); - if (existing) existing.replaceWith(section); - else body.appendChild(section); -} - -function authenticatePersonalTidal() { - window.open('/auth/tidal?profile_id=' + (currentProfile?.id || ''), '_blank'); -} - -async function renderPersonalSettingsServerLibrary(container, profileData) { - const section = document.createElement('div'); - section.id = 'ps-server-library-section'; - - // Detect which server is active - let serverType = 'none'; - let libraries = []; - let users = []; - const currentLib = profileData || {}; - - try { - // Try each server type to find the active one - const plexRes = await fetch('/api/plex/music-libraries'); - if (plexRes.ok) { - const plexData = await plexRes.json(); - if (plexData.libraries && plexData.libraries.length > 0) { - serverType = 'plex'; - libraries = plexData.libraries; - } - } - } catch (e) { } - - if (serverType === 'none') { - try { - const jellyRes = await fetch('/api/jellyfin/music-libraries'); - if (jellyRes.ok) { - const jellyData = await jellyRes.json(); - if (jellyData.libraries && jellyData.libraries.length > 0) { - serverType = 'jellyfin'; - libraries = jellyData.libraries; - users = jellyData.users || []; - } - } - } catch (e) { } - } - - if (serverType === 'none') { - section.innerHTML = ` -
    -
    -

    Media Server

    -
    -
    No media server connected. Ask your admin to configure Plex, Jellyfin, or Navidrome in Settings.
    -
    - `; - } else if (serverType === 'plex') { - const selectedLib = currentLib.plex_library_id || ''; - const optionsHtml = libraries.map(lib => { - const name = lib.name || lib.title || lib; - const val = typeof lib === 'string' ? lib : (lib.name || lib.title); - return ``; - }).join(''); - - section.innerHTML = ` -
    -
    -

    Plex Library

    - - - ${selectedLib ? 'Custom' : 'Default'} - -
    -
    Choose which Plex music library your playlists sync to.
    -
    - - -
    -
    - -
    -
    - `; - } else if (serverType === 'jellyfin') { - const selectedUser = currentLib.jellyfin_user_id || ''; - const selectedLib = currentLib.jellyfin_library_id || ''; - - const userOpts = users.map(u => { - const uid = u.id || u.Id; - const uname = u.name || u.Name; - return ``; - }).join(''); - - const libOpts = libraries.map(lib => { - const lid = lib.key || lib.id || lib.Id; - const lname = lib.name || lib.Name || lib.title; - return ``; - }).join(''); - - section.innerHTML = ` -
    -
    -

    Jellyfin

    - - - ${selectedUser || selectedLib ? 'Custom' : 'Default'} - -
    -
    Choose which Jellyfin user and library your playlists sync to.
    - ${users.length ? `
    ` : ''} -
    - - -
    -
    - -
    -
    - `; - } - - const existing = document.getElementById('ps-server-library-section'); - if (existing) existing.replaceWith(section); - else container.appendChild(section); -} - -async function savePersonalServerLibrary() { - try { - const plexSelect = document.getElementById('ps-plex-library-select'); - const jellyUserSelect = document.getElementById('ps-jellyfin-user-select'); - const jellyLibSelect = document.getElementById('ps-jellyfin-library-select'); - - if (plexSelect) { - await fetch('/api/profiles/me/server-library', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ server_type: 'plex', library_id: plexSelect.value || null }) - }); - } - if (jellyUserSelect || jellyLibSelect) { - await fetch('/api/profiles/me/server-library', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - server_type: 'jellyfin', - user_id: jellyUserSelect?.value || null, - library_id: jellyLibSelect?.value || null - }) - }); - } - - showToast('Server library settings saved', 'success'); - } catch (e) { - showToast('Error saving settings', 'error'); - } -} - -async function disconnectPersonalSpotify() { - try { - const res = await fetch('/api/profiles/me/spotify', { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - showToast('Spotify credentials removed — using shared config', 'info'); - openPersonalSettings(); // Reload - } - } catch (e) { - showToast('Error removing credentials', 'error'); - } -} - -function renderPersonalSettingsLB(data, container) { - const body = container || document.getElementById('personal-settings-body'); - const connected = data.connected; - const username = data.username || ''; - const baseUrl = data.base_url || ''; - const source = data.source || 'global'; - - const tokenFormHtml = ` -
    - - -
    -
    - - -
    - Get your token from listenbrainz.org/profile -
    -
    -
    -
    - - -
    - `; - - let contentHtml; - if (connected && source === 'profile') { - // Personal token — show connected state with Disconnect - const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org'; - contentHtml = ` -
    -
    🧠
    -
    -
    Connected as ${escapeHtml(username)}
    -
    ${escapeHtml(serverDisplay)}
    -
    Personal token
    -
    -
    -
    - -
    - `; - } else if (connected && source === 'global') { - // Using admin's shared token — show status + option to set own token - const serverDisplay = baseUrl ? baseUrl.replace(/\/1$/, '').replace(/^https?:\/\//, '') : 'api.listenbrainz.org'; - contentHtml = ` -
    -
    🧠
    -
    -
    Connected as ${escapeHtml(username)}
    -
    ${escapeHtml(serverDisplay)}
    -
    Using shared token from Settings
    -
    -
    -
    -
    Set your own token to use a different ListenBrainz account:
    - ${tokenFormHtml} -
    - `; - } else { - // Not connected at all - contentHtml = tokenFormHtml; - } - - const section = document.createElement('div'); - section.id = 'ps-listenbrainz-section'; - section.innerHTML = ` -
    -
    -

    ListenBrainz

    - - - ${connected ? 'Connected' : 'Not connected'} - -
    - ${contentHtml} -
    - `; - // Replace existing or append - const existing = document.getElementById('ps-listenbrainz-section'); - if (existing) existing.replaceWith(section); - else body.appendChild(section); -} - -async function testPersonalListenBrainz() { - const token = document.getElementById('ps-lb-token')?.value?.trim(); - const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || ''; - const resultEl = document.getElementById('ps-lb-result'); - if (!token) { - if (resultEl) resultEl.innerHTML = '
    Please enter a token
    '; - return; - } - if (resultEl) resultEl.innerHTML = '
    Testing...
    '; - try { - const res = await fetch('/api/profiles/me/listenbrainz/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, base_url: baseUrl }) - }); - const data = await res.json(); - if (data.success) { - resultEl.innerHTML = `
    Valid token — ${escapeHtml(data.username)}
    `; - } else { - resultEl.innerHTML = `
    ${escapeHtml(data.error || 'Invalid token')}
    `; - } - } catch (e) { - resultEl.innerHTML = '
    Connection failed
    '; - } -} - -async function connectPersonalListenBrainz() { - const token = document.getElementById('ps-lb-token')?.value?.trim(); - const baseUrl = document.getElementById('ps-lb-base-url')?.value?.trim() || ''; - const resultEl = document.getElementById('ps-lb-result'); - if (!token) { - if (resultEl) resultEl.innerHTML = '
    Please enter a token
    '; - return; - } - // Disable buttons during connect - document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = true); - if (resultEl) resultEl.innerHTML = '
    Connecting...
    '; - try { - const res = await fetch('/api/profiles/me/listenbrainz', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, base_url: baseUrl }) - }); - const data = await res.json(); - if (data.success) { - showToast(`Connected to ListenBrainz as ${data.username}`, 'success'); - // Re-render as connected - renderPersonalSettingsLB({ connected: true, username: data.username, base_url: baseUrl, source: 'profile' }); - // Refresh LB playlists on discover page - _invalidateListenBrainzCache(); - if (typeof initializeListenBrainzTabs === 'function') { - initializeListenBrainzTabs(); - } - } else { - resultEl.innerHTML = `
    ${escapeHtml(data.error || 'Connection failed')}
    `; - document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false); - } - } catch (e) { - resultEl.innerHTML = '
    Connection failed
    '; - document.querySelectorAll('.ps-actions .ps-btn').forEach(b => b.disabled = false); - } -} - -async function disconnectPersonalListenBrainz() { - try { - await fetch('/api/profiles/me/listenbrainz', { method: 'DELETE' }); - showToast('ListenBrainz disconnected', 'info'); - // Re-render as disconnected — re-fetch to check if global fallback exists - const res = await fetch('/api/profiles/me/listenbrainz'); - const data = await res.json(); - renderPersonalSettingsLB(data); - // Refresh LB playlists on discover page - _invalidateListenBrainzCache(); - if (typeof initializeListenBrainzTabs === 'function') { - initializeListenBrainzTabs(); - } - } catch (e) { - showToast('Failed to disconnect', 'error'); - } -} - -function _invalidateListenBrainzCache() { - if (typeof listenbrainzPlaylistsLoaded !== 'undefined') listenbrainzPlaylistsLoaded = false; - if (typeof listenbrainzPlaylistsCache !== 'undefined') { - try { Object.keys(listenbrainzPlaylistsCache).forEach(k => delete listenbrainzPlaylistsCache[k]); } catch (e) { } - } - if (typeof listenbrainzTracksCache !== 'undefined') { - try { Object.keys(listenbrainzTracksCache).forEach(k => delete listenbrainzTracksCache[k]); } catch (e) { } - } -} - -function initProfileManagement() { - const manageBtn = document.getElementById('manage-profiles-btn'); - const closeBtn = document.getElementById('profile-manage-close'); - const createBtn = document.getElementById('create-profile-btn'); - const adminPinBtn = document.getElementById('set-admin-pin-btn'); - - if (manageBtn) { - manageBtn.onclick = () => { - document.getElementById('profile-manage-panel').style.display = 'flex'; - loadProfileManageList(); - }; - } - - if (closeBtn) { - closeBtn.onclick = () => { - document.getElementById('profile-manage-panel').style.display = 'none'; - // Refresh picker — keep cancel button if user already has a profile selected - const hasCancel = !!currentProfile; - fetch('/api/profiles').then(r => r.json()).then(d => { - showProfilePicker(d.profiles || [], hasCancel); - }); - }; - } - - // Color picker - let selectedColor = '#6366f1'; - document.querySelectorAll('.profile-color-swatch').forEach(swatch => { - swatch.onclick = () => { - document.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected')); - swatch.classList.add('selected'); - selectedColor = swatch.dataset.color; - }; - }); - // Select first by default - const firstSwatch = document.querySelector('.profile-color-swatch'); - if (firstSwatch) firstSwatch.classList.add('selected'); - - if (createBtn) { - createBtn.onclick = async () => { - const name = document.getElementById('new-profile-name').value.trim(); - const avatarUrl = document.getElementById('new-profile-avatar-url').value.trim(); - const pin = document.getElementById('new-profile-pin').value; - if (!name) return; - - // Collect profile settings - const homePage = document.getElementById('new-profile-home-page').value || null; - const pageCheckboxes = document.querySelectorAll('#new-profile-allowed-pages input[type="checkbox"]:not(:disabled)'); - const allChecked = Array.from(pageCheckboxes).every(cb => cb.checked); - const allowedPages = allChecked ? null : Array.from(pageCheckboxes).filter(cb => cb.checked).map(cb => cb.value); - const canDl = document.getElementById('new-profile-can-download').checked; - - const res = await fetch('/api/profiles', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name, avatar_color: selectedColor, - avatar_url: avatarUrl || undefined, - pin: pin || undefined, - home_page: homePage, - allowed_pages: allowedPages, - can_download: canDl - }) - }); - const data = await res.json(); - if (data.success) { - document.getElementById('new-profile-name').value = ''; - document.getElementById('new-profile-avatar-url').value = ''; - document.getElementById('new-profile-pin').value = ''; - document.getElementById('new-profile-home-page').value = ''; - pageCheckboxes.forEach(cb => cb.checked = true); - document.getElementById('new-profile-can-download').checked = true; - loadProfileManageList(); - // Show admin PIN section if >1 profiles and admin has no PIN - checkAdminPinRequired(); - } else { - alert(data.error || 'Failed to create profile'); - } - }; - } - - if (adminPinBtn) { - adminPinBtn.onclick = async () => { - const pin = document.getElementById('admin-pin-input').value; - if (!pin || pin.length < 1) return; - // Find admin profile - const res = await fetch('/api/profiles'); - const data = await res.json(); - const admin = (data.profiles || []).find(p => p.is_admin); - if (!admin) return; - - try { - const pinRes = await fetch(`/api/profiles/${admin.id}/set-pin`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pin }) - }); - const pinData = await pinRes.json(); - if (!pinData.success) { - alert(pinData.error || 'Failed to set PIN'); - return; - } - } catch (e) { - alert('Connection error'); - return; - } - document.getElementById('admin-pin-input').value = ''; - document.getElementById('admin-pin-section').style.display = 'none'; - loadProfileManageList(); - }; - } -} - -async function loadProfileManageList() { - const list = document.getElementById('profile-manage-list'); - const res = await fetch('/api/profiles'); - const data = await res.json(); - const profiles = data.profiles || []; - - list.innerHTML = ''; - profiles.forEach(p => { - const item = document.createElement('div'); - item.className = 'profile-manage-item'; - - const av = document.createElement('div'); - renderProfileAvatar(av, p); - item.appendChild(av); - - const info = document.createElement('div'); - info.className = 'profile-info'; - const nameDiv = document.createElement('div'); - nameDiv.className = 'name'; - nameDiv.textContent = p.name + (p.has_pin ? ' 🔒' : ''); - info.appendChild(nameDiv); - const roleTags = []; - if (p.is_admin) roleTags.push('Admin'); - if (p.can_download === false) roleTags.push('No Downloads'); - if (p.allowed_pages) roleTags.push(`${p.allowed_pages.length} pages`); - if (roleTags.length) { - const roleDiv = document.createElement('div'); - roleDiv.className = 'role'; - roleDiv.textContent = roleTags.join(' · '); - info.appendChild(roleDiv); - } - item.appendChild(info); - - const actions = document.createElement('div'); - actions.className = 'profile-manage-actions'; - - const editBtn = document.createElement('button'); - editBtn.className = 'profile-edit-btn'; - editBtn.dataset.id = p.id; - editBtn.dataset.name = p.name; - editBtn.dataset.color = p.avatar_color || '#6366f1'; - editBtn.dataset.avatarUrl = p.avatar_url || ''; - editBtn.dataset.homePage = p.home_page || ''; - editBtn.dataset.allowedPages = p.allowed_pages ? JSON.stringify(p.allowed_pages) : ''; - editBtn.dataset.canDownload = p.can_download !== false ? '1' : '0'; - editBtn.dataset.isAdmin = p.is_admin ? '1' : '0'; - editBtn.title = 'Edit profile'; - editBtn.textContent = '✏️'; - actions.appendChild(editBtn); - - if (!p.is_admin) { - const delBtn = document.createElement('button'); - delBtn.className = 'profile-delete-btn'; - delBtn.dataset.id = p.id; - delBtn.title = 'Delete profile'; - delBtn.textContent = '🗑️'; - actions.appendChild(delBtn); - } - - item.appendChild(actions); - list.appendChild(item); - }); - - // Bind edit buttons - list.querySelectorAll('.profile-edit-btn').forEach(btn => { - btn.onclick = () => { - showProfileEditForm(btn.dataset.id, btn.dataset.name, btn.dataset.color, btn.dataset.avatarUrl, { - home_page: btn.dataset.homePage || '', - allowed_pages: btn.dataset.allowedPages ? JSON.parse(btn.dataset.allowedPages) : null, - can_download: btn.dataset.canDownload !== '0', - is_admin: btn.dataset.isAdmin === '1' - }); - }; - }); - - // Bind delete buttons - list.querySelectorAll('.profile-delete-btn').forEach(btn => { - btn.onclick = async () => { - if (!await showConfirmDialog({ title: 'Delete Profile', message: 'Delete this profile and all its data?', confirmText: 'Delete', destructive: true })) return; - try { - const res = await fetch(`/api/profiles/${btn.dataset.id}`, { method: 'DELETE' }); - const data = await res.json(); - if (!data.success) { - alert(data.error || 'Failed to delete profile'); - } - } catch (e) { - alert('Connection error'); - } - loadProfileManageList(); - }; - }); - - checkAdminPinRequired(); -} - -function showProfileEditForm(profileId, currentName, currentColor, currentAvatarUrl, profileSettings = {}) { - const list = document.getElementById('profile-manage-list'); - // Remove any existing edit form - const existing = document.getElementById('profile-edit-form'); - if (existing) existing.remove(); - - const isAdmin = currentProfile && currentProfile.is_admin; - const isEditingAdmin = profileSettings.is_admin; - const editColors = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6']; - const pageLabels = { - dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover', - artists: 'Artists', automations: 'Automations', library: 'Library', stats: 'Listening Stats', - 'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs' - }; - - const form = document.createElement('div'); - form.id = 'profile-edit-form'; - form.className = 'profile-edit-form'; - - const nameInput = document.createElement('input'); - nameInput.type = 'text'; - nameInput.className = 'profile-input'; - nameInput.value = currentName; - nameInput.maxLength = 20; - nameInput.placeholder = 'Profile name'; - form.appendChild(nameInput); - - const urlInput = document.createElement('input'); - urlInput.type = 'url'; - urlInput.className = 'profile-input'; - urlInput.value = currentAvatarUrl || ''; - urlInput.placeholder = 'Avatar image URL (optional)'; - form.appendChild(urlInput); - - const colorRow = document.createElement('div'); - colorRow.className = 'profile-color-picker'; - let editColor = currentColor; - editColors.forEach(c => { - const swatch = document.createElement('span'); - swatch.className = 'profile-color-swatch' + (c === currentColor ? ' selected' : ''); - swatch.style.background = c; - swatch.dataset.color = c; - swatch.onclick = () => { - colorRow.querySelectorAll('.profile-color-swatch').forEach(s => s.classList.remove('selected')); - swatch.classList.add('selected'); - editColor = c; - }; - colorRow.appendChild(swatch); - }); - form.appendChild(colorRow); - - // Home page selector — visible to everyone (self-edit or admin editing others) - const homeLabel = document.createElement('label'); - homeLabel.className = 'profile-settings-label'; - homeLabel.textContent = 'Home Page'; - form.appendChild(homeLabel); - - const homeSelect = document.createElement('select'); - homeSelect.className = 'profile-input'; - const defaultOpt = document.createElement('option'); - defaultOpt.value = ''; - defaultOpt.textContent = isEditingAdmin ? 'Default (Dashboard)' : 'Default (Discover)'; - homeSelect.appendChild(defaultOpt); - // Filter home page options to only allowed pages - const allowedSet = profileSettings.allowed_pages; - Object.entries(pageLabels).forEach(([id, label]) => { - if (allowedSet && !allowedSet.includes(id)) return; // Skip non-permitted - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = label; - if (id === profileSettings.home_page) opt.selected = true; - homeSelect.appendChild(opt); - }); - form.appendChild(homeSelect); - - // Admin-only settings: allowed pages & can_download - let pageCheckboxes = []; - let canDlCheckbox = null; - if (isAdmin && !isEditingAdmin) { - const apLabel = document.createElement('label'); - apLabel.className = 'profile-settings-label'; - apLabel.textContent = 'Page Access'; - form.appendChild(apLabel); - - const apContainer = document.createElement('div'); - apContainer.className = 'profile-page-checkboxes'; - Object.entries(pageLabels).forEach(([id, label]) => { - const lbl = document.createElement('label'); - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.value = id; - cb.checked = !allowedSet || allowedSet.includes(id); - lbl.appendChild(cb); - lbl.appendChild(document.createTextNode(' ' + label)); - apContainer.appendChild(lbl); - pageCheckboxes.push(cb); - }); - // Always-on help - const helpLbl = document.createElement('label'); - const helpCb = document.createElement('input'); - helpCb.type = 'checkbox'; - helpCb.checked = true; - helpCb.disabled = true; - helpLbl.appendChild(helpCb); - helpLbl.appendChild(document.createTextNode(' Help & Docs')); - apContainer.appendChild(helpLbl); - form.appendChild(apContainer); - - const dlLabel = document.createElement('label'); - dlLabel.className = 'profile-checkbox-label'; - canDlCheckbox = document.createElement('input'); - canDlCheckbox.type = 'checkbox'; - canDlCheckbox.checked = profileSettings.can_download !== false; - dlLabel.appendChild(canDlCheckbox); - dlLabel.appendChild(document.createTextNode(' Can download music')); - form.appendChild(dlLabel); - } - - const btnRow = document.createElement('div'); - btnRow.className = 'profile-edit-buttons'; - - const saveBtn = document.createElement('button'); - saveBtn.className = 'profile-create-btn'; - saveBtn.textContent = 'Save'; - saveBtn.onclick = async () => { - const newName = nameInput.value.trim(); - if (!newName) { alert('Name cannot be empty'); return; } - const newAvatarUrl = urlInput.value.trim() || null; - const payload = { name: newName, avatar_color: editColor, avatar_url: newAvatarUrl }; - - // Home page - payload.home_page = homeSelect.value || null; - - // Admin-only fields - if (isAdmin && !isEditingAdmin && pageCheckboxes.length) { - const allChecked = pageCheckboxes.every(cb => cb.checked); - payload.allowed_pages = allChecked ? null : pageCheckboxes.filter(cb => cb.checked).map(cb => cb.value); - payload.can_download = canDlCheckbox ? canDlCheckbox.checked : true; - } - - try { - const res = await fetch(`/api/profiles/${profileId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - const data = await res.json(); - if (data.success) { - // Update sidebar indicator if editing current profile - if (currentProfile && currentProfile.id == profileId) { - currentProfile.name = newName; - currentProfile.avatar_color = editColor; - currentProfile.avatar_url = newAvatarUrl; - if (payload.home_page !== undefined) currentProfile.home_page = payload.home_page; - if (payload.allowed_pages !== undefined) currentProfile.allowed_pages = payload.allowed_pages; - if (payload.can_download !== undefined) currentProfile.can_download = payload.can_download; - updateProfileIndicator(); - } - loadProfileManageList(); - } else { - alert(data.error || 'Failed to update profile'); - } - } catch (e) { - alert('Connection error'); - } - }; - btnRow.appendChild(saveBtn); - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'profile-picker-cancel'; - cancelBtn.textContent = 'Cancel'; - cancelBtn.onclick = () => form.remove(); - btnRow.appendChild(cancelBtn); - - form.appendChild(btnRow); - list.appendChild(form); - nameInput.focus(); - nameInput.select(); -} - -function showSelfEditForm() { - if (!currentProfile) return; - const overlay = document.getElementById('profile-picker-overlay'); - const container = overlay.querySelector('.profile-picker-container'); - - // Hide the picker grid and show self-edit form - const grid = document.getElementById('profile-picker-grid'); - const actions = document.getElementById('profile-picker-actions'); - grid.style.display = 'none'; - actions.style.display = 'none'; - - // Remove any existing self-edit form - const existing = document.getElementById('self-edit-form'); - if (existing) existing.remove(); - - const pageLabels = { - dashboard: 'Dashboard', sync: 'Sync', downloads: 'Search', discover: 'Discover', - artists: 'Artists', automations: 'Automations', library: 'Library', stats: 'Listening Stats', - 'playlist-explorer': 'Playlist Explorer', import: 'Import', help: 'Help & Docs' - }; - - const form = document.createElement('div'); - form.id = 'self-edit-form'; - form.className = 'profile-edit-form'; - form.style.marginTop = '16px'; - - const title = document.createElement('h3'); - title.textContent = 'My Profile'; - title.style.cssText = 'color: #fff; margin: 0 0 12px; font-size: 18px;'; - form.appendChild(title); - - // Name - const nameInput = document.createElement('input'); - nameInput.type = 'text'; - nameInput.className = 'profile-input'; - nameInput.value = currentProfile.name; - nameInput.maxLength = 20; - nameInput.placeholder = 'Profile name'; - form.appendChild(nameInput); - - // Home page - const homeLabel = document.createElement('label'); - homeLabel.className = 'profile-settings-label'; - homeLabel.textContent = 'Home Page'; - form.appendChild(homeLabel); - - const homeSelect = document.createElement('select'); - homeSelect.className = 'profile-input'; - const defaultOpt = document.createElement('option'); - defaultOpt.value = ''; - defaultOpt.textContent = 'Default (Discover)'; - homeSelect.appendChild(defaultOpt); - const ap = currentProfile.allowed_pages; - Object.entries(pageLabels).forEach(([id, label]) => { - if (ap && !ap.includes(id)) return; - const opt = document.createElement('option'); - opt.value = id; - opt.textContent = label; - if (id === currentProfile.home_page) opt.selected = true; - homeSelect.appendChild(opt); - }); - form.appendChild(homeSelect); - - // Buttons - const btnRow = document.createElement('div'); - btnRow.className = 'profile-edit-buttons'; - btnRow.style.marginTop = '12px'; - - const saveBtn = document.createElement('button'); - saveBtn.className = 'profile-create-btn'; - saveBtn.textContent = 'Save'; - saveBtn.onclick = async () => { - const newName = nameInput.value.trim(); - if (!newName) { alert('Name cannot be empty'); return; } - try { - const res = await fetch(`/api/profiles/${currentProfile.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: newName, home_page: homeSelect.value || null }) - }); - const data = await res.json(); - if (data.success) { - currentProfile.name = newName; - currentProfile.home_page = homeSelect.value || null; - updateProfileIndicator(); - closeSelfEdit(); - hideProfilePicker(); - } else { - alert(data.error || 'Failed to update'); - } - } catch (e) { - alert('Connection error'); - } - }; - btnRow.appendChild(saveBtn); - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'profile-picker-cancel'; - cancelBtn.textContent = 'Cancel'; - cancelBtn.onclick = () => closeSelfEdit(); - btnRow.appendChild(cancelBtn); - - form.appendChild(btnRow); - container.appendChild(form); - - function closeSelfEdit() { - form.remove(); - grid.style.display = ''; - actions.style.display = ''; - } -} - -async function checkAdminPinRequired() { - const res = await fetch('/api/profiles'); - const data = await res.json(); - const profiles = data.profiles || []; - const admin = profiles.find(p => p.is_admin); - const section = document.getElementById('admin-pin-section'); - - if (profiles.length > 1 && admin && !admin.has_pin && section) { - section.style.display = ''; - } else if (section) { - section.style.display = 'none'; - } -} - -document.addEventListener('DOMContentLoaded', async function () { - console.log('SoulSync WebUI initializing...'); - - // Check if first-run setup wizard should be shown - const params = new URLSearchParams(window.location.search); - const forceSetup = params.get('setup') === '1'; - let showWizard = forceSetup; - - if (!forceSetup) { - try { - const setupResp = await fetch('/api/setup/status'); - const setupData = await setupResp.json(); - if (!setupData.setup_complete) { - showWizard = true; - localStorage.removeItem('soulsync_setup_complete'); - } - } catch (e) { - console.warn('Setup status check failed, continuing normal init:', e); - } - } - - if (showWizard && typeof openSetupWizard === 'function') { - window._onSetupWizardComplete = function () { - _continueAppInit(); - }; - openSetupWizard(); - return; // Defer init until wizard closes - } - - _continueAppInit(); -}); - -async function _continueAppInit() { - // Initialize profile management UI handlers - initProfileManagement(); - - // Check profiles first — may show picker instead of app - const profileReady = await initProfileSystem(); - if (!profileReady) { - console.log('Waiting for profile selection...'); - return; // App init deferred until profile is selected via picker - } - - initApp(); -} - -function initApp() { - // Initialize components - initializeNavigation(); - initializeMobileNavigation(); - initializeMediaPlayer(); - initExpandedPlayer(); - initializeSyncPage(); - initializeWatchlist(); - initializeDownloadManagerToggle(); - - // Initialize Beatport rebuild slider if it's the active tab by default - const activeRebuildTab = document.querySelector('.beatport-tab-button.active[data-beatport-tab="rebuild"]'); - if (activeRebuildTab) { - console.log('🔄 Initializing default active rebuild tab...'); - initializeBeatportRebuildSlider(); - loadBeatportTop10Lists(); - loadBeatportTop10Releases(); - initializeBeatportReleasesSlider(); - initializeBeatportHypePicksSlider(); - initializeBeatportChartsSlider(); - initializeBeatportDJSlider(); - } - - - // Initialize WebSocket connection (falls back to HTTP polling if unavailable) - initializeWebSocket(); - - // Start global service status polling for sidebar (works on all pages) - // Initial fetch for immediate data, then setInterval as fallback when WebSocket is disconnected - fetchAndUpdateServiceStatus(); - setInterval(fetchAndUpdateServiceStatus, 5000); // Every 5 seconds (no-op when WebSocket active) - - // Check for updates on load and every hour - checkForUpdates(); - setInterval(checkForUpdates, 3600000); - - // Refresh key data immediately when user returns to this tab - document.addEventListener('visibilitychange', () => { - if (!document.hidden) { - fetchAndUpdateServiceStatus(); - // Refresh dashboard-specific data if on dashboard - const dashboardPage = document.getElementById('dashboard-page'); - if (dashboardPage && dashboardPage.classList.contains('active')) { - fetchAndUpdateSystemStats(); - fetchAndUpdateActivityFeed(); - } - } - }); - - // Start always-on download polling (batched, minimal overhead) - startGlobalDownloadPolling(); - - // Load issues badge count - loadIssuesBadge(); - - // Load initial data - loadInitialData(); - - // Handle window resize to re-check track title scrolling - window.addEventListener('resize', function () { - if (currentTrack) { - const trackTitleElement = document.getElementById('track-title'); - const trackTitle = currentTrack.title || 'Unknown Track'; - setTimeout(() => { - checkAndEnableScrolling(trackTitleElement, trackTitle); - }, 100); // Small delay to allow layout to settle - } - }); - - console.log('SoulSync WebUI initialized successfully!'); -} - -// =============================== -// NAVIGATION SYSTEM -// =============================== - -function initializeNavigation() { - const navButtons = document.querySelectorAll('.nav-button'); - - navButtons.forEach(button => { - button.addEventListener('click', () => { - const page = button.getAttribute('data-page'); - navigateToPage(page); - }); - }); - - window.addEventListener('popstate', (event) => { - const page = (event.state && event.state.page) || _getPageFromPath(); - if (page && page !== currentPage) { - navigateToPage(page, { skipPushState: true }); - } - }); -} - -const _DEEPLINK_VALID_PAGES = new Set([ - 'dashboard', 'sync', 'downloads', 'discover', 'artists', 'automations', - 'library', 'import', 'settings', 'help', 'issues', 'stats', 'watchlist', - 'wishlist', 'active-downloads', 'artist-detail', 'playlist-explorer', - 'hydrabase', 'tools' -]); - -function _getPageFromPath() { - const path = window.location.pathname.replace(/^\/+|\/+$/g, ''); - if (!path) return 'dashboard'; - const basePage = path.split('/')[0]; - if (!_DEEPLINK_VALID_PAGES.has(basePage)) return 'dashboard'; - // Context-dependent pages fall back to a sensible parent - if (basePage === 'artist-detail') return 'artists'; - if (basePage === 'playlist-explorer') return 'library'; - return basePage; -} - -// =============================== -// MOBILE NAVIGATION -// =============================== - -function initializeMobileNavigation() { - const hamburgerBtn = document.getElementById('hamburger-btn'); - const sidebar = document.querySelector('.sidebar'); - const overlay = document.getElementById('mobile-overlay'); - - if (!hamburgerBtn || !sidebar || !overlay) return; - - function openMobileNav() { - sidebar.classList.add('mobile-open'); - hamburgerBtn.classList.add('active'); - overlay.classList.add('active'); - document.body.classList.add('mobile-nav-open'); - } - - function closeMobileNav() { - sidebar.classList.remove('mobile-open'); - hamburgerBtn.classList.remove('active'); - overlay.classList.remove('active'); - document.body.classList.remove('mobile-nav-open'); - } - - hamburgerBtn.addEventListener('click', () => { - if (sidebar.classList.contains('mobile-open')) { - closeMobileNav(); - } else { - openMobileNav(); - } - }); - - overlay.addEventListener('click', closeMobileNav); - - // Close sidebar on nav button click (mobile only) - document.querySelectorAll('.nav-button').forEach(btn => { - btn.addEventListener('click', () => { - if (window.innerWidth <= 768) { - closeMobileNav(); - } - }); - }); -} - -function initializeWatchlist() { - // Watchlist button navigates to watchlist page - const watchlistButton = document.getElementById('watchlist-button'); - if (watchlistButton) { - watchlistButton.addEventListener('click', () => navigateToPage('watchlist')); - } - - // Wishlist button: quick check for active download, otherwise navigate to page - const wishlistButton = document.getElementById('wishlist-button'); - if (wishlistButton) { - wishlistButton.addEventListener('click', async () => { - // Fast path: check if we already know about an active wishlist process - const clientProcess = activeDownloadProcesses['wishlist']; - if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { - clientProcess.modalElement.style.display = 'flex'; - WishlistModalState.setVisible(); - return; - } - // Slow path: ask the server (with timeout to prevent button feeling dead) - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 2000); - const resp = await fetch('/api/active-processes', { signal: controller.signal }); - clearTimeout(timeout); - if (resp.ok) { - const data = await resp.json(); - const serverProcess = (data.active_processes || []).find(p => p.playlist_id === 'wishlist'); - if (serverProcess) { - try { - WishlistModalState.clearUserClosed(); - await rehydrateModal(serverProcess, true); - } catch (e) { - console.debug('Rehydration failed, navigating to page:', e); - navigateToPage('wishlist'); - } - return; - } - } - } catch (e) { - // Timeout or network error — just navigate - } - navigateToPage('wishlist'); - }); - } - - // Update watchlist count initially - updateWatchlistButtonCount(); - - // Update count every 10 seconds - setInterval(updateWatchlistButtonCount, 10000); - - console.log('Watchlist system initialized'); -} - -function initializeDownloadManagerToggle() { - const toggleButton = document.getElementById('toggle-download-manager-btn'); - const downloadsContent = document.querySelector('.downloads-content'); - - if (!toggleButton || !downloadsContent) { - console.log('Download manager toggle not found on this page'); - return; - } - - // Load saved state from localStorage (hidden by default for more search space) - const isHidden = localStorage.getItem('downloadManagerHidden') !== 'false'; - if (isHidden) { - downloadsContent.classList.add('manager-hidden'); - } - - // Add click handler - toggleButton.addEventListener('click', () => { - const isCurrentlyHidden = downloadsContent.classList.contains('manager-hidden'); - - if (isCurrentlyHidden) { - downloadsContent.classList.remove('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'false'); - } else { - downloadsContent.classList.add('manager-hidden'); - localStorage.setItem('downloadManagerHidden', 'true'); - } - }); - - console.log('Download manager toggle initialized'); -} - -function navigateToPage(pageId, options = {}) { - if (pageId === currentPage) return; - - // Permission guard — redirect to home page if not allowed - if (!isPageAllowed(pageId)) { - const home = getProfileHomePage(); - if (home !== currentPage && isPageAllowed(home)) { - navigateToPage(home); - } - return; - } - - // Update navigation buttons (only if there's a nav button for this page) - document.querySelectorAll('.nav-button').forEach(btn => { - btn.classList.remove('active'); - }); - - // Handle artist-detail page specially - it should highlight the 'library' nav button - const navPageId = pageId === 'artist-detail' ? 'library' : pageId; - const navButton = document.querySelector(`[data-page="${navPageId}"]`); - if (navButton) { - navButton.classList.add('active'); - } - - // Update pages - document.querySelectorAll('.page').forEach(page => { - page.classList.remove('active'); - }); - document.getElementById(`${pageId}-page`).classList.add('active'); - - currentPage = pageId; - - if (!options.skipPushState) { - const urlPath = pageId === 'dashboard' ? '/' : '/' + pageId; - if (window.location.pathname !== urlPath) { - history.pushState({ page: pageId }, '', urlPath); - } - } - - // Show/hide global search bar (hide on downloads page where enhanced search exists) - if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility(); - - // Show/hide discover download sidebar based on page - const downloadSidebar = document.getElementById('discover-download-sidebar'); - if (downloadSidebar) { - if (pageId === 'discover') { - // Show sidebar on discover page if there are active downloads - const activeDownloads = Object.keys(discoverDownloads || {}).length; - console.log(`📊 [NAVIGATE] Discover page - ${activeDownloads} active downloads`); - if (activeDownloads > 0) { - // Update the sidebar UI to render the bubbles - console.log(`🔄 [NAVIGATE] Updating discover download bar UI`); - updateDiscoverDownloadBar(); - } - } else { - // Always hide sidebar on other pages - downloadSidebar.classList.add('hidden'); - } - } - - // Load page-specific data - loadPageData(pageId); - - // Update page background particles - if (window.pageParticles && window._particlesEnabled !== false) window.pageParticles.setPage(pageId); - - // Update worker orbs - if (window.workerOrbs) window.workerOrbs.setPage(pageId); -} - -// REPLACE your old loadPageData function with this one: -// REPLACE your old loadPageData function with this corrected one - -async function loadPageData(pageId) { - try { - // Stop any active polling when navigating away - stopDbStatsPolling(); - stopDbUpdatePolling(); - stopWishlistCountPolling(); - stopLogPolling(); - // Stop watchlist/wishlist page timers when navigating away - if (watchlistCountdownInterval) { clearInterval(watchlistCountdownInterval); watchlistCountdownInterval = null; } - if (wishlistCountdownInterval) { clearInterval(wishlistCountdownInterval); wishlistCountdownInterval = null; } - if (typeof _stopNebulaLivePolling === 'function') _stopNebulaLivePolling(); - switch (pageId) { - case 'dashboard': - await loadDashboardData(); - loadDashboardSyncHistory(); - break; - case 'sync': - initializeSyncPage(); - await loadSyncData(); - break; - case 'downloads': - initializeSearch(); - initializeSearchModeToggle(); - initializeFilters(); - await loadDownloadsData(); - break; - case 'artists': - // Only fully initialize if not already initialized - if (!artistsPageState.isInitialized) { - initializeArtistsPage(); - } else { - // Just restore state if already initialized - restoreArtistsPageState(); - } - break; - case 'active-downloads': - loadActiveDownloadsPage(); - break; - case 'library': - // Check if we should return to artist detail view instead of list - if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { - navigateToPage('artist-detail'); - if (!artistDetailPageState.isInitialized) { - initializeArtistDetailPage(); - loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); - } - // Already initialized — DOM content persists, no reload needed - } else { - if (!libraryPageState.isInitialized) { - initializeLibraryPage(); - } - // Already initialized — DOM content persists, no reload needed - } - break; - case 'artist-detail': - // Artist detail page is handled separately by navigateToArtistDetail() - break; - case 'discover': - if (!discoverPageInitialized) { - await loadDiscoverPage(); - discoverPageInitialized = true; - } - // Already initialized — DOM content persists, no reload needed - break; - case 'playlist-explorer': - initExplorer(); - break; - case 'settings': - initializeSettings(); - switchSettingsTab('connections'); - await loadSettingsData(); - await loadQualityProfile(); - loadApiKeys(); - loadBlacklistCount(); - break; - case 'stats': - initializeStatsPage(); - break; - case 'import': - initializeImportPage(); - break; - case 'hydrabase': - // Check connection status and pre-fill saved credentials - try { - const hsResp = await fetch('/api/hydrabase/status'); - const hsData = await hsResp.json(); - _hydrabaseConnected = hsData.connected; - document.getElementById('hydra-connection-status').textContent = hsData.connected ? 'Connected' : 'Disconnected'; - document.getElementById('hydra-connection-status').style.color = hsData.connected ? 'rgb(var(--accent-light-rgb))' : '#888'; - document.getElementById('hydra-connect-btn').textContent = hsData.connected ? 'Disconnect' : 'Connect'; - // Pre-fill saved credentials - if (hsData.saved_url) { - document.getElementById('hydra-ws-url').value = hsData.saved_url; - } - if (hsData.saved_api_key) { - document.getElementById('hydra-api-key').value = hsData.saved_api_key; - } - // Update peer count - if (hsData.peer_count !== null && hsData.peer_count !== undefined) { - document.getElementById('hydra-peer-count').textContent = `Peers: ${hsData.peer_count}`; - } - } catch (e) { } - // Load comparisons - loadHydrabaseComparisons(); - break; - case 'tools': - await initializeToolsPage(); - break; - case 'watchlist': - await initializeWatchlistPage(); - break; - case 'wishlist': - await initializeWishlistPage(); - break; - case 'automations': - await loadAutomations(); - break; - case 'issues': - await loadIssuesPage(); - break; - case 'help': - initializeDocsPage(); - break; - } - } catch (error) { - console.error(`Error loading ${pageId} data:`, error); - showToast(`Failed to load ${pageId} data`, 'error'); - } -} - -// =============================== -// SERVICE STATUS MONITORING -// =============================== - -// Legacy function - now handled by fetchAndUpdateServiceStatus -// Keeping this for compatibility but it's no longer actively used - -// Old updateStatusIndicator function removed - replaced by updateSidebarServiceStatus - -// =============================== -// MEDIA PLAYER FUNCTIONALITY -// =============================== - -function initializeMediaPlayer() { - const trackTitle = document.getElementById('track-title'); - const playButton = document.getElementById('play-button'); - const stopButton = document.getElementById('stop-button'); - const volumeSlider = document.getElementById('volume-slider'); - - // Start in idle state (no track playing) - const player = document.getElementById('media-player'); - if (player && !currentTrack) player.classList.add('idle'); - - // Initialize HTML5 audio player - audioPlayer = document.getElementById('audio-player'); - if (audioPlayer) { - // Set up audio event listeners - audioPlayer.addEventListener('timeupdate', updateAudioProgress); - audioPlayer.addEventListener('ended', onAudioEnded); - audioPlayer.addEventListener('error', onAudioError); - audioPlayer.addEventListener('loadstart', onAudioLoadStart); - audioPlayer.addEventListener('canplay', onAudioCanPlay); - - // Set initial volume - audioPlayer.volume = 0.7; // 70% - if (volumeSlider) volumeSlider.value = 70; - } - - // Track title click handled by initExpandedPlayer's media-player click handler - - // Media controls - playButton.addEventListener('click', handlePlayPause); - stopButton.addEventListener('click', handleStop); - if (volumeSlider) volumeSlider.addEventListener('input', handleVolumeChange); - - // Progress bar controls - const progressBar = document.getElementById('progress-bar'); - if (progressBar) { - // Handle seeking - progressBar.addEventListener('input', handleProgressBarChange); - progressBar.addEventListener('mousedown', () => { - progressBar.dataset.seeking = 'true'; - }); - progressBar.addEventListener('mouseup', () => { - delete progressBar.dataset.seeking; - }); - } - - // Update volume slider styling - if (volumeSlider) volumeSlider.addEventListener('input', updateVolumeSliderAppearance); - - // Mini player prev / next buttons - const miniPrevBtn = document.getElementById('mini-prev-btn'); - const miniNextBtn = document.getElementById('mini-next-btn'); - if (miniPrevBtn) miniPrevBtn.addEventListener('click', (e) => { e.stopPropagation(); playPreviousInQueue(); }); - if (miniNextBtn) miniNextBtn.addEventListener('click', (e) => { e.stopPropagation(); playNextInQueue(); }); -} - -function toggleMediaPlayerExpansion() { - // No-op: controls are always visible in the new layout. - // Kept for backward compatibility with any callers. -} - -function extractTrackTitle(filename) { - if (!filename) return null; - - // Remove file extension - let title = filename.replace(/\.[^/.]+$/, ''); - - // Remove path components, keep only the filename - title = title.split('/').pop().split('\\').pop(); - - // Clean up common filename patterns - title = title - .replace(/^\d+\.?\s*/, '') // Remove track numbers at start - .replace(/^\d+\s*-\s*/, '') // Remove "01 - " patterns - .replace(/\s*-\s*\d{4}\s*$/, '') // Remove years at end - .replace(/\s*\[\d+kbps\].*$/, '') // Remove bitrate info - .replace(/\s*\(.*?\)\s*$/, '') // Remove parenthetical info at end - .trim(); - - return title || null; -} - -function setTrackInfo(track) { - currentTrack = track; - - const trackTitleElement = document.getElementById('track-title'); - const trackTitle = track.title || 'Unknown Track'; - - // Set up the HTML structure for scrolling - trackTitleElement.innerHTML = `${escapeHtml(trackTitle)}`; - - document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist'; - document.getElementById('album-name').textContent = track.album || 'Unknown Album'; - - // Check if title needs scrolling (similar to GUI app) - setTimeout(() => { - checkAndEnableScrolling(trackTitleElement, trackTitle); - }, 100); // Allow DOM to settle - - // Enable controls - document.getElementById('play-button').disabled = false; - document.getElementById('stop-button').disabled = false; - - // Hide no track message and expand player - document.getElementById('no-track-message').classList.add('hidden'); - document.getElementById('media-player').classList.remove('idle'); - - // Sync expanded player and media session - updateNpTrackInfo(); - updateMediaSessionMetadata(); - updateMediaSessionPlaybackState(); -} - -function checkAndEnableScrolling(element, text) { - // Remove any existing scrolling class and reset styles - element.classList.remove('scrolling'); - element.style.removeProperty('--scroll-distance'); - - // Force a layout to get accurate measurements - element.offsetWidth; - - // Get the inner text element - const titleTextElement = element.querySelector('.title-text'); - if (!titleTextElement) return; - - // Check if text is wider than container - const containerWidth = element.offsetWidth; - const textWidth = titleTextElement.scrollWidth; - - // Enable scrolling if text is significantly wider than container - if (textWidth > containerWidth + 15) { - const scrollDistance = containerWidth - textWidth; - element.style.setProperty('--scroll-distance', `${scrollDistance}px`); - element.classList.add('scrolling'); - console.log(`📜 Enabled scrolling for title: "${text}"`); - console.log(`📜 Container: ${containerWidth}px, Text: ${textWidth}px, Scroll: ${scrollDistance}px`); - } -} - - -function clearTrack() { - // Clear track state - currentTrack = null; - isPlaying = false; - - const trackTitleElement = document.getElementById('track-title'); - trackTitleElement.innerHTML = 'No track'; - trackTitleElement.classList.remove('scrolling'); // Remove scrolling animation - trackTitleElement.style.removeProperty('--scroll-distance'); // Clear CSS variable - - document.getElementById('artist-name').textContent = 'Unknown Artist'; - document.getElementById('album-name').textContent = 'Unknown Album'; - // Reset play button SVGs (don't use textContent — it destroys SVG children) - const clearPlayBtn = document.getElementById('play-button'); - const clearPlayIcon = clearPlayBtn.querySelector('.play-icon'); - const clearPauseIcon = clearPlayBtn.querySelector('.pause-icon'); - if (clearPlayIcon) clearPlayIcon.style.display = ''; - if (clearPauseIcon) clearPauseIcon.style.display = 'none'; - clearPlayBtn.disabled = true; - document.getElementById('stop-button').disabled = true; - - // Reset progress bar and time displays - const progressBar = document.getElementById('progress-bar'); - const progressFill = document.getElementById('progress-fill'); - if (progressBar) { - progressBar.value = 0; - delete progressBar.dataset.seeking; - } - if (progressFill) { - progressFill.style.width = '0%'; - } - - const currentTimeElement = document.getElementById('current-time'); - const totalTimeElement = document.getElementById('total-time'); - if (currentTimeElement) currentTimeElement.textContent = '0:00'; - if (totalTimeElement) totalTimeElement.textContent = '0:00'; - - // Hide loading animation - hideLoadingAnimation(); - - // Show no track message and collapse player - document.getElementById('no-track-message').classList.remove('hidden'); - document.getElementById('media-player').classList.add('idle'); - - // Reset queue state - npQueue = []; - npQueueIndex = -1; - - // Sync expanded player and media session - updateNpTrackInfo(); - updateNpPlayButton(); - updateNpProgress(); - renderNpQueue(); - updateNpPrevNextButtons(); - updateMediaSessionPlaybackState(); - stopSidebarVisualizer(); - if (npModalOpen) closeNowPlayingModal(); - - console.log('🧹 Track cleared and media player reset'); -} - -function setPlayingState(playing) { - isPlaying = playing; - const playButton = document.getElementById('play-button'); - // Toggle SVG icons (don't use textContent — it destroys SVG children) - const playIcon = playButton.querySelector('.play-icon'); - const pauseIcon = playButton.querySelector('.pause-icon'); - if (playIcon) playIcon.style.display = playing ? 'none' : ''; - if (pauseIcon) pauseIcon.style.display = playing ? '' : 'none'; - updateNpPlayButton(); - updateMediaSessionPlaybackState(); - - // Sidebar audio visualizer - if (playing) { - npInitVisualizer(); - startSidebarVisualizer(); - } else { - stopSidebarVisualizer(); - } -} - -async function handlePlayPause() { - // Use new streaming system toggle function - togglePlayback(); -} - -async function handleStop() { - // Use new streaming system stop function - await stopStream(); - clearTrack(); -} - -function handleVolumeChange(event) { - const volume = event.target.value; - updateVolumeSliderAppearance(); - - // Update HTML5 audio player volume - if (audioPlayer) { - audioPlayer.volume = volume / 100; - } - - // Sync modal volume and clear mute state - npMuted = false; - const npVol = document.getElementById('np-volume-slider'); - const npFill = document.getElementById('np-volume-fill'); - if (npVol) npVol.value = volume; - if (npFill) npFill.style.width = volume + '%'; - updateNpMuteIcon(); -} - -function handleProgressBarChange(event) { - // Handle seeking in the audio track - if (!audioPlayer || !audioPlayer.duration) return; - - const progress = parseFloat(event.target.value); - const newTime = (progress / 100) * audioPlayer.duration; - - console.log(`🎯 Seeking to ${formatTime(newTime)} (${progress.toFixed(1)}%)`); - - try { - audioPlayer.currentTime = newTime; - - // Update visual progress immediately - const progressFill = document.getElementById('progress-fill'); - if (progressFill) { - progressFill.style.width = `${progress}%`; - } - - // Update time displays immediately - const currentTimeElement = document.getElementById('current-time'); - if (currentTimeElement) { - currentTimeElement.textContent = formatTime(newTime); - } - - // Sync modal progress - const npBar = document.getElementById('np-progress-bar'); - const npFill = document.getElementById('np-progress-fill'); - const npTime = document.getElementById('np-current-time'); - if (npBar) npBar.value = progress; - if (npFill) npFill.style.width = progress + '%'; - if (npTime) npTime.textContent = formatTime(newTime); - } catch (error) { - console.warn('⚠️ Seek failed:', error.message); - // Reset progress bar to current position - const actualProgress = (audioPlayer.currentTime / audioPlayer.duration) * 100; - event.target.value = actualProgress; - const progressFill = document.getElementById('progress-fill'); - if (progressFill) { - progressFill.style.width = `${actualProgress}%`; - } - } -} - -function updateVolumeSliderAppearance() { - const slider = document.getElementById('volume-slider'); - if (!slider) return; - const value = slider.value; - slider.style.setProperty('--volume-percent', `${value}%`); -} - -function showLoadingAnimation() { - document.getElementById('loading-animation').classList.remove('hidden'); -} - -function hideLoadingAnimation() { - document.getElementById('loading-animation').classList.add('hidden'); -} - -function setLoadingProgress(percentage) { - const loadingAnimation = document.getElementById('loading-animation'); - const progressBar = loadingAnimation.querySelector('.loading-progress'); - const loadingText = loadingAnimation.querySelector('.loading-text'); - - loadingAnimation.classList.remove('hidden'); - progressBar.style.width = `${percentage}%`; - loadingText.textContent = `${Math.round(percentage)}%`; -} - -// =============================== -// STREAMING FUNCTIONALITY -// =============================== - -let _streamLock = false; - -async function startStream(searchResult) { - // Start streaming a track - handles same track toggle and new track streaming - try { - // Prevent multiple concurrent stream starts (rapid clicking) - if (_streamLock) { - console.log('⏳ Stream already starting, ignoring duplicate click'); - return; - } - - console.log(`🎮 startStream() called with data:`, searchResult); - - // Check if this is the same track that's currently playing/loading - const currentTrackId = currentTrack ? `${currentTrack.username}:${currentTrack.filename}` : null; - const newTrackId = `${searchResult.username}:${searchResult.filename}`; - - console.log(`🎮 startStream() called for: ${searchResult.filename}`); - console.log(`🎮 Current track ID: ${currentTrackId}`); - console.log(`🎮 New track ID: ${newTrackId}`); - - if (currentTrackId === newTrackId && audioPlayer && !audioPlayer.paused) { - // Same track clicked while playing - toggle pause - console.log("🔄 Toggling playback for same track"); - togglePlayback(); - return; - } - - // Lock to prevent duplicate stream starts - _streamLock = true; - - // Different track or no current track - start new stream - console.log("🎵 Starting new stream"); - - // Stop current streaming/playback if any - await stopStream(); - - // Set track info and show loading state - setTrackInfo({ - title: extractTrackTitle(searchResult.filename) || searchResult.title || 'Unknown Track', - artist: searchResult.artist || searchResult.username || 'Unknown Artist', - album: searchResult.album || 'Unknown Album', - username: searchResult.username, - filename: searchResult.filename, - image_url: searchResult.image_url || searchResult.album_cover_url || null - }); - - showLoadingAnimation(); - setLoadingProgress(0); - - // Start streaming request - const response = await fetch(API.stream.start, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(searchResult) - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to start streaming'); - } - - console.log("✅ Stream started successfully"); - - // Start status polling - startStreamStatusPolling(); - - } catch (error) { - console.error('Error starting stream:', error); - showToast(`Failed to start stream: ${error.message}`, 'error'); - hideLoadingAnimation(); - clearTrack(); - } finally { - _streamLock = false; - } -} - -function startStreamStatusPolling() { - // Start polling for stream status updates with retry logic - if (streamStatusPoller) { - clearInterval(streamStatusPoller); - } - - // Reset polling state - streamPollingRetries = 0; - streamPollingInterval = 1000; // Reset to 1-second interval - - console.log('🔄 Starting enhanced stream status polling'); - updateStreamStatus(); // Initial check - streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); -} - -function stopStreamStatusPolling() { - // Stop polling for stream status updates - if (streamStatusPoller) { - clearInterval(streamStatusPoller); - streamStatusPoller = null; - streamPollingRetries = 0; - streamPollingInterval = 1000; // Reset interval - console.log('⏹️ Stopped stream status polling'); - } -} - -// Phase 4: Track last known tool statuses to prevent repeated toasts on terminal states -let _lastToolStatus = {}; - -// Phase 5: Sync/Discovery/Scan WebSocket router functions -function updateSyncProgressFromData(data) { - const pid = data.playlist_id; - const callback = _syncProgressCallbacks[pid]; - if (callback) callback(data); -} - -function updateDiscoveryProgressFromData(data) { - const id = data.id; - const callback = _discoveryProgressCallbacks[id]; - if (callback) callback(data); -} - -function updateWatchlistScanFromData(data) { - if (!data.success) return; - if (_lastWatchlistScanStatus === data.status && data.status !== 'scanning') return; - _lastWatchlistScanStatus = data.status; - handleWatchlistScanData(data); -} - -function updateMediaScanFromData(data) { - if (!data.success || !data.status) return; - const status = data.status; - const statusKey = status.is_scanning ? 'scanning' : (status.status || 'unknown'); - if (_lastMediaScanStatus === statusKey && statusKey !== 'scanning') return; - _lastMediaScanStatus = statusKey; - - const phaseLabel = document.getElementById('media-scan-phase-label'); - const progressLabel = document.getElementById('media-scan-progress-label'); - const button = document.getElementById('media-scan-btn'); - const progressBar = document.getElementById('media-scan-progress-bar'); - const statusValue = document.getElementById('media-scan-status'); - - if (status.is_scanning) { - if (phaseLabel) phaseLabel.textContent = 'Media server scanning...'; - if (progressLabel) progressLabel.textContent = status.progress_message || 'Scan in progress'; - } else if (status.status === 'idle') { - if (button) button.disabled = false; - if (phaseLabel) phaseLabel.textContent = 'Scan completed successfully'; - if (progressBar) progressBar.style.width = '0%'; - if (progressLabel) progressLabel.textContent = 'Ready for next scan'; - if (statusValue) { - statusValue.textContent = 'Idle'; - statusValue.style.color = '#b3b3b3'; - } - showToast('✅ Media scan completed', 'success', 3000); - } -} - -let _wishlistAutoProcessingNotified = false; -function updateWishlistStatsFromData(data) { - // Auto-processing detection: close modal and notify (once only) - if (data.is_auto_processing) { - if (!_wishlistAutoProcessingNotified) { - if (currentPage === 'wishlist') navigateToPage('active-downloads'); - showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); - _wishlistAutoProcessingNotified = true; - } - return; - } - // Reset flag when auto-processing ends - _wishlistAutoProcessingNotified = false; - // Store latest stats for countdown timer refresh - _lastWishlistStats = data; -} - -async function updateStreamStatus() { - if (socketConnected) return; // WebSocket handles this - // Poll server for streaming progress and handle state changes with enhanced error recovery - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); // 10-second timeout - - const response = await fetch(API.stream.status, { - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - // Reset retry count on successful response - streamPollingRetries = 0; - streamPollingInterval = 1000; // Reset to normal interval - - // Update current stream state - currentStream.status = data.status; - currentStream.progress = data.progress; - - switch (data.status) { - case 'loading': - setLoadingProgress(data.progress); - // Update loading text with progress - const loadingText = document.querySelector('.loading-text'); - if (loadingText && data.progress > 0) { - loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; - } - break; - - case 'queued': - // Show queue status with better messaging - const queueText = document.querySelector('.loading-text'); - if (queueText) { - queueText.textContent = 'Queuing with uploader...'; - } - setLoadingProgress(0); // Reset progress for queue state - break; - - case 'ready': - // Stream is ready - start audio playback - console.log('🎵 Stream ready, starting audio playback'); - stopStreamStatusPolling(); - // Restore player UI if JS state was wiped (e.g. page refresh) - if (!currentTrack && data.track_info) { - const ti = data.track_info; - setTrackInfo({ - title: ti.name || ti.title || 'Unknown Track', - artist: ti.artist || 'Unknown Artist', - album: ti.album || 'Unknown Album', - filename: ti.filename || '', - is_library: !!ti.is_library, - image_url: ti.image_url || null, - id: ti.id || null, - artist_id: ti.artist_id || null, - album_id: ti.album_id || null, - }); - } - await startAudioPlayback(); - break; - - case 'error': - console.error('❌ Streaming error:', data.error_message); - stopStreamStatusPolling(); - hideLoadingAnimation(); - showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); - clearTrack(); - break; - - case 'stopped': - // Handle stopped state — do NOT clear track here; explicit stop (handleStop) - // calls clearTrack() directly. Clearing here collapses the player mid-playback - // when the backend transitions to 'stopped' after audio naturally ends or during - // queue track transitions. - console.log('🛑 Stream stopped'); - stopStreamStatusPolling(); - hideLoadingAnimation(); - break; - } - - } catch (error) { - streamPollingRetries++; - console.warn(`Stream status polling error (attempt ${streamPollingRetries}):`, error.message); - - if (streamPollingRetries >= maxStreamPollingRetries) { - // Too many consecutive failures - give up - console.error('❌ Stream status polling failed after maximum retries'); - stopStreamStatusPolling(); - hideLoadingAnimation(); - showToast('Lost connection to streaming server', 'error'); - clearTrack(); - } else { - // Implement exponential backoff for retries - const backoffMultiplier = Math.min(streamPollingRetries, 5); // Max 5x backoff - streamPollingInterval = 1000 * backoffMultiplier; - - // Restart polling with new interval - if (streamStatusPoller) { - clearInterval(streamStatusPoller); - streamStatusPoller = setInterval(updateStreamStatus, streamPollingInterval); - console.log(`🔄 Retrying stream status polling with ${streamPollingInterval}ms interval`); - } - } - } -} - -function updateStreamStatusFromData(data) { - const prev = _lastToolStatus['stream']; - _lastToolStatus['stream'] = data.status; - // Skip repeated terminal states to avoid duplicate toasts/actions - if (prev !== undefined && data.status === prev && data.status !== 'loading' && data.status !== 'queued') return; - - currentStream.status = data.status; - currentStream.progress = data.progress; - - switch (data.status) { - case 'loading': - setLoadingProgress(data.progress); - const loadingText = document.querySelector('.loading-text'); - if (loadingText && data.progress > 0) { - loadingText.textContent = `Downloading... ${Math.round(data.progress)}%`; - } - break; - case 'queued': - const queueText = document.querySelector('.loading-text'); - if (queueText) { - queueText.textContent = 'Queuing with uploader...'; - } - setLoadingProgress(0); - break; - case 'ready': - console.log('🎵 Stream ready, starting audio playback'); - stopStreamStatusPolling(); - // Restore player UI if JS state was wiped (e.g. page refresh) - if (!currentTrack && data.track_info) { - const ti = data.track_info; - setTrackInfo({ - title: ti.name || ti.title || 'Unknown Track', - artist: ti.artist || 'Unknown Artist', - album: ti.album || 'Unknown Album', - filename: ti.filename || '', - is_library: !!ti.is_library, - image_url: ti.image_url || null, - id: ti.id || null, - artist_id: ti.artist_id || null, - album_id: ti.album_id || null, - }); - } - startAudioPlayback(); - break; - case 'error': - console.error('❌ Streaming error:', data.error_message); - stopStreamStatusPolling(); - hideLoadingAnimation(); - showToast(`Streaming error: ${data.error_message || 'Unknown error'}`, 'error'); - clearTrack(); - break; - case 'stopped': - // Do NOT clear track here — explicit stop (handleStop) calls clearTrack() directly. - // Clearing here collapses the player after audio naturally ends or during queue transitions. - console.log('🛑 Stream stopped'); - stopStreamStatusPolling(); - hideLoadingAnimation(); - break; - } -} - -async function startAudioPlayback() { - // Start HTML5 audio playback of the streamed file with enhanced state management - try { - if (!audioPlayer) { - throw new Error('Audio player not initialized'); - } - - // Show loading state while preparing audio - const loadingText = document.querySelector('.loading-text'); - if (loadingText) { - loadingText.textContent = 'Preparing playback...'; - } - - // Set audio source with cache-busting timestamp - const audioUrl = `/stream/audio?t=${new Date().getTime()}`; - console.log(`🎵 Loading audio from: ${audioUrl}`); - - // Clear any existing source first - audioPlayer.pause(); - audioPlayer.currentTime = 0; - audioPlayer.src = ''; - - // Set new source - audioPlayer.src = audioUrl; - audioPlayer.load(); // Force reload - - // Wait for audio to be ready with promise-based approach - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - reject(new Error('Audio loading timeout')); - }, 15000); // 15-second timeout - - const onCanPlay = () => { - clearTimeout(timeout); - audioPlayer.removeEventListener('canplay', onCanPlay); - audioPlayer.removeEventListener('error', onError); - resolve(); - }; - - const onError = (event) => { - clearTimeout(timeout); - audioPlayer.removeEventListener('canplay', onCanPlay); - audioPlayer.removeEventListener('error', onError); - const error = event.target.error || new Error('Audio loading failed'); - reject(error); - }; - - audioPlayer.addEventListener('canplay', onCanPlay); - audioPlayer.addEventListener('error', onError); - - // If already ready, resolve immediately - if (audioPlayer.readyState >= 3) { // HAVE_FUTURE_DATA - onCanPlay(); - } - }); - - console.log('✅ Audio loaded and ready for playback'); - - // Try to start playback with retry logic - let retryCount = 0; - const maxRetries = 3; - - while (retryCount < maxRetries) { - try { - await audioPlayer.play(); - console.log('✅ Audio playback started successfully'); - - // Update UI to playing state - hideLoadingAnimation(); - setPlayingState(true); - - // Show media player if hidden - const noTrackMessage = document.getElementById('no-track-message'); - if (noTrackMessage) { - noTrackMessage.classList.add('hidden'); - } - - // Update volume to current slider value - const volumeSlider = document.getElementById('volume-slider'); - if (volumeSlider) { - audioPlayer.volume = volumeSlider.value / 100; - } - - // Enable play/stop buttons - const playButton = document.getElementById('play-button'); - const stopButton = document.getElementById('stop-button'); - if (playButton) playButton.disabled = false; - if (stopButton) stopButton.disabled = false; - - return; // Success! - - } catch (playError) { - retryCount++; - console.warn(`⚠️ Audio play attempt ${retryCount} failed:`, playError.message); - - if (retryCount >= maxRetries) { - throw playError; // Re-throw after max retries - } - - // Wait before retry with exponential backoff - await new Promise(resolve => setTimeout(resolve, 1000 * retryCount)); - } - } - - } catch (error) { - console.error('❌ Error starting audio playback:', error); - hideLoadingAnimation(); - - // Provide user-friendly error messages - let userMessage = 'Playback failed'; - - if (error.message.includes('no supported source') || - error.message.includes('Not supported') || - error.message.includes('MEDIA_ELEMENT_ERROR')) { - userMessage = 'Audio format not supported by your browser. Try downloading instead.'; - } else if (error.message.includes('network') || error.message.includes('fetch')) { - userMessage = 'Network error - please check your connection'; - } else if (error.message.includes('decode')) { - userMessage = 'Audio file is corrupted or incompatible'; - } else if (error.message.includes('timeout')) { - userMessage = 'Audio loading timeout - file may be too large'; - } else if (error.message.includes('AbortError')) { - userMessage = 'Playback was interrupted'; - } - - showToast(userMessage, 'error'); - // Only clear track if not in queue playback mode — queue handles its own error recovery - if (npQueue.length === 0) { - clearTrack(); - } - } -} - -async function stopStream() { - // Stop streaming and clean up all state - try { - // Stop status polling - stopStreamStatusPolling(); - - // Stop audio playback - if (audioPlayer) { - audioPlayer.pause(); - audioPlayer.src = ''; - } - - // Call backend stop endpoint - const response = await fetch(API.stream.stop, { method: 'POST' }); - if (response.ok) { - const data = await response.json(); - console.log('🛑 Stream stopped:', data.message); - } - - // Reset UI state - hideLoadingAnimation(); - setPlayingState(false); - - // Reset stream state - currentStream = { - status: 'stopped', - progress: 0, - track: null - }; - - } catch (error) { - console.error('Error stopping stream:', error); - } -} - -function togglePlayback() { - // Toggle play/pause for currently loaded audio - if (!audioPlayer || !currentTrack) { - console.log('⚠️ No audio player or track to toggle'); - return; - } - - if (audioPlayer.paused) { - audioPlayer.play() - .then(() => { - setPlayingState(true); - console.log('▶️ Resumed playback'); - }) - .catch(error => { - console.error('Error resuming playback:', error); - showToast('Failed to resume playback', 'error'); - }); - } else { - audioPlayer.pause(); - setPlayingState(false); - console.log('⏸️ Paused playback'); - } -} - -// =============================== -// AUDIO EVENT HANDLERS -// =============================== - -function updateAudioProgress() { - // Update progress bar based on audio playback time - if (!audioPlayer || !audioPlayer.duration) return; - - const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100; - - // Update progress bar - const progressBar = document.getElementById('progress-bar'); - const progressFill = document.getElementById('progress-fill'); - if (progressBar && !progressBar.dataset.seeking) { - progressBar.value = progress; - // Update visual progress fill - if (progressFill) { - progressFill.style.width = `${progress}%`; - } - } - - // Update time display - const currentTimeElement = document.getElementById('current-time'); - const totalTimeElement = document.getElementById('total-time'); - - if (currentTimeElement) { - currentTimeElement.textContent = formatTime(audioPlayer.currentTime); - } - if (totalTimeElement) { - totalTimeElement.textContent = formatTime(audioPlayer.duration); - } - - // Sync expanded player modal - if (npModalOpen) updateNpProgress(); -} - -function onAudioEnded() { - // Handle audio playback completion - console.log('🏁 Audio playback ended'); - setPlayingState(false); - - // Reset progress to beginning - const progressBar = document.getElementById('progress-bar'); - const progressFill = document.getElementById('progress-fill'); - if (progressBar) { - progressBar.value = 0; - } - if (progressFill) { - progressFill.style.width = '0%'; - } - - const currentTimeElement = document.getElementById('current-time'); - if (currentTimeElement) { - currentTimeElement.textContent = '0:00'; - } - - // Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat) - // Auto-advance to next track if queue has a next item (guard against race conditions) - if (npQueue.length > 0 && !npLoadingQueueItem) { - const hasNext = npShuffleOn - ? npQueue.length > 1 - : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'); - if (hasNext) { playNextInQueue(); return; } - } - - // Radio mode: auto-fetch similar tracks when queue is exhausted - if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) { - npFetchRadioTracks(); - } -} - -function onAudioError(event) { - // Handle audio playback errors - const error = event.target.error; - console.error('❌ Audio error:', error); - - // Don't show error toast if it's just a format/codec issue and retrying - if (error && error.code) { - console.error(`Audio error code: ${error.code}, message: ${error.message || 'Unknown error'}`); - - // Only show user-facing errors for serious issues - if (error.code === 4) { // MEDIA_ELEMENT_ERROR: Media not supported - console.warn('⚠️ Media format not supported by browser, but streaming may still work'); - // Don't clear track or show error - let retry logic handle it - return; - } - } - - hideLoadingAnimation(); - - // Only clear track after a short delay to allow for recovery - setTimeout(() => { - if (audioPlayer && audioPlayer.error) { - let userMessage = 'Audio format not supported by your browser. Try downloading instead.'; - - if (error && error.code) { - switch (error.code) { - case 1: // MEDIA_ERR_ABORTED - userMessage = 'Playback was stopped'; - break; - case 2: // MEDIA_ERR_NETWORK - userMessage = 'Network error - please try again'; - break; - case 3: // MEDIA_ERR_DECODE - userMessage = 'Audio file is corrupted or incompatible'; - break; - case 4: // MEDIA_ERR_SRC_NOT_SUPPORTED - userMessage = 'Audio format not supported by your browser. Try downloading instead.'; - break; - } - } - - showToast(userMessage, 'error'); - // Only clear track if not in queue playback — queue handles its own recovery - if (npQueue.length === 0) { - clearTrack(); - } - } - }, 2000); -} - -function onAudioLoadStart() { - // Handle audio load start - console.log('🔄 Audio loading started'); -} - -function onAudioCanPlay() { - // Handle when audio can start playing - console.log('✅ Audio ready to play'); -} - -function formatTime(seconds) { - // Format seconds as MM:SS - if (!seconds || !isFinite(seconds)) return '0:00'; - - const minutes = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - return `${minutes}:${secs.toString().padStart(2, '0')}`; -} - -function formatCountdownTime(seconds) { - // Format seconds as countdown timer (e.g., "24m 13s", "2h 15m", "23h 59m") - if (seconds === null || seconds === undefined || seconds < 0) return ''; - if (seconds === 0) return '0s'; // Show "0s" instead of hiding timer - - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (hours > 0) { - return `${hours}h ${minutes}m`; - } else if (minutes > 0) { - return `${minutes}m ${secs}s`; - } else { - return `${secs}s`; - } -} - -// =============================== -// AUDIO FORMAT SUPPORT DETECTION -// =============================== - -function getFileExtension(filename) { - if (!filename) return ''; - const ext = filename.toLowerCase().match(/\.([^.]+)$/); - return ext ? ext[1] : ''; -} - -function isAudioFormatSupported(filename) { - const ext = getFileExtension(filename); - const supportedFormats = ['mp3', 'ogg', 'wav']; // Most reliable formats - const partialSupport = ['flac', 'aac', 'm4a', 'opus', 'webm']; // Test browser support - const unsupported = ['wma', 'ape', 'aiff']; // Generally problematic - - if (supportedFormats.includes(ext)) { - return true; - } - - if (partialSupport.includes(ext)) { - // Test if browser can actually play this format - return canPlayAudioFormat(ext); - } - - return false; // Unsupported formats -} - -function canPlayAudioFormat(extension) { - const audio = document.createElement('audio'); - - const mimeTypes = { - 'mp3': 'audio/mpeg', - 'ogg': 'audio/ogg; codecs="vorbis"', - 'wav': 'audio/wav', - 'flac': 'audio/flac', - 'aac': 'audio/aac', - 'm4a': 'audio/mp4; codecs="mp4a.40.2"', // More specific M4A MIME type - 'opus': 'audio/ogg; codecs="opus"', - 'webm': 'audio/webm; codecs="opus"', - 'wma': 'audio/x-ms-wma' - }; - - const mimeType = mimeTypes[extension]; - if (!mimeType) { - console.warn(`🎵 [FORMAT CHECK] No MIME type found for extension: ${extension}`); - return false; - } - - const canPlay = audio.canPlayType(mimeType); - console.log(`🎵 [FORMAT CHECK] ${extension} (${mimeType}): ${canPlay}`); - - let isSupported = canPlay === 'probably' || canPlay === 'maybe'; - - // Special handling for M4A - try fallback MIME types if first one fails - if (!isSupported && extension === 'm4a') { - const fallbackMimeTypes = ['audio/mp4', 'audio/x-m4a', 'audio/aac']; - console.log(`🎵 [FORMAT CHECK] M4A failed with primary MIME type, trying fallbacks...`); - - for (const fallbackMime of fallbackMimeTypes) { - const fallbackResult = audio.canPlayType(fallbackMime); - console.log(`🎵 [FORMAT CHECK] M4A fallback (${fallbackMime}): ${fallbackResult}`); - if (fallbackResult === 'probably' || fallbackResult === 'maybe') { - isSupported = true; - console.log(`🎵 [FORMAT CHECK] M4A supported with fallback MIME type: ${fallbackMime}`); - break; - } - } - } - - console.log(`🎵 [FORMAT CHECK] ${extension} final support result: ${isSupported}`); - return isSupported; -} - -// =============================== -// EXPANDED NOW PLAYING MODAL -// =============================== - -let npModalOpen = false; -let npRepeatMode = 'off'; // 'off' | 'all' | 'one' -let npShuffleOn = false; -let npQueue = []; -let npQueueIndex = -1; -let npMuted = false; -let npPreMuteVolume = 70; -let npMediaSessionThrottle = 0; -let npLoadingQueueItem = false; -let npRadioMode = false; -let npRecentlyPlayedIds = []; -let npAudioContext = null; -let npAnalyser = null; -let npMediaSource = null; -let npVizAnimFrame = null; -let npVizInitialized = false; - -function initExpandedPlayer() { - const closeBtn = document.getElementById('np-close-btn'); - const overlay = document.getElementById('np-modal-overlay'); - const playBtn = document.getElementById('np-play-btn'); - const stopBtn = document.getElementById('np-stop-btn'); - const shuffleBtn = document.getElementById('np-shuffle-btn'); - const repeatBtn = document.getElementById('np-repeat-btn'); - const muteBtn = document.getElementById('np-mute-btn'); - const npProgressBar = document.getElementById('np-progress-bar'); - const npVolumeSlider = document.getElementById('np-volume-slider'); - - if (!overlay) return; - - // Close handlers - closeBtn.addEventListener('click', closeNowPlayingModal); - overlay.addEventListener('click', (e) => { if (e.target === overlay) closeNowPlayingModal(); }); - - // Control handlers - playBtn.addEventListener('click', () => { togglePlayback(); }); - stopBtn.addEventListener('click', async () => { await handleStop(); closeNowPlayingModal(); }); - shuffleBtn.addEventListener('click', handleNpShuffle); - repeatBtn.addEventListener('click', handleNpRepeat); - muteBtn.addEventListener('click', handleNpMuteToggle); - - // Progress bar (mouse) - npProgressBar.addEventListener('input', handleNpProgressBarChange); - npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; }); - npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; }); - - // Progress bar (touch) - npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true }); - npProgressBar.addEventListener('touchmove', (e) => { - const touch = e.touches[0]; - const rect = npProgressBar.getBoundingClientRect(); - const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100)); - npProgressBar.value = pct; - npProgressBar.dispatchEvent(new Event('input')); - }, { passive: true }); - npProgressBar.addEventListener('touchend', () => { delete npProgressBar.dataset.seeking; }, { passive: true }); - - // Volume slider - npVolumeSlider.addEventListener('input', handleNpVolumeChange); - - // Keyboard shortcuts (global) - document.addEventListener('keydown', handlePlayerKeyboardShortcuts); - - // Make sidebar media player clickable to open modal - const mediaPlayer = document.getElementById('media-player'); - if (mediaPlayer) { - mediaPlayer.style.cursor = 'pointer'; - mediaPlayer.addEventListener('click', (e) => { - // Don't open modal when clicking controls (let expand-hint through) - if (e.target.closest('.play-button, .stop-button, .volume-slider, .volume-control, .progress-bar, .volume-icon, .mini-nav-btn') && !e.target.closest('.expand-hint')) return; - if (currentTrack) openNowPlayingModal(); - }); - } - - // Prev / Next buttons - const prevBtn = document.getElementById('np-prev-btn'); - const nextBtn = document.getElementById('np-next-btn'); - if (prevBtn) prevBtn.addEventListener('click', () => { playPreviousInQueue(); }); - if (nextBtn) nextBtn.addEventListener('click', () => { playNextInQueue(); }); - - // Queue panel toggle + clear - const queueToggle = document.getElementById('np-queue-toggle'); - if (queueToggle) { - queueToggle.addEventListener('click', () => { - const body = document.getElementById('np-queue-body'); - if (body) body.classList.toggle('hidden'); - queueToggle.classList.toggle('active'); - }); - } - const queueClearBtn = document.getElementById('np-queue-clear'); - if (queueClearBtn) queueClearBtn.addEventListener('click', () => { clearQueue(); }); - - // Radio mode button - const radioBtn = document.getElementById('np-radio-btn'); - if (radioBtn) { - radioBtn.addEventListener('click', () => { - npRadioMode = !npRadioMode; - radioBtn.classList.toggle('active', npRadioMode); - showToast(npRadioMode ? 'Radio mode on — similar tracks will auto-queue' : 'Radio mode off', 'success'); - // Immediately fetch radio tracks if turned on while playing with empty/exhausted queue - if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) { - const hasNext = npQueue.length > 0 && (npShuffleOn - ? npQueue.length > 1 - : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all')); - if (!hasNext) { - // Add current track to queue first so it appears as "now playing" in context - if (npQueue.length === 0 && currentTrack.is_library) { - npQueue.push({ - title: currentTrack.title, - artist: currentTrack.artist, - album: currentTrack.album, - file_path: currentTrack.filename || currentTrack.file_path, - filename: currentTrack.filename || currentTrack.file_path, - is_library: true, - image_url: currentTrack.image_url, - id: currentTrack.id, - artist_id: currentTrack.artist_id, - album_id: currentTrack.album_id, - bitrate: currentTrack.bitrate - }); - npQueueIndex = 0; - renderNpQueue(); - updateNpPrevNextButtons(); - } - npFetchRadioTracks(); - } - } - }); - } - - // Action button (Go to Artist) - const gotoArtistBtn = document.getElementById('np-goto-artist'); - if (gotoArtistBtn) { - gotoArtistBtn.addEventListener('click', () => { - if (currentTrack && currentTrack.artist_id) { - closeNowPlayingModal(); - navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || ''); - } - }); - } - // Buffering state listeners on audioPlayer - if (audioPlayer) { - audioPlayer.addEventListener('waiting', () => { - const ring = document.getElementById('np-buffering-ring'); - if (ring) ring.classList.remove('hidden'); - }); - audioPlayer.addEventListener('canplay', () => { - const ring = document.getElementById('np-buffering-ring'); - if (ring) ring.classList.add('hidden'); - }); - audioPlayer.addEventListener('playing', () => { - const ring = document.getElementById('np-buffering-ring'); - if (ring) ring.classList.add('hidden'); - }); - } - - // Init Media Session API - initMediaSession(); -} - -function openNowPlayingModal() { - const overlay = document.getElementById('np-modal-overlay'); - if (!overlay) return; - npModalOpen = true; - overlay.classList.remove('hidden'); - document.body.style.overflow = 'hidden'; - syncExpandedPlayerUI(); - // Start visualizer if already playing - if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); } -} - -function closeNowPlayingModal() { - const overlay = document.getElementById('np-modal-overlay'); - if (!overlay) return; - npModalOpen = false; - overlay.classList.add('hidden'); - document.body.style.overflow = ''; - npStopVisualizerLoop(); -} - -function syncExpandedPlayerUI() { - if (!npModalOpen) return; - - // Track info - updateNpTrackInfo(); - - // Play state - updateNpPlayButton(); - - // Progress - updateNpProgress(); - - // Volume - const sidebarVol = document.getElementById('volume-slider'); - const npVol = document.getElementById('np-volume-slider'); - const npVolFill = document.getElementById('np-volume-fill'); - if (sidebarVol && npVol) { - npVol.value = sidebarVol.value; - if (npVolFill) npVolFill.style.width = sidebarVol.value + '%'; - } - - // Visualizer - const viz = document.getElementById('np-visualizer'); - if (viz) viz.classList.toggle('playing', isPlaying); - - // Queue - renderNpQueue(); - updateNpPrevNextButtons(); -} - -function updateNpTrackInfo() { - const titleEl = document.getElementById('np-track-title'); - const artistEl = document.getElementById('np-artist-name'); - const albumEl = document.getElementById('np-album-name'); - const artImg = document.getElementById('np-album-art'); - const artPlaceholder = document.getElementById('np-album-art-placeholder'); - const badgesEl = document.getElementById('np-format-badges'); - const actionBtns = document.getElementById('np-action-buttons'); - - if (!titleEl) return; - - // Sidebar album art - const sidebarArt = document.getElementById('sidebar-album-art'); - - if (currentTrack) { - // Track text transition animation - const textEls = [titleEl, artistEl, albumEl]; - const oldTitle = titleEl.textContent; - const newTitle = currentTrack.title || 'Unknown Track'; - const trackChanged = oldTitle !== newTitle && oldTitle !== 'No track'; - - titleEl.textContent = newTitle; - artistEl.textContent = currentTrack.artist || 'Unknown Artist'; - albumEl.textContent = currentTrack.album || 'Unknown Album'; - - if (trackChanged) { - textEls.forEach(el => { - el.classList.remove('np-text-transition'); - void el.offsetWidth; // force reflow - el.classList.add('np-text-transition'); - }); - } - - // Album art (modal + sidebar) + ambient glow extraction - const artUrl = getNpAlbumArtUrl(); - if (artUrl && artImg) { - // Only set crossOrigin for external URLs — local paths break with CORS headers - if (artUrl.startsWith('http')) { - artImg.crossOrigin = 'anonymous'; - } else { - artImg.removeAttribute('crossOrigin'); - } - artImg.src = artUrl; - artImg.classList.remove('hidden'); - artImg.onerror = () => { artImg.classList.add('hidden'); npResetAmbientGlow(); }; - artImg.onload = () => { npExtractAmbientColor(artImg); }; - } else if (artImg) { - artImg.classList.add('hidden'); - npResetAmbientGlow(); - } - if (sidebarArt) { - if (artUrl) { - sidebarArt.src = artUrl; - sidebarArt.style.display = ''; - sidebarArt.onerror = () => { sidebarArt.src = '/static/trans2.png'; }; - } else { - sidebarArt.src = '/static/trans2.png'; - } - } - - // Format badges (richer: include bitrate/sample_rate) - if (badgesEl) { - badgesEl.innerHTML = ''; - const filename = currentTrack.filename || ''; - if (filename) { - const ext = getFileExtension(filename); - if (ext) { - let label = ext.toUpperCase(); - if (currentTrack.sample_rate) { - const khz = (currentTrack.sample_rate / 1000); - label += ' ' + (khz % 1 === 0 ? khz.toFixed(0) : khz.toFixed(1)) + 'kHz'; - } - const badge = document.createElement('span'); - badge.className = 'np-format-badge' + (ext === 'flac' ? ' flac' : ''); - badge.textContent = label; - badgesEl.appendChild(badge); - } - if (currentTrack.bitrate) { - const brBadge = document.createElement('span'); - brBadge.className = 'np-format-badge'; - brBadge.textContent = currentTrack.bitrate + 'k'; - badgesEl.appendChild(brBadge); - } - } - } - - // Action buttons visibility - if (actionBtns) { - const hasArtist = currentTrack.artist_id; - actionBtns.classList.toggle('hidden', !hasArtist); - } - - // Track recently played for radio mode - if (currentTrack.id && !npRecentlyPlayedIds.includes(currentTrack.id)) { - npRecentlyPlayedIds.push(currentTrack.id); - if (npRecentlyPlayedIds.length > 50) npRecentlyPlayedIds.shift(); - } - } else { - titleEl.textContent = 'No track'; - artistEl.textContent = 'Unknown Artist'; - albumEl.textContent = 'Unknown Album'; - if (artImg) artImg.classList.add('hidden'); - if (sidebarArt) sidebarArt.src = '/static/trans2.png'; - if (badgesEl) badgesEl.innerHTML = ''; - if (actionBtns) actionBtns.classList.add('hidden'); - npResetAmbientGlow(); - } -} - -function npExtractAmbientColor(imgEl) { - try { - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - canvas.width = 50; - canvas.height = 50; - ctx.drawImage(imgEl, 0, 0, 50, 50); - const data = ctx.getImageData(0, 0, 50, 50).data; - let rSum = 0, gSum = 0, bSum = 0, count = 0; - for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel - const r = data[i], g = data[i + 1], b = data[i + 2]; - const brightness = (r + g + b) / 3; - if (brightness > 20 && brightness < 230) { - rSum += r; gSum += g; bSum += b; count++; - } - } - if (count > 0) { - const modal = document.querySelector('.np-modal'); - if (modal) { - modal.style.setProperty('--np-ambient-r', Math.round(rSum / count)); - modal.style.setProperty('--np-ambient-g', Math.round(gSum / count)); - modal.style.setProperty('--np-ambient-b', Math.round(bSum / count)); - } - } - } catch (e) { - // Cross-origin or canvas error — ignore silently - } -} - -function npResetAmbientGlow() { - const modal = document.querySelector('.np-modal'); - if (modal) { - modal.style.setProperty('--np-ambient-r', '29'); - modal.style.setProperty('--np-ambient-g', '185'); - modal.style.setProperty('--np-ambient-b', '84'); - } -} - -function updateNpPlayButton() { - const playIcon = document.querySelector('.np-icon-play'); - const pauseIcon = document.querySelector('.np-icon-pause'); - if (playIcon && pauseIcon) { - playIcon.classList.toggle('hidden', isPlaying); - pauseIcon.classList.toggle('hidden', !isPlaying); - } - - const viz = document.getElementById('np-visualizer'); - if (viz) viz.classList.toggle('playing', isPlaying); - - // Drive Web Audio visualizer (only when modal is open to save CPU) - if (isPlaying && npModalOpen) { - npInitVisualizer(); - npStartVisualizerLoop(); - } else { - npStopVisualizerLoop(); - } -} - -function updateNpProgress() { - if (!npModalOpen || !audioPlayer) return; - - const npProgressBar = document.getElementById('np-progress-bar'); - const npProgressFill = document.getElementById('np-progress-fill'); - const npCurrentTime = document.getElementById('np-current-time'); - const npTotalTime = document.getElementById('np-total-time'); - - if (audioPlayer.duration) { - const progress = (audioPlayer.currentTime / audioPlayer.duration) * 100; - if (npProgressBar && !npProgressBar.dataset.seeking) { - npProgressBar.value = progress; - } - if (npProgressFill) npProgressFill.style.width = progress + '%'; - if (npCurrentTime) npCurrentTime.textContent = formatTime(audioPlayer.currentTime); - if (npTotalTime) npTotalTime.textContent = formatTime(audioPlayer.duration); - } else { - if (npProgressBar) npProgressBar.value = 0; - if (npProgressFill) npProgressFill.style.width = '0%'; - if (npCurrentTime) npCurrentTime.textContent = '0:00'; - if (npTotalTime) npTotalTime.textContent = '0:00'; - } -} - -function handleNpProgressBarChange(event) { - if (!audioPlayer || !audioPlayer.duration) return; - const progress = parseFloat(event.target.value); - const newTime = (progress / 100) * audioPlayer.duration; - - try { - audioPlayer.currentTime = newTime; - - // Sync sidebar progress - const sidebarBar = document.getElementById('progress-bar'); - const sidebarFill = document.getElementById('progress-fill'); - if (sidebarBar) sidebarBar.value = progress; - if (sidebarFill) sidebarFill.style.width = progress + '%'; - - // Sync modal progress fill - const npFill = document.getElementById('np-progress-fill'); - if (npFill) npFill.style.width = progress + '%'; - - // Update time displays - const sidebarTime = document.getElementById('current-time'); - const npTime = document.getElementById('np-current-time'); - if (sidebarTime) sidebarTime.textContent = formatTime(newTime); - if (npTime) npTime.textContent = formatTime(newTime); - } catch (error) { - console.warn('Seek failed:', error.message); - } -} - -function handleNpVolumeChange(event) { - const volume = parseInt(event.target.value); - if (audioPlayer) audioPlayer.volume = volume / 100; - - // Sync sidebar volume slider - const sidebarVol = document.getElementById('volume-slider'); - if (sidebarVol) { - sidebarVol.value = volume; - sidebarVol.style.setProperty('--volume-percent', volume + '%'); - } - - // Update modal volume fill - const npFill = document.getElementById('np-volume-fill'); - if (npFill) npFill.style.width = volume + '%'; - - // Update mute state - npMuted = volume === 0; - updateNpMuteIcon(); -} - -function handleNpMuteToggle() { - const npVol = document.getElementById('np-volume-slider'); - if (!npVol) return; - - if (npMuted) { - // Unmute — restore previous volume - npVol.value = npPreMuteVolume; - npVol.dispatchEvent(new Event('input')); - npMuted = false; - } else { - // Mute — save current volume, set to 0 - npPreMuteVolume = parseInt(npVol.value) || 70; - npVol.value = 0; - npVol.dispatchEvent(new Event('input')); - npMuted = true; - } - updateNpMuteIcon(); -} - -function updateNpMuteIcon() { - const muteBtn = document.getElementById('np-mute-btn'); - const volIcon = muteBtn ? muteBtn.querySelector('.np-icon-vol') : null; - const mutedIcon = muteBtn ? muteBtn.querySelector('.np-icon-muted') : null; - if (volIcon && mutedIcon) { - volIcon.classList.toggle('hidden', npMuted); - mutedIcon.classList.toggle('hidden', !npMuted); - } - if (muteBtn) muteBtn.classList.toggle('muted', npMuted); -} - -function handleNpShuffle() { - npShuffleOn = !npShuffleOn; - const btn = document.getElementById('np-shuffle-btn'); - if (btn) btn.classList.toggle('active', npShuffleOn); - updateNpPrevNextButtons(); -} - -function handleNpRepeat() { - const badge = document.getElementById('np-repeat-one-badge'); - if (npRepeatMode === 'off') { - npRepeatMode = 'all'; - if (audioPlayer) audioPlayer.loop = false; - } else if (npRepeatMode === 'all') { - npRepeatMode = 'one'; - if (audioPlayer) audioPlayer.loop = true; - } else { - npRepeatMode = 'off'; - if (audioPlayer) audioPlayer.loop = false; - } - const btn = document.getElementById('np-repeat-btn'); - if (btn) btn.classList.toggle('active', npRepeatMode !== 'off'); - if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one'); - updateNpPrevNextButtons(); -} - -// =============================== -// QUEUE MANAGEMENT -// =============================== - -function addToQueue(track) { - npQueue.push(track); - showToast('Added to queue', 'success'); - renderNpQueue(); - updateNpPrevNextButtons(); - // If nothing is currently playing, auto-play the first queued track - if (!currentTrack) { - playQueueItem(npQueue.length - 1); - } -} - -function removeFromQueue(index) { - if (index < 0 || index >= npQueue.length) return; - const wasCurrentTrack = (index === npQueueIndex); - npQueue.splice(index, 1); - // Adjust current index - if (npQueue.length === 0) { - npQueueIndex = -1; - // Current track keeps playing but queue is now empty — that's OK - } else if (index < npQueueIndex) { - npQueueIndex--; - } else if (wasCurrentTrack) { - // Removed the currently playing item - if (npQueueIndex >= npQueue.length) { - npQueueIndex = npQueue.length - 1; - } - // Play the next track at the adjusted index - playQueueItem(npQueueIndex); - } - renderNpQueue(); - updateNpPrevNextButtons(); -} - -function clearQueue() { - npQueue = []; - npQueueIndex = -1; - renderNpQueue(); - updateNpPrevNextButtons(); -} - -function playNextInQueue() { - if (npQueue.length === 0) return; - if (npShuffleOn) { - // Pick a random index that is not the current one - const candidates = []; - for (let i = 0; i < npQueue.length; i++) { - if (i !== npQueueIndex) candidates.push(i); - } - if (candidates.length === 0) return; - const next = candidates[Math.floor(Math.random() * candidates.length)]; - playQueueItem(next); - } else { - const next = npQueueIndex + 1; - if (next >= npQueue.length) { - // End of queue — repeat-all wraps to start - if (npRepeatMode === 'all') { - playQueueItem(0); - } - return; - } - playQueueItem(next); - } -} - -function playPreviousInQueue() { - // If more than 3 seconds in, restart current track - if (audioPlayer && audioPlayer.currentTime > 3) { - audioPlayer.currentTime = 0; - if (audioPlayer.paused) audioPlayer.play(); - return; - } - if (npQueue.length === 0) return; - const prev = npQueueIndex - 1; - if (prev < 0) { - // At start — restart current track - if (audioPlayer) { - audioPlayer.currentTime = 0; - if (audioPlayer.paused) audioPlayer.play(); - } - return; - } - playQueueItem(prev); -} - -async function playQueueItem(index) { - if (index < 0 || index >= npQueue.length) return; - if (npLoadingQueueItem) return; // Prevent race condition from double-advance - npLoadingQueueItem = true; - npQueueIndex = index; - const track = npQueue[index]; - - try { - if (track.is_library) { - // Library track playback flow - await stopStream(); - setTrackInfo({ - title: track.title, - artist: track.artist, - album: track.album, - filename: track.file_path, - is_library: true, - image_url: track.image_url, - id: track.id, - artist_id: track.artist_id, - album_id: track.album_id, - bitrate: track.bitrate, - sample_rate: track.sample_rate - }); - showLoadingAnimation(); - - const response = await fetch('/api/library/play', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - file_path: track.file_path, - title: track.title || '', - artist: track.artist || '', - album: track.album || '' - }) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error || 'Failed to start playback'); - // Re-apply repeat-one loop property - if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one'); - await startAudioPlayback(); - } else { - // Non-library (stream) tracks cannot be queued for auto-advance - // Just show track info — the stream flow handles its own playback - setTrackInfo({ - title: track.title, - artist: track.artist, - album: track.album, - filename: track.filename || track.file_path, - is_library: false, - image_url: track.image_url, - id: track.id, - artist_id: track.artist_id, - album_id: track.album_id, - bitrate: track.bitrate, - sample_rate: track.sample_rate - }); - } - } catch (error) { - console.error('Queue playback error:', error); - showToast(`Skipping track: ${error.message}`, 'error'); - hideLoadingAnimation(); - // Auto-skip to next track on failure instead of stopping the queue - npLoadingQueueItem = false; - const nextIdx = npQueueIndex + 1; - if (nextIdx < npQueue.length) { - setTimeout(() => playQueueItem(nextIdx), 500); - } - return; - } finally { - npLoadingQueueItem = false; - } - - renderNpQueue(); - updateNpPrevNextButtons(); -} - -function renderNpQueue() { - const listEl = document.getElementById('np-queue-list'); - const emptyEl = document.getElementById('np-queue-empty'); - const countEl = document.getElementById('np-queue-count'); - if (!listEl) return; - - if (countEl) countEl.textContent = npQueue.length > 0 ? `(${npQueue.length})` : ''; - - if (npQueue.length === 0) { - listEl.innerHTML = ''; - if (emptyEl) emptyEl.classList.remove('hidden'); - return; - } - - if (emptyEl) emptyEl.classList.add('hidden'); - listEl.innerHTML = ''; - - npQueue.forEach((track, i) => { - const item = document.createElement('div'); - item.className = 'np-queue-item' + (i === npQueueIndex ? ' active' : ''); - item.onclick = () => playQueueItem(i); - - const info = document.createElement('div'); - info.className = 'np-queue-item-info'; - - const title = document.createElement('div'); - title.className = 'np-queue-item-title'; - title.textContent = track.title || 'Unknown Track'; - - const artist = document.createElement('div'); - artist.className = 'np-queue-item-artist'; - artist.textContent = track.artist || 'Unknown Artist'; - - info.appendChild(title); - info.appendChild(artist); - item.appendChild(info); - - const removeBtn = document.createElement('button'); - removeBtn.className = 'np-queue-item-remove'; - removeBtn.innerHTML = '✕'; - removeBtn.title = 'Remove from queue'; - removeBtn.onclick = (e) => { - e.stopPropagation(); - removeFromQueue(i); - }; - item.appendChild(removeBtn); - - listEl.appendChild(item); - }); -} - -function updateNpPrevNextButtons() { - const canPrev = npQueueIndex > 0 || (audioPlayer && audioPlayer.currentTime > 3); - const canNext = npQueue.length > 0 && (npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all')); - - // Full Now Playing modal buttons - const prevBtn = document.getElementById('np-prev-btn'); - const nextBtn = document.getElementById('np-next-btn'); - if (prevBtn) prevBtn.disabled = !canPrev; - if (nextBtn) nextBtn.disabled = !canNext; - - // Mini player buttons - const miniPrevBtn = document.getElementById('mini-prev-btn'); - const miniNextBtn = document.getElementById('mini-next-btn'); - if (miniPrevBtn) miniPrevBtn.disabled = !canPrev; - if (miniNextBtn) miniNextBtn.disabled = !canNext; -} - -function handlePlayerKeyboardShortcuts(event) { - // Don't intercept when typing in inputs or when non-player modals are open - const tag = document.activeElement.tagName.toLowerCase(); - if (tag === 'input' || tag === 'textarea' || tag === 'select' || document.activeElement.isContentEditable) return; - - // Only handle when player modal is open OR when no other modal is visible - const otherModals = document.querySelectorAll('.modal-overlay:not(.hidden):not(#np-modal-overlay)'); - if (otherModals.length > 0 && !npModalOpen) return; - - switch (event.key) { - case ' ': - if (!currentTrack) return; - event.preventDefault(); - togglePlayback(); - break; - case 'ArrowLeft': - if (!audioPlayer || !audioPlayer.duration) return; - event.preventDefault(); - audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 5); - break; - case 'ArrowRight': - if (!audioPlayer || !audioPlayer.duration) return; - event.preventDefault(); - audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 5); - break; - case 'ArrowUp': - event.preventDefault(); - if (audioPlayer) { - const newVol = Math.min(1, audioPlayer.volume + 0.05); - audioPlayer.volume = newVol; - syncVolumeUI(Math.round(newVol * 100)); - } - break; - case 'ArrowDown': - event.preventDefault(); - if (audioPlayer) { - const newVol = Math.max(0, audioPlayer.volume - 0.05); - audioPlayer.volume = newVol; - syncVolumeUI(Math.round(newVol * 100)); - } - break; - case 'm': - case 'M': - if (npModalOpen) handleNpMuteToggle(); - break; - case 'Escape': - if (npModalOpen) closeNowPlayingModal(); - break; - default: - return; // Don't prevent default for unhandled keys - } -} - -function syncVolumeUI(volumePercent) { - // Sync both sidebar and modal volume UIs - const sidebarVol = document.getElementById('volume-slider'); - const npVol = document.getElementById('np-volume-slider'); - const npFill = document.getElementById('np-volume-fill'); - - if (sidebarVol) { - sidebarVol.value = volumePercent; - sidebarVol.style.setProperty('--volume-percent', volumePercent + '%'); - } - if (npVol) npVol.value = volumePercent; - if (npFill) npFill.style.width = volumePercent + '%'; -} - -function getNpAlbumArtUrl() { - if (!currentTrack) return null; - return currentTrack.image_url || currentTrack.album_cover_url || currentTrack.thumb_url || null; -} - -// =============================== -// WEB AUDIO VISUALIZER -// =============================== - -function npInitVisualizer() { - if (npVizInitialized || !audioPlayer) return; - try { - if (!npAudioContext) { - npAudioContext = new (window.AudioContext || window.webkitAudioContext)(); - } - if (!npMediaSource) { - npMediaSource = npAudioContext.createMediaElementSource(audioPlayer); - npAnalyser = npAudioContext.createAnalyser(); - npAnalyser.fftSize = 64; - npAnalyser.smoothingTimeConstant = 0.8; - npMediaSource.connect(npAnalyser); - npAnalyser.connect(npAudioContext.destination); - } - npVizInitialized = true; - } catch (e) { - console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message); - // Mark as CSS fallback - const viz = document.getElementById('np-visualizer'); - if (viz) viz.classList.add('np-viz-css-fallback'); - npVizInitialized = true; // don't retry - } -} - -function npStartVisualizerLoop() { - if (npVizAnimFrame) return; // Already running - if (!npAnalyser) return; // No analyser — CSS fallback handles it - - if (npAudioContext && npAudioContext.state === 'suspended') { - npAudioContext.resume(); - } - - const bars = document.querySelectorAll('.np-viz-bar'); - if (bars.length === 0) return; - const bufferLength = npAnalyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - - function draw() { - npVizAnimFrame = requestAnimationFrame(draw); - npAnalyser.getByteFrequencyData(dataArray); - - // Map 7 bars to frequency bins (skip bin 0 which is DC offset) - const binCount = Math.min(bufferLength - 1, 7); - for (let i = 0; i < bars.length; i++) { - const binIndex = Math.min(i + 1, bufferLength - 1); - const value = dataArray[binIndex] / 255; // 0..1 - const scale = Math.max(0.08, value); // minimum visible height - bars[i].style.transform = `scaleY(${scale})`; - } - } - draw(); -} - -function npStopVisualizerLoop() { - if (npVizAnimFrame) { - cancelAnimationFrame(npVizAnimFrame); - npVizAnimFrame = null; - } - // Reset bars to min - const bars = document.querySelectorAll('.np-viz-bar'); - bars.forEach(bar => { bar.style.transform = 'scaleY(0.125)'; }); -} - -// =============================== -// SIDEBAR AUDIO VISUALIZER -// =============================== - -let sidebarVizAnimFrame = null; -let sidebarVisualizerType = 'bars'; // bars | wave | spectrum | mirror | equalizer | none -const SIDEBAR_VIZ_BAR_COUNT = 32; - -let _sidebarVizBuiltType = null; - -function buildSidebarVizElements(type) { - const container = document.getElementById('sidebar-visualizer'); - if (!container) return; - if (_sidebarVizBuiltType === type && container.children.length > 0) return; - _sidebarVizBuiltType = type; - container.innerHTML = ''; - container.className = 'sidebar-visualizer'; - - if (type === 'bars') { - container.classList.add('viz-bars'); - for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) { - const bar = document.createElement('div'); - bar.className = 'sidebar-viz-bar'; - container.appendChild(bar); - } - } else if (type === 'wave' || type === 'spectrum') { - container.classList.add('viz-canvas'); - const canvas = document.createElement('canvas'); - canvas.className = 'sidebar-viz-canvas'; - canvas.width = 10; - canvas.height = 600; - container.appendChild(canvas); - } else if (type === 'mirror') { - container.classList.add('viz-mirror'); - for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) { - const bar = document.createElement('div'); - bar.className = 'sidebar-viz-mirror-bar'; - container.appendChild(bar); - } - } else if (type === 'equalizer') { - container.classList.add('viz-equalizer'); - for (let i = 0; i < SIDEBAR_VIZ_BAR_COUNT; i++) { - const wrap = document.createElement('div'); - wrap.className = 'sidebar-viz-eq-wrap'; - const bar = document.createElement('div'); - bar.className = 'sidebar-viz-eq-bar'; - const peak = document.createElement('div'); - peak.className = 'sidebar-viz-eq-peak'; - wrap.appendChild(bar); - wrap.appendChild(peak); - container.appendChild(wrap); - } - } -} - -function startSidebarVisualizer() { - const type = sidebarVisualizerType; - if (type === 'none') return; - - const container = document.getElementById('sidebar-visualizer'); - if (!container) return; - - buildSidebarVizElements(type); - container.classList.add('active'); - - if (sidebarVizAnimFrame) return; - if (!npAnalyser) return; - - const bufferLength = npAnalyser.frequencyBinCount; - const dataArray = new Uint8Array(bufferLength); - const hueStart = 200, hueRange = 160; - - // Helper: average frequency bins for a given segment index - function getBinValue(i, count) { - const binsPerSeg = Math.max(1, Math.floor((bufferLength - 1) / count)); - let sum = 0; - const start = i * binsPerSeg + 1; - for (let b = 0; b < binsPerSeg; b++) sum += dataArray[Math.min(start + b, bufferLength - 1)]; - return (sum / binsPerSeg) / 255; - } - - // ── Bars ── - if (type === 'bars') { - const bars = container.querySelectorAll('.sidebar-viz-bar'); - if (bars.length === 0) return; - function drawBars() { - sidebarVizAnimFrame = requestAnimationFrame(drawBars); - npAnalyser.getByteFrequencyData(dataArray); - for (let i = 0; i < bars.length; i++) { - const value = getBinValue(i, bars.length); - const scale = Math.max(0.08, value); - const hue = (hueStart + (i / bars.length) * hueRange + value * 30) % 360; - bars[i].style.transform = `scaleX(${scale})`; - bars[i].style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`; - } - } - drawBars(); - - // ── Wave ── - } else if (type === 'wave') { - const canvas = container.querySelector('.sidebar-viz-canvas'); - if (!canvas) return; - const ctx = canvas.getContext('2d'); - let hueOffset = 0; - function drawWave() { - sidebarVizAnimFrame = requestAnimationFrame(drawWave); - const ch = container.clientHeight; - if (ch > 0 && canvas.height !== ch) canvas.height = ch; - npAnalyser.getByteFrequencyData(dataArray); - const w = canvas.width, h = canvas.height; - if (h === 0) return; - ctx.clearRect(0, 0, w, h); - - let totalEnergy = 0; - for (let i = 1; i < bufferLength; i++) totalEnergy += dataArray[i]; - const avgEnergy = totalEnergy / (bufferLength - 1) / 255; - hueOffset = (hueOffset + 0.5) % 360; - - const segments = 64; - ctx.lineWidth = 3; - ctx.lineCap = 'round'; - ctx.beginPath(); - for (let i = 0; i <= segments; i++) { - const y = (i / segments) * h; - const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1); - const value = dataArray[binIdx] / 255; - const x = (w / 2) + Math.sin((i / segments) * Math.PI * 4 + Date.now() * 0.003) * value * (w - 2) * 0.4; - if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); - } - const grad = ctx.createLinearGradient(0, 0, 0, h); - grad.addColorStop(0, `hsla(${hueOffset + 200}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`); - grad.addColorStop(0.5, `hsla(${hueOffset + 280}, 80%, 55%, ${0.3 + avgEnergy * 0.7})`); - grad.addColorStop(1, `hsla(${hueOffset + 360}, 80%, 60%, ${0.3 + avgEnergy * 0.7})`); - ctx.strokeStyle = grad; - ctx.stroke(); - ctx.lineWidth = 6; - ctx.globalAlpha = 0.15 + avgEnergy * 0.2; - ctx.stroke(); - ctx.globalAlpha = 1; - } - drawWave(); - - // ── Spectrum (mountain/terrain fill) ── - } else if (type === 'spectrum') { - const canvas = container.querySelector('.sidebar-viz-canvas'); - if (!canvas) return; - const ctx = canvas.getContext('2d'); - let hueOffset = 0; - // Smoothed values for fluid motion - const smoothed = new Float32Array(64); - - function drawSpectrum() { - sidebarVizAnimFrame = requestAnimationFrame(drawSpectrum); - const ch = container.clientHeight; - if (ch > 0 && canvas.height !== ch) canvas.height = ch; - npAnalyser.getByteFrequencyData(dataArray); - const w = canvas.width, h = canvas.height; - if (h === 0) return; - ctx.clearRect(0, 0, w, h); - - hueOffset = (hueOffset + 0.3) % 360; - const segments = smoothed.length; - - // Smooth the frequency data - for (let i = 0; i < segments; i++) { - const binIdx = Math.min(Math.floor((i / segments) * (bufferLength - 1)) + 1, bufferLength - 1); - const target = dataArray[binIdx] / 255; - smoothed[i] += (target - smoothed[i]) * 0.25; - } - - // Draw filled mountain shape from left edge - ctx.beginPath(); - ctx.moveTo(0, 0); - for (let i = 0; i <= segments; i++) { - const y = (i / segments) * h; - const value = i < segments ? smoothed[i] : smoothed[segments - 1]; - const x = value * w * 0.95; - ctx.lineTo(x, y); - } - ctx.lineTo(0, h); - ctx.closePath(); - - // Gradient fill - const fillGrad = ctx.createLinearGradient(0, 0, 0, h); - fillGrad.addColorStop(0, `hsla(${hueOffset + 200}, 85%, 55%, 0.7)`); - fillGrad.addColorStop(0.25, `hsla(${hueOffset + 240}, 80%, 50%, 0.6)`); - fillGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 85%, 50%, 0.65)`); - fillGrad.addColorStop(0.75, `hsla(${hueOffset + 330}, 80%, 50%, 0.6)`); - fillGrad.addColorStop(1, `hsla(${hueOffset + 360}, 85%, 55%, 0.7)`); - ctx.fillStyle = fillGrad; - ctx.fill(); - - // Bright edge line - ctx.beginPath(); - ctx.moveTo(0, 0); - for (let i = 0; i <= segments; i++) { - const y = (i / segments) * h; - const value = i < segments ? smoothed[i] : smoothed[segments - 1]; - ctx.lineTo(value * w * 0.95, y); - } - const lineGrad = ctx.createLinearGradient(0, 0, 0, h); - lineGrad.addColorStop(0, `hsla(${hueOffset + 200}, 90%, 70%, 0.9)`); - lineGrad.addColorStop(0.5, `hsla(${hueOffset + 290}, 90%, 65%, 0.9)`); - lineGrad.addColorStop(1, `hsla(${hueOffset + 360}, 90%, 70%, 0.9)`); - ctx.strokeStyle = lineGrad; - ctx.lineWidth = 1.5; - ctx.stroke(); - - // Outer glow - ctx.lineWidth = 4; - ctx.globalAlpha = 0.2; - ctx.stroke(); - ctx.globalAlpha = 1; - } - drawSpectrum(); - - // ── Mirror (bars from center outward) ── - } else if (type === 'mirror') { - const bars = container.querySelectorAll('.sidebar-viz-mirror-bar'); - if (bars.length === 0) return; - function drawMirror() { - sidebarVizAnimFrame = requestAnimationFrame(drawMirror); - npAnalyser.getByteFrequencyData(dataArray); - const half = Math.floor(bars.length / 2); - for (let i = 0; i < half; i++) { - const value = getBinValue(i, half); - const scale = Math.max(0.06, value); - const hue = (hueStart + (i / half) * hueRange + value * 30) % 360; - const color = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`; - // Top half — mirror index from center - const topIdx = half - 1 - i; - const bottomIdx = half + i; - bars[topIdx].style.transform = `scaleX(${scale})`; - bars[topIdx].style.backgroundColor = color; - if (bottomIdx < bars.length) { - bars[bottomIdx].style.transform = `scaleX(${scale})`; - bars[bottomIdx].style.backgroundColor = color; - } - } - } - drawMirror(); - - // ── Equalizer (bars with falling peak indicators) ── - } else if (type === 'equalizer') { - const wraps = container.querySelectorAll('.sidebar-viz-eq-wrap'); - if (wraps.length === 0) return; - const peaks = new Float32Array(wraps.length); - const peakVelocity = new Float32Array(wraps.length); - - function drawEqualizer() { - sidebarVizAnimFrame = requestAnimationFrame(drawEqualizer); - npAnalyser.getByteFrequencyData(dataArray); - for (let i = 0; i < wraps.length; i++) { - const value = getBinValue(i, wraps.length); - const scale = Math.max(0.06, value); - const hue = (hueStart + (i / wraps.length) * hueRange + value * 30) % 360; - const barEl = wraps[i].querySelector('.sidebar-viz-eq-bar'); - const peakEl = wraps[i].querySelector('.sidebar-viz-eq-peak'); - - barEl.style.transform = `scaleX(${scale})`; - barEl.style.backgroundColor = `hsla(${hue}, 80%, ${50 + value * 15}%, ${0.5 + value * 0.5})`; - - // Peak hold with gravity - if (value > peaks[i]) { - peaks[i] = value; - peakVelocity[i] = 0; - } else { - peakVelocity[i] += 0.002; // gravity - peaks[i] = Math.max(0, peaks[i] - peakVelocity[i]); - } - const peakPos = Math.max(0.06, peaks[i]); - peakEl.style.left = `${peakPos * 100}%`; - peakEl.style.backgroundColor = `hsla(${hue}, 90%, 75%, ${0.6 + peaks[i] * 0.4})`; - peakEl.style.boxShadow = `0 0 4px hsla(${hue}, 90%, 70%, ${peaks[i] * 0.5})`; - } - } - drawEqualizer(); - } -} - -function stopSidebarVisualizer() { - if (sidebarVizAnimFrame) { - cancelAnimationFrame(sidebarVizAnimFrame); - sidebarVizAnimFrame = null; - } - const container = document.getElementById('sidebar-visualizer'); - if (container) { - container.classList.remove('active'); - } -} - -// Listen for visualizer type changes in settings — use isPlaying (not wasRunning) -// so switching from 'none' to a real type while music plays starts the visualizer -document.addEventListener('change', (e) => { - if (e.target.id === 'sidebar-visualizer-type') { - const newType = e.target.value; - stopSidebarVisualizer(); - _sidebarVizBuiltType = null; // force rebuild for new type - sidebarVisualizerType = newType; - if (isPlaying && newType !== 'none') { - npInitVisualizer(); - startSidebarVisualizer(); - } - } -}); - -// =============================== -// RADIO MODE -// =============================== - -async function npFetchRadioTracks() { - if (!currentTrack || !currentTrack.id) return; - try { - npLoadingQueueItem = true; - const excludeIds = npRecentlyPlayedIds.join(','); - const resp = await fetch(`/api/library/radio?track_id=${currentTrack.id}&limit=50&exclude=${encodeURIComponent(excludeIds)}`); - if (!resp.ok) { - console.warn('Radio endpoint returned', resp.status); - npLoadingQueueItem = false; - return; - } - const data = await resp.json(); - // Bail if radio was toggled off during the fetch - if (!npRadioMode) { npLoadingQueueItem = false; return; } - if (data.tracks && data.tracks.length > 0) { - data.tracks.forEach(t => { - npQueue.push({ - title: t.title || 'Unknown Track', - artist: t.artist || 'Unknown Artist', - album: t.album || 'Unknown Album', - file_path: t.file_path, - filename: t.file_path, - is_library: true, - image_url: t.image_url || null, - id: t.id, - artist_id: t.artist_id, - album_id: t.album_id, - bitrate: t.bitrate, - sample_rate: t.sample_rate - }); - }); - showToast(`Radio: Added ${data.tracks.length} similar tracks`, 'success'); - renderNpQueue(); - updateNpPrevNextButtons(); - npLoadingQueueItem = false; - // Only auto-advance if nothing is currently playing (triggered by onAudioEnded) - if (!isPlaying) { - playNextInQueue(); - } - } else { - showToast('Radio: No similar tracks found', 'info'); - npLoadingQueueItem = false; - } - } catch (e) { - console.warn('Radio fetch error:', e); - npLoadingQueueItem = false; - } -} - -// Media Session API -function initMediaSession() { - if (!('mediaSession' in navigator)) return; - - navigator.mediaSession.setActionHandler('play', () => { - if (audioPlayer && currentTrack) { - audioPlayer.play().then(() => setPlayingState(true)); - } - }); - navigator.mediaSession.setActionHandler('pause', () => { - if (audioPlayer) { - audioPlayer.pause(); - setPlayingState(false); - } - }); - navigator.mediaSession.setActionHandler('stop', () => { - handleStop(); - }); - navigator.mediaSession.setActionHandler('seekbackward', () => { - if (audioPlayer && audioPlayer.duration) { - audioPlayer.currentTime = Math.max(0, audioPlayer.currentTime - 10); - } - }); - navigator.mediaSession.setActionHandler('seekforward', () => { - if (audioPlayer && audioPlayer.duration) { - audioPlayer.currentTime = Math.min(audioPlayer.duration, audioPlayer.currentTime + 10); - } - }); - navigator.mediaSession.setActionHandler('previoustrack', () => { - if (npQueue.length > 0) playPreviousInQueue(); - }); - navigator.mediaSession.setActionHandler('nexttrack', () => { - if (npQueue.length > 0) playNextInQueue(); - }); -} - -function updateMediaSessionMetadata() { - if (!('mediaSession' in navigator) || !currentTrack) return; - const artwork = []; - const artUrl = getNpAlbumArtUrl(); - if (artUrl) artwork.push({ src: artUrl, sizes: '512x512', type: 'image/jpeg' }); - - navigator.mediaSession.metadata = new MediaMetadata({ - title: currentTrack.title || 'Unknown Track', - artist: currentTrack.artist || 'Unknown Artist', - album: currentTrack.album || 'Unknown Album', - artwork: artwork - }); -} - -function updateMediaSessionPlaybackState() { - if (!('mediaSession' in navigator)) return; - if (!currentTrack) { - navigator.mediaSession.playbackState = 'none'; - } else { - navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused'; - } -} - -// =============================== -// SUPPORT MODAL -// =============================== - -function showSupportModal() { - const overlay = document.getElementById('support-modal-overlay'); - if (overlay) overlay.classList.remove('hidden'); -} - -function closeSupportModal() { - const overlay = document.getElementById('support-modal-overlay'); - if (overlay) overlay.classList.add('hidden'); -} - -async function copyAddress(address, cryptoName) { - try { - // navigator.clipboard requires HTTPS — use fallback for HTTP (Docker) - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(address); - } else { - const textarea = document.createElement('textarea'); - textarea.value = address; - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - document.execCommand('copy'); - document.body.removeChild(textarea); - } - showToast(`${cryptoName} address copied to clipboard`, 'success'); - } catch (error) { - console.error('Failed to copy address:', error); - // Show the address so user can copy manually - showToast(`${cryptoName}: ${address}`, 'info'); - } -} - -// =============================== -// SETTINGS FUNCTIONALITY -// =============================== - -let settingsAutoSaveTimer = null; - -function debouncedAutoSaveSettings() { - if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); - settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000); -} - -function handleManualSaveClick() { - if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); - saveSettings(false); -} - -function initializeSettings() { - // This function is called when the settings page is loaded. - // It attaches event listeners to all interactive elements on the page. - - // Accent color listeners (live preview + custom picker toggle) - initAccentColorListeners(); - - // Main save button (manual save, non-quiet) - // Uses named function reference so addEventListener deduplicates across repeated calls - const saveButton = document.getElementById('save-settings'); - if (saveButton) { - saveButton.addEventListener('click', handleManualSaveClick); - } - - // Debounced auto-save on all settings inputs - // Uses named function reference (debouncedAutoSaveSettings) so addEventListener deduplicates - const settingsPage = document.getElementById('settings-page'); - if (settingsPage) { - settingsPage.querySelectorAll('input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="range"]').forEach(input => { - input.addEventListener('input', debouncedAutoSaveSettings); - }); - settingsPage.querySelectorAll('input[type="checkbox"], select').forEach(input => { - input.addEventListener('change', debouncedAutoSaveSettings); - }); - } - - // Server toggle buttons - const plexToggle = document.getElementById('plex-toggle'); - if (plexToggle) { - plexToggle.addEventListener('click', () => toggleServer('plex')); - } - const jellyfinToggle = document.getElementById('jellyfin-toggle'); - if (jellyfinToggle) { - jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin')); - } - - // Auto-detect buttons - const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button'); - if (detectSlskdBtn) { - detectSlskdBtn.addEventListener('click', autoDetectSlskd); - } - const detectPlexBtn = document.querySelector('#plex-container .detect-button'); - if (detectPlexBtn) { - detectPlexBtn.addEventListener('click', autoDetectPlex); - } - const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button'); - if (detectJellyfinBtn) { - detectJellyfinBtn.addEventListener('click', autoDetectJellyfin); - } - - // Test connection buttons - // Test button event listeners removed - they use onclick attributes in HTML to avoid double firing -} - -function resetFileOrganizationTemplates() { - // Reset templates to defaults - const defaults = { - album: '$albumartist/$albumartist - $album/$track - $title', - single: '$artist/$artist - $title/$title', - playlist: '$playlist/$artist - $title', - video: '$artist/$title-video' - }; - - document.getElementById('template-album-path').value = defaults.album; - document.getElementById('template-single-path').value = defaults.single; - document.getElementById('template-playlist-path').value = defaults.playlist; - document.getElementById('template-video-path').value = defaults.video; - - debouncedAutoSaveSettings(); -} - -function validateFileOrganizationTemplates() { - const errors = []; - - // Valid variables for each template type - const validVars = { - album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$year', '$quality'], - single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$year', '$quality'], - playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'], - video: ['$artist', '$artistletter', '$title', '$year'] - }; - - // Get template values - const albumPath = document.getElementById('template-album-path').value.trim(); - const singlePath = document.getElementById('template-single-path').value.trim(); - const playlistPath = document.getElementById('template-playlist-path').value.trim(); - - // Validate album template - if (albumPath) { - if (albumPath.endsWith('/')) { - errors.push('Album template cannot end with /'); - } - if (albumPath.startsWith('/')) { - errors.push('Album template cannot start with /'); - } - if (!albumPath.includes('/')) { - errors.push('Album template must include at least one folder (use / separator)'); - } - if (albumPath.includes('//')) { - errors.push('Album template cannot have consecutive slashes //'); - } - // Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.) - const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; - const foundVars = albumPath.match(albumVarPattern) || []; - foundVars.forEach(v => { - // Normalize ${var} to $var for validation - const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; - const lowerVar = normalized.toLowerCase(); - // Check if lowercase version exists in valid vars - const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar); - if (!isValid) { - errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`); - } else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) { - // Variable is valid but has wrong case - errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); - } - }); - } - - // Validate single template - if (singlePath) { - if (singlePath.endsWith('/')) { - errors.push('Single template cannot end with /'); - } - if (singlePath.startsWith('/')) { - errors.push('Single template cannot start with /'); - } - // Note: single template is allowed to have no slash (flat file: "$artist - $title") - if (singlePath.includes('//')) { - errors.push('Single template cannot have consecutive slashes //'); - } - const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; - const foundVars = singlePath.match(singleVarPattern) || []; - foundVars.forEach(v => { - const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; - const lowerVar = normalized.toLowerCase(); - const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar); - if (!isValid) { - errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`); - } else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) { - errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); - } - }); - } - - // Validate playlist template - if (playlistPath) { - if (playlistPath.endsWith('/')) { - errors.push('Playlist template cannot end with /'); - } - if (playlistPath.startsWith('/')) { - errors.push('Playlist template cannot start with /'); - } - if (!playlistPath.includes('/')) { - errors.push('Playlist template must include at least one folder (use / separator)'); - } - if (playlistPath.includes('//')) { - errors.push('Playlist template cannot have consecutive slashes //'); - } - const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; - const foundVars = playlistPath.match(playlistVarPattern) || []; - foundVars.forEach(v => { - const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; - const lowerVar = normalized.toLowerCase(); - const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar); - if (!isValid) { - errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`); - } else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) { - errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); - } - }); - } - - return errors; -} - -// Settings redesign — tab switching + service accordions -function switchSettingsTab(tab) { - // Update tab bar - document.querySelectorAll('.stg-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); - // Show/hide settings groups and section headers by data-stg attribute - document.querySelectorAll('#settings-page [data-stg]').forEach(g => { - g.style.display = g.dataset.stg === tab ? '' : 'none'; - }); - // Re-apply collapsed state on section bodies (tab switch resets inline display) - document.querySelectorAll('#settings-page .settings-section-body.collapsed').forEach(b => { - b.style.display = 'none'; - }); - // Also hide/show the column wrappers if they're empty in this tab - document.querySelectorAll('#settings-page .settings-left-column, #settings-page .settings-right-column, #settings-page .settings-third-column').forEach(col => { - const hasVisible = Array.from(col.querySelectorAll('.settings-group[data-stg]')).some(g => g.style.display !== 'none'); - col.style.display = hasVisible ? '' : 'none'; - }); - // Re-apply conditional visibility (quality profile, source containers, etc.) - if (typeof updateDownloadSourceUI === 'function') { - try { updateDownloadSourceUI(); } catch (e) { } - } - // Load DB maintenance info when switching to Advanced tab - if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') { - try { loadDbMaintenanceInfo(); } catch (e) { } - } - // Initialize live log viewer when switching to Logs tab - if (tab === 'logs') { - _logViewerInit(); - } else { - _logViewerStop(); - } -} - -function toggleStgService(el) { - const service = el.closest('.stg-service'); - if (service) service.classList.toggle('expanded'); -} -function toggleAllServiceAccordions(btn) { - const services = document.querySelectorAll('#settings-page .stg-service'); - const allExpanded = Array.from(services).every(s => s.classList.contains('expanded')); - services.forEach(s => s.classList.toggle('expanded', !allExpanded)); - btn.textContent = allExpanded ? 'Expand All' : 'Collapse All'; -} - -// ── Hybrid source priority list (drag-and-drop) ── -const HYBRID_SOURCES = [ - { id: 'soulseek', name: 'Soulseek', icon: 'https://raw.githubusercontent.com/slskd/slskd/master/docs/icon.png', emoji: '🎵' }, - { id: 'youtube', name: 'YouTube', icon: 'https://www.svgrepo.com/show/13671/youtube.svg', emoji: '▶️' }, - { id: 'tidal', name: 'Tidal', icon: 'https://www.svgrepo.com/show/519734/tidal.svg', emoji: '🌊' }, - { id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' }, - { id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' }, - { id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' }, - { id: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' }, -]; - -let _hybridSourceOrder = ['soulseek', 'youtube']; -let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false }; -let _hybridVisualOrder = null; // Full visual order including disabled sources - -function buildHybridSourceList() { - const container = document.getElementById('hybrid-source-list'); - if (!container) return; - - container.innerHTML = ''; - // Build visual order: use persisted visual order, or enabled first + disabled at bottom - if (!_hybridVisualOrder) { - _hybridVisualOrder = [..._hybridSourceOrder]; - for (const src of HYBRID_SOURCES) { - if (!_hybridVisualOrder.includes(src.id)) _hybridVisualOrder.push(src.id); - } - } - const allIds = _hybridVisualOrder; - - allIds.forEach((srcId, idx) => { - const src = HYBRID_SOURCES.find(s => s.id === srcId); - if (!src) return; - const enabled = _hybridSourceEnabled[srcId] !== false; - const isInOrder = _hybridSourceOrder.includes(srcId); - const priorityNum = isInOrder && enabled ? _hybridSourceOrder.indexOf(srcId) + 1 : ''; - - const item = document.createElement('div'); - item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`; - item.draggable = true; - item.dataset.sourceId = srcId; - - item.innerHTML = ` - - - - - ${src.icon - ? `${src.name}` - : `${src.emoji}` - } - ${src.name} - ${priorityNum} - - `; - - container.appendChild(item); - }); - - // Sync hidden selects for backward compat - _syncHybridHiddenSelects(); -} - -function moveHybridSource(srcId, direction) { - if (!_hybridVisualOrder) return; - const idx = _hybridVisualOrder.indexOf(srcId); - if (idx < 0) return; - const newIdx = idx + direction; - if (newIdx < 0 || newIdx >= _hybridVisualOrder.length) return; - - // Swap in visual order - [_hybridVisualOrder[idx], _hybridVisualOrder[newIdx]] = [_hybridVisualOrder[newIdx], _hybridVisualOrder[idx]]; - - // Rebuild enabled order from visual order - _hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false); - buildHybridSourceList(); - updateDownloadSourceUI(); - debouncedAutoSaveSettings(); -} - -function toggleHybridSource(srcId, enabled) { - _hybridSourceEnabled[srcId] = enabled; - // Rebuild enabled order from visual order so priority matches position - if (_hybridVisualOrder) { - _hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false); - } - buildHybridSourceList(); - updateDownloadSourceUI(); - debouncedAutoSaveSettings(); -} - -function _syncHybridOrderFromDOM() { - const container = document.getElementById('hybrid-source-list'); - if (!container) return; - const items = container.querySelectorAll('.hybrid-source-item'); - const newOrder = []; - items.forEach(item => { - const id = item.dataset.sourceId; - if (_hybridSourceEnabled[id] !== false) { - newOrder.push(id); - } - }); - _hybridSourceOrder = newOrder; -} - -function _syncHybridHiddenSelects() { - // Keep hidden selects in sync for backward compat with saveSettings - const primary = document.getElementById('hybrid-primary-source'); - const secondary = document.getElementById('hybrid-secondary-source'); - if (primary && _hybridSourceOrder.length > 0) primary.value = _hybridSourceOrder[0]; - if (secondary && _hybridSourceOrder.length > 1) secondary.value = _hybridSourceOrder[1]; -} - -function getHybridOrder() { - return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false); -} - -function loadHybridSourceOrder(settings) { - const order = settings.download_source?.hybrid_order; - const sourceStatus = settings._source_status || {}; - - if (order && Array.isArray(order) && order.length > 0) { - _hybridSourceOrder = order; - _hybridSourceEnabled = {}; - for (const src of HYBRID_SOURCES) { - _hybridSourceEnabled[src.id] = order.includes(src.id); - } - } else { - // Legacy: fall back to primary/secondary - const primary = settings.download_source?.hybrid_primary || 'soulseek'; - const secondary = settings.download_source?.hybrid_secondary || 'youtube'; - _hybridSourceOrder = [primary, secondary]; - _hybridSourceEnabled = {}; - for (const src of HYBRID_SOURCES) { - _hybridSourceEnabled[src.id] = src.id === primary || src.id === secondary; - } - } - - // Auto-disable sources that aren't configured on the server - let changed = false; - for (const src of HYBRID_SOURCES) { - if (_hybridSourceEnabled[src.id] && sourceStatus[src.id] === false) { - _hybridSourceEnabled[src.id] = false; - changed = true; - } - } - if (changed) { - _hybridSourceOrder = _hybridSourceOrder.filter(id => _hybridSourceEnabled[id] !== false); - } - - _hybridVisualOrder = null; // Reset so buildHybridSourceList rebuilds it - buildHybridSourceList(); -} - -function updateLossyBitrateOptions() { - const codec = document.getElementById('lossy-copy-codec')?.value || 'mp3'; - const bitrateSelect = document.getElementById('lossy-copy-bitrate'); - if (!bitrateSelect) return; - const opt320 = bitrateSelect.querySelector('option[value="320"]'); - if (codec === 'opus') { - // Opus max is 256kbps per channel — hide 320 option - if (opt320) opt320.disabled = true; - if (bitrateSelect.value === '320') bitrateSelect.value = '256'; - } else { - if (opt320) opt320.disabled = false; - } -} - -function updatePlexConfigurationButtons() { - const plexUrl = document.getElementById('plex-url'); - const plexToken = document.getElementById('plex-token'); - const hasPlexConfig = Boolean((plexUrl?.value || '').trim() || (plexToken?.value || '').trim()); - const plexViewConfigButton = document.getElementById('plex-view-config-button'); - const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); - const plexManualConfigButton = document.getElementById('plex-manual-config-button'); - const plexUrlActions = document.getElementById('plex-url-actions'); - const plexTokenActions = document.getElementById('plex-token-actions'); - const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); - - if (plexViewConfigButton) plexViewConfigButton.style.display = hasPlexConfig ? '' : 'none'; - if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = hasPlexConfig ? 'none' : ''; - if (plexManualConfigButton) plexManualConfigButton.style.display = hasPlexConfig ? 'none' : ''; - if (plexUrlActions) plexUrlActions.style.display = hasPlexConfig ? 'none' : 'flex'; - if (plexTokenActions) plexTokenActions.style.display = hasPlexConfig ? 'none' : 'flex'; - if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; -} - -async function loadSettingsData() { - try { - const response = await fetch(API.settings); - const settings = await response.json(); - - // Populate Spotify settings - document.getElementById('spotify-client-id').value = settings.spotify?.client_id || ''; - document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || ''; - document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; - document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; - - // Populate Tidal settings - document.getElementById('tidal-client-id').value = settings.tidal?.client_id || ''; - document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || ''; - document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; - document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; - - // Populate Deezer OAuth settings - document.getElementById('deezer-app-id').value = settings.deezer?.app_id || ''; - document.getElementById('deezer-app-secret').value = settings.deezer?.app_secret || ''; - document.getElementById('deezer-redirect-uri').value = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback'; - document.getElementById('deezer-callback-display').textContent = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback'; - - // Add event listeners to update display URLs when input changes - document.getElementById('spotify-redirect-uri').addEventListener('input', function () { - document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback'; - }); - - document.getElementById('tidal-redirect-uri').addEventListener('input', function () { - document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback'; - }); - - document.getElementById('deezer-redirect-uri').addEventListener('input', function () { - document.getElementById('deezer-callback-display').textContent = this.value || 'http://127.0.0.1:8008/deezer/callback'; - }); - - // Populate Plex settings - const plexUrlInput = document.getElementById('plex-url'); - const plexTokenInput = document.getElementById('plex-token'); - if (plexUrlInput) plexUrlInput.value = settings.plex?.base_url || ''; - if (plexTokenInput) plexTokenInput.value = settings.plex?.token || ''; - if (plexUrlInput) plexUrlInput.addEventListener('input', updatePlexConfigurationButtons); - if (plexTokenInput) plexTokenInput.addEventListener('input', updatePlexConfigurationButtons); - updatePlexConfigurationButtons(); - - // Populate Jellyfin settings - document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || ''; - document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || ''; - document.getElementById('jellyfin-timeout').value = settings.jellyfin?.api_timeout || 120; - - // Populate Navidrome settings - document.getElementById('navidrome-url').value = settings.navidrome?.base_url || ''; - document.getElementById('navidrome-username').value = settings.navidrome?.username || ''; - document.getElementById('navidrome-password').value = settings.navidrome?.password || ''; - - // Set active server and toggle visibility - const activeServer = settings.active_media_server || 'plex'; - toggleServer(activeServer); - - // Load Plex music libraries if Plex is the active server - if (activeServer === 'plex') { - loadPlexMusicLibraries(); - } - - // Load Jellyfin users and music libraries if Jellyfin is the active server - if (activeServer === 'jellyfin') { - loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); - } - - // Load Navidrome music folders if Navidrome is the active server - if (activeServer === 'navidrome') { - loadNavidromeMusicFolders(); - } - - // Populate Soulseek settings - document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || ''; - document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || ''; - document.getElementById('soulseek-search-timeout').value = settings.soulseek?.search_timeout || 60; - document.getElementById('soulseek-search-timeout-buffer').value = settings.soulseek?.search_timeout_buffer || 15; - document.getElementById('soulseek-min-peer-speed').value = settings.soulseek?.min_peer_upload_speed || 0; - document.getElementById('soulseek-max-peer-queue').value = settings.soulseek?.max_peer_queue || 0; - document.getElementById('soulseek-download-timeout').value = Math.round((settings.soulseek?.download_timeout || 600) / 60); - document.getElementById('soulseek-auto-clear-searches').checked = settings.soulseek?.auto_clear_searches !== false; - - // Populate ListenBrainz settings - document.getElementById('listenbrainz-base-url').value = settings.listenbrainz?.base_url || ''; - document.getElementById('listenbrainz-token').value = settings.listenbrainz?.token || ''; - - // Populate AcoustID settings - document.getElementById('acoustid-api-key').value = settings.acoustid?.api_key || ''; - document.getElementById('acoustid-enabled').checked = settings.acoustid?.enabled || false; - - // Populate Last.fm settings - document.getElementById('lastfm-api-key').value = settings.lastfm?.api_key || ''; - document.getElementById('lastfm-api-secret').value = settings.lastfm?.api_secret || ''; - document.getElementById('lastfm-scrobble-enabled').checked = settings.lastfm?.scrobble_enabled === true; - const lfmStatus = document.getElementById('lastfm-scrobble-status'); - if (lfmStatus) { - lfmStatus.textContent = settings.lastfm?.session_key ? 'Authorized' : 'Not authorized'; - } - - // Populate ListenBrainz scrobble toggle - document.getElementById('listenbrainz-scrobble-enabled').checked = settings.listenbrainz?.scrobble_enabled === true; - - // Populate Genius settings - document.getElementById('genius-access-token').value = settings.genius?.access_token || ''; - - // Populate iTunes settings - document.getElementById('itunes-country').value = settings.itunes?.country || 'US'; - - // Populate Discogs settings - document.getElementById('discogs-token').value = settings.discogs?.token || ''; - - // Populate Metadata source setting - document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'itunes'; - - // Populate Hydrabase settings - const hbConfig = settings.hydrabase || {}; - document.getElementById('hydrabase-url').value = hbConfig.url || ''; - document.getElementById('hydrabase-api-key').value = hbConfig.api_key || ''; - document.getElementById('hydrabase-auto-connect').checked = hbConfig.auto_connect || false; - // Check live connection status + add Hydrabase to fallback dropdown if connected - fetch('/api/hydrabase/status').then(r => r.json()).then(s => { - const btn = document.getElementById('hydrabase-connect-btn'); - const statusEl = document.getElementById('hydrabase-settings-status'); - if (s.connected) { - if (btn) btn.textContent = 'Disconnect'; - if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } - // Add Hydrabase to fallback source dropdown - const fbSelect = document.getElementById('metadata-fallback-source'); - if (fbSelect && !fbSelect.querySelector('option[value="hydrabase"]')) { - const opt = document.createElement('option'); - opt.value = 'hydrabase'; - opt.textContent = 'Hydrabase (P2P)'; - fbSelect.appendChild(opt); - } - // Restore selection if it was hydrabase - if ((settings.metadata?.fallback_source) === 'hydrabase') { - fbSelect.value = 'hydrabase'; - } - } - }).catch(() => { }); - - // Populate Download settings (right column) - document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads'; - document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; - document.getElementById('staging-path').value = settings.import?.staging_path || './Staging'; - document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos'; - - // Populate Download Source settings - document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; - document.getElementById('stream-source').value = settings.download_source?.stream_source || 'youtube'; - document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3'; - loadHybridSourceOrder(settings); - document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless'; - document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false; - document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless'; - document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false; - document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless'; - document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false; - document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac'; - document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false; - document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || ''; - document.getElementById('lidarr-url').value = settings.lidarr_download?.url || ''; - document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || ''; - // Sync ARL to connections tab field + bidirectional listeners - const _connArl = document.getElementById('deezer-connection-arl'); - const _dlArl = document.getElementById('deezer-download-arl'); - if (_connArl) _connArl.value = settings.deezer_download?.arl || ''; - if (_connArl && _dlArl) { - _connArl.addEventListener('input', () => { _dlArl.value = _connArl.value; }); - _dlArl.addEventListener('input', () => { _connArl.value = _dlArl.value; }); - } - - // Populate YouTube settings - document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || ''; - document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3; - - // Update UI based on download source mode - updateDownloadSourceUI(); - - // Populate Database settings - document.getElementById('max-workers').value = settings.database?.max_workers || '5'; - - // Populate Post-Processing settings - document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false; - document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false; - document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false; - document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true; - document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false; - document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true; - // Load service master toggles - document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false; - document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false; - document.getElementById('embed-musicbrainz').checked = settings.musicbrainz?.embed_tags !== false; - document.getElementById('embed-deezer').checked = settings.deezer?.embed_tags !== false; - document.getElementById('embed-audiodb').checked = settings.audiodb?.embed_tags !== false; - document.getElementById('embed-tidal').checked = settings.tidal?.embed_tags !== false; - document.getElementById('embed-qobuz').checked = settings.qobuz?.embed_tags !== false; - document.getElementById('embed-lastfm').checked = settings.lastfm?.embed_tags !== false; - document.getElementById('embed-genius').checked = settings.genius?.embed_tags !== false; - // Load per-tag toggles from data-config attributes - document.querySelectorAll('[data-config]').forEach(cb => { - const path = cb.dataset.config.split('.'); - let val = settings; - for (const key of path) { val = val?.[key]; } - cb.checked = val !== false; - }); - // Apply service disabled state to child tags - ['spotify', 'itunes', 'musicbrainz', 'deezer', 'audiodb', 'tidal', 'qobuz', 'lastfm', 'genius'].forEach(svc => { - const master = document.getElementById('embed-' + svc); - if (master) toggleServiceTags(master, svc); - }); - document.getElementById('post-processing-options').style.display = settings.metadata_enhancement?.enabled !== false ? 'block' : 'none'; - - // Populate File Organization settings - document.getElementById('file-organization-enabled').checked = settings.file_organization?.enabled !== false; - document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title'; - document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title'; - document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title'; - document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video'; - document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc'; - document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first'; - document.getElementById('artist-separator').value = settings.metadata_enhancement?.tags?.artist_separator || ', '; - document.getElementById('write-multi-artist').checked = settings.metadata_enhancement?.tags?.write_multi_artist || false; - document.getElementById('feat-in-title').checked = settings.metadata_enhancement?.tags?.feat_in_title || false; - document.getElementById('allow-duplicate-tracks').checked = settings.wishlist?.allow_duplicate_tracks !== false; - - // Populate Playlist Sync settings - document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false; - - // Populate Post-Download Conversion settings - document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true; - document.getElementById('lossy-copy-enabled').checked = settings.lossy_copy?.enabled === true; - document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3'; - document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320'; - updateLossyBitrateOptions(); - document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true; - - // Populate Listening Stats settings - document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true; - document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30; - document.getElementById('lossy-copy-options').style.display = - settings.lossy_copy?.enabled ? 'block' : 'none'; - - // Populate Music Library Paths - const _musicPaths = settings.library?.music_paths || []; - renderMusicPaths(_musicPaths); - - // Populate Content Filter settings - document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false; - - // Populate Genre Whitelist - const gwEnabled = settings.genre_whitelist?.enabled === true; - document.getElementById('genre-whitelist-enabled').checked = gwEnabled; - const gwContainer = document.getElementById('genre-whitelist-container'); - if (gwContainer) gwContainer.style.display = gwEnabled ? '' : 'none'; - if (gwEnabled) { - _genreWhitelistRender(settings.genre_whitelist?.genres || []); - } - - // Populate Import settings - document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true; - - // Populate M3U Export settings - document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true; - document.getElementById('m3u-entry-base-path').value = settings.m3u_export?.entry_base_path || ''; - - // Populate UI Appearance settings - const accentPreset = settings.ui_appearance?.accent_preset || '#1db954'; - const accentCustom = settings.ui_appearance?.accent_color || '#1db954'; - const presetSelect = document.getElementById('accent-preset'); - const customPicker = document.getElementById('accent-custom-color'); - const customGroup = document.getElementById('custom-color-group'); - if (presetSelect) { - // Check if the saved preset matches a dropdown option - const presetOptions = Array.from(presetSelect.options).map(o => o.value); - if (presetOptions.includes(accentPreset)) { - presetSelect.value = accentPreset; - } else { - presetSelect.value = 'custom'; - } - if (presetSelect.value === 'custom') { - if (customGroup) customGroup.style.display = ''; - if (customPicker) customPicker.value = accentCustom; - applyAccentColor(accentCustom); - } else { - if (customGroup) customGroup.style.display = 'none'; - applyAccentColor(accentPreset); - } - } - - // Sidebar visualizer type - const vizType = settings.ui_appearance?.sidebar_visualizer || 'bars'; - const vizSelect = document.getElementById('sidebar-visualizer-type'); - if (vizSelect) vizSelect.value = vizType; - sidebarVisualizerType = vizType; - - // Background particles toggle - const particlesEnabled = settings.ui_appearance?.particles_enabled !== false; // default true - const particlesCheckbox = document.getElementById('particles-enabled'); - if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled; - applyParticlesSetting(particlesEnabled); - - // Worker orbs toggle - const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true - const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); - if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled; - applyWorkerOrbsSetting(workerOrbsEnabled); - - // Reduce effects toggle - const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false - const reduceCheckbox = document.getElementById('reduce-effects-enabled'); - if (reduceCheckbox) reduceCheckbox.checked = reduceEffects; - applyReduceEffects(reduceEffects); - - // Populate Logging information - const logLevelSelect = document.getElementById('log-level-select'); - if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO'; - document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; - - // Load Discovery Lookback Period setting - try { - const lookbackResponse = await fetch('/api/discovery/lookback-period'); - const lookbackData = await lookbackResponse.json(); - if (lookbackData.period) { - document.getElementById('discovery-lookback-period').value = lookbackData.period; - } - } catch (error) { - console.error('Error loading discovery lookback period:', error); - } - - // Load Hemisphere setting - try { - const hemiResponse = await fetch('/api/discovery/hemisphere'); - const hemiData = await hemiResponse.json(); - if (hemiData.hemisphere) { - document.getElementById('discovery-hemisphere').value = hemiData.hemisphere; - } - } catch (error) { - console.error('Error loading hemisphere setting:', error); - } - - // Load current log level - try { - const logLevelResponse = await fetch('/api/settings/log-level'); - const logLevelData = await logLevelResponse.json(); - if (logLevelData.success && logLevelData.level) { - document.getElementById('log-level-select').value = logLevelData.level; - } - } catch (error) { - console.error('Error loading log level:', error); - } - - // Load security settings - try { - const requirePin = settings.security?.require_pin_on_launch || false; - document.getElementById('security-require-pin').checked = requirePin; - - // Check if admin has a PIN set - const profilesRes = await fetch('/api/profiles'); - const profilesData = await profilesRes.json(); - const adminProfile = (profilesData.profiles || []).find(p => p.is_admin); - const adminHasPin = adminProfile?.has_pin || false; - - // Show/hide PIN setup vs change sections - document.getElementById('security-pin-setup').style.display = adminHasPin ? 'none' : 'block'; - document.getElementById('security-change-pin-section').style.display = adminHasPin ? 'block' : 'none'; - - // If no PIN, disable the toggle - if (!adminHasPin) { - document.getElementById('security-require-pin').checked = false; - document.getElementById('security-require-pin').disabled = true; - } - } catch (error) { - console.error('Error loading security settings:', error); - } - - // Check dev mode status - try { - const devResponse = await fetch('/api/dev-mode'); - const devData = await devResponse.json(); - if (devData.enabled) { - document.getElementById('dev-mode-status').textContent = 'Active'; - document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))'; - document.getElementById('hydrabase-nav').style.display = ''; - document.getElementById('hydrabase-button-container').style.display = ''; - } - } catch (error) { - console.error('Error checking dev mode:', error); - } - - } catch (error) { - console.error('Error loading settings:', error); - showToast('Failed to load settings', 'error'); - } -} - -async function changeLogLevel() { - const selector = document.getElementById('log-level-select'); - const level = selector.value; - - try { - const response = await fetch('/api/settings/log-level', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ level: level }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`Log level changed to ${level}`, 'success'); - console.log(`Log level changed to: ${level}`); - } else { - showToast(`Failed to change log level: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error changing log level:', error); - showToast('Failed to change log level', 'error'); - } -} - -function updateMediaServerFields() { - const serverType = document.getElementById('media-server-type').value; - const urlInput = document.getElementById('media-server-url'); - const tokenInput = document.getElementById('media-server-token'); - - if (serverType === 'plex') { - urlInput.placeholder = 'http://localhost:32400'; - tokenInput.placeholder = 'Plex Token'; - } else { - urlInput.placeholder = 'http://localhost:8096'; - tokenInput.placeholder = 'Jellyfin API Key'; - } -} - -let _plexPinAuthRequestId = null; -let _plexPinAuthPollInterval = null; - -function showPlexConfiguration(disableFields = false, isManualConfig = false) { - stopPlexPinAuthPolling(); - const plexConfig = document.getElementById('plex-configuration'); - const plexSetup = document.getElementById('plex-setup'); - const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); - const plexUrl = document.getElementById('plex-url'); - const plexToken = document.getElementById('plex-token'); - const plexLibraryContainer = document.getElementById('plex-library-selector-container'); - - if (plexConfig) plexConfig.style.display = ''; - if (plexSetup) plexSetup.style.display = 'none'; - if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; - if (plexUrl) plexUrl.disabled = disableFields; - if (plexToken) plexToken.disabled = disableFields; - if (plexLibraryContainer && isManualConfig) { - plexLibraryContainer.style.display = 'none'; - } - setPlexConfigActionButton(isManualConfig); - updatePlexConfigurationButtons(); -} - -function showPlexSetup() { - const plexConfig = document.getElementById('plex-configuration'); - const plexSetup = document.getElementById('plex-setup'); - const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); - const plexLibraryContainer = document.getElementById('plex-library-selector-container'); - - if (plexConfig) plexConfig.style.display = 'none'; - if (plexSetup) plexSetup.style.display = ''; - if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; - if (plexLibraryContainer) plexLibraryContainer.style.display = 'none'; - setPlexConfigActionButton(false); -} - -function setPlexConfigActionButton(isManualConfig) { - const actionButton = document.getElementById('plex-config-action-button'); - if (!actionButton) return; - - if (isManualConfig) { - actionButton.textContent = 'Cancel'; - actionButton.onclick = showPlexSetup; - actionButton.title = 'Cancel manual Plex configuration'; - } else { - actionButton.textContent = 'Clear Configuration'; - actionButton.onclick = clearPlexConfiguration; - actionButton.title = 'Clear saved Plex configuration'; - } -} - -async function startPlexPinAuth() { - const setupButtons = document.getElementById('plex-setup-buttons'); - const authFlow = document.getElementById('plex-pin-auth-flow'); - const statusEl = document.getElementById('plex-pin-status'); - if (setupButtons) setupButtons.style.display = 'none'; - if (authFlow) authFlow.style.display = ''; - if (statusEl) statusEl.textContent = 'Starting Plex authorization...'; - - try { - showLoadingOverlay('Starting Plex authorization...'); - const response = await fetch('/api/plex/pin/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - const result = await response.json(); - if (!result.success) { - throw new Error(result.error || 'Failed to start Plex PIN flow'); - } - - _plexPinAuthRequestId = result.request_id; - const pinCodeEl = document.getElementById('plex-pin-code'); - if (pinCodeEl) pinCodeEl.textContent = result.code || ''; - if (statusEl) { - statusEl.textContent = result.expires_in - ? `Enter this code at plex.tv/link. Code expires in ${result.expires_in} seconds.` - : 'Enter this code at plex.tv/link. Waiting for authorization...'; - } - - startPlexPinAuthPolling(); - } catch (error) { - console.error('Plex PIN auth start failed:', error); - showToast(error.message || 'Failed to start Plex authorization', 'error'); - cancelPlexPinAuth(); - } finally { - hideLoadingOverlay(); - } -} - -function startPlexPinAuthPolling() { - stopPlexPinAuthPolling(); - if (!_plexPinAuthRequestId) return; - _plexPinAuthPollInterval = setInterval(pollPlexPinAuthStatus, 5000); - pollPlexPinAuthStatus(); -} - -function stopPlexPinAuthPolling() { - if (_plexPinAuthPollInterval) { - clearInterval(_plexPinAuthPollInterval); - _plexPinAuthPollInterval = null; - } -} - -async function pollPlexPinAuthStatus() { - if (!_plexPinAuthRequestId) return; - try { - const response = await fetch(`/api/plex/pin/status?request_id=${encodeURIComponent(_plexPinAuthRequestId)}`); - const result = await response.json(); - const statusEl = document.getElementById('plex-pin-status'); - - if (!result.success && result.expired) { - if (statusEl) statusEl.textContent = 'PIN code expired. Generate a new code to continue.'; - stopPlexPinAuthPolling(); - return; - } - - if (result.success) { - stopPlexPinAuthPolling(); - if (statusEl) statusEl.textContent = 'Authorization complete! Saving Plex configuration...'; - document.getElementById('plex-url').value = result.found_url || ''; - document.getElementById('plex-token').value = result.token || ''; - if (typeof saveSettings === 'function') { - await saveSettings(true); - } - showToast('Plex successfully linked', 'success'); - showPlexConfiguration(true); - await testConnection('plex'); - return; - } - - if (result.status) { - if (statusEl) statusEl.textContent = result.status; - return; - } - - if (result.error) { - if (statusEl) statusEl.textContent = result.error; - return; - } - } catch (error) { - console.error('Error polling Plex PIN status:', error); - const statusEl = document.getElementById('plex-pin-status'); - if (statusEl) statusEl.textContent = 'Unable to contact Plex auth status. Retrying...'; - } -} - -function cancelPlexPinAuth() { - stopPlexPinAuthPolling(); - _plexPinAuthRequestId = null; - const setupButtons = document.getElementById('plex-setup-buttons'); - const authFlow = document.getElementById('plex-pin-auth-flow'); - if (setupButtons) setupButtons.style.display = ''; - if (authFlow) authFlow.style.display = 'none'; -} - -function restartPlexPinAuth() { - cancelPlexPinAuth(); - startPlexPinAuth(); -} - -async function clearPlexConfiguration() { - cancelPlexPinAuth(); - const plexUrl = document.getElementById('plex-url'); - const plexToken = document.getElementById('plex-token'); - const plexConfig = document.getElementById('plex-configuration'); - const plexSetup = document.getElementById('plex-setup'); - const plexSetupButtons = document.getElementById('plex-setup-buttons'); - const plexViewConfigButton = document.getElementById('plex-view-config-button'); - const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); - const plexManualConfigButton = document.getElementById('plex-manual-config-button'); - - if (plexUrl) plexUrl.value = ''; - if (plexToken) plexToken.value = ''; - if (plexConfig) plexConfig.style.display = 'none'; - if (plexSetup) plexSetup.style.display = ''; - if (plexSetupButtons) plexSetupButtons.style.display = ''; - if (plexViewConfigButton) plexViewConfigButton.style.display = 'none'; - if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = ''; - if (plexManualConfigButton) plexManualConfigButton.style.display = ''; - - const plexLibraryContainer = document.getElementById('plex-library-selector-container'); - const plexLibrarySelect = document.getElementById('plex-music-library'); - if (plexLibrarySelect) { - plexLibrarySelect.innerHTML = ''; - } - if (plexLibraryContainer) { - plexLibraryContainer.style.display = 'none'; - } - - updatePlexConfigurationButtons(); - - try { - await fetch('/api/plex/clear-library', { method: 'POST' }); - } catch (e) { - console.warn('Failed to clear Plex library preference:', e); - } - - if (typeof saveSettings === 'function') { - saveSettings(true); - } - if (typeof showToast === 'function') { - showToast('Plex configuration cleared', 'success'); - } -} - -function toggleServer(serverType) { - // Update toggle buttons - document.getElementById('plex-toggle').classList.remove('active'); - document.getElementById('jellyfin-toggle').classList.remove('active'); - document.getElementById('navidrome-toggle').classList.remove('active'); - document.getElementById('soulsync-toggle')?.classList.remove('active'); - document.getElementById(`${serverType}-toggle`)?.classList.add('active'); - - // Show/hide server containers - document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex'); - document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin'); - document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome'); - document.getElementById('soulsync-container')?.classList.toggle('hidden', serverType !== 'soulsync'); - - // Show Plex setup when Plex is selected; otherwise hide both Plex panels - const plexConfig = document.getElementById('plex-configuration'); - const plexSetup = document.getElementById('plex-setup'); - if (plexConfig) plexConfig.style.display = serverType === 'plex' ? 'none' : ''; - if (plexSetup) plexSetup.style.display = serverType === 'plex' ? '' : 'none'; - - // Load Plex music libraries when switching to Plex - if (serverType === 'plex') { - loadPlexMusicLibraries(); - } - - // Load Jellyfin users and music libraries when switching to Jellyfin - if (serverType === 'jellyfin') { - loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); - } - - // Load Navidrome music folders when switching to Navidrome - if (serverType === 'navidrome') { - loadNavidromeMusicFolders(); - } - - // Auto-save after server toggle change - debouncedAutoSaveSettings(); -} - -function updateDownloadSourceUI() { - const mode = document.getElementById('download-source-mode').value; - const hybridContainer = document.getElementById('hybrid-settings-container'); - const soulseekContainer = document.getElementById('soulseek-settings-container'); - const tidalContainer = document.getElementById('tidal-download-settings-container'); - const qobuzContainer = document.getElementById('qobuz-settings-container'); - const youtubeContainer = document.getElementById('youtube-settings-container'); - const hifiContainer = document.getElementById('hifi-download-settings-container'); - const deezerDlContainer = document.getElementById('deezer-download-settings-container'); - const lidarrContainer = document.getElementById('lidarr-download-settings-container'); - - hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none'; - - // Determine which sources are active - let activeSources = new Set(); - if (mode === 'hybrid') { - const order = getHybridOrder(); - for (const src of order) activeSources.add(src); - // Fallback: if no sources enabled, at least show soulseek - if (activeSources.size === 0) activeSources.add('soulseek'); - } else { - activeSources.add(mode); - } - - soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none'; - tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none'; - qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none'; - youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none'; - hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none'; - if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none'; - if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none'; - - // Quality profile is Soulseek-only and downloads-tab-only - const qualityProfileSection = document.getElementById('quality-profile-section'); - if (qualityProfileSection) { - const activeTab = document.querySelector('.stg-tab.active'); - const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads'; - qualityProfileSection.style.display = (activeSources.has('soulseek') && onDownloadsTab) ? '' : 'none'; - } - - if (activeSources.has('tidal')) { - checkTidalDownloadAuthStatus(); - } - if (activeSources.has('qobuz')) { - checkQobuzAuthStatus(); - } - if (activeSources.has('hifi')) { - testHiFiConnection(); - } -} - -function updateHybridSecondaryOptions() { - const primary = document.getElementById('hybrid-primary-source').value; - const secondary = document.getElementById('hybrid-secondary-source'); - const currentValue = secondary.value; - const allSources = [ - { value: 'soulseek', label: 'Soulseek' }, - { value: 'youtube', label: 'YouTube' }, - { value: 'tidal', label: 'Tidal' }, - { value: 'qobuz', label: 'Qobuz' }, - { value: 'hifi', label: 'HiFi' }, - ]; - - secondary.innerHTML = ''; - for (const source of allSources) { - if (source.value === primary) continue; - const opt = document.createElement('option'); - opt.value = source.value; - opt.textContent = source.label; - secondary.appendChild(opt); - } - - // Restore previous selection if still valid, otherwise pick first available - if (currentValue !== primary) { - secondary.value = currentValue; - } - - // Refresh source-specific settings visibility based on new primary/secondary - updateDownloadSourceUI(); -} - -// =============================== -// QUALITY PROFILE FUNCTIONS -// =============================== - -let currentQualityProfile = null; - -async function loadQualityProfile() { - try { - const response = await fetch('/api/quality-profile'); - const data = await response.json(); - - if (data.success) { - currentQualityProfile = data.profile; - populateQualityProfileUI(currentQualityProfile); - } - } catch (error) { - console.error('Error loading quality profile:', error); - } -} - -function populateQualityProfileUI(profile) { - // Update preset buttons - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); - const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`); - if (activePresetBtn) { - activePresetBtn.classList.add('active'); - } - - // Populate each quality tier - const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; - qualities.forEach(quality => { - const config = profile.qualities[quality]; - if (config) { - // Set enabled checkbox - const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`); - if (enabledCheckbox) { - enabledCheckbox.checked = config.enabled; - } - - // Set min/max sliders - const minSlider = document.getElementById(`${quality}-min`); - const maxSlider = document.getElementById(`${quality}-max`); - if (minSlider && maxSlider) { - minSlider.value = config.min_kbps; - maxSlider.value = config.max_kbps; - updateQualityRange(quality); - } - - // Set priority display - const prioritySpan = document.getElementById(`priority-${quality}`); - if (prioritySpan) { - prioritySpan.textContent = `Priority: ${config.priority}`; - } - - // Toggle sliders visibility - const sliders = document.getElementById(`sliders-${quality}`); - if (sliders) { - if (config.enabled) { - sliders.classList.remove('disabled'); - } else { - sliders.classList.add('disabled'); - } - } - - // FLAC-specific: restore bit depth selector and fallback toggle - if (quality === 'flac') { - const bitDepthValue = config.bit_depth || 'any'; - document.querySelectorAll('.bit-depth-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue); - }); - const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); - if (bitDepthSelector) { - if (config.enabled) { - bitDepthSelector.classList.remove('disabled'); - } else { - bitDepthSelector.classList.add('disabled'); - } - } - // Show/hide and restore fallback toggle - const fallbackToggle = document.getElementById('flac-fallback-toggle'); - if (fallbackToggle) { - fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block'; - } - const fallbackCb = document.getElementById('flac-bit-depth-fallback'); - if (fallbackCb) { - fallbackCb.checked = config.bit_depth_fallback !== false; - } - } - } - }); - - // Set fallback checkbox - const fallbackCheckbox = document.getElementById('quality-fallback-enabled'); - if (fallbackCheckbox) { - fallbackCheckbox.checked = profile.fallback_enabled; - } -} - -function updateQualityRange(quality) { - const minSlider = document.getElementById(`${quality}-min`); - const maxSlider = document.getElementById(`${quality}-max`); - const minValue = document.getElementById(`${quality}-min-value`); - const maxValue = document.getElementById(`${quality}-max-value`); - - if (!minSlider || !maxSlider || !minValue || !maxValue) return; - - let min = parseInt(minSlider.value); - let max = parseInt(maxSlider.value); - - // Ensure min doesn't exceed max - if (min > max) { - min = max; - minSlider.value = min; - } - - // Ensure max doesn't go below min - if (max < min) { - max = min; - maxSlider.value = max; - } - - minValue.textContent = `${min} kbps`; - maxValue.textContent = `${max} kbps`; -} - -function toggleQuality(quality) { - const checkbox = document.getElementById(`quality-${quality}-enabled`); - const sliders = document.getElementById(`sliders-${quality}`); - - if (checkbox && sliders) { - if (checkbox.checked) { - sliders.classList.remove('disabled'); - } else { - sliders.classList.add('disabled'); - } - } - - // Also toggle FLAC bit depth selector - if (quality === 'flac') { - const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); - if (bitDepthSelector && checkbox) { - if (checkbox.checked) { - bitDepthSelector.classList.remove('disabled'); - } else { - bitDepthSelector.classList.add('disabled'); - } - } - } - - // Mark preset as custom when manually changing - if (currentQualityProfile) { - currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); - } -} - -function setFlacBitDepth(value) { - document.querySelectorAll('.bit-depth-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-value') === value); - }); - - // Show/hide fallback toggle — only relevant when a specific bit depth is selected - const fallbackToggle = document.getElementById('flac-fallback-toggle'); - if (fallbackToggle) { - fallbackToggle.style.display = value === 'any' ? 'none' : 'block'; - } - - // Mark preset as custom when manually changing - if (currentQualityProfile) { - currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); - } - - debouncedAutoSaveSettings(); -} - -function setFlacBitDepthFallback(enabled) { - if (currentQualityProfile) { - currentQualityProfile.preset = 'custom'; - document.querySelectorAll('.preset-button').forEach(btn => { - btn.classList.remove('active'); - }); - } - debouncedAutoSaveSettings(); -} - -async function applyQualityPreset(presetName) { - try { - showLoadingOverlay(`Applying ${presetName} preset...`); - - const response = await fetch(`/api/quality-profile/preset/${presetName}`, { - method: 'POST' - }); - - const data = await response.json(); - - if (data.success) { - currentQualityProfile = data.profile; - populateQualityProfileUI(currentQualityProfile); - showToast(`Applied '${presetName}' preset`, 'success'); - } else { - showToast(`Failed to apply preset: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error applying quality preset:', error); - showToast('Failed to apply preset', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -function collectQualityProfileFromUI() { - const profile = { - version: 2, - preset: 'custom', // Will be overridden if a preset is active - qualities: {}, - fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true - }; - - const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; - - qualities.forEach((quality, index) => { - const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false; - const minSlider = document.getElementById(`${quality}-min`); - const maxSlider = document.getElementById(`${quality}-max`); - - // Preserve priority from the currently loaded profile instead of using array order - const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1); - - profile.qualities[quality] = { - enabled: enabled, - min_kbps: parseInt(minSlider?.value || 0), - max_kbps: parseInt(maxSlider?.value || 99999), - priority: existingPriority - }; - - // Add FLAC-specific bit_depth and fallback settings - if (quality === 'flac') { - const activeBtn = document.querySelector('.bit-depth-btn.active'); - profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any'; - const fallbackCb = document.getElementById('flac-bit-depth-fallback'); - profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true; - } - }); - - // Check if current profile matches a preset - if (currentQualityProfile && currentQualityProfile.preset !== 'custom') { - profile.preset = currentQualityProfile.preset; - } - - return profile; -} - -async function saveQualityProfile() { - try { - const profile = collectQualityProfileFromUI(); - - const response = await fetch('/api/quality-profile', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(profile) - }); - - const data = await response.json(); - - if (data.success) { - currentQualityProfile = profile; - console.log('Quality profile saved successfully'); - return true; - } else { - console.error('Failed to save quality profile:', data.error); - return false; - } - } catch (error) { - console.error('Error saving quality profile:', error); - return false; - } -} - -// =============================== -// END QUALITY PROFILE FUNCTIONS -// =============================== - -async function toggleHydrabaseFromSettings() { - const statusEl = document.getElementById('hydrabase-settings-status'); - const btn = document.getElementById('hydrabase-connect-btn'); - const url = document.getElementById('hydrabase-url').value.trim(); - const apiKey = document.getElementById('hydrabase-api-key').value.trim(); - - if (!url || !apiKey) { - if (statusEl) statusEl.textContent = 'URL and API Key required'; - return; - } - - // Save settings first - await saveSettings(true); - - try { - // Check current status - const statusRes = await fetch('/api/hydrabase/status'); - const statusData = await statusRes.json(); - - if (statusData.connected) { - // Disconnect - await fetch('/api/hydrabase/disconnect', { method: 'POST' }); - if (btn) btn.textContent = 'Connect'; - if (statusEl) { statusEl.textContent = 'Disconnected'; statusEl.style.color = 'rgba(255,255,255,0.4)'; } - // Remove from fallback dropdown + reset to iTunes if was selected - const fbSel2 = document.getElementById('metadata-fallback-source'); - if (fbSel2) { - const hbOpt = fbSel2.querySelector('option[value="hydrabase"]'); - if (hbOpt) { - if (fbSel2.value === 'hydrabase') fbSel2.value = 'itunes'; - hbOpt.remove(); - } - } - showToast('Hydrabase disconnected', 'info'); - } else { - // Connect - const res = await fetch('/api/hydrabase/connect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, api_key: apiKey }) - }); - const data = await res.json(); - if (data.success) { - if (btn) btn.textContent = 'Disconnect'; - if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } - // Add to fallback dropdown - const fbSel = document.getElementById('metadata-fallback-source'); - if (fbSel && !fbSel.querySelector('option[value="hydrabase"]')) { - const opt = document.createElement('option'); - opt.value = 'hydrabase'; - opt.textContent = 'Hydrabase (P2P)'; - fbSel.appendChild(opt); - } - showToast('Hydrabase connected', 'success'); - } else { - if (statusEl) statusEl.textContent = data.error || 'Connection failed'; - showToast('Hydrabase connection failed', 'error'); - } - } - } catch (e) { - if (statusEl) statusEl.textContent = 'Error'; - showToast('Hydrabase connection error', 'error'); - } -} - -// ── Music Library Paths ── -function renderMusicPaths(paths) { - const container = document.getElementById('music-paths-list'); - if (!container) return; - if (!paths || paths.length === 0) { - container.innerHTML = '
    No paths configured. Click "Add Path" to add your music folder(s).
    '; - return; - } - container.innerHTML = paths.map((p, i) => ` -
    - - -
    - `).join(''); - // Attach auto-save to dynamically rendered inputs - container.querySelectorAll('.music-path-input').forEach(input => { - input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); - }); -} - -function addMusicPathRow() { - const container = document.getElementById('music-paths-list'); - if (!container) return; - // Clear the "no paths" message if present - const placeholder = container.querySelector('div[style*="color: rgba"]'); - if (placeholder && !container.querySelector('.music-path-row')) placeholder.remove(); - const row = document.createElement('div'); - row.className = 'form-group music-path-row'; - row.style.marginBottom = '4px'; - row.innerHTML = ` - - - `; - container.appendChild(row); - const input = row.querySelector('input'); - input.focus(); - // Auto-save when the user finishes typing a path - input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); -} - -function _removeMusicPathRow(btn) { - btn.closest('.music-path-row').remove(); - // Auto-save after removing a path - if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); -} - -function collectMusicPaths() { - const inputs = document.querySelectorAll('.music-path-input'); - const paths = []; - inputs.forEach(input => { - const val = input.value.trim(); - if (val) paths.push(val); - }); - return paths; -} - -// ── Genre Whitelist ── -let _genreWhitelistCache = []; - -function _genreWhitelistRender(genres) { - _genreWhitelistCache = genres && genres.length ? genres : []; - const container = document.getElementById('genre-whitelist-chips'); - const countEl = document.getElementById('genre-whitelist-count'); - if (!container) return; - if (!_genreWhitelistCache.length) { - container.innerHTML = '
    No genres configured. Click "Reset to Defaults" to load the default whitelist.
    '; - if (countEl) countEl.textContent = ''; - return; - } - const searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase(); - const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache; - container.innerHTML = filtered.map(g => - `${escapeHtml(g)}` - ).join(''); - if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`; -} - -function _genreWhitelistRemove(genre) { - _genreWhitelistCache = _genreWhitelistCache.filter(g => g !== genre); - _genreWhitelistRender(_genreWhitelistCache); - if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); -} - -function _genreWhitelistAdd(genre) { - genre = genre.trim(); - if (!genre) return; - if (_genreWhitelistCache.some(g => g.toLowerCase() === genre.toLowerCase())) return; - _genreWhitelistCache.push(genre); - _genreWhitelistCache.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); - _genreWhitelistRender(_genreWhitelistCache); - if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); -} - -async function _genreWhitelistReset() { - try { - const resp = await fetch('/api/genre-whitelist/defaults'); - const data = await resp.json(); - if (data.genres) { - _genreWhitelistCache = data.genres; - _genreWhitelistRender(_genreWhitelistCache); - if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); - showToast(`Loaded ${data.genres.length} default genres`, 'success'); - } - } catch (e) { - showToast('Failed to load defaults', 'error'); - } -} - -// Toggle whitelist container visibility + init -document.addEventListener('change', (e) => { - if (e.target.id === 'genre-whitelist-enabled') { - const container = document.getElementById('genre-whitelist-container'); - if (container) container.style.display = e.target.checked ? '' : 'none'; - // Auto-populate with defaults on first enable if empty - if (e.target.checked && _genreWhitelistCache.length === 0) { - _genreWhitelistReset(); - } - } -}); - -// Search/add handler -document.addEventListener('keydown', (e) => { - if (e.target.id === 'genre-whitelist-search' && e.key === 'Enter') { - e.preventDefault(); - _genreWhitelistAdd(e.target.value); - e.target.value = ''; - } -}); -document.addEventListener('input', (e) => { - if (e.target.id === 'genre-whitelist-search') { - _genreWhitelistRender(_genreWhitelistCache); - } -}); - -function _collectGenreWhitelist() { - return _genreWhitelistCache; -} - -// ── Live Log Viewer ── -let _logViewerActive = false; -let _logViewerFilter = ''; -let _logViewerSource = 'app'; -let _logViewerSearch = ''; -const _LOG_MAX_LINES = 2000; - -function _logClassify(line) { - // Exact logger format first - if (line.includes(' - DEBUG - ')) return 'DEBUG'; - if (line.includes(' - INFO - ')) return 'INFO'; - if (line.includes(' - WARNING - ')) return 'WARNING'; - if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'ERROR'; - // Heuristic for print() output - const ll = line.toLowerCase(); - if (ll.includes('error') || ll.includes('traceback') || ll.includes('exception') || ll.includes('failed')) return 'ERROR'; - if (ll.includes('warning') || ll.includes('warn')) return 'WARNING'; - if (ll.includes('debug')) return 'DEBUG'; - return 'INFO'; -} - -function _logClassToCSS(level) { - return { DEBUG: 'log-debug', INFO: 'log-info', WARNING: 'log-warning', ERROR: 'log-error' }[level] || 'log-plain'; -} - -async function _logViewerInit() { - if (_logViewerActive) return; - _logViewerActive = true; - _logViewerSource = document.getElementById('log-viewer-source')?.value || 'app'; - - // Fetch initial tail - try { - const params = new URLSearchParams({ source: _logViewerSource, lines: 300 }); - if (_logViewerFilter) params.set('level', _logViewerFilter); - if (_logViewerSearch) params.set('search', _logViewerSearch); - const resp = await fetch(`/api/logs/tail?${params}`); - const data = await resp.json(); - if (data.lines) { - const container = document.getElementById('log-viewer-lines'); - if (container) { - container.innerHTML = ''; - _logViewerAppendLines(data.lines); - } - } - } catch (e) { - console.warn('Failed to load initial logs:', e); - } - - // Subscribe to live updates - if (typeof socket !== 'undefined' && socket && socket.connected) { - socket.emit('logs:subscribe', { source: _logViewerSource }); - socket.on('logs:live', _logViewerOnLive); - } -} - -function _logViewerStop() { - if (!_logViewerActive) return; - _logViewerActive = false; - if (typeof socket !== 'undefined' && socket) { - socket.off('logs:live', _logViewerOnLive); - socket.emit('logs:unsubscribe', {}); - } -} - -function _logViewerOnLive(data) { - if (!_logViewerActive || !data.lines) return; - if (data.source !== _logViewerSource) return; - let lines = data.lines; - // Apply level filter client-side for live lines - if (_logViewerFilter) { - lines = lines.filter(l => _logClassify(l) === _logViewerFilter); - } - // Apply search filter - if (_logViewerSearch) { - const s = _logViewerSearch.toLowerCase(); - lines = lines.filter(l => l.toLowerCase().includes(s)); - } - if (lines.length > 0) _logViewerAppendLines(lines); -} - -function _logViewerAppendLines(lines) { - const container = document.getElementById('log-viewer-lines'); - if (!container) return; - const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked; - const terminal = document.getElementById('log-viewer-terminal'); - - const frag = document.createDocumentFragment(); - for (const line of lines) { - const div = document.createElement('div'); - div.className = 'log-line ' + _logClassToCSS(_logClassify(line)); - div.textContent = line; - frag.appendChild(div); - } - container.appendChild(frag); - - // Trim old lines - while (container.children.length > _LOG_MAX_LINES) { - container.removeChild(container.firstChild); - } - - // Update count - const countEl = document.getElementById('log-viewer-line-count'); - if (countEl) countEl.textContent = `${container.children.length} lines`; - - // Auto-scroll - if (autoScroll && terminal) { - terminal.scrollTop = terminal.scrollHeight; - } -} - -async function _logViewerChangeSource() { - _logViewerStop(); - _logViewerSource = document.getElementById('log-viewer-source')?.value || 'app'; - const container = document.getElementById('log-viewer-lines'); - if (container) container.innerHTML = '
    Loading...
    '; - await _logViewerInit(); -} - -function _logViewerFilterLevel(btn) { - document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - _logViewerFilter = btn.dataset.level || ''; - _logViewerReload(); -} - -let _logSearchDebounce = null; -function _logViewerOnSearch(input) { - clearTimeout(_logSearchDebounce); - _logSearchDebounce = setTimeout(() => { - _logViewerSearch = (input.value || '').trim(); - _logViewerReload(); - }, 300); -} - -function _logViewerReload() { - _logViewerStop(); - const container = document.getElementById('log-viewer-lines'); - if (container) container.innerHTML = '
    Loading...
    '; - _logViewerInit(); -} - -function _logViewerCopy() { - const container = document.getElementById('log-viewer-lines'); - if (!container) return; - const text = Array.from(container.children).map(el => el.textContent).join('\n'); - if (navigator.clipboard && window.isSecureContext) { - navigator.clipboard.writeText(text).then(() => showToast('Logs copied', 'success')); - } else { - const ta = document.createElement('textarea'); - ta.value = text; - ta.style.cssText = 'position:fixed;left:-9999px'; - document.body.appendChild(ta); - ta.select(); - document.execCommand('copy'); - document.body.removeChild(ta); - showToast('Logs copied', 'success'); - } -} - -function _logViewerClear() { - const container = document.getElementById('log-viewer-lines'); - if (container) container.innerHTML = ''; - const countEl = document.getElementById('log-viewer-line-count'); - if (countEl) countEl.textContent = '0 lines'; -} - -// ── Database Maintenance ── -async function loadDbMaintenanceInfo() { - try { - const resp = await fetch('/api/database/maintenance/info'); - const data = await resp.json(); - if (!data.success) return; - const sizeEl = document.getElementById('db-size-display'); - const freeEl = document.getElementById('db-freepages-display'); - const vacEl = document.getElementById('db-autovacuum-display'); - if (sizeEl) sizeEl.textContent = data.total_size_display; - if (freeEl) freeEl.textContent = data.free_pages > 0 - ? `${data.free_pages.toLocaleString()} (${data.free_size_display} reclaimable)` - : 'None — database is fully compacted'; - if (vacEl) vacEl.textContent = data.auto_vacuum_label; - // Hide enable button if already incremental - const incBtn = document.getElementById('db-incvacuum-btn'); - if (incBtn && data.auto_vacuum === 2) { - incBtn.textContent = 'Incremental Vacuum Enabled'; - incBtn.disabled = true; - incBtn.style.opacity = '0.5'; - } - } catch (e) { console.error('Error loading DB maintenance info:', e); } -} - -async function runDatabaseVacuum() { - const btn = document.getElementById('db-vacuum-btn'); - const status = document.getElementById('db-vacuum-status'); - if (!confirm('This will compact the database by rewriting it. The database will be locked during this operation. For large databases this may take over a minute. Continue?')) return; - btn.disabled = true; - btn.textContent = 'Compacting...'; - if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Running VACUUM — this may take a while...'; } - try { - const resp = await fetch('/api/database/maintenance/vacuum', { method: 'POST' }); - const data = await resp.json(); - if (data.success) { - showToast(`Database compacted in ${data.elapsed_seconds}s — saved ${data.saved_display}`, 'success'); - if (status) { status.style.color = '#4caf50'; status.textContent = `Done in ${data.elapsed_seconds}s. Saved ${data.saved_display}.`; } - loadDbMaintenanceInfo(); - } else { - showToast('Vacuum failed: ' + (data.error || 'Unknown error'), 'error'); - if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); } - } - } catch (e) { - showToast('Vacuum failed: ' + e.message, 'error'); - if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; } - } finally { - btn.disabled = false; - btn.textContent = 'Compact Database (VACUUM)'; - } -} - -async function enableIncrementalVacuum() { - const btn = document.getElementById('db-incvacuum-btn'); - const status = document.getElementById('db-vacuum-status'); - if (!confirm('This will enable incremental vacuum mode. It requires a one-time full VACUUM to activate, which locks the database and may take over a minute on large databases. Continue?')) return; - btn.disabled = true; - btn.textContent = 'Enabling...'; - if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Enabling incremental vacuum — this may take a while...'; } - try { - const resp = await fetch('/api/database/maintenance/enable-incremental-vacuum', { method: 'POST' }); - const data = await resp.json(); - if (data.success) { - const msg = data.already_enabled ? 'Already enabled' : `Enabled in ${data.elapsed_seconds}s — saved ${data.saved_display}`; - showToast(msg, 'success'); - if (status) { status.style.color = '#4caf50'; status.textContent = msg; } - loadDbMaintenanceInfo(); - } else { - showToast('Failed: ' + (data.error || 'Unknown error'), 'error'); - if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); } - } - } catch (e) { - showToast('Failed: ' + e.message, 'error'); - if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; } - } finally { - btn.disabled = false; - btn.textContent = 'Enable Incremental Vacuum'; - } -} - -async function activateDevMode() { - const password = document.getElementById('dev-mode-password').value; - try { - const response = await fetch('/api/dev-mode', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ password }) - }); - const data = await response.json(); - if (data.success) { - document.getElementById('dev-mode-status').textContent = 'Active'; - document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))'; - document.getElementById('hydrabase-nav').style.display = ''; - document.getElementById('hydrabase-button-container').style.display = ''; - document.getElementById('dev-mode-password').value = ''; - showToast('Dev mode activated', 'success'); - } else { - showToast('Invalid password', 'error'); - } - } catch (e) { - showToast('Failed to activate dev mode', 'error'); - } -} - -// ── Hydrabase Functions ── - -let _hydrabaseConnected = false; - -async function hydrabaseToggleConnection() { - if (_hydrabaseConnected) { - await hydrabaseDisconnect(); - } else { - await hydrabaseConnect(); - } -} - -async function hydrabaseConnect() { - const url = document.getElementById('hydra-ws-url').value.trim(); - const apiKey = document.getElementById('hydra-api-key').value.trim(); - if (!url || !apiKey) { - showToast('URL and API key required', 'error'); - return; - } - const statusEl = document.getElementById('hydra-connection-status'); - const btn = document.getElementById('hydra-connect-btn'); - statusEl.textContent = 'Connecting...'; - statusEl.style.color = '#f0ad4e'; - try { - const response = await fetch('/api/hydrabase/connect', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url, api_key: apiKey }) - }); - const data = await response.json(); - if (data.success) { - _hydrabaseConnected = true; - statusEl.textContent = 'Connected'; - statusEl.style.color = 'rgb(var(--accent-light-rgb))'; - btn.textContent = 'Disconnect'; - showToast('Connected to Hydrabase', 'success'); - } else { - statusEl.textContent = 'Failed'; - statusEl.style.color = '#f44336'; - showToast(data.error || 'Connection failed', 'error'); - } - } catch (e) { - statusEl.textContent = 'Error'; - statusEl.style.color = '#f44336'; - showToast('Connection error', 'error'); - } -} - -async function hydrabaseDisconnect() { - try { - await fetch('/api/hydrabase/disconnect', { method: 'POST' }); - } catch (e) { } - _hydrabaseConnected = false; - document.getElementById('hydra-connection-status').textContent = 'Disconnected'; - document.getElementById('hydra-connection-status').style.color = '#888'; - document.getElementById('hydra-connect-btn').textContent = 'Connect'; - // Dev mode is disabled on disconnect — hide Hydrabase nav and update settings status - document.getElementById('hydrabase-nav').style.display = 'none'; - document.getElementById('hydrabase-button-container').style.display = 'none'; - const devStatus = document.getElementById('dev-mode-status'); - if (devStatus) { - devStatus.textContent = 'Inactive'; - devStatus.style.color = '#888'; - } - showToast('Disconnected — dev mode disabled', 'success'); - navigateToPage('settings'); -} - -async function loadHydrabaseComparisons() { - const container = document.getElementById('hydra-comparisons-container'); - if (!container) return; - try { - const response = await fetch('/api/hydrabase/comparisons'); - const data = await response.json(); - if (!data.success || !data.comparisons?.length) { - container.innerHTML = '

    No comparisons yet. Search with Hydrabase active to generate comparisons.

    '; - return; - } - let html = ''; - for (const comp of data.comparisons) { - const time = new Date(comp.timestamp * 1000).toLocaleTimeString(); - html += `
    -
    - "${comp.query}" - ${time} -
    -
    -
    -
    Hydrabase
    -
    ${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al
    -
    -
    -
    Spotify
    -
    ${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al
    -
    -
    -
    ${comp.fallback_source === 'deezer' ? 'Deezer' : 'iTunes'}
    -
    ${(comp.fallback || comp.itunes)?.tracks || 0}T / ${(comp.fallback || comp.itunes)?.artists || 0}A / ${(comp.fallback || comp.itunes)?.albums || 0}Al
    -
    -
    -
    `; - } - container.innerHTML = html; - } catch (e) { - container.innerHTML = '

    Failed to load comparisons.

    '; - } -} - -async function hydrabaseSendRaw(textareaId) { - const textarea = document.getElementById(textareaId); - const raw = textarea.value.trim(); - if (!raw) { - showToast('Payload is empty', 'error'); - return; - } - if (!_hydrabaseConnected) { - showToast('Not connected to Hydrabase', 'error'); - return; - } - let payload; - try { - payload = JSON.parse(raw); - } catch (e) { - showToast('Invalid JSON payload', 'error'); - return; - } - // Auto-inject a fresh nonce if not set or zero - if (!payload.nonce) { - payload.nonce = Date.now(); - } - const responseArea = document.getElementById('hydra-response'); - responseArea.textContent = 'Sending...'; - try { - const response = await fetch('/api/hydrabase/send', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ payload }) - }); - const data = await response.json(); - if (data.success) { - responseArea.textContent = JSON.stringify(data.data, null, 2); - } else { - responseArea.textContent = 'Error: ' + (data.error || 'Unknown error'); - if (data.error && data.error.includes('Not connected')) { - _hydrabaseConnected = false; - document.getElementById('hydra-connection-status').textContent = 'Disconnected'; - document.getElementById('hydra-connection-status').style.color = '#888'; - document.getElementById('hydra-connect-btn').textContent = 'Connect'; - } - } - } catch (e) { - responseArea.textContent = 'Error: ' + e.message; - } -} - -// ── Tag embedding accordion helpers ── -function toggleTagGroup(header) { - const body = header.nextElementSibling; - const arrow = header.querySelector('.tag-group-arrow'); - if (body.style.display === 'none') { - body.style.display = 'block'; - arrow.classList.add('open'); - } else { - body.style.display = 'none'; - arrow.classList.remove('open'); - } -} - -function toggleServiceTags(masterCheckbox, serviceName) { - const group = masterCheckbox.closest('.tag-service-group'); - if (!group) return; - const body = group.querySelector('.tag-service-body'); - if (!body) return; - const childCheckboxes = body.querySelectorAll('input[type="checkbox"]'); - childCheckboxes.forEach(cb => { - const label = cb.closest('.checkbox-label'); - if (masterCheckbox.checked) { - if (label) label.classList.remove('disabled-tag'); - cb.disabled = false; - } else { - if (label) label.classList.add('disabled-tag'); - cb.disabled = true; - } - }); -} - -function _collectServiceTags(serviceName) { - const tags = {}; - document.querySelectorAll(`[data-config^="${serviceName}.tags."]`).forEach(cb => { - const key = cb.dataset.config.split('.').pop(); - tags[key] = cb.checked; - }); - return tags; -} - -function _getTagConfig(path) { - const el = document.querySelector(`[data-config="${path}"]`); - return el ? el.checked : true; -} - -async function saveSettings(quiet = false) { - // Validate file organization templates before saving - const validationErrors = validateFileOrganizationTemplates(); - if (validationErrors.length > 0) { - if (!quiet) showToast('Template validation failed: ' + validationErrors.join(', '), 'error'); - return; - } - - // Determine active server from toggle buttons - let activeServer = 'plex'; - if (document.getElementById('jellyfin-toggle').classList.contains('active')) { - activeServer = 'jellyfin'; - } else if (document.getElementById('navidrome-toggle').classList.contains('active')) { - activeServer = 'navidrome'; - } else if (document.getElementById('soulsync-toggle')?.classList.contains('active')) { - activeServer = 'soulsync'; - } - - const settings = { - active_media_server: activeServer, - spotify: { - client_id: document.getElementById('spotify-client-id').value, - client_secret: document.getElementById('spotify-client-secret').value, - redirect_uri: document.getElementById('spotify-redirect-uri').value, - embed_tags: document.getElementById('embed-spotify').checked, - tags: _collectServiceTags('spotify') - }, - tidal: { - client_id: document.getElementById('tidal-client-id').value, - client_secret: document.getElementById('tidal-client-secret').value, - redirect_uri: document.getElementById('tidal-redirect-uri').value, - embed_tags: document.getElementById('embed-tidal').checked, - tags: _collectServiceTags('tidal') - }, - plex: { - base_url: document.getElementById('plex-url').value, - token: document.getElementById('plex-token').value - }, - jellyfin: { - base_url: document.getElementById('jellyfin-url').value, - api_key: document.getElementById('jellyfin-api-key').value, - api_timeout: parseInt(document.getElementById('jellyfin-timeout').value) || 30 - }, - navidrome: { - base_url: document.getElementById('navidrome-url').value, - username: document.getElementById('navidrome-username').value, - password: document.getElementById('navidrome-password').value - }, - soulseek: { - slskd_url: document.getElementById('soulseek-url').value, - api_key: document.getElementById('soulseek-api-key').value, - download_path: document.getElementById('download-path').value, - transfer_path: document.getElementById('transfer-path').value, - search_timeout: parseInt(document.getElementById('soulseek-search-timeout').value) || 60, - search_timeout_buffer: parseInt(document.getElementById('soulseek-search-timeout-buffer').value) || 15, - min_peer_upload_speed: parseInt(document.getElementById('soulseek-min-peer-speed').value) || 0, - max_peer_queue: parseInt(document.getElementById('soulseek-max-peer-queue').value) || 0, - download_timeout: (parseInt(document.getElementById('soulseek-download-timeout').value) || 10) * 60, - auto_clear_searches: document.getElementById('soulseek-auto-clear-searches').checked - }, - listenbrainz: { - base_url: document.getElementById('listenbrainz-base-url').value, - token: document.getElementById('listenbrainz-token').value, - scrobble_enabled: document.getElementById('listenbrainz-scrobble-enabled').checked, - }, - acoustid: { - api_key: document.getElementById('acoustid-api-key').value, - enabled: document.getElementById('acoustid-enabled').checked - }, - lastfm: { - api_key: document.getElementById('lastfm-api-key').value, - api_secret: document.getElementById('lastfm-api-secret').value, - scrobble_enabled: document.getElementById('lastfm-scrobble-enabled').checked, - embed_tags: document.getElementById('embed-lastfm').checked, - tags: _collectServiceTags('lastfm') - }, - genius: { - access_token: document.getElementById('genius-access-token').value, - embed_tags: document.getElementById('embed-genius').checked, - tags: _collectServiceTags('genius') - }, - itunes: { - country: document.getElementById('itunes-country').value || 'US', - embed_tags: document.getElementById('embed-itunes').checked, - tags: _collectServiceTags('itunes') - }, - discogs: { - token: document.getElementById('discogs-token').value, - }, - metadata: { - fallback_source: document.getElementById('metadata-fallback-source').value || 'itunes' - }, - hydrabase: { - url: document.getElementById('hydrabase-url').value, - api_key: document.getElementById('hydrabase-api-key').value, - auto_connect: document.getElementById('hydrabase-auto-connect').checked - }, - download_source: { - mode: document.getElementById('download-source-mode').value, - hybrid_primary: document.getElementById('hybrid-primary-source').value, - hybrid_secondary: document.getElementById('hybrid-secondary-source').value, - hybrid_order: getHybridOrder(), - stream_source: document.getElementById('stream-source').value, - max_concurrent: parseInt(document.getElementById('max-concurrent-downloads').value) || 3, - }, - tidal_download: { - quality: document.getElementById('tidal-download-quality').value || 'lossless', - allow_fallback: document.getElementById('tidal-allow-fallback').checked, - }, - hifi_download: { - quality: document.getElementById('hifi-download-quality').value || 'lossless', - allow_fallback: document.getElementById('hifi-allow-fallback').checked, - }, - deezer_download: { - quality: document.getElementById('deezer-download-quality').value || 'flac', - arl: document.getElementById('deezer-download-arl').value || '', - allow_fallback: document.getElementById('deezer-allow-fallback').checked, - }, - lidarr_download: { - url: document.getElementById('lidarr-url').value || '', - api_key: document.getElementById('lidarr-api-key').value || '', - }, - qobuz: { - quality: document.getElementById('qobuz-quality').value || 'lossless', - embed_tags: document.getElementById('embed-qobuz').checked, - tags: _collectServiceTags('qobuz'), - allow_fallback: document.getElementById('qobuz-allow-fallback').checked, - }, - database: { - max_workers: parseInt(document.getElementById('max-workers').value) - }, - metadata_enhancement: { - enabled: document.getElementById('metadata-enabled').checked, - embed_album_art: document.getElementById('embed-album-art').checked, - cover_art_download: document.getElementById('cover-art-download').checked, - prefer_caa_art: document.getElementById('prefer-caa-art').checked, - lrclib_enabled: document.getElementById('lrclib-enabled').checked, - tags: { - quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'), - genre_merge: _getTagConfig('metadata_enhancement.tags.genre_merge'), - artist_separator: document.getElementById('artist-separator').value, - write_multi_artist: document.getElementById('write-multi-artist').checked, - feat_in_title: document.getElementById('feat-in-title').checked - } - }, - musicbrainz: { - embed_tags: document.getElementById('embed-musicbrainz').checked, - tags: _collectServiceTags('musicbrainz') - }, - deezer: { - app_id: document.getElementById('deezer-app-id').value, - app_secret: document.getElementById('deezer-app-secret').value, - redirect_uri: document.getElementById('deezer-redirect-uri').value, - embed_tags: document.getElementById('embed-deezer').checked, - tags: _collectServiceTags('deezer') - }, - audiodb: { - embed_tags: document.getElementById('embed-audiodb').checked, - tags: _collectServiceTags('audiodb') - }, - file_organization: { - enabled: document.getElementById('file-organization-enabled').checked, - disc_label: document.getElementById('disc-label').value, - collab_artist_mode: document.getElementById('collab-artist-mode').value, - templates: { - album_path: document.getElementById('template-album-path').value, - single_path: document.getElementById('template-single-path').value, - playlist_path: document.getElementById('template-playlist-path').value, - video_path: document.getElementById('template-video-path').value - } - }, - wishlist: { - allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked - }, - playlist_sync: { - create_backup: document.getElementById('create-backup').checked - }, - content_filter: { - allow_explicit: document.getElementById('allow-explicit').checked - }, - genre_whitelist: { - enabled: document.getElementById('genre-whitelist-enabled').checked, - genres: _collectGenreWhitelist(), - }, - post_processing: { - replaygain_enabled: document.getElementById('replaygain-enabled').checked, - }, - library: { - music_paths: collectMusicPaths(), - music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos' - }, - import: { - replace_lower_quality: document.getElementById('import-replace-lower-quality').checked, - staging_path: document.getElementById('staging-path').value || './Staging' - }, - lossy_copy: { - enabled: document.getElementById('lossy-copy-enabled').checked, - codec: document.getElementById('lossy-copy-codec').value, - bitrate: document.getElementById('lossy-copy-bitrate').value, - delete_original: document.getElementById('lossy-copy-delete-original').checked, - downsample_hires: document.getElementById('downsample-hires').checked - }, - listening_stats: { - enabled: document.getElementById('listening-stats-enabled').checked, - poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30, - }, - m3u_export: { - enabled: document.getElementById('m3u-export-enabled').checked, - entry_base_path: document.getElementById('m3u-entry-base-path').value || '' - }, - ui_appearance: { - accent_preset: document.getElementById('accent-preset')?.value || '#1db954', - accent_color: document.getElementById('accent-custom-color')?.value || '#1db954', - sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars', - particles_enabled: document.getElementById('particles-enabled')?.checked !== false, - worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false, - reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true - }, - youtube: { - cookies_browser: document.getElementById('youtube-cookies-browser').value, - download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3, - }, - security: { - require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, - } - }; - - try { - if (!quiet) showLoadingOverlay('Saving settings...'); - - // Save main settings - const response = await fetch(API.settings, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings) - }); - - const result = await response.json(); - - // Save quality profile - const qualityProfileSaved = await saveQualityProfile(); - - // Save discovery lookback period - let lookbackSaved = true; - try { - const lookbackPeriod = document.getElementById('discovery-lookback-period').value; - const lookbackResponse = await fetch('/api/discovery/lookback-period', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ period: lookbackPeriod }) - }); - const lookbackResult = await lookbackResponse.json(); - lookbackSaved = lookbackResult.success === true; - } catch (error) { - console.error('Error saving discovery lookback period:', error); - lookbackSaved = false; - } - - // Save hemisphere setting - try { - const hemisphere = document.getElementById('discovery-hemisphere').value; - await fetch('/api/discovery/hemisphere', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ hemisphere }) - }); - } catch (error) { - console.error('Error saving hemisphere setting:', error); - } - - if (result.success && qualityProfileSaved && lookbackSaved) { - showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success'); - _forceServiceStatusRefresh(); - } else if (result.success && qualityProfileSaved && !lookbackSaved) { - showToast('Settings saved, but discovery lookback period failed to save', 'warning'); - _forceServiceStatusRefresh(); - } else if (result.success && !qualityProfileSaved) { - showToast('Settings saved, but quality profile failed to save', 'warning'); - _forceServiceStatusRefresh(); - } else { - showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services'); - } - } catch (error) { - console.error('Error saving settings:', error); - showToast('Failed to save settings', 'error', 'set-services'); - } finally { - if (!quiet) hideLoadingOverlay(); - } -} - -async function authorizeLastfmScrobbling() { - try { - // Save settings first so API secret is stored - await saveSettings(); - const resp = await fetch('/api/lastfm/auth-url'); - const data = await resp.json(); - if (data.success && data.url) { - window.open(data.url, '_blank', 'width=600,height=500'); - showToast('Authorize SoulSync in the Last.fm window that opened', 'info'); - } else { - showToast(data.error || 'Could not generate auth URL', 'error'); - } - } catch (e) { - showToast('Failed to start Last.fm authorization', 'error'); - } -} - -async function testConnection(service) { - try { - showLoadingOverlay(`Testing ${service} connection...`); - - const response = await fetch(API.testConnection, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service }) - }); - - const result = await response.json(); - - if (result.success) { - // Use backend's message which contains dynamic source name - showToast(result.message || `${service} connection successful`, 'success'); - - // Load music libraries after successful connection - if (service === 'plex') { - loadPlexMusicLibraries(); - } else if (service === 'jellyfin') { - loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); - } else if (service === 'navidrome') { - loadNavidromeMusicFolders(); - } - } else { - showToast(`${service} connection failed: ${result.error}`, 'error', 'gs-connecting'); - } - } catch (error) { - console.error(`Error testing ${service} connection:`, error); - showToast(`Failed to test ${service} connection`, 'error', 'gs-connecting'); - } finally { - hideLoadingOverlay(); - } -} - -async function clearQuarantine() { - if (!await showConfirmDialog({ title: 'Clear Quarantine', message: 'Delete all files in the quarantine folder? This cannot be undone.', confirmText: 'Delete', destructive: true })) return; - try { - showLoadingOverlay('Clearing quarantine folder...'); - const response = await fetch('/api/quarantine/clear', { method: 'POST' }); - const result = await response.json(); - if (result.success) { - showToast(result.message || 'Quarantine cleared', 'success'); - } else { - showToast(`Failed to clear quarantine: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error clearing quarantine:', error); - showToast('Failed to clear quarantine', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -// ======================== API Key Management ======================== - -async function loadApiKeys() { - const container = document.getElementById('api-keys-list'); - if (!container) return; - - try { - const response = await fetch('/api/v1/api-keys-internal'); - if (response.ok) { - const data = await response.json(); - renderApiKeys(data.data?.keys || []); - } else { - container.innerHTML = '
    No API keys configured.
    '; - } - } catch (e) { - container.innerHTML = '
    No API keys configured.
    '; - } -} - -function renderApiKeys(keys) { - const container = document.getElementById('api-keys-list'); - if (!container) return; - - if (!keys || keys.length === 0) { - container.innerHTML = '
    No API keys yet. Generate one below.
    '; - return; - } - - container.innerHTML = keys.map(k => ` -
    -
    -
    ${k.label || 'Unnamed'}
    -
    - ${k.key_prefix || 'sk_...'}... - · Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'} - ${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''} -
    -
    - -
    - `).join(''); -} - -async function generateApiKey() { - const labelInput = document.getElementById('api-key-label'); - const label = labelInput ? labelInput.value.trim() : ''; - - try { - const response = await fetch('/api/v1/api-keys-internal/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ label: label || 'Default' }) - }); - const data = await response.json(); - - if (data.success && data.data?.key) { - const keyDisplay = document.getElementById('api-key-generated'); - const keyValue = document.getElementById('api-key-value'); - if (keyDisplay && keyValue) { - keyValue.textContent = data.data.key; - keyDisplay.style.display = 'block'; - } - if (labelInput) labelInput.value = ''; - showToast('API key generated! Copy it now.', 'success'); - loadApiKeys(); - } else { - showToast(data.error?.message || 'Failed to generate API key', 'error'); - } - } catch (error) { - console.error('Error generating API key:', error); - showToast('Failed to generate API key', 'error'); - } -} - -function copyApiKey() { - const keyValue = document.getElementById('api-key-value'); - if (keyValue) { - navigator.clipboard.writeText(keyValue.textContent).then(() => { - showToast('API key copied to clipboard', 'success'); - }).catch(() => { - // Fallback for older browsers - const range = document.createRange(); - range.selectNode(keyValue); - window.getSelection().removeAllRanges(); - window.getSelection().addRange(range); - document.execCommand('copy'); - showToast('API key copied', 'success'); - }); - } -} - -async function revokeApiKey(keyId, label) { - if (!await showConfirmDialog({ title: 'Revoke API Key', message: `Revoke API key "${label}"? Any apps using this key will stop working.`, confirmText: 'Revoke', destructive: true })) return; - - try { - const response = await fetch(`/api/v1/api-keys-internal/revoke/${keyId}`, { method: 'DELETE' }); - const data = await response.json(); - if (data.success) { - showToast('API key revoked', 'success'); - loadApiKeys(); - } else { - showToast(data.error?.message || 'Failed to revoke key', 'error'); - } - } catch (error) { - console.error('Error revoking API key:', error); - showToast('Failed to revoke key', 'error'); - } -} - -// Dashboard-specific test functions that create activity items -async function testDashboardConnection(service) { - try { - showLoadingOverlay(`Testing ${service} service...`); - - const response = await fetch(API.testDashboardConnection, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service }) - }); - - const result = await response.json(); - - if (result.success) { - // Use backend's message which contains dynamic source name - showToast(result.message || `${service} service verified`, 'success'); - // Refresh status indicators immediately so UI reflects the new state - fetchAndUpdateServiceStatus(); - } else { - showToast(`${service} service check failed: ${result.error}`, 'error'); - } - } catch (error) { - console.error(`Error testing ${service} service:`, error); - showToast(`Failed to test ${service} service`, 'error'); - } finally { - hideLoadingOverlay(); - } -} - -// Individual Auto-detect functions - same as GUI -async function autoDetectPlex() { - try { - showLoadingOverlay('Auto-detecting Plex server...'); - - const response = await fetch('/api/detect-media-server', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ server_type: 'plex' }) - }); - - const result = await response.json(); - - if (result.success) { - document.getElementById('plex-url').value = result.found_url; - showToast(`Plex server detected: ${result.found_url}`, 'success'); - } else { - showToast(result.error, 'error'); - } - - } catch (error) { - console.error('Error auto-detecting Plex:', error); - showToast('Failed to auto-detect Plex server', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -async function autoDetectJellyfin() { - try { - showLoadingOverlay('Auto-detecting Jellyfin server...'); - - const response = await fetch('/api/detect-media-server', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ server_type: 'jellyfin' }) - }); - - const result = await response.json(); - - if (result.success) { - document.getElementById('jellyfin-url').value = result.found_url; - showToast(`Jellyfin server detected: ${result.found_url}`, 'success'); - } else { - showToast(result.error, 'error'); - } - - } catch (error) { - console.error('Error auto-detecting Jellyfin:', error); - showToast('Failed to auto-detect Jellyfin server', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -async function autoDetectNavidrome() { - try { - showLoadingOverlay('Auto-detecting Navidrome server...'); - - const response = await fetch('/api/detect-media-server', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ server_type: 'navidrome' }) - }); - - const result = await response.json(); - - if (result.success) { - document.getElementById('navidrome-url').value = result.found_url; - showToast(`Navidrome server detected: ${result.found_url}`, 'success'); - } else { - showToast(result.error, 'error'); - } - - } catch (error) { - console.error('Error auto-detecting Navidrome:', error); - showToast('Failed to auto-detect Navidrome server', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -async function autoDetectSlskd() { - try { - showLoadingOverlay('Auto-detecting Soulseek (slskd) server...'); - - const response = await fetch('/api/detect-soulseek', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const result = await response.json(); - - if (result.success) { - document.getElementById('soulseek-url').value = result.found_url; - showToast(`Soulseek server detected: ${result.found_url}`, 'success'); - } else { - showToast(result.error, 'error'); - } - - } catch (error) { - console.error('Error auto-detecting Soulseek:', error); - showToast('Failed to auto-detect Soulseek server', 'error'); - } finally { - hideLoadingOverlay(); - } -} - - -function cancelDetection(service) { - const progressDiv = document.getElementById(`${service}-detection-progress`); - progressDiv.classList.add('hidden'); - showToast(`${service} detection cancelled`, 'error'); -} - -function updateStatusDisplays() { - // Update status displays based on current service status - // This would be called after status updates - const services = ['spotify', 'media-server', 'soulseek']; - services.forEach(service => { - const display = document.getElementById(`${service}-status-display`); - if (display) { - // Status will be updated by the regular status monitoring - } - }); -} - -async function authenticateSpotify() { - try { - showLoadingOverlay('Saving credentials and starting Spotify authentication...'); - // Save settings first to ensure client_id/client_secret are persisted - await saveSettings(); - showToast('Spotify authentication started', 'success'); - window.open('/auth/spotify', '_blank'); - } catch (error) { - console.error('Error authenticating Spotify:', error); - showToast('Failed to start Spotify authentication', 'error', 'gs-connecting'); - } finally { - hideLoadingOverlay(); - } -} - -async function disconnectSpotify() { - const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source'; - if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: `Disconnect Spotify? The app will switch to ${fallbackName} for metadata.` })) { - return; - } - try { - showLoadingOverlay('Disconnecting Spotify...'); - const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); - const data = await response.json(); - if (data.success) { - showToast(`Spotify disconnected. Now using ${fallbackName}.`, 'success'); - // Immediately refresh status to update UI - await fetchAndUpdateServiceStatus(); - } else { - showToast(`Failed to disconnect: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error disconnecting Spotify:', error); - showToast('Failed to disconnect Spotify', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -async function clearSpotifyCacheAndFallback() { - const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source'; - if (!await showConfirmDialog({ - title: 'Clear Spotify Cache', - message: `This will clear the Spotify token cache and switch metadata to ${fallbackName}. You can re-authenticate later.` - })) return; - try { - showLoadingOverlay('Clearing Spotify cache...'); - const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); - const data = await response.json(); - if (data.success) { - showToast(data.message || `Switched to ${fallbackName}`, 'success'); - await fetchAndUpdateServiceStatus(); - } else { - showToast(`Failed: ${data.error}`, 'error'); - } - } catch (error) { - showToast('Failed to clear Spotify cache', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -// ── Spotify Rate Limit Handling ─────────────────────────────────────────── -let _spotifyRateLimitShown = false; -let _spotifyInCooldown = false; -let _rateLimitModalOpen = false; -let _rateLimitCountdownInterval = null; -let _rateLimitExpiresAt = 0; - -function handleSpotifyRateLimit(rateLimitInfo) { - if (!rateLimitInfo || !rateLimitInfo.active) { - if (_spotifyRateLimitShown) { - _spotifyRateLimitShown = false; - closeRateLimitModal(); - showToast('Spotify access restored', 'success'); - // Refresh discover page if user is on it — data source switched back to Spotify - if (currentPage === 'discover') { - console.log('Spotify restored — refreshing discover page data'); - loadDiscoverPage(); - } - } - return; - } - // Update countdown if modal is open (status pushes every 10s keep it accurate) - if (_rateLimitModalOpen && rateLimitInfo.remaining_seconds) { - _rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000); - } - if (!_spotifyRateLimitShown) { - _spotifyRateLimitShown = true; - _spotifyInCooldown = false; - showRateLimitModal(rateLimitInfo); - // Refresh discover page if user is on it — data source switched to iTunes - if (currentPage === 'discover') { - console.log('Spotify rate limited — refreshing discover page with iTunes data'); - loadDiscoverPage(); - } - } -} - -function showRateLimitModal(rateLimitInfo) { - const overlay = document.getElementById('rate-limit-modal-overlay'); - if (!overlay) return; - - // Populate details - const banDuration = document.getElementById('rate-limit-ban-duration'); - const endpoint = document.getElementById('rate-limit-endpoint'); - const countdown = document.getElementById('rate-limit-countdown'); - - banDuration.textContent = formatRateLimitDuration(rateLimitInfo.retry_after || rateLimitInfo.remaining_seconds); - endpoint.textContent = rateLimitInfo.endpoint || 'unknown'; - countdown.textContent = formatRateLimitDuration(rateLimitInfo.remaining_seconds); - - // Set expiry for live countdown - _rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000); - - // Start live countdown timer - if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval); - _rateLimitCountdownInterval = setInterval(() => { - const remaining = Math.max(0, Math.round((_rateLimitExpiresAt - Date.now()) / 1000)); - countdown.textContent = formatRateLimitDuration(remaining); - if (remaining <= 0) { - clearInterval(_rateLimitCountdownInterval); - _rateLimitCountdownInterval = null; - } - }, 1000); - - overlay.classList.remove('hidden'); - _rateLimitModalOpen = true; -} - -function closeRateLimitModal() { - const overlay = document.getElementById('rate-limit-modal-overlay'); - if (overlay) overlay.classList.add('hidden'); - if (_rateLimitCountdownInterval) { - clearInterval(_rateLimitCountdownInterval); - _rateLimitCountdownInterval = null; - } - _rateLimitModalOpen = false; -} - -async function disconnectSpotifyFromRateLimit() { - closeRateLimitModal(); - try { - showLoadingOverlay('Disconnecting Spotify...'); - const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); - const data = await response.json(); - if (data.success) { - _spotifyRateLimitShown = false; - showToast(`Spotify disconnected. Now using ${currentMusicSourceName}.`, 'success'); - await fetchAndUpdateServiceStatus(); - if (currentPage === 'discover') { - loadDiscoverPage(); - } - } else { - showToast(`Failed to disconnect: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error disconnecting Spotify:', error); - showToast('Failed to disconnect Spotify', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -function formatRateLimitDuration(seconds) { - if (!seconds || seconds <= 0) return '0s'; - const h = Math.floor(seconds / 3600); - const m = Math.floor((seconds % 3600) / 60); - const s = seconds % 60; - if (h > 0) return `${h}h ${m}m`; - if (m > 0) return `${m}m ${s}s`; - return `${s}s`; -} - -async function authenticateTidal() { - try { - showLoadingOverlay('Saving credentials and starting Tidal authentication...'); - // Save settings first to ensure credentials are persisted - await saveSettings(); - showToast('Tidal authentication started', 'success'); - window.open('/auth/tidal', '_blank'); - } catch (error) { - console.error('Error authenticating Tidal:', error); - showToast('Failed to start Tidal authentication', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -async function authenticateDeezer() { - try { - showLoadingOverlay('Saving credentials and starting Deezer authentication...'); - await saveSettings(); - showToast('Deezer authentication started', 'success'); - window.open('/auth/deezer', '_blank'); - } catch (error) { - console.error('Error authenticating Deezer:', error); - showToast('Failed to start Deezer authentication', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -// ===== Tidal Download Auth (Device Flow) ===== - -async function testHiFiConnection() { - const statusEl = document.getElementById('hifi-connection-status'); - const btn = document.getElementById('hifi-test-btn'); - if (!statusEl) return; - statusEl.textContent = 'Checking...'; - statusEl.style.color = '#aaa'; - try { - const resp = await fetch('/api/hifi/status'); - const data = await resp.json(); - if (data.available) { - statusEl.textContent = `Connected (v${data.version || '?'})`; - statusEl.style.color = '#4caf50'; - } else { - statusEl.textContent = 'No instances reachable'; - statusEl.style.color = '#ff9800'; - } - } catch (e) { - statusEl.textContent = 'Connection error'; - statusEl.style.color = '#f44336'; - } -} - -async function testLidarrConnection() { - const statusEl = document.getElementById('lidarr-connection-status'); - if (!statusEl) return; - statusEl.textContent = 'Checking...'; - statusEl.style.color = '#aaa'; - try { - // Save settings first so the backend has the URL/key - await saveSettings(); - const resp = await fetch('/api/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service: 'lidarr' }) - }); - const data = await resp.json(); - if (data.success) { - statusEl.textContent = 'Connected'; - statusEl.style.color = '#4caf50'; - } else { - statusEl.textContent = data.error || 'Connection failed'; - statusEl.style.color = '#f44336'; - } - } catch (e) { - statusEl.textContent = 'Connection error'; - statusEl.style.color = '#f44336'; - } -} - -async function checkHiFiInstances() { - const panel = document.getElementById('hifi-instances-panel'); - const btn = document.getElementById('hifi-instances-check-btn'); - if (!panel) return; - panel.style.display = 'block'; - panel.innerHTML = '
    Checking instances...
    '; - if (btn) { btn.disabled = true; btn.textContent = 'Checking...'; } - try { - const resp = await fetch('/api/hifi/instances'); - const data = await resp.json(); - if (!data.instances || data.instances.length === 0) { - panel.innerHTML = '
    No instances configured.
    '; - return; - } - const _statusIcon = (inst) => { - if (inst.can_download) return '● Download'; - if (inst.can_search) return '● Search only'; - if (inst.status === 'online') return '● Online (limited)'; - if (inst.status === 'ssl_error') return '● SSL error'; - if (inst.status === 'timeout') return '● Timeout'; - if (inst.status === 'offline') return '● Offline'; - return `● ${escapeHtml(inst.status)}`; - }; - panel.innerHTML = data.instances.map(inst => { - const isActive = inst.url === data.active; - const ver = inst.version ? ` v${inst.version}` : ''; - const activeTag = isActive ? ' (ACTIVE)' : ''; - return `
    - ${escapeHtml(inst.url)}${ver}${activeTag} - ${_statusIcon(inst)} -
    `; - }).join(''); - } catch (e) { - panel.innerHTML = `
    Error checking instances: ${escapeHtml(e.message)}
    `; - } finally { - if (btn) { btn.disabled = false; btn.textContent = 'Check All Instances'; } - } -} - -async function testDeezerDownloadConnection() { - const statusEl = document.getElementById('deezer-download-status'); - if (!statusEl) return; - statusEl.textContent = 'Checking...'; - statusEl.style.color = '#aaa'; - try { - // Save the ARL first so the backend can use it - const arl = document.getElementById('deezer-download-arl')?.value || ''; - if (!arl) { - statusEl.textContent = 'No ARL token provided'; - statusEl.style.color = '#ff9800'; - return; - } - const resp = await fetch('/api/deezer-download/test', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ arl }), - }); - const data = await resp.json(); - if (data.success) { - statusEl.textContent = `Connected as ${data.user || 'Unknown'} (${data.tier || 'Free'})`; - statusEl.style.color = '#4caf50'; - } else { - statusEl.textContent = data.error || 'Authentication failed'; - statusEl.style.color = '#f44336'; - } - } catch (e) { - statusEl.textContent = 'Connection error'; - statusEl.style.color = '#f44336'; - } -} - -async function checkTidalDownloadAuthStatus() { - const statusEl = document.getElementById('tidal-download-auth-status'); - const btn = document.getElementById('tidal-download-auth-btn'); - try { - const resp = await fetch('/api/tidal/download/auth/status'); - const data = await resp.json(); - if (data.authenticated) { - statusEl.textContent = 'Authenticated'; - statusEl.style.color = '#4caf50'; - btn.textContent = 'Re-link Tidal Account'; - } else { - statusEl.textContent = 'Not authenticated'; - statusEl.style.color = '#ff9800'; - btn.textContent = 'Link Tidal Account'; - } - } catch (e) { - statusEl.textContent = ''; - } -} - -let _tidalAuthPollTimer = null; - -async function startTidalDownloadAuth() { - const btn = document.getElementById('tidal-download-auth-btn'); - const statusEl = document.getElementById('tidal-download-auth-status'); - const codeEl = document.getElementById('tidal-download-auth-code'); - - btn.disabled = true; - btn.textContent = 'Starting...'; - statusEl.textContent = ''; - - try { - const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' }); - const data = await resp.json(); - - if (!resp.ok || !data.success) { - throw new Error(data.error || 'Failed to start auth'); - } - - // Show the link/code to the user - const uri = data.verification_uri || ''; - const code = data.user_code || ''; - codeEl.style.display = 'block'; - codeEl.innerHTML = `Go to ${uri} and enter code: ${code}`; - btn.textContent = 'Waiting for approval...'; - statusEl.textContent = 'Waiting...'; - statusEl.style.color = '#ff9800'; - - // Poll for completion - if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer); - _tidalAuthPollTimer = setInterval(async () => { - try { - const checkResp = await fetch('/api/tidal/download/auth/check'); - const checkData = await checkResp.json(); - - if (checkData.status === 'completed') { - clearInterval(_tidalAuthPollTimer); - _tidalAuthPollTimer = null; - codeEl.style.display = 'none'; - statusEl.textContent = 'Authenticated'; - statusEl.style.color = '#4caf50'; - btn.disabled = false; - btn.textContent = 'Re-link Tidal Account'; - showToast('Tidal download account linked successfully', 'success'); - } else if (checkData.status === 'error') { - clearInterval(_tidalAuthPollTimer); - _tidalAuthPollTimer = null; - codeEl.style.display = 'none'; - statusEl.textContent = 'Auth failed'; - statusEl.style.color = '#f44336'; - btn.disabled = false; - btn.textContent = 'Link Tidal Account'; - showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error'); - } - // status === 'pending' — keep polling - } catch (pollErr) { - console.error('Tidal auth poll error:', pollErr); - } - }, 3000); - - } catch (error) { - console.error('Tidal download auth error:', error); - showToast('Failed to start Tidal auth: ' + error.message, 'error'); - btn.disabled = false; - btn.textContent = 'Link Tidal Account'; - codeEl.style.display = 'none'; - } -} - -// =============================== -// QOBUZ AUTH FUNCTIONS -// =============================== - -async function checkQobuzAuthStatus() { - try { - const resp = await fetch('/api/qobuz/auth/status'); - const data = await resp.json(); - - // Update downloads tab section - const formEl = document.getElementById('qobuz-auth-form'); - const loggedInEl = document.getElementById('qobuz-auth-logged-in'); - const userInfoEl = document.getElementById('qobuz-auth-user-info'); - - // Update connections tab section - const connFormEl = document.getElementById('qobuz-connection-form'); - const connLoggedInEl = document.getElementById('qobuz-connection-logged-in'); - const connUserInfoEl = document.getElementById('qobuz-connection-user-info'); - - if (data.authenticated) { - const user = data.user || {}; - const label = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`; - - if (userInfoEl) { userInfoEl.textContent = label; } - if (loggedInEl) loggedInEl.style.display = 'flex'; - if (formEl) formEl.style.display = 'none'; - - if (connUserInfoEl) { connUserInfoEl.textContent = label; } - if (connLoggedInEl) connLoggedInEl.style.display = 'flex'; - if (connFormEl) connFormEl.style.display = 'none'; - } else { - if (loggedInEl) loggedInEl.style.display = 'none'; - if (formEl) formEl.style.display = 'block'; - - if (connLoggedInEl) connLoggedInEl.style.display = 'none'; - if (connFormEl) connFormEl.style.display = 'block'; - } - } catch (e) { - console.error('Qobuz auth status check failed:', e); - } -} - -async function loginQobuzFromConnections() { - const btn = document.getElementById('qobuz-connection-login-btn'); - const statusEl = document.getElementById('qobuz-connection-status'); - const email = document.getElementById('qobuz-connection-email').value.trim(); - const password = document.getElementById('qobuz-connection-password').value; - - if (!email || !password) { - showToast('Please enter your Qobuz email and password', 'warning'); - return; - } - - btn.disabled = true; - btn.textContent = 'Connecting...'; - statusEl.textContent = ''; - - try { - const resp = await fetch('/api/qobuz/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - const data = await resp.json(); - - if (data.success) { - showToast('Qobuz connected successfully!', 'success'); - document.getElementById('qobuz-connection-password').value = ''; - checkQobuzAuthStatus(); - } else { - statusEl.textContent = data.error || 'Login failed'; - statusEl.style.color = '#ff5555'; - showToast(data.error || 'Qobuz login failed', 'error'); - } - } catch (error) { - console.error('Qobuz login error:', error); - statusEl.textContent = 'Connection error'; - statusEl.style.color = '#ff5555'; - showToast('Failed to connect to Qobuz', 'error'); - } finally { - btn.disabled = false; - btn.textContent = 'Connect Qobuz'; - } -} - -async function loginQobuzWithToken() { - const btn = document.getElementById('qobuz-token-login-btn'); - const statusEl = document.getElementById('qobuz-token-status'); - const token = document.getElementById('qobuz-connection-token').value.trim(); - - if (!token) { - showToast('Please paste your Qobuz auth token', 'warning'); - return; - } - - btn.disabled = true; - btn.textContent = 'Connecting...'; - if (statusEl) statusEl.textContent = ''; - - try { - const resp = await fetch('/api/qobuz/auth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); - const data = await resp.json(); - - if (data.success) { - showToast('Qobuz connected via token!', 'success'); - document.getElementById('qobuz-connection-token').value = ''; - checkQobuzAuthStatus(); - } else { - if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; } - showToast(data.error || 'Qobuz token login failed', 'error'); - } - } catch (error) { - console.error('Qobuz token login error:', error); - if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; } - showToast('Failed to connect to Qobuz', 'error'); - } finally { - btn.disabled = false; - btn.textContent = 'Connect with Token'; - } -} - -async function loginQobuzWithTokenFromDownloads() { - const btn = document.getElementById('qobuz-download-token-btn'); - const statusEl = document.getElementById('qobuz-download-token-status'); - const token = document.getElementById('qobuz-download-token').value.trim(); - - if (!token) { - showToast('Please paste your Qobuz auth token', 'warning'); - return; - } - - btn.disabled = true; - btn.textContent = 'Connecting...'; - if (statusEl) statusEl.textContent = ''; - - try { - const resp = await fetch('/api/qobuz/auth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token }), - }); - const data = await resp.json(); - - if (data.success) { - showToast('Qobuz connected via token!', 'success'); - document.getElementById('qobuz-download-token').value = ''; - checkQobuzAuthStatus(); - } else { - if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; } - showToast(data.error || 'Qobuz token login failed', 'error'); - } - } catch (error) { - console.error('Qobuz token login error:', error); - if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; } - showToast('Failed to connect to Qobuz', 'error'); - } finally { - btn.disabled = false; - btn.textContent = 'Connect with Token'; - } -} - -async function loginQobuz() { - const btn = document.getElementById('qobuz-login-btn'); - const statusEl = document.getElementById('qobuz-auth-status'); - const email = document.getElementById('qobuz-email').value.trim(); - const password = document.getElementById('qobuz-password').value; - - if (!email || !password) { - showToast('Please enter your Qobuz email and password', 'warning'); - return; - } - - btn.disabled = true; - btn.textContent = 'Connecting...'; - statusEl.textContent = ''; - - try { - const resp = await fetch('/api/qobuz/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }); - - const data = await resp.json(); - - if (data.success) { - showToast('Qobuz connected successfully!', 'success'); - // Clear password field - document.getElementById('qobuz-password').value = ''; - checkQobuzAuthStatus(); - } else { - statusEl.textContent = data.error || 'Login failed'; - statusEl.style.color = '#ff5555'; - showToast(data.error || 'Qobuz login failed', 'error'); - } - } catch (error) { - console.error('Qobuz login error:', error); - statusEl.textContent = 'Connection error'; - statusEl.style.color = '#ff5555'; - showToast('Failed to connect to Qobuz', 'error'); - } finally { - btn.disabled = false; - btn.textContent = 'Connect Qobuz'; - } -} - -async function logoutQobuz() { - try { - await fetch('/api/qobuz/auth/logout', { method: 'POST' }); - showToast('Qobuz disconnected', 'success'); - checkQobuzAuthStatus(); - } catch (e) { - console.error('Qobuz logout error:', e); - } -} - -const PATH_INPUT_IDS = { - download: 'download-path', - transfer: 'transfer-path', - staging: 'staging-path', - 'music-videos': 'music-videos-path', - 'm3u-entry-base': 'm3u-entry-base-path' -}; - -function togglePathLock(pathType, btn) { - const input = document.getElementById(PATH_INPUT_IDS[pathType]); - if (!input) return; - const isLocked = input.hasAttribute('readonly'); - if (isLocked) { - input.removeAttribute('readonly'); - input.focus(); - btn.textContent = 'Lock'; - btn.classList.remove('locked'); - } else { - input.setAttribute('readonly', ''); - btn.textContent = 'Unlock'; - btn.classList.add('locked'); - } -} - - -// =============================== -// SEARCH FUNCTIONALITY -// =============================== - -function initializeSearch() { - // --- FIX: Corrected the element IDs to match the HTML --- - const searchInput = document.getElementById('downloads-search-input'); - const searchButton = document.getElementById('downloads-search-btn'); - - // Add this line to get the cancel button - const cancelButton = document.getElementById('downloads-cancel-btn'); - - if (searchButton && searchInput) { - searchButton.addEventListener('click', performDownloadsSearch); - searchInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') performDownloadsSearch(); - }); - } - - // Add this event listener for the cancel button - if (cancelButton) { - cancelButton.addEventListener('click', () => { - if (searchAbortController) { - searchAbortController.abort(); // This cancels the fetch request - console.log("Search cancelled by user."); - } - }); - } -} - -// =============================== -// SEARCH MODE TOGGLE -// =============================== - -let searchModeToggleInitialized = false; - -function initializeSearchModeToggle() { - // Only initialize once to prevent duplicate event listeners - if (searchModeToggleInitialized) { - console.log('Search mode toggle already initialized, skipping...'); - return; - } - - const toggleContainer = document.querySelector('.search-mode-toggle'); - const modeBtns = document.querySelectorAll('.search-mode-btn'); - const basicSection = document.getElementById('basic-search-section'); - const enhancedSection = document.getElementById('enhanced-search-section'); - - if (!toggleContainer || !modeBtns.length || !basicSection || !enhancedSection) { - console.warn('Search mode toggle elements not found'); - return; - } - - searchModeToggleInitialized = true; - console.log('✅ Initializing search mode toggle (first time only)'); - - modeBtns.forEach(btn => { - btn.addEventListener('click', () => { - const mode = btn.dataset.mode; - - // Update button active states - modeBtns.forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Update toggle slider position - toggleContainer.setAttribute('data-active', mode); - - // Toggle sections - if (mode === 'basic') { - basicSection.classList.add('active'); - enhancedSection.classList.remove('active'); - console.log('Switched to basic search mode'); - } else { - basicSection.classList.remove('active'); - enhancedSection.classList.add('active'); - console.log('Switched to enhanced search mode'); - } - }); - }); - - // Initialize enhanced search - const enhancedInput = document.getElementById('enhanced-search-input'); - const enhancedSearchBtn = document.getElementById('enhanced-search-btn'); - const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn'); - const enhancedDropdown = document.getElementById('enhanced-dropdown'); - const loadingState = document.getElementById('enhanced-loading'); - const emptyState = document.getElementById('enhanced-empty'); - const resultsContainer = document.getElementById('enhanced-results-container'); - - let debounceTimer = null; - let abortController = null; - - // Multi-source search state - let _enhancedSearchData = null; // Full response with all sources - let _activeSearchSource = null; // Currently displayed source tab - let _altSourceController = null; // AbortController for alternate source fetches - - const SOURCE_LABELS = { - spotify: { text: 'Spotify', tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify' }, - itunes: { text: 'Apple Music', tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes' }, - deezer: { text: 'Deezer', tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer' }, - discogs: { text: 'Discogs', tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs' }, - hydrabase: { text: 'Hydrabase', tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase' }, - youtube_videos: { text: 'Music Videos', tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube' }, - musicbrainz: { text: 'MusicBrainz', tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz' }, - }; - - // Live search with debouncing - if (enhancedInput) { - enhancedInput.addEventListener('input', (e) => { - const query = e.target.value.trim(); - - // Show/hide cancel button - if (enhancedCancelBtn) { - enhancedCancelBtn.classList.toggle('hidden', query.length === 0); - } - - // Clear debounce timer - clearTimeout(debounceTimer); - - // Hide dropdown if query too short - if (query.length < 2) { - hideDropdown(); - return; - } - - // Debounce search - debounceTimer = setTimeout(() => { - performEnhancedSearch(query); - }, 300); - }); - - enhancedInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - const query = e.target.value.trim(); - if (query.length >= 2) { - clearTimeout(debounceTimer); - performEnhancedSearch(query); - } - } - }); - } - - if (enhancedSearchBtn) { - enhancedSearchBtn.addEventListener('click', (e) => { - // Prevent click from bubbling to document (which would close the dropdown) - e.stopPropagation(); - - // Get fresh references (in case we navigated away and back) - const dropdown = document.getElementById('enhanced-dropdown'); - const results = document.getElementById('enhanced-results-container'); - - if (!dropdown) return; - - // Toggle the dropdown visibility to show/hide previous search results - if (dropdown.classList.contains('hidden')) { - // Check if there are results to show by looking for actual content - const hasResults = results && - !results.classList.contains('hidden') && - results.children.length > 0; - - if (hasResults) { - showDropdown(); - } else { - showToast('No previous results to show. Type to search!', 'info'); - } - } else { - hideDropdown(); - } - }); - } - - if (enhancedCancelBtn) { - enhancedCancelBtn.addEventListener('click', () => { - enhancedInput.value = ''; - enhancedCancelBtn.classList.add('hidden'); - hideDropdown(); - }); - } - - // Close button inside dropdown (mobile) - const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close'); - if (dropdownCloseBtn) { - dropdownCloseBtn.addEventListener('click', (e) => { - e.stopPropagation(); - hideDropdown(); - }); - } - - // Close dropdown when clicking outside - document.addEventListener('click', (e) => { - const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown && !dropdown.classList.contains('hidden')) { - const isClickInside = e.target.closest('.enhanced-search-input-wrapper'); - if (!isClickInside) { - hideDropdown(); - } - } - }); - - async function performEnhancedSearch(query) { - console.log('Enhanced search:', query); - const searchId = Date.now() + Math.random(); - - // Show loading state with correct source name - showDropdown(); - const loadingText = document.getElementById('enhanced-loading-text'); - if (loadingText) { - loadingText.textContent = `Searching across ${currentMusicSourceName} and your library...`; - } - loadingState.classList.remove('hidden'); - emptyState.classList.add('hidden'); - resultsContainer.classList.add('hidden'); - - // Abort previous requests (primary + alternates) - if (abortController) { - abortController.abort(); - } - if (_altSourceController) { - _altSourceController.abort(); - } - abortController = new AbortController(); - _altSourceController = new AbortController(); - - // Initialize multi-source state early so alternate fetches can write to it - _enhancedSearchData = { db_artists: [], primary_source: null, sources: {}, searchId, query }; - - try { - const response = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: abortController.signal - }); - - if (!response.ok) throw new Error('Search failed'); - - const data = await response.json(); - console.log('Enhanced results:', data); - - // Store multi-source state - const primarySource = data.primary_source || data.metadata_source || 'deezer'; - _activeSearchSource = primarySource; - _enhancedSearchData = _enhancedSearchData || {}; - _enhancedSearchData.db_artists = data.db_artists; - _enhancedSearchData.primary_source = primarySource; - if (!_enhancedSearchData.sources) _enhancedSearchData.sources = {}; - _enhancedSearchData.sources[primarySource] = { - artists: data.spotify_artists || [], - albums: data.spotify_albums || [], - tracks: data.spotify_tracks || [], - available: true, - }; - - // Calculate total from primary source - const total = (data.db_artists?.length || 0) + - (data.spotify_artists?.length || 0) + - (data.spotify_albums?.length || 0) + - (data.spotify_tracks?.length || 0); - - // Hide loading - loadingState.classList.add('hidden'); - - if (total === 0) { - emptyState.classList.remove('hidden'); - } else { - renderSourceTabs(_enhancedSearchData); - renderDropdownResults(data); - resultsContainer.classList.remove('hidden'); - } - - // Alternate sources now start after the primary response has landed. - // This avoids speculative fan-out for short or aborted searches. - _queueAlternateSourceFetches(data.alternate_sources || [], query, searchId); - - } catch (error) { - if (error.name !== 'AbortError') { - console.error('Enhanced search error:', error); - loadingState.classList.add('hidden'); - emptyState.classList.remove('hidden'); - } - } - } - - function renderDropdownResults(data) { - // Music Videos tab — don't render regular sections - if (_activeSearchSource === 'youtube_videos') return; - - // Determine source badge from active tab (not just primary) - const displaySource = _activeSearchSource || data.metadata_source || 'spotify'; - const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify; - const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass }; - - // Render DB Artists - renderCompactSection( - 'enh-db-artists-section', - 'enh-db-artists-list', - 'enh-db-artists-count', - data.db_artists || [], - (artist) => ({ - image: artist.image_url, - placeholder: '📚', - name: artist.name, - meta: 'In Your Library', - badge: { text: 'Library', class: 'enh-badge-library' }, - onClick: () => { - console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`); - hideDropdown(); - navigateToArtistDetail(artist.id, artist.name); - } - }) - ); - - // Render Artists (source-aware badge) - renderCompactSection( - 'enh-spotify-artists-section', - 'enh-spotify-artists-list', - 'enh-spotify-artists-count', - data.spotify_artists || [], - (artist) => ({ - image: artist.image_url, - placeholder: '🎤', - name: artist.name, - meta: 'Artist', - badge: sourceBadge, - onClick: async () => { - const sourceOverride = _activeSearchSource; - console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); - hideDropdown(); - - // Navigate to Artists page - navigateToPage('artists'); - - // Small delay to let the page load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load the artist details with source context - await selectArtistForDetail(artist, { - source: sourceOverride, - plugin: artist.external_urls?.hydrabase_plugin, - }); - } - }) - ); - - // Split albums from singles/EPs (albums is the catch-all for unknown types) - const allAlbums = data.spotify_albums || []; - const singlesAndEPs = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); - const albums = allAlbums.filter(a => a.album_type !== 'single' && a.album_type !== 'ep'); - - // Render Albums - renderCompactSection( - 'enh-albums-section', - 'enh-albums-list', - 'enh-albums-count', - albums, - (album) => ({ - image: album.image_url, - placeholder: '💿', - name: album.name, - meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`, - onClick: () => handleEnhancedSearchAlbumClick(album) - }) - ); - - // Render Singles & EPs - renderCompactSection( - 'enh-singles-section', - 'enh-singles-list', - 'enh-singles-count', - singlesAndEPs, - (album) => ({ - image: album.image_url, - placeholder: '🎶', - name: album.name, - meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`, - onClick: () => handleEnhancedSearchAlbumClick(album) - }) - ); - - // Render Tracks - renderCompactSection( - 'enh-tracks-section', - 'enh-tracks-list', - 'enh-tracks-count', - data.spotify_tracks || [], - (track) => { - const duration = formatDuration(track.duration_ms); - return { - image: track.image_url, - placeholder: '🎵', - name: track.name, - meta: `${track.artist} • ${track.album}`, - duration: duration, - onClick: () => handleEnhancedSearchTrackClick(track), - onPlay: () => streamEnhancedSearchTrack(track) - }; - } - ); - - // Lazy load artist images that are missing - lazyLoadEnhancedSearchArtistImages(); - - // Async library ownership check — doesn't block rendering - _checkSearchResultsLibraryOwnership(data); - } - - async function _checkSearchResultsLibraryOwnership(data) { - try { - const allAlbums = data.spotify_albums || []; - const allTracks = data.spotify_tracks || []; - if (!allAlbums.length && !allTracks.length) return; - - const resp = await fetch('/api/enhanced-search/library-check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - albums: allAlbums.map(a => ({ name: a.name, artist: a.artist })), - tracks: allTracks.map(t => ({ name: t.name, artist: t.artist })), - }), - }); - const result = await resp.json(); - - // Tag album cards with staggered animation - const albumCards = document.querySelectorAll('#enh-albums-list .enh-compact-item, #enh-singles-list .enh-compact-item'); - const albumResults = result.albums || []; - let delay = 0; - albumCards.forEach((card, i) => { - if (albumResults[i]) { - setTimeout(() => { - const badge = document.createElement('div'); - badge.className = 'enh-item-lib-badge'; - badge.textContent = 'In Library'; - card.appendChild(badge); - }, delay); - delay += 30; - } - }); - - // Tag track rows + wire up library playback - const trackCards = document.querySelectorAll('#enh-tracks-list .enh-compact-item'); - const trackResults = result.tracks || []; - trackCards.forEach((card, i) => { - const tr = trackResults[i]; - if (tr && tr.in_library) { - setTimeout(() => { - const badge = document.createElement('div'); - badge.className = 'enh-item-lib-badge'; - badge.textContent = 'In Library'; - card.appendChild(badge); - - // Replace stream button to play from library instead of searching - if (tr.file_path) { - const playBtn = card.querySelector('.enh-item-play-btn'); - if (playBtn) { - const newBtn = playBtn.cloneNode(true); - newBtn.title = 'Play from library'; - newBtn.textContent = '▶'; - const trackInfo = tr; - newBtn.addEventListener('click', (e) => { - e.stopPropagation(); - playLibraryTrack( - { id: trackInfo.track_id, title: trackInfo.title, file_path: trackInfo.file_path, _stats_image: trackInfo.album_thumb_url || null }, - trackInfo.album_title || '', - trackInfo.artist_name || '' - ); - }); - playBtn.replaceWith(newBtn); - } - } - }, delay); - delay += 30; - } else if (tr && tr.in_wishlist) { - setTimeout(() => { - if (!card.querySelector('.enh-item-wishlist-badge')) { - const badge = document.createElement('div'); - badge.className = 'enh-item-wishlist-badge'; - badge.textContent = 'In Wishlist'; - card.appendChild(badge); - } - }, delay); - delay += 30; - } - }); - } catch (e) { - console.debug('Library check failed:', e); - } - } - - function _queueAlternateSourceFetches(alternateSources, query, searchId) { - if (!Array.isArray(alternateSources) || alternateSources.length === 0) return; - - // Fetch metadata sources first, then YouTube last so it does not compete - // with the primary artist/album/track results for early attention. - const orderedSources = ['spotify', 'itunes', 'deezer', 'discogs', 'musicbrainz', 'hydrabase', 'youtube_videos'] - .filter(src => alternateSources.includes(src) && src !== _activeSearchSource); - - orderedSources.forEach((src, index) => { - setTimeout(() => { - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - _fetchAlternateSource(src, query, searchId); - }, index * 150); - }); - } - - async function _fetchAlternateSource(sourceName, query, searchId) { - try { - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - - const response = await fetch(`/api/enhanced-search/source/${sourceName}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _altSourceController?.signal, - }); - if (!response.ok) return; - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - - // Stream NDJSON — render each search type (artists, albums, tracks) as it arrives - if (!_enhancedSearchData.sources[sourceName]) { - const loadingSet = sourceName === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); - _enhancedSearchData.sources[sourceName] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; - } - const sourceData = _enhancedSearchData.sources[sourceName]; - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - let newlineIdx; - while ((newlineIdx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, newlineIdx).trim(); - buffer = buffer.slice(newlineIdx + 1); - if (!line) continue; - if (!_enhancedSearchData || _enhancedSearchData.searchId !== searchId) return; - - try { - const chunk = JSON.parse(line); - if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } - else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } - else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } - else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); } - else if (chunk.type === 'done') { delete sourceData._loading; break; } - - // Re-render tabs + content if this is the active source - if (_enhancedSearchData.primary_source) { - renderSourceTabs(_enhancedSearchData); - if (_activeSearchSource === sourceName) { - window._switchEnhSourceTab(sourceName); - } - } - } catch (parseErr) { - console.debug(`NDJSON parse error for ${sourceName}:`, parseErr); - } - } - } - - // Final render - if (_enhancedSearchData && _enhancedSearchData.searchId === searchId && _enhancedSearchData.primary_source) { - renderSourceTabs(_enhancedSearchData); - } - } catch (e) { - if (e.name !== 'AbortError') { - console.debug(`Alternate source ${sourceName} failed:`, e); - } - } - } - - function renderSourceTabs(data) { - const tabBar = document.getElementById('enh-source-tabs'); - if (!tabBar) return; - - const sources = data.sources || {}; - const primary = data.primary_source || 'spotify'; - - // Build tab list: primary first, then alternates sorted alphabetically. - // Hide completed zero-result sources so the bar stays focused. - const sourceNames = Object.keys(sources).filter(s => sources[s].available); - const visibleSources = sourceNames.filter(name => { - const src = sources[name] || {}; - const count = name === 'youtube_videos' - ? (src.videos?.length || 0) - : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); - const isLoading = !!(src._loading && src._loading.size > 0); - return isLoading || count > 0 || name === _activeSearchSource; - }); - if (visibleSources.length <= 1) { - tabBar.classList.add('hidden'); - tabBar.innerHTML = ''; - return; - } - - // Primary tab first, then others - const ordered = [primary, ...visibleSources.filter(s => s !== primary).sort()]; - - tabBar.innerHTML = ordered.map(name => { - const info = SOURCE_LABELS[name] || { text: name, tabClass: '' }; - const src = sources[name] || {}; - const count = name === 'youtube_videos' - ? (src.videos?.length || 0) - : (src.artists?.length || 0) + (src.albums?.length || 0) + (src.tracks?.length || 0); - const isActive = name === _activeSearchSource; - return ``; - }).join(''); - - tabBar.classList.remove('hidden'); - } - - // Expose tab switch globally (onclick from HTML) - window._switchEnhSourceTab = function (sourceName) { - if (!_enhancedSearchData || !_enhancedSearchData.sources) return; - const src = _enhancedSearchData.sources[sourceName]; - if (!src) return; - - _activeSearchSource = sourceName; - - // Update tab active states - document.querySelectorAll('.enh-source-tab').forEach(tab => { - tab.classList.toggle('active', tab.dataset.source === sourceName); - }); - - // Music Videos tab — render video cards instead of regular sections - if (sourceName === 'youtube_videos') { - // Hide ALL regular sections including wrappers - ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => { - const el = document.getElementById(id); - if (el) el.classList.add('hidden'); - }); - // Hide the artists wrapper div too - const artistsWrapper = document.querySelector('.enh-artists-wrapper'); - if (artistsWrapper) artistsWrapper.style.display = 'none'; - _renderVideoResults(src.videos || []); - resultsContainer.classList.remove('hidden'); - return; - } - - // Hide videos section and restore regular layout when switching to a metadata tab - const videosSec = document.getElementById('enh-videos-section'); - if (videosSec) videosSec.classList.add('hidden'); - const artistsWrapper = document.querySelector('.enh-artists-wrapper'); - if (artistsWrapper) artistsWrapper.style.display = ''; - - // Build data in the shape renderDropdownResults expects - const viewData = { - db_artists: _enhancedSearchData.db_artists, - spotify_artists: src.artists || [], - spotify_albums: src.albums || [], - spotify_tracks: src.tracks || [], - metadata_source: sourceName, - }; - - renderDropdownResults(viewData); - resultsContainer.classList.remove('hidden'); - - // Show loading spinners for categories still streaming - if (src._loading && src._loading.size > 0) { - const loadingHtml = '
    Loading...
    '; - if (src._loading.has('artists')) { - const sec = document.getElementById('enh-spotify-artists-section'); - if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-spotify-artists-list').innerHTML = loadingHtml; } - } - if (src._loading.has('albums')) { - const sec = document.getElementById('enh-albums-section'); - if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-albums-list').innerHTML = loadingHtml; } - const sec2 = document.getElementById('enh-singles-section'); - if (sec2) { sec2.classList.remove('hidden'); document.getElementById('enh-singles-list').innerHTML = loadingHtml; } - } - if (src._loading.has('tracks')) { - const sec = document.getElementById('enh-tracks-section'); - if (sec) { sec.classList.remove('hidden'); document.getElementById('enh-tracks-list').innerHTML = loadingHtml; } - } - } - }; - - function _renderVideoResults(videos) { - let section = document.getElementById('enh-videos-section'); - if (!section) { - // Create the section dynamically if it doesn't exist - const container = document.getElementById('enhanced-results-container'); - if (!container) return; - section = document.createElement('div'); - section.id = 'enh-videos-section'; - section.className = 'enh-dropdown-section'; - section.innerHTML = ` -
    - 🎬 -

    Music Videos

    - 0 -
    -
    - `; - container.appendChild(section); - } - - section.classList.remove('hidden'); - const countEl = document.getElementById('enh-videos-count'); - const listEl = document.getElementById('enh-videos-list'); - if (countEl) countEl.textContent = videos.length; - - if (!videos.length) { - listEl.innerHTML = '
    No music videos found
    '; - return; - } - - listEl.innerHTML = videos.map(v => { - const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; - const views = v.view_count ? _formatViewCount(v.view_count) : ''; - return ` -
    -
    - -
    - - - - ${duration ? `${duration}` : ''} -
    -
    -
    ${v.title}
    -
    ${v.channel}${views ? ` · ${views} views` : ''}
    -
    -
    - `; - }).join(''); - } - - function _formatViewCount(count) { - if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`; - if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; - if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; - return String(count); - } - - // Lazy load artist images for enhanced search results - async function lazyLoadEnhancedSearchArtistImages() { - const artistLists = [ - document.getElementById('enh-db-artists-list'), - document.getElementById('enh-spotify-artists-list') - ]; - - for (const list of artistLists) { - if (!list) continue; - - const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]'); - if (cardsNeedingImages.length === 0) continue; - - console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`); - - for (const card of cardsNeedingImages) { - const artistId = card.dataset.artistId; - if (!artistId) continue; - - try { - const imgUrl = _activeSearchSource && _activeSearchSource !== 'spotify' - ? `/api/artist/${artistId}/image?source=${_activeSearchSource}` - : `/api/artist/${artistId}/image`; - const response = await fetch(imgUrl); - const data = await response.json(); - - if (data.success && data.image_url) { - // Find the placeholder and replace with image - const placeholder = card.querySelector('.enh-item-image-placeholder'); - if (placeholder) { - const img = document.createElement('img'); - img.src = data.image_url; - img.className = 'enh-item-image artist-image'; - img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist'; - placeholder.replaceWith(img); - - // Apply dynamic glow - extractImageColors(data.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - card.dataset.needsImage = 'false'; - console.log(`✅ Loaded image for artist ${artistId}`); - } - } catch (error) { - console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error); - } - } - } - } - - function formatDuration(durationMs) { - if (!durationMs) return ''; - const totalSeconds = Math.floor(durationMs / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - } - - function renderCompactSection(sectionId, listId, countId, items, mapItem) { - const section = document.getElementById(sectionId); - const list = document.getElementById(listId); - const count = document.getElementById(countId); - - if (!list) return; - - list.innerHTML = ''; - - if (!items || items.length === 0) { - section.classList.add('hidden'); - return; - } - - section.classList.remove('hidden'); - count.textContent = items.length; - - // Determine type based on section ID - const isArtist = sectionId.includes('artists'); - const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); - const isTrack = sectionId.includes('tracks'); - - // Add appropriate grid class to list - if (isArtist) { - list.classList.add('enh-artists-grid'); - } else if (isAlbum) { - list.classList.add('enh-albums-grid'); - } else if (isTrack) { - list.classList.add('enh-tracks-list'); - } - - items.forEach(item => { - const config = mapItem(item); - const elem = document.createElement('div'); - - // Add appropriate card class - if (isArtist) { - elem.className = 'enh-compact-item artist-card'; - // Add data attributes for lazy loading - if (item.id) { - elem.dataset.artistId = item.id; - elem.dataset.needsImage = config.image ? 'false' : 'true'; - } - } else if (isAlbum) { - elem.className = 'enh-compact-item album-card'; - } else if (isTrack) { - elem.className = 'enh-compact-item track-item'; - } - - // Build image HTML with type-specific classes - let imageClass = 'enh-item-image'; - let placeholderClass = 'enh-item-image-placeholder'; - - if (isArtist) { - imageClass += ' artist-image'; - placeholderClass += ' artist-placeholder'; - } else if (isAlbum) { - imageClass += ' album-cover'; - placeholderClass += ' album-placeholder'; - } else if (isTrack) { - imageClass += ' track-cover'; - placeholderClass += ' track-placeholder'; - } - - const imageHtml = config.image - ? `${escapeHtml(config.name)}` - : `
    ${config.placeholder}
    `; - - const badgeHtml = config.badge - ? `
    ${config.badge.text}
    ` - : ''; - - const durationHtml = config.duration && isTrack - ? `
    - ${escapeHtml(config.duration)} - -
    ` - : ''; - - elem.innerHTML = ` - ${imageHtml} -
    -
    ${escapeHtml(config.name)}
    -
    ${escapeHtml(config.meta)}
    -
    - ${durationHtml} - ${badgeHtml} - `; - - elem.addEventListener('click', config.onClick); - - // Add play button handler for tracks - if (isTrack && config.onPlay) { - const playBtn = elem.querySelector('.enh-item-play-btn'); - if (playBtn) { - playBtn.addEventListener('click', (e) => { - e.stopPropagation(); // Don't trigger main onClick - config.onPlay(); - }); - } - } - - list.appendChild(elem); - - // Extract colors from image for dynamic glow effect - if (config.image) { - extractImageColors(config.image, (colors) => { - applyDynamicGlow(elem, colors); - }); - } - }); - } - - async function handleEnhancedSearchAlbumClick(album) { - console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); - - hideDropdown(); - showLoadingOverlay('Loading album...'); - - try { - // Fetch full album data with tracks — pass source for correct routing - const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' }); - if (_activeSearchSource && _activeSearchSource !== 'spotify') { - albumParams.set('source', _activeSearchSource); - } - // Pass Hydrabase plugin origin so server routes to correct client - if (album.external_urls?.hydrabase_plugin) { - albumParams.set('plugin', album.external_urls.hydrabase_plugin); - } - const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - throw new Error(`Failed to load album: ${response.status}`); - } - - const albumData = await response.json(); - - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - hideLoadingOverlay(); - showToast(`No tracks available for "${album.name}". This release may have been delisted or is not available in your region.`, 'warning'); - return; - } - - console.log(`✅ Loaded ${albumData.tracks.length} tracks for ${albumData.name}`); - - // Create virtual playlist ID for enhanced search albums - const virtualPlaylistId = `enhanced_search_album_${album.id}`; - - // Check if modal already exists and show it - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`📱 Reopening existing modal for ${album.name}`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - hideLoadingOverlay(); - return; - } - } - - // Enrich each track with full album object (needed for wishlist functionality) - const enrichedTracks = albumData.tracks.map(track => ({ - ...track, - album: { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks - } - })); - - console.log(`📦 Enriched ${enrichedTracks.length} tracks with album metadata`); - - // Format playlist name - const playlistName = `[${album.artist}] ${albumData.name}`; - - // Create artist object for the modal — extract ID from album data - const firstArtist = (albumData.artists || [])[0] || {}; - const artistObject = { - id: firstArtist.id || album.id?.split?.('_')?.[0] || '', - name: firstArtist.name || album.artist, - image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '', - source: _activeSearchSource || '', - }; - - // Prepare full album object for modal - const fullAlbumObject = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumData.artists || [{ name: album.artist }] - }; - - // Open download missing tracks modal - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - playlistName, - enrichedTracks, - fullAlbumObject, - artistObject, - false // Don't show loading overlay, we already have one - ); - - // Register this download in search bubbles - registerSearchDownload( - { - id: album.id, - name: albumData.name, - artist: album.artist, - image_url: albumData.images?.[0]?.url || null, - images: albumData.images || [] - }, - 'album', - virtualPlaylistId, - album.artist // artistName for grouping - ); - - hideLoadingOverlay(); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error handling enhanced search album click:', error); - showToast(`Error opening album: ${error.message}`, 'error'); - } - } - - async function streamEnhancedSearchTrack(track) { - console.log(`▶️ Stream enhanced search track: ${track.name} by ${track.artist}`); - - hideDropdown(); - showLoadingOverlay(`Searching for ${track.name}...`); - - try { - // Send track metadata to backend for quick slskd search - const response = await fetch('/api/enhanced-search/stream-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_name: track.name, - artist_name: track.artist, - album_name: track.album, - duration_ms: track.duration_ms - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error || 'Failed to search for track'); - } - - const data = await response.json(); - - if (!data.success || !data.result) { - throw new Error('No suitable track found'); - } - - const slskdResult = data.result; - - // Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check) - const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal' || slskdResult.username === 'qobuz' || slskdResult.username === 'hifi'; - if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) { - const format = getFileExtension(slskdResult.filename); - hideLoadingOverlay(); - showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); - return; - } - - console.log(`✅ Found track to stream:`, slskdResult); - console.log(`🎵 Track details - Username: ${slskdResult.username}, Filename: ${slskdResult.filename}`); - - hideLoadingOverlay(); - - // Use existing startStream function to play the track - console.log(`📡 Calling startStream() with result...`); - await startStream(slskdResult); - console.log(`✅ startStream() completed`); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error streaming enhanced search track:', error); - showToast(`Failed to stream track: ${error.message}`, 'error'); - } - } - - async function handleEnhancedSearchTrackClick(track) { - console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`); - - hideDropdown(); - showLoadingOverlay('Loading track...'); - - try { - // Create virtual playlist ID for enhanced search tracks - const virtualPlaylistId = `enhanced_search_track_${track.id}`; - - // Check if modal already exists and show it - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`📱 Reopening existing modal for ${track.name}`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - hideLoadingOverlay(); - return; - } - } - - // Enrich track with album object (needed for wishlist functionality) - const enrichedTrack = { - id: track.id, - name: track.name, - artists: [track.artist], // Convert string to array for modal compatibility - album: { - name: track.album, - id: null, - album_type: 'single', - images: track.image_url ? [{ url: track.image_url }] : [], - release_date: track.release_date || null, - total_tracks: 1 - }, - duration_ms: track.duration_ms, - popularity: track.popularity || 0, - preview_url: track.preview_url || null, - external_urls: track.external_urls || null, - image_url: track.image_url - }; - - console.log(`📦 Enriched track with album metadata`); - - // Format playlist name - const playlistName = `${track.artist} - ${track.name}`; - - // Create minimal artist object for the modal - const artistObject = { - id: null, - name: track.artist - }; - - // Prepare album object for modal (single track) - const albumObject = { - name: track.album, - id: null, - album_type: 'single', - images: track.image_url ? [{ url: track.image_url }] : [], - release_date: track.release_date || null, - total_tracks: 1, - artists: [{ name: track.artist }] - }; - - // Open download missing tracks modal with single track - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - playlistName, - [enrichedTrack], // Array with single track - albumObject, - artistObject, - false - ); - - // Register this download in search bubbles - registerSearchDownload( - { - id: track.id, - name: track.name, - artist: track.artist, - image_url: track.image_url, - images: track.image_url ? [{ url: track.image_url }] : [] - }, - 'track', - virtualPlaylistId, - track.artist // artistName for grouping - ); - - hideLoadingOverlay(); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error handling enhanced search track click:', error); - showToast(`Error opening track: ${error.message}`, 'error'); - } - } - - async function searchSlskdFor(type, item) { - const mainResultsArea = document.getElementById('enhanced-main-results-area'); - if (!mainResultsArea) return; - - // Show loading in main results area - mainResultsArea.innerHTML = ` -
    -
    -

    Searching for ${type === 'album' ? 'album' : 'track'}...

    -
    - `; - - const query = `${item.artist} ${item.name}`; - - try { - const response = await fetch('/api/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - - const data = await response.json(); - - if (data.error) { - showToast(`Search error: ${data.error}`, 'error'); - return; - } - - // Filter results - const filtered = data.results.filter(r => r.result_type === type); - - // Render slskd results in main area - renderSlskdInMainArea(filtered, type, item); - - } catch (error) { - console.error('Slskd search error:', error); - showToast('Search failed', 'error'); - mainResultsArea.innerHTML = '

    Search failed. Please try again.

    '; - } - } - - function renderSlskdInMainArea(results, type, originalItem) { - const mainResultsArea = document.getElementById('enhanced-main-results-area'); - if (!mainResultsArea) return; - - if (!results || results.length === 0) { - mainResultsArea.innerHTML = '

    No matches found for this ' + type + '.

    '; - return; - } - - // Render results using same style as basic search - mainResultsArea.innerHTML = results.map(result => { - const title = type === 'album' - ? `${result.album_title} (${result.tracks ? result.tracks.length : 0} tracks)` - : result.title; - - return ` -
    -
    -

    ${escapeHtml(title)}

    - -
    -
    - ${result.bitrate ? `${result.bitrate} kbps` : ''} - ${result.format ? `${result.format.toUpperCase()}` : ''} - ${result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : ''} - ${result.username ? `👤 ${escapeHtml(result.username)}` : ''} -
    -
    - `; - }).join(''); - - // Attach download handlers - mainResultsArea.querySelectorAll('.download-result-btn').forEach(btn => { - btn.addEventListener('click', async function () { - const result = JSON.parse(this.dataset.result); - const type = this.dataset.type; - - this.disabled = true; - this.textContent = 'Downloading...'; - - try { - const downloadData = type === 'album' - ? { result_type: 'album', tracks: result.tracks || [] } - : { result_type: 'track', username: result.username, filename: result.filename, size: result.size }; - - const response = await fetch('/api/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(downloadData) - }); - - const data = await response.json(); - - if (data.error) { - showToast(`Download error: ${data.error}`, 'error'); - this.disabled = false; - this.innerHTML = '💾 Download'; - } else { - showToast('Download started!', 'success'); - this.innerHTML = '✅ Added'; - } - } catch (error) { - console.error('Download error:', error); - showToast('Download failed', 'error'); - this.disabled = false; - this.innerHTML = '💾 Download'; - } - }); - }); - } - - function showDropdown() { - const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.remove('hidden'); - updateToggleButtonState(); - } - // Hide the page header + search mode toggle to reclaim space - const header = document.querySelector('#downloads-page .downloads-header'); - const modeToggle = document.querySelector('.search-mode-toggle-container'); - const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); - if (header) header.classList.add('enh-results-active-hide'); - if (modeToggle) modeToggle.classList.add('enh-results-active-hide'); - if (slskdPlaceholder) slskdPlaceholder.classList.add('enh-results-active-hide'); - } - - function hideDropdown() { - const dropdown = document.getElementById('enhanced-dropdown'); - if (dropdown) { - dropdown.classList.add('hidden'); - updateToggleButtonState(); - } - // Restore hidden elements - const header = document.querySelector('#downloads-page .downloads-header'); - const modeToggle = document.querySelector('.search-mode-toggle-container'); - const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); - if (header) header.classList.remove('enh-results-active-hide'); - if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); - if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide'); - } - - function updateToggleButtonState() { - // Get fresh references - const btn = document.getElementById('enhanced-search-btn'); - const dropdown = document.getElementById('enhanced-dropdown'); - - if (!btn || !dropdown) return; - - const btnIcon = btn.querySelector('.btn-icon'); - const btnText = btn.querySelector('.btn-text'); - - if (dropdown.classList.contains('hidden')) { - // Dropdown is hidden - button should say "Show Results" - if (btnIcon) btnIcon.textContent = '👁️'; - if (btnText) btnText.textContent = 'Show Results'; - } else { - // Dropdown is visible - button should say "Hide Results" - if (btnIcon) btnIcon.textContent = '🙈'; - if (btnText) btnText.textContent = 'Hide Results'; - } - } -} - -async function performSearch() { - const query = document.getElementById('search-input').value.trim(); - if (!query) { - showToast('Please enter a search term', 'error'); - return; - } - - try { - showLoadingOverlay('Searching...'); - displaySearchResults([]); // Clear previous results - - const response = await fetch(API.search, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - - const data = await response.json(); - - if (data.error) { - showToast(`Search error: ${data.error}`, 'error'); - return; - } - - searchResults = data.results || []; - displaySearchResults(searchResults); - - if (searchResults.length === 0) { - showToast('No results found', 'error'); - } else { - showToast(`Found ${searchResults.length} results`, 'success'); - } - - } catch (error) { - console.error('Error performing search:', error); - showToast('Search failed', 'error'); - } finally { - hideLoadingOverlay(); - } -} - -function displaySearchResults(results) { - const resultsContainer = document.getElementById('search-results'); - - if (!results.length) { - resultsContainer.innerHTML = '
    No search results
    '; - return; - } - - resultsContainer.innerHTML = results.map((result, index) => { - const isAlbum = result.type === 'album'; - const sizeText = isAlbum ? - `${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` : - `${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`; - - return ` -
    -
    -
    -
    ${escapeHtml(result.title)}
    -
    ${escapeHtml(result.artist)}
    - ${result.album ? `
    ${escapeHtml(result.album)}
    ` : ''} -
    -
    - - -
    -
    -
    - ${sizeText} - by ${escapeHtml(result.username)} - ${result.quality ? `${escapeHtml(result.quality)}` : ''} -
    -
    - `; - }).join(''); -} - -function selectResult(index) { - const result = searchResults[index]; - if (!result) return; - - console.log('Selected result:', result); - // Could show detailed view or additional actions here -} - - -async function startDownload(index) { - const result = searchResults[index]; - if (!result) return; - - try { - const response = await fetch('/api/downloads/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(result) - }); - - const data = await response.json(); - - if (data.success) { - showToast('Download started', 'success'); - } else { - showToast(`Download failed: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error starting download:', error); - showToast('Failed to start download', 'error'); - } -} - -// =============================== -// PAGE DATA LOADING -// =============================== - -async function loadInitialData() { - try { - // Load artist bubble state first - await hydrateArtistBubblesFromSnapshot(); - - // Load search bubble state - await hydrateSearchBubblesFromSnapshot(); - - // Load discover download state - await hydrateDiscoverDownloadsFromSnapshot(); - - // Load Beatport bubble state - await hydrateBeatportBubblesFromSnapshot(); - - // Navigate to user's home page (or dashboard for admin) - const homePage = getProfileHomePage(); - const urlPage = _getPageFromPath(); - const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage)) - ? urlPage - : homePage; - - history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash); - - if (targetPage !== 'dashboard') { - navigateToPage(targetPage, { skipPushState: true }); - } else { - await loadDashboardData(); - loadDashboardSyncHistory(); - } - } catch (error) { - console.error('Error loading initial data:', error); - } -} - -async function loadDashboardData() { - try { - const response = await fetch(API.activity); - const data = await response.json(); - - const activityFeed = document.getElementById('activity-feed'); - if (data.activities && data.activities.length) { - activityFeed.innerHTML = data.activities.map(activity => ` -
    - ${activity.time} - ${escapeHtml(activity.text)} -
    - `).join(''); - } - - // Initialize wishlist count when dashboard loads - await updateWishlistCount(); - - // Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior) - stopWishlistCountPolling(); // Ensure no duplicates - wishlistCountInterval = setInterval(updateWishlistCount, 30000); - - } catch (error) { - console.error('Error loading dashboard data:', error); - } -} - -// =========================================== -// == SYNC PAGE SPOTIFY FUNCTIONALITY == -// =========================================== - -async function loadSyncData() { - // This is called when the sync page is navigated to. - // Load server playlists first (default active tab) - if (!window._serverPlaylistsLoaded) { - window._serverPlaylistsLoaded = true; - loadServerPlaylists(); // Don't await — load in background - } - - if (!spotifyPlaylistsLoaded) { - await loadSpotifyPlaylists(); - } - - // Load YouTube playlists from backend (always refresh to get latest state) - await loadYouTubePlaylistsFromBackend(); - - // Load Beatport charts from backend (always refresh to get latest state) - await loadBeatportChartsFromBackend(); - - // Render saved URL histories for YouTube, Deezer, Spotify Link tabs - initUrlHistories(); - - // Refresh Beatport download bubbles (navigation trigger, like showArtistDownloadsSection in artists page) - showBeatportDownloadsSection(); -} - -async function checkForActiveProcesses() { - try { - const response = await fetch('/api/active-processes'); - if (!response.ok) return; - - const data = await response.json(); - const processes = data.active_processes || []; - - if (processes.length > 0) { - console.log(`🔄 Found ${processes.length} active process(es) from backend. Rehydrating UI...`); - - // Separate download batch processes from YouTube playlist processes - const downloadProcesses = processes.filter(p => p.type === 'batch'); - const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist'); - - console.log(`📊 Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`); - - // Rehydrate download modal processes (existing Spotify system) - for (const processInfo of downloadProcesses) { - if (!activeDownloadProcesses[processInfo.playlist_id]) { - rehydrateModal(processInfo); - } - } - - // Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist() - // in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration. - console.log(`ℹ️ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`); - } - } catch (error) { - console.error('Failed to check for active processes:', error); - } -} - -async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) { - /** - * Rehydrates an artist album download modal from backend process data. - * Extracts artist/album info from virtual playlist ID and recreates the modal. - */ - try { - console.log(`💧 Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`); - - // Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id] - const parts = virtualPlaylistId.split('_'); - if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') { - console.error(`❌ Invalid virtual playlist ID format: ${virtualPlaylistId}`); - return; - } - - const artistId = parts[2]; - const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores - - console.log(`🔍 Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`); - - // Fetch the album tracks to get proper artist and album data - try { - const response = await fetch(`/api/album/${albumId}/tracks`); - const data = await response.json(); - - if (!data.success || !data.album || !data.tracks) { - console.error('❌ Failed to fetch album data for rehydration:', data.error); - return; - } - - const album = data.album; - const tracks = data.tracks; - - // Extract artist info from the first track (all tracks should have same artist) - const artist = { - id: artistId, - name: tracks[0].artists[0] // Use first artist name from first track - }; - - console.log(`✅ Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`); - - // Create the modal using the same function as normal artist album downloads - await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist); - - // Update the rehydrated process with batch info and hide modal for background rehydration - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - - // Update button states to reflect running status - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - if (wishlistBtn) wishlistBtn.style.display = 'none'; - - // Hide the modal - this is background rehydration, not user-requested - if (process.modalElement) { - process.modalElement.style.display = 'none'; - console.log(`🔍 Hiding rehydrated modal for background processing: ${album.name}`); - } - - console.log(`✅ Rehydrated artist album modal: ${artist.name} - ${album.name}`); - } else { - console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); - } - - } catch (error) { - console.error(`❌ Error fetching album data for rehydration:`, error); - } - - } catch (error) { - console.error(`❌ Error rehydrating artist album modal:`, error); - } -} - -async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, batchId) { - /** - * Rehydrates a discover playlist download modal from backend process data. - * Fetches tracks from the appropriate discover API endpoint and recreates the modal. - */ - try { - console.log(`💧 Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`); - - // Handle album downloads from Recent Releases - if (virtualPlaylistId.startsWith('discover_album_')) { - const albumId = virtualPlaylistId.replace('discover_album_', ''); - console.log(`💧 Album download - fetching album ${albumId}...`); - - try { - const albumResponse = await fetch(`/api/spotify/album/${albumId}`); - if (!albumResponse.ok) { - console.error(`❌ Failed to fetch album: ${albumResponse.status}`); - return; - } - - const albumData = await albumResponse.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - console.error(`❌ No tracks in album`); - return; - } - - // Convert tracks to expected format - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || []; - if (Array.isArray(artists)) { - artists = artists.map(a => a.name || a); - } - - return { - id: track.id, - name: track.name, - artists: artists, - album: { - name: albumData.name || playlistName.split(' - ')[0], - images: albumData.images || [] - }, - duration_ms: track.duration_ms || 0 - }; - }); - - console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`); - - // Create modal - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - // Update process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Hide modal for background rehydration - if (process.modalElement) { - process.modalElement.style.display = 'none'; - console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); - } - - console.log(`✅ Rehydrated album modal: ${playlistName}`); - } - return; - - } catch (error) { - console.error(`❌ Error fetching album:`, error); - return; - } - } - - // Determine API endpoint based on playlist ID - let apiEndpoint; - if (virtualPlaylistId === 'discover_release_radar') { - apiEndpoint = '/api/discover/release-radar'; - } else if (virtualPlaylistId === 'discover_discovery_weekly') { - apiEndpoint = '/api/discover/discovery-weekly'; - } else if (virtualPlaylistId === 'discover_seasonal_playlist') { - apiEndpoint = '/api/discover/seasonal-playlist'; - } else if (virtualPlaylistId === 'discover_popular_picks') { - apiEndpoint = '/api/discover/popular-picks'; - } else if (virtualPlaylistId === 'discover_hidden_gems') { - apiEndpoint = '/api/discover/hidden-gems'; - } else if (virtualPlaylistId === 'discover_discovery_shuffle') { - apiEndpoint = '/api/discover/discovery-shuffle'; - } else if (virtualPlaylistId === 'discover_familiar_favorites') { - apiEndpoint = '/api/discover/familiar-favorites'; - } else if (virtualPlaylistId === 'build_playlist_custom') { - apiEndpoint = '/api/discover/build-playlist'; - } else if (virtualPlaylistId.startsWith('discover_lb_')) { - console.log(`💧 ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`); - return; - } else { - console.error(`❌ Unknown discover playlist type: ${virtualPlaylistId}`); - return; - } - - // Fetch tracks from API - console.log(`📡 Fetching tracks from ${apiEndpoint}...`); - const response = await fetch(apiEndpoint); - if (!response.ok) { - console.error(`❌ Failed to fetch discover playlist data: ${response.status}`); - return; - } - - const data = await response.json(); - if (!data.success || !data.tracks) { - console.error(`❌ Invalid discover playlist data:`, data); - return; - } - - const tracks = data.tracks; - console.log(`✅ Retrieved ${tracks.length} tracks for ${playlistName}`); - - // Transform tracks to format expected by download modal (same as openDownloadModalForDiscoverPlaylist) - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - - // Use track_data_json if available, otherwise construct from track data - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - // Fallback: construct track object from available data - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - - // Normalize artists to array of strings for modal compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; - }); - - // Create the modal using the same function as normal discover downloads - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - // Update the rehydrated process with batch info and hide modal for background rehydration - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - - // Update button states to reflect running status - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Hide the modal - this is background rehydration, not user-requested - if (process.modalElement) { - process.modalElement.style.display = 'none'; - console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); - } - - console.log(`✅ Rehydrated discover playlist modal: ${playlistName}`); - } else { - console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); - } - - } catch (error) { - console.error(`❌ Error rehydrating discover playlist modal:`, error); - } -} - -async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, batchId) { - /** - * Rehydrates an enhanced search download modal from backend process data. - * Fetches item data from searchDownloadBubbles and recreates the modal. - */ - try { - console.log(`💧 Rehydrating enhanced search modal: ${virtualPlaylistId} (${playlistName})`); - - // Find the download in searchDownloadBubbles - let downloadData = null; - for (const artistName in searchDownloadBubbles) { - const bubble = searchDownloadBubbles[artistName]; - const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (download) { - downloadData = download; - break; - } - } - - if (!downloadData) { - console.warn(`⚠️ No download data found in searchDownloadBubbles for ${virtualPlaylistId}`); - return; - } - - const { item, type } = downloadData; - - if (type === 'album') { - // For albums, fetch tracks (pass name/artist for Hydrabase support) - console.log(`💧 Album download - fetching album ${item.id}...`); - - try { - const _sap1 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); - const response = await fetch(`/api/spotify/album/${item.id}?${_sap1}`); - if (!response.ok) { - console.error(`❌ Failed to fetch album: ${response.status}`); - return; - } - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - console.error(`❌ No tracks in album`); - return; - } - - const spotifyTracks = albumData.tracks.map(track => ({ - id: track.id, - name: track.name, - artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], - album: { - name: item.name, - images: item.image_url ? [{ url: item.image_url }] : [] - }, - duration_ms: track.duration_ms || 0 - })); - - console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`); - - // Create modal - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - item.name, - spotifyTracks, - item, - { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, - false // Don't show loading overlay - ); - - // Update process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Hide modal for background rehydration - if (process.modalElement) { - process.modalElement.style.display = 'none'; - console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); - } - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - - console.log(`✅ Rehydrated enhanced search album modal: ${playlistName}`); - } else { - console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); - } - - } catch (error) { - console.error(`❌ Error fetching album:`, error); - } - - } else { - // For tracks, create enriched track and open modal - console.log(`💧 Track download - creating modal for ${item.name}...`); - - const enrichedTrack = { - id: item.id, - name: item.name, - artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], - album: item.album || { - name: item.album?.name || 'Unknown Album', - images: item.image_url ? [{ url: item.image_url }] : [] - }, - duration_ms: item.duration_ms || 0 - }; - - // Create modal - await openDownloadMissingModalForYouTube( - virtualPlaylistId, - `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, - [enrichedTrack] - ); - - // Update process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Hide modal for background rehydration - if (process.modalElement) { - process.modalElement.style.display = 'none'; - console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); - } - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - - console.log(`✅ Rehydrated enhanced search track modal: ${playlistName}`); - } else { - console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); - } - } - - } catch (error) { - console.error(`❌ Error rehydrating enhanced search modal:`, error); - } -} - -async function rehydrateModal(processInfo, userRequested = false) { - const { playlist_id, playlist_name, batch_id, current_cycle } = processInfo; - console.log(`💧 Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`); - - // Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system - if (playlist_id.startsWith('youtube_')) { - console.log(`⏭️ Skipping YouTube virtual playlist rehydration - handled by YouTube system`); - return; - } - - // Handle Beatport virtual playlists - skip rehydration here, handled by Beatport system - if (playlist_id.startsWith('beatport_')) { - console.log(`⏭️ Skipping Beatport virtual playlist rehydration - handled by Beatport system`); - return; - } - - // Handle artist album virtual playlists - if (playlist_id.startsWith('artist_album_')) { - console.log(`💧 Rehydrating artist album virtual playlist: ${playlist_id}`); - await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id); - return; - } - - // Handle discover virtual playlists (Fresh Tape, The Archives) - if (playlist_id.startsWith('discover_')) { - console.log(`💧 Rehydrating discover playlist: ${playlist_id}`); - await rehydrateDiscoverPlaylistModal(playlist_id, playlist_name, batch_id); - return; - } - - // Handle enhanced search virtual playlists (albums and tracks) - if (playlist_id.startsWith('enhanced_search_album_') || playlist_id.startsWith('enhanced_search_track_')) { - console.log(`💧 Rehydrating enhanced search virtual playlist: ${playlist_id}`); - await rehydrateEnhancedSearchModal(playlist_id, playlist_name, batch_id); - return; - } - - // Handle wishlist processes specially - if (playlist_id === "wishlist") { - console.log(`💧 [Rehydrate] Handling wishlist modal for active process: ${batch_id}`); - - // Check if modal already exists and is visible - const existingProcess = activeDownloadProcesses[playlist_id]; - const modalAlreadyOpen = existingProcess && existingProcess.modalElement && - existingProcess.modalElement.style.display === 'flex'; - - if (modalAlreadyOpen) { - console.log(`💧 [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`); - - // Update existing process with new batch info - existingProcess.status = 'running'; - existingProcess.batchId = batch_id; - - // Update UI to reflect running state - const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Ensure polling is active for live updates - if (!existingProcess.intervalId) { - console.log(`💧 [Rehydrate] Starting polling for existing modal`); - startModalDownloadPolling(playlist_id); - } - - console.log(`✅ [Rehydrate] Successfully updated existing wishlist modal for auto-process`); - } else { - // Only create modal if user requested it - don't create for background auto-processing - if (userRequested) { - console.log(`💧 [Rehydrate] User requested - creating wishlist modal for active process: ${batch_id}`); - - // Create the modal with current server state (pass category filter for auto-processing) - await openDownloadMissingWishlistModal(current_cycle); - const process = activeDownloadProcesses[playlist_id]; - if (!process) { - console.error('❌ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses'); - return; - } - - // Sync process state with server - console.log(`✅ [Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`); - process.status = 'running'; - process.batchId = batch_id; - - // Update UI to reflect running state - const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(playlist_id); - - // Show modal - console.log('👤 [Rehydrate] User requested - showing wishlist modal'); - process.modalElement.style.display = 'flex'; - WishlistModalState.setVisible(); - WishlistModalState.clearUserClosed(); - } else { - console.log('🔄 [Rehydrate] Background auto-processing detected - NOT creating modal (user must click wishlist button to see progress)'); - // Don't create modal for background auto-processing - // User must click the wishlist button to see the modal - } - } - return; - } - - // Handle Deezer ARL playlist processes — ensure playlist data is in spotifyPlaylists for modal reuse - if (playlist_id.startsWith('deezer_arl_') && !spotifyPlaylists.find(p => p.id === playlist_id)) { - const rawId = playlist_id.replace('deezer_arl_', ''); - const deezerPlaylist = deezerArlPlaylists.find(p => String(p.id) === rawId); - if (deezerPlaylist) { - spotifyPlaylists.push({ - id: playlist_id, - name: deezerPlaylist.name, - track_count: deezerPlaylist.track_count || 0, - image_url: deezerPlaylist.image_url || '', - owner: deezerPlaylist.owner || '', - }); - } else { - // Playlists not loaded yet — use process info as fallback - spotifyPlaylists.push({ - id: playlist_id, - name: playlist_name || 'Deezer Playlist', - track_count: 0, - }); - } - } - - // Handle regular Spotify / Deezer ARL playlist processes - let playlistData = spotifyPlaylists.find(p => p.id === playlist_id); - if (!playlistData) { - console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`); - return; - } - await openDownloadMissingModal(playlist_id); - const process = activeDownloadProcesses[playlist_id]; - if (!process) return; - - process.status = 'running'; - process.batchId = batch_id; - updatePlaylistCardUI(playlist_id); - updateRefreshButtonState(); - - document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none'; - document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block'; - - // Hide wishlist button if it exists - const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlist_id}`); - if (wishlistBtn) wishlistBtn.style.display = 'none'; - - startModalDownloadPolling(playlist_id); - - process.modalElement.style.display = 'none'; -} - -// =================================================================== -// YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS -// =================================================================== - -async function loadYouTubePlaylistsFromBackend() { - // Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration) - try { - console.log('📋 Loading YouTube playlists from backend...'); - - const response = await fetch('/api/youtube/playlists'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch YouTube playlists'); - } - - const data = await response.json(); - const playlists = data.playlists || []; - - console.log(`🎬 Found ${playlists.length} stored YouTube playlists in backend`); - - if (playlists.length === 0) { - console.log('📋 No YouTube playlists to hydrate'); - return; - } - - const container = document.getElementById('youtube-playlist-container'); - - // Create cards for playlists that don't already exist (avoid duplicates) - for (const playlistInfo of playlists) { - const urlHash = playlistInfo.url_hash; - - // Check if card already exists (from rehydration or previous loading) - if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement && - document.body.contains(youtubePlaylistStates[urlHash].cardElement)) { - console.log(`⏭️ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`); - - // Update existing state with backend data - const state = youtubePlaylistStates[urlHash]; - state.phase = playlistInfo.phase; - state.discoveryProgress = playlistInfo.discovery_progress; - state.spotifyMatches = playlistInfo.spotify_matches; - state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; - - // Fetch discovery results for existing cards too if they don't have them - if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' && - (!state.discoveryResults || state.discoveryResults.length === 0)) { - try { - console.log(`🔍 Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`); - const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - if (fullState.discovery_results) { - state.discoveryResults = fullState.discovery_results; - state.syncPlaylistId = fullState.sync_playlist_id; - state.syncProgress = fullState.sync_progress || {}; - console.log(`✅ Restored ${state.discoveryResults.length} discovery results for existing card`); - } - } - } catch (error) { - console.warn(`⚠️ Error fetching discovery results for existing card:`, error.message); - } - } - - continue; - } - - console.log(`🎬 Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`); - createYouTubeCardFromBackendState(playlistInfo); - - // Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist) - if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') { - try { - console.log(`🔍 Fetching discovery results for: ${playlistInfo.playlist.name}`); - const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); - - // Store discovery results in local state - const state = youtubePlaylistStates[urlHash]; - if (fullState.discovery_results && state) { - state.discoveryResults = fullState.discovery_results; - state.syncPlaylistId = fullState.sync_playlist_id; - state.syncProgress = fullState.sync_progress || {}; - console.log(`✅ Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`); - } - } else { - console.warn(`⚠️ Could not fetch discovery results for: ${playlistInfo.playlist.name}`); - } - } catch (error) { - console.warn(`⚠️ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message); - } - } - } - - // Rehydrate download modals for YouTube playlists in downloading/download_complete phases - for (const playlistInfo of playlists) { - if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') && - playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) { - - const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id; - - if (!activeDownloadProcesses[convertedPlaylistId]) { - console.log(`💧 Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`); - try { - // Create the download modal using the YouTube-specific function - const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults - ?.filter(result => result.spotify_data) - ?.map(result => result.spotify_data) || []; - - if (spotifyTracks.length > 0) { - await openDownloadMissingModalForYouTube( - convertedPlaylistId, - playlistInfo.playlist.name, - spotifyTracks - ); - - // Set the modal to running state with the correct batch ID - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = playlistInfo.download_process_id; - - // Update UI to running state - const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for this process - startModalDownloadPolling(convertedPlaylistId); - - // Hide modal since this is background rehydration - process.modalElement.style.display = 'none'; - console.log(`✅ Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`); - } - } else { - console.warn(`⚠️ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`); - } - } catch (error) { - console.error(`❌ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error); - } - } - } - } - - console.log(`✅ Successfully hydrated ${playlists.length} YouTube playlists from backend`); - - } catch (error) { - console.error('❌ Error loading YouTube playlists from backend:', error); - showToast(`Error loading YouTube playlists: ${error.message}`, 'error'); - } -} - -async function loadBeatportChartsFromBackend() { - // Load all stored Beatport charts from backend and recreate cards (similar to YouTube hydration) - try { - console.log('📋 Loading Beatport charts from backend...'); - - const response = await fetch('/api/beatport/charts'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Beatport charts'); - } - - const charts = await response.json(); - - console.log(`🎧 Found ${charts.length} stored Beatport charts in backend`); - - if (charts.length === 0) { - console.log('📋 No Beatport charts to hydrate'); - return; - } - - const container = document.getElementById('beatport-playlist-container'); - - // Create cards for charts that don't already exist (avoid duplicates) - for (const chartInfo of charts) { - const chartHash = chartInfo.hash; - - // Check if card already exists (from previous loading) - if (beatportChartStates[chartHash] && beatportChartStates[chartHash].cardElement && - document.body.contains(beatportChartStates[chartHash].cardElement)) { - console.log(`⏭️ Skipping existing Beatport chart card: ${chartInfo.name}`); - - // Update existing state with backend data - const state = beatportChartStates[chartHash]; - state.phase = chartInfo.phase; - - continue; - } - - console.log(`🎧 Creating Beatport chart card: ${chartInfo.name} (Phase: ${chartInfo.phase})`); - createBeatportCardFromBackendState(chartInfo); - - // Fetch full state for non-fresh charts to restore discovery results - if (chartInfo.phase !== 'fresh') { - try { - console.log(`🔍 Fetching full state for: ${chartInfo.name}`); - const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); - - // Store in YouTube state system (since Beatport reuses it) - if (fullState.discovery_results && fullState.discovery_results.length > 0) { - // Transform backend results to frontend format (like Tidal does) - const transformedResults = fullState.discovery_results.map((result, index) => ({ - index: result.index !== undefined ? result.index : index, - yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', - yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', - status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), - spotify_track: result.spotify_data ? result.spotify_data.name : '-', - spotify_artist: result.spotify_data && result.spotify_data.artists ? - result.spotify_data.artists.map(a => a.name || a).join(', ') : '-', - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-' - })); - - // Create Beatport state in YouTube system for modal functionality - youtubePlaylistStates[chartHash] = { - phase: fullState.phase, - playlist: { - name: chartInfo.name, - tracks: chartInfo.chart_data.tracks, - description: `${chartInfo.track_count} tracks from ${chartInfo.name}`, - source: 'beatport' - }, - is_beatport_playlist: true, - beatport_chart_type: chartInfo.chart_data.chart_type, - beatport_chart_hash: chartHash, - discovery_progress: fullState.discovery_progress, - discoveryProgress: fullState.discovery_progress, - spotify_matches: fullState.spotify_matches, - spotifyMatches: fullState.spotify_matches, - discovery_results: fullState.discovery_results, - discoveryResults: transformedResults, - convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id, - download_process_id: fullState.download_process_id, - syncPlaylistId: fullState.sync_playlist_id, - syncProgress: fullState.sync_progress || {} - }; - - console.log(`✅ Restored ${transformedResults.length} discovery results for: ${chartInfo.name}`); - } - } else { - console.warn(`⚠️ Could not fetch full state for: ${chartInfo.name}`); - } - } catch (error) { - console.warn(`⚠️ Error fetching full state for ${chartInfo.name}:`, error.message); - } - } - } - - // Rehydrate download modals for Beatport charts in downloading/download_complete phases - for (const chartInfo of charts) { - if ((chartInfo.phase === 'downloading' || chartInfo.phase === 'download_complete') && - chartInfo.converted_spotify_playlist_id && chartInfo.download_process_id) { - - const convertedPlaylistId = chartInfo.converted_spotify_playlist_id; - console.log(`📥 Rehydrating download modal for Beatport chart: ${chartInfo.name} (Playlist: ${convertedPlaylistId})`); - - // Set up active download process for Beatport chart (like YouTube/Tidal) - try { - // Rehydrate the chart state first to get discovery results - await rehydrateBeatportChart(chartInfo, false); - - // Create the download modal using the Beatport-specific function (like YouTube) - if (!activeDownloadProcesses[convertedPlaylistId]) { - // Get tracks from the rehydrated state - const ytState = youtubePlaylistStates[chartInfo.hash]; - let spotifyTracks = []; - - if (ytState && ytState.discovery_results) { - spotifyTracks = ytState.discovery_results - .filter(result => result.spotify_data) - .map(result => { - const track = result.spotify_data; - // Ensure artists is an array of strings - if (track.artists && Array.isArray(track.artists)) { - track.artists = track.artists.map(artist => - typeof artist === 'string' ? artist : (artist.name || artist) - ); - } else if (track.artists && typeof track.artists === 'string') { - track.artists = [track.artists]; - } else { - track.artists = ['Unknown Artist']; - } - return { - id: track.id, - name: track.name, - artists: track.artists, - album: track.album || 'Unknown Album', - duration_ms: track.duration_ms || 0, - external_urls: track.external_urls || {} - }; - }); - } - - if (spotifyTracks.length > 0) { - await openDownloadMissingModalForYouTube( - convertedPlaylistId, - chartInfo.name, - spotifyTracks - ); - - // Set the modal to running state with the correct batch ID - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process) { - process.status = chartInfo.phase === 'download_complete' ? 'complete' : 'running'; - process.batchId = chartInfo.download_process_id; - - // Update UI to running state - const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for this process - startModalDownloadPolling(convertedPlaylistId); - - // Hide modal since this is background rehydration - process.modalElement.style.display = 'none'; - console.log(`✅ Rehydrated download modal for Beatport chart: ${chartInfo.name}`); - } - } else { - console.warn(`⚠️ No Spotify tracks found for Beatport download modal: ${chartInfo.name}`); - } - } - } catch (error) { - console.warn(`⚠️ Error setting up download process for Beatport chart "${chartInfo.name}":`, error.message); - } - } - } - - console.log(`✅ Successfully loaded and rehydrated ${charts.length} Beatport charts`); - - // Start polling for any charts that are still in discovering phase - for (const chartInfo of charts) { - if (chartInfo.phase === 'discovering') { - console.log(`🔄 [Backend Loading] Auto-starting polling for discovering chart: ${chartInfo.name}`); - startBeatportDiscoveryPolling(chartInfo.hash); - } - } - - // Update clear button state after loading charts - updateBeatportClearButtonState(); - - } catch (error) { - console.error('❌ Error loading Beatport charts from backend:', error); - showToast(`Error loading Beatport charts: ${error.message}`, 'error'); - } -} - -async function loadListenBrainzPlaylistsFromBackend() { - // Load all stored ListenBrainz playlist states from backend for persistence (similar to Beatport hydration) - try { - console.log('📋 Loading ListenBrainz playlists from backend...'); - - const response = await fetch('/api/listenbrainz/playlists'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch ListenBrainz playlists'); - } - - const data = await response.json(); - const playlists = data.playlists || []; - - console.log(`🎵 Found ${playlists.length} stored ListenBrainz playlists in backend`); - - if (playlists.length === 0) { - console.log('📋 No ListenBrainz playlists to hydrate'); - listenbrainzPlaylistsLoaded = true; - return; - } - - // Restore state for each playlist - for (const playlistInfo of playlists) { - const playlistMbid = playlistInfo.playlist_mbid; - - console.log(`🎵 Hydrating ListenBrainz playlist: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase}, MBID: ${playlistMbid})`); - - // Fetch full state for non-fresh playlists to restore discovery results - if (playlistInfo.phase !== 'fresh') { - try { - console.log(`🔍 Fetching full state for: ${playlistInfo.playlist.name}`); - const stateResponse = await fetch(`/api/listenbrainz/state/${playlistMbid}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); - - // Transform backend results to frontend format (like Beatport does) - const transformedResults = (fullState.discovery_results || []).map((result, index) => ({ - index: result.index !== undefined ? result.index : index, - yt_track: result.lb_track || result.track_name || 'Unknown', - yt_artist: result.lb_artist || result.artist_name || 'Unknown', - status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, - duration: result.duration || '0:00' - })); - - // Create ListenBrainz state with both naming conventions - listenbrainzPlaylistStates[playlistMbid] = { - phase: fullState.phase, - playlist: fullState.playlist, - is_listenbrainz_playlist: true, - playlist_mbid: playlistMbid, - // Store with both naming conventions - discovery_results: fullState.discovery_results || [], - discoveryResults: transformedResults, - discovery_progress: fullState.discovery_progress || 0, - discoveryProgress: fullState.discovery_progress || 0, - spotify_matches: fullState.spotify_matches || 0, - spotifyMatches: fullState.spotify_matches || 0, - spotify_total: fullState.spotify_total || 0, - spotifyTotal: fullState.spotify_total || 0, - convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id, - download_process_id: fullState.download_process_id - }; - - console.log(`✅ Restored ${transformedResults.length} discovery results for: ${playlistInfo.playlist.name}`); - } else { - console.warn(`⚠️ Could not fetch full state for: ${playlistInfo.playlist.name}`); - } - } catch (error) { - console.warn(`⚠️ Error fetching full state for ${playlistInfo.playlist.name}:`, error.message); - } - } - } - - // Start polling for any playlists that are still in discovering phase - for (const playlistInfo of playlists) { - if (playlistInfo.phase === 'discovering') { - console.log(`🔄 [Backend Loading] Auto-starting polling for discovering playlist: ${playlistInfo.playlist.name}`); - startListenBrainzDiscoveryPolling(playlistInfo.playlist_mbid); - } - // Show sync button for discovered playlists (hidden by default) - else if (playlistInfo.phase === 'discovered' || playlistInfo.phase === 'syncing' || playlistInfo.phase === 'sync_complete') { - const playlistId = `discover-lb-playlist-${playlistInfo.playlist_mbid}`; - const syncBtn = document.getElementById(`${playlistId}-sync-btn`); - if (syncBtn) { - syncBtn.style.display = 'inline-block'; - console.log(`✅ Showing sync button for discovered playlist: ${playlistInfo.playlist.name}`); - } - } - } - - listenbrainzPlaylistsLoaded = true; - console.log(`✅ Successfully loaded and rehydrated ${playlists.length} ListenBrainz playlists`); - - } catch (error) { - console.error('❌ Error loading ListenBrainz playlists from backend:', error); - listenbrainzPlaylistsLoaded = true; // Mark as loaded even on error to prevent retries - } -} - -function createBeatportCardFromBackendState(chartInfo) { - // Create Beatport chart card from backend state data - const chartHash = chartInfo.hash; - const chartData = chartInfo.chart_data; - const phase = chartInfo.phase; - - const container = document.getElementById('beatport-playlist-container'); - - // Remove placeholder if it exists - const placeholder = container.querySelector('.playlist-placeholder'); - if (placeholder) { - placeholder.remove(); - } - - // Create card HTML using same structure as createBeatportCard - const cardHtml = ` -
    -
    🎧
    -
    -
    ${escapeHtml(chartInfo.name)}
    -
    - ${chartInfo.track_count} tracks - ${getPhaseText(phase)} -
    -
    -
    - ♪ ${chartInfo.spotify_total} / ✓ ${chartInfo.spotify_matches} / ✗ ${chartInfo.spotify_total - chartInfo.spotify_matches} (${Math.round((chartInfo.spotify_matches / chartInfo.spotify_total) * 100) || 0}%) -
    - -
    - `; - - container.insertAdjacentHTML('beforeend', cardHtml); - - // Initialize state - beatportChartStates[chartHash] = { - phase: phase, - chart: chartData, - cardElement: document.getElementById(`beatport-card-${chartHash}`) - }; - - // Add click handler - const card = document.getElementById(`beatport-card-${chartHash}`); - if (card) { - card.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); - } - - console.log(`🃏 Created Beatport card from backend state: ${chartInfo.name} (${phase})`); -} - -async function rehydrateBeatportChart(chartInfo, userRequested = false) { - // Rehydrate Beatport chart state and optionally open modal (similar to rehydrateYouTubePlaylist) - const chartHash = chartInfo.hash; - const chartName = chartInfo.name; - - try { - console.log(`🔄 [Rehydration] Starting rehydration for Beatport chart: ${chartName}`); - - // Get full state from backend including discovery results - let fullState; - try { - const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); - if (stateResponse.ok) { - fullState = await stateResponse.json(); - console.log(`📋 [Rehydration] Retrieved full backend state with ${fullState.discovery_results?.length || 0} discovery results`); - } else { - console.warn(`⚠️ [Rehydration] Could not fetch full state, using basic info`); - } - } catch (error) { - console.warn(`⚠️ [Rehydration] Error fetching full state:`, error.message); - } - - const phase = chartInfo.phase; - - // Create or update Beatport chart state - if (!beatportChartStates[chartHash]) { - beatportChartStates[chartHash] = { - phase: 'fresh', - chart: chartInfo.chart_data, - cardElement: null - }; - } - - const state = beatportChartStates[chartHash]; - state.phase = phase; - - // Transform discovery results if available (like Tidal does) - let transformedResults = []; - if (fullState && fullState.discovery_results) { - transformedResults = fullState.discovery_results.map((result, index) => ({ - index: result.index !== undefined ? result.index : index, - yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', - yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', - status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), - spotify_track: result.spotify_data ? result.spotify_data.name : '-', - spotify_artist: result.spotify_data && result.spotify_data.artists ? - result.spotify_data.artists.map(a => a.name || a).join(', ') : '-', - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-' - })); - } - - // Store in YouTube state system (since Beatport reuses it) - youtubePlaylistStates[chartHash] = { - phase: phase, - playlist: { - name: chartName, - tracks: chartInfo.chart_data.tracks, - description: `${chartInfo.track_count} tracks from ${chartName}`, - source: 'beatport' - }, - is_beatport_playlist: true, - beatport_chart_type: chartInfo.chart_data.chart_type, - beatport_chart_hash: chartHash, - discovery_progress: fullState?.discovery_progress || chartInfo.discovery_progress, - discoveryProgress: fullState?.discovery_progress || chartInfo.discovery_progress, - spotify_matches: fullState?.spotify_matches || chartInfo.spotify_matches, - spotifyMatches: fullState?.spotify_matches || chartInfo.spotify_matches, - discovery_results: fullState?.discovery_results || [], - discoveryResults: transformedResults, - convertedSpotifyPlaylistId: fullState?.converted_spotify_playlist_id || chartInfo.converted_spotify_playlist_id, - download_process_id: fullState?.download_process_id || chartInfo.download_process_id, - syncPlaylistId: fullState?.sync_playlist_id, - syncProgress: fullState?.sync_progress || {} - }; - - // Restore discovery results if we have them - if (fullState && fullState.discovery_results) { - console.log(`✅ Restored ${fullState.discovery_results.length} discovery results from backend`); - - // Update modal if it already exists - const existingModal = document.getElementById(`youtube-discovery-modal-${chartHash}`); - if (existingModal && !existingModal.classList.contains('hidden')) { - console.log(`🔄 Refreshing existing modal with restored discovery results`); - refreshYouTubeDiscoveryModalTable(chartHash); - } - } - - // Update card display - updateBeatportCardPhase(chartHash, phase); - updateBeatportCardProgress(chartHash, { - spotify_total: chartInfo.spotify_total, - spotify_matches: chartInfo.spotify_matches, - failed: chartInfo.spotify_total - chartInfo.spotify_matches - }); - - // Handle active polling resumption - if (phase === 'discovering') { - console.log(`🔍 Resuming discovery polling for: ${chartName}`); - startBeatportDiscoveryPolling(chartHash); - } else if (phase === 'syncing') { - console.log(`🔄 Resuming sync polling for: ${chartName}`); - startBeatportSyncPolling(chartHash); - } - - // Open modal if user requested - if (userRequested) { - switch (phase) { - case 'discovering': - case 'discovered': - case 'syncing': - case 'sync_complete': - openYouTubeDiscoveryModal(chartHash); - break; - case 'downloading': - case 'download_complete': - // Open download modal if we have the converted playlist ID - if (chartInfo.converted_spotify_playlist_id) { - await openDownloadMissingModal(chartInfo.converted_spotify_playlist_id); - } - break; - } - } - - console.log(`✅ Successfully rehydrated Beatport chart: ${chartName}`); - - } catch (error) { - console.error(`❌ Error rehydrating Beatport chart "${chartName}":`, error); - } -} - -function createYouTubeCardFromBackendState(playlistInfo) { - // Create YouTube playlist card from backend state data - const urlHash = playlistInfo.url_hash; - const playlist = playlistInfo.playlist; - const phase = playlistInfo.phase; - - const container = document.getElementById('youtube-playlist-container'); - - // Remove placeholder if it exists - const placeholder = container.querySelector('.youtube-playlist-placeholder'); - if (placeholder) { - placeholder.remove(); - } - - // Create card HTML (using EXACT same structure as createYouTubeCard) - const cardHtml = ` -
    -
    -
    -
    ${escapeHtml(playlist.name)}
    -
    - ${playlist.tracks.length} tracks - ${getPhaseText(phase)} -
    -
    -
    - ♪ ${playlistInfo.spotify_total} / ✓ ${playlistInfo.spotify_matches} / ✗ ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}% -
    - -
    - `; - - container.insertAdjacentHTML('beforeend', cardHtml); - - // Store state for UI management (but backend remains source of truth) - youtubePlaylistStates[urlHash] = { - phase: phase, - url: playlistInfo.url, - playlist: playlist, - cardElement: document.getElementById(`youtube-card-${urlHash}`), - discoveryResults: [], - discoveryProgress: playlistInfo.discovery_progress, - spotifyMatches: playlistInfo.spotify_matches, - convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id, - backendSynced: true // Flag to indicate this came from backend - }; - - console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`); -} - -function getActionButtonText(phase) { - switch (phase) { - case 'fresh': return 'Discover'; - case 'discovering': return 'View Progress'; - case 'discovered': return 'View Results'; - case 'syncing': return 'View Sync'; - case 'sync_complete': return 'Download'; - case 'downloading': return 'View Downloads'; - case 'download_complete': return 'Complete'; - default: return 'Open'; - } -} - -function getPhaseText(phase) { - switch (phase) { - case 'fresh': return 'Ready to discover'; - case 'discovering': return 'Discovering...'; - case 'discovered': return 'Discovery Complete'; - case 'syncing': return 'Syncing...'; - case 'sync_complete': return 'Sync Complete'; - case 'downloading': return 'Downloading...'; - case 'download_complete': return 'Download Complete'; - default: return phase; - } -} - -function getPhaseColor(phase) { - switch (phase) { - case 'fresh': return '#999'; - case 'discovering': case 'syncing': case 'downloading': return '#ffa500'; - case 'discovered': case 'sync_complete': case 'download_complete': return 'rgb(var(--accent-rgb))'; - default: return '#999'; - } -} - -function getProgressWidth(playlistInfo) { - if (playlistInfo.phase === 'fresh') return 0; - if (playlistInfo.spotify_total === 0) return 0; - return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100); -} - -async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { - // Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal) - const urlHash = playlistInfo.url_hash; - const playlistName = playlistInfo.playlist_name; - const phase = playlistInfo.phase; - - console.log(`💧 Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`); - - try { - // First, ensure the card exists (create from backend if needed) - if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) { - console.log(`🃏 Creating missing YouTube card for rehydration: ${playlistName}`); - - // Since playlistInfo from active processes doesn't have full playlist data, - // we need to fetch it from the backend first - try { - const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); - if (stateResponse.ok) { - const fullPlaylistState = await stateResponse.json(); - createYouTubeCardFromBackendState(fullPlaylistState); - } else { - console.error(`❌ Could not fetch full playlist state for card creation: ${playlistName}`); - return; // Can't create card without playlist data - } - } catch (error) { - console.error(`❌ Error fetching playlist state for card creation: ${error.message}`); - return; - } - } - - // Fetch full state from backend to get discovery results - let fullState = null; - if (phase !== 'fresh' && phase !== 'discovering') { - try { - console.log(`🔍 Fetching full backend state for: ${playlistName}`); - const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); - if (stateResponse.ok) { - fullState = await stateResponse.json(); - console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); - } - } catch (error) { - console.warn(`⚠️ Could not fetch full state for ${playlistName}:`, error.message); - } - } - - // Update local state to match backend - const state = youtubePlaylistStates[urlHash]; - state.phase = phase; - state.discoveryProgress = playlistInfo.discovery_progress; - state.spotifyMatches = playlistInfo.spotify_matches; - state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; - - // Restore discovery results if we have them - if (fullState && fullState.discovery_results) { - state.discoveryResults = fullState.discovery_results; - state.syncPlaylistId = fullState.sync_playlist_id; - state.syncProgress = fullState.sync_progress || {}; - console.log(`✅ Restored ${state.discoveryResults.length} discovery results from backend`); - - // Update modal if it already exists - const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (existingModal && !existingModal.classList.contains('hidden')) { - console.log(`🔄 Refreshing existing modal with restored discovery results`); - refreshYouTubeDiscoveryModalTable(urlHash); - } - } - - // Update card display - updateYouTubeCardPhase(urlHash, phase); - updateYouTubeCardProgress(urlHash, playlistInfo); - - // Handle active polling resumption - if (phase === 'discovering') { - console.log(`🔍 Resuming discovery polling for: ${playlistName}`); - startYouTubeDiscoveryPolling(urlHash); - } else if (phase === 'syncing') { - console.log(`🔄 Resuming sync polling for: ${playlistName}`); - startYouTubeSyncPolling(urlHash); - } - - // Open modal if user requested - if (userRequested) { - switch (phase) { - case 'discovering': - case 'discovered': - case 'syncing': - case 'sync_complete': - openYouTubeDiscoveryModal(urlHash); - break; - case 'downloading': - case 'download_complete': - // Open download modal if we have the converted playlist ID - if (playlistInfo.converted_spotify_playlist_id) { - await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id); - } - break; - } - } - - console.log(`✅ Successfully rehydrated YouTube playlist: ${playlistName}`); - - } catch (error) { - console.error(`❌ Error rehydrating YouTube playlist "${playlistName}":`, error); - } -} - -async function removeYouTubePlaylistFromBackend(event, urlHash) { - // Remove YouTube playlist from backend storage and update UI - event.stopPropagation(); // Prevent card click - - const state = youtubePlaylistStates[urlHash]; - if (!state) return; - - const playlistName = state.playlist.name; - - try { - console.log(`🗑️ Removing YouTube playlist from backend: ${playlistName}`); - - const response = await fetch(`/api/youtube/delete/${urlHash}`, { - method: 'DELETE' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete playlist'); - } - - // Remove card from UI - if (state.cardElement) { - state.cardElement.remove(); - } - - // Remove from client state - delete youtubePlaylistStates[urlHash]; - - // Stop any active polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - // Close discovery modal if open - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (modal) { - modal.remove(); - } - - // Show placeholder if no cards left - const container = document.getElementById('youtube-playlist-container'); - const cards = container.querySelectorAll('.youtube-playlist-card'); - if (cards.length === 0) { - container.innerHTML = '
    No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!
    '; - } - - showToast(`Removed "${playlistName}" from backend storage`, 'success'); - console.log(`✅ Successfully removed YouTube playlist: ${playlistName}`); - - } catch (error) { - console.error(`❌ Error removing YouTube playlist "${playlistName}":`, error); - showToast(`Error removing playlist: ${error.message}`, 'error'); - } -} - -async function loadSpotifyPlaylists() { - const container = document.getElementById('spotify-playlist-container'); - const refreshBtn = document.getElementById('spotify-refresh-btn'); - - container.innerHTML = `
    🔄 Loading playlists...
    `; - refreshBtn.disabled = true; - refreshBtn.textContent = '🔄 Loading...'; - - try { - const response = await fetch('/api/spotify/playlists'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch playlists'); - } - spotifyPlaylists = await response.json(); - renderSpotifyPlaylists(); - spotifyPlaylistsLoaded = true; - - await checkForActiveProcesses(); - - } catch (error) { - container.innerHTML = `
    ❌ Error: ${error.message}
    `; - showToast(`Error loading playlists: ${error.message}`, 'error'); - } finally { - refreshBtn.disabled = false; - refreshBtn.textContent = '🔄 Refresh'; - } -} - -function renderSpotifyPlaylists() { - const container = document.getElementById('spotify-playlist-container'); - if (spotifyPlaylists.length === 0) { - container.innerHTML = `
    No Spotify playlists found.
    `; - return; - } - - container.innerHTML = spotifyPlaylists.map(p => { - let statusClass = 'status-never-synced'; - if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; - if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; - - // This HTML structure creates the interactive playlist cards - return ` -
    -
    -
    -
    ${escapeHtml(p.name)}
    -
    - ${p.track_count} tracks • - ${p.sync_status} -
    -
    -
    -
    - - -
    -
    -
    - `; - }).join(''); -} - -function handleViewProgressClick(event, playlistId) { - event.stopPropagation(); // Prevent the card selection from toggling - const process = activeDownloadProcesses[playlistId]; - - if (process && process.modalElement) { - // If a process is active, just show its modal - console.log(`Re-opening active download modal for playlist ${playlistId}`); - process.modalElement.style.display = 'flex'; - } -} - -function updatePlaylistCardUI(playlistId) { - const process = activeDownloadProcesses[playlistId]; - const progressBtn = document.getElementById(`progress-btn-${playlistId}`); - const actionBtn = document.getElementById(`action-btn-${playlistId}`); - const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); - - if (!progressBtn || !actionBtn) return; - - if (process && process.status === 'running') { - // A process is running: show the progress button - progressBtn.classList.remove('hidden'); - progressBtn.textContent = 'View Progress'; - progressBtn.style.backgroundColor = ''; // Reset any custom styling - actionBtn.textContent = '📥 Downloading...'; - actionBtn.disabled = true; - - // Remove completion styling from card - if (card) card.classList.remove('download-complete'); - - } else if (process && process.status === 'complete') { - // Process completed: show "ready for review" indicator - progressBtn.classList.remove('hidden'); - progressBtn.textContent = '📋 View Results'; - progressBtn.style.backgroundColor = '#28a745'; // Green success color - progressBtn.style.color = 'white'; - actionBtn.textContent = '✅ Ready for Review'; - actionBtn.disabled = false; // Allow clicking to see results - - // Add completion styling to card - if (card) card.classList.add('download-complete'); - - } else { - // No process or it's been cleaned up: normal state - progressBtn.classList.add('hidden'); - progressBtn.style.backgroundColor = ''; // Reset styling - progressBtn.style.color = ''; // Reset styling - actionBtn.textContent = 'Sync / Download'; - actionBtn.disabled = false; - - // Remove completion styling from card - if (card) card.classList.remove('download-complete'); - } -} - -async function cleanupDownloadProcess(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) return; - - console.log(`🧹 Cleaning up download process for playlist ${playlistId}`); - - // Stop any active polling first - if (process.poller) { - console.log(`🛑 Stopping individual polling for ${playlistId}`); - clearInterval(process.poller); - process.poller = null; - } - - // Mark process as no longer running - if (process.status === 'running') { - process.status = 'complete'; - } - - // If the process has a batchId, tell the server to clean it up. - if (process.batchId) { - try { - console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`); - const response = await fetch('/api/playlists/cleanup_batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ batch_id: process.batchId }) - }); - - // Handle deferred cleanup (202 = wishlist processing in progress) - if (response.status === 202) { - console.log(`⏳ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`); - // Retry cleanup after delay to allow wishlist processing to complete - setTimeout(async () => { - try { - await fetch('/api/playlists/cleanup_batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ batch_id: process.batchId }) - }); - console.log(`✅ Delayed cleanup completed for batch: ${process.batchId}`); - } catch (error) { - console.warn(`⚠️ Delayed cleanup failed:`, error); - } - }, 2000); // 2 second delay - } else { - console.log(`✅ Server cleanup completed for batch: ${process.batchId}`); - } - } catch (error) { - console.warn(`⚠️ Failed to send cleanup request to server:`, error); - // Don't show toast for cleanup failures - they're not user-facing - } - } - - // Remove modal from DOM - if (process.modalElement && process.modalElement.parentElement) { - process.modalElement.parentElement.removeChild(process.modalElement); - } - - // Remove from client-side global state - delete activeDownloadProcesses[playlistId]; - - // Check if global polling should be stopped - checkAndCleanupGlobalPolling(); - - // Restore card UI (only for non-wishlist playlists) - if (playlistId !== 'wishlist') { - updatePlaylistCardUI(playlistId); - } - updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist -} - -function togglePlaylistSelection(event) { - const card = event.currentTarget; - const playlistId = card.dataset.playlistId; - - // Don't toggle if clicking the button - if (event.target.tagName === 'BUTTON') return; - - const isSelected = !card.classList.contains('selected'); - card.classList.toggle('selected', isSelected); - - if (isSelected) { - selectedPlaylists.add(playlistId); - } else { - selectedPlaylists.delete(playlistId); - } - updateSyncActionsUI(); -} - -function updateSyncActionsUI() { - // If sequential sync is running, let the manager handle UI updates - if (sequentialSyncManager && sequentialSyncManager.isRunning) { - sequentialSyncManager.updateUI(); - return; - } - - const selectionInfo = document.getElementById('selection-info'); - const startSyncBtn = document.getElementById('start-sync-btn'); - const count = selectedPlaylists.size; - - if (count === 0) { - if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync'; - if (startSyncBtn) startSyncBtn.disabled = true; - } else { - if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`; - if (startSyncBtn) startSyncBtn.disabled = false; - } -} - -async function openPlaylistDetailsModal(event, playlistId) { - event.stopPropagation(); - - const playlist = spotifyPlaylists.find(p => p.id === playlistId); - if (!playlist) return; - - showLoadingOverlay(`Loading playlist: ${playlist.name}...`); - - try { - // --- CACHING LOGIC START --- - if (playlistTrackCache[playlistId]) { - console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`); - // Use the cached tracks instead of fetching - const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] }; - showPlaylistDetailsModal(fullPlaylist); - } else { - console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); - // Fetch from the server if not in cache - const response = await fetch(`/api/spotify/playlist/${playlistId}`); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - - // Store the fetched tracks in the cache - playlistTrackCache[playlistId] = fullPlaylist.tracks; - console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`); - - // Auto-mirror this Spotify playlist - mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ - track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', - album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', - duration_ms: t.duration_ms || 0, - image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null, - source_track_id: t.id || t.spotify_track_id || '' - })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); - - showPlaylistDetailsModal(fullPlaylist); - } - // --- CACHING LOGIC END --- - - } catch (error) { - showToast(`Error: ${error.message}`, 'error'); - } finally { - hideLoadingOverlay(); - } -} - -function showPlaylistDetailsModal(playlist) { - // Create modal if it doesn't exist - let modal = document.getElementById('playlist-details-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'playlist-details-modal'; - modal.className = 'modal-overlay'; - document.body.appendChild(modal); - } - - // Check if there's a completed download missing tracks process for this playlist - const activeProcess = activeDownloadProcesses[playlist.id]; - const hasCompletedProcess = activeProcess && activeProcess.status === 'complete'; - - // Check if sync is currently running for this playlist - const isSyncing = !!activeSyncPollers[playlist.id]; - - modal.innerHTML = ` - - `; - - modal.style.display = 'flex'; -} - -function closePlaylistDetailsModal() { - const modal = document.getElementById('playlist-details-modal'); - if (modal) { - modal.style.display = 'none'; - } -} - -function formatDuration(ms) { - const minutes = Math.floor(ms / 60000); - const seconds = Math.floor((ms % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -// =============================== -// DOWNLOAD MISSING TRACKS MODAL -// =============================== - -let activeAnalysisTaskId = null; -let currentPlaylistTracks = []; -let analysisResults = []; -let missingTracks = []; - -// New variables for enhanced modal functionality -let currentDownloadBatchId = null; - -// =============================== -// HERO SECTION HELPER FUNCTIONS -// =============================== - -/** - * Generate hero section HTML for download missing tracks modal - * Context-aware display based on available data - */ -function generateDownloadModalHeroSection(context) { - const { type, playlist, artist, album, trackCount } = context; - - let heroContent = ''; - let heroBackgroundImage = ''; - - switch (type) { - case 'album': - case 'artist_album': - // Artist/album context - show artist + album images - const artistImage = artist?.image_url || artist?.images?.[0]?.url; - const albumImage = album?.image_url || album?.images?.[0]?.url; - - // Use album image as background if available - if (albumImage) { - heroBackgroundImage = `
    `; - } - - heroContent = ` -
    -
    - ${artistImage ? `${escapeHtml(artist.name)}` : ''} - ${albumImage ? `${escapeHtml(album.name)}` : ''} -
    - -
    - `; - break; - - case 'playlist': - // Playlist context - show playlist info - heroContent = ` -
    -
    🎵
    - -
    - `; - break; - - case 'wishlist': - // Wishlist context - show wishlist icon - heroContent = ` -
    -
    👁️
    - -
    - `; - break; - - default: - // Fallback - basic display - heroContent = ` -
    -
    📥
    - -
    - `; - break; - } - - return ` -
    - ${heroBackgroundImage} - ${heroContent} -
    -
    -
    ${context.trackCount}
    -
    Total
    -
    -
    -
    -
    -
    Found
    -
    -
    -
    -
    -
    Missing
    -
    -
    -
    0
    -
    Downloaded
    -
    -
    -
    -
    - × -
    - `; -} -let modalDownloadPoller = null; -let currentModalPlaylistId = null; - -// PHASE 2: Local cancelled track management (GUI PARITY) -let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks - -const TRACK_RENDER_BATCH_SIZE = 100; - -function applyProgressiveTrackRendering(playlistId, totalTrackCount) { - if (totalTrackCount <= TRACK_RENDER_BATCH_SIZE) return; - - const modal = document.getElementById(`download-missing-modal-${playlistId}`); - if (!modal) return; - - const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); - if (!tbody) return; - - const rows = tbody.querySelectorAll('tr[data-track-index]'); - if (rows.length <= TRACK_RENDER_BATCH_SIZE) return; - - // Hide rows beyond first batch - for (let i = TRACK_RENDER_BATCH_SIZE; i < rows.length; i++) { - rows[i].classList.add('hidden'); - } - - let revealedCount = TRACK_RENDER_BATCH_SIZE; - - // Append indicator into .download-tracks-title - const titleEl = modal.querySelector('.download-tracks-title'); - if (titleEl) { - const indicator = document.createElement('span'); - indicator.className = 'track-render-indicator'; - indicator.id = `track-render-indicator-${playlistId}`; - indicator.textContent = `Showing ${revealedCount} of ${totalTrackCount} tracks`; - titleEl.appendChild(indicator); - } - - // Scroll listener on table container - const container = modal.querySelector('.download-tracks-table-container'); - if (!container) return; - - container.addEventListener('scroll', function onScroll() { - const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight; - if (scrollBottom > 200) return; - if (revealedCount >= rows.length) return; - - const nextEnd = Math.min(revealedCount + TRACK_RENDER_BATCH_SIZE, rows.length); - for (let i = revealedCount; i < nextEnd; i++) { - rows[i].classList.remove('hidden'); - } - revealedCount = nextEnd; - - const indicator = document.getElementById(`track-render-indicator-${playlistId}`); - if (indicator) { - indicator.textContent = revealedCount >= rows.length - ? `Showing all ${totalTrackCount} tracks` - : `Showing ${revealedCount} of ${totalTrackCount} tracks`; - } - - if (revealedCount >= rows.length) { - container.removeEventListener('scroll', onScroll); - } - }); -} - -async function openDownloadMissingModal(playlistId) { - showLoadingOverlay('Loading playlist...'); - - // **NEW**: Check if a process is already active for this playlist - if (activeDownloadProcesses[playlistId]) { - console.log(`Modal for ${playlistId} already exists. Showing it.`); - closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal - const process = activeDownloadProcesses[playlistId]; - if (process.modalElement) { - // Show helpful message if it's a completed process - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - } - hideLoadingOverlay(); - return; // Don't create a new one - } - - console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`); - - closePlaylistDetailsModal(); - const playlist = spotifyPlaylists.find(p => p.id === playlistId); - if (!playlist) { - showToast('Could not find playlist data.', 'error'); - hideLoadingOverlay(); - return; - } - - let tracks = playlistTrackCache[playlistId]; - if (!tracks) { - try { - const fetchUrl = playlistId.startsWith('deezer_arl_') - ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` - : `/api/spotify/playlist/${playlistId}`; - const response = await fetch(fetchUrl); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - tracks = fullPlaylist.tracks; - playlistTrackCache[playlistId] = tracks; - } catch (error) { - showToast(`Failed to fetch tracks: ${error.message}`, 'error'); - hideLoadingOverlay(); - return; - } - } - - currentPlaylistTracks = tracks; - currentModalPlaylistId = playlistId; - - let modal = document.createElement('div'); - modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID - modal.className = 'download-missing-modal'; // **NEW**: Use class for styling - modal.style.display = 'none'; // Start hidden - document.body.appendChild(modal); - - // **NEW**: Register the new process in our global state tracker - activeDownloadProcesses[playlistId] = { - status: 'idle', // idle, running, complete, cancelled - modalElement: modal, - poller: null, - batchId: null, - playlist: playlist, - tracks: tracks - }; - - // Generate hero section for playlist context - const heroContext = { - type: 'playlist', - playlist: playlist, - trackCount: tracks.length, - playlistId: playlistId - }; - - modal.innerHTML = ` -
    -
    - ${generateDownloadModalHeroSection(heroContext)} -
    - -
    -
    -
    -
    - 🔍 Library Analysis - Ready to start -
    -
    -
    -
    -
    -
    -
    - ⏬ Downloads - Waiting for analysis -
    -
    -
    -
    -
    -
    - -
    -
    -

    📋 Track Analysis & Download Status

    - ${tracks.length} / ${tracks.length} tracks selected -
    -
    - - - - - - - - - - - - - - - ${tracks.map((track, index) => ` - - - - - - - - - - - `).join('')} - -
    - - #TrackArtistDurationLibrary MatchDownload StatusActions
    - - ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
    -
    -
    -
    - - -
    - `; - - applyProgressiveTrackRendering(playlistId, tracks.length); - modal.style.display = 'flex'; - hideLoadingOverlay(); -} - -async function autoSavePlaylistM3U(playlistId) { - /** - * Automatically save M3U file server-side for playlist modals only. - * Albums are skipped — they're already grouped by media servers. - * The server checks the m3u_export.enabled setting before writing. - * Uses real DB file paths via /api/generate-playlist-m3u. - */ - const process = activeDownloadProcesses[playlistId]; - if (!process || !process.tracks || process.tracks.length === 0) { - return; - } - - const modal = document.getElementById(`download-missing-modal-${playlistId}`); - if (!modal) return; - - // Skip M3U for non-playlist downloads — albums, singles, redownloads, etc. - const nonPlaylistPrefixes = [ - 'artist_album_', 'discover_album_', 'enhanced_search_album_', 'enhanced_search_track_', - 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_', - 'issue_download_', 'library_redownload_', 'redownload_', - ]; - if (nonPlaylistPrefixes.some(p => playlistId.startsWith(p))) return; - - const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; - const artistName = process.artist?.name || ''; - const albumName = process.album?.name || ''; - const releaseDate = process.album?.release_date || ''; - const year = releaseDate ? releaseDate.substring(0, 4) : ''; - - try { - const response = await fetch('/api/generate-playlist-m3u', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_name: playlistName, - tracks: _extractM3UTracks(process.tracks), - context_type: 'playlist', - artist_name: artistName, - album_name: albumName, - year: year, - save_to_disk: true - }) - }); - - if (response.ok) { - console.log(`✅ Auto-saved M3U for playlist: ${playlistName}`); - } else { - console.warn(`⚠️ Failed to auto-save M3U for ${playlistName}`); - } - } catch (error) { - console.debug('Auto-save M3U error (non-critical):', error); - } -} - -function generateM3UContent(playlistId) { - /** - * Generate M3U file content from modal data - * Shared between manual export and auto-save - */ - const process = activeDownloadProcesses[playlistId]; - if (!process || !process.tracks || process.tracks.length === 0) { - return null; - } - - const tracks = process.tracks; - const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; - - // Generate M3U8 content with status information - let m3uContent = '#EXTM3U\n'; - m3uContent += `#PLAYLIST:${playlistName}\n`; - m3uContent += `#GENERATED:${new Date().toISOString()}\n\n`; - - let foundCount = 0; - let downloadedCount = 0; - let missingCount = 0; - - tracks.forEach((track, index) => { - const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1; - let artists = 'Unknown Artist'; - if (Array.isArray(track.artists)) { - artists = track.artists.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : String(a)).filter(Boolean).join(', ') || 'Unknown Artist'; - } else if (typeof track.artists === 'string') { - artists = track.artists; - } else if (track.artist) { - artists = typeof track.artist === 'object' ? (track.artist.name || 'Unknown Artist') : String(track.artist); - } - - // Check library match status from the modal UI - const matchEl = document.getElementById(`match-${playlistId}-${index}`); - const downloadEl = document.getElementById(`download-${playlistId}-${index}`); - - const isFoundInLibrary = matchEl && matchEl.textContent.includes('Found'); - const isDownloaded = downloadEl && downloadEl.textContent.includes('Completed'); - const isMissing = matchEl && matchEl.textContent.includes('Missing'); - - // Track status - let status = 'UNKNOWN'; - if (isDownloaded) { - status = 'DOWNLOADED'; - downloadedCount++; - } else if (isFoundInLibrary) { - status = 'FOUND_IN_LIBRARY'; - foundCount++; - } else if (isMissing) { - status = 'MISSING'; - missingCount++; - } - - // Add track info - m3uContent += `#EXTINF:${durationSeconds},${artists} - ${track.name}\n`; - m3uContent += `#STATUS:${status}\n`; - - // Generate file path - const sanitizedArtist = artists.replace(/[/\\?%*:|"<>]/g, '-'); - const sanitizedTrack = track.name.replace(/[/\\?%*:|"<>]/g, '-'); - - if (isDownloaded || isFoundInLibrary) { - m3uContent += `${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`; - } else { - m3uContent += `# NOT AVAILABLE: ${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`; - } - }); - - // Add summary - m3uContent += `#SUMMARY\n`; - m3uContent += `#TOTAL_TRACKS:${tracks.length}\n`; - m3uContent += `#FOUND_IN_LIBRARY:${foundCount}\n`; - m3uContent += `#DOWNLOADED:${downloadedCount}\n`; - m3uContent += `#MISSING:${missingCount}\n`; - - return m3uContent; -} - -async function exportPlaylistAsM3U(playlistId) { - /** - * Export the tracks from the download missing tracks modal as an M3U playlist file. - * Downloads via browser AND saves server-side to the relevant folder (force=true). - * Uses real DB file paths via /api/generate-playlist-m3u. - */ - console.log(`📋 Exporting playlist ${playlistId} as M3U`); - - const process = activeDownloadProcesses[playlistId]; - if (!process || !process.tracks || process.tracks.length === 0) { - showToast('No tracks available to export', 'warning'); - return; - } - - const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; - const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_']; - const isAlbumExport = albumPrefixes.some(p => playlistId.startsWith(p)); - const releaseDate = process.album?.release_date || ''; - const year = releaseDate ? releaseDate.substring(0, 4) : ''; - - let m3uContent, foundCount, missingCount; - try { - const response = await fetch('/api/generate-playlist-m3u', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_name: playlistName, - tracks: _extractM3UTracks(process.tracks), - context_type: isAlbumExport ? 'album' : 'playlist', - artist_name: process.artist?.name || '', - album_name: process.album?.name || '', - year: year, - save_to_disk: true, - force: true - }) - }); - const data = await response.json(); - if (!data.success) throw new Error(data.error || 'Unknown error'); - m3uContent = data.m3u_content; - foundCount = (data.stats?.found || 0) + (data.stats?.downloaded || 0); - missingCount = data.stats?.missing || 0; - } catch (error) { - showToast('Failed to generate M3U content', 'error'); - console.error('M3U export error:', error); - return; - } - - // Browser download - const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - showToast(`Exported M3U: ${foundCount} available, ${missingCount} missing`, 'success'); - console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${foundCount}, Missing: ${missingCount}`); -} - -function _extractM3UTracks(tracks) { - /** Extract simplified track data for the /api/generate-playlist-m3u endpoint. */ - return tracks.map(t => { - let artist = ''; - if (Array.isArray(t.artists)) { - const first = t.artists[0]; - artist = typeof first === 'object' ? (first.name || '') : String(first || ''); - } else if (typeof t.artists === 'string') { - artist = t.artists; - } else if (t.artist) { - artist = typeof t.artist === 'object' ? (t.artist.name || '') : String(t.artist); - } - return { name: t.name || '', artist, duration_ms: t.duration_ms || 0 }; - }); -} - -// ================================================================================== -// WING IT — Download without metadata discovery -// ================================================================================== - -function _toggleWingItDropdown(btn, urlHash) { - // Remove any existing dropdown - const existing = document.querySelector('.wing-it-dropdown.visible'); - if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; } - - const wrap = btn.closest('.wing-it-wrap'); - if (!wrap) return; - - const dropdown = document.createElement('div'); - dropdown.className = 'wing-it-dropdown'; - dropdown.innerHTML = ` - - - `; - - dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { - item.addEventListener('click', () => { - dropdown.classList.remove('visible'); - setTimeout(() => dropdown.remove(), 150); - const action = item.dataset.action; - if (action === 'download') { - _wingItAction(urlHash, 'download'); - } else { - _wingItAction(urlHash, 'sync'); - } - }); - }); - - // Flip dropdown direction if button is in the top portion of viewport - const btnRect = btn.getBoundingClientRect(); - if (btnRect.top < 200) dropdown.classList.add('flip-down'); - - wrap.appendChild(dropdown); - requestAnimationFrame(() => dropdown.classList.add('visible')); - - // Close on outside click - setTimeout(() => { - const closeHandler = e => { - if (!dropdown.contains(e.target) && e.target !== btn) { - dropdown.classList.remove('visible'); - setTimeout(() => dropdown.remove(), 150); - document.removeEventListener('click', closeHandler); - } - }; - document.addEventListener('click', closeHandler); - }, 50); -} - -function _wingItAction(urlHash, action) { - if (urlHash) { - // Called from a modal — use _wingItFromModal logic - const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; - const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; - const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; - const isTidal = state.is_tidal_playlist; - const isLB = state.is_listenbrainz_playlist; - const isBeatport = state.is_beatport_playlist; - const isDeezer = state.is_deezer_playlist; - const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; - - if (!tracks.length) { - showToast('No tracks available for Wing It', 'error'); - return; - } - - if (action === 'sync') { - // Sync inline — keep modal open - _wingItSyncFromModal(urlHash, tracks, name, isLB); - } else { - // Download — close modal, open download modal - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (modal) modal.remove(); - const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); - if (overlay) overlay.remove(); - wingItDownload(tracks, name, source, null, true); - } - } -} - -async function _wingItSyncFromModal(urlHash, tracks, name, isLB) { - showToast('Starting Wing It sync...', 'info'); - updateYouTubeModalButtons(urlHash, 'syncing'); - - try { - const syncTracks = tracks.map((t, i) => { - let artists = t.artists || []; - if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; - return { - id: t.id || t.source_track_id || `wing_it_${i}`, - name: t.name || t.track_name || 'Unknown', - artists: artists.map(a => typeof a === 'string' ? { name: a } : a), - album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, - duration_ms: t.duration_ms || 0, - }; - }); - - const res = await fetch('/api/wing-it/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) - }); - const data = await res.json(); - - if (data.error) { - showToast(`Sync failed: ${data.error}`, 'error'); - updateYouTubeModalButtons(urlHash, 'discovered'); - return; - } - - if (isLB) { - const state = listenbrainzPlaylistStates[urlHash]; - if (state) state.syncPlaylistId = data.sync_playlist_id; - startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); - } else { - startYouTubeSyncPolling(urlHash, data.sync_playlist_id); - } - } catch (e) { - showToast('Sync failed: ' + e.message, 'error'); - updateYouTubeModalButtons(urlHash, 'discovered'); - } -} - -async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null, skipConfirm = false) { - if (!tracks || tracks.length === 0) { - showToast('No tracks to download', 'error'); - return; - } - - if (!skipConfirm) { - // Show choice: Download or Sync (for LB card button which doesn't have dropdown) - const choice = await _showWingItChoiceDialog(tracks.length, source); - if (!choice) return; - - if (choice === 'sync') { - await _wingItSync(tracks, playlistName, source, cardIdentifier); - return; - } - } - - // Normalize tracks to Spotify-compatible format - const formattedTracks = tracks.map(t => { - // Handle various artist formats - let artists = []; - if (t.artists) { - if (Array.isArray(t.artists)) { - artists = t.artists.map(a => typeof a === 'string' ? { name: a } : a); - } else if (typeof t.artists === 'string') { - artists = [{ name: t.artists }]; - } - } else if (t.artist_name) { - artists = [{ name: t.artist_name }]; - } else if (t.artist) { - artists = [{ name: t.artist }]; - } - if (artists.length === 0) artists = [{ name: 'Unknown' }]; - - // Handle album - let album = { name: '' }; - if (t.album) { - album = typeof t.album === 'string' ? { name: t.album } : t.album; - } else if (t.album_name) { - album = { name: t.album_name }; - } - - return { - id: t.id || t.source_track_id || `wing_it_${Date.now()}_${Math.random()}`, - name: t.name || t.track_name || 'Unknown Track', - artists: artists, - duration_ms: t.duration_ms || 0, - album: album, - }; - }); - - const virtualPlaylistId = `wing_it_${Date.now()}`; - - // Store wing_it flag BEFORE opening the modal - youtubePlaylistStates[virtualPlaylistId] = { - wing_it: true, - tracks: formattedTracks, - }; - - await openDownloadMissingModalForYouTube(virtualPlaylistId, `⚡ ${playlistName}`, formattedTracks); - - // Pre-check the Force Download toggle - setTimeout(() => { - const forceToggle = document.getElementById(`force-download-all-${virtualPlaylistId}`); - if (forceToggle && !forceToggle.checked) forceToggle.checked = true; - }, 800); -} - -function _showWingItChoiceDialog(trackCount, source) { - return new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = '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;'; - const close = val => { overlay.remove(); resolve(val); }; - overlay.onclick = e => { if (e.target === overlay) close(null); }; - - overlay.innerHTML = ` -
    -
    -

    ⚡ Wing It

    - -
    -

    ${trackCount} track${trackCount !== 1 ? 's' : ''} from ${source}. No metadata discovery — uses raw names. Failed tracks won't be added to wishlist.

    -
    - - -
    -
    - `; - - overlay.querySelectorAll('.smart-delete-option').forEach(btn => { - btn.addEventListener('click', () => close(btn.dataset.choice)); - }); - overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); - const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); close(null); } }; - document.addEventListener('keydown', escH); - document.body.appendChild(overlay); - }); -} - -async function _wingItSync(tracks, playlistName, source, cardIdentifier = null) { - try { - showToast('Syncing playlist to server...', 'info'); - - // Format tracks for the sync endpoint - const syncTracks = tracks.map((t, i) => { - let artists = t.artists || []; - if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; - return { - id: t.id || t.source_track_id || `wing_it_${i}`, - name: t.name || t.track_name || 'Unknown', - artists: artists.map(a => typeof a === 'string' ? { name: a } : a), - album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, - duration_ms: t.duration_ms || 0, - artist_name: t.artist_name, - }; - }); - - const res = await fetch('/api/wing-it/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tracks: syncTracks, playlist_name: playlistName }) - }); - const data = await res.json(); - - if (data.error) { - showToast(`Sync failed: ${data.error}`, 'error'); - return; - } - - // Show inline sync status on the card (same display as normal sync) - const playlistId = cardIdentifier ? `discover-lb-playlist-${cardIdentifier}` : null; - if (playlistId) { - const statusDisplay = document.getElementById(`${playlistId}-sync-status`); - if (statusDisplay) statusDisplay.style.display = 'block'; - // Disable sync/wing-it buttons during sync - const syncBtn = document.getElementById(`${playlistId}-sync-btn`); - if (syncBtn) { syncBtn.disabled = true; syncBtn.style.opacity = '0.5'; } - } - - // Poll for sync progress — update inline display - if (data.sync_playlist_id) { - _pollWingItSyncProgress(data.sync_playlist_id, playlistName, playlistId); - } - - } catch (e) { - showToast('Sync failed: ' + e.message, 'error'); - } -} - -function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) { - const poll = setInterval(async () => { - try { - const res = await fetch(`/api/sync/status/${syncPlaylistId}`); - const data = await res.json(); - - // Update inline status display if we have a card - if (cardPlaylistId && data.progress) { - const p = data.progress; - const total = p.total_tracks || p.total || 0; - const matched = p.matched_tracks || p.matched || 0; - const failed = p.failed_tracks || p.failed || 0; - const totalEl = document.getElementById(`${cardPlaylistId}-sync-total`); - const matchedEl = document.getElementById(`${cardPlaylistId}-sync-matched`); - const failedEl = document.getElementById(`${cardPlaylistId}-sync-failed`); - const pctEl = document.getElementById(`${cardPlaylistId}-sync-percentage`); - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - if (pctEl) pctEl.textContent = total > 0 ? Math.round((matched / total) * 100) : 0; - } - - if (data.status === 'finished' || data.status === 'complete' || data.status === 'error') { - clearInterval(poll); - const matched = data.progress?.matched_tracks || data.progress?.matched || 0; - const total = data.progress?.total_tracks || data.progress?.total || 0; - - if (data.status === 'error') { - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } else { - showToast(`⚡ Wing It sync complete — "${playlistName}" created on server (${matched}/${total} tracks matched)`, 'success'); - } - - // Update card status display to show completion - if (cardPlaylistId) { - const statusLabel = document.querySelector(`#${cardPlaylistId}-sync-status .sync-status-label span:last-child`); - if (statusLabel) statusLabel.textContent = `Sync complete — ${matched}/${total} matched`; - const syncIcon = document.querySelector(`#${cardPlaylistId}-sync-status .sync-icon`); - if (syncIcon) syncIcon.textContent = '✓'; - } - } - } catch (e) { /* ignore poll errors */ } - }, 2000); - - // Safety timeout - setTimeout(() => clearInterval(poll), 180000); -} - -async function _wingItFromModal(urlHash) { - // Extract tracks from the discovery modal state — tracks can be in various locations - const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {}; - const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || []; - const name = state.playlistName || state.name || state.playlist?.name || 'Playlist'; - const isTidal = state.is_tidal_playlist; - const isLB = state.is_listenbrainz_playlist; - const isBeatport = state.is_beatport_playlist; - const isDeezer = state.is_deezer_playlist; - const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube'; - - if (!tracks.length) { - showToast('No tracks available for Wing It', 'error'); - return; - } - - const choice = await _showWingItChoiceDialog(tracks.length, source); - if (!choice) return; - - if (choice === 'sync') { - // Sync inline — keep modal open, show progress in modal - showToast('Starting Wing It sync...', 'info'); - updateYouTubeModalButtons(urlHash, 'syncing'); - - try { - // Format and send sync request - const syncTracks = tracks.map((t, i) => { - let artists = t.artists || []; - if (!Array.isArray(artists)) artists = [{ name: String(artists) }]; - return { - id: t.id || t.source_track_id || `wing_it_${i}`, - name: t.name || t.track_name || 'Unknown', - artists: artists.map(a => typeof a === 'string' ? { name: a } : a), - album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' }, - duration_ms: t.duration_ms || 0, - }; - }); - - const res = await fetch('/api/wing-it/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tracks: syncTracks, playlist_name: name }) - }); - const data = await res.json(); - - if (data.error) { - showToast(`Sync failed: ${data.error}`, 'error'); - updateYouTubeModalButtons(urlHash, 'discovered'); - return; - } - - // Use the same sync polling as normal sync — works for any source - if (isLB) { - if (state) state.syncPlaylistId = data.sync_playlist_id; - startListenBrainzSyncPolling(urlHash, data.sync_playlist_id); - } else { - startYouTubeSyncPolling(urlHash, data.sync_playlist_id); - } - } catch (e) { - showToast('Sync failed: ' + e.message, 'error'); - updateYouTubeModalButtons(urlHash, 'discovered'); - } - return; - } - - // choice === 'download' — close modal and open download modal - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (modal) modal.remove(); - const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`); - if (overlay) overlay.remove(); - - wingItDownload(tracks, name, source); -} - -async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks, artist = null, album = null) { - showLoadingOverlay('Loading YouTube playlist...'); - // Check if a process is already active for this virtual playlist - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - } - hideLoadingOverlay(); // Hide overlay when reopening existing modal - return; - } - - console.log(`📥 Opening Download Missing Tracks modal for YouTube playlist: ${virtualPlaylistId}`); - - // Create virtual playlist object for compatibility with existing modal logic - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Store the tracks in the cache for the modal to use - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - currentPlaylistTracks = spotifyTracks; - currentModalPlaylistId = virtualPlaylistId; - - let modal = document.createElement('div'); - modal.id = `download-missing-modal-${virtualPlaylistId}`; - modal.className = 'download-missing-modal'; - modal.style.display = 'none'; - document.body.appendChild(modal); - - // Register the new process in our global state tracker using the same structure as Spotify - activeDownloadProcesses[virtualPlaylistId] = { - status: 'idle', - modalElement: modal, - poller: null, - batchId: null, - playlist: virtualPlaylist, - tracks: spotifyTracks, - artist: artist, // ✅ Store artist context - album: album // ✅ Store album context - }; - - // Generate hero section with dynamic source detection - const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : - virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : - virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : - virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : - virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; - - // Store metadata for discover download sidebar (will be added when Begin Analysis is clicked) - if (source === 'SoulSync' || virtualPlaylistId.startsWith('discover_lb_') || virtualPlaylistId.startsWith('listenbrainz_') || virtualPlaylistId.startsWith('wing_it_')) { - // Extract image URL from album context or first track's album cover - let imageUrl = null; - if (album && album.images && album.images.length > 0) { - imageUrl = album.images[0].url; - } else if (spotifyTracks && spotifyTracks.length > 0) { - const firstTrack = spotifyTracks[0]; - if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { - imageUrl = firstTrack.album.images[0].url; - } - } - // Store in process for later use when Begin Analysis is clicked - activeDownloadProcesses[virtualPlaylistId].discoverMetadata = { - imageUrl: imageUrl, - type: album ? 'album' : 'playlist' // ✅ Use 'album' if album context provided - }; - } - - // CRITICAL FIX: Use album context for discover_album playlists - const isDiscoverAlbum = virtualPlaylistId.startsWith('discover_album_') || virtualPlaylistId.startsWith('discover_cache_') || virtualPlaylistId.startsWith('seasonal_album_') || virtualPlaylistId.startsWith('spotify_library_'); - const heroContext = isDiscoverAlbum && album && artist ? { - type: 'album', - artist: { - name: artist.name, - image_url: artist.image_url || null - }, - album: { - name: album.name, - album_type: album.album_type || 'album', - images: album.images || [] - }, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - } : { - type: 'playlist', - playlist: { name: playlistName, owner: source }, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - }; - - // Use the exact same modal HTML structure as the existing Spotify modal - modal.innerHTML = ` -
    -
    - ${generateDownloadModalHeroSection(heroContext)} -
    - -
    -
    -
    -
    - 🔍 Library Analysis - Ready to start -
    -
    -
    -
    -
    -
    -
    - ⏬ Downloads - Waiting for analysis -
    -
    -
    -
    -
    -
    - -
    -
    -

    📋 Track Analysis & Download Status

    - ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected -
    -
    - - - - - - - - - - - - - - - ${spotifyTracks.map((track, index) => ` - - - - - - - - - - - `).join('')} - -
    - - #TrackArtistDurationLibrary MatchDownload StatusActions
    - - ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
    -
    -
    -
    - - -
    - `; - - applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); - modal.style.display = 'flex'; - hideLoadingOverlay(); -} - -function _navigateToArtistFromModal(artistId, artistName, imageUrl, source, playlistId) { - if (!artistName) return; - // Close the download modal - if (playlistId) closeDownloadMissingModal(playlistId); - // Navigate to Artists page and load discography - navigateToPage('artists'); - setTimeout(() => { - // If we have an artist ID, use it directly - // If not, search by name — selectArtistForDetail handles both - selectArtistForDetail( - { id: artistId || artistName, name: artistName, image_url: imageUrl || '' }, - source ? { source: source } : undefined - ); - }, 200); -} - -async function closeDownloadMissingModal(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) { - // If somehow called without a process, try to find and remove the element - const modal = document.getElementById(`download-missing-modal-${playlistId}`); - if (modal && modal.parentElement) { - modal.parentElement.removeChild(modal); - } - return; - } - - // If the process is running, just hide the modal. - // If it's idle, complete, or cancelled, perform a full cleanup. - if (process.status === 'running') { - console.log(`Hiding active download modal for playlist ${playlistId}.`); - process.modalElement.style.display = 'none'; - - // Track wishlist modal state changes - if (playlistId === 'wishlist') { - WishlistModalState.setUserClosed(); // User manually closed during processing - console.log('📱 [Modal State] User manually closed wishlist modal during processing'); - } - } else { - console.log(`Closing and cleaning up download modal for playlist ${playlistId}.`); - - // Reset YouTube playlist phase to 'discovered' when modal is closed after completion - if (playlistId.startsWith('youtube_')) { - const urlHash = playlistId.replace('youtube_', ''); - updateYouTubeCardPhase(urlHash, 'discovered'); - // Also update mirrored playlist card if applicable - if (urlHash.startsWith('mirrored_')) { - updateMirroredCardPhase(urlHash, 'discovered'); - } - - // Update backend state to prevent rehydration issues on page refresh (similar to Tidal fix) - try { - const response = await fetch(`/api/youtube/update_phase/${urlHash}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - phase: 'discovered' - }) - }); - - if (response.ok) { - console.log(`✅ [Modal Close] Updated backend phase for YouTube playlist ${urlHash} to 'discovered'`); - } else { - console.warn(`⚠️ [Modal Close] Failed to update backend phase for YouTube playlist ${urlHash}`); - } - } catch (error) { - console.error(`❌ [Modal Close] Error updating backend phase for YouTube playlist ${urlHash}:`, error); - } - } - - // Reset Beatport chart phase to 'discovered' when modal is closed - if (playlistId.startsWith('beatport_')) { - const urlHash = playlistId.replace('beatport_', ''); - const state = youtubePlaylistStates[urlHash]; - - if (state && state.is_beatport_playlist) { - console.log(`🧹 [Modal Close] Processing Beatport chart close: playlistId="${playlistId}", urlHash="${urlHash}"`); - - const chartHash = state.beatport_chart_hash || urlHash; - - // Reset to discovered phase (unless download actually started and completed) - if (state.phase !== 'download_complete') { - updateBeatportCardPhase(chartHash, 'discovered'); - state.phase = 'discovered'; - - // Update Beatport chart state - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'discovered'; - } - - // Update backend state - try { - await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - console.log(`✅ [Modal Close] Updated backend phase for Beatport chart ${chartHash} to 'discovered'`); - } catch (error) { - console.error(`❌ [Modal Close] Error updating backend phase for Beatport chart ${chartHash}:`, error); - } - } - } - } - - // Enhanced Tidal playlist state management (based on GUI sync.py patterns) - if (playlistId.startsWith('tidal_')) { - const tidalPlaylistId = playlistId.replace('tidal_', ''); - - console.log(`🧹 [Modal Close] Processing Tidal playlist close: playlistId="${playlistId}", tidalPlaylistId="${tidalPlaylistId}"`); - console.log(`🧹 [Modal Close] Current Tidal state:`, tidalPlaylistStates[tidalPlaylistId]); - - // Clear download-specific state but preserve discovery results (like GUI closeEvent) - if (tidalPlaylistStates[tidalPlaylistId]) { - const currentPhase = tidalPlaylistStates[tidalPlaylistId].phase; - console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); - - // Preserve discovery data for future use (like GUI modal behavior) - const preservedData = { - playlist: tidalPlaylistStates[tidalPlaylistId].playlist, - discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results, - spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches, - discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, - convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId - }; - - // Clear download-specific state - delete tidalPlaylistStates[tidalPlaylistId].download_process_id; - delete tidalPlaylistStates[tidalPlaylistId].phase; - - // Restore preserved data and set to discovered phase - Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); - tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; - - console.log(`🧹 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} - cleared download state, preserved discovery data`); - console.log(`🧹 [Modal Close] New phase after reset: ${tidalPlaylistStates[tidalPlaylistId].phase}`); - } else { - console.error(`❌ [Modal Close] No Tidal state found for playlistId: ${tidalPlaylistId}`); - } - - updateTidalCardPhase(tidalPlaylistId, 'discovered'); - console.log(`🔄 [Modal Close] Reset Tidal playlist ${tidalPlaylistId} to discovered phase`); - console.log(`📝 [Modal Close] Expected button text for discovered phase: "${getActionButtonText('discovered')}"`); - - // Update backend state to prevent rehydration issues on page refresh - try { - const response = await fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - phase: 'discovered' - }) - }); - - if (response.ok) { - console.log(`✅ [Modal Close] Updated backend phase for Tidal playlist ${tidalPlaylistId} to 'discovered'`); - } else { - console.warn(`⚠️ [Modal Close] Failed to update backend phase for Tidal playlist ${tidalPlaylistId}`); - } - } catch (error) { - console.error(`❌ [Modal Close] Error updating backend phase for Tidal playlist ${tidalPlaylistId}:`, error); - } - } - - // Reset ListenBrainz playlist phase to 'discovered' when modal is closed - if (playlistId.startsWith('listenbrainz_')) { - const playlistMbid = playlistId.replace('listenbrainz_', ''); - - console.log(`🧹 [Modal Close] Processing ListenBrainz playlist close: playlistId="${playlistId}", mbid="${playlistMbid}"`); - - // Clear download-specific state but preserve discovery results - if (listenbrainzPlaylistStates[playlistMbid]) { - const currentPhase = listenbrainzPlaylistStates[playlistMbid].phase; - console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); - - // Reset to discovered phase (unless download actually completed successfully) - if (currentPhase !== 'download_complete') { - // Clear download-specific fields - delete listenbrainzPlaylistStates[playlistMbid].download_process_id; - delete listenbrainzPlaylistStates[playlistMbid].convertedSpotifyPlaylistId; - - // Set back to discovered - listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; - - // Update backend state - try { - await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - console.log(`✅ [Modal Close] Updated backend phase for ListenBrainz playlist ${playlistMbid} to 'discovered'`); - } catch (error) { - console.error(`❌ [Modal Close] Error updating backend phase for ListenBrainz playlist ${playlistMbid}:`, error); - } - - console.log(`🔄 [Modal Close] Reset ListenBrainz playlist ${playlistMbid} to discovered phase`); - } - } else { - console.error(`❌ [Modal Close] No ListenBrainz state found for mbid: ${playlistMbid}`); - } - } - - // Reset Spotify Public playlist phase to 'discovered' when modal is closed - if (playlistId.startsWith('spotify_public_')) { - const spUrlHash = playlistId.replace('spotify_public_', ''); - - console.log(`🧹 [Modal Close] Processing Spotify Public playlist close: playlistId="${playlistId}", urlHash="${spUrlHash}"`); - - if (spotifyPublicPlaylistStates[spUrlHash]) { - const currentPhase = spotifyPublicPlaylistStates[spUrlHash].phase; - console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); - - const preservedData = { - playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, - discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, - spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, - discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, - convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId - }; - - delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; - delete spotifyPublicPlaylistStates[spUrlHash].phase; - - Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); - spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; - - console.log(`🧹 [Modal Close] Reset Spotify Public playlist ${spUrlHash} - cleared download state, preserved discovery data`); - } - - updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); - - try { - await fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - console.log(`✅ [Modal Close] Updated backend phase for Spotify Public playlist ${spUrlHash} to 'discovered'`); - } catch (error) { - console.error(`❌ [Modal Close] Error updating backend phase for Spotify Public playlist ${spUrlHash}:`, error); - } - } - - // Reset Deezer playlist phase to 'discovered' when modal is closed - if (playlistId.startsWith('deezer_')) { - const deezerPlaylistId = playlistId.replace('deezer_', ''); - - console.log(`🧹 [Modal Close] Processing Deezer playlist close: playlistId="${playlistId}", deezerPlaylistId="${deezerPlaylistId}"`); - - if (deezerPlaylistStates[deezerPlaylistId]) { - const currentPhase = deezerPlaylistStates[deezerPlaylistId].phase; - console.log(`🧹 [Modal Close] Current phase before reset: ${currentPhase}`); - - const preservedData = { - playlist: deezerPlaylistStates[deezerPlaylistId].playlist, - discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results, - spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches, - discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress, - convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId - }; - - delete deezerPlaylistStates[deezerPlaylistId].download_process_id; - delete deezerPlaylistStates[deezerPlaylistId].phase; - - Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); - deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; - - console.log(`🧹 [Modal Close] Reset Deezer playlist ${deezerPlaylistId} - cleared download state, preserved discovery data`); - } - - updateDeezerCardPhase(deezerPlaylistId, 'discovered'); - - try { - await fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - console.log(`✅ [Modal Close] Updated backend phase for Deezer playlist ${deezerPlaylistId} to 'discovered'`); - } catch (error) { - console.error(`❌ [Modal Close] Error updating backend phase for Deezer playlist ${deezerPlaylistId}:`, error); - } - } - - // Clear wishlist modal state when modal is fully closed - if (playlistId === 'wishlist') { - WishlistModalState.clear(); // Clear all tracking since modal is fully closed - console.log('📱 [Modal State] Cleared wishlist modal state on full close'); - } - - // Clean up artist download if this is an artist album playlist - if (playlistId.startsWith('artist_album_')) { - console.log(`🧹 [MODAL CLOSE] Cleaning up artist download for completed modal: ${playlistId}`); - cleanupArtistDownload(playlistId); - console.log(`✅ [MODAL CLOSE] Artist download cleanup completed for: ${playlistId}`); - } - - // Clean up search download if this is an enhanced search playlist - if (playlistId.startsWith('enhanced_search_')) { - console.log(`🧹 [MODAL CLOSE] Cleaning up search download for completed modal: ${playlistId}`); - cleanupSearchDownload(playlistId); - console.log(`✅ [MODAL CLOSE] Search download cleanup completed for: ${playlistId}`); - } - - // Clean up Beatport download if this is a beatport chart or release playlist - if (playlistId.startsWith('beatport_chart_') || playlistId.startsWith('beatport_release_')) { - console.log(`🧹 [MODAL CLOSE] Cleaning up Beatport download for completed modal: ${playlistId}`); - cleanupBeatportDownload(playlistId); - console.log(`✅ [MODAL CLOSE] Beatport download cleanup completed for: ${playlistId}`); - } - - // Remove from discover download sidebar if this is a discover page download - if (discoverDownloads && discoverDownloads[playlistId]) { - console.log(`🧹 [MODAL CLOSE] Removing discover download bubble: ${playlistId}`); - removeDiscoverDownload(playlistId); - console.log(`✅ [MODAL CLOSE] Discover download bubble removed for: ${playlistId}`); - } - - // Automatic cleanup and server operations after successful downloads - await handlePostDownloadAutomation(playlistId, process); - - cleanupDownloadProcess(playlistId); - } -} - -/** - * Extract unique album cover images from tracks - */ -function extractUniqueCoverImages(tracks, maxCovers = 20) { - const uniqueCovers = new Set(); - const covers = []; - - for (const track of tracks) { - if (covers.length >= maxCovers) break; - - let coverUrl = null; - let spotifyData = track.spotify_data; - - // Parse spotify_data if it's a string - if (typeof spotifyData === 'string') { - try { - spotifyData = JSON.parse(spotifyData); - } catch (e) { - continue; - } - } - - // Extract cover URL - coverUrl = spotifyData?.album?.images?.[0]?.url; - - // Add to list if unique and valid - if (coverUrl && !uniqueCovers.has(coverUrl)) { - uniqueCovers.add(coverUrl); - covers.push(coverUrl); - } - } - - return covers; -} - -/** - * Shuffle array using Fisher-Yates algorithm - */ -function shuffleArray(array) { - const shuffled = [...array]; - for (let i = shuffled.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; - } - return shuffled; -} - -/** - * Generate mosaic grid background HTML with continuous scrolling rows - */ -function generateMosaicBackground(coverUrls) { - // If less than 3 covers, use gradient fallback - if (!coverUrls || coverUrls.length < 3) { - return ` -
    -
    - `; - } - - // Cap covers per row to 15 for GPU performance (avoids hundreds of tiles) - if (coverUrls.length > 15) { - coverUrls = coverUrls.slice(0, 15); - } - - const rows = 4; - let mosaicHTML = '
    '; - - // Calculate scroll speed based on number of images - // More images = longer duration to maintain consistent visual speed - // Minimum 40s to prevent scrolling too fast - const scrollSpeed = Math.max(40, coverUrls.length * 2); - - for (let row = 0; row < rows; row++) { - const isEvenRow = row % 2 === 0; - const direction = isEvenRow ? 'left' : 'right'; - - // Randomize order for each row - const shuffledCovers = shuffleArray(coverUrls); - - // Create row wrapper - mosaicHTML += `
    `; - mosaicHTML += `
    `; - - // Generate tiles - duplicate 2 times for smooth infinite scroll - for (let duplicate = 0; duplicate < 2; duplicate++) { - for (let i = 0; i < shuffledCovers.length; i++) { - const coverUrl = shuffledCovers[i]; - mosaicHTML += ` -
    -
    -
    - `; - } - } - - mosaicHTML += '
    '; // Close row - mosaicHTML += '
    '; // Close wrapper - } - - mosaicHTML += '
    '; - mosaicHTML += '
    '; // Dark overlay for readability - - return mosaicHTML; -} - -/** - * Open wishlist overview modal showing category breakdown - * This is the NEW entry point for wishlist from dashboard - */ -async function openWishlistOverviewModal() { - try { - showLoadingOverlay('Loading wishlist...'); - - // Fetch wishlist stats - const statsResponse = await fetch('/api/wishlist/stats'); - const statsData = await statsResponse.json(); - - if (!statsResponse.ok) { - throw new Error(statsData.error || 'Failed to fetch wishlist stats'); - } - - const { singles, albums, total } = statsData; - - if (total === 0) { - hideLoadingOverlay(); - showToast('Wishlist is empty. No tracks to process.', 'info'); - return; - } - - // Fetch album covers for mosaic backgrounds - // Limit to 50 tracks per category (enough to get 20 unique covers while being efficient) - const albumCoversPromise = fetch('/api/wishlist/tracks?category=albums&limit=50').then(r => r.json()); - const singleCoversPromise = fetch('/api/wishlist/tracks?category=singles&limit=50').then(r => r.json()); - - const [albumTracksData, singleTracksData] = await Promise.all([albumCoversPromise, singleCoversPromise]); - - // Extract unique album covers (max 20 per category) - const albumCovers = extractUniqueCoverImages(albumTracksData.tracks || [], 20); - const singleCovers = extractUniqueCoverImages(singleTracksData.tracks || [], 20); - - // Create modal if it doesn't exist - let modal = document.getElementById('wishlist-overview-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'wishlist-overview-modal'; - modal.className = 'modal-overlay'; - document.body.appendChild(modal); - } - - // Fetch current cycle - const cycleResponse = await fetch('/api/wishlist/cycle'); - const cycleData = await cycleResponse.json(); - const currentCycle = cycleData.cycle || 'albums'; - - // Format countdown timer - const nextRunSeconds = statsData.next_run_in_seconds || 0; - const countdownText = formatCountdownTime(nextRunSeconds); - const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; - - modal.innerHTML = ` - - `; - - modal.style.display = 'flex'; - hideLoadingOverlay(); - - // Start countdown timer update interval - startWishlistCountdownTimer(currentCycle, nextRunSeconds); - - } catch (error) { - console.error('Error opening wishlist overview:', error); - showToast(`Failed to load wishlist: ${error.message}`, 'error'); - hideLoadingOverlay(); - } -} - -function startWishlistCountdownTimer(currentCycle, initialSeconds) { - // Clear any existing interval - if (wishlistCountdownInterval) { - clearInterval(wishlistCountdownInterval); - } - - let remainingSeconds = initialSeconds; - const nextCycleText = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; - - wishlistCountdownInterval = setInterval(async () => { - remainingSeconds--; - - // Check if auto-processing has started (every 2 seconds to avoid overwhelming backend) - if (remainingSeconds % 2 === 0 || remainingSeconds <= 0) { - // Use WebSocket data if available, otherwise fall back to HTTP - if (socketConnected && _lastWishlistStats) { - const data = _lastWishlistStats; - if (data.is_auto_processing) { - if (!_wishlistAutoProcessingNotified) { - navigateToPage('active-downloads'); - showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); - _wishlistAutoProcessingNotified = true; - } - return; - } - if (remainingSeconds <= 0) { - remainingSeconds = data.next_run_in_seconds || 0; - const timerElement = document.getElementById('wishlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; - } - } - } else { - try { - const response = await fetch('/api/wishlist/stats'); - const data = await response.json(); - - // AUTO-CLOSE DETECTION: If auto-processing started, close modal and notify user (once) - if (data.is_auto_processing) { - if (!_wishlistAutoProcessingNotified) { - console.log('🤖 [Wishlist] Auto-processing detected, closing overview modal'); - closeWishlistOverviewModal(); - showToast('Wishlist auto-processing started. View progress in Download Manager.', 'info'); - _wishlistAutoProcessingNotified = true; - } - return; // Exit interval - } - - // Update remaining seconds if timer expired - if (remainingSeconds <= 0) { - remainingSeconds = data.next_run_in_seconds || 0; - - // Also update cycle in case it changed - const newCycle = data.current_cycle || 'albums'; - const newCycleText = newCycle === 'albums' ? 'Albums/EPs' : 'Singles'; - - const timerElement = document.getElementById('wishlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto: ${newCycleText}${countdownText ? ' in ' + countdownText : ''}`; - } - } - } catch (error) { - console.debug('Error updating wishlist countdown:', error); - } - } // end else (HTTP fallback) - } - - // Always update the display countdown - const timerElement = document.getElementById('wishlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto: ${nextCycleText}${countdownText ? ' in ' + countdownText : ''}`; - } - }, 1000); // Update every second -} - -function closeWishlistOverviewModal() { - console.log('🚪 closeWishlistOverviewModal() called'); - - // Stop countdown timer - if (wishlistCountdownInterval) { - clearInterval(wishlistCountdownInterval); - wishlistCountdownInterval = null; - } - - const modal = document.getElementById('wishlist-overview-modal'); - console.log('Modal element:', modal); - if (modal) { - modal.style.display = 'none'; - console.log('Modal display set to none'); - // Also remove from DOM to ensure clean state - modal.remove(); - console.log('Modal removed from DOM'); - } else { - console.warn('Modal element not found'); - } - window.selectedWishlistCategory = null; - console.log('✅ Modal closed'); -} - -async function cleanupWishlistOverview() { - console.log('🧹 cleanupWishlistOverview() called'); - - if (!await showConfirmDialog({ title: 'Cleanup Wishlist', message: 'This will remove all tracks from the wishlist that already exist in your library. Continue?' })) { - return; - } - - try { - showLoadingOverlay('Cleaning up wishlist...'); - - const response = await fetch('/api/wishlist/cleanup', { - method: 'POST' - }); - - const result = await response.json(); - - if (result.success) { - const removedCount = result.removed_count || 0; - - if (removedCount > 0) { - showToast(`Cleanup complete! Removed ${removedCount} tracks that already exist in your library`, 'success'); - } else { - showToast('No tracks needed to be removed', 'info'); - } - - // Check if wishlist is now empty - const statsResponse = await fetch('/api/wishlist/stats'); - const statsData = await statsResponse.json(); - - if (statsData.total === 0) { - // Wishlist is empty, refresh the page to show empty state - wishlistPageState.isInitialized = false; - await initializeWishlistPage(); - await updateWishlistCount(); - } else { - // Wishlist still has items, refresh the page to show updated counts - wishlistPageState.isInitialized = false; - await initializeWishlistPage(); - } - } else { - showToast(`Failed to cleanup wishlist: ${result.error || 'Unknown error'}`, 'error'); - } - - hideLoadingOverlay(); - - } catch (error) { - console.error('Error cleaning up wishlist:', error); - showToast(`Failed to cleanup wishlist: ${error.message}`, 'error'); - hideLoadingOverlay(); - } -} - -async function clearEntireWishlist() { - console.log('🗑️ clearEntireWishlist() called'); - - if (!await showConfirmDialog({ title: 'Clear Wishlist', message: 'WARNING: This will permanently delete ALL tracks from your wishlist.\n\nThis action cannot be undone.\n\nAre you sure you want to continue?', confirmText: 'Clear All', destructive: true })) { - console.log('User cancelled confirmation'); - return; - } - - console.log('User confirmed, proceeding with clear...'); - - try { - showLoadingOverlay('Clearing wishlist...'); - console.log('Loading overlay shown'); - - const response = await fetch('/api/wishlist/clear', { - method: 'POST' - }); - console.log('API response received:', response.status); - - const result = await response.json(); - console.log('Clear wishlist response:', result); - - hideLoadingOverlay(); - console.log('Loading overlay hidden'); - - if (result.success) { - console.log('Clear was successful, showing toast...'); - showToast('Wishlist cleared successfully', 'success'); - - console.log('Updating wishlist button count...'); - await updateWishlistCount(); - - console.log('Refreshing wishlist page...'); - wishlistPageState.isInitialized = false; - await initializeWishlistPage(); - } else { - console.error('Clear failed:', result.error); - showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error'); - } - - } catch (error) { - console.error('Error clearing wishlist:', error); - hideLoadingOverlay(); - showToast(`Failed to clear wishlist: ${error.message}`, 'error'); - } -} - -async function selectWishlistCategory(category) { - try { - window.selectedWishlistCategory = category; - - const tracksList = document.getElementById('wishlist-tracks-list'); - const categoryTracksSection = document.getElementById('wishlist-category-tracks'); - const nebulaEl = document.getElementById('wishlist-nebula'); - const downloadBtn = document.getElementById('wishlist-download-btn'); - const categoryName = document.getElementById('wishlist-category-name'); - - if (nebulaEl) nebulaEl.style.display = 'none'; - categoryTracksSection.style.display = 'block'; - downloadBtn.style.display = 'inline-block'; - categoryName.textContent = category === 'albums' ? 'Albums / EPs' : 'Singles'; - - tracksList.innerHTML = '
    Loading tracks...
    '; - - const _wlPageSize = window._wlNextLimit || 200; - window._wlNextLimit = null; - const response = await fetch(`/api/wishlist/tracks?category=${category}&limit=${_wlPageSize}`); - const data = await response.json(); - - if (!response.ok) throw new Error(data.error || 'Failed to fetch tracks'); - - const tracks = data.tracks || []; - const totalAvailable = data.total || tracks.length; - window._wlCategory = category; - window._wlOffset = tracks.length; - window._wlTotal = totalAvailable; - - if (tracks.length === 0) { - tracksList.innerHTML = '
    No tracks in this category
    '; - return; - } - - // For Albums/EPs, group by album - if (category === 'albums') { - const albumGroups = {}; - - tracks.forEach(track => { - let spotifyData = track.spotify_data; - if (typeof spotifyData === 'string') { - try { - spotifyData = JSON.parse(spotifyData); - } catch (e) { - spotifyData = null; - } - } - - const rawAlbum = spotifyData?.album; - const albumName = (typeof rawAlbum === 'string' ? rawAlbum : rawAlbum?.name) || 'Unknown Album'; - - // Handle both object format {name: '...'} and sanitized string format - let artistName = 'Unknown Artist'; - let artistId = null; - if (spotifyData?.artists?.[0]?.name) { - // Object format from Spotify API - artistName = spotifyData.artists[0].name; - artistId = spotifyData.artists[0].id; - } else if (spotifyData?.artists?.[0] && typeof spotifyData.artists[0] === 'string') { - // Sanitized string format - artistName = spotifyData.artists[0]; - } else if (Array.isArray(track.artists) && track.artists.length > 0) { - // Fallback to track.artists - if (typeof track.artists[0] === 'string') { - artistName = track.artists[0]; - } else if (track.artists[0]?.name) { - artistName = track.artists[0].name; - artistId = track.artists[0].id; - } - } - - const albumImage = spotifyData?.album?.images?.[0]?.url || ''; - - // Use album ID if available, otherwise create unique key from album + artist - // Sanitize the ID to remove all special characters that could break DOM IDs or CSS selectors - const albumId = spotifyData?.album?.id || `${albumName}_${artistName}` - .replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove all special chars except spaces, underscores, hyphens - .replace(/\s+/g, '_') // Replace spaces with underscores - .toLowerCase(); - - if (!albumGroups[albumId]) { - albumGroups[albumId] = { - albumName, - artistName, - artistId, - albumImage, - tracks: [] - }; - } - - const spotifyTrackId = track.spotify_track_id || track.id || ''; - - albumGroups[albumId].tracks.push({ - name: track.name || 'Unknown Track', - artistName, - trackNumber: spotifyData?.track_number || 0, - spotifyTrackId - }); - }); - - // Render album cards - let albumsHTML = '
    '; - Object.entries(albumGroups).forEach(([albumId, albumData]) => { - // Sort tracks by track number - albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber); - - const tracksListHTML = albumData.tracks.map(track => ` -
    - - ${track.name} - -
    - `).join(''); - - // Handle missing album images with a placeholder - const albumImageStyle = albumData.albumImage - ? `background-image: url('${albumData.albumImage}')` - : `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`; - const albumImageContent = albumData.albumImage ? '' : '💿'; - - albumsHTML += ` -
    -
    - -
    ${albumImageContent}
    -
    -
    ${albumData.albumName}
    -
    ${albumData.artistName}
    -
    ${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}
    -
    - -
    -
    - -
    - `; - }); - albumsHTML += '
    '; - - tracksList.innerHTML = albumsHTML; - if (totalAvailable > tracks.length) { - tracksList.insertAdjacentHTML('beforeend', - ``); - } - _attachWishlistDelegation(tracksList); - - } else { - // For Singles, show list with album images - let tracksHTML = ''; - tracks.forEach((track, index) => { - const trackName = track.name || 'Unknown Track'; - - let spotifyData = track.spotify_data; - if (typeof spotifyData === 'string') { - try { - spotifyData = JSON.parse(spotifyData); - } catch (e) { - spotifyData = null; - } - } - - let artistName = 'Unknown Artist'; - if (spotifyData?.artists?.[0]?.name) { - artistName = spotifyData.artists[0].name; - } else if (Array.isArray(track.artists) && track.artists.length > 0) { - if (typeof track.artists[0] === 'string') { - artistName = track.artists[0]; - } else if (track.artists[0]?.name) { - artistName = track.artists[0].name; - } - } - - let albumName = 'Unknown Album'; - if (spotifyData?.album?.name) { - albumName = spotifyData.album.name; - } else if (typeof track.album === 'string') { - albumName = track.album; - } else if (track.album?.name) { - albumName = track.album.name; - } - - const albumImage = spotifyData?.album?.images?.[0]?.url || ''; - const spotifyTrackId = track.spotify_track_id || track.id || ''; - - tracksHTML += ` -
    - -
    -
    -
    ${trackName}
    -
    ${artistName} • ${albumName}
    -
    - -
    - `; - }); - - tracksList.innerHTML = tracksHTML; - if (totalAvailable > tracks.length) { - tracksList.insertAdjacentHTML('beforeend', - ``); - } - _attachWishlistDelegation(tracksList); - } - - } catch (error) { - console.error('Error loading category tracks:', error); - showToast(`Failed to load tracks: ${error.message}`, 'error'); - } -} - -async function loadMoreWishlistTracks() { - const btn = document.querySelector('.wishlist-load-more-btn'); - if (btn) { btn.textContent = 'Loading...'; btn.disabled = true; } - // Increase page size and reload - window._wlOffset = (window._wlOffset || 200) + 200; - // Override the page size for this reload - window._wlNextLimit = window._wlOffset; - selectWishlistCategory(window._wlCategory); -} - -function _attachWishlistDelegation(container) { - // Single click handler for all wishlist album/track interactions - container.addEventListener('click', (e) => { - const target = e.target; - - // Skip checkbox wrapper clicks — handled by change listener - if (target.closest('.wishlist-checkbox-wrapper')) return; - - // Album header click (expand/collapse) - const header = target.closest('.wishlist-album-header'); - if (header && !target.closest('.wishlist-delete-album-btn')) { - toggleAlbumTracks(header.dataset.albumId); - return; - } - - // Album delete button - const albumDelBtn = target.closest('.wishlist-delete-album-btn'); - if (albumDelBtn) { - e.stopPropagation(); - removeAlbumFromWishlist(albumDelBtn.dataset.albumId, e); - return; - } - - // Track delete button - const trackDelBtn = target.closest('.wishlist-delete-btn'); - if (trackDelBtn && trackDelBtn.dataset.trackId) { - e.stopPropagation(); - removeTrackFromWishlist(trackDelBtn.dataset.trackId, e); - return; - } - }); - - // Separate change handler for checkboxes (more reliable than click for inputs) - container.addEventListener('change', (e) => { - const target = e.target; - if (target.classList.contains('wishlist-album-select-all-cb')) { - toggleWishlistAlbumSelection(target.dataset.albumId, target.checked); - } else if (target.classList.contains('wishlist-select-cb')) { - updateWishlistBatchBar(); - } - }); -} - -function backToCategories() { - _nebulaBack(); -} - -function toggleAlbumTracks(albumId) { - const tracksElement = document.getElementById(`tracks-${albumId}`); - const expandIcon = document.getElementById(`expand-icon-${albumId}`); - - if (tracksElement.style.display === 'none') { - tracksElement.style.display = 'block'; - expandIcon.textContent = '▲'; - } else { - tracksElement.style.display = 'none'; - expandIcon.textContent = '▼'; - } -} - -/** - * Get all checked wishlist track checkboxes - */ -function getCheckedWishlistTracks() { - return Array.from(document.querySelectorAll('.wishlist-select-cb:checked')); -} - -/** - * Toggle select all / deselect all tracks in the current wishlist category - */ -function toggleWishlistSelectAll() { - const allCheckboxes = document.querySelectorAll('.wishlist-select-cb'); - const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb'); - const btn = document.getElementById('wishlist-select-all-btn'); - const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); - - const newState = !allChecked; - - allCheckboxes.forEach(cb => { cb.checked = newState; }); - albumCheckboxes.forEach(cb => { cb.checked = newState; }); - - // Expand all albums when selecting all - if (newState) { - document.querySelectorAll('.wishlist-album-tracks').forEach(el => { - el.style.display = 'block'; - }); - document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => { - icon.textContent = '▲'; - }); - } - - if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All'; - updateWishlistBatchBar(); -} - -/** - * Update the wishlist batch action bar based on checkbox selection - */ -function updateWishlistBatchBar() { - const checked = getCheckedWishlistTracks(); - const bar = document.getElementById('wishlist-batch-bar'); - const countEl = document.getElementById('wishlist-batch-count'); - - if (!bar || !countEl) return; - - if (checked.length > 0) { - bar.style.display = 'flex'; - countEl.textContent = `${checked.length} selected`; - } else { - bar.style.display = 'none'; - } - - // Sync the Select All button text - const btn = document.getElementById('wishlist-select-all-btn'); - if (btn) { - const allCheckboxes = document.querySelectorAll('.wishlist-select-cb'); - const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked); - btn.textContent = allChecked ? 'Deselect All' : 'Select All'; - } -} - -/** - * Toggle all track checkboxes within an album when album header checkbox is clicked - */ -function toggleWishlistAlbumSelection(albumId, checked) { - const tracksContainer = document.getElementById(`tracks-${albumId}`); - if (tracksContainer) { - // Expand the album tracks if selecting - if (checked) { - tracksContainer.style.display = 'block'; - const expandIcon = document.getElementById(`expand-icon-${albumId}`); - if (expandIcon) expandIcon.textContent = '▲'; - } - tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => { - cb.checked = checked; - }); - } - updateWishlistBatchBar(); -} - -/** - * Batch remove selected tracks from wishlist - */ -async function batchRemoveFromWishlist() { - const checked = getCheckedWishlistTracks(); - if (checked.length === 0) return; - - const count = checked.length; - const confirmed = await showConfirmationModal( - 'Remove Tracks', - `Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`, - '🗑️' - ); - - if (!confirmed) return; - - const trackIds = checked.map(cb => cb.getAttribute('data-track-id')); - - try { - const response = await fetch('/api/wishlist/remove-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ spotify_track_ids: trackIds }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`Removed ${data.removed} track(s) from wishlist`, 'success'); - - // Reload the current category to refresh the list - if (window.selectedWishlistCategory) { - await selectWishlistCategory(window.selectedWishlistCategory); - } - - // Update wishlist count in sidebar - await updateWishlistCount(); - } else { - showToast(`Failed to remove tracks: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error batch removing from wishlist:', error); - showToast('Failed to remove tracks from wishlist', 'error'); - } -} - -function showConfirmationModal(title, message, icon = '⚠️') { - return new Promise((resolve) => { - // Create modal if it doesn't exist - let modal = document.getElementById('confirmation-modal-overlay'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'confirmation-modal-overlay'; - modal.className = 'confirmation-modal-overlay'; - document.body.appendChild(modal); - } - - // Set modal content - modal.innerHTML = ` -
    -
    ${icon}
    -
    ${title}
    -
    ${message}
    -
    - - -
    -
    - `; - - // Show modal with animation - setTimeout(() => { - modal.classList.add('show'); - }, 10); - - // Escape key handler - defined outside so we can remove it - const handleEscape = (e) => { - if (e.key === 'Escape') { - handleCancel(); - } - }; - - // Handle button clicks - const handleCancel = () => { - document.removeEventListener('keydown', handleEscape); - modal.classList.remove('show'); - setTimeout(() => { - modal.remove(); - }, 200); - resolve(false); - }; - - const handleConfirm = () => { - document.removeEventListener('keydown', handleEscape); - modal.classList.remove('show'); - setTimeout(() => { - modal.remove(); - }, 200); - resolve(true); - }; - - document.getElementById('confirm-cancel').addEventListener('click', handleCancel); - document.getElementById('confirm-yes').addEventListener('click', handleConfirm); - - // Close on overlay click - modal.addEventListener('click', (e) => { - if (e.target === modal) { - handleCancel(); - } - }); - - // Add Escape key listener - document.addEventListener('keydown', handleEscape); - }); -} - -async function removeTrackFromWishlist(spotifyTrackId, event) { - // Stop event propagation to prevent triggering parent click handlers - if (event) { - event.stopPropagation(); - } - - const confirmed = await showConfirmationModal( - 'Remove Track', - 'Are you sure you want to remove this track from your wishlist?', - '🗑️' - ); - - if (!confirmed) { - return; - } - - try { - const response = await fetch('/api/wishlist/remove-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ spotify_track_id: spotifyTrackId }) - }); - - const data = await response.json(); - - if (data.success) { - showToast('Track removed from wishlist', 'success'); - - // Reload the current category to refresh the list - if (window.selectedWishlistCategory) { - await selectWishlistCategory(window.selectedWishlistCategory); - } - - // Update wishlist count in sidebar - await updateWishlistCount(); - } else { - showToast(`Failed to remove track: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error removing track from wishlist:', error); - showToast('Failed to remove track from wishlist', 'error'); - } -} - -async function removeAlbumFromWishlist(albumId, event) { - // Stop event propagation to prevent triggering parent click handlers - if (event) { - event.stopPropagation(); - } - - const confirmed = await showConfirmationModal( - 'Remove Album', - 'Are you sure you want to remove all tracks from this album from your wishlist?', - '💿' - ); - - if (!confirmed) { - return; - } - - try { - const response = await fetch('/api/wishlist/remove-album', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ album_id: albumId }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`Removed ${data.removed_count} track(s) from wishlist`, 'success'); - - // Reload the current category to refresh the list - if (window.selectedWishlistCategory) { - await selectWishlistCategory(window.selectedWishlistCategory); - } - - // Update wishlist count in sidebar - await updateWishlistCount(); - } else { - showToast(`Failed to remove album: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Error removing album from wishlist:', error); - showToast('Failed to remove album from wishlist', 'error'); - } -} - -async function downloadSelectedCategory() { - const category = window.selectedWishlistCategory; - if (!category) { - showToast('No category selected', 'error'); - return; - } - - // Collect checked track IDs - const checkedBoxes = document.querySelectorAll('.wishlist-select-cb:checked'); - const selectedTrackIds = new Set(Array.from(checkedBoxes).map(cb => cb.dataset.trackId).filter(Boolean)); - - await openDownloadMissingWishlistModal(category, selectedTrackIds.size > 0 ? selectedTrackIds : null); -} - -async function openDownloadMissingWishlistModal(category = null, selectedTrackIds = null) { - showLoadingOverlay('Loading wishlist...'); - const playlistId = "wishlist"; // Use a consistent ID for wishlist - - // Check if a process is already active for the wishlist - if (activeDownloadProcesses[playlistId]) { - console.log(`Modal for wishlist already exists. Showing it.`); - const process = activeDownloadProcesses[playlistId]; - if (process.modalElement) { - // Show helpful message if it's a completed process - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - WishlistModalState.setVisible(); // Track that modal is now visible - } - hideLoadingOverlay(); // Always hide overlay before returning - return; // Don't create a new one - } - - console.log(`📥 Opening Download Missing Tracks modal for wishlist${category ? ' (' + category + ')' : ''}`); - - // Store category in global state for when process starts - window.currentWishlistCategory = category; - - // Fetch actual wishlist tracks from the server - let tracks; - try { - // Build API URL with optional category filter - const apiUrl = category ? `/api/wishlist/tracks?category=${category}` : '/api/wishlist/tracks'; - - const response = await fetch('/api/wishlist/count'); - const countData = await response.json(); - if (countData.count === 0) { - showToast('Wishlist is empty. No tracks to download.', 'info'); - hideLoadingOverlay(); - return; - } - - // Fetch the actual wishlist tracks for display (filtered by category if specified) - const tracksResponse = await fetch(apiUrl); - if (!tracksResponse.ok) { - throw new Error('Failed to fetch wishlist tracks'); - } - const tracksData = await tracksResponse.json(); - tracks = tracksData.tracks || []; - - // Filter to only selected tracks if user made a selection - if (selectedTrackIds && selectedTrackIds.size > 0) { - tracks = tracks.filter(t => selectedTrackIds.has(t.id) || selectedTrackIds.has(t.spotify_track_id)); - console.log(`📥 Filtered to ${tracks.length} selected tracks (from ${tracksData.tracks?.length || 0} total)`); - } - - } catch (error) { - showToast(`Failed to fetch wishlist data: ${error.message}`, 'error'); - hideLoadingOverlay(); - return; - } - - currentPlaylistTracks = tracks; - currentModalPlaylistId = playlistId; - - let modal = document.createElement('div'); - modal.id = `download-missing-modal-${playlistId}`; // Unique ID - modal.className = 'download-missing-modal'; // Use class for styling - modal.style.display = 'none'; // Start hidden - document.body.appendChild(modal); - - // Register the new process in our global state tracker - activeDownloadProcesses[playlistId] = { - status: 'idle', // idle, running, complete, cancelled - modalElement: modal, - poller: null, - batchId: null, - playlist: { id: playlistId, name: "Wishlist" }, // Create a pseudo-playlist object - tracks: tracks - }; - - // Generate hero section for wishlist context - const heroContext = { - type: 'wishlist', - trackCount: tracks.length, - playlistId: playlistId - }; - - modal.innerHTML = ` -
    -
    - ${generateDownloadModalHeroSection(heroContext)} -
    - -
    -
    -
    -
    - 🔍 Library Analysis - Ready to start -
    -
    -
    -
    -
    -
    -
    - ⏬ Downloads - Waiting for analysis -
    -
    -
    -
    -
    -
    - -
    -
    -

    📋 Track Analysis & Download Status

    -
    -
    - - - - - - - - - - - - - ${tracks.map((track, index) => ` - - - - - - - - - `).join('')} - -
    #TrackArtistLibrary MatchDownload StatusActions
    ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}🔍 Pending--
    -
    -
    -
    - - -
    - `; - - applyProgressiveTrackRendering(playlistId, tracks.length); - modal.style.display = 'flex'; - hideLoadingOverlay(); - WishlistModalState.setVisible(); // Track that new wishlist modal is now visible -} - -async function startWishlistMissingTracksProcess(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) return; - - console.log(`🚀 Kicking off wishlist missing tracks process`); - try { - process.status = 'running'; - // Note: Wishlist processes don't affect sync page refresh button state - document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; - document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; - - // Check if force download toggle is enabled - const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); - const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; - - // Hide the force download toggle during processing - const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; - if (forceToggleContainer) { - forceToggleContainer.style.display = 'none'; - } - - // Extract track IDs from what the user is currently seeing in the modal - // This prevents race conditions where wishlist changes between modal open and analysis start - const trackIds = process.tracks ? process.tracks.map(t => t.spotify_track_id || t.id).filter(id => id) : null; - console.log(`🎯 [Wishlist] Sending ${trackIds ? trackIds.length : 'all'} specific track IDs to prevent race condition`); - - const response = await fetch('/api/wishlist/download_missing', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - force_download_all: forceDownloadAll, - category: window.currentWishlistCategory, // Keep for backward compat - track_ids: trackIds // NEW: Send exact tracks to process - }) - }); - - const data = await response.json(); - if (!data.success) { - // Special handling for auto-processing conflict - if (response.status === 409) { - console.log('🤖 [Wishlist] Auto-processing is running, redirecting to download manager'); - showToast('Wishlist auto-processing is already running. Opening Download Manager...', 'info'); - - // Close wishlist modal and show download manager - const wishlistModal = document.getElementById('download-modal-wishlist'); - if (wishlistModal) { - wishlistModal.remove(); - } - delete activeDownloadProcesses[playlistId]; - - // Open download manager to show active batch - setTimeout(() => { - const downloadManager = document.getElementById('download-manager-modal'); - if (downloadManager) { - downloadManager.style.display = 'flex'; - } else { - openDownloadManagerModal(); - } - }, 300); - return; - } - // Special handling for rate limit - if (response.status === 429) { - throw new Error(`${data.error} Try closing some other download processes first.`); - } - throw new Error(data.error); - } - - process.batchId = data.batch_id; - console.log(`✅ Wishlist process started successfully. Batch ID: ${data.batch_id}`); - - // Start polling for updates - startModalDownloadPolling(playlistId); - - } catch (error) { - console.error('Error starting wishlist missing tracks process:', error); - showToast(`Error: ${error.message}`, 'error'); - - // Reset UI state on error - process.status = 'idle'; - // Note: Wishlist processes don't affect sync page refresh button state - document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'inline-block'; - document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; - - // Show the force download toggle again - const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); - if (forceToggleContainer) { - forceToggleContainer.style.display = 'flex'; - } - } -} - -async function startMissingTracksProcess(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) return; - - console.log(`🚀 Kicking off unified missing tracks process for playlist: ${playlistId}`); - try { - process.status = 'running'; - updatePlaylistCardUI(playlistId); - updateRefreshButtonState(); - - // Set album to downloading status if this is an artist album - if (playlistId.startsWith('artist_album_')) { - // Format: artist_album_{artist.id}_{album.id} - const parts = playlistId.split('_'); - if (parts.length >= 4) { - const albumId = parts.slice(3).join('_'); // In case album ID has underscores - const totalTracks = process.tracks ? process.tracks.length : 0; - setAlbumDownloadingStatus(albumId, 0, totalTracks); - console.log(`🔄 Set album ${albumId} to downloading status (0/${totalTracks} tracks)`); - console.log(`🔍 Virtual playlist ID: ${playlistId} → Album ID: ${albumId}`); - } - } - - // Update YouTube playlist phase to 'downloading' if this is a YouTube playlist - if (playlistId.startsWith('youtube_')) { - const urlHash = playlistId.replace('youtube_', ''); - updateYouTubeCardPhase(urlHash, 'downloading'); - // Also update mirrored playlist card if applicable - if (urlHash.startsWith('mirrored_')) { - updateMirroredCardPhase(urlHash, 'downloading'); - } - } - - // Update Tidal playlist phase to 'downloading' if this is a Tidal playlist - if (playlistId.startsWith('tidal_')) { - const tidalPlaylistId = playlistId.replace('tidal_', ''); - if (tidalPlaylistStates[tidalPlaylistId]) { - tidalPlaylistStates[tidalPlaylistId].phase = 'downloading'; - updateTidalCardPhase(tidalPlaylistId, 'downloading'); - console.log(`🔄 Updated Tidal playlist ${tidalPlaylistId} to downloading phase`); - } - } - - // Update Beatport chart phase to 'downloading' if this is a Beatport chart - if (playlistId.startsWith('beatport_')) { - const urlHash = playlistId.replace('beatport_', ''); - const state = youtubePlaylistStates[urlHash]; - - if (state && state.is_beatport_playlist) { - const chartHash = state.beatport_chart_hash || urlHash; - - // Update frontend states - state.phase = 'downloading'; - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'downloading'; - } - - // Update card UI - updateBeatportCardPhase(chartHash, 'downloading'); - - // Update backend state - try { - fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'downloading' }) - }); - } catch (error) { - console.warn('⚠️ Error updating backend Beatport phase to downloading:', error); - } - - console.log(`🔄 Updated Beatport chart ${chartHash} to downloading phase`); - } - } - - // Update Spotify Public playlist phase to 'downloading' if this is a Spotify Public playlist - if (playlistId.startsWith('spotify_public_')) { - const urlHash = playlistId.replace('spotify_public_', ''); - if (spotifyPublicPlaylistStates[urlHash]) { - spotifyPublicPlaylistStates[urlHash].phase = 'downloading'; - spotifyPublicPlaylistStates[urlHash].convertedSpotifyPlaylistId = playlistId; - updateSpotifyPublicCardPhase(urlHash, 'downloading'); - - try { - fetch(`/api/spotify-public/update_phase/${urlHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) - }); - } catch (error) { - console.warn('Error updating backend Spotify Public phase to downloading:', error); - } - - console.log(`🔄 Updated Spotify Public playlist ${urlHash} to downloading phase`); - } - } - - // Update Deezer playlist phase to 'downloading' if this is a Deezer playlist - if (playlistId.startsWith('deezer_')) { - const deezerPlaylistId = playlistId.replace('deezer_', ''); - if (deezerPlaylistStates[deezerPlaylistId]) { - deezerPlaylistStates[deezerPlaylistId].phase = 'downloading'; - deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId = playlistId; - updateDeezerCardPhase(deezerPlaylistId, 'downloading'); - - try { - fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'downloading', converted_spotify_playlist_id: playlistId }) - }); - } catch (error) { - console.warn('Error updating backend Deezer phase to downloading:', error); - } - - console.log(`🔄 Updated Deezer playlist ${deezerPlaylistId} to downloading phase`); - } - } - - // Update ListenBrainz playlist phase to 'downloading' if this is a ListenBrainz playlist - if (playlistId.startsWith('listenbrainz_')) { - const playlistMbid = playlistId.replace('listenbrainz_', ''); - const state = listenbrainzPlaylistStates[playlistMbid]; - - if (state) { - // Update frontend state - state.phase = 'downloading'; - - // Update backend state - try { - fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'downloading' }) - }); - } catch (error) { - console.warn('⚠️ Error updating backend ListenBrainz phase to downloading:', error); - } - - console.log(`🔄 Updated ListenBrainz playlist ${playlistMbid} to downloading phase`); - } - } - document.getElementById(`begin-analysis-btn-${playlistId}`).style.display = 'none'; - document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'inline-block'; - - // Hide wishlist button if it exists (only for non-wishlist modals) - const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); - if (wishlistBtn) { - wishlistBtn.style.display = 'none'; - } - - // Add to discover download sidebar if this is a discover page download - if (process.discoverMetadata) { - const playlistName = process.playlist.name; - const imageUrl = process.discoverMetadata.imageUrl; - const type = process.discoverMetadata.type; - addDiscoverDownload(playlistId, playlistName, type, imageUrl); - console.log(`📥 [BEGIN ANALYSIS] Added discover download: ${playlistName}`); - } - - // Check if force download toggle is enabled - const forceDownloadCheckbox = document.getElementById(`force-download-all-${playlistId}`); - const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; - - // Check if playlist folder mode toggle is enabled (only for sync page playlists) - const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`); - const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false; - - // Hide the force download toggle during processing - const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; - if (forceToggleContainer) { - forceToggleContainer.style.display = 'none'; - } - - // Filter tracks based on checkbox selection (if checkboxes exist in this modal) - const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); - let selectedTracks = process.tracks; - if (tbody) { - const allCbs = tbody.querySelectorAll('.track-select-cb'); - if (allCbs.length > 0) { - // Checkboxes exist — filter to only checked tracks - const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked'); - const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex))); - console.log(`🔲 [Track Selection] Total checkboxes: ${allCbs.length}, Checked: ${checkedCbs.length}`); - console.log(`🔲 [Track Selection] Checked indices:`, [...selectedIndices]); - console.log(`🔲 [Track Selection] process.tracks has ${process.tracks.length} items, first: "${process.tracks[0]?.name}", last: "${process.tracks[process.tracks.length - 1]?.name}"`); - // Stamp each selected track with its original table index so the backend - // maps status updates back to the correct modal row - selectedTracks = process.tracks - .map((track, i) => ({ ...track, _original_index: i })) - .filter(track => selectedIndices.has(track._original_index)); - console.log(`🔲 [Track Selection] Filtered to ${selectedTracks.length} tracks:`, selectedTracks.map(t => `[${t._original_index}] ${t.name}`)); - // Disable checkboxes once analysis starts - allCbs.forEach(cb => { cb.disabled = true; }); - } - } - const selectAllCb = document.getElementById(`select-all-${playlistId}`); - if (selectAllCb) selectAllCb.disabled = true; - - // Prepare request body - add album/artist context for artist album downloads - const wingItState = youtubePlaylistStates[playlistId] || {}; - const isWingIt = wingItState.wing_it || false; - const requestBody = { - tracks: selectedTracks, - force_download_all: forceDownloadAll || isWingIt, - wing_it: isWingIt, - }; - - // If this is an artist album download, use album name and include full context - // Match 'artist_album_', 'enhanced_search_album_', 'discover_album_', and 'seasonal_album_' prefixes - // Note: 'enhanced_search_track_' is excluded — single track search results use singles context - const _isAlbumContext = playlistId.startsWith('artist_album_') || playlistId.startsWith('enhanced_search_album_') || playlistId.startsWith('discover_album_') || playlistId.startsWith('seasonal_album_') || playlistId.startsWith('spotify_library_') || playlistId.startsWith('issue_download_') || playlistId.startsWith('library_redownload_') || playlistId.startsWith('beatport_release_'); - const _isSearchTrack = playlistId.startsWith('enhanced_search_track_') || playlistId.startsWith('gsearch_track_'); - if (_isAlbumContext || _isSearchTrack) { - requestBody.playlist_name = process.album?.name || process.playlist.name; - requestBody.is_album_download = _isAlbumContext; // false for single track search results - requestBody.album_context = process.album; // Full Spotify album object - requestBody.artist_context = process.artist; // Full Spotify artist object - console.log(`🎵 [${_isAlbumContext ? 'Album' : 'Single Track'}] Sending context: ${process.album?.name} by ${process.artist?.name}`); - } else { - // For playlists/wishlists, use the virtual playlist name - requestBody.playlist_name = process.playlist.name; - // Add playlist folder mode flag for sync page playlists - requestBody.playlist_folder_mode = playlistFolderMode; - if (playlistFolderMode) { - console.log(`📁 [Playlist Folder] Enabled for playlist: ${process.playlist.name}`); - } - } - - const response = await fetch(`/api/playlists/${playlistId}/start-missing-process`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }); - - const data = await response.json(); - if (!data.success) { - // Special handling for rate limit - if (response.status === 429) { - throw new Error(`${data.error} Try closing some other download processes first.`); - } - throw new Error(data.error); - } - - process.batchId = data.batch_id; - - // Update Beatport backend state with download_process_id now that we have the batchId - if (playlistId.startsWith('beatport_')) { - const urlHash = playlistId.replace('beatport_', ''); - const state = youtubePlaylistStates[urlHash]; - if (state && state.is_beatport_playlist) { - const chartHash = state.beatport_chart_hash || urlHash; - try { - fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phase: 'downloading', - download_process_id: data.batch_id - }) - }); - console.log(`🔄 Updated Beatport backend with download_process_id: ${data.batch_id}`); - } catch (error) { - console.warn('⚠️ Error updating Beatport backend with download_process_id:', error); - } - } - } - - // Update ListenBrainz backend state with download_process_id and convertedSpotifyPlaylistId - if (playlistId.startsWith('listenbrainz_')) { - const playlistMbid = playlistId.replace('listenbrainz_', ''); - const state = listenbrainzPlaylistStates[playlistMbid]; - if (state) { - // Store in frontend state - state.download_process_id = data.batch_id; - state.convertedSpotifyPlaylistId = playlistId; - - // Update backend state - try { - fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phase: 'downloading', - download_process_id: data.batch_id, - converted_spotify_playlist_id: playlistId - }) - }); - console.log(`🔄 Updated ListenBrainz backend with download_process_id: ${data.batch_id}`); - } catch (error) { - console.warn('⚠️ Error updating ListenBrainz backend with download_process_id:', error); - } - } - } - - startModalDownloadPolling(playlistId); - } catch (error) { - showToast(`Failed to start process: ${error.message}`, 'error'); - process.status = 'cancelled'; - - // Reset button states on error - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); - const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); - if (beginBtn) beginBtn.style.display = 'inline-block'; - if (cancelBtn) cancelBtn.style.display = 'none'; - if (wishlistBtn) wishlistBtn.style.display = 'inline-block'; - - // Show the force download toggle again - const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); - if (forceToggleContainer) { - forceToggleContainer.style.display = 'flex'; - } - - cleanupDownloadProcess(playlistId); - } -} - - -function updateTrackAnalysisResults(playlistId, results) { - // Update match results for all rows (tracks are now pre-populated) - for (const result of results) { - const matchElement = document.getElementById(`match-${playlistId}-${result.track_index}`); - if (matchElement) { - matchElement.textContent = result.found ? '✅ Found' : '❌ Missing'; - matchElement.className = `track-match-status ${result.found ? 'match-found' : 'match-missing'}`; - } - } -} - - - -// ============================================================================ -// GLOBAL BATCHED POLLING SYSTEM - Optimized for multiple concurrent modals -// ============================================================================ - -let globalDownloadStatusPoller = null; -let globalPollingFailureCount = 0; // Track consecutive failures for exponential backoff -let globalPollingBaseInterval = 2000; // Base polling interval in ms - MATCHES sync.py exactly - -function startGlobalDownloadPolling() { - // Always run HTTP polling as a fallback — WebSocket connections can silently - // stop delivering messages (room subscription lost, server emit error, proxy - // timeout) without triggering a disconnect event. The 2-second poll is cheap - // (single batched request) and ensures modals never go stale. - if (globalDownloadStatusPoller) { - console.debug('🔄 [Global Polling] Already running, skipping start'); - return; // Prevent duplicate pollers - } - - console.log('🔄 [Global Polling] Starting batched download status polling'); - - globalDownloadStatusPoller = setInterval(async () => { - if (document.hidden) return; // Skip polling when tab is not visible - // Get all active processes that need polling - const activeBatchIds = []; - const batchToPlaylistMap = {}; - let hasOpenWishlistModal = false; - - Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { - // Include running AND recently-completed batches — ensures late task - // status updates still reach the modal so rows don't freeze mid-download - if (process.batchId && (process.status === 'running' || process.status === 'complete')) { - activeBatchIds.push(process.batchId); - batchToPlaylistMap[process.batchId] = playlistId; - } - - // Check if there's an open wishlist modal (visible and idle/waiting) - if (playlistId === 'wishlist' && process.modalElement && - process.modalElement.style.display === 'flex' && - (!process.batchId || process.status !== 'running')) { - hasOpenWishlistModal = true; - } - }); - - // Special handling for open wishlist modal - check for new auto-processing - if (hasOpenWishlistModal) { - try { - const response = await fetch('/api/active-processes'); - if (response.ok) { - const data = await response.json(); - const processes = data.active_processes || []; - const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); - - if (serverWishlistProcess) { - console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); - await rehydrateModal(serverWishlistProcess, false); // false = not user-requested - } - } - } catch (error) { - console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error); - } - } - - if (activeBatchIds.length === 0) { - console.debug('📊 [Global Polling] No active processes, continuing polling'); - return; - } - - try { - // Single batched API call for all active processes - const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); - const response = await fetch(`/api/download_status/batch?${queryParams}`); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); - - // Process each batch's status data using existing logic - Object.entries(data.batches).forEach(([batchId, statusData]) => { - const playlistId = batchToPlaylistMap[batchId]; - if (!playlistId || statusData.error) { - if (statusData.error) { - console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error); - } - return; - } - - // Use existing modal update logic - zero changes needed! - processModalStatusUpdate(playlistId, statusData); - }); - - // ENHANCED: Reset failure count on successful polling - globalPollingFailureCount = 0; - - } catch (error) { - console.error('❌ [Global Polling] Batched request failed:', error); - - // ENHANCED: Implement exponential backoff on failure - globalPollingFailureCount++; - - if (globalPollingFailureCount >= 5) { - console.error(`🚨 [Global Polling] ${globalPollingFailureCount} consecutive failures, continuing with backoff`); - // Don't stop polling - just continue with exponential backoff - } - - // Exponential backoff: increase interval temporarily - const backoffInterval = Math.min(globalPollingBaseInterval * Math.pow(2, globalPollingFailureCount - 1), 8000); - console.warn(`⚠️ [Global Polling] Failure ${globalPollingFailureCount}/5, backing off to ${backoffInterval}ms`); - - // Temporarily adjust the polling interval - if (globalDownloadStatusPoller) { - clearInterval(globalDownloadStatusPoller); - globalDownloadStatusPoller = null; - - // Restart with backoff interval - setTimeout(() => { - if (Object.keys(activeDownloadProcesses).length > 0) { - startGlobalDownloadPollingWithInterval(backoffInterval); - } - }, backoffInterval); - } - } - }, globalPollingBaseInterval); // Use base interval initially -} - -function startGlobalDownloadPollingWithInterval(interval) { - if (globalDownloadStatusPoller) { - console.debug('🔄 [Global Polling] Already running, skipping start with interval'); - return; - } - - console.log(`🔄 [Global Polling] Starting with interval ${interval}ms`); - - // Use the exact same logic as startGlobalDownloadPolling but with custom interval - globalDownloadStatusPoller = setInterval(async () => { - const activeBatchIds = []; - const batchToPlaylistMap = {}; - let hasOpenWishlistModal = false; - - Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => { - if (process.batchId && (process.status === 'running' || process.status === 'complete')) { - activeBatchIds.push(process.batchId); - batchToPlaylistMap[process.batchId] = playlistId; - } - - // Check if there's an open wishlist modal (visible and idle/waiting) - if (playlistId === 'wishlist' && process.modalElement && - process.modalElement.style.display === 'flex' && - (!process.batchId || process.status !== 'running')) { - hasOpenWishlistModal = true; - } - }); - - // Special handling for open wishlist modal - check for new auto-processing - if (hasOpenWishlistModal) { - try { - const response = await fetch('/api/active-processes'); - if (response.ok) { - const data = await response.json(); - const processes = data.active_processes || []; - const serverWishlistProcess = processes.find(p => p.playlist_id === 'wishlist'); - - if (serverWishlistProcess) { - console.log('🔄 [Global Polling] Detected auto-processing for open wishlist modal - rehydrating'); - await rehydrateModal(serverWishlistProcess, false); // false = not user-requested - } - } - } catch (error) { - console.debug('⚠️ [Global Polling] Failed to check for wishlist auto-processing:', error); - } - } - - if (activeBatchIds.length === 0) { - console.debug('📊 [Global Polling] No active processes, continuing polling'); - return; - } - - try { - const queryParams = activeBatchIds.map(id => `batch_ids=${id}`).join('&'); - const response = await fetch(`/api/download_status/batch?${queryParams}`); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - console.debug(`📊 [Global Polling] Received batched update for ${Object.keys(data.batches).length} processes`); - - Object.entries(data.batches).forEach(([batchId, statusData]) => { - const playlistId = batchToPlaylistMap[batchId]; - if (!playlistId || statusData.error) { - if (statusData.error) { - console.error(`❌ [Global Polling] Error for batch ${batchId}:`, statusData.error); - } - return; - } - processModalStatusUpdate(playlistId, statusData); - }); - - // Success - reset to normal interval if we were backing off - globalPollingFailureCount = 0; - if (interval !== globalPollingBaseInterval) { - console.log('✅ [Global Polling] Recovered from backoff, returning to normal interval'); - clearInterval(globalDownloadStatusPoller); - globalDownloadStatusPoller = null; - startGlobalDownloadPolling(); // Restart with normal interval - } - - } catch (error) { - console.error('❌ [Global Polling] Request failed:', error); - globalPollingFailureCount++; - - if (globalPollingFailureCount >= 5) { - console.error(`🚨 [Global Polling] Too many failures, continuing with backoff`); - // Don't stop polling - just continue with exponential backoff - } - } - }, interval); -} - -function stopGlobalDownloadPolling() { - if (globalDownloadStatusPoller) { - console.log('🛑 [Global Polling] Stopping batched download status polling'); - clearInterval(globalDownloadStatusPoller); - globalDownloadStatusPoller = null; - } -} - -// --- Error tooltip for failed/cancelled downloads (fixed-position, escapes overflow) --- -function _getErrorTooltipPopup() { - let el = document.getElementById('error-tooltip-popup'); - if (!el) { - el = document.createElement('div'); - el.id = 'error-tooltip-popup'; - document.body.appendChild(el); - } - return el; -} - -function _hideErrorTooltip() { - const popup = document.getElementById('error-tooltip-popup'); - if (popup) popup.classList.remove('visible'); -} - -function _ensureErrorTooltipListeners(statusEl) { - if (statusEl._errorTooltipBound) return; - statusEl._errorTooltipBound = true; - statusEl.addEventListener('mouseenter', function () { - const msg = this.dataset.errorMsg; - if (!msg || !this.offsetParent) return; // skip if element is hidden - const popup = _getErrorTooltipPopup(); - popup.textContent = msg; - popup.classList.add('visible'); - const rect = this.getBoundingClientRect(); - const popupRect = popup.getBoundingClientRect(); - let left = rect.left + rect.width / 2 - popupRect.width / 2; - let top = rect.top - popupRect.height - 10; - // Keep within viewport - if (left < 8) left = 8; - if (left + popupRect.width > window.innerWidth - 8) left = window.innerWidth - 8 - popupRect.width; - if (top < 8) { top = rect.bottom + 10; } // flip below if no room above - popup.style.left = left + 'px'; - popup.style.top = top + 'px'; - }); - statusEl.addEventListener('mouseleave', _hideErrorTooltip); - - // Dismiss tooltip when the scrollable modal body scrolls - const scrollParent = statusEl.closest('.download-missing-modal-body'); - if (scrollParent && !scrollParent._errorTooltipScrollBound) { - scrollParent._errorTooltipScrollBound = true; - scrollParent.addEventListener('scroll', _hideErrorTooltip, { passive: true }); - } -} - -function _ensureCandidatesClickListener(statusEl) { - if (statusEl._candidatesClickBound) return; - statusEl._candidatesClickBound = true; - statusEl.addEventListener('click', function (e) { - e.stopPropagation(); - _hideErrorTooltip(); - const taskId = this.dataset.taskId; - if (taskId) showCandidatesModal(taskId); - }); -} - -async function showCandidatesModal(taskId) { - try { - const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/candidates`); - if (!resp.ok) { console.error('Failed to fetch candidates:', resp.status); return; } - const data = await resp.json(); - _renderCandidatesModal(data); - } catch (err) { - console.error('Error fetching candidates:', err); - } -} - -function _renderCandidatesModal(data) { - let overlay = document.getElementById('candidates-modal-overlay'); - if (overlay) overlay.remove(); - - const trackName = data.track_info?.name || 'Unknown Track'; - const trackArtist = data.track_info?.artist || 'Unknown Artist'; - const candidates = data.candidates || []; - const errorMsg = data.error_message || ''; - - const fmtSize = (bytes) => { - if (!bytes) return '-'; - const units = ['B', 'KB', 'MB', 'GB']; - let s = bytes, u = 0; - while (s >= 1024 && u < units.length - 1) { s /= 1024; u++; } - return `${s.toFixed(1)} ${units[u]}`; - }; - const fmtDur = (ms) => { - if (!ms) return '-'; - const sec = Math.floor(ms / 1000); - return `${Math.floor(sec / 60)}:${(sec % 60).toString().padStart(2, '0')}`; - }; - - let tableRows = ''; - if (candidates.length === 0) { - tableRows = ` - No candidates were found during search.`; - } else { - candidates.forEach((c, i) => { - const shortFile = c.filename ? c.filename.split(/[/\\]/).pop() : '-'; - const qBadge = c.quality - ? `${c.quality.toUpperCase()}` - : ''; - tableRows += ` - ${i + 1} - ${escapeHtml(shortFile)} - ${qBadge}${c.bitrate ? ` ${c.bitrate}kbps` : ''} - ${fmtSize(c.size)} - ${fmtDur(c.duration)} - ${escapeHtml(c.username || '-')} - - `; - }); - } - - overlay = document.createElement('div'); - overlay.id = 'candidates-modal-overlay'; - overlay.className = 'candidates-modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) closeCandidatesModal(); }; - overlay.innerHTML = ` -
    -
    -
    -

    Search Results

    -
    ${escapeHtml(trackName)} — ${escapeHtml(trackArtist)}
    -
    - -
    -
    - ${errorMsg ? `
    ${escapeHtml(errorMsg)}
    ` : ''} -
    ${candidates.length} candidate${candidates.length !== 1 ? 's' : ''} found${candidates.length > 0 ? ' but none passed filters' : ''}
    -
    - - - - - ${tableRows} -
    #FileQualitySizeDurationUser
    -
    -
    -
    `; - - document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add('visible')); - - // Bind download buttons - overlay.querySelectorAll('.candidates-download-btn').forEach(btn => { - btn.addEventListener('click', () => { - const idx = parseInt(btn.dataset.index); - const c = candidates[idx]; - if (c) downloadCandidate(data.task_id, c, trackName); - }); - }); -} - -async function downloadCandidate(taskId, candidate, trackName) { - if (!await showConfirmDialog({ title: 'Download File', message: `Download this file as "${trackName}"?\n\n${candidate.filename?.split(/[/\\]/).pop() || 'Unknown file'}\nfrom ${candidate.username || 'Unknown user'}`, confirmText: 'Download' })) return; - try { - const resp = await fetch(`/api/downloads/task/${encodeURIComponent(taskId)}/download-candidate`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(candidate) - }); - const result = await resp.json(); - if (result.success) { - closeCandidatesModal(); - showToast(result.message || 'Download initiated', 'success'); - } else { - showToast(`Failed: ${result.error}`, 'error'); - } - } catch (err) { - console.error('Error initiating manual download:', err); - showToast('Failed to initiate download', 'error'); - } -} - -function closeCandidatesModal() { - const overlay = document.getElementById('candidates-modal-overlay'); - if (overlay) { - overlay.classList.remove('visible'); - setTimeout(() => overlay.remove(), 300); - } -} - -function processModalStatusUpdate(playlistId, data) { - // This function contains ALL the existing polling logic from startModalDownloadPolling - // Extracted so it can be called from both individual and batched polling - const process = activeDownloadProcesses[playlistId]; - if (!process) { - console.debug(`⚠️ [Status Update] No process found for ${playlistId}, skipping update`); - return; - } - - if (data.error) { - console.error(`❌ [Status Update] Error for ${playlistId}: ${data.error}`); - return; - } - - // ENHANCED: Validate response data to prevent UI corruption - if (!data || typeof data !== 'object') { - console.error(`❌ [Status Update] Invalid data for ${playlistId}:`, data); - return; - } - - // ENHANCED: Validate task data structure - if (data.tasks && !Array.isArray(data.tasks)) { - console.error(`❌ [Status Update] Invalid tasks data for ${playlistId} - not an array:`, data.tasks); - return; - } - - console.debug(`📊 [Status Update] Processing update for ${playlistId}: phase=${data.phase}, tasks=${(data.tasks || []).length}`); - - // Note: Wishlist modal visibility is now managed by handleWishlistButtonClick() only - // Auto-show logic has been simplified to prevent conflicts - - if (data.phase === 'analysis') { - const progress = data.analysis_progress; - const percent = progress.total > 0 ? (progress.processed / progress.total) * 100 : 0; - document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = `${percent}%`; - document.getElementById(`analysis-progress-text-${playlistId}`).textContent = - `${progress.processed}/${progress.total} tracks analyzed`; - if (data.analysis_results) { - updateTrackAnalysisResults(playlistId, data.analysis_results); - // Update stats when we first get analysis results - const foundCount = data.analysis_results.filter(r => r.found).length; - const missingCount = data.analysis_results.filter(r => !r.found).length; - document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; - document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; - - // Auto-save M3U file for playlists after analysis - autoSavePlaylistM3U(playlistId); - } - } else if (data.phase === 'downloading' || data.phase === 'complete' || data.phase === 'error') { - console.debug(`📊 [Status Update] Processing ${data.phase} phase for playlistId: ${playlistId}, tasks: ${(data.tasks || []).length}`); - - if (document.getElementById(`analysis-progress-fill-${playlistId}`).style.width !== '100%') { - document.getElementById(`analysis-progress-fill-${playlistId}`).style.width = '100%'; - document.getElementById(`analysis-progress-text-${playlistId}`).textContent = 'Analysis complete!'; - if (data.analysis_results) { - updateTrackAnalysisResults(playlistId, data.analysis_results); - const foundCount = data.analysis_results.filter(r => r.found).length; - const missingCount = data.analysis_results.filter(r => !r.found).length; - document.getElementById(`stat-found-${playlistId}`).textContent = foundCount; - document.getElementById(`stat-missing-${playlistId}`).textContent = missingCount; - } - } - const missingTracks = (data.analysis_results || []).filter(r => !r.found); - const missingCount = missingTracks.length; - let completedCount = 0; - let failedOrCancelledCount = 0; - let notFoundCount = 0; - - // Verify modal exists before processing tasks - const modal = document.getElementById(`download-missing-modal-${playlistId}`); - if (!modal) { - console.error(`❌ [Status Update] Modal not found: download-missing-modal-${playlistId}`); - return; - } - - // Update download progress text immediately when entering downloading phase - // This handles the case where tasks array is empty or still being populated - const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`); - if (data.phase === 'downloading' && missingCount > 0 && (!data.tasks || data.tasks.length === 0)) { - // No tasks yet, but we're in downloading phase with missing tracks - if (downloadProgressText) { - downloadProgressText.textContent = 'Preparing downloads...'; - console.log(`📥 [Download Phase] Preparing ${missingCount} downloads...`); - } - } - - (data.tasks || []).forEach(task => { - const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`); - if (!row) { - console.debug(`❌ [Status Update] Row not found for playlistId: ${playlistId}, track_index: ${task.track_index}`); - return; - } - - // V2 SYSTEM: Check for persistent cancel state from backend - const isV2Task = task.playlist_id !== undefined; // V2 tasks have playlist_id - const cancelRequested = task.cancel_requested || false; - const uiState = task.ui_state || 'normal'; - - // Legacy protection for old system compatibility - if (row.dataset.locallyCancelled === 'true' && !isV2Task) { - failedOrCancelledCount++; - return; // Only skip for legacy system tasks - } - - // Mark row with V2 system info - if (isV2Task) { - row.dataset.useV2System = 'true'; - row.dataset.cancelRequested = cancelRequested.toString(); - row.dataset.uiState = uiState; - } - - row.dataset.taskId = task.task_id; - const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); - const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); - - let statusText = ''; - // V2 SYSTEM: Handle UI state override for cancelling tasks - if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') { - statusText = '🔄 Cancelling...'; - } else { - switch (task.status) { - case 'pending': statusText = '⏸️ Pending'; break; - case 'searching': statusText = '🔍 Searching...'; break; - case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break; - case 'post_processing': statusText = '⌛ Processing...'; break; - case 'completed': statusText = '✅ Completed'; completedCount++; break; - case 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break; - case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; - case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; - default: statusText = `⚪ ${task.status}`; break; - } - } - - if (statusEl) { - statusEl.classList.remove('has-error-tooltip'); - statusEl.removeAttribute('title'); - statusEl.removeAttribute('data-error-msg'); - statusEl.textContent = statusText; - - if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) { - statusEl.classList.add('has-error-tooltip'); - statusEl.dataset.errorMsg = task.error_message; - _ensureErrorTooltipListeners(statusEl); - } - // Make not_found and failed cells clickable to review search candidates - if ((task.status === 'not_found' || task.status === 'failed') && task.has_candidates) { - statusEl.classList.add('has-candidates'); - statusEl.dataset.taskId = task.task_id; - _ensureCandidatesClickListener(statusEl); - } - console.debug(`✅ [Status Update] Updated track ${task.track_index} to: ${statusText}${isV2Task ? ' (V2)' : ''}`); - } else { - console.warn(`❌ [Status Update] Status element not found: download-${playlistId}-${task.track_index}`); - } - - // V2 SYSTEM: Smart button management with persistent state awareness - if (actionsEl && !['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) { - // Check if we're in a cancelling state - if (isV2Task && uiState === 'cancelling') { - actionsEl.innerHTML = 'Cancelling...'; - } else { - // Create V2 cancel button for all active tasks - const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload'; - actionsEl.innerHTML = ``; - } - } else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) { - actionsEl.innerHTML = '-'; // No actions available for terminal or processing states - } - }); - - // ENHANCED: Validate worker counts from server data - const serverActiveWorkers = data.active_count || 0; - const maxWorkers = data.max_concurrent || 3; - - // V2 SYSTEM: Simplified worker counting - backend is authoritative - // Count active tasks, excluding locally cancelled legacy tasks only - const clientActiveWorkers = (data.tasks || []).filter(task => { - const row = document.querySelector(`tr[data-track-index="${task.track_index}"]`); - const isLegacyCancelled = row && row.dataset.locallyCancelled === 'true' && !row.dataset.useV2System; - return ['searching', 'downloading', 'queued'].includes(task.status) && !isLegacyCancelled; - }).length; - - // Log discrepancies for debugging - if (serverActiveWorkers !== clientActiveWorkers) { - console.warn(`🔍 [Worker Validation] ${playlistId}: server reports ${serverActiveWorkers} active, client sees ${clientActiveWorkers} active tasks`); - - // If server reports 0 but client sees active tasks, this might indicate ghost workers were fixed - if (serverActiveWorkers === 0 && clientActiveWorkers > 0) { - console.warn(`🚨 [Worker Validation] Server reports 0 workers but client sees ${clientActiveWorkers} active tasks - potential UI desync`); - } - } - - console.debug(`📊 [Worker Status] ${playlistId}: ${serverActiveWorkers}/${maxWorkers} active workers, ${clientActiveWorkers} client-side active tasks`); - - const totalFinished = completedCount + failedOrCancelledCount + notFoundCount; - const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 0; - document.getElementById(`download-progress-fill-${playlistId}`).style.width = `${progressPercent}%`; - document.getElementById(`download-progress-text-${playlistId}`).textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; - document.getElementById(`stat-downloaded-${playlistId}`).textContent = completedCount; - - // Auto-save M3U file once when all downloads finish (not on every poll cycle). - // Previously this fired on EVERY 2-second poll when completedCount > 0, flooding - // the server with heavyweight M3U generation requests that exhausted Flask threads - // and caused the batch status endpoint to hang — killing the poller. - - // CLIENT-SIDE COMPLETION: Only complete when ALL task rows in the UI reflect a terminal state. - // Using totalFinished (derived from DOM updates in THIS render pass) prevents premature - // completion when the server sends phase='complete' before all rows have been updated. - const allTracksFinished = totalFinished >= missingCount && missingCount > 0 && totalFinished > 0; - // Extra guard: require the server to also report no active tasks - const serverHasActiveWork = (data.tasks || []).some(t => - ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(t.status)); - if (allTracksFinished && !serverHasActiveWork && process.status !== 'complete') { - console.log(`🎯 [Client Completion] All ${totalFinished}/${missingCount} tracks finished - completing modal locally`); - - // Hide cancel button and mark as complete - document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; - process.status = 'complete'; - updatePlaylistCardUI(playlistId); - - // Save M3U once on completion (not during progress polling) - if (completedCount > 0) { - autoSavePlaylistM3U(playlistId); - } - - // Show the force download toggle again - const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); - if (forceToggleContainer) { - forceToggleContainer.style.display = 'flex'; - } - - // Set album to downloaded status if this is an artist album - if (playlistId.startsWith('artist_album_')) { - const parts = playlistId.split('_'); - if (parts.length >= 4) { - const albumId = parts.slice(3).join('_'); - setTimeout(() => setAlbumDownloadedStatus(albumId), 500); // Small delay to ensure UI updates - } - } - - // Update mirrored playlist card phase on client-side completion - if (playlistId.startsWith('youtube_')) { - const urlHash = playlistId.replace('youtube_', ''); - if (urlHash.startsWith('mirrored_')) { - updateMirroredCardPhase(urlHash, 'download_complete'); - } - } - - // Auto-save final M3U file for playlists - autoSavePlaylistM3U(playlistId); - - // Show completion message - let completionParts = [`${completedCount} downloaded`]; - if (notFoundCount > 0) completionParts.push(`${notFoundCount} not found`); - if (failedOrCancelledCount > 0) completionParts.push(`${failedOrCancelledCount} failed`); - const completionMessage = `Download complete! ${completionParts.join(', ')}.`; - showToast(completionMessage, 'success'); - - // Refresh server playlists tab so it reflects newly synced tracks - if (typeof loadServerPlaylists === 'function') { - setTimeout(() => loadServerPlaylists(), 2000); - } - - // Auto-close wishlist modal when completed (for auto-processing) - if (playlistId === 'wishlist') { - console.log('🔄 [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle'); - setTimeout(() => { - closeDownloadMissingModal(playlistId); - }, 3000); // 3-second delay to show completion message - } - - // Check if any other processes still need polling - checkAndCleanupGlobalPolling(); - - return; // Skip waiting for backend signal - } - - // FIXED: Only trigger completion logic when backend actually reports batch as complete - // Don't assume completion based on task counts - let backend determine when truly complete - if (data.phase === 'complete' || data.phase === 'error') { - // Enhanced check for background auto-processing for wishlist - const isWishlist = (playlistId === 'wishlist'); - const isModalHidden = (process.modalElement && process.modalElement.style.display === 'none'); - const isAutoInitiated = data.auto_initiated || false; // Server indicates if batch was auto-started - const isBackgroundWishlist = isWishlist && (isModalHidden || isAutoInitiated); - - // Note: Auto-show logic removed - wishlist modal visibility managed by user interaction only - - if (data.phase === 'cancelled') { - process.status = 'cancelled'; - - // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on cancel - if (playlistId.startsWith('youtube_')) { - const urlHash = playlistId.replace('youtube_', ''); - updateYouTubeCardPhase(urlHash, 'discovered'); - if (urlHash.startsWith('mirrored_')) { - updateMirroredCardPhase(urlHash, 'discovered'); - } - } - - showToast(`Process cancelled for ${process.playlist.name}.`, 'info'); - } else if (data.phase === 'error') { - process.status = 'complete'; // Treat as complete to allow cleanup - updatePlaylistCardUI(playlistId); // Update card to show ready for review - - // Reset YouTube playlist phase to 'discovered' if this is a YouTube playlist on error - if (playlistId.startsWith('youtube_')) { - const urlHash = playlistId.replace('youtube_', ''); - updateYouTubeCardPhase(urlHash, 'discovered'); - if (urlHash.startsWith('mirrored_')) { - updateMirroredCardPhase(urlHash, 'discovered'); - } - } - - showToast(`Process for ${process.playlist.name} failed!`, 'error'); - } else { - process.status = 'complete'; - updatePlaylistCardUI(playlistId); // Update card to show ready for review - - // Update YouTube playlist phase to 'download_complete' if this is a YouTube playlist - if (playlistId.startsWith('youtube_')) { - const urlHash = playlistId.replace('youtube_', ''); - updateYouTubeCardPhase(urlHash, 'download_complete'); - if (urlHash.startsWith('mirrored_')) { - updateMirroredCardPhase(urlHash, 'download_complete'); - } - } - - // Update Tidal playlist phase to 'download_complete' if this is a Tidal playlist - if (playlistId.startsWith('tidal_')) { - const tidalPlaylistId = playlistId.replace('tidal_', ''); - if (tidalPlaylistStates[tidalPlaylistId]) { - tidalPlaylistStates[tidalPlaylistId].phase = 'download_complete'; - // Store the download process ID for potential modal rehydration - tidalPlaylistStates[tidalPlaylistId].download_process_id = process.batchId; - updateTidalCardPhase(tidalPlaylistId, 'download_complete'); - console.log(`✅ [Status Complete] Updated Tidal playlist ${tidalPlaylistId} to download_complete phase`); - } - } - - // Update Beatport chart phase to 'download_complete' if this is a Beatport chart - if (playlistId.startsWith('beatport_')) { - const urlHash = playlistId.replace('beatport_', ''); - const state = youtubePlaylistStates[urlHash]; - - if (state && state.is_beatport_playlist) { - const chartHash = state.beatport_chart_hash || urlHash; - - // Update frontend states - state.phase = 'download_complete'; - state.download_process_id = process.batchId; - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'download_complete'; - } - - // Update card UI - updateBeatportCardPhase(chartHash, 'download_complete'); - - // Update backend state - try { - fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phase: 'download_complete', - download_process_id: process.batchId - }) - }); - } catch (error) { - console.warn('⚠️ Error updating backend Beatport phase to download_complete:', error); - } - - console.log(`✅ [Status Complete] Updated Beatport chart ${chartHash} to download_complete phase`); - } - } - - // Handle background wishlist processing completion specially - if (isBackgroundWishlist) { - console.log(`🎉 Background wishlist processing complete: ${completedCount} downloaded, ${notFoundCount} not found, ${failedOrCancelledCount} failed`); - - // Reset modal to idle state to prevent "complete" phase disruption - setTimeout(() => { - resetWishlistModalToIdleState(); - // Server-side auto-processing will handle next cycle automatically - }, 500); - - return; // Skip normal completion handling - } - - // Show completion summary with wishlist stats (matching sync.py behavior) - let completionMessage = `Process complete for ${process.playlist.name}!`; - let messageType = 'success'; - - // Check for wishlist summary from backend (added when failed/cancelled tracks are processed) - if (data.wishlist_summary) { - const summary = data.wishlist_summary; - let summaryParts = [`Downloaded: ${completedCount}`]; - if (notFoundCount > 0) summaryParts.push(`Not Found: ${notFoundCount}`); - if (failedOrCancelledCount > 0) summaryParts.push(`Failed: ${failedOrCancelledCount}`); - completionMessage = `Download process complete! ${summaryParts.join(', ')}.`; - - if (summary.tracks_added > 0) { - completionMessage += ` Added ${summary.tracks_added} failed track${summary.tracks_added !== 1 ? 's' : ''} to wishlist for automatic retry.`; - } else if (summary.total_failed > 0) { - completionMessage += ` ${summary.total_failed} track${summary.total_failed !== 1 ? 's' : ''} could not be added to wishlist.`; - messageType = 'warning'; - } - } - - showToast(completionMessage, messageType); - } - - document.getElementById(`cancel-all-btn-${playlistId}`).style.display = 'none'; - - // Mark process as complete and trigger cleanup check - process.status = 'complete'; - updatePlaylistCardUI(playlistId); - - // Check if any other processes still need polling - checkAndCleanupGlobalPolling(); - } - } -} - -function checkAndCleanupGlobalPolling() { - // Check if any processes still need polling - const hasActivePolling = Object.values(activeDownloadProcesses) - .some(p => p.batchId && p.status === 'running'); - - if (!hasActivePolling) { - console.debug('🧹 [Cleanup] No more active processes, continuing polling'); - // Keep polling active - no need to stop - } -} - -// LEGACY FUNCTION: Keep for backward compatibility, but now uses global polling -function startModalDownloadPolling(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process || !process.batchId) return; - - console.log(`🔄 [Legacy Polling] Starting polling for ${playlistId}, delegating to global poller`); - - // Clear any existing individual poller (cleanup) - if (process.poller) { - clearInterval(process.poller); - process.poller = null; - } - - // Mark process as running to be picked up by global poller - process.status = 'running'; - - // Start global polling if not already running - startGlobalDownloadPolling(); - - // Create dummy poller for backward compatibility with cleanup functions - ensureLegacyCompatibility(playlistId); -} - -// For backward compatibility with cleanup functions that expect process.poller -// Creates a dummy poller that will be cleaned up by the existing cleanup logic -function createLegacyPoller(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) return; - - // Create a dummy interval that just checks if the process is still active - // This ensures existing cleanup logic that calls clearInterval(process.poller) works - process.poller = setInterval(() => { - // This dummy poller doesn't do anything - global poller handles updates - if (!activeDownloadProcesses[playlistId] || process.status === 'complete') { - clearInterval(process.poller); - process.poller = null; - return; - } - }, 5000); // Very infrequent check, just for cleanup compatibility -} - -// Call this to create the legacy poller after starting global polling -function ensureLegacyCompatibility(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (process && !process.poller) { - createLegacyPoller(playlistId); - } -} -async function updateModalWithLiveDownloadProgress() { - try { - if (!currentDownloadBatchId) return; - - // Fetch live download data from the downloads API - const response = await fetch('/api/downloads/status'); - const downloadData = await response.json(); - - if (downloadData.error) return; - - // Get all active and finished downloads - const allDownloads = { ...(downloadData.active || {}), ...(downloadData.finished || {}) }; - - // Update modal tracks that have active downloads - const modalRows = document.querySelectorAll('.download-missing-modal tr[data-track-index]'); - - for (const row of modalRows) { - const taskId = row.dataset.taskId; - if (!taskId) continue; - - // Find corresponding download by checking if filename/title matches - const trackName = row.querySelector('.track-name')?.textContent?.trim(); - if (!trackName) continue; - - // Search for matching download - for (const [downloadId, downloadInfo] of Object.entries(allDownloads)) { - // Extract display title from filename (handle YouTube encoding) - let downloadTitle = ''; - if (downloadInfo.filename) { - if ((downloadInfo.username === 'youtube' || downloadInfo.username === 'tidal' || downloadInfo.username === 'qobuz' || downloadInfo.username === 'hifi') && downloadInfo.filename.includes('||')) { - const parts = downloadInfo.filename.split('||'); - downloadTitle = parts[1] || parts[0]; - } else { - downloadTitle = downloadInfo.filename.split(/[\\/]/).pop(); - } - } - - // Simple matching - could be improved with better logic - if (downloadTitle && trackName && ( - downloadTitle.toLowerCase().includes(trackName.toLowerCase()) || - trackName.toLowerCase().includes(downloadTitle.toLowerCase()) - )) { - // Update the track with live download progress - const statusElement = row.querySelector('.track-download-status'); - const progress = downloadInfo.percentComplete || 0; - const state = downloadInfo.state || ''; - - if (statusElement && state.includes('InProgress') && progress > 0) { - statusElement.textContent = `⏬ Downloading... ${Math.round(progress)}%`; - statusElement.className = 'track-download-status download-downloading'; - } else if (statusElement && (state.includes('Completed') || state.includes('Succeeded'))) { - statusElement.textContent = '✅ Completed'; - statusElement.className = 'track-download-status download-complete'; - } - - break; // Found a match, stop searching - } - } - } - - } catch (error) { - // Silent fail - don't spam console during normal operation - } -} - -function toggleAllTrackSelections(playlistId, checked) { - const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); - if (!tbody) return; - const checkboxes = tbody.querySelectorAll('.track-select-cb'); - checkboxes.forEach(cb => { cb.checked = checked; }); - updateTrackSelectionCount(playlistId); -} - -function updateTrackSelectionCount(playlistId) { - const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); - if (!tbody) return; - const allCbs = tbody.querySelectorAll('.track-select-cb'); - const checkedCbs = tbody.querySelectorAll('.track-select-cb:checked'); - const total = allCbs.length; - const selected = checkedCbs.length; - - // Update selection count label - const countLabel = document.getElementById(`track-selection-count-${playlistId}`); - if (countLabel) { - countLabel.textContent = `${selected} / ${total} tracks selected`; - } - - // Update select-all checkbox state - const selectAll = document.getElementById(`select-all-${playlistId}`); - if (selectAll) { - selectAll.checked = selected === total; - selectAll.indeterminate = selected > 0 && selected < total; - } - - // Update row dimming - allCbs.forEach(cb => { - const row = cb.closest('tr'); - if (row) row.classList.toggle('track-deselected', !cb.checked); - }); - - // Disable Begin Analysis and Add to Wishlist buttons when 0 selected - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - if (beginBtn) { - beginBtn.disabled = selected === 0; - } - const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); - if (wishlistBtn) { - wishlistBtn.disabled = selected === 0; - } -} - -async function cancelAllOperations(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) return; - - // Prevent multiple cancel all operations - if (process.cancellingAll) { - console.log(`⚠️ Cancel All already in progress for ${playlistId}`); - return; - } - process.cancellingAll = true; - - console.log(`🚫 Cancel All clicked for playlist ${playlistId} - closing modal and cleaning up server`); - - showToast('Cancelling all operations and closing modal...', 'info'); - - // Mark process as complete immediately so polling stops - process.status = 'complete'; - - // Stop any active polling - if (process.poller) { - clearInterval(process.poller); - process.poller = null; - } - - // Tell server to stop starting new downloads and clean up the batch - if (process.batchId) { - try { - // Cancel the batch (stops new downloads from starting) - const cancelResponse = await fetch(`/api/playlists/${process.batchId}/cancel_batch`, { - method: 'POST' - }); - if (cancelResponse.ok) { - const cancelData = await cancelResponse.json(); - console.log(`✅ Server stopped new downloads for batch ${process.batchId}`); - } - } catch (error) { - console.warn('Error during server batch cancel:', error); - } - } - - // Close the modal immediately - this will handle cleanup - closeDownloadMissingModal(playlistId); - - showToast('Modal closed. Active downloads will finish in background.', 'success'); -} - -function resetToInitialState() { - // Reset UI - document.getElementById('begin-analysis-btn').style.display = 'inline-block'; - document.getElementById('start-downloads-btn').style.display = 'none'; - document.getElementById('cancel-all-btn').style.display = 'none'; - - // Reset progress bars - document.getElementById('analysis-progress-fill').style.width = '0%'; - document.getElementById('download-progress-fill').style.width = '0%'; - document.getElementById('analysis-progress-text').textContent = 'Ready to start'; - document.getElementById('download-progress-text').textContent = 'Waiting for analysis'; - - // Reset stats - document.getElementById('stat-found').textContent = '-'; - document.getElementById('stat-missing').textContent = '-'; - document.getElementById('stat-downloaded').textContent = '0'; - - // Reset track table - const tbody = document.getElementById('download-tracks-tbody'); - if (tbody) { - const rows = tbody.querySelectorAll('tr'); - rows.forEach((row, index) => { - const matchElement = row.querySelector('.track-match-status'); - const downloadElement = row.querySelector('.track-download-status'); - const actionsElement = row.querySelector('.track-actions'); - - if (matchElement) { - matchElement.textContent = '🔍 Pending'; - matchElement.className = 'track-match-status match-checking'; - } - if (downloadElement) { - downloadElement.textContent = '-'; - downloadElement.className = 'track-download-status'; - } - if (actionsElement) { - actionsElement.textContent = '-'; - } - }); - } - - // Reset state - activeAnalysisTaskId = null; - analysisResults = []; - missingTracks = []; -} - -// =============================== -// NEW ATOMIC CANCEL SYSTEM V2 -// =============================== - -async function cancelTrackDownloadV2(playlistId, trackIndex) { - /** - * NEW ATOMIC CANCEL SYSTEM V2 - * - * - No optimistic UI updates - * - Single API call handles everything atomically - * - Backend is single source of truth for all state - * - No race conditions or dual state management - */ - const process = activeDownloadProcesses[playlistId]; - if (!process) { - console.warn(`❌ [Cancel V2] No process found for playlist: ${playlistId}`); - return; - } - - const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`); - if (!row) { - console.warn(`❌ [Cancel V2] No row found for track index: ${trackIndex}`); - return; - } - - // Check if already in cancelling state - const statusEl = document.getElementById(`download-${playlistId}-${trackIndex}`); - const currentStatus = statusEl ? statusEl.textContent : ''; - - if (currentStatus.includes('Cancelling') || currentStatus.includes('Cancelled')) { - console.log(`⚠️ [Cancel V2] Task already being cancelled or cancelled: ${currentStatus}`); - return; - } - - console.log(`🎯 [Cancel V2] Starting atomic cancel: playlist=${playlistId}, track=${trackIndex}`); - - // V2 SYSTEM: Set temporary UI state - will be confirmed by server - row.dataset.uiState = 'cancelling'; - - // Show loading state only - no optimistic "cancelled" state - if (statusEl) { - statusEl.textContent = '🔄 Cancelling...'; - } - - // Disable the cancel button to prevent double-clicks - const actionsEl = document.getElementById(`actions-${playlistId}-${trackIndex}`); - if (actionsEl) { - actionsEl.innerHTML = 'Cancelling...'; - } - - try { - const response = await fetch('/api/downloads/cancel_task_v2', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_id: playlistId, - track_index: trackIndex - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`✅ [Cancel V2] Successfully cancelled: ${data.task_info.track_name}`); - showToast(`Cancelled "${data.task_info.track_name}" and added to wishlist.`, 'success'); - - // Let the status polling system update the UI with server truth - // No manual UI updates - backend is authoritative - - } else { - console.error(`❌ [Cancel V2] Cancel failed: ${data.error}`); - showToast(`Cancel failed: ${data.error}`, 'error'); - - // Reset UI to previous state on failure - row.dataset.uiState = 'normal'; // Reset UI state - if (statusEl) { - statusEl.textContent = '❌ Cancel Failed'; - } - if (actionsEl) { - actionsEl.innerHTML = ``; - } - } - - } catch (error) { - console.error(`❌ [Cancel V2] Network/API error:`, error); - showToast(`Cancel request failed: ${error.message}`, 'error'); - - // Reset UI on network error - row.dataset.uiState = 'normal'; // Reset UI state - if (statusEl) { - statusEl.textContent = '❌ Cancel Failed'; - } - if (actionsEl) { - actionsEl.innerHTML = ``; - } - } -} - -// =============================== -// LEGACY CANCEL SYSTEM (OLD) -// =============================== - -async function cancelTrackDownload(playlistId, trackIndex) { - const process = activeDownloadProcesses[playlistId]; - if (!process) return; - - const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${trackIndex}"]`); - if (!row) return; - - // Prevent double cancellation - if (row.dataset.locallyCancelled === 'true') { - return; // Already cancelled locally - } - - const taskId = row.dataset.taskId; - if (!taskId) { - showToast('Task not started yet, cannot cancel.', 'warning'); - return; - } - - // UI update for immediate feedback - mark as cancelled FIRST to prevent race conditions - row.dataset.locallyCancelled = 'true'; - document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelling...'; - document.getElementById(`actions-${playlistId}-${trackIndex}`).innerHTML = '-'; - - try { - const response = await fetch('/api/downloads/cancel_task', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ task_id: taskId }) - }); - const data = await response.json(); - if (data.success) { - // Update final UI state after successful cancellation - document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '🚫 Cancelled'; - showToast('Download cancelled and added to wishlist.', 'info'); - } else { - throw new Error(data.error); - } - } catch (error) { - // Reset UI state if cancellation failed - row.dataset.locallyCancelled = 'false'; - document.getElementById(`download-${playlistId}-${trackIndex}`).textContent = '❌ Cancel Failed'; - showToast(`Could not cancel task: ${error.message}`, 'error'); - } -} - -// Find and REPLACE the old startPlaylistSyncFromModal function -async function startPlaylistSync(playlistId) { - const startTime = Date.now(); - console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`); - const playlist = spotifyPlaylists.find(p => p.id === playlistId); - if (!playlist) { - console.error(`❌ Could not find playlist data for ID: ${playlistId}`); - showToast('Could not find playlist data.', 'error'); - return; - } - console.log(`✅ Found playlist: ${playlist.name} with ${playlist.track_count || 'unknown'} tracks`); - - // Check if already syncing to prevent duplicate syncs - if (activeSyncPollers[playlistId]) { - showToast('Sync already in progress for this playlist', 'warning'); - return; - } - - // Update button state immediately for user feedback - const syncBtn = document.getElementById(`sync-btn-${playlistId}`); - if (syncBtn) { - syncBtn.disabled = true; - syncBtn.textContent = '⏳ Syncing...'; - } - - // Ensure we have the full track list before starting - let tracks = playlistTrackCache[playlistId]; - if (!tracks) { - const trackFetchStart = Date.now(); - console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`); - try { - // Use the right endpoint based on playlist source - const fetchUrl = playlistId.startsWith('deezer_arl_') - ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` - : `/api/spotify/playlist/${playlistId}`; - const response = await fetch(fetchUrl); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - tracks = fullPlaylist.tracks; - playlistTrackCache[playlistId] = tracks; // Cache it - const trackFetchTime = Date.now() - trackFetchStart; - console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`); - } catch (error) { - console.error(`❌ Failed to fetch tracks:`, error); - showToast(`Failed to fetch tracks for sync: ${error.message}`, 'error'); - return; - } - } else { - console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Using cached tracks: ${tracks.length} tracks`); - } - - // DON'T close the modal - let it show live progress like the GUI - - try { - const syncStartTime = Date.now(); - console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Making API call to /api/sync/start with ${tracks.length} tracks`); - const response = await fetch('/api/sync/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_id: playlist.id, - playlist_name: playlist.name, - tracks: tracks, // Send the full track list - image_url: playlist.image_url || '' - }) - }); - - const syncRequestTime = Date.now() - syncStartTime; - console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response status: ${response.status} (took ${syncRequestTime}ms)`); - const data = await response.json(); - console.log(`📡 [${new Date().toTimeString().split(' ')[0]}] API response data:`, data); - - if (!data.success) throw new Error(data.error); - - const totalTime = Date.now() - startTime; - console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Sync started successfully for "${playlist.name}" (total time: ${totalTime}ms)`); - showToast(`Sync started for "${playlist.name}"`, 'success'); - - // Show initial sync state in modal if open - const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal'); - if (modal && modal.style.display !== 'none') { - const statusDisplay = document.getElementById(`modal-sync-status-${playlist.id}`); - if (statusDisplay) { - statusDisplay.style.display = 'flex'; - console.log(`📊 [${new Date().toTimeString().split(' ')[0]}] Showing modal sync status for ${playlist.id}`); - } - } - - updateCardToSyncing(playlist.id, 0); // Initial state - startSyncPolling(playlist.id); - - } catch (error) { - console.error(`❌ Failed to start sync:`, error); - showToast(`Failed to start sync: ${error.message}`, 'error'); - updateCardToDefault(playlist.id); - } -} - -// Add these new helper functions to script.js - -function startSyncPolling(playlistId) { - // Clear any existing poller for this playlist - if (activeSyncPollers[playlistId]) { - clearInterval(activeSyncPollers[playlistId]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); - _syncProgressCallbacks[playlistId] = (data) => { - if (data.status === 'syncing') { - const progress = data.progress; - updateCardToSyncing(playlistId, progress.progress, progress); - updateModalSyncProgress(playlistId, progress); - } else if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') { - stopSyncPolling(playlistId); - updateCardToDefault(playlistId, data); - closePlaylistDetailsModal(); - } - }; - } - - // Start a new poller that checks every 2 seconds - console.log(`🔄 Starting sync polling for playlist: ${playlistId}`); - activeSyncPollers[playlistId] = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - console.log(`📊 Polling sync status for: ${playlistId}`); - const response = await fetch(`/api/sync/status/${playlistId}`); - const state = await response.json(); - console.log(`📊 Poll response:`, state); - - if (state.status === 'syncing') { - const progress = state.progress; - console.log(`📊 Sync progress:`, progress); - console.log(` 📊 Progress values: ${progress.progress}% | Total: ${progress.total_tracks} | Matched: ${progress.matched_tracks} | Failed: ${progress.failed_tracks}`); - console.log(` 📊 Current step: "${progress.current_step}" | Current track: "${progress.current_track}"`); - - // Use the actual progress percentage from the sync service - updateCardToSyncing(playlistId, progress.progress, progress); - // Also update the modal if it's open - updateModalSyncProgress(playlistId, progress); - } else if (state.status === 'finished' || state.status === 'error' || state.status === 'cancelled') { - console.log(`🏁 Sync completed with status: ${state.status}`); - stopSyncPolling(playlistId); - updateCardToDefault(playlistId, state); - // Also update the modal if it's open - closePlaylistDetailsModal(); closeDeezerArlPlaylistDetailsModal(); // Close modal on completion/error - } - } catch (error) { - console.error(`❌ Error polling sync status for ${playlistId}:`, error); - stopSyncPolling(playlistId); - updateCardToDefault(playlistId, { status: 'error', error: 'Polling failed' }); - } - }, 2000); // Poll every 2 seconds - updateRefreshButtonState(); -} - -function stopSyncPolling(playlistId) { - if (activeSyncPollers[playlistId]) { - clearInterval(activeSyncPollers[playlistId]); - delete activeSyncPollers[playlistId]; - } - // Phase 5: Unsubscribe and clean up callback - if (_syncProgressCallbacks[playlistId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); - delete _syncProgressCallbacks[playlistId]; - } - updateRefreshButtonState(); -} - -// Sync sidebar visibility helpers -function showSyncSidebar() { - const sidebar = document.querySelector('.sync-sidebar'); - const contentArea = document.querySelector('.sync-content-area'); - if (sidebar && contentArea && window.innerWidth > 1300) { - sidebar.style.display = ''; - contentArea.style.gridTemplateColumns = '2.5fr 0.75fr'; - } -} - -function hideSyncSidebar() { - const sidebar = document.querySelector('.sync-sidebar'); - const contentArea = document.querySelector('.sync-content-area'); - if (sidebar && contentArea) { - sidebar.style.display = 'none'; - contentArea.style.gridTemplateColumns = '1fr'; - } -} - -// Sequential Sync Functions -function startSequentialSync() { - // Initialize manager if needed - if (!sequentialSyncManager) { - sequentialSyncManager = new SequentialSyncManager(); - } - - // Check if already running - if so, cancel - if (sequentialSyncManager.isRunning) { - sequentialSyncManager.cancel(); - return; - } - - // Validate selection - if (selectedPlaylists.size === 0) { - showToast('No playlists selected for sync', 'error'); - return; - } - - // Get playlist order from DOM to maintain display order - const playlistCards = document.querySelectorAll('.playlist-card'); - const orderedPlaylistIds = []; - - playlistCards.forEach(card => { - const playlistId = card.dataset.playlistId; - if (selectedPlaylists.has(playlistId)) { - orderedPlaylistIds.push(playlistId); - } - }); - - console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists`); - - // Show sidebar for sync progress - showSyncSidebar(); - - // Start sequential sync - sequentialSyncManager.start(orderedPlaylistIds); - - // Disable playlist selection during sync - disablePlaylistSelection(true); -} - -function disablePlaylistSelection(disabled) { - const checkboxes = document.querySelectorAll('.playlist-checkbox'); - checkboxes.forEach(checkbox => { - checkbox.disabled = disabled; - }); -} - -function hasActiveOperations() { - const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; - // Only check non-wishlist download processes for sync page refresh button - const hasActiveDownloads = Object.entries(activeDownloadProcesses) - .filter(([playlistId, process]) => playlistId !== 'wishlist') // Exclude wishlist - .some(([_, process]) => process.status === 'running'); - const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; - return hasActiveSyncs || hasActiveDownloads || hasSequentialSync; -} - - -function updateRefreshButtonState() { - const refreshBtn = document.getElementById('spotify-refresh-btn'); - if (!refreshBtn) return; - - if (hasActiveOperations()) { - refreshBtn.disabled = true; - // Provide context-specific text - const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; - const hasSequentialSync = sequentialSyncManager && sequentialSyncManager.isRunning; - if (hasActiveSyncs || hasSequentialSync) { - refreshBtn.textContent = '🔄 Syncing...'; - } else { - refreshBtn.textContent = '📥 Downloading...'; - } - } else { - refreshBtn.disabled = false; - refreshBtn.textContent = '🔄 Refresh'; - } -} - -function updateCardToSyncing(playlistId, percent, progress = null) { - const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); - if (!card) return; - - const progressBar = card.querySelector('.sync-progress-indicator'); - progressBar.style.display = 'block'; - - let progressText = 'Starting...'; - let actualPercent = percent || 0; - - if (progress) { - // Create detailed progress text like the GUI - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const currentStep = progress.current_step || 'Processing'; - - // Calculate actual progress as processed/total, not just successful/total - if (total > 0) { - const processed = matched + failed; - actualPercent = Math.round((processed / total) * 100); - progressText = `${currentStep}: ${processed}/${total} (${matched} matched, ${failed} failed)`; - } else { - progressText = currentStep; - } - - // If there's a current track being processed, show it - if (progress.current_track) { - progressText += ` - ${progress.current_track}`; - } - } - - // Build live status counter HTML (same as modal) - let statusCounterHTML = ''; - if (progress && progress.total_tracks > 0) { - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const processed = matched + failed; - const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - statusCounterHTML = ` -
    - ♪ ${total} - / - ✓ ${matched} - / - ✗ ${failed} - (${percentage}%) -
    - `; - } - - progressBar.innerHTML = ` - ${statusCounterHTML} -
    -
    -
    -
    ${progressText}
    - `; -} - -function updateCardToDefault(playlistId, finalState = null) { - const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); - if (!card) return; - - const progressBar = card.querySelector('.sync-progress-indicator'); - progressBar.style.display = 'none'; - progressBar.innerHTML = ''; - - const statusEl = card.querySelector('.playlist-card-status'); - if (finalState) { - if (finalState.status === 'finished') { - statusEl.textContent = `Synced: Just now`; - statusEl.className = 'playlist-card-status status-synced'; - - // Check if any tracks were added to wishlist - const wishlistCount = finalState.progress?.wishlist_added_count || finalState.result?.wishlist_added_count || 0; - const unmatchedTracks = finalState.progress?.unmatched_tracks || finalState.result?.unmatched_tracks || []; - const playlistName = card.querySelector('.playlist-card-name').textContent; - - if (wishlistCount > 0 && unmatchedTracks.length > 0) { - const trackList = unmatchedTracks.map(t => `${t.artist} - ${t.name}`).join(', '); - showToast(`Sync complete for "${playlistName}". ${wishlistCount} not found in library: ${trackList}`, 'warning'); - } else if (wishlistCount > 0) { - showToast(`Sync complete for "${playlistName}". Added ${wishlistCount} missing track${wishlistCount > 1 ? 's' : ''} to wishlist.`, 'success'); - } else { - showToast(`Sync complete for "${playlistName}"`, 'success'); - } - } else { - statusEl.textContent = `Sync Failed`; - statusEl.className = 'playlist-card-status status-needs-sync'; // Or a new error class - showToast(`Sync failed: ${finalState.error || 'Unknown error'}`, 'error'); - } - } -} - -// Update the modal's sync progress display (matches GUI functionality) -function updateModalSyncProgress(playlistId, progress) { - const modal = document.getElementById('playlist-details-modal') || document.getElementById('deezer-arl-playlist-details-modal'); - if (modal && modal.style.display !== 'none') { - console.log(`📊 Updating modal sync progress for ${playlistId}:`, progress); - - // Show sync status display - const statusDisplay = document.getElementById(`modal-sync-status-${playlistId}`); - if (statusDisplay) { - statusDisplay.style.display = 'flex'; - - // Update counters (matching GUI exactly) - const totalEl = document.getElementById(`modal-total-${playlistId}`); - const matchedEl = document.getElementById(`modal-matched-${playlistId}`); - const failedEl = document.getElementById(`modal-failed-${playlistId}`); - const percentageEl = document.getElementById(`modal-percentage-${playlistId}`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - - // Calculate percentage like GUI - if (total > 0) { - const processed = matched + failed; - const percentage = Math.round((processed / total) * 100); - if (percentageEl) percentageEl.textContent = percentage; - } - - console.log(`📊 Modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); - } else { - console.warn(`❌ Modal sync status display not found for ${playlistId}`); - } - } else { - console.log(`📊 Modal not open for ${playlistId}, skipping update`); - } -} - - -// Download tracking state management - matching GUI functionality -let activeDownloads = {}; -let finishedDownloads = {}; -let downloadStatusInterval = null; -let isDownloadPollingActive = false; - -async function loadDownloadsData() { - // Downloads page loads search results dynamically - console.log('Downloads page loaded'); - - // Event listeners are already set up in initializeSearch() - don't duplicate them - const clearButton = document.querySelector('.controls-panel__clear-btn'); - const cancelAllButton = document.querySelector('.controls-panel__cancel-all-btn'); - - if (clearButton) { - clearButton.addEventListener('click', clearFinishedDownloads); - } - if (cancelAllButton) { - cancelAllButton.addEventListener('click', cancelAllDownloads); - } - - // Start sophisticated polling system (1-second interval like GUI) - startDownloadPolling(); - - // Initialize tab management - initializeDownloadTabs(); -} - -function startDownloadPolling() { - if (isDownloadPollingActive) return; - - console.log('Starting download status polling (1-second interval)'); - isDownloadPollingActive = true; - - // Initial call - updateDownloadQueues(); - - // Start 1-second polling (matching GUI's 1000ms timer) - downloadStatusInterval = setInterval(updateDownloadQueues, 1000); -} - -function stopDownloadPolling() { - if (downloadStatusInterval) { - clearInterval(downloadStatusInterval); - downloadStatusInterval = null; - } - isDownloadPollingActive = false; - console.log('Stopped download status polling'); -} - -async function updateDownloadQueues() { - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/downloads/status'); - const data = await response.json(); - - if (data.error) { - console.error("Error fetching download status:", data.error); - return; - } - - const newActive = {}; - const newFinished = {}; - - // Terminal states matching GUI logic - const terminalStates = ['Completed', 'Succeeded', 'Cancelled', 'Canceled', 'Failed', 'Errored']; - - // Process transfers exactly like GUI - data.transfers.forEach(item => { - const isTerminal = terminalStates.some(state => - item.state && item.state.includes(state) - ); - - if (isTerminal) { - newFinished[item.id] = item; - } else { - newActive[item.id] = item; - } - }); - - // Update global state - activeDownloads = newActive; - finishedDownloads = newFinished; - - // Render both queues - renderQueue('active-queue', activeDownloads, true); - renderQueue('finished-queue', finishedDownloads, false); - - // Update tab counts - updateTabCounts(); - - // Update stats in the side panel - updateDownloadStats(); - - } catch (error) { - // Only log errors occasionally to avoid console spam - if (Math.random() < 0.1) { - console.error("Failed to update download queues:", error); - } - } -} - -function renderQueue(containerId, downloads, isActiveQueue) { - const container = document.getElementById(containerId); - if (!container) return; - - const downloadIds = Object.keys(downloads); - - if (downloadIds.length === 0) { - container.innerHTML = `
    ${isActiveQueue ? 'No active downloads.' : 'No finished downloads.'}
    `; - return; - } - - let html = ''; - for (const id of downloadIds) { - const item = downloads[id]; - - // Extract display title from filename - let title = 'Unknown File'; - if (item.filename) { - // YouTube/Tidal filenames are encoded as "id||title" - if ((item.username === 'youtube' || item.username === 'tidal' || item.username === 'qobuz' || item.username === 'hifi') && item.filename.includes('||')) { - const parts = item.filename.split('||'); - title = parts[1] || parts[0]; // Use title part, fallback to id - } else { - // Regular Soulseek filename - extract last part of path - title = item.filename.split(/[\\/]/).pop(); - } - } - - const progress = item.percentComplete || 0; - const bytesTransferred = item.bytesTransferred || 0; - const totalBytes = item.size || 0; - const speed = item.averageSpeed || 0; - - // Format file size - const formatSize = (bytes) => { - if (!bytes) return 'Unknown size'; - const units = ['B', 'KB', 'MB', 'GB']; - let size = bytes; - let unitIndex = 0; - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - return `${size.toFixed(1)} ${units[unitIndex]}`; - }; - - // Format speed - const formatSpeed = (bytesPerSecond) => { - if (!bytesPerSecond || bytesPerSecond <= 0) return ''; - return `${formatSize(bytesPerSecond)}/s`; - }; - - let actionButtonHTML = ''; - if (isActiveQueue) { - // Active items get progress bar and cancel button - actionButtonHTML = ` -
    -
    -
    -
    -
    - ${item.state} - ${progress.toFixed(1)}% - ${speed > 0 ? `• ${formatSpeed(speed)}` : ''} - ${totalBytes > 0 ? `• ${formatSize(bytesTransferred)} / ${formatSize(totalBytes)}` : ''} -
    -
    - - `; - } else { - // Finished items get status and open button - let statusClass = ''; - if (item.state.includes('Cancelled')) statusClass = 'status--cancelled'; - else if (item.state.includes('Failed') || item.state.includes('Errored')) statusClass = 'status--failed'; - else if (item.state.includes('Completed') || item.state.includes('Succeeded')) statusClass = 'status--completed'; - - actionButtonHTML = ` -
    - ${item.state} -
    - - `; - } - - // Enrich with metadata from backend context (artist, album, artwork) - const meta = item._meta || {}; - const sourceLabels = { youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', lidarr: 'Lidarr' }; - const sourceBadge = sourceLabels[item.username] || item.username; - - html += ` -
    -
    - ${meta.artwork_url - ? `` - : '
    '} -
    -
    -
    ${title}
    - ${meta.artist || meta.album ? ` -
    - ${meta.artist ? `${escapeHtml(meta.artist)}` : ''} - ${meta.artist && meta.album ? '·' : ''} - ${meta.album ? `${escapeHtml(meta.album)}` : ''} -
    - ` : ''} -
    - ${sourceBadge} - ${meta.quality ? `${escapeHtml(meta.quality)}` : ''} -
    -
    -
    - ${actionButtonHTML} -
    -
    - `; - } - container.innerHTML = html; -} - -function updateTabCounts() { - const activeCount = Object.keys(activeDownloads).length; - const finishedCount = Object.keys(finishedDownloads).length; - - const activeTabBtn = document.querySelector('.tab-btn[data-tab="active-queue"]'); - const finishedTabBtn = document.querySelector('.tab-btn[data-tab="finished-queue"]'); - - if (activeTabBtn) activeTabBtn.textContent = `Download Queue (${activeCount})`; - if (finishedTabBtn) finishedTabBtn.textContent = `Finished (${finishedCount})`; -} - -function updateDownloadStats() { - const activeCount = Object.keys(activeDownloads).length; - const finishedCount = Object.keys(finishedDownloads).length; - - const activeLabel = document.getElementById('active-downloads-label'); - const finishedLabel = document.getElementById('finished-downloads-label'); - - if (activeLabel) activeLabel.textContent = `• Active Downloads: ${activeCount}`; - if (finishedLabel) finishedLabel.textContent = `• Finished Downloads: ${finishedCount}`; -} - -function initializeDownloadTabs() { - const tabButtons = document.querySelectorAll('.tab-btn'); - tabButtons.forEach(btn => { - btn.addEventListener('click', () => switchDownloadTab(btn)); - }); -} - -function switchDownloadTab(button) { - const targetTabId = button.getAttribute('data-tab'); - - // Update buttons - document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content panes - document.querySelectorAll('.download-queue').forEach(queue => queue.classList.remove('active')); - const targetQueue = document.getElementById(targetTabId); - if (targetQueue) targetQueue.classList.add('active'); -} - -async function cancelDownloadItem(downloadId, username) { - try { - const response = await fetch('/api/downloads/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ download_id: downloadId, username: username }) - }); - const result = await response.json(); - - if (result.success) { - showToast('Download cancelled', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling download:', error); - showToast('Error sending cancel request', 'error'); - } -} - -async function clearFinishedDownloads() { - const finishedCount = Object.keys(finishedDownloads).length; - if (finishedCount === 0) { - showToast('No finished downloads to clear', 'error'); - return; - } - - try { - const response = await fetch('/api/downloads/clear-finished', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('Finished downloads cleared', 'success'); - } else { - showToast(`Failed to clear: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error clearing finished downloads:', error); - showToast('Error sending clear request', 'error'); - } -} - -async function cancelAllDownloads() { - if (!await showConfirmDialog({ title: 'Cancel All Downloads', message: 'Cancel ALL active downloads and clear the transfer list? This cannot be undone.', confirmText: 'Cancel All', destructive: true })) { - return; - } - - try { - const response = await fetch('/api/downloads/cancel-all', { - method: 'POST' - }); - const result = await response.json(); - - if (result.success) { - showToast('All downloads cancelled and cleared', 'success'); - } else { - showToast(`Failed to cancel: ${result.error}`, 'error'); - } - } catch (error) { - console.error('Error cancelling all downloads:', error); - showToast('Error cancelling downloads', 'error'); - } -} - -// REPLACE the old performDownloadsSearch function with this new one. -async function performDownloadsSearch() { - const query = document.getElementById('downloads-search-input').value.trim(); - if (!query) { - showToast('Please enter a search term', 'error'); - return; - } - - // --- UI Element References --- - const searchInput = document.getElementById('downloads-search-input'); - const searchButton = document.getElementById('downloads-search-btn'); - const cancelButton = document.getElementById('downloads-cancel-btn'); - const statusText = document.getElementById('search-status-text'); - const spinner = document.querySelector('.spinner-animation'); - const dots = document.querySelector('.dots-animation'); - - // --- Start a new AbortController for this search --- - searchAbortController = new AbortController(); - - try { - // --- 1. Update UI to "Searching" State --- - searchInput.disabled = true; - searchButton.disabled = true; - cancelButton.classList.remove('hidden'); - spinner.classList.remove('hidden'); - dots.classList.remove('hidden'); - statusText.textContent = `Searching for '${query}'...`; - displayDownloadsResults([]); // Clear previous results - - // --- 2. Perform the Fetch Request --- - const response = await fetch('/api/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: searchAbortController.signal // Link fetch to the AbortController - }); - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - const results = data.results || []; - allSearchResults = results; - resetFilters(); - applyFiltersAndSort(); - - // --- 3. Update UI with Success State --- - if (results.length === 0) { - statusText.textContent = `No results found for '${query}'`; - showToast('No results found', 'error'); - } else { - document.getElementById('filters-container').classList.remove('hidden'); - - // Count albums and singles like the GUI app - let totalAlbums = 0; - let totalTracks = 0; - - results.forEach(result => { - if (result.result_type === 'album') { - totalAlbums++; - } else { - totalTracks++; - } - }); - - statusText.textContent = `✨ Found ${results.length} results • ${totalAlbums} albums, ${totalTracks} singles`; - showToast(`Found ${results.length} results`, 'success'); - } - - } catch (error) { - // --- 4. Handle Errors, Including Cancellation --- - if (error.name === 'AbortError') { - // This specific error is thrown when the user clicks "Cancel" - statusText.textContent = 'Search was cancelled.'; - showToast('Search cancelled', 'info'); - displayDownloadsResults([]); // Clear any partial results - } else { - console.error('Search failed:', error); - statusText.textContent = `Search failed: ${error.message}`; - showToast('Search failed', 'error'); - } - } finally { - // --- 5. Clean Up UI Regardless of Outcome --- - searchInput.disabled = false; - searchButton.disabled = false; - cancelButton.classList.add('hidden'); - spinner.classList.add('hidden'); - dots.classList.add('hidden'); - searchAbortController = null; // Clear the controller - } -} - -function displayDownloadsResults(results) { - const resultsArea = document.getElementById('search-results-area'); - if (!resultsArea) return; - - if (!results.length) { - resultsArea.innerHTML = '

    No search results found.

    '; - return; - } - - let html = ''; - results.forEach((result, index) => { - const isAlbum = result.result_type === 'album'; - - if (isAlbum) { - const trackCount = result.tracks ? result.tracks.length : 0; - const totalSize = result.total_size ? `${(result.total_size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; - - // Generate individual track items - let trackListHtml = ''; - if (result.tracks && result.tracks.length > 0) { - // Detect disc boundaries from track number resets for multi-disc albums - let currentDisc = 1; - let lastTrackNum = 0; - let discBreaks = new Set(); - result.tracks.forEach((track, trackIndex) => { - const tn = track.track_number || 0; - if (trackIndex > 0 && tn > 0 && tn <= lastTrackNum) { - currentDisc++; - discBreaks.add(trackIndex); - } - if (tn > 0) lastTrackNum = tn; - }); - const isMultiDisc = discBreaks.size > 0; - if (isMultiDisc) { - trackListHtml += `
    Disc 1
    `; - } - let discNum = 1; - result.tracks.forEach((track, trackIndex) => { - if (discBreaks.has(trackIndex)) { - discNum++; - trackListHtml += `
    Disc ${discNum}
    `; - } - const trackSize = track.size ? `${(track.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; - const trackBitrate = track.bitrate ? `${track.bitrate}kbps` : ''; - trackListHtml += ` -
    -
    -
    ${escapeHtml(track.title || `Track ${trackIndex + 1}`)}
    -
    - ${track.track_number ? `${track.track_number}. ` : ''}${escapeHtml(track.artist || result.artist || 'Unknown Artist')} • ${trackSize} • ${escapeHtml(track.quality || 'Unknown')} ${trackBitrate} -
    -
    -
    - - - -
    -
    - `; - }); - } - - html += ` -
    -
    -
    -
    💿
    -
    -
    ${escapeHtml(result.album_title || result.title || 'Unknown Album')}
    -
    by ${escapeHtml(result.artist || 'Unknown Artist')}
    -
    - ${trackCount} tracks • ${totalSize} • ${escapeHtml(result.quality || 'Mixed')} -
    -
    Shared by ${escapeHtml(result.username || 'Unknown')}
    -
    -
    - - -
    -
    - -
    - `; - } else { - const sizeText = result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : 'Unknown size'; - const bitrateText = result.bitrate ? `${result.bitrate}kbps` : ''; - html += ` -
    -
    🎵
    -
    -
    ${escapeHtml(result.title || 'Unknown Title')}
    -
    by ${escapeHtml(result.artist || 'Unknown Artist')}
    -
    - ${sizeText} • ${escapeHtml(result.quality || 'Unknown')} ${bitrateText} -
    -
    Shared by ${escapeHtml(result.username || 'Unknown')}
    -
    -
    - - - -
    -
    - `; - } - }); - - resultsArea.innerHTML = html; - // Store results globally for download functions - window.currentSearchResults = results; -} - -async function downloadTrack(index) { - const results = window.currentSearchResults; - if (!results || !results[index]) return; - - const track = results[index]; - - try { - const response = await fetch('/api/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(track) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`Download started: ${track.title}`, 'success'); - } else { - showToast(`Download failed: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Download error:', error); - showToast('Failed to start download', 'error'); - } -} - -async function downloadAlbum(index) { - const results = window.currentSearchResults; - if (!results || !results[index]) return; - - const album = results[index]; - - try { - const response = await fetch('/api/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(album) - }); - - const data = await response.json(); - - if (data.success) { - showToast(data.message, 'success'); - } else { - showToast(`Album download failed: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Album download error:', error); - showToast('Failed to start album download', 'error'); - } -} - -// Matched download functions -function matchedDownloadTrack(index) { - const results = window.currentSearchResults; - if (!results || !results[index]) return; - - const track = results[index]; - console.log('🎯 Starting matched download for single track:', track); - - // Open matching modal for single track - openMatchingModal(track, false, null); -} - -function matchedDownloadAlbum(index) { - const results = window.currentSearchResults; - if (!results || !results[index]) return; - - const album = results[index]; - console.log('🎯 Starting matched download for album:', album); - - // Open matching modal for album download - openMatchingModal(album, true, album); -} - -function matchedDownloadAlbumTrack(albumIndex, trackIndex) { - const results = window.currentSearchResults; - if (!results || !results[albumIndex]) return; - - const album = results[albumIndex]; - if (!album.tracks || !album.tracks[trackIndex]) return; - - const track = album.tracks[trackIndex]; - - // Ensure track has necessary properties from parent album - track.username = album.username; - track.artist = track.artist || album.artist; - track.album = album.album_title || album.title; - - console.log('🎯 Starting matched download for album track:', track); - - // Open matching modal for single track (from album context) - openMatchingModal(track, false, null); -} - -function toggleAlbumExpansion(albumIndex) { - const albumCard = document.querySelector(`[data-album-index="${albumIndex}"]`); - if (!albumCard) return; - - const trackList = albumCard.querySelector('.album-track-list'); - const indicator = albumCard.querySelector('.album-expand-indicator'); - - if (trackList.style.display === 'none' || !trackList.style.display) { - // Expand - trackList.style.display = 'block'; - indicator.textContent = '▼'; - albumCard.classList.add('expanded'); - } else { - // Collapse - trackList.style.display = 'none'; - indicator.textContent = '▶'; - albumCard.classList.remove('expanded'); - } -} - -async function downloadAlbumTrack(albumIndex, trackIndex) { - const results = window.currentSearchResults; - if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) return; - - const track = results[albumIndex].tracks[trackIndex]; - - try { - const response = await fetch('/api/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - ...track, - result_type: 'track' - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`Download started: ${track.title}`, 'success'); - } else { - showToast(`Track download failed: ${data.error}`, 'error'); - } - } catch (error) { - console.error('Track download error:', error); - showToast('Failed to start track download', 'error'); - } -} - -// =============================== -// STREAMING WRAPPER FUNCTIONS -// =============================== - -async function streamTrack(index) { - // Stream a single track from search results - try { - console.log(`🎵 streamTrack called with index: ${index}`); - console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults); - - if (!window.currentSearchResults || !window.currentSearchResults[index]) { - console.error(`❌ No search results or invalid index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); - showToast('Track not found', 'error'); - return; - } - - const result = window.currentSearchResults[index]; - console.log(`🎵 Streaming track:`, result); - - // Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check) - const isStreamingSource = result.username === 'youtube' || result.username === 'tidal' || result.username === 'qobuz' || result.username === 'hifi'; - if (!isStreamingSource && result.filename) { - const format = getFileExtension(result.filename); - console.log(`🎵 [STREAM CHECK] File: ${result.filename}, Extension: ${format}`); - - const isSupported = isAudioFormatSupported(result.filename); - console.log(`🎵 [STREAM CHECK] Format ${format} supported: ${isSupported}`); - - if (!isSupported) { - showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); - return; - } - } - - await startStream(result); - - } catch (error) { - console.error('Track streaming error:', error); - showToast('Failed to start track stream', 'error'); - } -} - - -async function streamAlbumTrack(albumIndex, trackIndex) { - // Stream a specific track from an album - try { - console.log(`🎵 streamAlbumTrack called with albumIndex: ${albumIndex}, trackIndex: ${trackIndex}`); - console.log(`🎵 window.currentSearchResults:`, window.currentSearchResults); - - if (!window.currentSearchResults || !window.currentSearchResults[albumIndex]) { - console.error(`❌ No search results or invalid album index. Results length: ${window.currentSearchResults ? window.currentSearchResults.length : 'undefined'}`); - showToast('Album not found', 'error'); - return; - } - - const album = window.currentSearchResults[albumIndex]; - console.log(`🎵 Album data:`, album); - - // Surgical Fix: Handle YouTube/Tidal results which are "flat" (no tracks array) - if (album.username === 'youtube' || album.username === 'tidal' || album.username === 'qobuz' || album.username === 'hifi') { - // For YouTube/Tidal results, the "album" is actually the track itself - const track = album; - const trackData = { - ...track, - username: track.username, - filename: track.filename, - artist: track.artist, - album: track.title, // Use title as album name for player - title: track.title - }; - console.log(`🎵 Streaming YouTube track directly:`, trackData); - await startStream(trackData); - return; - } - - if (!album.tracks || !album.tracks[trackIndex]) { - console.error(`❌ No tracks in album or invalid track index. Tracks length: ${album.tracks ? album.tracks.length : 'undefined'}`); - showToast('Track not found in album', 'error'); - return; - } - - const track = album.tracks[trackIndex]; - console.log(`🎵 Streaming album track:`, track); - - // Ensure album tracks have required fields - const trackData = { - ...track, - username: track.username || album.username, - filename: track.filename || track.path, - artist: track.artist || album.artist, - album: track.album || album.title || album.album - }; - - console.log(`🎵 Enhanced track data:`, trackData); - - // Check for unsupported formats before streaming (streaming sources use encoded filenames, skip check) - const isStreamingSource2 = trackData.username === 'youtube' || trackData.username === 'tidal' || trackData.username === 'qobuz' || trackData.username === 'hifi'; - if (!isStreamingSource2 && trackData.filename && !isAudioFormatSupported(trackData.filename)) { - const format = getFileExtension(trackData.filename); - showToast(`Sorry, ${format.toUpperCase()} format is not supported in web browsers. Try downloading instead.`, 'error'); - return; - } - - await startStream(trackData); - - } catch (error) { - console.error('Album track streaming error:', error); - showToast('Failed to start track stream', 'error'); - } -} - -async function loadArtistsData() { - try { - const response = await fetch(API.artists); - const data = await response.json(); - - const artistsGrid = document.getElementById('artists-grid'); - if (data.artists && data.artists.length) { - artistsGrid.innerHTML = data.artists.map(artist => ` -
    -
    - ${artist.image ? - `${escapeHtml(artist.name)}` : - '
    🎵
    ' - } -
    -
    -
    ${escapeHtml(artist.name)}
    -
    ${artist.album_count || 0} albums
    -
    -
    - `).join(''); - } else { - artistsGrid.innerHTML = '
    No artists found
    '; - } - } catch (error) { - console.error('Error loading artists data:', error); - document.getElementById('artists-grid').innerHTML = '
    Error loading artists
    '; - } -} - -// =============================== -// UTILITY FUNCTIONS -// =============================== - -function showLoadingOverlay(message = 'Loading...') { - const overlay = document.getElementById('loading-overlay'); - const messageElement = overlay.querySelector('.loading-message'); - messageElement.textContent = message; - overlay.classList.remove('hidden'); -} - -function hideLoadingOverlay() { - document.getElementById('loading-overlay').classList.add('hidden'); -} - -// ================================================================================== -// NOTIFICATION SYSTEM — Compact toasts + bell button + notification history panel -// ================================================================================== - -const _notifState = { - history: [], - unreadCount: 0, - panelOpen: false, - currentToast: null, - toastTimer: null, - maxHistory: 50, -}; -const _recentToastKeys = new Map(); - -const _notifIcons = { success: '✓', error: '✕', warning: '⚠', info: 'ℹ' }; - -function showToast(message, type = 'success', helpSection = null) { - const toastKey = `${type}:${message}`; - const now = Date.now(); - - // Deduplication — suppress identical toasts within 5 seconds - if (_recentToastKeys.has(toastKey) && now - _recentToastKeys.get(toastKey) < 5000) return; - _recentToastKeys.set(toastKey, now); - for (const [k, t] of _recentToastKeys) { if (now - t > 10000) _recentToastKeys.delete(k); } - - // Add to notification history - const entry = { id: now + Math.random(), message, type, helpSection, timestamp: now, read: false }; - _notifState.history.unshift(entry); - if (_notifState.history.length > _notifState.maxHistory) _notifState.history.pop(); - _notifState.unreadCount++; - _updateNotifBadge(); - - // Show compact toast — dismiss current if showing - const container = document.getElementById('toast-container'); - if (!container) return; - - if (_notifState.currentToast && container.contains(_notifState.currentToast)) { - _notifState.currentToast.classList.add('toast-exit'); - const old = _notifState.currentToast; - setTimeout(() => { if (container.contains(old)) container.removeChild(old); }, 200); - } - if (_notifState.toastTimer) clearTimeout(_notifState.toastTimer); - - const icon = _notifIcons[type] || 'ℹ'; - const toast = document.createElement('div'); - toast.className = `toast-compact toast-${type}`; - toast.innerHTML = `${icon}${_escToast(message)}`; - if (helpSection) { - const link = document.createElement('span'); - link.className = 'toast-compact-link'; - link.textContent = 'Learn more →'; - link.onclick = e => { e.stopPropagation(); if (typeof navigateToDocsSection === 'function') navigateToDocsSection(helpSection); }; - toast.appendChild(link); - } - toast.onclick = () => { toast.classList.add('toast-exit'); setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 200); }; - - container.appendChild(toast); - requestAnimationFrame(() => toast.classList.add('toast-enter')); - _notifState.currentToast = toast; - - _notifState.toastTimer = setTimeout(() => { - if (container.contains(toast)) { - toast.classList.add('toast-exit'); - setTimeout(() => { if (container.contains(toast)) container.removeChild(toast); }, 300); - } - _notifState.currentToast = null; - }, helpSection ? 5000 : 3500); -} - -function _escToast(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } -function _escAttr(s) { return _escToast(s).replace(/'/g, "\\'").replace(/\n/g, ' ').replace(/\r/g, ''); } - -function _updateNotifBadge() { - const badge = document.getElementById('notif-bell-badge'); - if (badge) { - badge.textContent = _notifState.unreadCount > 99 ? '99+' : _notifState.unreadCount; - badge.style.display = _notifState.unreadCount > 0 ? '' : 'none'; - } -} - -function toggleNotifPanel() { - if (_notifState.panelOpen) { - _closeNotifPanel(); - } else { - _openNotifPanel(); - } -} - -function _openNotifPanel() { - _closeNotifPanel(); // Remove existing - - _notifState.panelOpen = true; - _notifState.unreadCount = 0; - _notifState.history.forEach(e => e.read = true); - _updateNotifBadge(); - - const btn = document.getElementById('notif-bell-btn'); - const panel = document.createElement('div'); - panel.id = 'notif-panel'; - panel.className = 'notif-panel'; - - const entries = _notifState.history; - - panel.innerHTML = ` -
    - Notifications - ${entries.length > 0 ? '' : ''} -
    -
    - ${entries.length === 0 ? '
    No notifications yet
    ' : - entries.map(e => { - const icon = _notifIcons[e.type] || 'ℹ'; - const ago = _notifTimeAgo(e.timestamp); - const unreadDot = e.read ? '' : ''; - const learnMore = e.helpSection ? `Learn more →` : ''; - return ` -
    - ${unreadDot} - ${icon} -
    -
    ${_escToast(e.message)}
    - -
    -
    `; - }).join('')} -
    - `; - - document.body.appendChild(panel); - - // Position above the bell button - if (btn) { - const rect = btn.getBoundingClientRect(); - panel.style.right = (window.innerWidth - rect.right) + 'px'; - panel.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; - } - - requestAnimationFrame(() => panel.classList.add('visible')); - - // Close on outside click - setTimeout(() => { - const closeHandler = e => { - if (!panel.contains(e.target) && e.target.id !== 'notif-bell-btn') { - _closeNotifPanel(); - document.removeEventListener('click', closeHandler); - } - }; - document.addEventListener('click', closeHandler); - }, 100); -} - -function _closeNotifPanel() { - _notifState.panelOpen = false; - const panel = document.getElementById('notif-panel'); - if (panel) { - panel.classList.remove('visible'); - setTimeout(() => panel.remove(), 200); - } -} - -function _clearNotifHistory() { - _notifState.history = []; - _notifState.unreadCount = 0; - _updateNotifBadge(); - _closeNotifPanel(); -} - -function _notifTimeAgo(ts) { - const s = Math.floor((Date.now() - ts) / 1000); - if (s < 5) return 'just now'; - if (s < 60) return `${s}s ago`; - const m = Math.floor(s / 60); - if (m < 60) return `${m}m ago`; - const h = Math.floor(m / 60); - if (h < 24) return `${h}h ago`; - return `${Math.floor(h / 24)}d ago`; -} - -// ================================================================================== -// Music video download handler — defined at top level so both enhanced and global search can use it -function _downloadMusicVideo(cardEl, video) { - if (cardEl.classList.contains('downloading') || cardEl.classList.contains('completed')) return; - cardEl.classList.add('downloading'); - cardEl.onclick = null; - - const playBtn = cardEl.querySelector('.enh-video-play'); - const progressRing = cardEl.querySelector('.enh-video-progress-ring'); - const progressBar = cardEl.querySelector('.enh-video-progress-bar'); - const doneIcon = cardEl.querySelector('.enh-video-done'); - const errorIcon = cardEl.querySelector('.enh-video-error'); - - if (playBtn) playBtn.classList.add('hidden'); - if (progressRing) progressRing.classList.remove('hidden'); - - fetch('/api/music-video/download', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ video_id: video.video_id, url: video.url, title: video.title, channel: video.channel }), - }).then(res => { - if (!res.ok) throw new Error('Download request failed'); - const circumference = 97.4; - const pollInterval = setInterval(async () => { - try { - const statusRes = await fetch(`/api/music-video/status/${video.video_id}`); - const status = await statusRes.json(); - if (progressBar && status.progress > 0) { - progressBar.style.strokeDashoffset = circumference - (status.progress / 100) * circumference; - } - if (status.status === 'completed') { - clearInterval(pollInterval); - cardEl.classList.remove('downloading'); - cardEl.classList.add('completed'); - if (progressRing) progressRing.classList.add('hidden'); - if (doneIcon) doneIcon.classList.remove('hidden'); - } else if (status.status === 'error') { - clearInterval(pollInterval); - cardEl.classList.remove('downloading'); - cardEl.classList.add('errored'); - if (progressRing) progressRing.classList.add('hidden'); - if (errorIcon) errorIcon.classList.remove('hidden'); - cardEl.onclick = () => _downloadMusicVideo(cardEl, video); - } - } catch (e) { } - }, 500); - }).catch(e => { - cardEl.classList.remove('downloading'); - if (progressRing) progressRing.classList.add('hidden'); - if (playBtn) playBtn.classList.remove('hidden'); - if (errorIcon) errorIcon.classList.remove('hidden'); - cardEl.onclick = () => _downloadMusicVideo(cardEl, video); - }); -} - -// Global search video click — decodes base64 video data and delegates to _downloadMusicVideo -function _gsClickVideo(cardEl) { - try { - const encoded = cardEl.dataset.video; - const video = JSON.parse(decodeURIComponent(escape(atob(encoded)))); - _downloadMusicVideo(cardEl, video); - } catch (e) { - console.error('Failed to parse video data:', e); - } -} - -// GLOBAL SEARCH BAR — Spotlight-style search from anywhere -// ================================================================================== - -const _gsState = { - active: false, - query: '', - data: null, - sources: {}, - activeSource: null, - abortCtrl: null, - altAbortCtrl: null, - debounceTimer: null, -}; - -(function initGlobalSearch() { - // Defer init until DOM is ready - const _doInit = () => { - const bar = document.getElementById('gsearch-bar'); - const input = document.getElementById('gsearch-input'); - const results = document.getElementById('gsearch-results'); - if (!input || !bar) return; - - bar.addEventListener('click', () => input.focus()); - - input.addEventListener('focus', () => { - bar.classList.add('active'); - _gsState.active = true; - const shortcut = document.getElementById('gsearch-shortcut'); - if (shortcut) shortcut.style.display = 'none'; - if (_gsState.data && _gsState.query) _gsShowResults(); - }); - - // No blur handler — closing is handled by click-outside and Escape only - // This prevents tab switching and result clicks from closing the panel - - const clearBtn = document.getElementById('gsearch-clear'); - - input.addEventListener('input', () => { - const q = input.value.trim(); - _gsState.query = q; - if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none'; - if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); - if (q.length < 2) { _gsHideResults(); return; } - _gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300); - }); - - if (clearBtn) { - clearBtn.addEventListener('click', e => { - e.stopPropagation(); - input.value = ''; - _gsState.query = ''; - _gsState.data = null; - clearBtn.style.display = 'none'; - _gsHideResults(); - input.focus(); - }); - } - - input.addEventListener('keydown', e => { - if (e.key === 'Enter') { - e.preventDefault(); - if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer); - const q = input.value.trim(); - if (q.length >= 2) _gsPerformSearch(q); - } else if (e.key === 'Escape') { - _gsDeactivate(); - input.blur(); - } - }); - - // Keyboard shortcuts - document.addEventListener('keydown', e => { - if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); input.focus(); return; } - if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); input.focus(); } - }); - - // Click outside to close — uses delayed check because tab clicks replace DOM - document.addEventListener('click', e => { - if (!_gsState.active) return; - // Skip if click was recent interaction with search system (within 100ms of a switch) - if (_gsState._lastInteraction && Date.now() - _gsState._lastInteraction < 200) return; - setTimeout(() => { - if (!_gsState.active) return; - const freshBar = document.getElementById('gsearch-bar'); - const freshResults = document.getElementById('gsearch-results'); - const target = e.target; - if (freshBar?.contains(target) || freshResults?.contains(target)) return; - _gsDeactivate(); - }, 100); - }); - - // Collapse on sidebar navigation + hide on downloads page - document.addEventListener('click', e => { - if (e.target.closest('.sidebar-link, .nav-item, .back-btn')) { - if (_gsState.active) _gsDeactivate(); - // Check after navigation which page we're on - setTimeout(_gsUpdateVisibility, 200); - } - }); - }; - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { _doInit(); _gsUpdateVisibility(); }); - else { _doInit(); setTimeout(_gsUpdateVisibility, 500); } -})(); - -function _gsUpdateVisibility() { - const bar = document.getElementById('gsearch-bar'); - if (!bar) return; - // Hide on downloads page where enhanced search already exists - const onDownloads = typeof currentPage !== 'undefined' && currentPage === 'downloads'; - bar.style.display = onDownloads ? 'none' : ''; - if (onDownloads && _gsState.active) _gsDeactivate(); -} - -function _gsDeactivate() { - const bar = document.getElementById('gsearch-bar'); - const shortcut = document.getElementById('gsearch-shortcut'); - if (bar) bar.classList.remove('active'); - if (shortcut) shortcut.style.display = ''; - _gsState.active = false; - _gsHideResults(); -} - -function _gsHideResults() { - const r = document.getElementById('gsearch-results'); - if (r) r.classList.remove('visible'); -} - -function _gsShowResults() { - const r = document.getElementById('gsearch-results'); - if (r && r.innerHTML.trim()) r.classList.add('visible'); -} - -async function _gsPerformSearch(query) { - if (_gsState.abortCtrl) _gsState.abortCtrl.abort(); - if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort(); - _gsState.abortCtrl = new AbortController(); - _gsState.altAbortCtrl = new AbortController(); - - const results = document.getElementById('gsearch-results'); - if (!results) return; - - results.innerHTML = '
    Searching...
    '; - results.classList.add('visible'); - - try { - const res = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _gsState.abortCtrl.signal, - }); - const data = await res.json(); - _gsState.data = data; - _gsState.activeSource = data.primary_source || 'spotify'; - _gsState.sources = {}; - _gsState.sources[_gsState.activeSource] = { - artists: data.spotify_artists || [], - albums: data.spotify_albums || [], - tracks: data.spotify_tracks || [], - }; - - _gsRender(data); - - // Async library ownership check — adds badges + swaps play buttons for library tracks - setTimeout(() => _gsLibraryCheck(), 200); - - // Fetch alternate sources — stream NDJSON so slow sources render incrementally - const alts = data.alternate_sources || []; - for (const src of alts) { - if (src === _gsState.activeSource) continue; - _gsFetchSourceStream(src, query); - } - } catch (e) { - if (e.name !== 'AbortError') results.innerHTML = '
    Search failed
    '; - } -} - -async function _gsFetchSourceStream(src, query) { - try { - const res = await fetch(`/api/enhanced-search/source/${src}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }), - signal: _gsState.altAbortCtrl.signal, - }); - if (!res.ok) return; - - if (!_gsState.sources[src]) { - const loadingSet = src === 'youtube_videos' ? new Set(['videos']) : new Set(['artists', 'albums', 'tracks']); - _gsState.sources[src] = { artists: [], albums: [], tracks: [], videos: [], available: true, _loading: loadingSet }; - } - const sourceData = _gsState.sources[src]; - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - let idx; - while ((idx = buffer.indexOf('\n')) !== -1) { - const line = buffer.slice(0, idx).trim(); - buffer = buffer.slice(idx + 1); - if (!line) continue; - try { - const chunk = JSON.parse(line); - if (chunk.type === 'artists') { sourceData.artists = chunk.data; if (sourceData._loading) sourceData._loading.delete('artists'); } - else if (chunk.type === 'albums') { sourceData.albums = chunk.data; if (sourceData._loading) sourceData._loading.delete('albums'); } - else if (chunk.type === 'tracks') { sourceData.tracks = chunk.data; if (sourceData._loading) sourceData._loading.delete('tracks'); } - else if (chunk.type === 'videos') { sourceData.videos = chunk.data; if (sourceData._loading) sourceData._loading.delete('videos'); } - if (chunk.type === 'done') delete sourceData._loading; - _gsRenderTabs(); - // Re-render content if this is the active source tab - if (_gsState.activeSource === src && _gsState.data) { - _gsRender(_gsState.data); - } - } catch (e) { } - } - } - _gsRenderTabs(); - } catch (e) { - if (e.name !== 'AbortError') console.debug(`GS alt source ${src} failed:`, e); - } -} - -function _gsRender(data) { - const results = document.getElementById('gsearch-results'); - if (!results) return; - - // Music Videos tab — render video grid instead of regular results - if (_gsState.activeSource === 'youtube_videos') { - const src = _gsState.sources['youtube_videos'] || {}; - const videos = src.videos || []; - const isLoading = src._loading && src._loading.size > 0; - let h = ''; - h += `
    Results${videos.length} videos
    `; - h += '
    '; - h += '
    '; - if (isLoading) { - h += '
    Searching YouTube...
    '; - } else if (videos.length === 0) { - h += `
    No music videos found for "${_escToast(_gsState.query)}"
    `; - } else { - h += '
    🎬 Music Videos
    '; - h += '
    '; - h += videos.map(v => { - const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; - const views = v.view_count >= 1000000 ? `${(v.view_count / 1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count / 1000).toFixed(1)}K` : (v.view_count || ''); - const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v)))); - return `
    -
    - - - ${dur ? `${dur}` : ''}
    -
    ${_escToast(v.title)}
    ${_escToast(v.channel)}${views ? ` · ${views} views` : ''}
    -
    `; - }).join(''); - h += '
    '; - } - h += '
    '; - results.innerHTML = h; - results.classList.add('visible'); - _gsRenderTabs(); - return; - } - - const src = _gsState.sources[_gsState.activeSource] || {}; - const loading = src._loading || new Set(); - const dbArtists = data?.db_artists || []; - const artists = src.artists || []; - const allAlbums = src.albums || []; - const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); - const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); - const tracks = src.tracks || []; - const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length; - const isLoading = loading.size > 0; - - if (total === 0 && !isLoading) { - results.innerHTML = `
    No results for "${_escToast(_gsState.query)}"
    Try different keywords or check spelling
    `; - results.classList.add('visible'); - return; - } - - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase', youtube_videos: 'Music Videos', musicbrainz: 'MusicBrainz' }; - const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || ''; - - let h = ''; - h += `
    Results${total} items
    `; - h += '
    '; - h += '
    '; - - if (dbArtists.length) { - h += '
    📚 In Your Library
    '; - h += dbArtists.map(a => `
    ${a.image_url ? `` : '🎤'}
    ${_escToast(a.name)}
    Library
    `).join(''); - h += '
    '; - } - - if (artists.length) { - h += `
    🎤 Artists ${srcLabel}
    `; - h += artists.map(a => `
    ${a.image_url ? `` : '🎤'}
    ${_escToast(a.name)}
    `).join(''); - h += '
    '; - } else if (loading.has('artists')) { - h += `
    🎤 Artists ${srcLabel}
    Loading artists...
    `; - } - - const activeSrc = _gsState.activeSource || 'spotify'; - - if (albums.length) { - h += `
    💿 Albums ${srcLabel}
    `; - h += albums.map(a => { - const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); - const yr = a.release_date ? a.release_date.substring(0, 4) : ''; - const img = (a.image_url || '').replace(/'/g, "\\'"); - return `
    ${a.image_url ? `` : '💿'}
    ${_escToast(a.name)}
    ${_escToast(ar)}${yr ? ` · ${yr}` : ''}
    `; - }).join(''); - h += '
    '; - } - - if (!albums.length && !singles.length && loading.has('albums')) { - h += `
    💿 Albums ${srcLabel}
    Loading albums...
    `; - } - - if (singles.length) { - h += `
    🎶 Singles & EPs ${srcLabel}
    `; - h += singles.map(a => { - const ar = a.artist || (a.artists ? a.artists.join(', ') : ''); - const img = (a.image_url || '').replace(/'/g, "\\'"); - return `
    ${a.image_url ? `` : '🎶'}
    ${_escToast(a.name)}
    ${_escToast(ar)}
    `; - }).join(''); - h += '
    '; - } - - if (tracks.length) { - h += `
    🎵 Tracks ${srcLabel}
    `; - h += tracks.map(t => { - const ar = t.artist || (t.artists ? t.artists.join(', ') : ''); - const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return `
    ${t.image_url ? `` : '🎵'}
    ${_escToast(t.name)}
    ${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}
    ${dur}
    `; - }).join(''); - h += '
    '; - } else if (loading.has('tracks')) { - h += `
    🎵 Tracks ${srcLabel}
    Loading tracks...
    `; - } - - h += '
    '; - results.innerHTML = h; - results.classList.add('visible'); - _gsRenderTabs(); - - // Lazy load artist images for sources that don't provide them (iTunes/Deezer) - _gsLazyLoadArtistImages(); -} - -async function _gsLazyLoadArtistImages() { - const grid = document.getElementById('gsearch-artists-grid'); - if (!grid) return; - const cards = grid.querySelectorAll('[data-needs-image="true"]'); - if (cards.length === 0) return; - const activeSrc = _gsState.activeSource || 'spotify'; - - for (const card of cards) { - const artistId = card.dataset.artistId; - if (!artistId) continue; - try { - const res = await fetch(`/api/artist/${artistId}/image?source=${activeSrc}`); - const data = await res.json(); - if (data.success && data.image_url) { - const artDiv = card.querySelector('.gsearch-item-art'); - if (artDiv) artDiv.innerHTML = ``; - card.removeAttribute('data-needs-image'); - } - } catch (e) { /* ignore */ } - } -} - -function _gsRenderTabs() { - const el = document.getElementById('gsearch-tabs'); - if (!el) return; - const sources = Object.keys(_gsState.sources); - const labels = { - spotify: 'Spotify', - itunes: 'Apple Music', - deezer: 'Deezer', - discogs: 'Discogs', - hydrabase: 'Hydrabase', - youtube_videos: 'Music Videos', - musicbrainz: 'MusicBrainz', - }; - const visibleSources = sources.filter(s => { - const d = _gsState.sources[s] || {}; - const count = s === 'youtube_videos' - ? (d.videos?.length || 0) - : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); - const isLoading = !!(d._loading && d._loading.size > 0); - return isLoading || count > 0 || s === _gsState.activeSource; - }); - if (visibleSources.length < 2) { el.style.display = 'none'; return; } - el.style.display = 'flex'; - el.innerHTML = visibleSources.map(s => { - const d = _gsState.sources[s]; - const c = s === 'youtube_videos' - ? (d.videos?.length || 0) - : (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0); - return ``; - }).join(''); -} - -function _gsSwitchSource(src) { - _gsState._lastInteraction = Date.now(); - _gsState.activeSource = src; - _gsRender(_gsState.data); - const input = document.getElementById('gsearch-input'); - if (input) input.focus(); -} - -function _gsClickArtist(id, name, isLibrary) { - _gsDeactivate(); - if (isLibrary) { - // Same as enhanced search: navigateToArtistDetail - navigateToArtistDetail(id, name); - } else { - // Same as enhanced search: navigate to Artists page + selectArtistForDetail - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail({ id, name, image_url: '' }, { - source: _gsState.activeSource || '', - }); - }, 150); - } -} - -async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) { - _gsDeactivate(); - // Same flow as handleEnhancedSearchAlbumClick — fetch album, open download modal - showLoadingOverlay('Loading album...'); - try { - const params = new URLSearchParams({ name: albumName, artist: artistName }); - if (source && source !== 'spotify') params.set('source', source); - const response = await fetch(`/api/spotify/album/${albumId}?${params}`); - if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); - const albumData = await response.json(); - - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - hideLoadingOverlay(); - showToast(`No tracks available for "${albumName}"`, 'warning'); - return; - } - - const enrichedTracks = albumData.tracks.map(t => ({ - ...t, - album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks } - })); - - const virtualPlaylistId = `enhanced_search_album_${albumId}`; - const firstArtist = (albumData.artists || [])[0] || {}; - const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' }; - const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] }; - - await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false); - - // Register download bubble (same pattern as enhanced search) - registerSearchDownload( - { - id: albumData.id, - name: albumData.name, - artist: artistName, - image_url: albumData.images?.[0]?.url || imageUrl || null, - images: albumData.images || [] - }, - 'album', - virtualPlaylistId, - artistName - ); - - } catch (e) { - hideLoadingOverlay(); - showToast('Failed to load album: ' + e.message, 'error'); - } -} - -async function _gsClickTrack(artistName, trackName, albumName, trackId, imageUrl, durationMs) { - _gsDeactivate(); - - // Build enriched track + open download modal directly (same as enhanced search) - const virtualPlaylistId = `gsearch_track_${trackId || (artistName + '_' + trackName).replace(/\s/g, '_')}`; - const enrichedTrack = { - id: trackId || '', - name: trackName, - artists: [artistName], - album: { name: albumName || '', id: null, album_type: 'single', images: imageUrl ? [{ url: imageUrl }] : [], total_tracks: 1 }, - duration_ms: durationMs || 0, - image_url: imageUrl || '', - }; - const albumObject = { - name: albumName || '', id: null, album_type: 'single', - images: imageUrl ? [{ url: imageUrl }] : [], - artists: [{ name: artistName }], total_tracks: 1, - }; - const artistObject = { id: null, name: artistName }; - const playlistName = `${artistName} - ${trackName}`; - - try { - showLoadingOverlay('Loading track...'); - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, playlistName, [enrichedTrack], albumObject, artistObject, false - ); - - // Register download bubble (same pattern as enhanced search) - registerSearchDownload( - { - id: trackId || '', - name: trackName, - artist: artistName, - image_url: imageUrl || null, - images: imageUrl ? [{ url: imageUrl }] : [] - }, - 'track', - virtualPlaylistId, - artistName - ); - } catch (e) { - console.error('Error opening track download:', e); - // Fallback: navigate to enhanced search - navigateToPage('downloads'); - setTimeout(() => { - const input = document.getElementById('enhanced-search-input'); - if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); } - }, 300); - } finally { - hideLoadingOverlay(); - } -} - -async function _gsPlayTrack(trackName, artistName, albumName) { - try { - showToast('Searching for stream...', 'info'); - const res = await fetch('/api/enhanced-search/stream-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName }) - }); - const data = await res.json(); - if (data.success && data.result) { - if (typeof startStream === 'function') { - startStream(data.result); - } else { - showToast('Streaming not available', 'error'); - } - } else { - showToast(data.error || 'No stream found', 'error'); - } - } catch (e) { - showToast('Stream failed: ' + e.message, 'error'); - } -} - -// Async library check for global search results — adds badges + swaps play buttons -async function _gsLibraryCheck() { - try { - const src = _gsState.sources[_gsState.activeSource] || {}; - const allAlbums = src.albums || []; - const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation'); - const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); - const tracks = src.tracks || []; - if (!allAlbums.length && !tracks.length) return; - - const res = await fetch('/api/enhanced-search/library-check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })), - tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })), - }) - }); - const checkData = await res.json(); - - // Add "In Library" badges to albums — match by index against allAlbums order - const albumResults = checkData.albums || []; - let albumIdx = 0; - // Albums section - document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => { - // Find all gsearch-item elements and tag ones that are albums - const sections = body.querySelectorAll('.gsearch-section-header'); - sections.forEach(header => { - const text = header.textContent; - const isAlbumSection = text.includes('Albums') || text.includes('Singles'); - if (!isAlbumSection) return; - const grid = header.nextElementSibling; - if (!grid) return; - const items = grid.querySelectorAll('.gsearch-item'); - items.forEach(item => { - if (albumIdx < albumResults.length && albumResults[albumIdx]) { - if (!item.querySelector('.gsearch-item-badge')) { - const badge = document.createElement('span'); - badge.className = 'gsearch-item-badge'; - badge.textContent = 'In Library'; - item.appendChild(badge); - } - } - albumIdx++; - }); - }); - }); - - // Tag tracks + swap play buttons for library playback - const trackResults = checkData.tracks || []; - const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track'); - trackEls.forEach((el, i) => { - const tr = trackResults[i]; - if (tr && tr.in_library) { - // Add badge - if (!el.querySelector('.gsearch-item-badge')) { - const badge = document.createElement('span'); - badge.className = 'gsearch-item-badge'; - badge.textContent = 'In Library'; - badge.style.marginRight = '4px'; - el.querySelector('.gsearch-track-dur')?.before(badge); - } - - // Swap play button to library playback - if (tr.file_path) { - const playBtn = el.querySelector('.gsearch-play-btn'); - if (playBtn) { - const newBtn = playBtn.cloneNode(true); - newBtn.removeAttribute('onclick'); - newBtn.title = 'Play from library'; - newBtn.style.background = 'rgba(76,175,80,0.15)'; - newBtn.style.color = '#4caf50'; - newBtn.addEventListener('click', e => { - e.stopPropagation(); - playLibraryTrack( - { id: tr.track_id, title: tr.title, file_path: tr.file_path, _stats_image: tr.album_thumb_url || null }, - tr.album_title || '', - tr.artist_name || '' - ); - }); - playBtn.replaceWith(newBtn); - } - } - } else if (tr && tr.in_wishlist) { - if (!el.querySelector('.gsearch-item-badge')) { - const badge = document.createElement('span'); - badge.className = 'gsearch-item-badge gsearch-wishlist-badge'; - badge.textContent = 'In Wishlist'; - badge.style.marginRight = '4px'; - el.querySelector('.gsearch-track-dur')?.before(badge); - } - } - }); - } catch (e) { - // Non-critical - } -} - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * Escape a value for safe use inside a single-quoted JS string literal - * within a double-quoted HTML attribute (e.g. onclick="fn('${val}')"). - * - * Layer 1 (JS): escape \ and ' so the JS string parses correctly. - * Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly. - * The browser applies these in reverse: HTML-decode first, then JS-execute. - */ -function escapeForInlineJs(str) { - if (str == null) return ''; - return String(str) - .replace(/\\/g, '\\\\') // JS: literal backslash - .replace(/'/g, "\\'") // JS: single quote - .replace(/&/g, '&') // HTML: ampersand - .replace(/"/g, '"') // HTML: double quote - .replace(//g, '>'); // HTML: greater-than -} - -function formatArtists(artists) { - if (!artists || !Array.isArray(artists)) { - return 'Unknown Artist'; - } - - // Handle both string arrays and object arrays with 'name' property - const artistNames = artists.map(artist => { - let artistName; - if (typeof artist === 'string') { - artistName = artist; - } else if (artist && typeof artist === 'object' && artist.name) { - artistName = artist.name; - } else { - artistName = 'Unknown Artist'; - } - - // Clean featured artists from the name - return cleanArtistName(artistName); - }); - - return artistNames.join(', ') || 'Unknown Artist'; -} - -async function checkForUpdates() { - try { - const res = await fetch('/api/update-check'); - if (!res.ok) return; - const data = await res.json(); - const btn = document.querySelector('.version-button'); - if (!btn) return; - if (data.update_available) { - const dismissed = localStorage.getItem('soulsync-update-dismissed'); - if (dismissed !== data.latest_sha) { - // Add glow class - btn.classList.add('update-available'); - // Add UPDATE badge if not already present - if (!btn.querySelector('.update-badge')) { - const badge = document.createElement('span'); - badge.className = 'update-badge'; - badge.textContent = 'UPDATE'; - btn.appendChild(badge); - } - // Show toast on first detection (not if already notified this session) - const notified = sessionStorage.getItem('soulsync-update-notified'); - if (notified !== data.latest_sha) { - sessionStorage.setItem('soulsync-update-notified', data.latest_sha); - showToast(data.is_docker - ? 'A new SoulSync update has been pushed to the repo — Docker image will be updated soon!' - : 'A new SoulSync update is available!', 'info'); - } - } - } else { - btn.classList.remove('update-available'); - const badge = btn.querySelector('.update-badge'); - if (badge) badge.remove(); - } - } catch (e) { - console.debug('Update check failed:', e); - } -} - -async function showVersionInfo() { - // Check update status before dismissing so we can pass it to the modal - let updateInfo = null; - const btn = document.querySelector('.version-button'); - const hadUpdate = btn && btn.classList.contains('update-available'); - - // Dismiss update glow when user opens the modal - if (hadUpdate) { - btn.classList.remove('update-available'); - const badge = btn.querySelector('.update-badge'); - if (badge) badge.remove(); - try { - const updateRes = await fetch('/api/update-check'); - if (updateRes.ok) { - updateInfo = await updateRes.json(); - if (updateInfo.latest_sha) { - localStorage.setItem('soulsync-update-dismissed', updateInfo.latest_sha); - } - } - } catch (e) { /* ignore */ } - } - - try { - console.log('Fetching version info...'); - - // Fetch version data from API - const response = await fetch('/api/version-info'); - if (!response.ok) { - throw new Error('Failed to fetch version info'); - } - - const versionData = await response.json(); - console.log('Version data received:', versionData); - - // Populate modal content - populateVersionModal(versionData, hadUpdate ? updateInfo : null); - - // Show modal - const modalOverlay = document.getElementById('version-modal-overlay'); - modalOverlay.classList.remove('hidden'); - - console.log('Version modal opened'); - - } catch (error) { - console.error('Error showing version info:', error); - showToast('Failed to load version information', 'error'); - } -} - -function closeVersionModal() { - const modalOverlay = document.getElementById('version-modal-overlay'); - modalOverlay.classList.add('hidden'); - console.log('Version modal closed'); -} - -function populateVersionModal(versionData, updateInfo) { - const container = document.getElementById('version-content-container'); - if (!container) { - console.error('Version content container not found'); - return; - } - - // Update header with dynamic data - const titleElement = document.querySelector('.version-modal-title'); - const subtitleElement = document.querySelector('.version-modal-subtitle'); - - if (titleElement) titleElement.textContent = versionData.title; - if (subtitleElement) subtitleElement.textContent = versionData.subtitle; - - // Clear existing content - container.innerHTML = ''; - - // Show update banner if an update was available when modal was opened - if (updateInfo && updateInfo.update_available) { - const banner = document.createElement('div'); - banner.className = 'version-update-banner'; - const isDocker = updateInfo.is_docker; - banner.innerHTML = ` -
    -
    - ${isDocker ? 'Repo update detected' : 'New update available'} - ${isDocker - ? 'A new update has been pushed to the repo. The Docker image will be updated soon — no action needed yet.' - : `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`} -
    - `; - container.appendChild(banner); - } - - // Create sections - versionData.sections.forEach(section => { - const sectionDiv = document.createElement('div'); - sectionDiv.className = 'version-feature-section'; - - // Section title - const titleDiv = document.createElement('div'); - titleDiv.className = 'version-section-title'; - titleDiv.textContent = section.title; - sectionDiv.appendChild(titleDiv); - - // Section description - const descDiv = document.createElement('div'); - descDiv.className = 'version-section-description'; - descDiv.textContent = section.description; - sectionDiv.appendChild(descDiv); - - // Features list - const featuresList = document.createElement('ul'); - featuresList.className = 'version-feature-list'; - - section.features.forEach(feature => { - const featureItem = document.createElement('li'); - featureItem.className = 'version-feature-item'; - featureItem.textContent = feature; - featuresList.appendChild(featureItem); - }); - - sectionDiv.appendChild(featuresList); - - // Usage note (if present) - if (section.usage_note) { - const usageDiv = document.createElement('div'); - usageDiv.className = 'version-usage-note'; - usageDiv.textContent = `💡 ${section.usage_note}`; - sectionDiv.appendChild(usageDiv); - } - - container.appendChild(sectionDiv); - }); - - console.log('Version modal content populated'); -} - -// =============================== -// ADDITIONAL STYLES FOR SEARCH RESULTS -// =============================== - -// Add dynamic styles for search results (since they're created dynamically) -const additionalStyles = ` - -`; - -// Inject additional styles -document.head.insertAdjacentHTML('beforeend', additionalStyles); - -// ============================================================================ -// DISCOVERY FIX MODAL - Manual Track Matching -// ============================================================================ - -// Global state for discovery fix -let currentDiscoveryFix = { - platform: null, // 'youtube', 'tidal', 'beatport' - identifier: null, // url_hash or playlist_id - trackIndex: null, - sourceTrack: null, - sourceArtist: null -}; - -// Store event handler reference to allow proper removal -let discoveryFixEnterHandler = null; - -/** - * Open discovery fix modal for a specific track - */ -function openDiscoveryFixModal(platform, identifier, trackIndex) { - console.log(`🔧 Opening fix modal: ${platform} - ${identifier} - track ${trackIndex}`); - - // Get the discovery state - // Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure - let state, result; - if (platform === 'youtube') { - // Check both states - ListenBrainz also uses YouTube modal infrastructure - state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; - } else if (platform === 'tidal') { - state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure - } else if (platform === 'beatport') { - state = youtubePlaylistStates[identifier]; // Beatport uses YouTube state infrastructure - } else if (platform === 'listenbrainz') { - state = listenbrainzPlaylistStates[identifier]; // ListenBrainz has its own state - } else if (platform === 'deezer') { - state = youtubePlaylistStates[identifier]; // Deezer uses YouTube state infrastructure - } else if (platform === 'mirrored') { - state = youtubePlaylistStates[identifier]; // Mirrored playlists use YouTube state infrastructure - } - - // Support both camelCase and snake_case for discovery results - const results = state?.discoveryResults || state?.discovery_results; - result = results?.[trackIndex]; - - if (!result) { - console.error('❌ Track data not found'); - console.error(' Platform:', platform); - console.error(' Identifier:', identifier); - console.error(' State:', state); - console.error(' Discovery results (camelCase):', state?.discoveryResults?.length); - console.error(' Discovery results (snake_case):', state?.discovery_results?.length); - showToast('Track data not found', 'error'); - return; - } - - console.log('✅ Found result:', result); - - // Store context - currentDiscoveryFix = { - platform, - identifier, - trackIndex, - sourceTrack: result.lb_track || result.yt_track || result.tidal_track?.name || result.beatport_track?.title || result.track_name || 'Unknown Track', - sourceArtist: result.lb_artist || result.yt_artist || result.tidal_track?.artist || result.beatport_track?.artist || result.artist_name || 'Unknown Artist' - }; - - // Find the fix modal within the active discovery modal - const discoveryModal = document.getElementById(`youtube-discovery-modal-${identifier}`); - if (!discoveryModal) { - console.error('❌ Discovery modal not found:', identifier); - showToast('Discovery modal not found', 'error'); - return; - } - - const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); - if (!fixModalOverlay) { - console.error('❌ Fix modal not found within discovery modal'); - showToast('Fix modal not found', 'error'); - return; - } - - console.log('🔍 Source track:', currentDiscoveryFix.sourceTrack); - console.log('🔍 Source artist:', currentDiscoveryFix.sourceArtist); - console.log('🔍 Fix modal overlay found:', fixModalOverlay); - - // Populate modal - scope within the specific fix modal overlay to handle duplicate IDs - const sourceTrackEl = fixModalOverlay.querySelector('#fix-modal-source-track'); - const sourceArtistEl = fixModalOverlay.querySelector('#fix-modal-source-artist'); - const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input'); - const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input'); - - console.log('🔍 Elements found:', { - sourceTrackEl, - sourceArtistEl, - trackInput, - artistInput - }); - - if (!sourceTrackEl || !sourceArtistEl || !trackInput || !artistInput) { - console.error('❌ Fix modal elements not found in DOM'); - showToast('Fix modal not properly initialized', 'error'); - return; - } - - sourceTrackEl.textContent = currentDiscoveryFix.sourceTrack; - sourceArtistEl.textContent = currentDiscoveryFix.sourceArtist; - trackInput.value = currentDiscoveryFix.sourceTrack; - artistInput.value = currentDiscoveryFix.sourceArtist; - - console.log('✅ Populated modal with:', { - track: trackInput.value, - artist: artistInput.value - }); - - // Remove old enter key handler if exists - if (discoveryFixEnterHandler) { - trackInput.removeEventListener('keypress', discoveryFixEnterHandler); - artistInput.removeEventListener('keypress', discoveryFixEnterHandler); - } - - // Add new enter key handler - discoveryFixEnterHandler = function (e) { - if (e.key === 'Enter') searchDiscoveryFix(); - }; - trackInput.addEventListener('keypress', discoveryFixEnterHandler); - artistInput.addEventListener('keypress', discoveryFixEnterHandler); - - // Show modal BEFORE auto-search so elements are visible - fixModalOverlay.classList.remove('hidden'); - console.log('✅ Fix modal opened, starting auto-search...'); - - // Auto-search with initial values (delay allows modal layout to settle and prevents accidental clicks) - setTimeout(() => searchDiscoveryFix(), 500); -} - -/** - * Close discovery fix modal - */ -function closeDiscoveryFixModal() { - if (!currentDiscoveryFix.identifier) { - console.warn('No active fix modal to close'); - return; - } - - const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`); - if (discoveryModal) { - const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); - if (fixModalOverlay) { - fixModalOverlay.classList.add('hidden'); - } - } - - currentDiscoveryFix = { platform: null, identifier: null, trackIndex: null, sourceTrack: null, sourceArtist: null }; -} - -/** - * Search for tracks in Spotify - */ -async function searchDiscoveryFix() { - if (!currentDiscoveryFix.identifier) { - console.error('No active fix modal context'); - return; - } - - const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`); - if (!discoveryModal) { - console.error('Discovery modal not found'); - return; - } - - const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); - if (!fixModalOverlay) { - console.error('Fix modal not found'); - return; - } - - const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input').value.trim(); - const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input').value.trim(); - - if (!trackInput && !artistInput) { - showToast('Enter track name or artist', 'error'); - return; - } - - const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results'); - - // Build search params - const params = new URLSearchParams(); - if (trackInput) params.set('track', trackInput); - if (artistInput) params.set('artist', artistInput); - if (!trackInput && !artistInput) { - resultsContainer.innerHTML = '
    Enter a track name or artist.
    '; - return; - } - params.set('limit', '50'); - - // Use the user's active metadata source first, then fall back to others - const activeSource = (currentMusicSourceName || 'Spotify').toLowerCase(); - const allSources = [ - { key: 'spotify', endpoint: '/api/spotify/search_tracks', label: 'Spotify' }, - { key: 'deezer', endpoint: '/api/deezer/search_tracks', label: 'Deezer' }, - { key: 'itunes', endpoint: '/api/itunes/search_tracks', label: 'iTunes' }, - ]; - // Put the active source first, keep others as fallbacks - const activeIdx = allSources.findIndex(s => activeSource.includes(s.key)); - const searchSources = activeIdx > 0 - ? [allSources[activeIdx], ...allSources.filter((_, i) => i !== activeIdx)] - : allSources; - - resultsContainer.innerHTML = `
    🔍 Searching ${searchSources[0].label}...
    `; - - try { - for (let i = 0; i < searchSources.length; i++) { - const source = searchSources[i]; - try { - const response = await fetch(`${source.endpoint}?${params.toString()}`); - const data = await response.json(); - - if (data.tracks && data.tracks.length > 0) { - renderDiscoveryFixResults(data.tracks, fixModalOverlay); - return; - } - // No results from this source — show next source status if there is one - if (i < searchSources.length - 1) { - resultsContainer.innerHTML = `
    🔍 Trying ${searchSources[i + 1].label}...
    `; - } - } catch (e) { - console.warn(`Discovery fix search failed on ${source.label}: ${e.message}`); - } - } - // All sources exhausted - resultsContainer.innerHTML = '
    No matches found on any source. Try different search terms.
    '; - - } catch (error) { - console.error('Search error:', error); - resultsContainer.innerHTML = '
    ❌ Search failed. Try again.
    '; - } -} - -/** - * Render search results as clickable cards - */ -function renderDiscoveryFixResults(tracks, fixModalOverlay) { - const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results'); - resultsContainer.innerHTML = ''; - - // Sort: standard album versions first, live/remix/cover/soundtrack last - const _variantPattern = /\b(live|remix|remaster|refix|cover|acoustic|demo|instrumental|radio edit|single version|deluxe|edition|soundtrack|from .* film|from .* movie|bonus track)\b|\b\w+ mix\b/i; - const _albumVariantPattern = /\b(live|greatest hits|best of|collection|compilation|soundtrack|from .* film|from .* movie|remaster|deluxe|redux|expanded|anniversary)\b/i; - tracks.sort((a, b) => { - const aVariant = _variantPattern.test(a.name || '') || _albumVariantPattern.test(a.album || ''); - const bVariant = _variantPattern.test(b.name || '') || _albumVariantPattern.test(b.album || ''); - if (aVariant !== bVariant) return aVariant ? 1 : -1; - return 0; // preserve original order within same category - }); - - tracks.forEach(track => { - const card = document.createElement('div'); - card.className = 'fix-result-card'; - card.onclick = () => selectDiscoveryFixTrack(track); - - card.innerHTML = ` -
    -
    ${escapeHtml(track.name || 'Unknown Track')}
    -
    ${escapeHtml((track.artists || ['Unknown Artist']).join(', '))}
    -
    ${escapeHtml(track.album || 'Unknown Album')}
    -
    ${formatDuration(track.duration_ms || 0)}
    -
    - `; - - resultsContainer.appendChild(card); - }); -} - -/** - * User selected a track - update discovery state - */ -async function selectDiscoveryFixTrack(track) { - console.log('✅ User selected track:', track); - - // Confirm selection to prevent accidental clicks from layout shift - const artists = (track.artists || ['Unknown Artist']).join(', '); - if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return; - - const { platform, identifier, trackIndex } = currentDiscoveryFix; - - console.log('📡 Updating backend match:', { platform, identifier, trackIndex, track }); - - // Update backend - try { - // Get the correct backend identifier based on platform - let backendIdentifier = identifier; - - if (platform === 'tidal') { - // For Tidal, backend expects the actual playlist_id, not url_hash - const state = youtubePlaylistStates[identifier]; - backendIdentifier = state?.tidal_playlist_id || identifier; - } else if (platform === 'deezer') { - // For Deezer, backend expects the actual playlist_id, not url_hash - const state = youtubePlaylistStates[identifier]; - backendIdentifier = state?.deezer_playlist_id || identifier; - } else if (platform === 'spotify_public') { - // For Spotify Public, backend expects the url_hash - const state = youtubePlaylistStates[identifier]; - backendIdentifier = state?.spotify_public_playlist_id || identifier; - } else if (platform === 'beatport') { - // For Beatport, backend expects url_hash (same as identifier) - backendIdentifier = identifier; - } - - // Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes) - const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform); - - const requestBody = { - identifier: backendIdentifier, - track_index: trackIndex, - spotify_track: { - id: track.id, - name: track.name, - artists: track.artists, - album: track.album, - duration_ms: track.duration_ms - } - }; - - console.log('📡 Request body:', requestBody); - console.log('📡 Backend identifier:', backendIdentifier); - - const response = await fetch(`/api/${apiPlatform}/discovery/update_match`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }); - - console.log('📡 Response status:', response.status); - - const data = await response.json(); - - console.log('📡 Response data:', data); - - if (data.error) { - showToast(`Failed to update: ${data.error}`, 'error'); - console.error('❌ Backend update failed:', data.error); - return; - } - - showToast('Match updated successfully!', 'success'); - console.log('✅ Backend update successful'); - - // Update frontend state - // Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results - // ListenBrainz uses its own state but may also be accessed via YouTube - let state; - if (platform === 'youtube') { - state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; - } else if (platform === 'tidal') { - state = youtubePlaylistStates[identifier]; - } else if (platform === 'deezer') { - state = youtubePlaylistStates[identifier]; - } else if (platform === 'beatport') { - state = youtubePlaylistStates[identifier]; - } else if (platform === 'listenbrainz') { - state = listenbrainzPlaylistStates[identifier]; - } else if (platform === 'mirrored') { - state = youtubePlaylistStates[identifier]; - } - - // Support both camelCase and snake_case - const results = state?.discoveryResults || state?.discovery_results; - if (state && results && results[trackIndex]) { - const result = results[trackIndex]; - const wasNotFound = result.status !== 'found' && result.status_class !== 'found'; - - // Update result - result.status = '✅ Found'; - result.status_class = 'found'; - result.spotify_track = track.name; - result.spotify_artist = Array.isArray(track.artists) ? track.artists.join(', ') : track.artists; - result.spotify_album = track.album; - result.spotify_id = track.id; - result.duration = formatDuration(track.duration_ms); - result.manual_match = true; - - // IMPORTANT: Also set spotify_data for download/sync compatibility - result.spotify_data = { - id: track.id, - name: track.name, - artists: track.artists, - album: track.album, - duration_ms: track.duration_ms - }; - - // Increment match count if this was previously not_found or error - if (wasNotFound) { - state.spotifyMatches = (state.spotifyMatches || 0) + 1; - - // Update progress bar and text - const spotify_total = state.spotify_total || state.playlist?.tracks?.length || 0; - const progress = spotify_total > 0 ? Math.round((state.spotifyMatches / spotify_total) * 100) : 0; - - const progressBar = document.getElementById(`youtube-discovery-progress-${identifier}`); - const progressText = document.getElementById(`youtube-discovery-progress-text-${identifier}`); - - if (progressBar) { - progressBar.style.width = `${progress}%`; - } - if (progressText) { - progressText.textContent = `${state.spotifyMatches} / ${spotify_total} tracks matched (${progress}%)`; - } - - console.log(`✅ Updated progress: ${state.spotifyMatches}/${spotify_total} (${progress}%)`); - - // Also update the Deezer playlist card if this is a Deezer fix - if (platform === 'deezer' && state.deezer_playlist_id) { - const deezerState = deezerPlaylistStates[state.deezer_playlist_id]; - if (deezerState) { - deezerState.spotifyMatches = state.spotifyMatches; - updateDeezerCardProgress(state.deezer_playlist_id, { - spotify_matches: state.spotifyMatches, - spotify_total: spotify_total - }); - } - } - - // Also update the Tidal playlist card if this is a Tidal fix - if (platform === 'tidal' && state.tidal_playlist_id) { - const tidalState = tidalPlaylistStates?.[state.tidal_playlist_id]; - if (tidalState) { - tidalState.spotifyMatches = state.spotifyMatches; - } - } - - // Also update the Spotify Public playlist card if this is a Spotify Public fix - if (platform === 'spotify_public' && state.spotify_public_playlist_id) { - const spState = spotifyPublicPlaylistStates?.[state.spotify_public_playlist_id]; - if (spState) { - spState.spotifyMatches = state.spotifyMatches; - updateSpotifyPublicCardProgress(state.spotify_public_playlist_id, { - spotify_matches: state.spotifyMatches, - spotify_total: spotify_total - }); - } - } - } - - // Update UI - refresh the table row - updateDiscoveryModalSingleRow(platform, identifier, trackIndex); - } - - // Close modal - closeDiscoveryFixModal(); - - } catch (error) { - console.error('Error updating match:', error); - showToast('Failed to update match', 'error'); - } -} - -/** - * Update a single row in the discovery modal table - */ -function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) { - // Check both state maps - ListenBrainz uses its own, others reuse youtubePlaylistStates - const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; - - // Support both camelCase and snake_case - const results = state?.discoveryResults || state?.discovery_results; - if (!state || !results || !results[trackIndex]) { - console.warn(`Cannot update row: state or result not found`); - return; - } - - const result = results[trackIndex]; - const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`); - - if (!row) { - console.warn(`Cannot update row: row element not found for ${identifier}-${trackIndex}`); - return; - } - - // Update cells - const statusCell = row.querySelector('.discovery-status'); - const spotifyTrackCell = row.querySelector('.spotify-track'); - const spotifyArtistCell = row.querySelector('.spotify-artist'); - const spotifyAlbumCell = row.querySelector('.spotify-album'); - const actionsCell = row.querySelector('.discovery-actions'); - - if (statusCell) { - statusCell.textContent = result.status; - statusCell.className = `discovery-status ${result.status_class}`; - } - - if (spotifyTrackCell) spotifyTrackCell.textContent = result.spotify_track || '-'; - if (spotifyArtistCell) spotifyArtistCell.textContent = result.spotify_artist || '-'; - if (spotifyAlbumCell) spotifyAlbumCell.textContent = result.spotify_album || '-'; - - // Update action button - if (actionsCell) { - actionsCell.innerHTML = generateDiscoveryActionButton(result, identifier, platform); - } - - console.log(`✅ Updated row ${trackIndex} in discovery modal`); -} - -async function unmatchDiscoveryTrack(platform, identifier, trackIndex) { - // Determine the correct API base for this platform - const apiBase = platform === 'tidal' ? '/api/tidal' - : platform === 'deezer' ? '/api/deezer' - : platform === 'spotify-public' ? '/api/spotify-public' - : platform === 'beatport' ? '/api/beatport' - : platform === 'listenbrainz' ? '/api/listenbrainz' - : '/api/youtube'; - - try { - const response = await fetch(`${apiBase}/discovery/unmatch`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ identifier, track_index: trackIndex }) - }); - const data = await response.json(); - if (data.success) { - // Update the row in the discovery modal table - const state = youtubePlaylistStates[identifier] - || (window.tidalDiscoveryStates && window.tidalDiscoveryStates[identifier]) - || {}; - if (state.discovery_results && state.discovery_results[trackIndex]) { - const r = state.discovery_results[trackIndex]; - r.status = '❌ Not Found'; - r.status_class = 'not-found'; - r.spotify_track = '-'; - r.spotify_artist = '-'; - r.spotify_album = '-'; - r.spotify_data = null; - r.matched_data = null; - r.confidence = 0; - r.wing_it_fallback = false; - r.manual_match = false; - } - // Re-render the row — discovery rows use id="discovery-row-{urlHash}-{index}" - const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`); - if (row) { - const statusCell = row.querySelector('.discovery-status'); - if (statusCell) { statusCell.textContent = '❌ Not Found'; statusCell.className = 'discovery-status not-found'; } - const matchedCells = row.querySelectorAll('.spotify-track, .spotify-artist, .spotify-album'); - matchedCells.forEach(c => c.textContent = '-'); - const actionsCell = row.querySelector('.discovery-actions'); - if (actionsCell) { - actionsCell.innerHTML = ``; - } - } - showToast('Match removed', 'success'); - } else { - showToast(data.error || 'Failed to remove match', 'error'); - } - } catch (e) { - console.error('Unmatch error:', e); - showToast('Failed to remove match', 'error'); - } -} - -// Make functions available globally for onclick handlers -window.openDiscoveryFixModal = openDiscoveryFixModal; -window.closeDiscoveryFixModal = closeDiscoveryFixModal; -window.searchDiscoveryFix = searchDiscoveryFix; -window.unmatchDiscoveryTrack = unmatchDiscoveryTrack; -window.openMatchingModal = openMatchingModal; -window.closeMatchingModal = closeMatchingModal; -window.selectArtist = selectArtist; -window.selectAlbum = selectAlbum; -window.navigateToPage = navigateToPage; -window.showSupportModal = showSupportModal; -window.closeSupportModal = closeSupportModal; -window.copyAddress = copyAddress; -window.retryLastSearch = retryLastSearch; -window.showVersionInfo = showVersionInfo; -window.closeVersionModal = closeVersionModal; -window.testConnection = testConnection; -window.autoDetectPlex = autoDetectPlex; -window.autoDetectJellyfin = autoDetectJellyfin; -window.autoDetectSlskd = autoDetectSlskd; -window.startPlexPinAuth = startPlexPinAuth; -window.cancelPlexPinAuth = cancelPlexPinAuth; -window.restartPlexPinAuth = restartPlexPinAuth; -window.showPlexConfiguration = showPlexConfiguration; -window.toggleServer = toggleServer; -window.authenticateSpotify = authenticateSpotify; -window.authenticateTidal = authenticateTidal; -window.togglePathLock = togglePathLock; -window.selectResult = selectResult; -window.startStream = startStream; -window.streamTrack = streamTrack; -window.streamAlbumTrack = streamAlbumTrack; -window.startDownload = startDownload; -window.downloadTrack = downloadTrack; -window.downloadAlbum = downloadAlbum; -window.toggleAlbumExpansion = toggleAlbumExpansion; -window.downloadAlbumTrack = downloadAlbumTrack; -window.switchDownloadTab = switchDownloadTab; -window.cancelDownloadItem = cancelDownloadItem; -window.clearFinishedDownloads = clearFinishedDownloads; - -window.matchedDownloadTrack = matchedDownloadTrack; -window.matchedDownloadAlbum = matchedDownloadAlbum; -window.matchedDownloadAlbumTrack = matchedDownloadAlbumTrack; - -/** - * Handle post-download cleanup: clear finished downloads from slskd. - * Scan and database update are now handled by system automations - * (batch_complete → scan_library → library_scan_completed → start_database_update). - */ -async function handlePostDownloadAutomation(playlistId, process) { - try { - const successfulDownloads = getSuccessfulDownloadCount(process); - if (successfulDownloads === 0) { - console.log(`🔄 [AUTO] No successful downloads for ${playlistId} - skipping cleanup`); - return; - } - console.log(`🔄 [AUTO] Post-download cleanup for ${playlistId} (${successfulDownloads} successful downloads)`); - - // Clear completed downloads from slskd - try { - const clearResponse = await fetch('/api/downloads/clear-finished', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - if (clearResponse.ok) { - console.log(`✅ [AUTO] Completed downloads cleared`); - } else { - console.warn(`⚠️ [AUTO] Clear downloads failed, continuing anyway`); - } - } catch (error) { - console.warn(`⚠️ [AUTO] Clear error: ${error.message}`); - } - } catch (error) { - console.error(`❌ [AUTO] Error in post-download cleanup: ${error.message}`); - } -} - -/** - * Extract successful download count from a download process - */ -function getSuccessfulDownloadCount(process) { - try { - // For processes that have completed, check the modal for completed count - if (process && process.modalElement) { - const statElement = process.modalElement.querySelector('[id*="stat-downloaded-"]'); - if (statElement && statElement.textContent) { - const count = parseInt(statElement.textContent, 10); - return isNaN(count) ? 0 : count; - } - } - - // Fallback: assume successful if process completed without obvious failure - if (process && process.status === 'complete') { - return 1; // Conservative assumption for single download - } - - return 0; - } catch (error) { - console.warn(`⚠️ [AUTO] Error getting successful download count: ${error.message}`); - return 0; - } -} - -// =============================== -// ADD TO WISHLIST MODAL FUNCTIONS -// =============================== - -let currentWishlistModalData = null; -let wishlistModalVersion = 0; - -/** - * Open the Add to Wishlist modal for an album/EP/single - * @param {Object} album - Album object with id, name, image_url, etc. - * @param {Object} artist - Artist object with id, name, image_url - * @param {Array} tracks - Array of track objects - * @param {string} albumType - Type of release (album, EP, single) - */ -async function openAddToWishlistModal(album, artist, tracks, albumType, trackOwnership) { - wishlistModalVersion++; - showLoadingOverlay('Preparing wishlist...'); - console.log(`🎵 Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`); - - try { - // Store current modal data for use by other functions - currentWishlistModalData = { - album, - artist, - tracks, - albumType - }; - - const modal = document.getElementById('add-to-wishlist-modal'); - const overlay = document.getElementById('add-to-wishlist-modal-overlay'); - - if (!modal || !overlay) { - console.error('Add to wishlist modal elements not found'); - return; - } - - // Generate and populate hero section - const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership); - const heroContainer = document.getElementById('add-to-wishlist-modal-hero'); - if (heroContainer) { - heroContainer.innerHTML = heroContent; - } - - // Generate and populate track list - const trackListHTML = generateWishlistTrackList(tracks, trackOwnership); - const trackListContainer = document.getElementById('wishlist-track-list'); - if (trackListContainer) { - trackListContainer.innerHTML = trackListHTML; - } - - // Set up the "Add to Wishlist" button click handler - const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn'); - if (addToWishlistBtn) { - addToWishlistBtn.onclick = () => handleAddToWishlist(); - } - - // Show the modal - overlay.classList.remove('hidden'); - hideLoadingOverlay(); - - console.log(`✅ Successfully opened Add to Wishlist modal for: ${album.name}`); - - } catch (error) { - console.error('❌ Error opening Add to Wishlist modal:', error); - hideLoadingOverlay(); - showToast(`Error opening wishlist modal: ${error.message}`, 'error'); - } -} - -/** - * Generate the hero section HTML for the wishlist modal - */ -function generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership) { - const artistImage = artist.image_url || ''; - const albumImage = album.image_url || ''; - const trackCount = tracks.length; - - // Calculate missing tracks if ownership info is available - let trackDetailText = `${trackCount} track${trackCount !== 1 ? 's' : ''}`; - if (trackOwnership) { - const ownedCount = Object.values(trackOwnership).filter(v => v === true).length; - const missingCount = trackCount - ownedCount; - if (missingCount > 0 && ownedCount > 0) { - trackDetailText = `${missingCount} of ${trackCount} tracks missing`; - } - } - - let heroBackgroundImage = ''; - if (albumImage) { - heroBackgroundImage = `
    `; - } - - const heroContent = ` -
    -
    - ${artistImage ? `${escapeHtml(artist.name)}` : ''} - ${albumImage ? `${escapeHtml(album.name)}` : ''} -
    - -
    - `; - - return ` - ${heroBackgroundImage} - ${heroContent} - `; -} - -/** - * Generate the track list HTML for the wishlist modal - */ -function generateWishlistTrackList(tracks, trackOwnership) { - if (!tracks || tracks.length === 0) { - return '
    No tracks found
    '; - } - - return tracks.map((track, index) => { - const trackNumber = track.track_number || (index + 1); - const trackName = escapeHtml(track.name || 'Unknown Track'); - const artistsString = formatArtists(track.artists) || 'Unknown Artist'; - const duration = formatDuration(track.duration_ms); - - const trackData = trackOwnership ? trackOwnership[track.name] : null; - const isOwned = trackData && (trackData.owned === true || trackData === true); - const isKnown = trackData !== null && trackData !== undefined; - const ownershipClass = isOwned ? 'owned' : (isKnown && !isOwned ? 'missing' : ''); - const badge = isOwned - ? '
    ' - : ''; - - return ` -
    -
    ${trackNumber}
    -
    -
    ${trackName}
    -
    ${artistsString}
    -
    -
    ${duration}
    - ${badge} -
    - `; - }).join(''); -} - -/** - * Handle the "Add to Wishlist" button click - */ -async function handleAddToWishlist() { - if (!currentWishlistModalData) { - console.error('❌ No wishlist modal data available'); - return; - } - - const { album, artist, tracks, albumType } = currentWishlistModalData; - const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn'); - - try { - // Show loading state - if (addToWishlistBtn) { - addToWishlistBtn.classList.add('loading'); - addToWishlistBtn.textContent = 'Adding...'; - addToWishlistBtn.disabled = true; - } - - console.log(`🔄 Adding ${tracks.length} tracks to wishlist for: ${artist.name} - ${album.name}`); - - let successCount = 0; - let errorCount = 0; - - // Add each track to wishlist individually - for (const track of tracks) { - try { - // Ensure artists field is in the correct format (array of objects) - let formattedArtists = track.artists; - if (typeof track.artists === 'string') { - // If artists is a string, convert to array of objects - formattedArtists = [{ name: track.artists }]; - } else if (Array.isArray(track.artists)) { - // If artists is already an array, ensure each item is an object - formattedArtists = track.artists.map(artistItem => { - if (typeof artistItem === 'string') { - return { name: artistItem }; - } else if (typeof artistItem === 'object' && artistItem !== null) { - return artistItem; - } else { - return { name: 'Unknown Artist' }; - } - }); - } else { - // Fallback to array with single artist object - formattedArtists = [{ name: artist.name }]; - } - - const formattedTrack = { - ...track, - artists: formattedArtists - }; - - // Use track's album data if available (from API), falling back to modal's album data - // This ensures consistency with how the Artists page handles wishlisting - let trackAlbum = track.album; - let trackAlbumType = albumType || 'album'; - - if (trackAlbum && typeof trackAlbum === 'object') { - // Track has album data from API - use its album_type - trackAlbumType = trackAlbum.album_type || albumType || 'album'; - // Ensure album has required fields - if (!trackAlbum.name) { - trackAlbum.name = album.name; - } - if (!trackAlbum.id) { - trackAlbum.id = album.id; - } - } else { - // Fall back to the album passed to the modal - trackAlbum = album; - } - - console.log(`🔄 Adding track with formatted artists:`, formattedTrack.name, formattedTrack.artists); - console.log(`🔄 Using album_type: ${trackAlbumType} (from ${track.album ? 'track.album' : 'modal album'})`); - - const response = await fetch('/api/add-album-to-wishlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - track: formattedTrack, - artist: artist, - album: trackAlbum, - source_type: 'album', - source_context: { - album_name: trackAlbum.name, - artist_name: artist.name, - album_type: trackAlbumType - } - }) - }); - - const result = await response.json(); - - if (result.success) { - successCount++; - console.log(`✅ Added "${track.name}" to wishlist`); - } else { - errorCount++; - console.error(`❌ Failed to add "${track.name}" to wishlist: ${result.error}`); - } - - } catch (error) { - errorCount++; - console.error(`❌ Error adding "${track.name}" to wishlist:`, error); - } - } - - // Show completion message - if (successCount > 0) { - const message = errorCount > 0 - ? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)` - : `Added ${successCount} tracks to wishlist`; - showToast(message, successCount === tracks.length ? 'success' : 'warning'); - } else { - showToast('Failed to add any tracks to wishlist', 'error'); - } - - // Close the modal - closeAddToWishlistModal(); - - console.log(`✅ Wishlist addition complete: ${successCount} successful, ${errorCount} failed`); - - } catch (error) { - console.error('❌ Error in handleAddToWishlist:', error); - showToast(`Error adding to wishlist: ${error.message}`, 'error'); - } finally { - // Reset button state - if (addToWishlistBtn) { - addToWishlistBtn.classList.remove('loading'); - addToWishlistBtn.textContent = 'Add to Wishlist'; - addToWishlistBtn.disabled = false; - } - } -} - -/** - * Lazy-load per-track ownership indicators into an already-open wishlist modal. - * Fetches ownership from the backend, then updates the modal DOM in-place. - * If all tracks are owned (Spotify metadata discrepancy), also fixes the source card. - */ -async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName = null) { - const myVersion = wishlistModalVersion; - try { - const checkBody = { - artist_name: artistName, - tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number })) - }; - if (albumName) checkBody.album_name = albumName; - const resp = await fetch('/api/library/check-tracks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(checkBody) - }); - const data = await resp.json(); - if (!data.success) return; - - // Guard against stale updates if user reopened modal for a different album - if (myVersion !== wishlistModalVersion) return; - - const ownership = data.owned_tracks; - const trackItems = document.querySelectorAll('#wishlist-track-list .wishlist-track-item'); - - let ownedCount = 0; - trackItems.forEach((item, index) => { - const track = tracks[index]; - if (!track) return; - const trackData = ownership[track.name]; - const isOwned = trackData && trackData.owned === true; - if (isOwned) { - ownedCount++; - item.classList.add('owned'); - // Add metadata line below track name - const trackInfo = item.querySelector('.wishlist-track-info'); - if (trackInfo && (trackData.format || trackData.bitrate)) { - const metaDiv = document.createElement('div'); - metaDiv.className = 'wishlist-track-meta'; - let metaHtml = ''; - if (trackData.format === 'MP3' && trackData.bitrate) { - metaHtml += `MP3 ${trackData.bitrate}`; - } else { - if (trackData.format) { - metaHtml += `${trackData.format}`; - } - if (trackData.bitrate) { - metaHtml += `${trackData.bitrate} kbps`; - } - } - metaDiv.innerHTML = metaHtml; - trackInfo.appendChild(metaDiv); - } - const badge = document.createElement('div'); - badge.className = 'wishlist-track-badge owned'; - badge.innerHTML = ''; - item.appendChild(badge); - } else { - item.classList.add('missing'); - } - }); - - // Aggregate format summary from owned tracks - const formatSet = new Set(); - for (const trackName of Object.keys(ownership)) { - const td = ownership[trackName]; - if (td && td.owned && td.format) { - if (td.format === 'MP3' && td.bitrate) { - formatSet.add(`MP3-${td.bitrate}`); - } else { - formatSet.add(td.format); - } - } - } - if (formatSet.size > 0) { - const heroDetailsContainer = document.querySelector('.add-to-wishlist-modal-hero-details'); - if (heroDetailsContainer) { - // Remove any existing format tag - const existing = heroDetailsContainer.querySelector('.modal-format-tag'); - if (existing) existing.remove(); - const formatTag = document.createElement('span'); - formatTag.className = 'modal-format-tag'; - formatTag.textContent = [...formatSet].sort().join(' / '); - heroDetailsContainer.appendChild(formatTag); - } - } - - // Update hero subtitle with missing count - const missingCount = tracks.length - ownedCount; - const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail'); - const trackDetailEl = heroDetails.length > 1 ? heroDetails[heroDetails.length - 1] : null; - if (trackDetailEl && missingCount > 0 && ownedCount > 0) { - trackDetailEl.textContent = `${missingCount} of ${tracks.length} tracks missing`; - } - - // If ALL returned tracks are owned, this is a Spotify metadata discrepancy - // (e.g. total_tracks says 15 but API only returns 14, and all 14 are owned) - // Fix the source card to show complete - if (missingCount === 0 && sourceCard && sourceCard._releaseData) { - sourceCard._releaseData.track_completion = { - owned_tracks: ownedCount, - total_tracks: tracks.length, - percentage: 100, - missing_tracks: 0 - }; - const completionText = sourceCard.querySelector('.completion-text'); - if (completionText) { - completionText.textContent = `Complete (${ownedCount})`; - completionText.className = 'completion-text complete'; - completionText.title = ''; - } - const completionFill = sourceCard.querySelector('.completion-fill'); - if (completionFill) { - completionFill.style.width = '100%'; - completionFill.classList.remove('partial'); - completionFill.classList.add('complete'); - } - } - } catch (e) { - console.warn('Could not load track ownership:', e); - } -} - -/** - * Close the Add to Wishlist modal - */ -function closeAddToWishlistModal() { - console.log('🔄 Closing Add to Wishlist modal'); - - try { - const overlay = document.getElementById('add-to-wishlist-modal-overlay'); - if (overlay) { - overlay.classList.add('hidden'); - } - - // Clear current modal data - currentWishlistModalData = null; - - // Clear hero content - const heroContainer = document.getElementById('add-to-wishlist-modal-hero'); - if (heroContainer) { - heroContainer.innerHTML = ''; - } - - // Clear track list - const trackListContainer = document.getElementById('wishlist-track-list'); - if (trackListContainer) { - trackListContainer.innerHTML = ''; - } - - console.log('✅ Add to Wishlist modal closed successfully'); - - } catch (error) { - console.error('❌ Error closing Add to Wishlist modal:', error); - } -} - -/** - * Handle "Download Now" button click from the Add to Wishlist modal. - * Captures modal data, closes the wishlist modal, then opens the download missing tracks modal. - */ -async function handleWishlistDownloadNow() { - if (!currentWishlistModalData) { - showToast('No album data available', 'error'); - return; - } - - // Capture data before closeAddToWishlistModal clears it - const { album, artist, tracks, albumType } = currentWishlistModalData; - - // Close the wishlist modal - closeAddToWishlistModal(); - - // Build virtual playlist ID and name (same pattern as createArtistAlbumVirtualPlaylist) - const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; - const playlistName = `[${artist.name}] ${album.name}`; - - // If a download process already exists for this album, just show the existing modal - if (activeDownloadProcesses[virtualPlaylistId]) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - process.modalElement.style.display = 'flex'; - } - return; - } - - // Open download missing modal (reuses existing function) - showLoadingOverlay('Loading album...'); - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, playlistName, tracks, album, artist, false - ); - hideLoadingOverlay(); - - // Register download bubble (reuses existing artist bubble system) - registerArtistDownload(artist, album, virtualPlaylistId, albumType); -} - -/** - * Add all tracks from any download modal to the wishlist - * Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.) - */ -async function addModalTracksToWishlist(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) { - console.error('❌ No active process found for:', playlistId); - showToast('Error: Could not find playlist data', 'error'); - return; - } - - // Verify we have tracks - if (!process.tracks || process.tracks.length === 0) { - console.error('❌ No tracks found in process:', process); - showToast('Error: No tracks to add', 'error'); - return; - } - - // Filter tracks based on checkbox selection (if checkboxes exist in this modal) - const wishlistTbody = document.getElementById(`download-tracks-tbody-${playlistId}`); - let tracks = process.tracks; - if (wishlistTbody) { - const allCbs = wishlistTbody.querySelectorAll('.track-select-cb'); - if (allCbs.length > 0) { - const checkedCbs = wishlistTbody.querySelectorAll('.track-select-cb:checked'); - const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex))); - tracks = process.tracks.filter((_, i) => selectedIndices.has(i)); - } - } - - // Get album context if available (for artist album downloads) - // Artist is resolved per-track below — process.artist is only set for album downloads, - // not for playlists, so we must NOT use it as a blanket default. - const processArtist = process.artist || null; - const album = process.album || process.playlist || { name: 'Playlist', id: playlistId }; - - console.log(`🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist (process artist: ${processArtist?.name || 'per-track'})`); - - // Disable the button to prevent double-clicks - const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); - if (wishlistBtn) { - wishlistBtn.disabled = true; - wishlistBtn.classList.add('loading'); - wishlistBtn.textContent = 'Adding...'; - } - - try { - let successCount = 0; - let errorCount = 0; - - // Add each track to wishlist individually - let wingItSkipped = 0; - for (const track of tracks) { - try { - // Skip wing-it fallback tracks — they have no real metadata, - // adding them to wishlist would just retry with raw data - const trackId = track.id || ''; - if (String(trackId).startsWith('wing_it_')) { - wingItSkipped++; - console.log(`⏭️ Skipping wing-it track from wishlist: ${track.name}`); - continue; - } - - // Format artists field to match backend expectations - let formattedArtists = track.artists; - if (typeof track.artists === 'string') { - formattedArtists = [{ name: track.artists }]; - } else if (Array.isArray(track.artists)) { - formattedArtists = track.artists.map(artistItem => { - if (typeof artistItem === 'string') { - return { name: artistItem }; - } else if (typeof artistItem === 'object' && artistItem !== null) { - return artistItem; - } else { - return { name: 'Unknown Artist' }; - } - }); - } else { - formattedArtists = [{ name: artist.name }]; - } - - const formattedTrack = { - ...track, - artists: formattedArtists - }; - - // Use track's own album data if available - // Convert string album names to objects if needed (no Spotify fetch!) - let trackAlbum = track.album; - let trackAlbumType = 'album'; - - // Handle both object and string album formats - if (typeof trackAlbum === 'string') { - // Album is just a string - convert to minimal object - trackAlbum = { - name: trackAlbum, - album_type: 'album', - images: [] - }; - trackAlbumType = 'album'; - } else if (trackAlbum && typeof trackAlbum === 'object') { - // Album is already an object - extract album_type - trackAlbumType = trackAlbum.album_type || 'album'; - // Ensure it has a name - if (!trackAlbum.name) { - trackAlbum.name = 'Unknown Album'; - } - } else { - // No album data at all - create minimal object - trackAlbum = { - name: 'Unknown Album', - album_type: 'album', - images: [] - }; - trackAlbumType = 'album'; - } - - // Resolve artist: for album downloads, use the album-level artist to keep - // all tracks grouped under one artist in the wishlist. Per-track artists - // (like individual vocalists on a soundtrack) should NOT split the album. - let trackArtist; - if (processArtist && processArtist.name) { - // Album context exists — use album artist to keep tracks grouped - trackArtist = processArtist; - } else if (formattedArtists.length > 0 && formattedArtists[0].name && formattedArtists[0].name !== 'Unknown Artist') { - // No album context (playlist/single) — use track's own artist - trackArtist = formattedArtists[0]; - } else { - trackArtist = { name: 'Unknown Artist', id: null }; - } - - const response = await fetch('/api/add-album-to-wishlist', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - track: formattedTrack, - artist: trackArtist, - album: trackAlbum, - source_type: 'album', - source_context: { - album_name: trackAlbum.name, - artist_name: trackArtist.name, - album_type: trackAlbumType - } - }) - }); - - const result = await response.json(); - - if (result.success) { - successCount++; - } else { - errorCount++; - console.error(`❌ Failed to add "${track.name}" to wishlist: ${result.error}`); - } - } catch (error) { - errorCount++; - console.error(`❌ Error adding "${track.name}" to wishlist:`, error); - } - } - - // Show result toast - if (successCount > 0) { - let message = errorCount > 0 - ? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)` - : `Added ${successCount} tracks to wishlist`; - if (wingItSkipped > 0) message += ` (${wingItSkipped} wing-it skipped)`; - showToast(message, 'success'); - - // Close the modal on success - await closeDownloadMissingModal(playlistId); - } else { - showToast('Failed to add any tracks to wishlist', 'error'); - } - - } catch (error) { - console.error('❌ Error in addModalTracksToWishlist:', error); - showToast(`Error adding to wishlist: ${error.message}`, 'error'); - } finally { - // Re-enable button if still on screen (in case of error) - if (wishlistBtn) { - wishlistBtn.disabled = false; - wishlistBtn.classList.remove('loading'); - wishlistBtn.textContent = 'Add to Wishlist'; - } - } -} - -/** - * Format duration from milliseconds to MM:SS format - */ -function formatDuration(durationMs) { - if (!durationMs || durationMs <= 0) { - return '--:--'; - } - - const totalSeconds = Math.floor(durationMs / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -// Download Missing Tracks Modal functions -window.openDownloadMissingModal = openDownloadMissingModal; -window.closeDownloadMissingModal = closeDownloadMissingModal; -window.startMissingTracksProcess = startMissingTracksProcess; -window.cancelAllOperations = cancelAllOperations; -window.cancelTrackDownload = cancelTrackDownload; // Legacy system -window.cancelTrackDownloadV2 = cancelTrackDownloadV2; // NEW V2 system -window.handleViewProgressClick = handleViewProgressClick; - -// Wishlist Modal functions (existing) -window.openDownloadMissingWishlistModal = openDownloadMissingWishlistModal; -window.startWishlistMissingTracksProcess = startWishlistMissingTracksProcess; -window.handleWishlistButtonClick = handleWishlistButtonClick; - -// Wishlist Overview Modal functions (new) -window.openWishlistOverviewModal = openWishlistOverviewModal; -window.closeWishlistOverviewModal = closeWishlistOverviewModal; -window.selectWishlistCategory = selectWishlistCategory; -window.backToCategories = backToCategories; -window.downloadSelectedCategory = downloadSelectedCategory; - -// Add to Wishlist Modal functions (new) -window.openAddToWishlistModal = openAddToWishlistModal; -window.closeAddToWishlistModal = closeAddToWishlistModal; -window.handleAddToWishlist = handleAddToWishlist; -window.handleWishlistDownloadNow = handleWishlistDownloadNow; -window.addModalTracksToWishlist = addModalTracksToWishlist; - -// Helper functions -window.escapeHtml = escapeHtml; -window.formatArtists = formatArtists; - -// Artist Download Management functions -window.closeArtistDownloadModal = closeArtistDownloadModal; -window.openArtistDownloadProcess = openArtistDownloadProcess; -window.bulkCompleteArtistDownloads = bulkCompleteArtistDownloads; -window.refreshAllArtistDownloadStatuses = refreshAllArtistDownloadStatuses; - - -// APPEND THIS JAVASCRIPT SNIPPET (B) - -function initializeFilters() { - const toggleBtn = document.getElementById('filter-toggle-btn'); - const container = document.getElementById('filters-container'); - const content = document.getElementById('filter-content'); - - if (toggleBtn && container && content) { - // Using .onclick ensures we only ever have one click handler - toggleBtn.onclick = () => { - const isExpanded = container.classList.contains('expanded'); - - if (isExpanded) { - // Collapse the container - container.classList.remove('expanded'); - toggleBtn.textContent = '⏷ Filters'; - } else { - // Expand the container - content.classList.remove('hidden'); // Make sure content is visible for animation - container.classList.add('expanded'); - toggleBtn.textContent = '⏶ Filters'; - } - }; - } - - // This part is correct and doesn't need to change - document.querySelectorAll('.filter-btn').forEach(button => { - button.addEventListener('click', handleFilterClick); - }); -} - -function handleFilterClick(event) { - const button = event.target; - const filterType = button.dataset.filterType; - const value = button.dataset.value; - - if (filterType === 'type') currentFilterType = value; - if (filterType === 'format') currentFilterFormat = value; - if (filterType === 'sort') currentSortBy = value; - - if (button.id === 'sort-order-btn') { - isSortReversed = !isSortReversed; - button.textContent = isSortReversed ? '↑' : '↓'; - } - - document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => { - btn.classList.remove('active'); - }); - if (filterType) { // Don't try to activate the sort order button - button.classList.add('active'); - } - - applyFiltersAndSort(); -} - -function resetFilters() { - currentFilterType = 'all'; - currentFilterFormat = 'all'; - currentSortBy = 'quality_score'; - isSortReversed = false; - - document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); - document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active'); - document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active'); - document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active'); - document.getElementById('sort-order-btn').textContent = '↓'; -} - -function applyFiltersAndSort() { - let processedResults = [...allSearchResults]; - const query = document.getElementById('downloads-search-input').value.trim().toLowerCase(); - - // 1. Filter by Type - if (currentFilterType !== 'all') { - processedResults = processedResults.filter(r => r.result_type === currentFilterType); - } - - // 2. Filter by Format - if (currentFilterFormat !== 'all') { - processedResults = processedResults.filter(r => { - const quality = (r.dominant_quality || r.quality || '').toLowerCase(); - return quality === currentFilterFormat; - }); - } - - // 3. Sort Results - processedResults.sort((a, b) => { - let valA, valB; - - // Special handling for relevance sort - if (currentSortBy === 'relevance') { - valA = calculateRelevanceScore(a, query); - valB = calculateRelevanceScore(b, query); - return valB - valA; // Higher score is better - } - - // Special handling for availability - if (currentSortBy === 'availability') { - valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1; - valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1; - return valB - valA; - } - - valA = a[currentSortBy] || 0; - valB = b[currentSortBy] || 0; - - if (typeof valA === 'string') { - // For name/title sort, use the correct property - const titleA = (a.album_title || a.title || '').toLowerCase(); - const titleB = (b.album_title || b.title || '').toLowerCase(); - return titleA.localeCompare(titleB); - } - - // Default numeric sort (descending) - return valB - valA; - }); - - // Handle sort direction toggle - const sortDefaults = { - relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', - upload_speed: 'desc', duration: 'desc', availability: 'desc', - title: 'asc', username: 'asc' - }; - - const defaultOrder = sortDefaults[currentSortBy] || 'desc'; - if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) { - processedResults.reverse(); - } - - displayDownloadsResults(processedResults); -} - -function calculateRelevanceScore(result, query) { - let score = 0.0; - const queryTerms = query.split(' ').filter(t => t.length > 1); - - // 1. Search Term Matching (40%) - let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase(); - let termMatches = 0; - for (const term of queryTerms) { - if (searchableText.includes(term)) { - termMatches++; - } - } - score += (termMatches / queryTerms.length) * 0.40; - - // 2. Quality Score (25%) - score += (result.quality_score || 0) * 0.25; - - // 3. User Reliability (Availability & Speed) (20%) - const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5; - score += reliability * 0.20; - - // 4. File Completeness (Bitrate & Duration) (15%) - const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0); - score += completeness * 0.15; - - return score; -} -// APPEND THIS JAVASCRIPT SNIPPET (B) - -function initializeFilters() { - const toggleBtn = document.getElementById('filter-toggle-btn'); - const container = document.getElementById('filters-container'); - const content = document.getElementById('filter-content'); - - if (toggleBtn && container && content) { - // Using .onclick ensures we only ever have one click handler - toggleBtn.onclick = () => { - const isExpanded = container.classList.contains('expanded'); - - if (isExpanded) { - // Collapse the container - container.classList.remove('expanded'); - toggleBtn.textContent = '⏷ Filters'; - } else { - // Expand the container - content.classList.remove('hidden'); // Make sure content is visible for animation - container.classList.add('expanded'); - toggleBtn.textContent = '⏶ Filters'; - } - }; - } - - // This part is correct and doesn't need to change - document.querySelectorAll('.filter-btn').forEach(button => { - button.addEventListener('click', handleFilterClick); - }); -} - -function handleFilterClick(event) { - const button = event.target; - const filterType = button.dataset.filterType; - const value = button.dataset.value; - - if (filterType === 'type') currentFilterType = value; - if (filterType === 'format') currentFilterFormat = value; - if (filterType === 'sort') currentSortBy = value; - - if (button.id === 'sort-order-btn') { - isSortReversed = !isSortReversed; - button.textContent = isSortReversed ? '↑' : '↓'; - } - - document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => { - btn.classList.remove('active'); - }); - if (filterType) { // Don't try to activate the sort order button - button.classList.add('active'); - } - - applyFiltersAndSort(); -} - -function resetFilters() { - currentFilterType = 'all'; - currentFilterFormat = 'all'; - currentSortBy = 'quality_score'; - isSortReversed = false; - - document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); - document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active'); - document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active'); - document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active'); - document.getElementById('sort-order-btn').textContent = '↓'; -} - -function applyFiltersAndSort() { - let processedResults = [...allSearchResults]; - const query = document.getElementById('downloads-search-input').value.trim().toLowerCase(); - - // 1. Filter by Type - if (currentFilterType !== 'all') { - processedResults = processedResults.filter(r => r.result_type === currentFilterType); - } - - // 2. Filter by Format - if (currentFilterFormat !== 'all') { - processedResults = processedResults.filter(r => { - const quality = (r.dominant_quality || r.quality || '').toLowerCase(); - return quality === currentFilterFormat; - }); - } - - // 3. Sort Results - processedResults.sort((a, b) => { - let valA, valB; - - // Special handling for relevance sort - if (currentSortBy === 'relevance') { - valA = calculateRelevanceScore(a, query); - valB = calculateRelevanceScore(b, query); - return valB - valA; // Higher score is better - } - - // Special handling for availability - if (currentSortBy === 'availability') { - valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1; - valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1; - return valB - valA; - } - - valA = a[currentSortBy] || 0; - valB = b[currentSortBy] || 0; - - if (typeof valA === 'string') { - // For name/title sort, use the correct property - const titleA = (a.album_title || a.title || '').toLowerCase(); - const titleB = (b.album_title || b.title || '').toLowerCase(); - return titleA.localeCompare(titleB); - } - - // Default numeric sort (descending) - return valB - valA; - }); - - // Handle sort direction toggle - const sortDefaults = { - relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', - upload_speed: 'desc', duration: 'desc', availability: 'desc', - title: 'asc', username: 'asc' - }; - - const defaultOrder = sortDefaults[currentSortBy] || 'desc'; - if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) { - processedResults.reverse(); - } - - displayDownloadsResults(processedResults); -} - -function calculateRelevanceScore(result, query) { - let score = 0.0; - const queryTerms = query.split(' ').filter(t => t.length > 1); - - // 1. Search Term Matching (40%) - let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase(); - let termMatches = 0; - for (const term of queryTerms) { - if (searchableText.includes(term)) { - termMatches++; - } - } - score += (termMatches / queryTerms.length) * 0.40; - - // 2. Quality Score (25%) - score += (result.quality_score || 0) * 0.25; - - // 3. User Reliability (Availability & Speed) (20%) - const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5; - score += reliability * 0.20; - - // 4. File Completeness (Bitrate & Duration) (15%) - const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0); - score += completeness * 0.15; - - return score; -} - -// Add to global scope for onclick -window.handleFilterClick = handleFilterClick; - -// =============================== -// MATCHED DOWNLOADS MODAL -// =============================== - -// Global state for matching modal -let currentMatchingData = { - searchResult: null, - isAlbumDownload: false, - albumResult: null, - selectedArtist: null, - selectedAlbum: null, - currentStage: 'artist' // 'artist' or 'album' -}; - -let searchTimers = { - artist: null, - album: null -}; - -function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) { - console.log('🎯 Opening matching modal for:', searchResult); - - // Store the current matching data - currentMatchingData = { - searchResult: searchResult, - isAlbumDownload: isAlbumDownload, - albumResult: albumResult, - selectedArtist: null, - selectedAlbum: null, - currentStage: 'artist' - }; - - // Show modal - const overlay = document.getElementById('matching-modal-overlay'); - overlay.classList.remove('hidden'); - - // Reset modal state - resetModalState(); - - // Set appropriate title and stage - const modalTitle = document.getElementById('matching-modal-title'); - const artistStageTitle = document.getElementById('artist-stage-title'); - - if (isAlbumDownload) { - modalTitle.textContent = 'Match Album Download to Spotify'; - artistStageTitle.textContent = 'Step 1: Select the correct Artist'; - document.getElementById('album-selection-stage').style.display = 'block'; - } else { - modalTitle.textContent = 'Match Download to Spotify'; - artistStageTitle.textContent = 'Select the correct Artist for this Single'; - document.getElementById('album-selection-stage').style.display = 'none'; - } - - // Generate initial artist suggestions - fetchArtistSuggestions(); - - // Setup event listeners - setupModalEventListeners(); -} - -function closeMatchingModal() { - const overlay = document.getElementById('matching-modal-overlay'); - overlay.classList.add('hidden'); - - // Clear timers - Object.values(searchTimers).forEach(timer => { - if (timer) clearTimeout(timer); - }); - - // Reset state - currentMatchingData = { - searchResult: null, - isAlbumDownload: false, - albumResult: null, - selectedArtist: null, - selectedAlbum: null, - currentStage: 'artist' - }; -} - -function resetModalState() { - // Show artist stage, hide album stage - document.getElementById('artist-selection-stage').classList.remove('hidden'); - document.getElementById('album-selection-stage').classList.add('hidden'); - - // Clear all suggestion containers - document.getElementById('artist-suggestions').innerHTML = ''; - document.getElementById('artist-manual-results').innerHTML = ''; - document.getElementById('album-suggestions').innerHTML = ''; - document.getElementById('album-manual-results').innerHTML = ''; - - // Clear search inputs - document.getElementById('artist-search-input').value = ''; - document.getElementById('album-search-input').value = ''; - - // Reset button states - document.getElementById('confirm-match-btn').disabled = true; - - // Reset selections - currentMatchingData.selectedArtist = null; - currentMatchingData.selectedAlbum = null; - currentMatchingData.currentStage = 'artist'; -} - -function setupModalEventListeners() { - // Search input listeners - const artistInput = document.getElementById('artist-search-input'); - const albumInput = document.getElementById('album-search-input'); - - artistInput.removeEventListener('input', handleArtistSearch); - artistInput.addEventListener('input', handleArtistSearch); - - albumInput.removeEventListener('input', handleAlbumSearch); - albumInput.addEventListener('input', handleAlbumSearch); - - // Button listeners - const skipBtn = document.getElementById('skip-matching-btn'); - const cancelBtn = document.getElementById('cancel-match-btn'); - const confirmBtn = document.getElementById('confirm-match-btn'); - - skipBtn.onclick = skipMatching; - cancelBtn.onclick = closeMatchingModal; - confirmBtn.onclick = confirmMatch; -} - -async function fetchArtistSuggestions() { - try { - showLoadingCards('artist-suggestions', 'Finding artist...'); - - const response = await fetch('/api/match/suggestions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - search_result: currentMatchingData.searchResult, - context: 'artist', - is_album: currentMatchingData.isAlbumDownload, - album_result: currentMatchingData.albumResult - }) - }); - - const data = await response.json(); - if (data.suggestions) { - renderArtistSuggestions(data.suggestions); - } else { - showNoResultsMessage('artist-suggestions', 'No artist suggestions found'); - } - } catch (error) { - console.error('Error fetching artist suggestions:', error); - showNoResultsMessage('artist-suggestions', 'Error loading suggestions'); - } -} - -async function fetchAlbumSuggestions() { - if (!currentMatchingData.selectedArtist) return; - - try { - showLoadingCards('album-suggestions', 'Finding album...'); - - const response = await fetch('/api/match/suggestions', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - search_result: currentMatchingData.searchResult, - context: 'album', - selected_artist: currentMatchingData.selectedArtist - }) - }); - - const data = await response.json(); - if (data.suggestions) { - renderAlbumSuggestions(data.suggestions); - } else { - showNoResultsMessage('album-suggestions', 'No album suggestions found'); - } - } catch (error) { - console.error('Error fetching album suggestions:', error); - showNoResultsMessage('album-suggestions', 'Error loading suggestions'); - } -} - -function renderArtistSuggestions(suggestions) { - const container = document.getElementById('artist-suggestions'); - container.innerHTML = ''; - - if (!suggestions.length) { - showNoResultsMessage('artist-suggestions', 'No artist matches found'); - return; - } - - suggestions.forEach(suggestion => { - const card = createArtistCard(suggestion.artist, suggestion.confidence); - container.appendChild(card); - }); -} - -function renderAlbumSuggestions(suggestions) { - const container = document.getElementById('album-suggestions'); - container.innerHTML = ''; - - if (!suggestions.length) { - showNoResultsMessage('album-suggestions', 'No album matches found'); - return; - } - - suggestions.forEach(suggestion => { - const card = createAlbumCard(suggestion.album, suggestion.confidence); - container.appendChild(card); - }); -} - -function createArtistCard(artist, confidence) { - const card = document.createElement('div'); - card.className = 'suggestion-card'; - card.onclick = () => selectArtist(artist); - - const imageUrl = artist.image_url || ''; - const confidencePercent = Math.round(confidence * 100); - - // Add data attribute for lazy loading - card.dataset.artistId = artist.id; - card.dataset.needsImage = imageUrl ? 'false' : 'true'; - - card.innerHTML = ` -
    -
    -
    ${escapeHtml(artist.name)}
    -
    - ${artist.genres && artist.genres.length ? escapeHtml(artist.genres.slice(0, 2).join(', ')) : 'Artist'} -
    -
    ${confidencePercent}% match
    -
    - `; - - // Set background image if available - if (imageUrl) { - card.style.backgroundImage = `url(${imageUrl})`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } - - return card; -} - -function createAlbumCard(album, confidence) { - const card = document.createElement('div'); - card.className = 'suggestion-card'; - card.onclick = () => selectAlbum(album); - - const imageUrl = album.image_url || ''; - const confidencePercent = Math.round(confidence * 100); - const year = album.release_date ? album.release_date.split('-')[0] : ''; - - card.innerHTML = ` -
    -
    -
    ${escapeHtml(album.name)}
    -
    - ${album.album_type ? escapeHtml(album.album_type.charAt(0).toUpperCase() + album.album_type.slice(1)) : 'Album'}${year ? ` • ${year}` : ''} -
    -
    ${confidencePercent}% match
    -
    - `; - - // Set background image if available - if (imageUrl) { - card.style.backgroundImage = `url(${imageUrl})`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } - - return card; -} - -function selectArtist(artist) { - // Clear previous selections - document.querySelectorAll('#artist-suggestions .suggestion-card').forEach(card => { - card.classList.remove('selected'); - }); - document.querySelectorAll('#artist-manual-results .suggestion-card').forEach(card => { - card.classList.remove('selected'); - }); - - // Mark new selection - event.currentTarget.classList.add('selected'); - - // Store selection - currentMatchingData.selectedArtist = artist; - - console.log('🎯 Selected artist:', artist.name); - - if (currentMatchingData.isAlbumDownload) { - // Transition to album selection stage - transitionToAlbumStage(); - } else { - // Enable confirm button for single downloads - document.getElementById('confirm-match-btn').disabled = false; - } -} - -function selectAlbum(album) { - // Clear previous selections - document.querySelectorAll('#album-suggestions .suggestion-card').forEach(card => { - card.classList.remove('selected'); - }); - document.querySelectorAll('#album-manual-results .suggestion-card').forEach(card => { - card.classList.remove('selected'); - }); - - // Mark new selection - event.currentTarget.classList.add('selected'); - - // Store selection - currentMatchingData.selectedAlbum = album; - - console.log('🎯 Selected album:', album.name); - - // Enable confirm button - document.getElementById('confirm-match-btn').disabled = false; -} - -function transitionToAlbumStage() { - // Hide artist stage - document.getElementById('artist-selection-stage').classList.add('hidden'); - - // Show album stage - const albumStage = document.getElementById('album-selection-stage'); - albumStage.classList.remove('hidden'); - - // Update selected artist name - document.getElementById('selected-artist-name').textContent = currentMatchingData.selectedArtist.name; - - // Update current stage - currentMatchingData.currentStage = 'album'; - - // Fetch album suggestions - fetchAlbumSuggestions(); -} - -function handleArtistSearch(event) { - const query = event.target.value.trim(); - - // Clear previous timer - if (searchTimers.artist) { - clearTimeout(searchTimers.artist); - } - - if (query.length < 2) { - document.getElementById('artist-manual-results').innerHTML = ''; - return; - } - - // Debounce search - searchTimers.artist = setTimeout(() => { - performArtistSearch(query); - }, 400); -} - -function handleAlbumSearch(event) { - const query = event.target.value.trim(); - - // Clear previous timer - if (searchTimers.album) { - clearTimeout(searchTimers.album); - } - - if (query.length < 2) { - document.getElementById('album-manual-results').innerHTML = ''; - return; - } - - // Debounce search - searchTimers.album = setTimeout(() => { - performAlbumSearch(query); - }, 400); -} - -async function performArtistSearch(query) { - try { - showLoadingCards('artist-manual-results', 'Searching artists...'); - - const requestBody = { - query: query, - context: 'artist' - }; - console.log('Manual search request:', requestBody); - - const response = await fetch('/api/match/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) - }); - - const data = await response.json(); - console.log('Manual search response:', data); - if (data.provider) currentMatchingData.provider = data.provider; - if (data.results) { - console.log('Results array:', data.results); - renderArtistSearchResults(data.results); - } else { - showNoResultsMessage('artist-manual-results', 'No artists found'); - } - } catch (error) { - console.error('Error searching artists:', error); - showNoResultsMessage('artist-manual-results', 'Error searching artists'); - } -} - -async function performAlbumSearch(query) { - if (!currentMatchingData.selectedArtist) return; - - try { - showLoadingCards('album-manual-results', 'Searching albums...'); - - const response = await fetch('/api/match/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - query: query, - context: 'album', - artist_id: currentMatchingData.selectedArtist.id - }) - }); - - const data = await response.json(); - if (data.results) { - renderAlbumSearchResults(data.results); - } else { - showNoResultsMessage('album-manual-results', 'No albums found'); - } - } catch (error) { - console.error('Error searching albums:', error); - showNoResultsMessage('album-manual-results', 'Error searching albums'); - } -} - -function renderArtistSearchResults(results) { - const container = document.getElementById('artist-manual-results'); - container.innerHTML = ''; - - results.forEach((result, index) => { - console.log(`Manual search result ${index}:`, result); - console.log(` result.artist:`, result.artist); - console.log(` result.confidence:`, result.confidence); - try { - const card = createArtistCard(result.artist, result.confidence); - console.log(`createArtistCard returned:`, card, typeof card, card instanceof Element); - if (card && card instanceof Element) { - container.appendChild(card); - } else { - console.error(`Invalid card returned for result ${index}:`, card); - } - } catch (error) { - console.error(`Error calling createArtistCard for result ${index}:`, error); - } - }); - - // Lazy load missing artist images - console.log('🖼️ Starting lazy load for artist images in matching modal...'); - if (typeof lazyLoadArtistImages === 'function') { - lazyLoadArtistImages(container); - } else if (typeof window.lazyLoadArtistImages === 'function') { - window.lazyLoadArtistImages(container); - } else { - console.error('❌ lazyLoadArtistImages function not found!'); - } -} - -function renderAlbumSearchResults(results) { - const container = document.getElementById('album-manual-results'); - container.innerHTML = ''; - - results.forEach(result => { - const card = createAlbumCard(result.album, result.confidence); - container.appendChild(card); - }); -} - -function showLoadingCards(containerId, message) { - const container = document.getElementById(containerId); - container.innerHTML = `
    ${message}
    `; -} - -function showNoResultsMessage(containerId, message) { - const container = document.getElementById(containerId); - container.innerHTML = `
    ${message}
    `; -} - -function skipMatching() { - console.log('🎯 Skipping matching, proceeding with normal download'); - - // Close modal - closeMatchingModal(); - - // Start normal download - if (currentMatchingData.isAlbumDownload) { - // For albums, we need to download each track - showToast('⬇️ Starting album download (unmatched)', 'info'); - // This would need to be implemented to download all album tracks - } else { - // Single track download - startDownload(window.currentSearchResults.indexOf(currentMatchingData.searchResult)); - } -} - -function matchSlskdTracksToSpotify(slskdTracks, spotifyTracks) { - /** - * Matches Soulseek tracks to Spotify tracks based on filename analysis. - * Returns enhanced tracks with full Spotify metadata. - */ - console.log(`🎯 Starting track matching: ${slskdTracks.length} Soulseek tracks vs ${spotifyTracks.length} Spotify tracks`); - - const matched = []; - const unmatched = []; - - for (const slskdTrack of slskdTracks) { - const filename = slskdTrack.filename || slskdTrack.title || ''; - const parsedMeta = parseTrackFilename(filename); - - console.log(`🔍 Matching: "${filename}" -> parsed as: "${parsedMeta.title}" (track #${parsedMeta.trackNumber})`); - - // Find best matching Spotify track - let bestMatch = null; - let bestScore = 0; - - for (const spotifyTrack of spotifyTracks) { - let score = 0; - - // Match by track number (highest priority if available) - if (parsedMeta.trackNumber && spotifyTrack.track_number === parsedMeta.trackNumber) { - score += 50; - console.log(` ✓ Track number match: ${parsedMeta.trackNumber} == ${spotifyTrack.track_number} (+50)`); - } - - // Match by title similarity - const titleScore = calculateStringSimilarity( - parsedMeta.title.toLowerCase(), - spotifyTrack.name.toLowerCase() - ); - score += titleScore * 50; // Max 50 points for perfect title match - - console.log(` Spotify track "${spotifyTrack.name}" (${spotifyTrack.track_number}): score ${score.toFixed(2)}`); - - if (score > bestScore) { - bestScore = score; - bestMatch = spotifyTrack; - } - } - - // Accept match if score is above threshold (70/100) - if (bestMatch && bestScore >= 70) { - console.log(`✅ MATCHED: "${filename}" -> "${bestMatch.name}" (score: ${bestScore.toFixed(2)})`); - matched.push({ - slskd_track: slskdTrack, - spotify_track: bestMatch, - confidence: bestScore / 100 - }); - } else { - console.log(`❌ NO MATCH: "${filename}" (best score: ${bestScore.toFixed(2)})`); - unmatched.push(slskdTrack); - } - } - - console.log(`🎯 Matching complete: ${matched.length} matched, ${unmatched.length} unmatched`); - - return { - matched: matched, - unmatched: unmatched, - total: slskdTracks.length - }; -} - -function parseTrackFilename(filename) { - /** - * Parse track metadata from filename. - * Handles common patterns like: - * - "01 - Title.flac" - * - "01. Title.flac" - * - "Artist - Title.flac" - * - "Title.flac" - * - YouTube: "video_id||title" (extract title part) - */ - // YouTube special handling: Extract title from encoded format - if (filename && filename.includes('||')) { - const parts = filename.split('||'); - const youtubeTitle = parts[1] || parts[0]; // Use title part, fallback to video_id - // Remove common YouTube suffixes - const cleanTitle = youtubeTitle - .replace(/\s*\[.*?\]\s*/g, '') // Remove [Official Video], [Lyrics], etc. - .replace(/\s*\(.*?\)\s*/g, '') // Remove (Official), (Audio), etc. - .trim(); - return { title: cleanTitle, trackNumber: null }; - } - - // Remove file extension and path - let basename = filename.split('/').pop().split('\\').pop(); - basename = basename.replace(/\.(flac|mp3|m4a|ogg|wav)$/i, ''); - - let trackNumber = null; - let title = basename; - - // Pattern 1: "01 - Title" or "01. Title" - const pattern1 = /^(\d{1,2})\s*[-\.]\s*(.+)$/; - const match1 = basename.match(pattern1); - if (match1) { - trackNumber = parseInt(match1[1]); - title = match1[2].trim(); - return { title, trackNumber }; - } - - // Pattern 2: "Artist - Title" (extract title only) - const pattern2 = /^.+?\s*[-–]\s*(.+)$/; - const match2 = basename.match(pattern2); - if (match2) { - title = match2[1].trim(); - return { title, trackNumber }; - } - - // Fallback: use whole basename as title - return { title: basename.trim(), trackNumber }; -} - -function calculateStringSimilarity(str1, str2) { - /** - * Calculate similarity between two strings (0-1 range). - * Uses Levenshtein distance for fuzzy matching. - */ - // Normalize strings - str1 = str1.trim().toLowerCase(); - str2 = str2.trim().toLowerCase(); - - if (str1 === str2) return 1.0; - - // Simple contains check - if (str1.includes(str2) || str2.includes(str1)) { - return 0.9; - } - - // Levenshtein distance calculation - const matrix = []; - const len1 = str1.length; - const len2 = str2.length; - - for (let i = 0; i <= len1; i++) { - matrix[i] = [i]; - } - - for (let j = 0; j <= len2; j++) { - matrix[0][j] = j; - } - - for (let i = 1; i <= len1; i++) { - for (let j = 1; j <= len2; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, // deletion - matrix[i][j - 1] + 1, // insertion - matrix[i - 1][j - 1] + cost // substitution - ); - } - } - - const maxLen = Math.max(len1, len2); - const distance = matrix[len1][len2]; - const similarity = 1 - (distance / maxLen); - - return Math.max(0, similarity); -} - -async function confirmMatch() { - if (!currentMatchingData.selectedArtist) { - showToast('⚠️ Please select an artist first', 'error'); - return; - } - - if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) { - showToast('⚠️ Please select an album first', 'error'); - return; - } - - const confirmBtn = document.getElementById('confirm-match-btn'); - const originalText = confirmBtn.textContent; - - try { - console.log('🎯 Confirming match with:', { - artist: currentMatchingData.selectedArtist.name, - album: currentMatchingData.selectedAlbum?.name - }); - - confirmBtn.disabled = true; - confirmBtn.textContent = 'Starting...'; - - // Determine the correct data to send - const downloadPayload = currentMatchingData.isAlbumDownload - ? currentMatchingData.albumResult - : currentMatchingData.searchResult; - - // --- NEW: For album downloads, fetch Spotify tracklist and match tracks --- - if (currentMatchingData.isAlbumDownload && currentMatchingData.selectedAlbum) { - confirmBtn.textContent = 'Matching tracks...'; - console.log('🎵 Fetching Spotify tracklist for album:', currentMatchingData.selectedAlbum.name); - - try { - // Fetch album tracks (pass name/artist for Hydrabase support) - const artistId = currentMatchingData.selectedArtist.id; - const albumId = currentMatchingData.selectedAlbum.id; - const _aat3 = new URLSearchParams({ name: currentMatchingData.selectedAlbum.name || '', artist: currentMatchingData.selectedArtist.name || '' }); - const tracksResponse = await fetch(`/api/album/${albumId}/tracks?${_aat3}`); - - if (!tracksResponse.ok) { - throw new Error(`Failed to fetch Spotify tracks: ${tracksResponse.status}`); - } - - const tracksData = await tracksResponse.json(); - const spotifyTracks = tracksData.tracks || []; - - console.log(`✅ Fetched ${spotifyTracks.length} Spotify tracks for matching`); - - // Match each Soulseek track to a Spotify track - const enhancedTracks = matchSlskdTracksToSpotify( - downloadPayload.tracks || [], - spotifyTracks - ); - - console.log(`🎯 Matched ${enhancedTracks.matched.length}/${enhancedTracks.total} tracks to Spotify`); - - // Send enhanced data with full Spotify track objects - confirmBtn.textContent = 'Downloading...'; - const response = await fetch('/api/download/matched', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - search_result: downloadPayload, - spotify_artist: currentMatchingData.selectedArtist, - spotify_album: currentMatchingData.selectedAlbum, - enhanced_tracks: enhancedTracks.matched, // Send matched tracks with full Spotify data - unmatched_tracks: enhancedTracks.unmatched // Send unmatched tracks for basic processing - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`🎯 Matched ${enhancedTracks.matched.length} tracks to Spotify`, 'success'); - closeMatchingModal(); - } else { - throw new Error(data.error || 'Failed to start matched download'); - } - - } catch (trackMatchError) { - console.error('❌ Track matching failed, falling back to simple matching:', trackMatchError); - showToast('⚠️ Track matching failed, using basic matching', 'warning'); - - // Fallback to simple matching (current behavior) - const response = await fetch('/api/download/matched', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - search_result: downloadPayload, - spotify_artist: currentMatchingData.selectedArtist, - spotify_album: currentMatchingData.selectedAlbum || null - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); - closeMatchingModal(); - } else { - throw new Error(data.error || 'Failed to start matched download'); - } - } - } else { - // Single track download - fetch Spotify track for full metadata - confirmBtn.textContent = 'Searching Spotify...'; - - try { - // Parse track name from Soulseek filename - const filename = downloadPayload.filename || downloadPayload.title || ''; - const parsedMeta = parseTrackFilename(filename); - - console.log(`🔍 Searching Spotify for: "${parsedMeta.title}" by ${currentMatchingData.selectedArtist.name}`); - - // Search Spotify for this track - const searchQuery = `track:${parsedMeta.title} artist:${currentMatchingData.selectedArtist.name}`; - const searchResponse = await fetch(`/api/spotify/search?q=${encodeURIComponent(searchQuery)}&type=track&limit=5`); - - if (!searchResponse.ok) { - throw new Error('Failed to search Spotify for track'); - } - - const searchData = await searchResponse.json(); - const spotifyTracks = searchData.tracks?.items || []; - - if (spotifyTracks.length === 0) { - throw new Error('No Spotify tracks found for this search'); - } - - // Find best match (prefer exact artist match) - let bestMatch = spotifyTracks.find(track => - track.artists.some(artist => artist.id === currentMatchingData.selectedArtist.id) - ) || spotifyTracks[0]; - - console.log(`✅ Found Spotify track: "${bestMatch.name}" (${bestMatch.id})`); - - // Get full track details with album info - const trackResponse = await fetch(`/api/spotify/track/${bestMatch.id}`); - if (!trackResponse.ok) { - throw new Error('Failed to fetch Spotify track details'); - } - - const fullTrack = await trackResponse.json(); - - // Send with full Spotify metadata (single track enhanced) - confirmBtn.textContent = 'Downloading...'; - const response = await fetch('/api/download/matched', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - search_result: downloadPayload, - spotify_artist: currentMatchingData.selectedArtist, - spotify_album: null, // Singles don't have album context - spotify_track: fullTrack, // Full Spotify track object - is_single_track: true // Flag for single track processing - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`🎯 Matched single: "${fullTrack.name}"`, 'success'); - closeMatchingModal(); - } else { - throw new Error(data.error || 'Failed to start matched download'); - } - - } catch (singleMatchError) { - console.error('❌ Spotify track matching failed, falling back to basic:', singleMatchError); - showToast('⚠️ Spotify matching failed, using basic metadata', 'warning'); - - // Fallback to basic matching (current behavior) - const response = await fetch('/api/download/matched', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - search_result: downloadPayload, - spotify_artist: currentMatchingData.selectedArtist, - spotify_album: currentMatchingData.selectedAlbum || null - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); - closeMatchingModal(); - } else { - throw new Error(data.error || 'Failed to start matched download'); - } - } - } - - } catch (error) { - console.error('Error starting matched download:', error); - showToast(`❌ Error starting matched download: ${error.message}`, 'error'); - - // Re-enable confirm button on failure - confirmBtn.disabled = false; - confirmBtn.textContent = originalText; - } -} - - - - -function matchedDownloadTrack(trackIndex) { - const results = window.currentSearchResults; - if (!results || !results[trackIndex]) { - console.error('Could not find track for matched download:', trackIndex); - showToast('Error preparing matched download.', 'error'); - return; - } - const trackData = results[trackIndex]; - // It's a single track, so isAlbumDownload is false and there's no album context. - openMatchingModal(trackData, false, null); -} - -function matchedDownloadAlbum(albumIndex) { - const results = window.currentSearchResults; - if (!results || !results[albumIndex]) { - console.error('Could not find album for matched download:', albumIndex); - showToast('Error preparing matched download.', 'error'); - return; - } - const albumData = results[albumIndex]; - // The first track is used as a reference for the initial artist search. - const firstTrack = albumData.tracks ? albumData.tracks[0] : albumData; - openMatchingModal(firstTrack, true, albumData); -} - -function matchedDownloadAlbumTrack(albumIndex, trackIndex) { - const results = window.currentSearchResults; - if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) { - console.error('Could not find album track for matched download:', albumIndex, trackIndex); - showToast('Error preparing matched download.', 'error'); - return; - } - const albumData = results[albumIndex]; - const trackData = albumData.tracks[trackIndex]; - - // This is the definitive fix. - // The second argument MUST be 'false' to treat this as a single track download, - // which prevents the modal from asking for an album selection. - openMatchingModal(trackData, false, albumData); -} - -// =========================================== -// == DASHBOARD DATABASE UPDATER FUNCTIONALITY == -// =========================================== - -// --- State and Polling Management --- - -function stopDbStatsPolling() { - if (dbStatsInterval) { - clearInterval(dbStatsInterval); - dbStatsInterval = null; - } -} - -function stopDbUpdatePolling() { - if (dbUpdateStatusInterval) { - console.log('⏹️ Stopping database update polling'); - clearInterval(dbUpdateStatusInterval); - dbUpdateStatusInterval = null; - } -} - -// =================================================================== -// QUALITY SCANNER TOOL -// =================================================================== - -async function handleQualityScanButtonClick() { - const button = document.getElementById('quality-scan-button'); - const currentAction = button.textContent; - - if (currentAction === 'Scan Library') { - const scopeSelect = document.getElementById('quality-scan-scope'); - const scope = scopeSelect.value; - - try { - button.disabled = true; - button.textContent = 'Starting...'; - const response = await fetch('/api/quality-scanner/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ scope: scope }) - }); - - if (response.ok) { - showToast('Quality scan started!', 'success'); - // Start polling immediately to get live status - checkAndUpdateQualityScanProgress(); - } else { - const errorData = await response.json(); - showToast(`Error: ${errorData.error}`, 'error'); - button.disabled = false; - button.textContent = 'Scan Library'; - } - } catch (error) { - showToast('Failed to start quality scan.', 'error'); - button.disabled = false; - button.textContent = 'Scan Library'; - } - - } else { // "Stop Scan" - try { - const response = await fetch('/api/quality-scanner/stop', { method: 'POST' }); - if (response.ok) { - showToast('Stop request sent.', 'info'); - } else { - showToast('Failed to send stop request.', 'error'); - } - } catch (error) { - showToast('Error sending stop request.', 'error'); - } - } -} - -async function checkAndUpdateQualityScanProgress() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/quality-scanner/status', { - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - if (!response.ok) return; - - const state = await response.json(); - console.debug('🔍 Quality Scanner Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); - updateQualityScanProgressUI(state); - - // Start polling only if not already polling and status is running - if (state.status === 'running' && !qualityScannerStatusInterval) { - console.log('🔄 Starting quality scanner polling (1 second interval)'); - qualityScannerStatusInterval = setInterval(checkAndUpdateQualityScanProgress, 1000); - } - - } catch (error) { - console.warn('Could not fetch quality scanner status:', error); - // Don't stop polling on network errors - keep trying - } -} - -function updateQualityScanProgressFromData(data) { - const prev = _lastToolStatus['quality-scanner']; - _lastToolStatus['quality-scanner'] = data.status; - if (prev !== undefined && data.status === prev && data.status !== 'running') return; - updateQualityScanProgressUI(data); -} - -function updateQualityScanProgressUI(state) { - const button = document.getElementById('quality-scan-button'); - const phaseLabel = document.getElementById('quality-phase-label'); - const progressLabel = document.getElementById('quality-progress-label'); - const progressBar = document.getElementById('quality-progress-bar'); - const scopeSelect = document.getElementById('quality-scan-scope'); - - // Stats - const processedStat = document.getElementById('quality-stat-processed'); - const metStat = document.getElementById('quality-stat-met'); - const lowStat = document.getElementById('quality-stat-low'); - const matchedStat = document.getElementById('quality-stat-matched'); - - if (!button || !phaseLabel || !progressLabel || !progressBar || !scopeSelect) return; - - // Update stats - if (processedStat) processedStat.textContent = state.processed || 0; - if (metStat) metStat.textContent = state.quality_met || 0; - if (lowStat) lowStat.textContent = state.low_quality || 0; - if (matchedStat) matchedStat.textContent = state.matched || 0; - - if (state.status === 'running') { - button.textContent = 'Stop Scan'; - button.disabled = false; - scopeSelect.disabled = true; - - phaseLabel.textContent = state.phase || 'Scanning...'; - progressLabel.textContent = `${state.processed} / ${state.total} tracks scanned (${state.progress.toFixed(1)}%)`; - progressBar.style.width = `${state.progress}%`; - } else { // idle, finished, or error - stopQualityScannerPolling(); - button.textContent = 'Scan Library'; - button.disabled = false; - scopeSelect.disabled = false; - - if (state.status === 'error') { - phaseLabel.textContent = `Error: ${state.error_message}`; - progressBar.style.backgroundColor = '#ff4444'; // Red for error - } else { - phaseLabel.textContent = state.phase || 'Ready to scan'; - progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal - } - - if (state.status === 'finished') { - // Show completion toast with results - showToast(`Scan complete! ${state.matched} tracks added to wishlist`, 'success'); - } - } -} - -function stopQualityScannerPolling() { - if (qualityScannerStatusInterval) { - console.log('⏹️ Stopping quality scanner polling'); - clearInterval(qualityScannerStatusInterval); - qualityScannerStatusInterval = null; - } -} - -// ============================================ -// == DUPLICATE CLEANER FUNCTIONS == -// ============================================ - -async function handleDuplicateCleanButtonClick() { - const button = document.getElementById('duplicate-clean-button'); - const currentAction = button.textContent; - - if (currentAction === 'Clean Duplicates') { - try { - button.disabled = true; - button.textContent = 'Starting...'; - const response = await fetch('/api/duplicate-cleaner/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - if (response.ok) { - showToast('Duplicate cleaner started!', 'success'); - // Start polling immediately to get live status - checkAndUpdateDuplicateCleanProgress(); - } else { - const errorData = await response.json(); - showToast(`Error: ${errorData.error}`, 'error'); - button.disabled = false; - button.textContent = 'Clean Duplicates'; - } - } catch (error) { - showToast('Failed to start duplicate cleaner.', 'error'); - button.disabled = false; - button.textContent = 'Clean Duplicates'; - } - - } else { // "Stop Cleaning" - try { - const response = await fetch('/api/duplicate-cleaner/stop', { method: 'POST' }); - if (response.ok) { - showToast('Stop request sent.', 'info'); - } else { - showToast('Failed to send stop request.', 'error'); - } - } catch (error) { - showToast('Error sending stop request.', 'error'); - } - } -} - -async function checkAndUpdateDuplicateCleanProgress() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/duplicate-cleaner/status', { - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - if (!response.ok) return; - - const state = await response.json(); - console.debug('🧹 Duplicate Cleaner Status:', state.status, `${state.files_scanned}/${state.total_files}`, `${state.progress.toFixed(1)}%`); - updateDuplicateCleanProgressUI(state); - - // Start polling only if not already polling and status is running - if (state.status === 'running' && !duplicateCleanerStatusInterval) { - console.log('🔄 Starting duplicate cleaner polling (1 second interval)'); - duplicateCleanerStatusInterval = setInterval(checkAndUpdateDuplicateCleanProgress, 1000); - } - - } catch (error) { - console.warn('Could not fetch duplicate cleaner status:', error); - // Don't stop polling on network errors - keep trying - } -} - -function updateDuplicateCleanProgressFromData(data) { - const prev = _lastToolStatus['duplicate-cleaner']; - _lastToolStatus['duplicate-cleaner'] = data.status; - if (prev !== undefined && data.status === prev && data.status !== 'running') return; - updateDuplicateCleanProgressUI(data); -} - -function updateDuplicateCleanProgressUI(state) { - const button = document.getElementById('duplicate-clean-button'); - const phaseLabel = document.getElementById('duplicate-phase-label'); - const progressLabel = document.getElementById('duplicate-progress-label'); - const progressBar = document.getElementById('duplicate-progress-bar'); - - // Stats - const scannedStat = document.getElementById('duplicate-stat-scanned'); - const foundStat = document.getElementById('duplicate-stat-found'); - const deletedStat = document.getElementById('duplicate-stat-deleted'); - const spaceStat = document.getElementById('duplicate-stat-space'); - - if (!button || !phaseLabel || !progressLabel || !progressBar) return; - - // Update stats - if (scannedStat) scannedStat.textContent = state.files_scanned || 0; - if (foundStat) foundStat.textContent = state.duplicates_found || 0; - if (deletedStat) deletedStat.textContent = state.deleted || 0; - if (spaceStat) { - const spaceMB = state.space_freed_mb || 0; - if (spaceMB >= 1024) { - spaceStat.textContent = `${(spaceMB / 1024).toFixed(2)} GB`; - } else { - spaceStat.textContent = `${spaceMB.toFixed(2)} MB`; - } - } - - if (state.status === 'running') { - button.textContent = 'Stop Cleaning'; - button.disabled = false; - - phaseLabel.textContent = state.phase || 'Scanning...'; - progressLabel.textContent = `${state.files_scanned} / ${state.total_files} files scanned (${state.progress.toFixed(1)}%)`; - progressBar.style.width = `${state.progress}%`; - } else { // idle, finished, or error - stopDuplicateCleanerPolling(); - button.textContent = 'Clean Duplicates'; - button.disabled = false; - - if (state.status === 'error') { - phaseLabel.textContent = `Error: ${state.error_message}`; - progressBar.style.backgroundColor = '#ff4444'; // Red for error - } else { - phaseLabel.textContent = state.phase || 'Ready to scan'; - progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal - } - - if (state.status === 'finished') { - // Show completion toast with results - const spaceMB = state.space_freed_mb || 0; - const spaceDisplay = spaceMB >= 1024 ? `${(spaceMB / 1024).toFixed(2)} GB` : `${spaceMB.toFixed(1)} MB`; - showToast(`Cleaning complete! ${state.deleted} files removed, ${spaceDisplay} freed`, 'success'); - } - } -} - -function stopDuplicateCleanerPolling() { - if (duplicateCleanerStatusInterval) { - console.log('⏹️ Stopping duplicate cleaner polling'); - clearInterval(duplicateCleanerStatusInterval); - duplicateCleanerStatusInterval = null; - } -} - -// ============================================ -// == BACKUP MANAGER == -// ============================================ - -async function loadBackupList() { - try { - const res = await fetch('/api/database/backups'); - const data = await res.json(); - if (data.success) { - updateBackupManagerUI(data); - renderBackupList(data.backups); - } - } catch (e) { - console.error('Failed to load backup list:', e); - } -} - -function updateBackupManagerUI(data) { - const lastEl = document.getElementById('backup-stat-last'); - const countEl = document.getElementById('backup-stat-count'); - const latestSizeEl = document.getElementById('backup-stat-latest-size'); - const dbSizeEl = document.getElementById('backup-stat-db-size'); - - if (countEl) countEl.textContent = data.count; - if (dbSizeEl) dbSizeEl.textContent = data.db_size_mb + ' MB'; - - if (data.backups && data.backups.length > 0) { - const newest = data.backups[0]; - if (lastEl) lastEl.textContent = timeAgo(newest.created); - if (latestSizeEl) latestSizeEl.textContent = newest.size_mb + ' MB'; - } else { - if (lastEl) lastEl.textContent = 'Never'; - if (latestSizeEl) latestSizeEl.textContent = '—'; - } -} - -function renderBackupList(backups) { - const container = document.getElementById('backup-list-container'); - if (!container) return; - if (!backups || backups.length === 0) { - container.innerHTML = ''; - return; - } - - container.innerHTML = backups.map(b => { - const date = new Date(b.created + (b.created.includes('Z') ? '' : 'Z')); - const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) - + ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); - const safeName = escapeForInlineJs(b.filename); - const versionBadge = b.version ? `v${escapeHtml(b.version)}` : ''; - return `
    -
    - ${escapeHtml(dateStr)} - ${b.size_mb} MB - ${versionBadge} -
    -
    - - - -
    -
    `; - }).join(''); -} - -async function handleBackupNowClick() { - const button = document.getElementById('backup-now-button'); - if (!button) return; - const origText = button.textContent; - button.disabled = true; - button.textContent = 'Backing up...'; - try { - const res = await fetch('/api/database/backup', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Database backed up (${data.size_mb} MB)`, 'success'); - await loadBackupList(); - } else { - showToast(`Backup failed: ${data.error}`, 'error'); - } - } catch (e) { - showToast('Backup request failed', 'error'); - } - button.disabled = false; - button.textContent = origText; -} - -function downloadBackup(filename) { - const a = document.createElement('a'); - a.href = `/api/database/backups/${encodeURIComponent(filename)}/download`; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); -} - -async function restoreBackup(filename, force = false) { - if (!force) { - if (!await showConfirmDialog({ title: 'Restore Backup', message: `Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`, confirmText: 'Restore' })) return; - } - try { - const fetchOpts = { method: 'POST' }; - if (force) { - fetchOpts.headers = { 'Content-Type': 'application/json' }; - fetchOpts.body = JSON.stringify({ force: true }); - } - const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, fetchOpts); - const data = await res.json(); - if (data.success) { - let msg = `Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`; - if (data.version_warning) msg += `\n⚠️ ${data.version_warning}`; - showToast(msg, 'success'); - await loadBackupList(); - } else if (data.version_mismatch) { - // Version mismatch — ask user to confirm - const confirmed = await showConfirmDialog({ - title: 'Version Mismatch', - message: `This backup was created on SoulSync v${data.backup_version}, but you're running v${data.current_version}.\n\nRestoring an older backup may cause issues if the database schema has changed. A safety backup will be created first.\n\nProceed anyway?`, - confirmText: 'Restore Anyway', - destructive: true - }); - if (confirmed) { - await restoreBackup(filename, true); - } - } else { - showToast(`Restore failed: ${data.error}`, 'error'); - } - } catch (e) { - showToast('Restore request failed', 'error'); - } -} - -async function deleteBackup(filename) { - if (!await showConfirmDialog({ title: 'Delete Backup', message: `Delete backup "${filename}"? This cannot be undone.`, confirmText: 'Delete', destructive: true })) return; - try { - const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - showToast(`Backup deleted: ${data.deleted}`, 'success'); - await loadBackupList(); - } else { - showToast(`Delete failed: ${data.error}`, 'error'); - } - } catch (e) { - showToast('Delete request failed', 'error'); - } -} - -// ============================================ -// == METADATA CACHE == -// ============================================ - -async function loadMetadataCacheStats() { - try { - const response = await fetch('/api/metadata-cache/stats'); - if (!response.ok) return; - const stats = await response.json(); - - const artistsEl = document.getElementById('mcache-stat-artists'); - const albumsEl = document.getElementById('mcache-stat-albums'); - const tracksEl = document.getElementById('mcache-stat-tracks'); - const hitsEl = document.getElementById('mcache-stat-hits'); - - if (artistsEl) artistsEl.textContent = (stats.artists?.spotify || 0) + (stats.artists?.itunes || 0) + (stats.artists?.deezer || 0) + (stats.artists?.beatport || 0); - if (albumsEl) albumsEl.textContent = (stats.albums?.spotify || 0) + (stats.albums?.itunes || 0) + (stats.albums?.deezer || 0) + (stats.albums?.beatport || 0); - if (tracksEl) tracksEl.textContent = (stats.tracks?.spotify || 0) + (stats.tracks?.itunes || 0) + (stats.tracks?.deezer || 0) + (stats.tracks?.beatport || 0); - if (hitsEl) hitsEl.textContent = stats.total_hits || 0; - } catch (e) { - // Silently fail — cache may not be initialized yet - } -} - -// ── Library History Modal ──────────────────────────────────────────── -let _libraryHistoryState = { tab: 'download', page: 1, limit: 50 }; - -function openLibraryHistoryModal() { - const overlay = document.getElementById('library-history-overlay'); - if (overlay) { - overlay.classList.remove('hidden'); - _libraryHistoryState.page = 1; - loadLibraryHistory(); - } -} - -function closeLibraryHistoryModal() { - const overlay = document.getElementById('library-history-overlay'); - if (overlay) overlay.classList.add('hidden'); -} - -function switchHistoryTab(tab) { - _libraryHistoryState.tab = tab; - _libraryHistoryState.page = 1; - document.querySelectorAll('.library-history-tab').forEach(t => { - t.classList.toggle('active', t.dataset.tab === tab); - }); - loadLibraryHistory(); -} - -async function loadLibraryHistory() { - const { tab, page, limit } = _libraryHistoryState; - const list = document.getElementById('library-history-list'); - const pagination = document.getElementById('library-history-pagination'); - if (!list) return; - list.innerHTML = '
    Loading...
    '; - if (pagination) pagination.innerHTML = ''; - - try { - const resp = await fetch(`/api/library/history?type=${tab}&page=${page}&limit=${limit}`); - const data = await resp.json(); - - // Update tab counts - const dlCount = document.getElementById('history-download-count'); - const imCount = document.getElementById('history-import-count'); - if (dlCount) dlCount.textContent = data.stats?.downloads || 0; - if (imCount) imCount.textContent = data.stats?.imports || 0; - - // Source breakdown bar (downloads tab only) - const sourceBar = document.getElementById('history-source-bar'); - if (sourceBar) { - const sc = data.stats?.source_counts || {}; - const srcEntries = Object.entries(sc).sort((a, b) => b[1] - a[1]); - if (srcEntries.length > 0 && tab === 'download') { - const _srcColors = { Soulseek: '#4caf50', Tidal: '#000', YouTube: '#ff0000', Qobuz: '#4285f4', HiFi: '#00bcd4', Deezer: '#a238ff' }; - sourceBar.innerHTML = srcEntries.map(([src, cnt]) => - `${src}: ${cnt}` - ).join(''); - sourceBar.style.display = ''; - } else { - sourceBar.style.display = 'none'; - } - } - - if (!data.entries || data.entries.length === 0) { - const emptyIcon = tab === 'download' ? '📥' : '📚'; - const emptyText = tab === 'download' - ? 'No downloads recorded yet. Completed downloads will appear here.' - : 'No server imports recorded yet. New tracks from library scans will appear here.'; - list.innerHTML = `
    ${emptyIcon}

    ${emptyText}
    `; - return; - } - - list.innerHTML = data.entries.map(renderHistoryEntry).join(''); - renderHistoryPagination(data.total, page, limit); - } catch (err) { - console.error('Error loading library history:', err); - list.innerHTML = '
    Error loading history
    '; - } -} - -function renderHistoryEntry(entry) { - // Server import thumb_urls are relative paths (e.g. /library/metadata/...) — use placeholder - const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://')); - const thumb = hasValidThumb - ? `` - : `
    ${entry.event_type === 'download' ? '📥' : '📚'}
    `; - - let badge = ''; - if (entry.event_type === 'download') { - const parts = []; - if (entry.download_source) parts.push(entry.download_source); - if (entry.quality) parts.push(entry.quality); - badge = parts.map(p => `${escapeHtml(p)}`).join(''); - } else if (entry.event_type === 'import' && entry.server_source) { - const sourceName = { plex: 'Plex', jellyfin: 'Jellyfin', navidrome: 'Navidrome' }[entry.server_source] || entry.server_source; - badge = `${escapeHtml(sourceName)}`; - } - - // AcoustID badge - let acoustidBadge = ''; - if (entry.acoustid_result) { - const _aidColors = { pass: '#4caf50', fail: '#ef5350', skip: '#ff9800', disabled: '#666', error: '#ef5350' }; - const _aidLabels = { pass: 'Verified', fail: 'Failed', skip: 'Skipped', disabled: 'Off', error: 'Error' }; - const color = _aidColors[entry.acoustid_result] || '#666'; - const label = _aidLabels[entry.acoustid_result] || entry.acoustid_result; - acoustidBadge = `AcoustID: ${label}`; - } - - const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — '); - - // Source provenance — expected vs downloaded - let sourceDetail = ''; - if (entry.event_type === 'download') { - const lines = []; - // Expected line (what we asked for) - if (entry.title || entry.artist_name) { - lines.push(`Expected: ${escapeHtml(entry.title || '?')} by ${escapeHtml(entry.artist_name || '?')}`); - } - // Downloaded line (what the source provided) - const srcTitle = entry.source_track_title || ''; - const srcArtist = entry.source_artist || ''; - if (srcTitle || srcArtist) { - const isMismatch = (srcTitle && entry.title && srcTitle.toLowerCase() !== entry.title.toLowerCase()) - || (srcArtist && entry.artist_name && srcArtist.toLowerCase() !== entry.artist_name.toLowerCase()); - const mismatchClass = isMismatch ? ' lh-prov-mismatch' : ''; - lines.push(`Downloaded: ${escapeHtml(srcTitle || '?')} by ${escapeHtml(srcArtist || '?')}`); - } - // Source file + ID line - if (entry.source_filename || entry.source_track_id) { - const fileParts = []; - if (entry.source_filename) fileParts.push(`File: ${escapeHtml(entry.source_filename)}`); - if (entry.source_track_id) fileParts.push(`${entry.source_filename ? '' : 'Source '}ID: ${escapeHtml(entry.source_track_id)}`); - lines.push(fileParts.join(` · `)); - } - if (lines.length > 0) { - sourceDetail = `
    ${lines.join('
    ')}
    `; - } - } - - const hasDetails = sourceDetail || acoustidBadge; - const expandIndicator = hasDetails ? `` : ''; - - return `
    - ${thumb} -
    -
    -
    -
    ${escapeHtml(entry.title || 'Unknown')}
    - -
    -
    ${badge}
    -
    ${formatHistoryTime(entry.created_at)}
    - ${expandIndicator} -
    - ${hasDetails ? `
    - ${sourceDetail} - ${acoustidBadge ? `
    ${acoustidBadge}
    ` : ''} -
    ` : ''} -
    -
    `; -} - -function formatHistoryTime(isoStr) { - if (!isoStr) return ''; - try { - // SQLite CURRENT_TIMESTAMP is UTC but lacks timezone marker — append Z - let normalized = isoStr; - if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(normalized) && !normalized.includes('Z') && !normalized.includes('+')) { - normalized = normalized.replace(' ', 'T') + 'Z'; - } - const date = new Date(normalized); - const now = new Date(); - const diffMs = now - date; - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'Just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); - } catch { return ''; } -} - -function renderHistoryPagination(total, page, limit) { - const pagination = document.getElementById('library-history-pagination'); - if (!pagination) return; - - const totalPages = Math.ceil(total / limit); - if (totalPages <= 1) { pagination.innerHTML = ''; return; } - - pagination.innerHTML = ` - - Page ${page} of ${totalPages} - - `; -} - -function changeHistoryPage(newPage) { - if (newPage < 1) return; - _libraryHistoryState.page = newPage; - loadLibraryHistory(); -} - -// ── Sync History Modal ────────────────────────────────────────────── -const _syncHistoryState = { source: null, page: 1, limit: 20 }; - -function openSyncHistoryModal() { - const overlay = document.getElementById('sync-history-overlay'); - if (overlay) { - overlay.classList.remove('hidden'); - _syncHistoryState.page = 1; - _syncHistoryState.source = null; - loadSyncHistory(); - } -} - -function closeSyncHistoryModal() { - const overlay = document.getElementById('sync-history-overlay'); - if (overlay) overlay.classList.add('hidden'); -} - -function switchSyncHistoryTab(source) { - _syncHistoryState.source = source; - _syncHistoryState.page = 1; - document.querySelectorAll('.sync-history-tab').forEach(t => { - t.classList.toggle('active', t.dataset.source === (source || 'all')); - }); - loadSyncHistory(); -} - -async function loadSyncHistory() { - const { source, page, limit } = _syncHistoryState; - const list = document.getElementById('sync-history-list'); - const tabsContainer = document.getElementById('sync-history-tabs'); - if (!list) return; - list.innerHTML = '
    Loading...
    '; - - try { - const params = new URLSearchParams({ page, limit }); - if (source) params.set('source', source); - const resp = await fetch(`/api/sync/history?${params}`); - const data = await resp.json(); - - // Build tabs from stats - if (tabsContainer && data.stats) { - const totalCount = Object.values(data.stats).reduce((a, b) => a + b, 0); - const sourceLabels = { - spotify: 'Spotify', beatport: 'Beatport', youtube: 'YouTube', - tidal: 'Tidal', deezer: 'Deezer', wishlist: 'Wishlist', - library: 'Library', discover: 'Discover', listenbrainz: 'ListenBrainz', - spotify_public: 'Spotify Public', mirrored: 'Mirrored' - }; - let tabsHtml = ``; - for (const [src, count] of Object.entries(data.stats).sort((a, b) => b[1] - a[1])) { - const label = sourceLabels[src] || src; - const isActive = source === src ? ' active' : ''; - tabsHtml += ``; - } - tabsContainer.innerHTML = tabsHtml; - } - - // Filter to only show playlist syncs — not album downloads, wishlist, or redownloads - const syncEntries = (data.entries || []).filter(e => e.sync_type === 'playlist' || !e.sync_type); - - if (syncEntries.length === 0) { - list.innerHTML = '
    No sync history yet. Completed syncs will appear here.
    '; - return; - } - - list.innerHTML = syncEntries.map(renderSyncHistoryEntry).join(''); - renderSyncHistoryPagination(data.total, page, limit); - } catch (err) { - console.error('Error loading sync history:', err); - list.innerHTML = '
    Error loading sync history
    '; - } -} - -function renderSyncHistoryEntry(entry) { - const thumb = entry.thumb_url - ? `` - : `
    ${_syncSourceIcon(entry.source)}
    `; - - const sourceBadge = `${escapeHtml(entry.source)}`; - - const title = entry.playlist_name || 'Unknown'; - const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — ') || entry.sync_type; - - // Stats - let statsHtml = ''; - if (entry.completed_at) { - const parts = []; - if (entry.tracks_found > 0) parts.push(`${entry.tracks_found} found`); - if (entry.tracks_downloaded > 0) parts.push(`${entry.tracks_downloaded} downloaded`); - if (entry.tracks_failed > 0) parts.push(`${entry.tracks_failed} failed`); - if (parts.length === 0) parts.push(`${entry.total_tracks} in library`); - statsHtml = `
    ${parts.join('')}
    `; - } else { - statsHtml = `
    In progress
    `; - } - - const timeStr = formatHistoryTime(entry.started_at); - - return `
    -
    - ${thumb} -
    -
    ${escapeHtml(title)}
    - -
    - ${sourceBadge} - ${statsHtml} -
    ${timeStr}
    - - -
    - -
    `; -} - -function _syncSourceIcon(source) { - const icons = { - spotify: '🎵', beatport: '🎶', youtube: '▶', - tidal: '🌊', deezer: '🎧', wishlist: '⭐', - library: '📚', discover: '🔍', mirrored: '🔗', - listenbrainz: '🎧', spotify_public: '🎵' - }; - return icons[source] || '📥'; -} - -function renderSyncHistoryPagination(total, page, limit) { - const pagination = document.getElementById('sync-history-pagination'); - if (!pagination) return; - const totalPages = Math.ceil(total / limit); - if (totalPages <= 1) { pagination.innerHTML = ''; return; } - pagination.innerHTML = ` - - Page ${page} of ${totalPages} - - `; -} - -function changeSyncHistoryPage(newPage) { - if (newPage < 1) return; - _syncHistoryState.page = newPage; - loadSyncHistory(); -} - -// Track active re-syncs from history -let _activeSyncHistoryResyncs = {}; - -// Sources that do server playlist sync (match to media server) vs download (Soulseek download) -const _serverSyncSources = new Set(['spotify', 'tidal', 'deezer', 'youtube', 'mirrored', 'listenbrainz', 'spotify_public', 'beatport']); -const _downloadSyncSources = new Set(['discover', 'library', 'wishlist']); - -async function retriggerSync(entryId) { - try { - const resp = await fetch(`/api/sync/history/${entryId}`); - const data = await resp.json(); - - if (!data.success || !data.entry) { - showToast('Failed to load sync data', 'error'); - return; - } - - const entry = data.entry; - - // Determine if this is a download-type sync or a server-sync-type - const isDownloadSync = entry.is_album_download || _downloadSyncSources.has(entry.source); - const isServerSync = _serverSyncSources.has(entry.source) && !entry.is_album_download; - - if (isDownloadSync) { - // Download syncs open the download modal (existing behavior) - closeSyncHistoryModal(); - - const virtualPlaylistId = entry.playlist_id || `resync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const albumObj = entry.album_context || { - id: `resync_album_${entryId}`, - name: entry.playlist_name, - album_type: entry.sync_type === 'album' ? 'album' : 'compilation', - images: entry.thumb_url ? [{ url: entry.thumb_url }] : [], - total_tracks: entry.total_tracks - }; - const artistObj = entry.artist_context || { id: 'resync_artist', name: 'Various Artists' }; - const contextType = entry.sync_type === 'album' ? 'artist_album' : 'playlist'; - - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, entry.playlist_name, entry.tracks, - albumObj, artistObj, false, contextType - ); - } else { - // Server sync — start sync and show live progress in the card - await _startSyncHistoryResync(entryId, entry); - } - } catch (err) { - console.error('Error re-triggering sync:', err); - showToast('Error loading sync data', 'error'); - } -} - -async function _startSyncHistoryResync(entryId, entry) { - // Disable the re-sync button - const btn = document.getElementById(`resync-btn-${entryId}`); - if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } - - // Show the progress area - const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`); - const progressArea = document.getElementById(`sync-history-progress-${entryId}`); - if (wrapper) wrapper.classList.add('syncing'); - if (progressArea) progressArea.style.display = ''; - - // Build a unique sync playlist ID for this re-sync - const syncPlaylistId = `resync_${entryId}_${Date.now()}`; - - // Prepare tracks for the sync API - const tracks = (entry.tracks || []).map(t => { - const artists = Array.isArray(t.artists) - ? (typeof t.artists[0] === 'object' ? t.artists.map(a => a.name || a) : t.artists) - : [t.artists || 'Unknown Artist']; - const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); - return { - id: t.id || '', - name: t.name || '', - artists: artists, - album: albumName, - duration_ms: t.duration_ms || 0, - popularity: t.popularity || 0 - }; - }); - - try { - const response = await fetch('/api/sync/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_id: syncPlaylistId, - playlist_name: entry.playlist_name, - tracks: tracks - }) - }); - - const result = await response.json(); - if (!result.success) { - showToast(`Sync failed: ${result.error || 'Unknown error'}`, 'error'); - _cleanupSyncHistoryResync(entryId); - return; - } - - // Store active re-sync state - _activeSyncHistoryResyncs[entryId] = { syncPlaylistId, entryId }; - - // Start polling for progress - _pollSyncHistoryProgress(entryId, syncPlaylistId); - - } catch (err) { - console.error('Error starting re-sync:', err); - showToast('Failed to start sync', 'error'); - _cleanupSyncHistoryResync(entryId); - } -} - -function _pollSyncHistoryProgress(entryId, syncPlaylistId) { - const pollInterval = setInterval(async () => { - try { - const resp = await fetch(`/api/sync/status/${syncPlaylistId}`); - if (!resp.ok) { - clearInterval(pollInterval); - _cleanupSyncHistoryResync(entryId, 'error'); - return; - } - const state = await resp.json(); - - if (state.status === 'syncing' || state.status === 'starting') { - const progress = state.progress || {}; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const step = progress.current_step || 'Processing'; - const currentTrack = progress.current_track || ''; - const processed = matched + failed; - const percent = total > 0 ? Math.round((processed / total) * 100) : 0; - - const bar = document.getElementById(`sync-history-bar-${entryId}`); - const stepEl = document.getElementById(`sync-history-step-${entryId}`); - const matchedEl = document.getElementById(`sync-history-matched-${entryId}`); - const failedEl = document.getElementById(`sync-history-failed-${entryId}`); - - if (bar) bar.style.width = `${percent}%`; - if (stepEl) stepEl.textContent = currentTrack ? `${step} — ${currentTrack}` : step; - if (matchedEl) matchedEl.textContent = `${matched} matched`; - if (failedEl) failedEl.textContent = `${failed} failed`; - - } else if (state.status === 'finished') { - clearInterval(pollInterval); - const progress = state.progress || state.result || {}; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const synced = progress.synced_tracks || 0; - - const bar = document.getElementById(`sync-history-bar-${entryId}`); - const stepEl = document.getElementById(`sync-history-step-${entryId}`); - const matchedEl = document.getElementById(`sync-history-matched-${entryId}`); - const failedEl = document.getElementById(`sync-history-failed-${entryId}`); - - if (bar) bar.style.width = '100%'; - if (stepEl) stepEl.textContent = `Sync complete — ${matched}/${total} matched, ${synced} synced`; - if (matchedEl) matchedEl.textContent = `${matched} matched`; - if (failedEl) failedEl.textContent = `${failed} failed`; - - // Hide cancel button - const cancelBtn = document.getElementById(`sync-history-cancel-${entryId}`); - if (cancelBtn) cancelBtn.style.display = 'none'; - - showToast(`Re-sync complete: ${matched}/${total} matched`, 'success'); - - // Auto-collapse after 5 seconds - setTimeout(() => _cleanupSyncHistoryResync(entryId, 'finished'), 5000); - - } else if (state.status === 'cancelled' || state.status === 'error') { - clearInterval(pollInterval); - const stepEl = document.getElementById(`sync-history-step-${entryId}`); - if (stepEl) stepEl.textContent = state.status === 'cancelled' ? 'Sync cancelled' : `Sync error: ${state.error || 'Unknown'}`; - - const cancelBtn = document.getElementById(`sync-history-cancel-${entryId}`); - if (cancelBtn) cancelBtn.style.display = 'none'; - - setTimeout(() => _cleanupSyncHistoryResync(entryId, state.status), 3000); - } - } catch (err) { - console.error('Error polling sync status:', err); - clearInterval(pollInterval); - _cleanupSyncHistoryResync(entryId, 'error'); - } - }, 2000); - - // Store interval so cancel can clear it - if (_activeSyncHistoryResyncs[entryId]) { - _activeSyncHistoryResyncs[entryId].pollInterval = pollInterval; - } -} - -async function cancelSyncHistoryResync(entryId) { - const active = _activeSyncHistoryResyncs[entryId]; - if (!active) return; - - try { - await fetch('/api/sync/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ playlist_id: active.syncPlaylistId }) - }); - - const stepEl = document.getElementById(`sync-history-step-${entryId}`); - if (stepEl) stepEl.textContent = 'Cancelling...'; - - } catch (err) { - console.error('Error cancelling sync:', err); - showToast('Failed to cancel sync', 'error'); - } -} - -function _cleanupSyncHistoryResync(entryId, finalStatus) { - const active = _activeSyncHistoryResyncs[entryId]; - if (active && active.pollInterval) { - clearInterval(active.pollInterval); - } - delete _activeSyncHistoryResyncs[entryId]; - - const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`); - const progressArea = document.getElementById(`sync-history-progress-${entryId}`); - const btn = document.getElementById(`resync-btn-${entryId}`); - - if (wrapper) wrapper.classList.remove('syncing'); - if (progressArea) progressArea.style.display = 'none'; - if (btn) { btn.disabled = false; btn.textContent = 'Re-sync'; } -} - -async function deleteSyncHistoryEntry(entryId) { - try { - const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' }); - const data = await resp.json(); - if (data.success) { - const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`); - if (wrapper) { - wrapper.style.transition = 'opacity 0.2s ease, max-height 0.3s ease'; - wrapper.style.opacity = '0'; - wrapper.style.maxHeight = wrapper.offsetHeight + 'px'; - requestAnimationFrame(() => { wrapper.style.maxHeight = '0'; wrapper.style.overflow = 'hidden'; }); - setTimeout(() => wrapper.remove(), 300); - } - } else { - showToast('Failed to delete entry', 'error'); - } - } catch (err) { - console.error('Error deleting sync history entry:', err); - showToast('Failed to delete entry', 'error'); - } -} - -// ── Sync Playlist to Server (from Download Modal) ────────────────── - -// Track active modal syncs -let _activeModalSyncs = {}; - -function _isBeatportPlaylistId(id) { - return id.startsWith('beatport_chart_') || id.startsWith('beatport_top100_') || id.startsWith('beatport_hype100_'); -} - -async function syncPlaylistToServer(playlistId) { - const process = activeDownloadProcesses[playlistId]; - if (!process) { showToast('No playlist data found', 'error'); return; } - - // Disable the sync button - const btn = document.getElementById(`sync-server-btn-${playlistId}`); - if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } - - // Show progress area - const progressArea = document.getElementById(`modal-sync-progress-${playlistId}`); - if (progressArea) progressArea.style.display = ''; - - const syncPlaylistId = `beatport_sync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const playlistName = process.playlist?.name || 'Beatport Playlist'; - - // Format tracks for the sync API - const tracks = (process.tracks || []).map(t => { - const artists = Array.isArray(t.artists) - ? (typeof t.artists[0] === 'object' ? t.artists.map(a => a.name || a) : t.artists) - : [t.artists || 'Unknown Artist']; - const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); - return { - id: t.id || '', - name: t.name || '', - artists: artists, - album: albumName, - duration_ms: t.duration_ms || 0, - popularity: t.popularity || 0 - }; - }); - - try { - const response = await fetch('/api/sync/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist_id: syncPlaylistId, - playlist_name: playlistName, - tracks: tracks - }) - }); - - const result = await response.json(); - if (!result.success) { - showToast(`Sync failed: ${result.error || 'Unknown error'}`, 'error'); - _cleanupModalSync(playlistId); - return; - } - - _activeModalSyncs[playlistId] = { syncPlaylistId }; - _pollModalSyncProgress(playlistId, syncPlaylistId); - - } catch (err) { - console.error('Error starting playlist sync:', err); - showToast('Failed to start sync', 'error'); - _cleanupModalSync(playlistId); - } -} - -function _pollModalSyncProgress(playlistId, syncPlaylistId) { - const pollInterval = setInterval(async () => { - try { - const resp = await fetch(`/api/sync/status/${syncPlaylistId}`); - if (!resp.ok) { clearInterval(pollInterval); _cleanupModalSync(playlistId, 'error'); return; } - const state = await resp.json(); - - const bar = document.getElementById(`modal-sync-bar-${playlistId}`); - const stepEl = document.getElementById(`modal-sync-step-${playlistId}`); - const matchedEl = document.getElementById(`modal-sync-matched-${playlistId}`); - const failedEl = document.getElementById(`modal-sync-failed-${playlistId}`); - - if (state.status === 'syncing' || state.status === 'starting') { - const p = state.progress || {}; - const matched = p.matched_tracks || 0; - const failed = p.failed_tracks || 0; - const total = p.total_tracks || 0; - const step = p.current_step || 'Processing'; - const currentTrack = p.current_track || ''; - const processed = matched + failed; - const percent = total > 0 ? Math.round((processed / total) * 100) : 0; - - if (bar) bar.style.width = `${percent}%`; - if (stepEl) stepEl.textContent = currentTrack ? `${step} — ${currentTrack}` : step; - if (matchedEl) matchedEl.textContent = `${matched} matched`; - if (failedEl) failedEl.textContent = `${failed} failed`; - - } else if (state.status === 'finished') { - clearInterval(pollInterval); - const p = state.progress || state.result || {}; - const matched = p.matched_tracks || 0; - const failed = p.failed_tracks || 0; - const total = p.total_tracks || 0; - const synced = p.synced_tracks || 0; - - if (bar) bar.style.width = '100%'; - if (stepEl) stepEl.textContent = `Sync complete — ${matched}/${total} matched, ${synced} synced`; - if (matchedEl) matchedEl.textContent = `${matched} matched`; - if (failedEl) failedEl.textContent = `${failed} failed`; - - const cancelBtn = document.getElementById(`modal-sync-cancel-${playlistId}`); - if (cancelBtn) cancelBtn.style.display = 'none'; - - showToast(`Server sync complete: ${matched}/${total} matched`, 'success'); - - // Re-enable sync button after a delay - setTimeout(() => _cleanupModalSync(playlistId, 'finished'), 5000); - - } else if (state.status === 'cancelled' || state.status === 'error') { - clearInterval(pollInterval); - if (stepEl) stepEl.textContent = state.status === 'cancelled' ? 'Sync cancelled' : `Sync error`; - const cancelBtn = document.getElementById(`modal-sync-cancel-${playlistId}`); - if (cancelBtn) cancelBtn.style.display = 'none'; - setTimeout(() => _cleanupModalSync(playlistId, state.status), 3000); - } - } catch (err) { - console.error('Error polling modal sync status:', err); - clearInterval(pollInterval); - _cleanupModalSync(playlistId, 'error'); - } - }, 2000); - - if (_activeModalSyncs[playlistId]) { - _activeModalSyncs[playlistId].pollInterval = pollInterval; - } -} - -async function cancelModalSync(playlistId) { - const active = _activeModalSyncs[playlistId]; - if (!active) return; - - try { - await fetch('/api/sync/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ playlist_id: active.syncPlaylistId }) - }); - const stepEl = document.getElementById(`modal-sync-step-${playlistId}`); - if (stepEl) stepEl.textContent = 'Cancelling...'; - } catch (err) { - console.error('Error cancelling modal sync:', err); - } -} - -function _cleanupModalSync(playlistId, finalStatus) { - const active = _activeModalSyncs[playlistId]; - if (active && active.pollInterval) clearInterval(active.pollInterval); - delete _activeModalSyncs[playlistId]; - - const progressArea = document.getElementById(`modal-sync-progress-${playlistId}`); - const btn = document.getElementById(`sync-server-btn-${playlistId}`); - - if (finalStatus === 'finished') { - // Keep progress visible but hide after fade - if (progressArea) setTimeout(() => { progressArea.style.display = 'none'; }, 300); - } else { - if (progressArea) progressArea.style.display = 'none'; - } - if (btn) { btn.disabled = false; btn.textContent = 'Sync to Server'; } -} - -// ── Metadata Cache Modal ──────────────────────────────────────────── -let _mcacheCurrentTab = 'artist'; -let _mcachePage = 0; -let _mcacheSearchTimeout = null; -// ================================================================================== -// DOWNLOAD BLACKLIST VIEWER -// ================================================================================== - -async function loadBlacklistCount() { - try { - const res = await fetch('/api/library/blacklist'); - const data = await res.json(); - const el = document.getElementById('blacklist-count'); - if (el) el.textContent = data.entries?.length || 0; - } catch (e) { /* ignore */ } -} - -async function openBlacklistModal() { - const existing = document.getElementById('blacklist-modal-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'blacklist-modal-overlay'; - overlay.className = 'redownload-overlay'; - overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; - - overlay.innerHTML = ` -
    -
    -

    Download Blacklist

    - -
    -
    -
    Loading...
    -
    -
    - `; - - document.body.appendChild(overlay); - - const escH = e => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escH); } }; - document.addEventListener('keydown', escH); - - try { - const res = await fetch('/api/library/blacklist'); - const data = await res.json(); - const body = document.getElementById('blacklist-modal-body'); - - if (!data.success || !data.entries || data.entries.length === 0) { - body.innerHTML = '
    No blocked sources. Sources can be blacklisted from the Source Info (ℹ) button on tracks in the enhanced library view.
    '; - return; - } - - const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜' }; - - body.innerHTML = data.entries.map(e => { - const displayFile = (e.blocked_filename || '').replace(/\\/g, '/').split('/').pop() || 'Unknown'; - const svc = e.blocked_username && ['youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'].includes(e.blocked_username) ? e.blocked_username : 'soulseek'; - const icon = serviceIcons[svc] || '🔍'; - const ago = e.created_at ? timeAgo(e.created_at) : ''; - return ` -
    -
    ${icon}
    - - - -
    `; - }).join(''); - - } catch (e) { - document.getElementById('blacklist-modal-body').innerHTML = `
    Error: ${e.message}
    `; - } -} - -async function _removeBlacklistEntry(id, btn) { - if (!await showConfirmDialog({ title: 'Remove from Blacklist', message: 'Allow this source to be used for downloads again?', confirmText: 'Remove' })) return; - try { - const res = await fetch(`/api/library/blacklist/${id}`, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - btn.closest('.blacklist-entry').remove(); - showToast('Removed from blacklist', 'success'); - loadBlacklistCount(); - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - } -} - -const MCACHE_PAGE_SIZE = 48; - -function openMetadataCacheModal() { - const modal = document.getElementById('mcache-browse-modal'); - if (modal) { - modal.style.display = 'flex'; - _mcacheCurrentTab = 'artist'; - _mcachePage = 0; - // Reset UI - document.querySelectorAll('.mcache-tab').forEach(t => t.classList.remove('active')); - document.querySelector('.mcache-tab[data-tab="artist"]')?.classList.add('active'); - const searchInput = document.getElementById('mcache-search'); - if (searchInput) searchInput.value = ''; - const sourceFilter = document.getElementById('mcache-source-filter'); - if (sourceFilter) sourceFilter.value = ''; - const sortFilter = document.getElementById('mcache-sort-filter'); - if (sortFilter) sortFilter.value = 'last_accessed_at'; - loadMetadataCacheBrowseStats(); - loadMetadataCacheBrowse(); - } -} - -function closeMetadataCacheModal() { - const modal = document.getElementById('mcache-browse-modal'); - if (modal) modal.style.display = 'none'; -} - -async function loadMetadataCacheBrowseStats() { - try { - const response = await fetch('/api/metadata-cache/stats'); - if (!response.ok) return; - const stats = await response.json(); - - const el = (id, val) => { - const e = document.getElementById(id); - if (e) e.textContent = val; - }; - - const spotifyTotal = (stats.artists?.spotify || 0) + (stats.albums?.spotify || 0) + (stats.tracks?.spotify || 0); - const itunesTotal = (stats.artists?.itunes || 0) + (stats.albums?.itunes || 0) + (stats.tracks?.itunes || 0); - const deezerTotal = (stats.artists?.deezer || 0) + (stats.albums?.deezer || 0) + (stats.tracks?.deezer || 0); - const beatportTotal = (stats.artists?.beatport || 0) + (stats.albums?.beatport || 0) + (stats.tracks?.beatport || 0); - el('mcache-browse-spotify-count', spotifyTotal); - el('mcache-browse-itunes-count', itunesTotal); - el('mcache-browse-deezer-count', deezerTotal); - el('mcache-browse-beatport-count', beatportTotal); - const discogsTotal = (stats.artists?.discogs || 0) + (stats.albums?.discogs || 0) + (stats.tracks?.discogs || 0); - el('mcache-browse-discogs-count', discogsTotal); - el('mcache-browse-musicbrainz-count', stats.musicbrainz_total || 0); - el('mcache-browse-hits', stats.total_hits || 0); - el('mcache-browse-searches', stats.searches || 0); - } catch (e) { /* ignore */ } -} - -function switchMetadataCacheTab(tab) { - _mcacheCurrentTab = tab; - _mcachePage = 0; - document.querySelectorAll('.mcache-tab').forEach(t => { - t.classList.toggle('active', t.dataset.tab === tab); - }); - loadMetadataCacheBrowse(); -} - -async function loadMetadataCacheBrowse() { - const grid = document.getElementById('mcache-grid'); - if (!grid) return; - - const source = document.getElementById('mcache-source-filter')?.value || ''; - const search = document.getElementById('mcache-search')?.value || ''; - const sort = document.getElementById('mcache-sort-filter')?.value || 'last_accessed_at'; - - grid.innerHTML = '
    ...
    Loading...
    '; - - try { - let data; - if (source === 'musicbrainz') { - // MusicBrainz is a separate cache table — use dedicated endpoint - const params = new URLSearchParams({ - entity_type: _mcacheCurrentTab, - page: _mcachePage + 1, - limit: MCACHE_PAGE_SIZE - }); - if (search) params.set('search', search); - const response = await fetch(`/api/metadata-cache/browse-musicbrainz?${params}`); - if (!response.ok) throw new Error('Failed to load'); - data = await response.json(); - } else { - const params = new URLSearchParams({ - type: _mcacheCurrentTab, - sort: sort, - sort_dir: sort === 'name' ? 'asc' : 'desc', - offset: _mcachePage * MCACHE_PAGE_SIZE, - limit: MCACHE_PAGE_SIZE - }); - if (source) params.set('source', source); - if (search) params.set('search', search); - const response = await fetch(`/api/metadata-cache/browse?${params}`); - if (!response.ok) throw new Error('Failed to load'); - data = await response.json(); - } - - if (!data.items || data.items.length === 0) { - grid.innerHTML = ` -
    -
    📦
    -
    No cached ${_mcacheCurrentTab}s yet
    -
    As you search and browse music in SoulSync, API responses will be cached here automatically.
    -
    `; - renderMetadataCachePagination(0, 0); - return; - } - - renderMetadataCacheGrid(data.items, _mcacheCurrentTab); - renderMetadataCachePagination(data.total, data.offset); - } catch (e) { - grid.innerHTML = '
    Failed to load cache data.
    '; - } -} - -function renderMetadataCacheGrid(items, entityType) { - const grid = document.getElementById('mcache-grid'); - if (!grid) return; - - grid.innerHTML = items.map(item => { - const source = item.source || 'spotify'; - const sourceBadge = `${source}`; - const cacheAge = formatCacheAge(item.last_accessed_at); - const hits = item.access_count || 1; - - let imageHtml = ''; - const isArtist = entityType === 'artist'; - const shapeClass = isArtist ? ' artist' : ''; - - if (item.image_url) { - imageHtml = ``; - } else { - imageHtml = `
    ${(item.name || '?')[0].toUpperCase()}
    `; - } - - let subText = ''; - let metaText = ''; - - if (source === 'musicbrainz') { - subText = item.artist_name || ''; - metaText = item._mb_matched ? `MBID: ${(item._mb_id || '').substring(0, 8)}…` : 'No match found'; - } else if (entityType === 'artist') { - const genres = item.genres ? (typeof item.genres === 'string' ? JSON.parse(item.genres || '[]') : item.genres) : []; - subText = genres.length > 0 ? genres.slice(0, 2).join(', ') : ''; - if (item.popularity) metaText = `Pop: ${item.popularity}`; - } else if (entityType === 'album') { - subText = item.artist_name || ''; - const parts = []; - if (item.release_date) parts.push(item.release_date.substring(0, 4)); - if (item.total_tracks) parts.push(`${item.total_tracks} tracks`); - if (item.album_type) parts.push(item.album_type); - metaText = parts.join(' · '); - } else if (entityType === 'track') { - subText = item.artist_name || ''; - const parts = []; - if (item.album_name) parts.push(item.album_name); - if (item.duration_ms) parts.push(formatDuration(item.duration_ms)); - metaText = parts.join(' · '); - } - - const clickAttr = source === 'musicbrainz' ? '' : `onclick="openMetadataCacheDetail('${source}', '${entityType}', '${encodeURIComponent(item.entity_id)}')"`; - const mbStatusClass = source === 'musicbrainz' ? (item._mb_matched ? ' mb-matched' : ' mb-failed') : ''; - - return ` -
    -
    - ${imageHtml} -
    -
    ${item.name || 'Unknown'}
    - ${subText ? `
    ${subText}
    ` : ''} - ${metaText ? `
    ${metaText}
    ` : ''} -
    -
    -
    - ${sourceBadge} - ${cacheAge} · ${hits}x -
    -
    `; - }).join(''); -} - -function renderMetadataCachePagination(total, offset) { - const container = document.getElementById('mcache-pagination'); - if (!container) return; - - const totalPages = Math.ceil(total / MCACHE_PAGE_SIZE); - const currentPage = Math.floor(offset / MCACHE_PAGE_SIZE); - - if (totalPages <= 1) { - container.innerHTML = total > 0 ? `${total} result${total !== 1 ? 's' : ''}` : ''; - return; - } - - let html = ''; - html += ``; - - const maxVisible = 7; - let start = Math.max(0, currentPage - Math.floor(maxVisible / 2)); - let end = Math.min(totalPages, start + maxVisible); - if (end - start < maxVisible) start = Math.max(0, end - maxVisible); - - if (start > 0) { - html += ``; - if (start > 1) html += `...`; - } - - for (let i = start; i < end; i++) { - html += ``; - } - - if (end < totalPages) { - if (end < totalPages - 1) html += `...`; - html += ``; - } - - html += ``; - html += `${total} total`; - - container.innerHTML = html; -} - -async function openMetadataCacheDetail(source, entityType, entityId) { - const modal = document.getElementById('mcache-detail-modal'); - const body = document.getElementById('mcache-detail-body'); - const title = document.getElementById('mcache-detail-title'); - if (!modal || !body) return; - - modal.style.display = 'flex'; - body.innerHTML = '
    Loading...
    '; - if (title) title.textContent = 'Loading...'; - - try { - const response = await fetch(`/api/metadata-cache/entity/${source}/${entityType}/${entityId}`); - if (!response.ok) throw new Error('Not found'); - const data = await response.json(); - - if (title) title.textContent = data.name || 'Unknown'; - - const isArtist = entityType === 'artist'; - const shapeClass = isArtist ? ' artist' : ''; - let imageHtml = ''; - if (data.image_url) { - imageHtml = ``; - } else { - imageHtml = `
    ${(data.name || '?')[0].toUpperCase()}
    `; - } - - const sourceBadge = `${source}`; - const typeBadge = `${entityType}`; - - // Build structured fields table - let fieldsHtml = ''; - const addRow = (label, value) => { - if (value !== null && value !== undefined && value !== '') { - fieldsHtml += ``; - } - }; - - addRow('Entity ID', data.entity_id); - addRow('Name', data.name); - - if (entityType === 'artist') { - const genres = data.genres ? (typeof data.genres === 'string' ? JSON.parse(data.genres || '[]') : data.genres) : []; - if (genres.length) addRow('Genres', genres.join(', ')); - if (data.popularity) addRow('Popularity', data.popularity); - if (data.followers) addRow('Followers', data.followers.toLocaleString()); - } else if (entityType === 'album') { - addRow('Artist', data.artist_name); - addRow('Release Date', data.release_date); - addRow('Total Tracks', data.total_tracks); - addRow('Album Type', data.album_type); - addRow('Label', data.label); - } else if (entityType === 'track') { - addRow('Artist', data.artist_name); - addRow('Album', data.album_name); - if (data.duration_ms) addRow('Duration', formatDuration(data.duration_ms)); - addRow('Track Number', data.track_number); - addRow('Disc Number', data.disc_number); - addRow('Explicit', data.explicit ? 'Yes' : 'No'); - addRow('ISRC', data.isrc); - if (data.preview_url) addRow('Preview', `Listen`); - } - - fieldsHtml += '
    ${label}${value}
    '; - - // Cache metadata section - let cacheHtml = '
    Cache Metadata
    '; - cacheHtml += ''; - if (data.created_at) cacheHtml += ``; - if (data.last_accessed_at) cacheHtml += ``; - if (data.access_count) cacheHtml += ``; - if (data.ttl_days) cacheHtml += ``; - cacheHtml += '
    Cached At${new Date(data.created_at).toLocaleString()}
    Last Accessed${new Date(data.last_accessed_at).toLocaleString()}
    Access Count${data.access_count}
    TTL${data.ttl_days} days
    '; - - // Raw JSON section - let rawJsonHtml = ''; - if (data.raw_json) { - const rawStr = typeof data.raw_json === 'string' ? data.raw_json : JSON.stringify(data.raw_json, null, 2); - const escapedJson = rawStr.replace(/&/g, '&').replace(//g, '>'); - rawJsonHtml = ` -
    Raw API Response
    - - `; - } - - body.innerHTML = ` -
    - ${imageHtml} -
    -
    ${data.name || 'Unknown'}
    - ${entityType !== 'artist' && data.artist_name ? `
    ${data.artist_name}
    ` : ''} -
    - ${sourceBadge} - ${typeBadge} -
    -
    -
    -
    Details
    - ${fieldsHtml} - ${cacheHtml} - ${rawJsonHtml}`; - } catch (e) { - body.innerHTML = '
    Failed to load entity details.
    '; - } -} - -function closeMetadataCacheDetail() { - const modal = document.getElementById('mcache-detail-modal'); - if (modal) modal.style.display = 'none'; -} - -function toggleMcacheClearDropdown(event) { - event.stopPropagation(); - const menu = document.getElementById('mcache-clear-dropdown-menu'); - if (!menu) return; - const isOpen = menu.style.display === 'block'; - menu.style.display = isOpen ? 'none' : 'block'; - if (!isOpen) { - const closeHandler = (e) => { - if (!e.target.closest('#mcache-clear-dropdown')) { - menu.style.display = 'none'; - document.removeEventListener('click', closeHandler); - } - }; - setTimeout(() => document.addEventListener('click', closeHandler), 0); - } -} - -async function clearMetadataCache() { - if (!confirm('Clear ALL cached metadata? This removes all cached API responses.')) return; - document.getElementById('mcache-clear-dropdown-menu').style.display = 'none'; - - try { - const response = await fetch('/api/metadata-cache/clear', { method: 'DELETE' }); - const data = await response.json(); - if (data.success) { - showToast(`Cleared ${data.cleared} cached entries`, 'success'); - loadMetadataCacheBrowseStats(); - loadMetadataCacheBrowse(); - loadMetadataCacheStats(); - } else { - showToast('Failed to clear cache', 'error'); - } - } catch (e) { - showToast('Error clearing cache', 'error'); - } -} - -async function clearMetadataCacheBySource(source) { - if (!confirm(`Clear all ${source} cached metadata?`)) return; - document.getElementById('mcache-clear-dropdown-menu').style.display = 'none'; - - try { - const response = await fetch(`/api/metadata-cache/clear?source=${source}`, { method: 'DELETE' }); - const data = await response.json(); - if (data.success) { - showToast(`Cleared ${data.cleared} ${source} cache entries`, 'success'); - loadMetadataCacheBrowseStats(); - loadMetadataCacheBrowse(); - loadMetadataCacheStats(); - } else { - showToast(`Failed to clear ${source} cache`, 'error'); - } - } catch (e) { - showToast(`Error clearing ${source} cache`, 'error'); - } -} - -async function clearMusicBrainzCache(failedOnly = false) { - const label = failedOnly ? 'failed MusicBrainz lookups' : 'ALL MusicBrainz cache entries'; - if (!confirm(`Clear ${label}?`)) return; - document.getElementById('mcache-clear-dropdown-menu').style.display = 'none'; - - try { - const url = failedOnly ? '/api/metadata-cache/clear-musicbrainz?failed_only=true' : '/api/metadata-cache/clear-musicbrainz'; - const response = await fetch(url, { method: 'DELETE' }); - const data = await response.json(); - if (data.success) { - showToast(`Cleared ${data.cleared} MusicBrainz cache entries`, 'success'); - loadMetadataCacheBrowseStats(); - loadMetadataCacheBrowse(); - loadMetadataCacheStats(); - } else { - showToast('Failed to clear MusicBrainz cache', 'error'); - } - } catch (e) { - showToast('Error clearing MusicBrainz cache', 'error'); - } -} - -function debouncedMetadataCacheSearch() { - if (_mcacheSearchTimeout) clearTimeout(_mcacheSearchTimeout); - _mcacheSearchTimeout = setTimeout(() => { - _mcachePage = 0; - loadMetadataCacheBrowse(); - }, 400); -} - -function formatCacheAge(timestamp) { - if (!timestamp) return '—'; - const now = new Date(); - const then = new Date(timestamp); - const diffMs = now - then; - const diffMin = Math.floor(diffMs / 60000); - if (diffMin < 1) return 'now'; - if (diffMin < 60) return `${diffMin}m`; - const diffHr = Math.floor(diffMin / 60); - if (diffHr < 24) return `${diffHr}h`; - const diffDays = Math.floor(diffHr / 24); - if (diffDays < 30) return `${diffDays}d`; - return `${Math.floor(diffDays / 30)}mo`; -} - -// ============================================ -// == TOOL HELP MODAL == -// ============================================ - -const TOOL_HELP_CONTENT = { - 'db-updater': { - title: 'Database Updater', - content: ` -

    What does this tool do?

    -

    The Database Updater syncs your media server library (Plex, Jellyfin, or Navidrome) with SoulSync's internal database.

    - -

    Update Modes

    -
      -
    • Incremental Update: Only scans for new artists, albums, and tracks that have been added since the last update. Fast and efficient for regular updates.
    • -
    • Full Refresh: Completely rebuilds the database from scratch. Use this if you've made significant changes to your library or if data seems out of sync.
    • -
    - -

    When to use it?

    -
      -
    • After adding new music to your media server
    • -
    • When library statistics seem incorrect
    • -
    • After changing media server settings
    • -
    - -

    Progress Persistence

    -

    The update runs in the background. You can close this page and return later - progress will be preserved and continue where it left off.

    - ` - }, - 'metadata-updater': { - title: 'Metadata Updater', - content: ` -

    What does this tool do?

    -

    The Metadata Updater triggers all enrichment workers simultaneously, re-checking every item in your library against all connected services (Spotify, MusicBrainz, iTunes, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz).

    - -

    Refresh Interval Options

    -
      -
    • 6 months: Only updates metadata for artists not updated in the last 180 days
    • -
    • 3 months: Updates metadata for artists not updated in the last 90 days
    • -
    • 1 month: Updates metadata for artists not updated in the last 30 days
    • -
    • Force All: Updates all artists regardless of when they were last updated
    • -
    - -

    What gets updated?

    -
      -
    • Artist profile photos, genres, and descriptions
    • -
    • Album cover artwork, labels, and release info
    • -
    • Track ISRCs, explicit flags, and external IDs
    • -
    • Service match status for all 9 enrichment workers
    • -
    - -

    Note

    -

    Available for Plex and Jellyfin media servers. Each enrichment worker only runs if its service is authenticated.

    - ` - }, - 'quality-scanner': { - title: 'Quality Scanner', - content: ` -

    What does this tool do?

    -

    The Quality Scanner identifies tracks in your library that don't meet your preferred quality settings and automatically matches them to Spotify to add to your wishlist for re-downloading.

    - -

    Scan Scope

    -
      -
    • Watchlist Artists Only: Only scans tracks from artists you're watching. Faster and more focused.
    • -
    • All Library Tracks: Scans your entire music library. Comprehensive but takes longer.
    • -
    - -

    How it works

    -
      -
    1. Scans tracks and checks file format against your quality preferences
    2. -
    3. Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)
    4. -
    5. Uses fuzzy matching to find the track on Spotify (70% confidence minimum)
    6. -
    7. Automatically adds matched tracks to your wishlist for re-download
    8. -
    - -

    Quality Tiers

    -
      -
    • Tier 1 (Best): FLAC, WAV, ALAC, AIFF - Lossless formats
    • -
    • Tier 2: OPUS, OGG - High quality lossy
    • -
    • Tier 3: M4A, AAC - Standard lossy
    • -
    • Tier 4: MP3, WMA - Lower quality lossy
    • -
    - -

    Stats Explained

    -
      -
    • Processed: Total tracks scanned so far
    • -
    • Quality Met: Tracks that meet your quality standards
    • -
    • Low Quality: Tracks below your quality threshold
    • -
    • Matched: Low quality tracks successfully matched to Spotify and added to wishlist
    • -
    - ` - }, - 'duplicate-cleaner': { - title: 'Duplicate Cleaner', - content: ` -

    What does this tool do?

    -

    The Duplicate Cleaner scans your output folder for duplicate audio files and automatically removes lower-quality versions, keeping only the best copy.

    - -

    How it detects duplicates

    -

    Files are considered duplicates when:

    -
      -
    • They are in the same folder
    • -
    • They have the exact same filename (ignoring file extension)
    • -
    -

    Example: Song.flac and Song.mp3 in the same folder = duplicates ✓

    -

    Example: Song.flac and Song (Remaster).flac = NOT duplicates ✗

    - -

    Which file is kept?

    -

    Priority order (best to worst):

    -
      -
    1. Format priority: FLAC/Lossless > OPUS/OGG > M4A/AAC > MP3/WMA
    2. -
    3. If same format: Larger file size is kept (usually indicates better bitrate)
    4. -
    - -

    Where do deleted files go?

    -

    Removed files are moved to Transfer/deleted/ folder (not permanently deleted). You can review and recover them if needed.

    - -

    Safety Features

    -
      -
    • Only processes audio files (FLAC, MP3, M4A, etc.)
    • -
    • Only removes files with identical names in the same folder
    • -
    • Files are moved, not deleted - fully recoverable
    • -
    • Preserves original folder structure in the deleted folder
    • -
    - -

    Stats Explained

    -
      -
    • Files Scanned: Total audio files checked
    • -
    • Duplicates Found: Number of duplicate files detected
    • -
    • Deleted: Files moved to deleted folder
    • -
    • Space Freed: Total disk space reclaimed
    • -
    - ` - }, - 'media-scan': { - title: 'Media Server Scan', - content: ` -

    What does this tool do?

    -

    The Media Server Scan tool manually triggers a Plex media library scan to detect newly downloaded music files.

    - -

    When to use it?

    -
      -
    • After downloading new tracks to refresh your Plex library
    • -
    • When new music isn't showing up in Plex
    • -
    • To force an immediate library update instead of waiting for auto-scan
    • -
    - -

    What happens when you scan?

    -
      -
    1. Plex library scan: Plex scans your music folder for new/changed files
    2. -
    3. Automatic database update: After the scan completes, SoulSync automatically updates its internal database with new tracks
    4. -
    5. Library refreshed: New music appears in Plex and SoulSync within moments
    6. -
    - -

    Plex only?

    -

    Yes! This tool only appears when Plex is your active media server because:

    -
      -
    • Jellyfin automatically detects new files instantly (real-time monitoring)
    • -
    • Navidrome automatically detects new files instantly (real-time monitoring)
    • -
    • Plex requires manual scans or has delayed auto-scanning
    • -
    - -

    Stats Explained

    -
      -
    • Last Scan: Time of the most recent scan request
    • -
    • Status: Current scan state (Idle, Scanning, Error)
    • -
    - -

    Scan workflow

    -

    This tool replicates the same scan process that runs automatically after completing a download modal - ensuring your new tracks are immediately available in your library!

    - ` - }, - 'retag-tool': { - title: 'Retag Tool', - content: ` -

    What does this tool do?

    -

    The Retag Tool lets you fix metadata on files that have already been downloaded and processed. If an album was tagged with wrong metadata, you can search for the correct match and re-apply tags.

    - -

    How it works

    -
      -
    • Browse your past downloads organized by artist
    • -
    • Expand an album or single to see individual tracks
    • -
    • Click Retag to search for the correct album match
    • -
    • Select the right album and confirm — metadata and file paths are updated automatically
    • -
    - -

    What gets updated?

    -
      -
    • File tags: Title, artist, album, track number, genre, cover art
    • -
    • File paths: Files are moved/renamed to match new metadata (based on your path template)
    • -
    • Cover art: cover.jpg is updated in the album folder
    • -
    - -

    Stats Explained

    -
      -
    • Groups: Number of album/single download groups tracked
    • -
    • Tracks: Total individual track files tracked
    • -
    • Artists: Number of unique artists across all groups
    • -
    - -

    Notes

    -
      -
    • Only album and single downloads are tracked (not playlists)
    • -
    • Deleting a group from the list does not delete the files
    • -
    • Only one retag operation can run at a time
    • -
    - ` - }, - 'discover-page': { - title: 'Discover Page Guide', - content: ` -

    What is the Discover page?

    -

    The Discover page is your personalized music discovery hub. It uses your watchlist, library listening history, and MusicMap to surface new music, create curated playlists, and organize your collection in dynamic ways.

    - -

    🎯 Hero Section (Featured Artists)

    -

    The rotating hero showcases similar artists discovered via MusicMap. These are artists you don't already have in your library but might enjoy based on your watchlist.

    -
      -
    • Auto-rotates every 8 seconds through 10 featured artists
    • -
    • Similar artists sourced from MusicMap and matched to Spotify
    • -
    • Click arrows to navigate manually
    • -
    • Add artists to watchlist or view their full discography
    • -
    • Data refreshed when watchlist scanner runs
    • -
    - -

    📀 Recent Releases

    -

    New albums from artists you're watching and their MusicMap similar artists. Cached from Spotify and updated during watchlist scans.

    -
      -
    • Shows up to 20 recent albums
    • -
    • Click any album to view tracks and add to wishlist
    • -
    • Automatically filtered to show albums released in the last 90 days
    • -
    • Includes both watchlist artists and similar artists from MusicMap
    • -
    - -

    🍂 Seasonal Content (Auto-detected)

    -

    Seasonal albums and playlists that appear automatically based on the current season (Winter, Spring, Summer, Fall).

    -
      -
    • Seasonal Albums: Albums matching the current season's vibe
    • -
    • Seasonal Playlist: Curated playlist that refreshes with each season
    • -
    • Only visible during the matching season
    • -
    • Can download and sync to your media server
    • -
    - -

    🎵 Fresh Tape (Release Radar)

    -

    Curated playlist of brand new releases from your discovery pool. Focuses on tracks released in the past 30 days.

    -
      -
    • 50 tracks, refreshed weekly by watchlist scanner
    • -
    • Stays consistent until next update (not random)
    • -
    • Download missing tracks or sync to media server
    • -
    • Tracks from watchlist artists and MusicMap similar artists
    • -
    - -

    📚 The Archives (Discovery Weekly)

    -

    Curated playlist from your entire discovery pool - a mix of new and catalog tracks from MusicMap discoveries.

    -
      -
    • 50 tracks, refreshed weekly by watchlist scanner
    • -
    • Stays consistent until next update (not random)
    • -
    • Broader selection than Fresh Tape (includes older releases)
    • -
    • Download missing tracks or sync to media server
    • -
    - -

    📊 Personalized Library Playlists

    -

    Playlists generated from your existing library using listening statistics:

    -
      -
    • Recently Added: Latest 50 tracks added to your library
    • -
    • Your Top 50: All-time most played tracks (requires play count data)
    • -
    • Forgotten Favorites: Tracks you loved but haven't played recently
    • -
    - -

    🎲 Discovery Pool Playlists

    -

    Playlists generated from your discovery pool (tracks from watchlist/similar artists you don't own yet):

    -
      -
    • Popular Picks: High-popularity tracks (Spotify popularity 70+)
    • -
    • Hidden Gems: Underground discoveries (Spotify popularity <40)
    • -
    • Discovery Shuffle: 50 random tracks - different every time you load
    • -
    • Familiar Favorites: Reliable, mid-popularity tracks (40-70)
    • -
    - -

    🎨 Build a Playlist

    -

    Create custom playlists using MusicMap similar artists from seed artists you select.

    -
      -
    • Search for any artist on Spotify (even if not in your library)
    • -
    • Select 1-5 seed artists
    • -
    • Choose playlist size: 25, 50, 75, or 100 tracks
    • -
    • Uses cached MusicMap similar artists from your database
    • -
    • Pulls albums from those similar artists to build the playlist
    • -
    • Download and sync like any other discover playlist
    • -
    - -

    🧠 ListenBrainz Playlists

    -

    Access playlists from your ListenBrainz account (requires ListenBrainz authentication).

    -
      -
    • Created For You: Playlists generated by ListenBrainz for you
    • -
    • Your Playlists: Playlists you've created on ListenBrainz
    • -
    • Collaborative: Collaborative playlists you're part of
    • -
    • Cached locally for performance - click Refresh to update from ListenBrainz
    • -
    • Click any playlist to view tracks and download/sync
    • -
    - -

    ⏰ Time Machine (Browse by Decade)

    -

    Explore your discovery pool organized by release decade.

    -
      -
    • Dynamically generated tabs for decades with available content (1950s-2020s)
    • -
    • Each decade shows up to 100 tracks from that era
    • -
    • Great for discovering older catalog releases from your favorite artists
    • -
    - -

    🎵 Browse by Genre

    -

    Explore your discovery pool filtered by music genre.

    -
      -
    • Shows top genres from your discovery pool
    • -
    • Click any genre tab to see up to 100 tracks in that genre
    • -
    • Genres sourced from Spotify metadata
    • -
    - -

    💾 What is the Discovery Pool?

    -

    The discovery pool is a database of tracks from:

    -
      -
    • Artists in your watchlist
    • -
    • Similar artists found via MusicMap
    • -
    • Populated during watchlist scanner runs (scrapes music-map.com, matches to Spotify)
    • -
    • Filtered to exclude tracks already in your library
    • -
    • Used to generate Fresh Tape, The Archives, and discovery pool playlists
    • -
    • Caches up to 50 top similar artists across your watchlist
    • -
    - -

    🗺️ How MusicMap Integration Works

    -

    SoulSync uses MusicMap (music-map.com) instead of Spotify's recommendation API to find similar artists:

    -
      -
    • During watchlist scans, each watchlist artist is looked up on MusicMap
    • -
    • MusicMap's artist similarity graph is scraped to find related artists
    • -
    • Similar artist names are matched to Spotify IDs
    • -
    • Up to 10 similar artists per watchlist artist are cached (refreshed every 30 days)
    • -
    • These cached similar artists power all discovery features
    • -
    • This approach gives you more diverse, community-driven recommendations
    • -
    - -

    ⬇️ Download & Sync Features

    -

    Most discover playlists support two actions:

    -
      -
    • Download: Opens modal to match tracks to Soulseek and add to download queue
    • -
    • Sync: Downloads tracks and automatically transfers them to your media server
    • -
    • Sync progress persists - you can close the page and it continues in the background
    • -
    • Sync status shows: ✓ completed, ⏳ pending, ✗ failed
    • -
    - -

    🔄 When is data refreshed?

    -
      -
    • MusicMap Similar Artists: Fetched during watchlist scans, cached for 30 days
    • -
    • Hero, Recent Releases, Fresh Tape, The Archives: Updated during watchlist scanner runs (Dashboard page)
    • -
    • Discovery Pool: Fully refreshed every 24 hours during watchlist scans (50 top similar artists, 10 albums each)
    • -
    • Seasonal Content: Auto-detected based on current date
    • -
    • Personalized Library Playlists: Generated on-demand from current library data
    • -
    • Discovery Pool Playlists: Generated on-demand from current discovery pool
    • -
    • Build a Playlist: Generated on-demand from cached MusicMap similar artists
    • -
    • ListenBrainz: Cached locally, manually refreshed via Refresh button
    • -
    • Time Machine & Genre: Generated on-demand from current discovery pool
    • -
    - -

    💡 Pro Tips

    -
      -
    • Curated playlists (Fresh Tape, The Archives) stay consistent until next watchlist scan - great for weekly listening routines
    • -
    • Discovery Shuffle changes every page load - perfect when you want spontaneous recommendations
    • -
    • Use Build a Playlist to explore artists not in your watchlist (if seed artist isn't in watchlist, MusicMap data must be cached first)
    • -
    • The discovery pool only includes tracks you don't own yet - download them to build your collection!
    • -
    • Sync feature is ideal for batch downloading entire playlists to your media server
    • -
    • MusicMap provides more diverse recommendations than Spotify's algorithm - expect deeper cuts and underground artists!
    • -
    • Add more artists to your watchlist to expand your discovery pool with their MusicMap similar artists
    • -
    - ` - }, - - // ==================== Automation Trigger Help ==================== - - 'auto-schedule': { - title: 'Schedule Timer', - content: ` -

    What is this trigger?

    -

    Runs your automation on a repeating interval — every X minutes, hours, or days.

    - -

    Configuration

    -
      -
    • Interval: How often to repeat (e.g. every 6 hours)
    • -
    • Unit: Minutes, Hours, or Days
    • -
    - -

    When does it first run?

    -

    The timer starts when SoulSync boots. If the automation was previously scheduled, it resumes from where it left off.

    - -

    Good for

    -
      -
    • Regular wishlist processing (every 30 minutes)
    • -
    • Periodic database backups (every 12 hours)
    • -
    • Any recurring maintenance task
    • -
    - ` - }, - 'auto-daily_time': { - title: 'Daily Time', - content: ` -

    What is this trigger?

    -

    Runs your automation once per day at a specific time.

    - -

    Configuration

    -
      -
    • Time: The wall-clock time to run (e.g. 03:00 for 3 AM)
    • -
    - -

    Good for

    -
      -
    • Nightly watchlist scans
    • -
    • Off-peak database updates
    • -
    • Daily backups at a consistent time
    • -
    - ` - }, - 'auto-weekly_time': { - title: 'Weekly Schedule', - content: ` -

    What is this trigger?

    -

    Runs your automation on specific days of the week at a set time.

    - -

    Configuration

    -
      -
    • Days: Select one or more days (Mon–Sun)
    • -
    • Time: The time to run on those days
    • -
    - -

    Good for

    -
      -
    • Weekend-only quality scans
    • -
    • Weekly playlist refreshes
    • -
    • Scheduled maintenance on quiet days
    • -
    - ` - }, - 'auto-app_started': { - title: 'App Started', - content: ` -

    What is this trigger?

    -

    Fires once when SoulSync starts up. Useful for tasks you want to run on every boot.

    - -

    Good for

    -
      -
    • Refreshing mirrored playlists on startup
    • -
    • Running a quick database sync
    • -
    • Sending a "SoulSync is online" notification
    • -
    - -

    Note

    -

    This trigger fires only once per startup — it will not fire again until SoulSync is restarted.

    - ` - }, - 'auto-track_downloaded': { - title: 'Track Downloaded', - content: ` -

    What is this trigger?

    -

    Fires every time a single track finishes downloading and post-processing (tagging, moving to library).

    - -

    Conditions

    -

    You can filter which downloads trigger this automation:

    -
      -
    • Artist: Only fire for specific artists
    • -
    • Title: Match on track title
    • -
    • Album: Match on album name
    • -
    • Quality: Match on file format (FLAC, MP3, etc.)
    • -
    - -

    Available variables for notifications

    -

    {artist}, {title}, {album}, {quality}

    - -

    Note

    -

    This fires per-track, not per-album. For an album with 12 tracks, it fires 12 times. Use Batch Complete if you want one event per album.

    - ` - }, - 'auto-batch_complete': { - title: 'Batch Complete', - content: ` -

    What is this trigger?

    -

    Fires when an entire album or playlist download batch finishes — all tracks in the batch are done (whether successful or failed).

    - -

    Conditions

    -
      -
    • Playlist name: Filter by the name of the album or playlist
    • -
    - -

    Available variables for notifications

    -

    {playlist_name}, {total_tracks}, {completed_tracks}, {failed_tracks}

    - -

    Good for

    -
      -
    • Triggering a media server scan after downloads finish
    • -
    • Sending a notification when an album is fully downloaded
    • -
    • Running a database update after new content arrives
    • -
    - ` - }, - 'auto-watchlist_new_release': { - title: 'New Release Found', - content: ` -

    What is this trigger?

    -

    Fires when the watchlist scanner detects new music from an artist you're watching. This means a new album, EP, or single has been released that you don't already have.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific watched artists
    • -
    - -

    Available variables for notifications

    -

    {artist}, {new_tracks}, {added_to_wishlist}

    - -

    Good for

    -
      -
    • Getting notified when your favorite artists drop new music
    • -
    • Auto-processing the wishlist immediately after new releases are found
    • -
    - ` - }, - 'auto-playlist_synced': { - title: 'Playlist Synced', - content: ` -

    What is this trigger?

    -

    Fires after a mirrored playlist is synced to your media server (Plex, Jellyfin, or Navidrome). This means the playlist has been matched and created/updated on your server.

    - -

    Conditions

    -
      -
    • Playlist name: Only fire for specific playlists
    • -
    - -

    Available variables for notifications

    -

    {playlist_name}, {total_tracks}, {matched_tracks}, {synced_tracks}, {failed_tracks}

    - ` - }, - 'auto-playlist_changed': { - title: 'Playlist Changed', - content: ` -

    What is this trigger?

    -

    Fires when a mirrored playlist detects that the source playlist (on Spotify, Tidal, YouTube, etc.) has changed — tracks were added or removed.

    - -

    Conditions

    -
      -
    • Playlist name: Only fire for specific playlists
    • -
    - -

    Available variables for notifications

    -

    {playlist_name}, {old_count}, {new_count}, {added}, {removed}

    - -

    Good for

    -
      -
    • Auto-discovering new tracks after a playlist updates
    • -
    • Auto-syncing the playlist to your media server
    • -
    • Getting notified when your followed playlists change
    • -
    - ` - }, - 'auto-discovery_completed': { - title: 'Discovery Complete', - content: ` -

    What is this trigger?

    -

    Fires when Spotify/iTunes metadata discovery finishes for a mirrored playlist. Discovery is the process of matching playlist tracks to official Spotify or iTunes metadata.

    - -

    Conditions

    -
      -
    • Playlist name: Only fire for specific playlists
    • -
    - -

    Available variables for notifications

    -

    {playlist_name}, {total_tracks}, {discovered_count}, {failed_count}, {skipped_count}

    - -

    Good for

    -
      -
    • Auto-syncing a playlist after discovery completes
    • -
    • Getting notified about discovery results (how many matched vs failed)
    • -
    - ` - }, - 'auto-wishlist_processing_completed': { - title: 'Wishlist Processed', - content: ` -

    What is this trigger?

    -

    Fires when the auto-wishlist processing batch finishes. This is the automated download cycle that searches Soulseek for wishlist tracks.

    - -

    Available variables for notifications

    -

    {tracks_processed}, {tracks_found}, {tracks_failed}

    - ` - }, - 'auto-watchlist_scan_completed': { - title: 'Watchlist Scan Done', - content: ` -

    What is this trigger?

    -

    Fires when the watchlist artist scan completes. The scan checks all watched artists for new releases and adds new tracks to your wishlist.

    - -

    Available variables for notifications

    -

    {artists_scanned}, {new_tracks_found}, {tracks_added}

    - ` - }, - 'auto-database_update_completed': { - title: 'Database Updated', - content: ` -

    What is this trigger?

    -

    Fires when the library database refresh finishes — either incremental or full. This means SoulSync's internal database has been synced with your media server.

    - -

    Available variables for notifications

    -

    {total_artists}, {total_albums}, {total_tracks}

    - -

    Good for

    -
      -
    • Running a quality scan after the database is refreshed
    • -
    • Sending a summary notification with library stats
    • -
    - ` - }, - 'auto-download_failed': { - title: 'Download Failed', - content: ` -

    What is this trigger?

    -

    Fires when a track permanently fails to download. This means all retry attempts and sources have been exhausted.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific artists
    • -
    • Title: Match on track title
    • -
    • Reason: Match on failure reason
    • -
    - -

    Available variables for notifications

    -

    {artist}, {title}, {reason}

    - ` - }, - 'auto-download_quarantined': { - title: 'File Quarantined', - content: ` -

    What is this trigger?

    -

    Fires when a downloaded file fails AcoustID verification and is moved to the quarantine folder. This means the audio fingerprint didn't match what was expected — the file might be the wrong song.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific artists
    • -
    • Title: Match on track title
    • -
    - -

    Available variables for notifications

    -

    {artist}, {title}, {reason}

    - -

    What is quarantine?

    -

    Files that fail audio fingerprint verification are moved to a quarantine folder instead of your library. This prevents wrong songs from polluting your collection. You can review quarantined files manually.

    - ` - }, - 'auto-wishlist_item_added': { - title: 'Wishlist Item Added', - content: ` -

    What is this trigger?

    -

    Fires when a track is added to your wishlist — whether manually, by the quality scanner, or by the watchlist scan.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific artists
    • -
    • Title: Match on track title
    • -
    - -

    Available variables for notifications

    -

    {artist}, {title}, {reason}

    - ` - }, - 'auto-watchlist_artist_added': { - title: 'Artist Watched', - content: ` -

    What is this trigger?

    -

    Fires when an artist is added to your watchlist. Watched artists are periodically scanned for new releases.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific artists
    • -
    - -

    Available variables for notifications

    -

    {artist}, {artist_id}

    - ` - }, - 'auto-watchlist_artist_removed': { - title: 'Artist Unwatched', - content: ` -

    What is this trigger?

    -

    Fires when an artist is removed from your watchlist.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific artists
    • -
    - -

    Available variables for notifications

    -

    {artist}, {artist_id}

    - ` - }, - 'auto-import_completed': { - title: 'Import Complete', - content: ` -

    What is this trigger?

    -

    Fires when an album or track import operation finishes. Imports bring music from external sources into your library.

    - -

    Conditions

    -
      -
    • Artist: Only fire for specific artists
    • -
    • Album name: Match on album name
    • -
    - -

    Available variables for notifications

    -

    {track_count}, {album_name}, {artist}

    - ` - }, - 'auto-mirrored_playlist_created': { - title: 'Playlist Mirrored', - content: ` -

    What is this trigger?

    -

    Fires when a new playlist mirror is created — a playlist from Spotify, Tidal, YouTube, ListenBrainz, or Beatport is set up for mirroring.

    - -

    Conditions

    -
      -
    • Playlist name: Match on playlist name
    • -
    • Source: Match on platform (spotify, tidal, youtube, etc.)
    • -
    - -

    Available variables for notifications

    -

    {playlist_name}, {source}, {track_count}

    - -

    Good for

    -
      -
    • Auto-discovering tracks immediately after a new mirror is created
    • -
    • Getting notified when new playlists are mirrored
    • -
    - ` - }, - 'auto-quality_scan_completed': { - title: 'Quality Scan Done', - content: ` -

    What is this trigger?

    -

    Fires when the quality scanner finishes. The scanner identifies tracks below your quality preferences and adds them to your wishlist for re-downloading.

    - -

    Available variables for notifications

    -

    {quality_met}, {low_quality}, {total_scanned}

    - ` - }, - 'auto-duplicate_scan_completed': { - title: 'Duplicate Scan Done', - content: ` -

    What is this trigger?

    -

    Fires when the duplicate cleaner finishes scanning your output folder for duplicate audio files.

    - -

    Available variables for notifications

    -

    {files_scanned}, {duplicates_found}, {space_freed}

    - ` - }, - 'auto-library_scan_completed': { - title: 'Library Scan Done', - content: ` -

    What is this trigger?

    -

    Fires when a media server library scan is considered complete. This only happens after a Scan Library action was triggered — it cannot fire on its own.

    - -

    How does it know the scan is done?

    -

    Your media server (Plex, Jellyfin, Navidrome) doesn't send a "scan finished" signal back to SoulSync. So after telling the server to scan, SoulSync waits approximately 5 minutes and then assumes the scan has finished. This is a generous estimate that works for most libraries.

    - -

    Timing

    -

    From the moment a download finishes to when this trigger fires, expect roughly 6-7 minutes:

    -
      -
    1. 60 second debounce wait (groups multiple downloads together)
    2. -
    3. Media server scan triggered
    4. -
    5. ~5 minute wait (assumed scan completion)
    6. -
    7. This event fires
    8. -
    - -

    Default use

    -

    The system automation Auto-Update Database After Scan listens for this trigger to start an incremental database update, keeping your SoulSync library in sync with your media server.

    - -

    Available variables

    -

    {server_type} — which media server was scanned (plex, jellyfin, navidrome)

    - ` - }, - - // ==================== Automation Action Help ==================== - - 'auto-process_wishlist': { - title: 'Process Wishlist', - content: ` -

    What does this action do?

    -

    Searches Soulseek for tracks in your wishlist and downloads them. This is the same process that runs automatically on a timer — this action lets you trigger it manually or chain it to events.

    - -

    Configuration

    -
      -
    • Category: Process all wishlist tracks, or only Albums/EPs, or only Singles
    • -
    - -

    How it works

    -
      -
    1. Picks tracks from the wishlist (alternating Albums and Singles cycles)
    2. -
    3. Searches Soulseek for each track
    4. -
    5. Downloads the best quality match found
    6. -
    7. Tags and moves files to your library
    8. -
    - ` - }, - 'auto-scan_watchlist': { - title: 'Scan Watchlist', - content: ` -

    What does this action do?

    -

    Checks all watched artists for new releases you don't already have. New tracks are automatically added to your wishlist for downloading.

    - -

    How it works

    -
      -
    1. Goes through each artist in your watchlist
    2. -
    3. Fetches their discography from Spotify
    4. -
    5. Compares against your library to find missing releases
    6. -
    7. Adds new tracks to your wishlist
    8. -
    - ` - }, - 'auto-scan_library': { - title: 'Scan Library', - content: ` -

    What does this action do?

    -

    Tells your media server (Plex, Jellyfin, or Navidrome) to scan its music library folder for new or changed files. This makes newly downloaded music appear in your media server.

    - -

    How it works

    -
      -
    1. A 60 second debounce groups rapid requests — if multiple downloads finish close together, only one scan is triggered
    2. -
    3. After the debounce, your media server is told to scan
    4. -
    5. SoulSync waits ~5 minutes (your media server doesn't report when it's finished, so this is an assumed completion time)
    6. -
    7. The Library Scan Done event fires, which can trigger follow-up actions like a database update
    8. -
    - -

    Default use

    -

    The system automation Auto-Scan After Downloads uses this action to automatically scan your library when a batch download completes. You can disable that automation if you prefer to scan manually.

    - -

    Note

    -

    Jellyfin and Navidrome often detect new files automatically, but the scan ensures nothing is missed.

    - ` - }, - 'auto-refresh_mirrored': { - title: 'Refresh Mirrored Playlist', - content: ` -

    What does this action do?

    -

    Re-fetches a mirrored playlist from its source platform (Spotify, Tidal, YouTube, etc.) and updates the local mirror with any track changes.

    - -

    Configuration

    -
      -
    • Playlist: Select a specific mirrored playlist, or check "Refresh all" to update all mirrors
    • -
    - -

    Good for

    -
      -
    • Keeping mirrors in sync with playlists that change frequently
    • -
    • Detecting added/removed tracks on the source platform
    • -
    - ` - }, - 'auto-sync_playlist': { - title: 'Sync Playlist', - content: ` -

    What does this action do?

    -

    Syncs a mirrored playlist to your media server. It matches discovered tracks against your library and creates or updates the playlist on Plex, Jellyfin, or Navidrome.

    - -

    Configuration

    -
      -
    • Playlist: Select which mirrored playlist to sync
    • -
    - -

    Prerequisites

    -

    Tracks should be discovered first (matched to Spotify/iTunes metadata) before syncing. Undiscovered tracks will be skipped.

    - ` - }, - 'auto-discover_playlist': { - title: 'Discover Playlist', - content: ` -

    What does this action do?

    -

    Finds official Spotify or iTunes metadata for tracks in a mirrored playlist. This is required before syncing — it matches each track to a known release so it can be found in your library.

    - -

    Configuration

    -
      -
    • Playlist: Select a specific playlist, or check "Discover all" to process all mirrored playlists
    • -
    - -

    How it works

    -
      -
    1. Takes each track name and artist from the mirror
    2. -
    3. Searches Spotify (or iTunes as fallback) for a match
    4. -
    5. Stores the best match with confidence score in the discovery cache
    6. -
    7. Already-discovered tracks are skipped for efficiency
    8. -
    - ` - }, - 'auto-playlist_pipeline': { - title: 'Playlist Pipeline', - content: ` -

    What does this action do?

    -

    Runs the full playlist lifecycle in one automation — no signal wiring needed. Executes four phases sequentially:

    -
      -
    1. Refresh — Re-fetches playlist tracks from the source platform (Spotify, Tidal, YouTube, Deezer)
    2. -
    3. Discover — Matches each track to official metadata (Spotify/iTunes/Deezer IDs)
    4. -
    5. Sync — Pushes the playlist to your media server (Plex, Jellyfin, Navidrome)
    6. -
    7. Download Missing — Queues unmatched tracks to the wishlist for automatic download
    8. -
    - -

    Configuration

    -
      -
    • Playlist: Select a specific mirrored playlist, or check "Process all" to run the pipeline for every mirrored playlist
    • -
    • Skip wishlist: Check this to skip the download phase (useful if you only want to sync, not download)
    • -
    - -

    How the re-sync loop works

    -

    Set this on a schedule (e.g., every 6 hours). Between runs, the wishlist processor downloads missing tracks in the background. On the next pipeline run, those newly downloaded tracks will match during the sync phase — so your server playlist gets more complete with each cycle until fully synced.

    - -

    Replaces

    -

    This single automation replaces the 4-automation signal chain pattern (Refresh → signal → Discover → signal → Sync → signal → Download). No signals, no chaining, no room for misconfiguration.

    - ` - }, - 'auto-notify_only': { - title: 'Notify Only', - content: ` -

    What does this action do?

    -

    Nothing — it performs no action. It just passes the event data through to the notification step.

    - -

    Good for

    -
      -
    • Getting notified about events without taking any automated action
    • -
    • Monitoring what's happening in SoulSync (downloads, failures, changes)
    • -
    • Pair with any event trigger + Discord/Telegram/Pushbullet notification
    • -
    - ` - }, - 'auto-start_database_update': { - title: 'Update Database', - content: ` -

    What does this action do?

    -

    Refreshes SoulSync's internal library database by syncing with your media server (Plex, Jellyfin, or Navidrome).

    - -

    Configuration

    -
      -
    • Full refresh: When checked, completely rebuilds the database from scratch. When unchecked, only scans for new content (faster).
    • -
    - ` - }, - 'auto-run_duplicate_cleaner': { - title: 'Run Duplicate Cleaner', - content: ` -

    What does this action do?

    -

    Scans your output folder for duplicate audio files (same filename, different format) and removes the lower-quality version. For example, if you have both Song.flac and Song.mp3, the MP3 is removed.

    - -

    Safety

    -

    Removed files are moved to a deleted/ subfolder, not permanently deleted. You can recover them if needed.

    - ` - }, - 'auto-clear_quarantine': { - title: 'Clear Quarantine', - content: ` -

    What does this action do?

    -

    Permanently deletes all files in the quarantine folder. Quarantined files are downloads that failed AcoustID audio fingerprint verification — they might be the wrong song.

    - -

    Warning

    -

    This permanently deletes files. Make sure you've reviewed quarantined files before setting up an automation for this.

    - ` - }, - 'auto-cleanup_wishlist': { - title: 'Clean Up Wishlist', - content: ` -

    What does this action do?

    -

    Removes duplicate entries and tracks you already own from your wishlist. Keeps the wishlist lean by removing items that no longer need downloading.

    - ` - }, - 'auto-update_discovery_pool': { - title: 'Update Discovery Pool', - content: ` -

    What does this action do?

    -

    Refreshes the discovery pool with new tracks from your mirrored playlists. The discovery pool tracks which playlist tracks have been successfully matched and which ones failed.

    - ` - }, - 'auto-start_quality_scan': { - title: 'Run Quality Scan', - content: ` -

    What does this action do?

    -

    Scans your library for tracks that don't meet your quality preferences (e.g., MP3 when you prefer FLAC). Low-quality tracks are matched to Spotify and added to your wishlist for re-downloading in better quality.

    - -

    Configuration

    -
      -
    • Scope: Scan only watchlist artists (faster) or your entire library (thorough)
    • -
    - ` - }, - 'auto-backup_database': { - title: 'Backup Database', - content: ` -

    What does this action do?

    -

    Creates a timestamped backup of SoulSync's SQLite database. Uses the SQLite backup API for a safe hot-copy while the app is running.

    - -

    Retention

    -

    Keeps the last 5 backups automatically. Older backups are cleaned up to save disk space.

    - -

    Good for

    -
      -
    • Nightly automated backups
    • -
    • Pre-update safety backups
    • -
    • Peace of mind for your library data
    • -
    - ` - }, - 'auto-refresh_beatport_cache': { - title: 'Refresh Beatport Cache', - content: ` -

    What does this action do?

    -

    Scrapes the Beatport homepage for top charts and caches the results locally. Keeps the Beatport charts page loading instantly without needing to scrape on every visit.

    - -

    Cache duration

    -

    Cache lasts 24 hours. This action refreshes it early so it's always warm when you visit the charts page.

    - -

    Good for

    -
      -
    • Keeping Beatport charts available instantly
    • -
    • Scheduling daily cache refreshes (e.g. every morning)
    • -
    - ` - }, - 'auto-clean_search_history': { - title: 'Clean Search History', - content: ` -

    What does this action do?

    -

    Removes old search queries from Soulseek. This keeps your search history clean and prevents buildup over time.

    - -

    Good for

    -
      -
    • Periodic housekeeping
    • -
    • Keeping Soulseek search history tidy
    • -
    - ` - }, - 'auto-clean_completed_downloads': { - title: 'Clean Completed Downloads', - content: ` -

    What does this action do?

    -

    Clears completed downloads from the transfer list and removes any empty directories left behind in the import folder.

    - -

    Good for

    -
      -
    • Automatic cleanup after batch downloads
    • -
    • Preventing import folder clutter
    • -
    • Chaining after a batch complete trigger
    • -
    - ` - }, - 'auto-full_cleanup': { - title: 'Full Cleanup', - content: ` -

    What does this action do?

    -

    Runs all housekeeping tasks in a single sweep:

    -
      -
    1. Clear Quarantine — permanently deletes all quarantined files
    2. -
    3. Clear Download Queue — removes completed, errored, and cancelled downloads from Soulseek
    4. -
    5. Sweep Empty Directories — removes empty folders left behind in the input directory
    6. -
    7. Sweep Import Folder — removes empty directories from the import folder
    8. -
    9. Clean Search History — trims old Soulseek search queries
    10. -
    - -

    Safety

    -

    Skips download queue cleanup if batches are actively downloading or post-processing. Each step runs independently — a failure in one step won't stop the others.

    - -

    Good for

    -
      -
    • Scheduled housekeeping every 12 hours
    • -
    • Keeping disk usage and queue clutter under control
    • -
    • Running after large batch downloads complete
    • -
    - ` - }, - 'auto-deep_scan_library': { - title: 'Deep Scan Library', - content: ` -

    What does this action do?

    -

    Walks your entire media server library and compares it against SoulSync's database. Adds any new tracks found and removes stale entries that no longer exist on the server.

    - -

    How is this different from Database Update?

    -
      -
    • Database Update: Incremental — only looks for new artists/albums added since last update
    • -
    • Deep Scan: Full comparison — checks every track on the server against the database, catches anything missed
    • -
    - -

    Safety

    -
      -
    • Never overwrites existing enrichment data (genres, Spotify IDs, artwork)
    • -
    • Only inserts tracks that don't already exist in the database
    • -
    • Stale track removal has a 50% safety threshold — if more than half the library appears missing, removal is skipped
    • -
    - ` - }, - - // ==================== Notification/Then-Action Help ==================== - - 'auto-discord_webhook': { - title: 'Discord Webhook', - content: ` -

    What does this then-action do?

    -

    Sends a notification to a Discord channel via webhook when the automation's action completes.

    - -

    Configuration

    -
      -
    • Webhook URL: The Discord webhook URL for your channel (found in Channel Settings → Integrations → Webhooks)
    • -
    • Message Template: Custom message with variable placeholders
    • -
    - -

    Available variables

    -

    Use these in your message template:

    -
      -
    • {time} — When the automation ran
    • -
    • {name} — Automation name
    • -
    • {run_count} — How many times this automation has run
    • -
    • {status} — Result status of the action
    • -
    - ` - }, - 'auto-pushbullet': { - title: 'Pushbullet', - content: ` -

    What does this then-action do?

    -

    Sends a push notification to your phone or desktop via Pushbullet when the automation's action completes.

    - -

    Configuration

    -
      -
    • API Key: Your Pushbullet access token (found in Pushbullet Settings → Access Tokens)
    • -
    • Message Template: Custom message with variable placeholders
    • -
    - -

    Available variables

    -

    Use these in your message template:

    -
      -
    • {time} — When the automation ran
    • -
    • {name} — Automation name
    • -
    • {run_count} — How many times this automation has run
    • -
    • {status} — Result status of the action
    • -
    - ` - }, - 'auto-telegram': { - title: 'Telegram', - content: ` -

    What does this then-action do?

    -

    Sends a message to a Telegram chat via bot when the automation's action completes.

    - -

    Configuration

    -
      -
    • Bot Token: Your Telegram bot token (from @BotFather)
    • -
    • Chat ID: The chat/group ID to send messages to
    • -
    • Message Template: Custom message with variable placeholders
    • -
    - -

    Available variables

    -

    Use these in your message template:

    -
      -
    • {time} — When the automation ran
    • -
    • {name} — Automation name
    • -
    • {run_count} — How many times this automation has run
    • -
    • {status} — Result status of the action
    • -
    - ` - }, - - 'auto-webhook': { - title: 'Webhook (POST)', - content: ` -

    What does this then-action do?

    -

    Sends an HTTP POST request with a JSON payload to any URL when the automation's action completes. Use it to integrate with Gotify, Home Assistant, Slack, n8n, or any service that accepts webhooks.

    - -

    Configuration

    -
      -
    • URL: The endpoint to POST to (e.g. https://gotify.example.com/message?token=xxx)
    • -
    • Headers: Optional custom headers, one per line in Key: Value format. Useful for auth tokens.
    • -
    • Custom Message: Optional message with variable placeholders. Added as a "message" field in the JSON payload.
    • -
    - -

    JSON payload

    -

    The POST body always includes all event variables as JSON fields:

    -
    {"time": "2026-04-02 ...", "name": "My Automation", "status": "success", ...}
    - -

    Available variables

    -

    Use these in your message or header values:

    -
      -
    • {time} — When the automation ran
    • -
    • {name} — Automation name
    • -
    • {run_count} — How many times this automation has run
    • -
    • {status} — Result status of the action
    • -
    - ` - }, - - // ==================== Signal System Help ==================== - - 'auto-signal_received': { - title: 'Signal Received', - content: ` -

    What is this trigger?

    -

    Fires when another automation sends a named signal using the Fire Signal then-action. This lets you chain automations together — one automation finishes and wakes up another.

    - -

    Configuration

    -
      -
    • Signal Name: The name to listen for (e.g. library_ready, scan_done). Must match the name used in the Fire Signal action.
    • -
    - -

    How chaining works

    -
      -
    1. Automation A: Trigger = Batch Complete, Action = Scan Library, Then = Fire Signal "scan_done"
    2. -
    3. Automation B: Trigger = Signal Received "scan_done", Action = Update Database
    4. -
    5. When a download finishes → A scans library → fires signal → B wakes up → updates database
    6. -
    - -

    Safety

    -
      -
    • Circular signal chains are detected and blocked when you save
    • -
    • Maximum chain depth of 5 levels to prevent runaway cascades
    • -
    • Same signal can only fire once every 10 seconds (cooldown)
    • -
    - -

    Signal names

    -

    Use descriptive lowercase names with underscores: library_ready, scan_complete, downloads_done. Existing signal names from other automations appear as suggestions.

    - ` - }, - 'auto-fire_signal': { - title: 'Fire Signal', - content: ` -

    What does this then-action do?

    -

    Fires a named signal after the automation's action completes. Any other automation with a Signal Received trigger listening for this signal name will wake up and run.

    - -

    Configuration

    -
      -
    • Signal Name: The signal to fire (e.g. library_ready). Use the same name in a Signal Received trigger on another automation to connect them.
    • -
    - -

    Use cases

    -
      -
    • Multi-step workflows: Scan library → fire signal → update database → fire signal → send notification
    • -
    • Fan-out: One signal can trigger multiple automations simultaneously
    • -
    • Decoupled logic: Keep each automation simple with one job, chain them via signals
    • -
    - -

    Combining with notifications

    -

    You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.

    - ` - }, - 'backup-manager': { - title: 'Backup Manager', - content: ` -

    What does this tool do?

    -

    The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.

    - -

    Features

    -
      -
    • Backup Now: Create an instant backup of the current database using SQLite's hot-copy API
    • -
    • Download: Download any backup file to your local machine
    • -
    • Restore: Roll back the database to a previous backup state
    • -
    • Delete: Remove old backups you no longer need
    • -
    - -

    Auto-Backups

    -

    SoulSync automatically creates a backup every 3 days via the automation engine. Up to 5 rolling backups are kept (oldest are removed when the limit is exceeded).

    - -

    Restore Safety

    -

    When you restore from a backup, a safety backup of your current database is created first. This means you can always undo a restore if something goes wrong.

    - -

    Stats Explained

    -
      -
    • Last Backup: When the most recent backup was created
    • -
    • Backups: Total number of backup files available
    • -
    • Latest Size: Size of the most recent backup
    • -
    • DB Size: Current size of the live database
    • -
    - ` - }, - 'metadata-cache': { - title: 'Metadata Cache', - content: ` -

    What is this?

    -

    The Metadata Cache stores every API response from Spotify and iTunes so SoulSync can reuse them instead of making duplicate API calls. This reduces rate limit pressure and speeds up lookups.

    - -

    How it works

    -

    When SoulSync fetches artist, album, or track data from Spotify or iTunes, the response is cached locally. The next time the same data is needed, it's served from cache instantly — no API call required. Cached data is even served during Spotify rate limit bans.

    - -

    Browsing the Cache

    -

    Click Browse Cache to explore all cached metadata. You can filter by entity type (artists, albums, tracks), search by name, filter by source (Spotify/iTunes), and sort by different fields. Click any card to see full details including the raw API response.

    - -

    Cache Management

    -
      -
    • TTL: Entities expire after 30 days, search mappings after 7 days
    • -
    • Eviction: Expired entries are automatically cleaned up
    • -
    • Clear: You can clear the entire cache or filter by source/type
    • -
    - -

    Stats Explained

    -
      -
    • Artists: Total cached artist profiles
    • -
    • Albums: Total cached album records
    • -
    • Tracks: Total cached track records
    • -
    • Hits: Total number of times cached data was served instead of making an API call
    • -
    - ` - } -}; - -function initializeToolHelpButtons() { - const helpButtons = document.querySelectorAll('.tool-help-button'); - const modal = document.getElementById('tool-help-modal'); - const closeButton = modal.querySelector('.tool-help-modal-close'); - - // Attach click handlers to all help buttons - helpButtons.forEach(button => { - button.addEventListener('click', (e) => { - e.stopPropagation(); - const toolId = button.getAttribute('data-tool'); - openToolHelpModal(toolId); - }); - }); - - // Close modal when clicking close button - closeButton.addEventListener('click', closeToolHelpModal); - - // Close modal when clicking outside content - modal.addEventListener('click', (e) => { - if (e.target === modal) { - closeToolHelpModal(); - } - }); - - // Close modal on Escape key - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && modal.classList.contains('active')) { - closeToolHelpModal(); - } - }); -} - -function openToolHelpModal(toolId) { - const modal = document.getElementById('tool-help-modal'); - const titleElement = document.getElementById('tool-help-modal-title'); - const bodyElement = document.getElementById('tool-help-modal-body'); - - const helpData = TOOL_HELP_CONTENT[toolId]; - if (!helpData) { - console.warn(`No help content found for tool: ${toolId}`); - return; - } - - titleElement.textContent = helpData.title; - bodyElement.innerHTML = helpData.content; - - modal.classList.add('active'); - document.body.style.overflow = 'hidden'; // Prevent background scrolling -} - -function closeToolHelpModal() { - const modal = document.getElementById('tool-help-modal'); - if (modal) modal.classList.remove('active'); - document.body.style.overflow = ''; // Restore scrolling -} -// Global Escape key handler for tool help modal (works even if Tools page wasn't visited) -document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - const modal = document.getElementById('tool-help-modal'); - if (modal && modal.classList.contains('active')) closeToolHelpModal(); - } -}); - -// =============================== -// == RETAG TOOL FUNCTIONS == -// =============================== - -let retagStatusInterval = null; -let retagCurrentGroupId = null; - -async function loadRetagStats() { - try { - const response = await fetch('/api/retag/stats'); - const data = await response.json(); - if (data.success !== false) { - const groupsEl = document.getElementById('retag-stat-groups'); - const tracksEl = document.getElementById('retag-stat-tracks'); - const artistsEl = document.getElementById('retag-stat-artists'); - if (groupsEl) groupsEl.textContent = data.groups || 0; - if (tracksEl) tracksEl.textContent = data.tracks || 0; - if (artistsEl) artistsEl.textContent = data.artists || 0; - } - } catch (e) { - console.warn('Failed to load retag stats:', e); - } -} - -async function openRetagModal() { - const modal = document.getElementById('retag-modal'); - if (!modal) return; - modal.style.display = 'flex'; - document.body.style.overflow = 'hidden'; - - // Reset batch bar and clear-all button - const batchBar = document.getElementById('retag-batch-bar'); - if (batchBar) batchBar.style.display = 'none'; - const clearBtn = document.getElementById('retag-clear-all-btn'); - if (clearBtn) { clearBtn.textContent = 'Clear All'; clearBtn.dataset.confirming = ''; clearBtn.style.background = ''; } - - const body = document.getElementById('retag-modal-body'); - body.innerHTML = '
    Loading downloads...
    '; - - try { - const response = await fetch('/api/retag/groups'); - const data = await response.json(); - if (!data.success || !data.groups || data.groups.length === 0) { - body.innerHTML = '

    No downloads recorded yet. Downloads will appear here after completing album or single downloads.

    '; - if (clearBtn) clearBtn.style.display = 'none'; - return; - } - if (clearBtn) clearBtn.style.display = ''; - renderRetagGroups(data.groups, body); - } catch (e) { - body.innerHTML = '

    Failed to load downloads.

    '; - } -} - -function closeRetagModal() { - const modal = document.getElementById('retag-modal'); - if (modal) modal.style.display = 'none'; - document.body.style.overflow = ''; -} - -function renderRetagGroups(groups, container) { - // Group by artist_name - const byArtist = {}; - groups.forEach(g => { - const artist = g.artist_name || 'Unknown Artist'; - if (!byArtist[artist]) byArtist[artist] = []; - byArtist[artist].push(g); - }); - - let html = ''; - Object.keys(byArtist).sort((a, b) => a.localeCompare(b)).forEach(artist => { - html += `
    -

    ${escapeHtml(artist)}

    -
    `; - - byArtist[artist].forEach(group => { - const imgHtml = group.image_url - ? `` - : '
    '; - const trackCount = group.track_count || group.total_tracks || 0; - const typeLabel = (group.group_type || 'album').charAt(0).toUpperCase() + (group.group_type || 'album').slice(1); - const releaseDate = group.release_date ? group.release_date.substring(0, 4) : ''; - const defaultQuery = (artist + ' ' + (group.album_name || '')).trim(); - - html += `
    -
    - - ${imgHtml} -
    - ${escapeHtml(group.album_name || 'Unknown')} - ${typeLabel}${releaseDate ? ' \u00b7 ' + releaseDate : ''} \u00b7 ${trackCount} track${trackCount !== 1 ? 's' : ''} -
    - -
    - -
    -
    - -
    `; - }); - - html += `
    `; - }); - - container.innerHTML = html; - _attachRetagDelegation(container); -} - -function _attachRetagDelegation(container) { - // Single click handler for all retag group interactions - container.addEventListener('click', (e) => { - const target = e.target; - - // Skip checkbox wrapper clicks — handled by change listener - if (target.closest('.retag-group-checkbox')) return; - - // Retag button - const retagBtn = target.closest('.retag-group-btn'); - if (retagBtn) { - e.stopPropagation(); - const groupId = parseInt(retagBtn.dataset.groupId); - const header = retagBtn.closest('.retag-group-header'); - const defaultQuery = header ? header.dataset.defaultQuery || '' : ''; - openRetagSearch(groupId, defaultQuery); - return; - } - - // Delete confirm buttons (dynamically injected) - const confirmYes = target.closest('.retag-confirm-yes'); - if (confirmYes) { - e.stopPropagation(); - const card = confirmYes.closest('.retag-group-card'); - if (card) executeRetagGroupDelete(parseInt(card.dataset.groupId)); - return; - } - const confirmNo = target.closest('.retag-confirm-no'); - if (confirmNo) { - e.stopPropagation(); - const card = confirmNo.closest('.retag-group-card'); - if (card) cancelRetagDeleteConfirm(parseInt(card.dataset.groupId)); - return; - } - - // Delete button - const delBtn = target.closest('.retag-group-delete-btn'); - if (delBtn) { - e.stopPropagation(); - showRetagDeleteConfirm(parseInt(delBtn.dataset.groupId)); - return; - } - - // Group header click (expand/collapse) - const header = target.closest('.retag-group-header'); - if (header) { - toggleRetagGroup(parseInt(header.dataset.groupId)); - return; - } - }); - - // Separate change handler for checkboxes - container.addEventListener('change', (e) => { - if (e.target.classList.contains('retag-select-cb')) { - updateRetagBatchBar(); - } - }); -} - -async function toggleRetagGroup(groupId) { - const tracksDiv = document.getElementById(`retag-tracks-${groupId}`); - if (!tracksDiv) return; - - if (tracksDiv.style.display === 'none') { - tracksDiv.style.display = 'block'; - if (tracksDiv.querySelector('.retag-tracks-loading')) { - try { - const response = await fetch(`/api/retag/groups/${groupId}/tracks`); - const data = await response.json(); - if (data.success && data.tracks && data.tracks.length > 0) { - tracksDiv.innerHTML = data.tracks.map(t => { - const discPrefix = t.disc_number > 1 ? `${t.disc_number}-` : ''; - const trackNum = t.track_number != null ? `${discPrefix}${String(t.track_number).padStart(2, '0')}` : '--'; - return `
    - ${trackNum} - ${escapeHtml(t.title || 'Unknown')} - ${(t.file_format || '').toUpperCase()} -
    `; - }).join(''); - } else { - tracksDiv.innerHTML = '

    No tracks found

    '; - } - } catch (e) { - tracksDiv.innerHTML = '

    Failed to load tracks

    '; - } - } - } else { - tracksDiv.style.display = 'none'; - } -} - -function openRetagSearch(groupId, defaultQuery) { - retagCurrentGroupId = groupId; - const modal = document.getElementById('retag-search-modal'); - if (!modal) return; - modal.style.display = 'flex'; - - const input = document.getElementById('retag-search-input'); - if (input) { - input.value = defaultQuery || ''; - input.focus(); - if (defaultQuery) { - searchRetagAlbums(defaultQuery); - } - } -} - -function closeRetagSearch() { - const modal = document.getElementById('retag-search-modal'); - if (modal) modal.style.display = 'none'; - retagCurrentGroupId = null; -} - -let retagSearchTimeout = null; -document.addEventListener('DOMContentLoaded', () => { - const retagSearchInput = document.getElementById('retag-search-input'); - if (retagSearchInput) { - retagSearchInput.addEventListener('input', (e) => { - clearTimeout(retagSearchTimeout); - retagSearchTimeout = setTimeout(() => searchRetagAlbums(e.target.value), 400); - }); - retagSearchInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - clearTimeout(retagSearchTimeout); - searchRetagAlbums(e.target.value); - } - }); - } - - // Close retag modals on escape - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - const searchModal = document.getElementById('retag-search-modal'); - if (searchModal && searchModal.style.display === 'flex') { - closeRetagSearch(); - return; - } - const mainModal = document.getElementById('retag-modal'); - if (mainModal && mainModal.style.display === 'flex') { - closeRetagModal(); - } - } - }); - - // Close retag modal on overlay click - const retagModal = document.getElementById('retag-modal'); - if (retagModal) { - retagModal.addEventListener('click', (e) => { - if (e.target === retagModal) closeRetagModal(); - }); - } - const retagSearchModal = document.getElementById('retag-search-modal'); - if (retagSearchModal) { - retagSearchModal.addEventListener('click', (e) => { - if (e.target === retagSearchModal) closeRetagSearch(); - }); - } -}); - -async function searchRetagAlbums(query) { - if (!query || !query.trim()) return; - const resultsDiv = document.getElementById('retag-search-results'); - if (!resultsDiv) return; - resultsDiv.innerHTML = '
    Searching...
    '; - - try { - const response = await fetch(`/api/retag/search?q=${encodeURIComponent(query.trim())}`); - const data = await response.json(); - if (data.success && data.albums && data.albums.length > 0) { - resultsDiv.innerHTML = data.albums.map(a => { - const imgHtml = a.image_url - ? `` - : '
    '; - const typeLabel = (a.album_type || 'album').charAt(0).toUpperCase() + (a.album_type || 'album').slice(1); - const releaseYear = a.release_date ? a.release_date.substring(0, 4) : ''; - return `
    - ${imgHtml} -
    - ${escapeHtml(a.name || 'Unknown')} - ${escapeHtml(a.artist || 'Unknown')} - ${typeLabel}${releaseYear ? ' \u00b7 ' + releaseYear : ''} \u00b7 ${a.total_tracks || 0} tracks -
    -
    `; - }).join(''); - } else { - resultsDiv.innerHTML = '

    No albums found.

    '; - } - } catch (e) { - resultsDiv.innerHTML = '

    Search failed.

    '; - } -} - -/** - * Show inline confirmation on a search result before retagging - */ -function showRetagConfirm(el, groupId, albumId, albumName) { - // Clear any other confirming states - document.querySelectorAll('.retag-search-result.retag-confirming').forEach(r => { - r.classList.remove('retag-confirming'); - const bar = r.querySelector('.retag-result-confirm-bar'); - if (bar) bar.remove(); - r.onclick = r._originalOnclick || null; - }); - - el.classList.add('retag-confirming'); - el._originalOnclick = el.onclick; - el.onclick = null; // Disable clicking the row again - - const confirmBar = document.createElement('div'); - confirmBar.className = 'retag-result-confirm-bar'; - confirmBar.innerHTML = ` - Re-tag with "${escapeHtml(albumName)}"? -
    - - -
    - `; - el.appendChild(confirmBar); -} - -function cancelRetagConfirm(cancelBtn) { - const result = cancelBtn.closest('.retag-search-result'); - if (!result) return; - result.classList.remove('retag-confirming'); - const bar = result.querySelector('.retag-result-confirm-bar'); - if (bar) bar.remove(); - if (result._originalOnclick) { - result.onclick = result._originalOnclick; - } -} - -async function executeRetag(groupId, albumId, albumName) { - - closeRetagSearch(); - closeRetagModal(); - - try { - const response = await fetch('/api/retag/execute', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_id: groupId, album_id: albumId }) - }); - const data = await response.json(); - if (data.success) { - showToast('Retag operation started', 'success'); - startRetagPolling(); - } else { - showToast(`Error: ${data.error || 'Unknown error'}`, 'error'); - } - } catch (e) { - showToast('Failed to start retag operation', 'error'); - } -} - -function startRetagPolling() { - if (retagStatusInterval) return; - retagStatusInterval = setInterval(checkRetagStatus, 1000); - checkRetagStatus(); -} - -async function checkRetagStatus() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/retag/status'); - const state = await response.json(); - updateRetagProgressUI(state); - - if (state.status === 'running' && !retagStatusInterval) { - startRetagPolling(); - } - - if (state.status !== 'running' && retagStatusInterval) { - clearInterval(retagStatusInterval); - retagStatusInterval = null; - if (state.status === 'finished') { - showToast('Retag completed successfully', 'success'); - loadRetagStats(); - } else if (state.status === 'error') { - showToast(`Retag error: ${state.error_message || 'Unknown error'}`, 'error'); - } - } - } catch (e) { - // Ignore fetch errors during polling - } -} - -function updateRetagStatusFromData(data) { - const prev = _lastToolStatus['retag']; - _lastToolStatus['retag'] = data.status; - if (prev !== undefined && data.status === prev && data.status !== 'running') return; - updateRetagProgressUI(data); - // Handle terminal state toasts (only on transition) - if (prev === 'running' || prev === undefined) { - if (data.status === 'finished') { - showToast('Retag completed successfully', 'success'); - loadRetagStats(); - } else if (data.status === 'error') { - showToast(`Retag error: ${data.error_message || 'Unknown error'}`, 'error'); - } - } -} - -function updateRetagProgressUI(state) { - const phaseLabel = document.getElementById('retag-phase-label'); - const progressBar = document.getElementById('retag-progress-bar'); - const progressLabel = document.getElementById('retag-progress-label'); - const statusEl = document.getElementById('retag-stat-status'); - - if (phaseLabel) phaseLabel.textContent = state.phase || 'Ready'; - if (progressBar) progressBar.style.width = `${state.progress || 0}%`; - if (progressLabel) { - progressLabel.textContent = `${state.processed || 0} / ${state.total_tracks || 0} tracks (${(state.progress || 0).toFixed(1)}%)`; - } - if (statusEl) { - statusEl.textContent = state.status === 'running' ? 'Running' : 'Idle'; - } - - // Color the progress bar red on error - if (progressBar) { - progressBar.style.backgroundColor = state.status === 'error' ? '#ff4444' : ''; - } -} - -/** - * Show inline delete confirmation for a retag group - */ -function showRetagDeleteConfirm(groupId) { - const area = document.getElementById(`retag-delete-area-${groupId}`); - if (!area) return; - area.innerHTML = `
    - Remove? - - -
    `; -} - -function cancelRetagDeleteConfirm(groupId) { - const area = document.getElementById(`retag-delete-area-${groupId}`); - if (!area) return; - area.innerHTML = ``; -} - -async function executeRetagGroupDelete(groupId) { - try { - const response = await fetch(`/api/retag/groups/${groupId}`, { method: 'DELETE' }); - const data = await response.json(); - if (data.success) { - const card = document.querySelector(`.retag-group-card[data-group-id="${groupId}"]`); - if (card) { - const section = card.closest('.retag-artist-section'); - card.remove(); - if (section && section.querySelectorAll('.retag-group-card').length === 0) { - section.remove(); - } - } - loadRetagStats(); - updateRetagBatchBar(); - showToast('Group removed', 'success'); - } else { - showToast('Failed to remove group', 'error'); - } - } catch (e) { - showToast('Failed to remove group', 'error'); - } -} - -/** - * Update the retag batch action bar based on checkbox selection - */ -function updateRetagBatchBar() { - const checked = document.querySelectorAll('.retag-select-cb:checked'); - const bar = document.getElementById('retag-batch-bar'); - const countEl = document.getElementById('retag-batch-count'); - if (!bar) return; - - if (checked.length > 0) { - bar.style.display = 'flex'; - countEl.textContent = `${checked.length} selected`; - } else { - bar.style.display = 'none'; - } -} - -/** - * Batch remove selected retag groups - */ -async function batchRemoveRetagGroups() { - const checked = document.querySelectorAll('.retag-select-cb:checked'); - if (checked.length === 0) return; - - const groupIds = Array.from(checked).map(cb => parseInt(cb.getAttribute('data-group-id'))); - - try { - const response = await fetch('/api/retag/groups/delete-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_ids: groupIds }) - }); - const data = await response.json(); - if (data.success) { - showToast(`Removed ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success'); - openRetagModal(); // Refresh - } else { - showToast('Failed to remove groups', 'error'); - } - } catch (e) { - showToast('Failed to remove groups', 'error'); - } -} - -/** - * Clear all retag groups — inline confirm on the button itself - */ -function clearAllRetagGroups(btn) { - if (!btn) return; - if (btn.dataset.confirming === 'true') { - // Already confirming — execute - btn.dataset.confirming = ''; - btn.textContent = 'Clear All'; - executeClearAllRetag(); - return; - } - // First click — show confirm state - btn.dataset.confirming = 'true'; - btn.textContent = 'Confirm Clear?'; - btn.style.background = 'rgba(255, 59, 48, 0.15)'; - // Auto-reset after 3 seconds if not clicked again - setTimeout(() => { - if (btn.dataset.confirming === 'true') { - btn.dataset.confirming = ''; - btn.textContent = 'Clear All'; - btn.style.background = ''; - } - }, 3000); -} - -async function executeClearAllRetag() { - try { - const response = await fetch('/api/retag/groups/clear-all', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - const data = await response.json(); - if (data.success) { - showToast(`Cleared ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success'); - openRetagModal(); // Refresh - } else { - showToast('Failed to clear groups', 'error'); - } - } catch (e) { - showToast('Failed to clear groups', 'error'); - } -} - -function stopWishlistCountPolling() { - if (wishlistCountInterval) { - clearInterval(wishlistCountInterval); - wishlistCountInterval = null; - } -} - - - -function resetWishlistModalToIdleState() { - // Reset wishlist modal to idle state after background processing completes - const playlistId = 'wishlist'; - const process = activeDownloadProcesses[playlistId]; - - if (process) { - console.log('🔄 Resetting wishlist modal to idle state...'); - - // Reset button states - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); - if (beginBtn) { - beginBtn.style.display = 'inline-block'; - beginBtn.disabled = false; - beginBtn.textContent = 'Begin Analysis'; - } - if (cancelBtn) { - cancelBtn.style.display = 'none'; - } - - // Show the force download toggle again - const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); - if (forceToggleContainer) { - forceToggleContainer.style.display = 'flex'; - } - - // Reset progress displays - const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`); - const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`); - const downloadText = document.getElementById(`download-progress-text-${playlistId}`); - const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`); - - if (analysisText) analysisText.textContent = 'Ready to start'; - if (analysisBar) analysisBar.style.width = '0%'; - if (downloadText) downloadText.textContent = 'Waiting for analysis'; - if (downloadBar) downloadBar.style.width = '0%'; - - // Reset all track rows to pending state - const trackRows = document.querySelectorAll(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index]`); - trackRows.forEach((row, index) => { - const matchCell = row.querySelector(`#match-${playlistId}-${index}`); - const downloadCell = row.querySelector(`#download-${playlistId}-${index}`); - const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`); - - if (matchCell) matchCell.textContent = '🔍 Pending'; - if (downloadCell) downloadCell.textContent = '-'; - if (actionsCell) actionsCell.innerHTML = '-'; - }); - - // Reset stats - const foundElement = document.getElementById(`stat-found-${playlistId}`); - const missingElement = document.getElementById(`stat-missing-${playlistId}`); - const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`); - if (foundElement) foundElement.textContent = '-'; - if (missingElement) missingElement.textContent = '-'; - if (downloadedElement) downloadedElement.textContent = '0'; - - // Reset process status - process.status = 'idle'; - process.batchId = null; - if (process.poller) { - clearInterval(process.poller); - process.poller = null; - } - - console.log('✅ Wishlist modal fully reset to idle state'); - } else { - console.log('⚠️ No wishlist process found to reset'); - } -} - -let toolsPageState = { isInitialized: false }; - -async function initializeToolsPage() { - // Attach event listeners for tool buttons (idempotent — getElementById returns null if already wired) - const updateButton = document.getElementById('db-update-button'); - if (updateButton && !updateButton._toolsWired) { - updateButton.addEventListener('click', handleDbUpdateButtonClick); - updateButton._toolsWired = true; - } - - const metadataButton = document.getElementById('metadata-update-button'); - if (metadataButton && !metadataButton._toolsWired) { - metadataButton.addEventListener('click', handleMetadataUpdateButtonClick); - metadataButton._toolsWired = true; - } - - const qualityScanButton = document.getElementById('quality-scan-button'); - if (qualityScanButton && !qualityScanButton._toolsWired) { - qualityScanButton.addEventListener('click', handleQualityScanButtonClick); - qualityScanButton._toolsWired = true; - } - - const duplicateCleanButton = document.getElementById('duplicate-clean-button'); - if (duplicateCleanButton && !duplicateCleanButton._toolsWired) { - duplicateCleanButton.addEventListener('click', handleDuplicateCleanButtonClick); - duplicateCleanButton._toolsWired = true; - } - - const retagOpenButton = document.getElementById('retag-open-button'); - if (retagOpenButton && !retagOpenButton._toolsWired) { - retagOpenButton.addEventListener('click', openRetagModal); - retagOpenButton._toolsWired = true; - } - - const mediaScanButton = document.getElementById('media-scan-button'); - if (mediaScanButton && !mediaScanButton._toolsWired) { - mediaScanButton.addEventListener('click', handleMediaScanButtonClick); - mediaScanButton._toolsWired = true; - } - - const backupNowButton = document.getElementById('backup-now-button'); - if (backupNowButton && !backupNowButton._toolsWired) { - backupNowButton.addEventListener('click', handleBackupNowClick); - backupNowButton._toolsWired = true; - } - - // Tool-specific init - await checkAndHideMetadataUpdaterForNonPlex(); - await checkAndRestoreMetadataUpdateState(); - await checkAndShowMediaScanForPlex(); - loadBackupList(); - initializeToolHelpButtons(); - loadRetagStats(); - checkRetagStatus(); - await fetchAndUpdateDbStats(); - loadDiscoveryPoolStats(); - loadMetadataCacheStats(); - - // Start polling (cleared when navigating away via loadPageData preamble) - stopDbStatsPolling(); - dbStatsInterval = setInterval(fetchAndUpdateDbStats, 10000); - - // Check for ongoing operations - await checkAndUpdateDbProgress(); - await checkAndUpdateQualityScanProgress(); - await checkAndUpdateDuplicateCleanProgress(); - - // Initialize library maintenance section - updateRepairStatus(); - switchRepairTab('jobs'); - - toolsPageState.isInitialized = true; -} - -async function loadDashboardData() { - // Initial load of wishlist count - await updateWishlistCount(); - - // Start periodic refresh of wishlist count (every 10 seconds) - stopWishlistCountPolling(); // Ensure no duplicates - wishlistCountInterval = setInterval(updateWishlistCount, 10000); - - // Initial load of service status, system statistics, and library status - await fetchAndUpdateServiceStatus(); - await fetchAndUpdateSystemStats(); - await fetchAndUpdateDbStats(); - - // Service status is already polled globally (line 311) - // System stats polling kept here (dashboard-specific) - setInterval(fetchAndUpdateSystemStats, 10000); - - // Initial load of activity feed - await fetchAndUpdateActivityFeed(); - - // Start periodic refresh of activity feed (every 2 seconds for responsiveness) - setInterval(fetchAndUpdateActivityFeed, 2000); - - // Start periodic toast checking (every 3 seconds) - setInterval(checkForActivityToasts, 3000); - - // Check for any active download processes that need rehydration - await checkForActiveProcesses(); - - // Populate the Active Downloads dashboard section with any existing downloads - updateDashboardDownloads(); - - // Automatic wishlist processing now runs server-side -} - -// --- Data Fetching and UI Updates --- - -async function fetchAndUpdateDbStats() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/database/stats'); - if (!response.ok) return; - - const stats = await response.json(); - - // This function updates the stat cards in the top grid - updateDashboardStatCards(stats); - - // This function updates the info within the DB Updater tool card - updateDbUpdaterCardInfo(stats); - - } catch (error) { - console.warn('Could not fetch DB stats:', error); - } -} - -function updateDashboardStatCards(stats) { - // Update the Library Status card on the dashboard - updateLibraryStatusCard(stats); -} - -/** - * Smart Library Status card on the Dashboard. - * Shows different states: no server, empty library, healthy library, scanning. - */ -function updateLibraryStatusCard(dbStats) { - const card = document.getElementById('library-status-card'); - if (!card) return; - - const title = document.getElementById('library-status-title'); - const subtitle = document.getElementById('library-status-subtitle'); - const statsRow = document.getElementById('library-status-stats'); - const scanBtn = document.getElementById('library-status-scan-btn'); - const scanLabel = document.getElementById('library-status-scan-label'); - const deepBtn = document.getElementById('library-status-deep-btn'); - const progressDiv = document.getElementById('library-status-progress'); - const messageDiv = document.getElementById('library-status-message'); - - const artists = dbStats ? (dbStats.artists || 0) : 0; - const albums = dbStats ? (dbStats.albums || 0) : 0; - const tracks = dbStats ? (dbStats.tracks || 0) : 0; - const sizeMb = dbStats ? (dbStats.database_size_mb || 0) : 0; - const lastUpdate = dbStats ? dbStats.last_update : null; - const serverSource = dbStats ? dbStats.server_source : null; - - // Check if a scan is in progress - const isScanning = window._libraryStatusScanning || false; - - // Determine state - const serverConnected = _lastServiceStatus && _lastServiceStatus.media_server && _lastServiceStatus.media_server.connected; - const serverType = _lastServiceStatus && _lastServiceStatus.active_media_server; - const hasData = tracks > 0; - const hasServer = !!serverType && serverType !== 'none'; - - // Reset classes - card.className = 'library-status-card'; - - if (isScanning) { - // State: Scanning - card.classList.add('scanning'); - if (title) title.textContent = 'Library Scan'; - if (subtitle) subtitle.textContent = 'Updating library database...'; - if (scanBtn) { - scanBtn.style.display = ''; - scanBtn.classList.add('scanning'); - scanLabel.textContent = 'Stop'; - scanBtn.disabled = false; - } - if (deepBtn) deepBtn.style.display = 'none'; - if (statsRow) statsRow.style.display = hasData ? '' : 'none'; - if (progressDiv) progressDiv.style.display = ''; - if (messageDiv) messageDiv.style.display = 'none'; - - } else if (!hasServer) { - // State: No server configured - card.classList.add('needs-setup'); - if (title) title.textContent = 'No Media Server'; - if (subtitle) subtitle.textContent = 'Connect a server to get started'; - if (scanBtn) scanBtn.style.display = 'none'; - if (deepBtn) deepBtn.style.display = 'none'; - if (statsRow) statsRow.style.display = 'none'; - if (progressDiv) progressDiv.style.display = 'none'; - if (messageDiv) { - messageDiv.style.display = ''; - messageDiv.innerHTML = 'SoulSync needs a media server to manage your library. ' - + 'Go to Settings ' - + 'to connect Plex, Jellyfin, or Navidrome.'; - } - - } else if (!serverConnected) { - // State: Server configured but not connected - card.classList.add('needs-setup'); - const serverName = _capitalize(serverType); - if (title) title.textContent = `${serverName} — Disconnected`; - if (subtitle) subtitle.textContent = 'Cannot reach your media server'; - if (scanBtn) scanBtn.style.display = 'none'; - if (deepBtn) deepBtn.style.display = 'none'; - if (statsRow) statsRow.style.display = 'none'; - if (progressDiv) progressDiv.style.display = 'none'; - if (messageDiv) { - messageDiv.style.display = ''; - messageDiv.innerHTML = `Your ${serverName} server is configured but not responding. ` - + 'Check that it\'s running and the connection details are correct in ' - + 'Settings.'; - } - - } else if (!hasData) { - // State: Server connected but library is empty - card.classList.add('empty-library'); - const serverName = _capitalize(serverType); - if (title) title.textContent = `${serverName} Connected`; - if (subtitle) subtitle.textContent = 'Library database is empty'; - if (scanBtn) { - scanBtn.style.display = ''; - scanBtn.classList.remove('scanning'); - scanLabel.textContent = 'Scan Now'; - scanBtn.disabled = false; - } - if (deepBtn) deepBtn.style.display = 'none'; - if (statsRow) statsRow.style.display = 'none'; - if (progressDiv) progressDiv.style.display = 'none'; - if (messageDiv) { - messageDiv.style.display = ''; - messageDiv.innerHTML = 'Your server is connected but SoulSync hasn\'t imported your library yet. ' - + 'Click Scan Now to pull your artists, albums, and tracks into SoulSync.'; - } - - } else { - // State: Healthy library with data - card.classList.add('has-data'); - const serverName = _capitalize(serverType); - let lastRefreshText = 'Never'; - if (lastUpdate) { - const d = new Date(lastUpdate); - if (!isNaN(d.getTime())) { - lastRefreshText = typeof _formatTimeAgo === 'function' ? _formatTimeAgo(d) : d.toLocaleDateString(); - } - } - if (title) title.textContent = `${serverName} Library`; - if (subtitle) subtitle.textContent = `Last refreshed ${lastRefreshText}`; - if (scanBtn) { - scanBtn.style.display = ''; - scanBtn.classList.remove('scanning'); - scanLabel.textContent = 'Refresh'; - scanBtn.disabled = false; - } - if (deepBtn) deepBtn.style.display = ''; - if (statsRow) { - statsRow.style.display = ''; - document.getElementById('library-status-artists').textContent = artists.toLocaleString(); - document.getElementById('library-status-albums').textContent = albums.toLocaleString(); - document.getElementById('library-status-tracks').textContent = tracks.toLocaleString(); - document.getElementById('library-status-size').textContent = sizeMb < 1 ? `${Math.round(sizeMb * 1024)} KB` : `${sizeMb.toFixed(1)} MB`; - } - if (progressDiv) progressDiv.style.display = 'none'; - if (messageDiv) messageDiv.style.display = 'none'; - } -} - -// Track last service status for library card -let _lastServiceStatus = null; -let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden) -const _origFetchServiceStatus = typeof fetchAndUpdateServiceStatus === 'function' ? fetchAndUpdateServiceStatus : null; - -function _capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } - -/** - * Dashboard library scan button handler — triggers incremental DB update. - */ -async function dashboardLibraryScan(fullRefresh = false) { - const scanBtn = document.getElementById('library-status-scan-btn'); - const scanLabel = document.getElementById('library-status-scan-label'); - - // If already scanning, stop it - if (window._libraryStatusScanning) { - try { - await fetch('/api/database/update/stop', { method: 'POST' }); - window._libraryStatusScanning = false; - showToast('Library scan stopped', 'info'); - // Refresh the card - try { - const r = await fetch('/api/database/stats'); - if (r.ok) updateLibraryStatusCard(await r.json()); - } catch (e) {} - } catch (e) { - showToast('Failed to stop scan', 'error'); - } - return; - } - - // Start scan - try { - window._libraryStatusScanning = true; - updateLibraryStatusCard(null); // Update to scanning state - - const response = await fetch('/api/database/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ full_refresh: fullRefresh }) - }); - const data = await response.json(); - if (!data.success) { - window._libraryStatusScanning = false; - showToast(data.error || 'Failed to start scan', 'error'); - return; - } - - showToast('Library scan started', 'success'); - - // Poll for progress - const pollInterval = setInterval(async () => { - try { - const statusResp = await fetch('/api/database/update/status'); - if (!statusResp.ok) return; - const status = await statusResp.json(); - - const phase = document.getElementById('library-status-phase'); - const barFill = document.getElementById('library-status-bar-fill'); - const detail = document.getElementById('library-status-progress-detail'); - - if (phase) phase.textContent = status.phase || 'Scanning...'; - if (barFill) barFill.style.width = `${status.progress || 0}%`; - if (detail && status.processed !== undefined) { - detail.textContent = `${status.processed} / ${status.total || '?'}`; - } - - if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') { - clearInterval(pollInterval); - window._libraryStatusScanning = false; - - if (status.status === 'completed' || status.status === 'finished') { - showToast('Library scan complete', 'success'); - } else if (status.status === 'error') { - showToast(`Scan error: ${status.error_message || 'Unknown'}`, 'error'); - } - - // Refresh stats - try { - const r = await fetch('/api/database/stats'); - if (r.ok) updateLibraryStatusCard(await r.json()); - } catch (e) {} - } - } catch (e) { - clearInterval(pollInterval); - window._libraryStatusScanning = false; - } - }, 2000); - - } catch (e) { - window._libraryStatusScanning = false; - showToast(`Scan failed: ${e.message}`, 'error'); - } -} - -/** - * Dashboard deep scan — finds new tracks, removes stale ones, preserves enrichment data. - */ -async function dashboardLibraryDeepScan() { - if (window._libraryStatusScanning) { - showToast('A scan is already running', 'warning'); - return; - } - - if (!await showConfirmDialog({ - title: 'Deep Scan Library', - message: 'A deep scan re-checks every track in your media server library.\n\n' + - '• Adds any new tracks that were missed\n' + - '• Removes tracks no longer on your server\n' + - '• Preserves all existing metadata and enrichment data\n\n' + - 'This may take a while for large libraries. Continue?', - })) return; - - // Use the same scan flow as dashboardLibraryScan but with deep_scan flag - try { - window._libraryStatusScanning = true; - updateLibraryStatusCard(null); - - const response = await fetch('/api/database/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ deep_scan: true }) - }); - const data = await response.json(); - if (!data.success) { - window._libraryStatusScanning = false; - showToast(data.error || 'Failed to start deep scan', 'error'); - try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {} - return; - } - - showToast('Deep scan started — this may take a while', 'success'); - - const pollInterval = setInterval(async () => { - try { - const statusResp = await fetch('/api/database/update/status'); - if (!statusResp.ok) return; - const status = await statusResp.json(); - - const phase = document.getElementById('library-status-phase'); - const barFill = document.getElementById('library-status-bar-fill'); - const detail = document.getElementById('library-status-progress-detail'); - - if (phase) phase.textContent = status.phase || 'Deep scanning...'; - if (barFill) barFill.style.width = `${status.progress || 0}%`; - if (detail && status.processed !== undefined) { - detail.textContent = `${status.processed} / ${status.total || '?'}`; - } - - if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') { - clearInterval(pollInterval); - window._libraryStatusScanning = false; - - if (status.status === 'completed' || status.status === 'finished') { - showToast('Deep scan complete', 'success'); - } else if (status.status === 'error') { - showToast(`Deep scan error: ${status.error_message || 'Unknown'}`, 'error'); - } - - try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {} - } - } catch (e) { - clearInterval(pollInterval); - window._libraryStatusScanning = false; - } - }, 2000); - - } catch (e) { - window._libraryStatusScanning = false; - showToast(`Deep scan failed: ${e.message}`, 'error'); - } -} - -/** - * Update the Active Downloads section on the dashboard. - * Called from artist, search, and discover update points (event-driven, no polling). - */ -function updateDashboardDownloads() { - const section = document.getElementById('dashboard-active-downloads-section'); - const container = document.getElementById('dashboard-downloads-container'); - if (!section || !container) return; - - // Collect active entries from each source - const activeArtists = Object.keys(artistDownloadBubbles).filter(id => - artistDownloadBubbles[id].downloads.length > 0 - ); - const activeSearch = Object.keys(searchDownloadBubbles).filter(name => - searchDownloadBubbles[name].downloads.length > 0 - ); - const activeDiscover = Object.keys(discoverDownloads); - const activeBeatport = Object.keys(beatportDownloadBubbles).filter(key => - beatportDownloadBubbles[key].downloads.length > 0 - ); - - const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length + activeBeatport.length; - - if (totalCount === 0) { - section.style.display = 'none'; - container.innerHTML = ''; - return; - } - - section.style.display = ''; - let html = ''; - - // --- Artists group --- - if (activeArtists.length > 0) { - html += ` -
    -
    - Artists - ${activeArtists.length} -
    -
    - ${activeArtists.map(id => createArtistBubbleCard(artistDownloadBubbles[id])).join('')} -
    -
    `; - } - - // --- Search group --- - if (activeSearch.length > 0) { - html += ` -
    -
    - Search - ${activeSearch.length} -
    -
    - ${activeSearch.map(name => createSearchBubbleCard(searchDownloadBubbles[name])).join('')} -
    -
    `; - } - - // --- Discover group --- - if (activeDiscover.length > 0) { - html += ` -
    -
    - Discover - ${activeDiscover.length} -
    -
    - ${activeDiscover.map(pid => createDashboardDiscoverBubble(pid)).join('')} -
    -
    `; - } - - // --- Beatport group --- - if (activeBeatport.length > 0) { - html += ` -
    -
    - Beatport - ${activeBeatport.length} -
    -
    - ${activeBeatport.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} -
    -
    `; - } - - container.innerHTML = html; - - // Post-render: attach artist bubble click handlers + dynamic glow - activeArtists.forEach(artistId => { - const card = container.querySelector(`.artist-bubble-card[data-artist-id="${artistId}"]`); - if (card) { - card.addEventListener('click', () => openArtistDownloadModal(artistId)); - const artist = artistDownloadBubbles[artistId].artist; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - } - }); - // Beatport bubble click handlers + glow - activeBeatport.forEach(chartKey => { - const card = container.querySelector(`.artist-bubble-card[data-chart-key="${chartKey}"]`); - if (card) { - card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); - const chartImage = beatportDownloadBubbles[chartKey].chart.image; - if (chartImage) { - extractImageColors(chartImage, (colors) => { - applyDynamicGlow(card, colors); - }); - } - } - }); - // Search and discover cards use inline onclick — no post-render needed -} - -/** - * Create a 150px circle card for a discover download (dashboard variant). - * Matches artist/search bubble sizing. - */ -function createDashboardDiscoverBubble(playlistId) { - const download = discoverDownloads[playlistId]; - if (!download) return ''; - - const isCompleted = download.status === 'completed'; - const imageUrl = download.imageUrl || ''; - const backgroundStyle = imageUrl - ? `background-image: url('${imageUrl}');` - : `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; - - return ` -
    -
    -
    -
    -
    ${escapeHtml(download.name)}
    -
    ${isCompleted ? 'Completed' : 'In Progress'}
    -
    -
    - `; -} - - - -function updateDbUpdaterCardInfo(stats) { - // Update the detailed stats within the DB Updater tool card - const lastRefreshEl = document.getElementById('db-last-refresh'); - const artistsStatEl = document.getElementById('db-stat-artists'); - const albumsStatEl = document.getElementById('db-stat-albums'); - const tracksStatEl = document.getElementById('db-stat-tracks'); - const sizeStatEl = document.getElementById('db-stat-size'); - - if (lastRefreshEl) { - if (stats.last_full_refresh) { - const date = new Date(stats.last_full_refresh); - lastRefreshEl.textContent = date.toLocaleString(); - } else { - lastRefreshEl.textContent = 'Never'; - } - } - - if (artistsStatEl) artistsStatEl.textContent = stats.artists.toLocaleString() || '0'; - if (albumsStatEl) albumsStatEl.textContent = stats.albums.toLocaleString() || '0'; - if (tracksStatEl) tracksStatEl.textContent = stats.tracks.toLocaleString() || '0'; - if (sizeStatEl) sizeStatEl.textContent = `${stats.database_size_mb.toFixed(2)} MB`; - - // Update the title of the tool card to show which server is active - const toolCardTitle = document.querySelector('#db-updater-card .tool-card-title'); - if (toolCardTitle && stats.server_source) { - const serverName = stats.server_source.charAt(0).toUpperCase() + stats.server_source.slice(1); - toolCardTitle.textContent = `${serverName} Database Updater`; - } -} - -// --- Wishlist Count Functions --- - -async function updateWishlistCount() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/wishlist/count'); - if (!response.ok) return; - - const data = await response.json(); - 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'); - } - } - - // Check for auto-initiated wishlist processes that user should see immediately - await checkForAutoInitiatedWishlistProcess(); - - } catch (error) { - console.warn('Could not fetch wishlist count:', error); - } -} - -async function checkForAutoInitiatedWishlistProcess() { - try { - const playlistId = 'wishlist'; - - // Only check if we're on the dashboard and no modal is currently visible - if (currentPage !== 'dashboard') { - return; - } - - // Don't override if user has manually closed the modal during auto-processing - if (WishlistModalState.wasUserClosed()) { - return; - } - - // Check for active wishlist processes - const response = await fetch('/api/active-processes'); - if (!response.ok) return; - - const data = await response.json(); - const processes = data.active_processes || []; - const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); - const clientWishlistProcess = activeDownloadProcesses[playlistId]; - - if (serverWishlistProcess && serverWishlistProcess.auto_initiated) { - console.log('🤖 [Auto-Processing] Detected auto-initiated wishlist process during polling'); - - // Only sync frontend state if needed, but don't auto-show modal - const needsSync = !clientWishlistProcess || - clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || - !clientWishlistProcess.modalElement || - !document.body.contains(clientWishlistProcess.modalElement); - - if (needsSync) { - console.log('🔄 [Auto-Processing] Syncing frontend state for auto-processing (background mode)'); - await rehydrateModal(serverWishlistProcess, false); // Background sync only - } - - // Note: Modal visibility is controlled by user interaction only - // User must click wishlist button to see auto-processing progress - } - - } catch (error) { - console.warn('Error checking for auto-initiated wishlist process:', error); - } -} - -async function checkAndUpdateDbProgress() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/database/update/status', { - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - if (!response.ok) return; - - const state = await response.json(); - console.debug('📊 DB Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); - updateDbProgressUI(state); - - // Start polling only if not already polling and status is running - if (state.status === 'running' && !dbUpdateStatusInterval) { - console.log('🔄 Starting database update polling (1 second interval)'); - dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000); - } - - } catch (error) { - console.warn('Could not fetch DB update status:', error); - // Don't stop polling on network errors - keep trying - } -} - -function updateDbProgressFromData(data) { - const prev = _lastToolStatus['db-update']; - _lastToolStatus['db-update'] = data.status; - if (prev !== undefined && data.status === prev && data.status !== 'running') return; - updateDbProgressUI(data); -} - -function updateDbProgressUI(state) { - const button = document.getElementById('db-update-button'); - const phaseLabel = document.getElementById('db-phase-label'); - const progressLabel = document.getElementById('db-progress-label'); - const progressBar = document.getElementById('db-progress-bar'); - const refreshSelect = document.getElementById('db-refresh-type'); - - if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return; - - if (state.status === 'running') { - button.textContent = 'Stop Update'; - button.disabled = false; - refreshSelect.disabled = true; - - phaseLabel.textContent = state.phase || 'Processing...'; - progressLabel.textContent = `${state.processed} / ${state.total} artists (${state.progress.toFixed(1)}%)`; - progressBar.style.width = `${state.progress}%`; - } else { // idle, finished, or error - stopDbUpdatePolling(); - button.textContent = 'Update Database'; - button.disabled = false; - refreshSelect.disabled = false; - - if (state.status === 'error') { - phaseLabel.textContent = `Error: ${state.error_message}`; - progressBar.style.backgroundColor = '#ff4444'; // Red for error - } else { - phaseLabel.textContent = state.phase || 'Idle'; - progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal - } - - if (state.status === 'finished' || state.status === 'error') { - // Final stats refresh after completion/error - setTimeout(fetchAndUpdateDbStats, 500); - } - } -} - -// =================================================================== -// TIDAL PLAYLIST MANAGEMENT (YouTube-style cards with Tidal colors) -// =================================================================== - -async function loadTidalPlaylists() { - const container = document.getElementById('tidal-playlist-container'); - const refreshBtn = document.getElementById('tidal-refresh-btn'); - - container.innerHTML = `
    🔄 Loading Tidal playlists...
    `; - refreshBtn.disabled = true; - refreshBtn.textContent = '🔄 Loading...'; - - try { - const response = await fetch('/api/tidal/playlists'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Tidal playlists'); - } - - tidalPlaylists = await response.json(); - renderTidalPlaylists(); - tidalPlaylistsLoaded = true; - - console.log(`🎵 Loaded ${tidalPlaylists.length} Tidal playlists`); - - // Auto-mirror Tidal playlists: fetch tracks in background then mirror - // Cards render instantly from metadata; tracks load per-playlist without blocking UI - for (const p of tidalPlaylists) { - // Skip if already have tracks from a previous load - if (p.tracks && p.tracks.length > 0) { - mirrorPlaylist('tidal', p.id, p.name, p.tracks.map(t => ({ - track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''), - album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0, - source_track_id: t.id || '' - })), { owner: p.owner, image_url: p.image_url, description: p.description }); - continue; - } - // Fetch tracks on-demand for this playlist - try { - const fullResp = await fetch(`/api/tidal/playlist/${p.id}`); - if (fullResp.ok) { - const fullData = await fullResp.json(); - if (fullData.tracks && fullData.tracks.length > 0) { - p.tracks = fullData.tracks; - p.track_count = fullData.tracks.length; - // Update card track count in UI - const countEl = document.querySelector(`#tidal-card-${p.id} .playlist-card-track-count`); - if (countEl) countEl.textContent = `${fullData.tracks.length} tracks`; - // Mirror with full track data - mirrorPlaylist('tidal', p.id, p.name, fullData.tracks.map(t => ({ - track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''), - album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0, - source_track_id: t.id || '' - })), { owner: p.owner, image_url: p.image_url, description: p.description }); - } - } - } catch (e) { - console.warn(`Failed to fetch tracks for Tidal playlist ${p.name}: ${e.message}`); - } - } - - // Load and apply saved discovery states from backend (like YouTube) - await loadTidalPlaylistStatesFromBackend(); - - } catch (error) { - container.innerHTML = `
    ❌ Error: ${error.message}
    `; - showToast(`Error loading Tidal playlists: ${error.message}`, 'error'); - } finally { - refreshBtn.disabled = false; - refreshBtn.textContent = '🔄 Refresh'; - } -} - -function renderTidalPlaylists() { - const container = document.getElementById('tidal-playlist-container'); - if (tidalPlaylists.length === 0) { - container.innerHTML = `
    No Tidal playlists found.
    `; - return; - } - - container.innerHTML = tidalPlaylists.map(p => { - // Initialize state if not exists (fresh state like sync.py) - if (!tidalPlaylistStates[p.id]) { - tidalPlaylistStates[p.id] = { - phase: 'fresh', - playlist: p - }; - } - - return createTidalCard(p); - }).join(''); - - // Add click handlers to cards - tidalPlaylists.forEach(p => { - const card = document.getElementById(`tidal-card-${p.id}`); - if (card) { - card.addEventListener('click', () => handleTidalCardClick(p.id)); - } - }); -} - -function createTidalCard(playlist) { - const state = tidalPlaylistStates[playlist.id]; - const phase = state.phase; - - // Get phase-specific button text (like YouTube cards) - let buttonText = getActionButtonText(phase); - let phaseText = getPhaseText(phase); - let phaseColor = getPhaseColor(phase); - - return ` -
    -
    🎵
    -
    -
    ${escapeHtml(playlist.name)}
    -
    - ${playlist.track_count} tracks - ${phaseText} -
    -
    -
    - -
    - -
    - `; -} - -async function handleTidalCardClick(playlistId) { - // Robust state validation - const state = tidalPlaylistStates[playlistId]; - if (!state) { - console.error(`❌ [Card Click] No state found for Tidal playlist: ${playlistId}`); - showToast('Playlist state not found - try refreshing the page', 'error'); - return; - } - - // Validate required state data - if (!state.playlist) { - console.error(`❌ [Card Click] No playlist data found for Tidal playlist: ${playlistId}`); - showToast('Playlist data missing - try refreshing the page', 'error'); - return; - } - - // Validate phase - if (!state.phase) { - console.warn(`⚠️ [Card Click] No phase set for Tidal playlist ${playlistId} - defaulting to 'fresh'`); - state.phase = 'fresh'; - } - - console.log(`🎵 [Card Click] Tidal card clicked: ${playlistId}, Phase: ${state.phase}`); - - if (state.phase === 'fresh') { - // Fetch tracks if not yet loaded (metadata-only listing doesn't include them) - if (!state.playlist.tracks || state.playlist.tracks.length === 0) { - console.log(`🎵 Fetching tracks for Tidal playlist: ${state.playlist.name}`); - showLoadingOverlay(`Loading ${state.playlist.name}...`); - try { - const resp = await fetch(`/api/tidal/playlist/${playlistId}`); - if (resp.ok) { - const fullData = await resp.json(); - if (fullData.tracks && fullData.tracks.length > 0) { - // Convert to Track-like objects for the discovery modal - state.playlist.tracks = fullData.tracks.map(t => ({ - id: t.id, name: t.name, artists: t.artists || [], - album: t.album || '', duration_ms: t.duration_ms || 0, - track_number: t.track_number || 0 - })); - // Update card count - const countEl = document.querySelector(`#tidal-card-${playlistId} .playlist-card-track-count`); - if (countEl) countEl.textContent = `${state.playlist.tracks.length} tracks`; - } - } - } catch (e) { - console.error(`Failed to fetch Tidal playlist tracks: ${e}`); - hideLoadingOverlay(); - } - } - - if (!state.playlist.tracks || state.playlist.tracks.length === 0) { - hideLoadingOverlay(); - showToast('Could not load tracks for this playlist', 'error'); - return; - } - - hideLoadingOverlay(); - console.log(`🎵 Ready with ${state.playlist.tracks.length} Tidal tracks for discovery`); - - // Open discovery modal - phase will be updated when discovery actually starts - openTidalDiscoveryModal(playlistId, state.playlist); - - } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { - // Reopen existing modal with preserved discovery results (like GUI sync.py) - console.log(`🎵 [Card Click] Opening Tidal discovery modal for ${state.phase} phase`); - - // Validate that we have discovery results to show - if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { - console.warn(`⚠️ [Card Click] Discovered phase but no discovery results found - attempting to reload from backend`); - - // Try to fetch from backend as fallback - try { - const stateResponse = await fetch(`/api/tidal/state/${playlistId}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - if (fullState.discovery_results) { - // Merge backend state with current state - state.discovery_results = fullState.discovery_results; - state.spotify_matches = fullState.spotify_matches || state.spotify_matches; - state.discovery_progress = fullState.discovery_progress || state.discovery_progress; - tidalPlaylistStates[playlistId] = { ...tidalPlaylistStates[playlistId], ...state }; - console.log(`✅ [Card Click] Restored ${fullState.discovery_results.length} discovery results from backend`); - } - } - } catch (error) { - console.error(`❌ [Card Click] Failed to fetch discovery results from backend: ${error}`); - } - } - - openTidalDiscoveryModal(playlistId, state.playlist); - } else if (state.phase === 'downloading' || state.phase === 'download_complete') { - // Open download modal if we have the converted playlist ID - if (state.convertedSpotifyPlaylistId) { - console.log(`🔍 [Card Click] Opening download modal for Tidal playlist: ${state.playlist.name} (phase: ${state.phase})`); - // Check if modal already exists, if not create it - if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { - const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; - if (process.modalElement) { - console.log(`📱 [Card Click] Showing existing download modal for ${state.phase} phase`); - process.modalElement.style.display = 'flex'; - } else { - console.warn(`⚠️ [Card Click] Download process exists but modal element missing - rehydrating`); - await rehydrateTidalDownloadModal(playlistId, state); - } - } else { - // Need to create the download modal - fetch the discovery results - console.log(`🔧 [Card Click] Rehydrating Tidal download modal for ${state.phase} phase`); - await rehydrateTidalDownloadModal(playlistId, state); - } - } else { - console.error('❌ [Card Click] No converted Spotify playlist ID found for Tidal download modal'); - console.log('📊 [Card Click] Available state data:', Object.keys(state)); - - // Fallback: try to open discovery modal if we have discovery results - if (state.discovery_results && state.discovery_results.length > 0) { - console.log(`🔄 [Card Click] Fallback: Opening discovery modal with ${state.discovery_results.length} results`); - openTidalDiscoveryModal(playlistId, state.playlist); - } else { - showToast('Unable to open download modal - missing playlist data', 'error'); - } - } - } -} - -async function rehydrateTidalDownloadModal(playlistId, state) { - try { - // Robust state validation for rehydration - if (!state || !state.playlist) { - console.error(`❌ [Rehydration] Invalid state data for Tidal playlist: ${playlistId}`); - showToast('Cannot open download modal - invalid playlist data', 'error'); - return; - } - - console.log(`💧 [Rehydration] Rehydrating Tidal download modal for: ${state.playlist.name}`); - - // Get discovery results from backend if not already loaded - if (!state.discovery_results) { - console.log(`🔍 Fetching discovery results from backend for Tidal playlist: ${playlistId}`); - const stateResponse = await fetch(`/api/tidal/state/${playlistId}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - state.discovery_results = fullState.discovery_results; - state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; - state.download_process_id = fullState.download_process_id; - console.log(`✅ Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`); - } else { - console.error('❌ Failed to fetch Tidal discovery results from backend'); - showToast('Error loading playlist data', 'error'); - return; - } - } - - // Extract Spotify tracks from discovery results - const spotifyTracks = []; - for (const result of state.discovery_results) { - if (result.spotify_data) { - spotifyTracks.push(result.spotify_data); - } - } - - if (spotifyTracks.length === 0) { - console.error('❌ No Spotify tracks found for download modal'); - showToast('No Spotify matches found for download', 'error'); - return; - } - - const virtualPlaylistId = state.convertedSpotifyPlaylistId; - const playlistName = state.playlist.name; - - // Create the download modal - await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); - - // If we have a download process ID, set up the modal for the running state - if (state.download_process_id) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = state.phase === 'download_complete' ? 'complete' : 'running'; - process.batchId = state.download_process_id; - - // Update UI based on phase - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - - if (state.phase === 'downloading') { - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - console.log(`🔄 Started polling for active Tidal download: ${state.download_process_id}`); - } else if (state.phase === 'download_complete') { - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'none'; - console.log(`✅ Showing completed Tidal download results: ${state.download_process_id}`); - - // For completed downloads, fetch the final results once to populate the modal - try { - const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`); - if (response.ok) { - const data = await response.json(); - if (data.phase === 'complete' && data.tasks) { - console.log(`📊 [Rehydration] Loading ${data.tasks.length} completed tasks for modal display`); - // Process the completed tasks to update modal display - updateCompletedModalResults(virtualPlaylistId, data); - } else { - console.warn(`⚠️ [Rehydration] Unexpected data from download_status: phase=${data.phase}, tasks=${data.tasks?.length || 0}`); - } - } else { - console.error(`❌ [Rehydration] Failed to fetch download status: ${response.status} ${response.statusText}`); - } - } catch (error) { - console.error(`❌ [Rehydration] Error fetching final results for completed download: ${error}`); - // Show a user-friendly message but still allow modal to open - showToast('Could not load download results - modal may show incomplete data', 'warning', 3000); - } - } - } - } - - console.log(`✅ Successfully rehydrated Tidal download modal for: ${state.playlist.name}`); - - } catch (error) { - console.error(`❌ Error rehydrating Tidal download modal:`, error); - showToast('Error opening download modal', 'error'); - } -} - -function updateCompletedModalResults(playlistId, downloadData) { - /** - * Update a completed download modal with final results - * This reuses the existing status polling logic but applies it once for completed state - */ - console.log(`📊 [Completed Results] Updating modal ${playlistId} with final download results`); - - // Validate input data - if (!downloadData || !downloadData.tasks) { - console.error(`❌ [Completed Results] Invalid download data for playlist ${playlistId}:`, downloadData); - return; - } - - try { - // Update analysis progress to 100% - const analysisProgressFill = document.getElementById(`analysis-progress-fill-${playlistId}`); - const analysisProgressText = document.getElementById(`analysis-progress-text-${playlistId}`); - if (analysisProgressFill) analysisProgressFill.style.width = '100%'; - if (analysisProgressText) analysisProgressText.textContent = 'Analysis complete!'; - - // Update analysis results and stats - if (downloadData.analysis_results) { - updateTrackAnalysisResults(playlistId, downloadData.analysis_results); - const foundCount = downloadData.analysis_results.filter(r => r.found).length; - const missingCount = downloadData.analysis_results.filter(r => !r.found).length; - - const statFound = document.getElementById(`stat-found-${playlistId}`); - const statMissing = document.getElementById(`stat-missing-${playlistId}`); - if (statFound) statFound.textContent = foundCount; - if (statMissing) statMissing.textContent = missingCount; - } - - // Process completed tasks to update individual track statuses - const missingTracks = (downloadData.analysis_results || []).filter(r => !r.found); - let completedCount = 0; - let failedOrCancelledCount = 0; - let notFoundCount = 0; - - (downloadData.tasks || []).forEach(task => { - const row = document.querySelector(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index="${task.track_index}"]`); - if (!row) return; - - row.dataset.taskId = task.task_id; - const statusEl = document.getElementById(`download-${playlistId}-${task.track_index}`); - const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`); - - let statusText = ''; - switch (task.status) { - case 'pending': statusText = '⏸️ Pending'; break; - case 'searching': statusText = '🔍 Searching...'; break; - case 'downloading': statusText = `⏬ Downloading... ${Math.round(task.progress || 0)}%`; break; - case 'post_processing': statusText = '⌛ Processing...'; break; // NEW VERIFICATION WORKFLOW - case 'completed': statusText = '✅ Completed'; completedCount++; break; - case 'not_found': statusText = '🔇 Not Found'; notFoundCount++; break; - case 'failed': statusText = '❌ Failed'; failedOrCancelledCount++; break; - case 'cancelled': statusText = '🚫 Cancelled'; failedOrCancelledCount++; break; - default: statusText = `⚪ ${task.status}`; break; - } - - if (statusEl) { - statusEl.textContent = statusText; - if ((task.status === 'failed' || task.status === 'cancelled' || task.status === 'not_found') && task.error_message) { - statusEl.classList.add('has-error-tooltip'); - statusEl.dataset.errorMsg = task.error_message; - _ensureErrorTooltipListeners(statusEl); - } - if (task.status === 'not_found' && task.has_candidates) { - statusEl.classList.add('has-candidates'); - statusEl.dataset.taskId = task.task_id; - _ensureCandidatesClickListener(statusEl); - } - } - if (actionsEl) actionsEl.innerHTML = '-'; // Remove action buttons for completed tasks - }); - - // Update download progress to final state - const totalFinished = completedCount + failedOrCancelledCount + notFoundCount; - const missingCount = missingTracks.length; - const progressPercent = missingCount > 0 ? (totalFinished / missingCount) * 100 : 100; - - const downloadProgressFill = document.getElementById(`download-progress-fill-${playlistId}`); - const downloadProgressText = document.getElementById(`download-progress-text-${playlistId}`); - const statDownloaded = document.getElementById(`stat-downloaded-${playlistId}`); - - if (downloadProgressFill) downloadProgressFill.style.width = `${progressPercent}%`; - if (downloadProgressText) downloadProgressText.textContent = `${completedCount}/${missingCount} completed (${progressPercent.toFixed(0)}%)`; - if (statDownloaded) statDownloaded.textContent = completedCount; - - console.log(`✅ [Completed Results] Updated modal with ${completedCount} completed, ${notFoundCount} not found, ${failedOrCancelledCount} failed tasks`); - - } catch (error) { - console.error(`❌ [Completed Results] Error updating completed modal results:`, error); - } -} - -function updateTidalCardPhase(playlistId, phase) { - const state = tidalPlaylistStates[playlistId]; - if (!state) return; - - state.phase = phase; - - // Re-render the card with new phase - const card = document.getElementById(`tidal-card-${playlistId}`); - if (card) { - const oldButtonText = card.querySelector('.playlist-card-action-btn')?.textContent || 'unknown'; - const newCardHtml = createTidalCard(state.playlist); - card.outerHTML = newCardHtml; - - // Verify the card was actually updated - const updatedCard = document.getElementById(`tidal-card-${playlistId}`); - const newButtonText = updatedCard?.querySelector('.playlist-card-action-btn')?.textContent || 'unknown'; - - console.log(`🔄 [Card Update] Re-rendered Tidal card ${playlistId}:`); - console.log(` 📊 Phase: ${phase}`); - console.log(` 🔘 Button text: "${oldButtonText}" → "${newButtonText}"`); - console.log(` ✅ Expected: "${getActionButtonText(phase)}"`); - - if (newButtonText !== getActionButtonText(phase)) { - console.error(`❌ [Card Update] Button text mismatch! Expected "${getActionButtonText(phase)}", got "${newButtonText}"`); - } - - // Re-attach click handler - const newCard = document.getElementById(`tidal-card-${playlistId}`); - if (newCard) { - newCard.addEventListener('click', () => handleTidalCardClick(playlistId)); - console.debug(`🔗 [Card Update] Reattached click handler for Tidal card: ${playlistId}`); - } else { - console.error(`❌ [Card Update] Failed to find new card after rendering: tidal-card-${playlistId}`); - } - - // If we have sync progress and we're in sync/sync_complete phase, restore it - if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { - setTimeout(() => { - updateTidalCardSyncProgress(playlistId, state.lastSyncProgress); - }, 0); - } - } - - console.log(`🎵 Updated Tidal card phase: ${playlistId} -> ${phase}`); -} - -async function openTidalDiscoveryModal(playlistId, playlistData) { - console.log(`🎵 Opening Tidal discovery modal (reusing YouTube modal): ${playlistData.name}`); - - // Create a fake YouTube-style urlHash for the modal system - const fakeUrlHash = `tidal_${playlistId}`; - - // Get current Tidal card state to check if discovery is already done or in progress - const tidalCardState = tidalPlaylistStates[playlistId]; - const isAlreadyDiscovered = tidalCardState && (tidalCardState.phase === 'discovered' || tidalCardState.phase === 'syncing' || tidalCardState.phase === 'sync_complete'); - const isCurrentlyDiscovering = tidalCardState && tidalCardState.phase === 'discovering'; - - // Prepare discovery results in the correct format for modal - let transformedResults = []; - let actualMatches = 0; - if (isAlreadyDiscovered && tidalCardState.discovery_results) { - transformedResults = tidalCardState.discovery_results.map((result, index) => { - // Check multiple status formats - const isFound = result.status === 'found' || - result.status === '✅ Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; - if (isFound) actualMatches++; - - return { - index: index, - yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown', - yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isFound ? '✅ Found' : '❌ Not Found', - status_class: isFound ? 'found' : 'not-found', - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, // Pass through spotify_data - spotify_id: result.spotify_id, // Pass through spotify_id - manual_match: result.manual_match // Pass through manual match flag - }; - }); - console.log(`🎵 Tidal modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); - } - - // Create YouTube-compatible state structure - const modalPhase = tidalCardState ? tidalCardState.phase : 'fresh'; - youtubePlaylistStates[fakeUrlHash] = { - phase: modalPhase, - playlist: { - name: playlistData.name, - tracks: playlistData.tracks - }, - is_tidal_playlist: true, // Flag to identify this as Tidal - tidal_playlist_id: playlistId, - discovery_progress: isAlreadyDiscovered ? 100 : 0, - spotify_matches: isAlreadyDiscovered ? actualMatches : 0, // Backend format (snake_case) - spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, // Frontend format (camelCase) - for button logic - spotify_total: playlistData.tracks.length, - discovery_results: transformedResults, - discoveryResults: transformedResults, // Both formats for compatibility - discoveryProgress: isAlreadyDiscovered ? 100 : 0 // Frontend format for modal progress display - }; - - // Only start discovery if not already discovered AND not currently discovering - if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { - // Start Tidal discovery process automatically (like sync.py) - try { - console.log(`🔍 Starting Tidal discovery for: ${playlistData.name}`); - - const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - console.error('❌ Error starting Tidal discovery:', result.error); - showToast(`Error starting discovery: ${result.error}`, 'error'); - return; - } - - console.log('✅ Tidal discovery started, beginning polling...'); - - // Update phase to discovering now that backend discovery is actually started - tidalPlaylistStates[playlistId].phase = 'discovering'; - updateTidalCardPhase(playlistId, 'discovering'); - - // Update modal phase to match - youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; - - // Start polling for progress - startTidalDiscoveryPolling(fakeUrlHash, playlistId); - - } catch (error) { - console.error('❌ Error starting Tidal discovery:', error); - showToast(`Error starting discovery: ${error.message}`, 'error'); - } - } else if (isCurrentlyDiscovering) { - // Resume polling if discovery is already in progress (like YouTube) - console.log(`🔄 Resuming Tidal discovery polling for: ${playlistData.name}`); - startTidalDiscoveryPolling(fakeUrlHash, playlistId); - } else if (tidalCardState && tidalCardState.phase === 'syncing') { - // Resume sync polling if sync is in progress - console.log(`🔄 Resuming Tidal sync polling for: ${playlistData.name}`); - startTidalSyncPolling(fakeUrlHash); - } else { - console.log('✅ Using existing results - no need to re-discover'); - } - - // Reuse YouTube discovery modal (exact sync.py pattern) - openYouTubeDiscoveryModal(fakeUrlHash); -} - -function startTidalDiscoveryPolling(fakeUrlHash, playlistId) { - console.log(`🔄 Starting Tidal discovery polling for: ${playlistId}`); - - // Stop any existing polling - if (activeYouTubePollers[fakeUrlHash]) { - clearInterval(activeYouTubePollers[fakeUrlHash]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('discovery:subscribe', { ids: [playlistId] }); - _discoveryProgressCallbacks[playlistId] = (data) => { - if (data.error) { - if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } - socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; - return; - } - // Transform to YouTube modal format - const transformed = { - progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, - complete: data.complete, - results: (data.results || []).map((r, i) => { - const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; - const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); - return { - index: i, yt_track: r.tidal_track ? r.tidal_track.name : 'Unknown', - yt_artist: r.tidal_track ? (r.tidal_track.artists ? r.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), - status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), - spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), - spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'), - spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), - spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, - wing_it_fallback: isWingIt - }; - }) - }; - const st = youtubePlaylistStates[fakeUrlHash]; - if (st) { - st.discovery_progress = data.progress; st.discoveryProgress = data.progress; - st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; - st.discovery_results = data.results; st.discoveryResults = transformed.results; - st.phase = data.phase; - updateYouTubeDiscoveryModal(fakeUrlHash, transformed); - } - if (tidalPlaylistStates[playlistId]) { - tidalPlaylistStates[playlistId].phase = data.phase; - tidalPlaylistStates[playlistId].discovery_results = data.results; - tidalPlaylistStates[playlistId].spotify_matches = data.spotify_matches; - tidalPlaylistStates[playlistId].discovery_progress = data.progress; - updateTidalCardPhase(playlistId, data.phase); - } - updateTidalCardProgress(playlistId, data); - if (data.complete) { - if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } - socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; - } - }; - } - - const pollInterval = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/tidal/discovery/status/${playlistId}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling Tidal discovery status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - return; - } - - // Transform Tidal results to YouTube modal format first - const transformedStatus = { - progress: status.progress, - spotify_matches: status.spotify_matches, - spotify_total: status.spotify_total, - complete: status.complete, - results: status.results.map((result, index) => { - const isFound = result.status === 'found' || - result.status === '✅ Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; - - return { - index: index, - yt_track: result.tidal_track ? result.tidal_track.name : 'Unknown', - yt_artist: result.tidal_track ? (result.tidal_track.artists ? result.tidal_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isFound ? '✅ Found' : '❌ Not Found', - status_class: isFound ? 'found' : 'not-found', - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, // Pass through - spotify_id: result.spotify_id, // Pass through - manual_match: result.manual_match // Pass through - }; - }) - }; - - // Update fake YouTube state with Tidal discovery results - const state = youtubePlaylistStates[fakeUrlHash]; - if (state) { - state.discovery_progress = status.progress; // Backend format - state.discoveryProgress = status.progress; // Frontend format - for modal progress display - state.spotify_matches = status.spotify_matches; // Backend format - state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic - state.discovery_results = status.results; // Backend format - state.discoveryResults = transformedStatus.results; // Frontend format - for button logic - state.phase = status.phase; - - // Update modal with transformed data (reuse YouTube modal update logic) - updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); - - // Update Tidal card phase and save discovery results FIRST - if (tidalPlaylistStates[playlistId]) { - tidalPlaylistStates[playlistId].phase = status.phase; - tidalPlaylistStates[playlistId].discovery_results = status.results; - tidalPlaylistStates[playlistId].spotify_matches = status.spotify_matches; - tidalPlaylistStates[playlistId].discovery_progress = status.progress; - updateTidalCardPhase(playlistId, status.phase); - } - - // Update Tidal card progress AFTER phase update to avoid being overwritten - updateTidalCardProgress(playlistId, status); - - console.log(`🔄 Tidal discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); - } - - // Stop polling when complete - if (status.complete) { - console.log(`✅ Tidal discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - } - - } catch (error) { - console.error('❌ Error polling Tidal discovery:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - } - }, 1000); // Poll every second like YouTube - - // Store poller reference (reuse YouTube poller storage) - activeYouTubePollers[fakeUrlHash] = pollInterval; -} - -async function loadTidalPlaylistStatesFromBackend() { - // Load all stored Tidal playlist discovery states from backend (similar to YouTube hydration) - try { - console.log('🎵 Loading Tidal playlist states from backend...'); - - const response = await fetch('/api/tidal/playlists/states'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Tidal playlist states'); - } - - const data = await response.json(); - const states = data.states || []; - - console.log(`🎵 Found ${states.length} stored Tidal playlist states in backend`); - - if (states.length === 0) { - console.log('🎵 No Tidal playlist states to hydrate'); - return; - } - - // Apply states to existing playlist cards - for (const stateInfo of states) { - await applyTidalPlaylistState(stateInfo); - } - - // Rehydrate download modals for Tidal playlists in downloading/download_complete phases - for (const stateInfo of states) { - if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && - stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { - - const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; - - if (!activeDownloadProcesses[convertedPlaylistId]) { - console.log(`💧 Rehydrating download modal for Tidal playlist: ${stateInfo.playlist_id}`); - try { - // Get the playlist data - const playlistData = tidalPlaylists.find(p => p.id === stateInfo.playlist_id); - if (!playlistData) { - console.warn(`⚠️ Playlist data not found for rehydration: ${stateInfo.playlist_id}`); - continue; - } - - // Create the download modal using the Tidal-specific function - const spotifyTracks = tidalPlaylistStates[stateInfo.playlist_id]?.discovery_results - ?.filter(result => result.spotify_data) - ?.map(result => result.spotify_data) || []; - - if (spotifyTracks.length > 0) { - await openDownloadMissingModalForTidal( - convertedPlaylistId, - playlistData.name, - spotifyTracks - ); - - // Set the modal to running state with the correct batch ID - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = stateInfo.download_process_id; - - // Update UI to running state - const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for this process - startModalDownloadPolling(convertedPlaylistId); - - console.log(`✅ Rehydrated Tidal download modal for batch ${stateInfo.download_process_id}`); - } - } else { - console.warn(`⚠️ No Spotify tracks found for Tidal playlist rehydration: ${stateInfo.playlist_id}`); - } - } catch (error) { - console.error(`❌ Error rehydrating Tidal download modal for ${stateInfo.playlist_id}:`, error); - } - } - } - } - - console.log('✅ Tidal playlist states loaded and applied'); - - } catch (error) { - console.error('❌ Error loading Tidal playlist states:', error); - } -} - -async function applyTidalPlaylistState(stateInfo) { - const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; - - try { - console.log(`🎵 Applying saved state for Tidal playlist: ${playlist_id}, Phase: ${phase}`); - - // Find the playlist data from the loaded playlists - const playlistData = tidalPlaylists.find(p => p.id === playlist_id); - if (!playlistData) { - console.warn(`⚠️ Playlist data not found for state ${playlist_id} - skipping`); - return; - } - - // Update local state - if (!tidalPlaylistStates[playlist_id]) { - // Initialize state if it doesn't exist - tidalPlaylistStates[playlist_id] = { - playlist: playlistData, - phase: 'fresh' - }; - } - - // Update with backend state - tidalPlaylistStates[playlist_id].phase = phase; - tidalPlaylistStates[playlist_id].discovery_progress = discovery_progress; - tidalPlaylistStates[playlist_id].spotify_matches = spotify_matches; - tidalPlaylistStates[playlist_id].discovery_results = discovery_results; - tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; - tidalPlaylistStates[playlist_id].download_process_id = download_process_id; - tidalPlaylistStates[playlist_id].playlist = playlistData; // Ensure playlist data is set - - // Fetch full discovery results for non-fresh playlists (matching YouTube pattern) - if (phase !== 'fresh' && phase !== 'discovering') { - try { - console.log(`🔍 Fetching full discovery results for Tidal playlist: ${playlistData.name}`); - const stateResponse = await fetch(`/api/tidal/state/${playlist_id}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - console.log(`📋 Retrieved full Tidal state with ${fullState.discovery_results?.length || 0} discovery results`); - - // Store full discovery results in local state (matching YouTube pattern) - if (fullState.discovery_results && tidalPlaylistStates[playlist_id]) { - tidalPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; - tidalPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; - tidalPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; - tidalPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; - tidalPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; - console.log(`✅ Restored ${fullState.discovery_results.length} discovery results for Tidal playlist: ${playlistData.name}`); - } - } else { - console.warn(`⚠️ Could not fetch full discovery results for Tidal playlist: ${playlistData.name}`); - } - } catch (error) { - console.warn(`⚠️ Error fetching full discovery results for Tidal playlist ${playlistData.name}:`, error.message); - } - } - - // Update the card UI to reflect the saved state - updateTidalCardPhase(playlist_id, phase); - - // Update card progress if we have discovery results - if (phase === 'discovered' && tidalPlaylistStates[playlist_id]) { - const progressInfo = { - spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, - spotify_matches: tidalPlaylistStates[playlist_id].spotify_matches || 0 - }; - updateTidalCardProgress(playlist_id, progressInfo); - } - - // Handle active polling resumption (matching YouTube/Beatport pattern) - if (phase === 'discovering') { - console.log(`🔍 Resuming discovery polling for Tidal: ${playlistData.name}`); - const fakeUrlHash = `tidal_${playlist_id}`; - startTidalDiscoveryPolling(fakeUrlHash, playlist_id); - } else if (phase === 'syncing') { - console.log(`🔄 Resuming sync polling for Tidal: ${playlistData.name}`); - const fakeUrlHash = `tidal_${playlist_id}`; - startTidalSyncPolling(fakeUrlHash); - } - - console.log(`✅ Applied saved state for Tidal playlist: ${playlist_id} -> ${phase}`); - - } catch (error) { - console.error(`❌ Error applying Tidal playlist state for ${playlist_id}:`, error); - } -} - -function updateTidalCardProgress(playlistId, progress) { - const state = tidalPlaylistStates[playlistId]; - if (!state) return; - - const card = document.getElementById(`tidal-card-${playlistId}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - if (!progressElement) return; - - const total = progress.spotify_total || 0; - const matches = progress.spotify_matches || 0; - const failed = total - matches; - const percentage = total > 0 ? Math.round((matches / total) * 100) : 0; - - progressElement.textContent = `♪ ${total} / ✓ ${matches} / ✗ ${failed} / ${percentage}%`; - progressElement.classList.remove('hidden'); // Show progress during discovery - - console.log('🎵 Updated Tidal card progress:', playlistId, `${matches}/${total} (${percentage}%)`); -} - -// =============================== -// TIDAL SYNC FUNCTIONALITY -// =============================== - -async function startTidalPlaylistSync(urlHash) { - try { - console.log('🎵 Starting Tidal playlist sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_tidal_playlist) { - console.error('❌ Invalid Tidal playlist state for sync'); - return; - } - - const playlistId = state.tidal_playlist_id; - const response = await fetch(`/api/tidal/sync/start/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error starting sync: ${result.error}`, 'error'); - return; - } - - // Capture sync_playlist_id for WebSocket subscription - const syncPlaylistId = result.sync_playlist_id; - if (state) state.syncPlaylistId = syncPlaylistId; - - // Update card and modal to syncing phase - updateTidalCardPhase(playlistId, 'syncing'); - - // Update modal buttons if modal is open - updateTidalModalButtons(urlHash, 'syncing'); - - // Start sync polling - startTidalSyncPolling(urlHash, syncPlaylistId); - - showToast('Tidal playlist sync started!', 'success'); - - } catch (error) { - console.error('❌ Error starting Tidal sync:', error); - showToast(`Error starting sync: ${error.message}`, 'error'); - } -} - -function startTidalSyncPolling(urlHash, syncPlaylistId) { - // Stop any existing polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - const state = youtubePlaylistStates[urlHash]; - const playlistId = state.tidal_playlist_id; - - // Resolve syncPlaylistId from argument or stored state - syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); - - // Phase 6: Subscribe via WebSocket - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - updateTidalCardSyncProgress(playlistId, progress); - updateTidalModalSyncProgress(urlHash, progress); - - if (data.status === 'finished') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; - updateTidalCardPhase(playlistId, 'sync_complete'); - updateTidalModalButtons(urlHash, 'sync_complete'); - showToast('Tidal playlist sync complete!', 'success'); - } else if (data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; - updateTidalCardPhase(playlistId, 'discovered'); - updateTidalModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - }; - } - - // Define the polling function (HTTP fallback) - const pollFunction = async () => { - if (socketConnected) return; // Phase 6: WS handles updates - try { - const response = await fetch(`/api/tidal/sync/status/${playlistId}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling Tidal sync status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - updateTidalCardSyncProgress(playlistId, status.progress); - updateTidalModalSyncProgress(urlHash, status.progress); - - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'sync_complete'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; - updateTidalCardPhase(playlistId, 'sync_complete'); - updateTidalModalButtons(urlHash, 'sync_complete'); - showToast('Tidal playlist sync complete!', 'success'); - } else if (status.sync_status === 'error') { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - if (tidalPlaylistStates[playlistId]) tidalPlaylistStates[playlistId].phase = 'discovered'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; - updateTidalCardPhase(playlistId, 'discovered'); - updateTidalModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); - } - } catch (error) { - console.error('❌ Error polling Tidal sync:', error); - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - } - }; - - // Run immediately to get current status (skip if WS active) - if (!socketConnected) pollFunction(); - - // Then continue polling at regular intervals - const pollInterval = setInterval(pollFunction, 1000); - activeYouTubePollers[urlHash] = pollInterval; -} - -async function cancelTidalSync(urlHash) { - try { - console.log('❌ Cancelling Tidal sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_tidal_playlist) { - console.error('❌ Invalid Tidal playlist state'); - return; - } - - const playlistId = state.tidal_playlist_id; - const response = await fetch(`/api/tidal/sync/cancel/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error cancelling sync: ${result.error}`, 'error'); - return; - } - - // Stop polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - // Phase 6: Clean up WS subscription - const syncId = state && state.syncPlaylistId; - if (syncId && _syncProgressCallbacks[syncId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); - delete _syncProgressCallbacks[syncId]; - } - - // Revert to discovered phase - updateTidalCardPhase(playlistId, 'discovered'); - updateTidalModalButtons(urlHash, 'discovered'); - - showToast('Tidal sync cancelled', 'info'); - - } catch (error) { - console.error('❌ Error cancelling Tidal sync:', error); - showToast(`Error cancelling sync: ${error.message}`, 'error'); - } -} - -function updateTidalCardSyncProgress(playlistId, progress) { - const state = tidalPlaylistStates[playlistId]; - if (!state || !state.playlist || !progress) return; - - // Save the progress for later restoration - state.lastSyncProgress = progress; - - const card = document.getElementById(`tidal-card-${playlistId}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - - // Build clean status counter HTML exactly like YouTube cards - let statusCounterHTML = ''; - if (progress && progress.total_tracks > 0) { - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const processed = matched + failed; - const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - statusCounterHTML = ` -
    - ♪ ${total} - / - ✓ ${matched} - / - ✗ ${failed} - (${percentage}%) -
    - `; - } - - // Only update if we have valid sync progress, otherwise preserve existing discovery results - if (statusCounterHTML) { - progressElement.innerHTML = statusCounterHTML; - } - - console.log(`🎵 Updated Tidal card sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`); -} - -function updateTidalModalSyncProgress(urlHash, progress) { - const statusDisplay = document.getElementById(`tidal-sync-status-${urlHash}`); - if (!statusDisplay || !progress) return; - - console.log(`📊 Updating Tidal modal sync progress for ${urlHash}:`, progress); - - // Update individual counters exactly like YouTube sync - const totalEl = document.getElementById(`tidal-total-${urlHash}`); - const matchedEl = document.getElementById(`tidal-matched-${urlHash}`); - const failedEl = document.getElementById(`tidal-failed-${urlHash}`); - const percentageEl = document.getElementById(`tidal-percentage-${urlHash}`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - - // Calculate percentage like YouTube sync - if (total > 0) { - const processed = matched + failed; - const percentage = Math.round((processed / total) * 100); - if (percentageEl) percentageEl.textContent = percentage; - } - - console.log(`📊 Tidal modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); -} - -function updateTidalModalButtons(urlHash, phase) { - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (!modal) return; - - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } -} - -async function startTidalDownloadMissing(urlHash) { - try { - console.log('🔍 Starting download missing tracks for Tidal playlist:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_tidal_playlist) { - console.error('❌ Invalid Tidal playlist state for download'); - return; - } - - // Tidal reuses youtubePlaylistStates infrastructure, so get results from there - const discoveryResults = state.discoveryResults || state.discovery_results; - - if (!discoveryResults) { - showToast('No discovery results available for download', 'error'); - return; - } - - // Convert Tidal discovery results to Spotify tracks format (same as YouTube) - const spotifyTracks = []; - for (const result of discoveryResults) { - if (result.spotify_data) { - spotifyTracks.push(result.spotify_data); - } else if (result.spotify_track && result.status_class === 'found') { - // Build from individual fields (automatic discovery format) - // Convert album to proper object format for wishlist compatibility - const albumData = result.spotify_album || 'Unknown Album'; - const albumObject = typeof albumData === 'object' && albumData !== null - ? albumData - : { - name: typeof albumData === 'string' ? albumData : 'Unknown Album', - album_type: 'album', - images: [] - }; - - spotifyTracks.push({ - id: result.spotify_id || 'unknown', - name: result.spotify_track || 'Unknown Track', - artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], - album: albumObject, - duration_ms: 0 - }); - } - } - - if (spotifyTracks.length === 0) { - showToast('No Spotify matches found for download', 'error'); - return; - } - - // Create a virtual playlist for the download system - const virtualPlaylistId = `tidal_${state.tidal_playlist_id}`; - const playlistName = state.playlist.name; - - // Store reference for card navigation (same as YouTube) - state.convertedSpotifyPlaylistId = virtualPlaylistId; - - // Close the discovery modal if it's open (same as YouTube) - const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (discoveryModal) { - discoveryModal.classList.add('hidden'); - console.log('🔄 Closed Tidal discovery modal to show download modal'); - } - - // Open download missing tracks modal for Tidal playlist - await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); - - // Phase will change to 'downloading' when user clicks "Begin Analysis" button - - } catch (error) { - console.error('❌ Error starting download missing tracks:', error); - showToast(`Error starting downloads: ${error.message}`, 'error'); - } -} - -async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) { - showLoadingOverlay('Loading Tidal playlist...'); - // Check if a process is already active for this virtual playlist - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - } - return; - } - - console.log(`📥 Opening Download Missing Tracks modal for Tidal playlist: ${virtualPlaylistId}`); - - // Create virtual playlist object for compatibility with existing modal logic - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Store the tracks in the cache for the modal to use - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - currentPlaylistTracks = spotifyTracks; - currentModalPlaylistId = virtualPlaylistId; - - let modal = document.createElement('div'); - modal.id = `download-missing-modal-${virtualPlaylistId}`; - modal.className = 'download-missing-modal'; - modal.style.display = 'none'; - document.body.appendChild(modal); - - // Register the new process in our global state tracker using the same structure as Spotify - activeDownloadProcesses[virtualPlaylistId] = { - status: 'idle', - modalElement: modal, - poller: null, - batchId: null, - playlist: virtualPlaylist, - tracks: spotifyTracks - }; - - // Generate hero section with dynamic source detection (same as YouTube/Beatport) - const source = virtualPlaylistId.startsWith('beatport_') ? 'Beatport' : - virtualPlaylistId.startsWith('tidal_') ? 'Tidal' : - virtualPlaylistId.startsWith('listenbrainz_') ? 'ListenBrainz' : - virtualPlaylistId.startsWith('spotify_public_') ? 'Spotify' : - virtualPlaylistId.startsWith('spotify:') ? 'Spotify' : - virtualPlaylistId.startsWith('discover_') ? 'SoulSync' : - virtualPlaylistId.startsWith('seasonal_') ? 'SoulSync' : - virtualPlaylistId.startsWith('spotify_library_') ? 'SoulSync' : - virtualPlaylistId.startsWith('build_playlist_') ? 'SoulSync' : - virtualPlaylistId.startsWith('decade_') ? 'SoulSync' : - virtualPlaylistId === 'build_playlist_custom' ? 'SoulSync' : - 'YouTube'; - - const heroContext = { - type: 'playlist', - playlist: { name: playlistName, owner: source }, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - }; - - // Use the exact same modal HTML structure as the existing Spotify modal - modal.innerHTML = ` -
    -
    - ${generateDownloadModalHeroSection(heroContext)} -
    - -
    -
    -
    -
    - 🔍 Library Analysis - Ready to start -
    -
    -
    -
    -
    -
    -
    - ⏬ Downloads - Waiting for analysis -
    -
    -
    -
    -
    -
    - -
    -
    -

    📋 Track Analysis & Download Status

    - ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected -
    -
    - - - - - - - - - - - - - - - ${spotifyTracks.map((track, index) => ` - - - - - - - - - - - `).join('')} - -
    - - #TrackArtistDurationLibrary MatchDownload StatusActions
    - - ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
    -
    -
    -
    - - -
    - `; - - applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); - modal.style.display = 'flex'; - hideLoadingOverlay(); -} - - -// =================================================================== -// DEEZER ARL PLAYLIST MANAGEMENT (Spotify-identical pattern) -// =================================================================== - -async function loadDeezerArlPlaylists() { - const container = document.getElementById('deezer-arl-playlist-container'); - const refreshBtn = document.getElementById('deezer-arl-refresh-btn'); - - container.innerHTML = `
    🔄 Loading playlists...
    `; - refreshBtn.disabled = true; - refreshBtn.textContent = '🔄 Loading...'; - - try { - const response = await fetch('/api/deezer/arl-playlists'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Deezer playlists'); - } - deezerArlPlaylists = await response.json(); - renderDeezerArlPlaylists(); - deezerArlPlaylistsLoaded = true; - - // Check for active syncs or downloads and rehydrate UI - await checkForActiveProcesses(); - for (const p of deezerArlPlaylists) { - const arlId = `deezer_arl_${p.id}`; - try { - const syncResp = await fetch(`/api/sync/status/${arlId}`); - if (syncResp.ok) { - const syncState = await syncResp.json(); - if (syncState.status === 'syncing') { - // Re-attach sync polling and update card UI - if (!spotifyPlaylists.find(sp => sp.id === arlId)) { - spotifyPlaylists.push({ id: arlId, name: p.name, track_count: p.track_count || 0, image_url: p.image_url || '', owner: p.owner || '' }); - } - updateCardToSyncing(arlId, syncState.progress?.progress || 0, syncState.progress); - startSyncPolling(arlId); - console.log(`🔄 Rehydrated active sync for Deezer ARL playlist: ${p.name}`); - } - } - } catch (e) { /* No active sync — normal */ } - } - - } catch (error) { - container.innerHTML = `
    ❌ Error: ${error.message}
    `; - showToast(`Error loading Deezer playlists: ${error.message}`, 'error'); - } finally { - refreshBtn.disabled = false; - refreshBtn.textContent = '🔄 Refresh'; - } -} - -function renderDeezerArlPlaylists() { - const container = document.getElementById('deezer-arl-playlist-container'); - if (deezerArlPlaylists.length === 0) { - container.innerHTML = `
    No Deezer playlists found.
    `; - return; - } - - container.innerHTML = deezerArlPlaylists.map(p => { - const arlId = `deezer_arl_${p.id}`; - let statusClass = 'status-never-synced'; - if (p.sync_status && p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; - - return ` -
    -
    -
    -
    ${escapeHtml(p.name)}
    -
    - ${p.track_count} tracks • - ${p.sync_status || 'Never Synced'} -
    -
    -
    -
    - - -
    -
    -
    - `; - }).join(''); -} - -function handleDeezerArlViewProgressClick(event, playlistId) { - event.stopPropagation(); - const arlPlaylistId = `deezer_arl_${playlistId}`; - const process = activeDownloadProcesses[arlPlaylistId]; - if (process && process.modalElement) { - process.modalElement.style.display = 'flex'; - } -} - -async function openDeezerArlPlaylistDetailsModal(event, playlistId) { - event.stopPropagation(); - - const playlist = deezerArlPlaylists.find(p => String(p.id) === String(playlistId)); - if (!playlist) return; - - const arlPlaylistId = `deezer_arl_${playlistId}`; - showLoadingOverlay(`Loading playlist: ${playlist.name}...`); - - try { - if (playlistTrackCache[arlPlaylistId]) { - const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] }; - showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId); - } else { - const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - - playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks; - - // Auto-mirror - mirrorPlaylist('deezer', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ - track_name: t.name, - artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', - album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', - duration_ms: t.duration_ms || 0, - source_track_id: t.id || '' - })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); - - showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId); - } - } catch (error) { - showToast(`Error: ${error.message}`, 'error'); - } finally { - hideLoadingOverlay(); - } -} - -function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) { - let modal = document.getElementById('deezer-arl-playlist-details-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'deezer-arl-playlist-details-modal'; - modal.className = 'modal-overlay'; - document.body.appendChild(modal); - } - - const playlistId = playlist.id; - const activeProcess = activeDownloadProcesses[playlistId]; - const hasCompletedProcess = activeProcess && activeProcess.status === 'complete'; - const isSyncing = !!activeSyncPollers[playlistId]; - - modal.innerHTML = ` - - `; - - // Store playlist in spotifyPlaylists-compatible format for openDownloadMissingModal - if (!spotifyPlaylists.find(p => p.id === playlistId)) { - spotifyPlaylists.push({ - id: playlistId, - name: playlist.name, - track_count: playlist.tracks ? playlist.tracks.length : 0, - image_url: playlist.image_url || '', - owner: playlist.owner || '', - }); - } - - modal.style.display = 'flex'; -} - -function closeDeezerArlPlaylistDetailsModal() { - const modal = document.getElementById('deezer-arl-playlist-details-modal'); - if (modal) modal.style.display = 'none'; -} - -function updateDeezerArlPlaylistCardUI(playlistId) { - const arlPlaylistId = `deezer_arl_${playlistId}`; - const process = activeDownloadProcesses[arlPlaylistId]; - const progressBtn = document.getElementById(`progress-btn-${arlPlaylistId}`); - const actionBtn = document.getElementById(`action-btn-${arlPlaylistId}`); - const card = document.querySelector(`.playlist-card[data-playlist-id="${arlPlaylistId}"]`); - - if (!progressBtn || !actionBtn) return; - - if (process && process.status === 'running') { - progressBtn.classList.remove('hidden'); - progressBtn.textContent = 'View Progress'; - progressBtn.style.backgroundColor = ''; - actionBtn.textContent = '📥 Downloading...'; - actionBtn.disabled = true; - if (card) card.classList.remove('download-complete'); - } else if (process && process.status === 'complete') { - progressBtn.classList.remove('hidden'); - progressBtn.textContent = '📋 View Results'; - progressBtn.style.backgroundColor = '#28a745'; - progressBtn.style.color = 'white'; - actionBtn.textContent = '✅ Ready for Review'; - actionBtn.disabled = false; - if (card) card.classList.add('download-complete'); - } else { - progressBtn.classList.add('hidden'); - progressBtn.style.backgroundColor = ''; - progressBtn.style.color = ''; - actionBtn.textContent = 'Sync / Download'; - actionBtn.disabled = false; - if (card) card.classList.remove('download-complete'); - } -} - - -// =================================================================== -// DEEZER PLAYLIST MANAGEMENT (URL-input like YouTube, reuses YouTube modal) -// =================================================================== - -async function loadDeezerPlaylist() { - const urlInput = document.getElementById('deezer-url-input'); - if (!urlInput) return; - - const rawUrl = urlInput.value.trim(); - if (!rawUrl) { - showToast('Please paste a Deezer playlist URL', 'error'); - return; - } - - // Extract playlist ID from URL - // Supports: deezer.com/playlist/{id}, deezer.com/{locale}/playlist/{id}, or raw numeric ID - let playlistId = null; - const urlMatch = rawUrl.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i); - if (urlMatch) { - playlistId = urlMatch[1]; - } else if (/^\d+$/.test(rawUrl)) { - playlistId = rawUrl; - } - - if (!playlistId) { - showToast('Invalid Deezer playlist URL. Expected format: deezer.com/playlist/{id}', 'error'); - return; - } - - // Check if already loaded - if (deezerPlaylists.find(p => String(p.id) === String(playlistId))) { - showToast('This playlist is already loaded', 'info'); - urlInput.value = ''; - return; - } - - const parseBtn = document.getElementById('deezer-parse-btn'); - if (parseBtn) { - parseBtn.disabled = true; - parseBtn.textContent = 'Loading...'; - } - - try { - const response = await fetch(`/api/deezer/playlist/${playlistId}`); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Deezer playlist'); - } - - const playlist = await response.json(); - deezerPlaylists.push(playlist); - - // Auto-mirror Deezer playlist - if (playlist.tracks && playlist.tracks.length > 0) { - mirrorPlaylist('deezer', playlist.id, playlist.name, playlist.tracks.map(t => ({ - track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''), - album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0, - source_track_id: t.id || '' - })), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl }); - } - - // Save to URL history - saveUrlHistory('deezer', rawUrl, playlist.name); - - renderDeezerPlaylists(); - await loadDeezerPlaylistStatesFromBackend(); - - urlInput.value = ''; - showToast(`Deezer playlist loaded: ${playlist.name} (${playlist.track_count || playlist.tracks.length} tracks)`, 'success'); - console.log(`🎵 Loaded Deezer playlist: ${playlist.name}`); - - } catch (error) { - showToast(`Error loading Deezer playlist: ${error.message}`, 'error'); - } finally { - if (parseBtn) { - parseBtn.disabled = false; - parseBtn.textContent = 'Load Playlist'; - } - } -} - -function renderDeezerPlaylists() { - const container = document.getElementById('deezer-playlist-container'); - if (deezerPlaylists.length === 0) { - container.innerHTML = `
    Paste a Deezer playlist URL above to get started.
    `; - return; - } - - container.innerHTML = deezerPlaylists.map(p => { - if (!deezerPlaylistStates[p.id]) { - deezerPlaylistStates[p.id] = { - phase: 'fresh', - playlist: p - }; - } - return createDeezerCard(p); - }).join(''); - - // Add click handlers to cards - deezerPlaylists.forEach(p => { - const card = document.getElementById(`deezer-card-${p.id}`); - if (card) { - card.addEventListener('click', () => handleDeezerCardClick(p.id)); - } - }); -} - -function createDeezerCard(playlist) { - const state = deezerPlaylistStates[playlist.id]; - const phase = state.phase; - - let buttonText = getActionButtonText(phase); - let phaseText = getPhaseText(phase); - let phaseColor = getPhaseColor(phase); - - return ` -
    -
    🎵
    -
    -
    ${escapeHtml(playlist.name)}
    -
    - ${playlist.track_count || playlist.tracks.length} tracks - ${phaseText} -
    -
    -
    - -
    - -
    - `; -} - -async function handleDeezerCardClick(playlistId) { - const state = deezerPlaylistStates[playlistId]; - if (!state) { - console.error(`No state found for Deezer playlist: ${playlistId}`); - showToast('Playlist state not found - try refreshing the page', 'error'); - return; - } - - if (!state.playlist) { - console.error(`No playlist data found for Deezer playlist: ${playlistId}`); - showToast('Playlist data missing - try refreshing the page', 'error'); - return; - } - - if (!state.phase) { - state.phase = 'fresh'; - } - - console.log(`🎵 [Card Click] Deezer card clicked: ${playlistId}, Phase: ${state.phase}`); - - if (state.phase === 'fresh') { - console.log(`🎵 Using pre-loaded Deezer playlist data for: ${state.playlist.name}`); - openDeezerDiscoveryModal(playlistId, state.playlist); - - } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { - console.log(`🎵 [Card Click] Opening Deezer discovery modal for ${state.phase} phase`); - - if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { - try { - const stateResponse = await fetch(`/api/deezer/state/${playlistId}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - if (fullState.discovery_results) { - state.discovery_results = fullState.discovery_results; - state.spotify_matches = fullState.spotify_matches || state.spotify_matches; - state.discovery_progress = fullState.discovery_progress || state.discovery_progress; - deezerPlaylistStates[playlistId] = { ...deezerPlaylistStates[playlistId], ...state }; - console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); - } - } - } catch (error) { - console.error(`Failed to fetch discovery results from backend: ${error}`); - } - } - - openDeezerDiscoveryModal(playlistId, state.playlist); - } else if (state.phase === 'downloading' || state.phase === 'download_complete') { - if (state.convertedSpotifyPlaylistId) { - if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { - const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; - if (process.modalElement) { - process.modalElement.style.display = 'flex'; - } else { - await rehydrateDeezerDownloadModal(playlistId, state); - } - } else { - await rehydrateDeezerDownloadModal(playlistId, state); - } - } else { - if (state.discovery_results && state.discovery_results.length > 0) { - openDeezerDiscoveryModal(playlistId, state.playlist); - } else { - showToast('Unable to open download modal - missing playlist data', 'error'); - } - } - } -} - -async function rehydrateDeezerDownloadModal(playlistId, state) { - try { - if (!state || !state.playlist) { - showToast('Cannot open download modal - invalid playlist data', 'error'); - return; - } - - const spotifyTracks = state.discovery_results - ?.filter(result => result.spotify_data) - ?.map(result => result.spotify_data) || []; - - if (spotifyTracks.length > 0) { - const virtualPlaylistId = state.convertedSpotifyPlaylistId || `deezer_${playlistId}`; - await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); - - if (state.download_process_id) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = state.download_process_id; - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - startModalDownloadPolling(virtualPlaylistId); - } - } - } else { - showToast('No Spotify tracks found for download', 'error'); - } - } catch (error) { - console.error(`Error rehydrating Deezer download modal: ${error}`); - } -} - -async function openDeezerDiscoveryModal(playlistId, playlistData) { - console.log(`🎵 Opening Deezer discovery modal (reusing YouTube modal): ${playlistData.name}`); - - const fakeUrlHash = `deezer_${playlistId}`; - - const deezerCardState = deezerPlaylistStates[playlistId]; - const isAlreadyDiscovered = deezerCardState && (deezerCardState.phase === 'discovered' || deezerCardState.phase === 'syncing' || deezerCardState.phase === 'sync_complete'); - const isCurrentlyDiscovering = deezerCardState && deezerCardState.phase === 'discovering'; - - let transformedResults = []; - let actualMatches = 0; - if (isAlreadyDiscovered && deezerCardState.discovery_results) { - transformedResults = deezerCardState.discovery_results.map((result, index) => { - const isFound = result.status === 'found' || - result.status === '✅ Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; - if (isFound) actualMatches++; - - return { - index: index, - yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown', - yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isFound ? '✅ Found' : '❌ Not Found', - status_class: isFound ? 'found' : 'not-found', - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, - spotify_id: result.spotify_id, - manual_match: result.manual_match - }; - }); - console.log(`🎵 Deezer modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); - } - - const modalPhase = deezerCardState ? deezerCardState.phase : 'fresh'; - youtubePlaylistStates[fakeUrlHash] = { - phase: modalPhase, - playlist: { - name: playlistData.name, - tracks: playlistData.tracks - }, - is_deezer_playlist: true, - deezer_playlist_id: playlistId, - discovery_progress: isAlreadyDiscovered ? 100 : 0, - spotify_matches: isAlreadyDiscovered ? actualMatches : 0, - spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, - spotify_total: playlistData.tracks.length, - discovery_results: transformedResults, - discoveryResults: transformedResults, - discoveryProgress: isAlreadyDiscovered ? 100 : 0 - }; - - if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { - try { - console.log(`🔍 Starting Deezer discovery for: ${playlistData.name}`); - - const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - console.error('Error starting Deezer discovery:', result.error); - showToast(`Error starting discovery: ${result.error}`, 'error'); - return; - } - - console.log('Deezer discovery started, beginning polling...'); - - deezerPlaylistStates[playlistId].phase = 'discovering'; - updateDeezerCardPhase(playlistId, 'discovering'); - youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; - - startDeezerDiscoveryPolling(fakeUrlHash, playlistId); - - } catch (error) { - console.error('Error starting Deezer discovery:', error); - showToast(`Error starting discovery: ${error.message}`, 'error'); - } - } else if (isCurrentlyDiscovering) { - console.log(`🔄 Resuming Deezer discovery polling for: ${playlistData.name}`); - startDeezerDiscoveryPolling(fakeUrlHash, playlistId); - } else if (deezerCardState && deezerCardState.phase === 'syncing') { - console.log(`🔄 Resuming Deezer sync polling for: ${playlistData.name}`); - startDeezerSyncPolling(fakeUrlHash); - } else { - console.log('Using existing results - no need to re-discover'); - } - - openYouTubeDiscoveryModal(fakeUrlHash); -} - -function startDeezerDiscoveryPolling(fakeUrlHash, playlistId) { - console.log(`🔄 Starting Deezer discovery polling for: ${playlistId}`); - - if (activeYouTubePollers[fakeUrlHash]) { - clearInterval(activeYouTubePollers[fakeUrlHash]); - } - - // WebSocket subscription - if (socketConnected) { - socket.emit('discovery:subscribe', { ids: [playlistId] }); - _discoveryProgressCallbacks[playlistId] = (data) => { - if (data.error) { - if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } - socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; - return; - } - const transformed = { - progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, - complete: data.complete, - results: (data.results || []).map((r, i) => { - const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; - const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); - return { - index: i, yt_track: r.deezer_track ? r.deezer_track.name : 'Unknown', - yt_artist: r.deezer_track ? (r.deezer_track.artists ? r.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), - status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), - spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), - spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'), - spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), - spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, - wing_it_fallback: isWingIt - }; - }) - }; - const st = youtubePlaylistStates[fakeUrlHash]; - if (st) { - st.discovery_progress = data.progress; st.discoveryProgress = data.progress; - st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; - st.discovery_results = data.results; st.discoveryResults = transformed.results; - st.phase = data.phase; - updateYouTubeDiscoveryModal(fakeUrlHash, transformed); - } - if (deezerPlaylistStates[playlistId]) { - deezerPlaylistStates[playlistId].phase = data.phase; - deezerPlaylistStates[playlistId].discovery_results = data.results; - deezerPlaylistStates[playlistId].spotify_matches = data.spotify_matches; - deezerPlaylistStates[playlistId].discovery_progress = data.progress; - updateDeezerCardPhase(playlistId, data.phase); - } - updateDeezerCardProgress(playlistId, data); - if (data.complete) { - if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } - socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; - } - }; - } - - const pollInterval = setInterval(async () => { - if (socketConnected) return; - try { - const response = await fetch(`/api/deezer/discovery/status/${playlistId}`); - const status = await response.json(); - - if (status.error) { - console.error('Error polling Deezer discovery status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - return; - } - - const transformedStatus = { - progress: status.progress, - spotify_matches: status.spotify_matches, - spotify_total: status.spotify_total, - complete: status.complete, - results: status.results.map((result, index) => { - const isFound = result.status === 'found' || - result.status === '✅ Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; - - return { - index: index, - yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown', - yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isFound ? '✅ Found' : '❌ Not Found', - status_class: isFound ? 'found' : 'not-found', - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, - spotify_id: result.spotify_id, - manual_match: result.manual_match - }; - }) - }; - - const state = youtubePlaylistStates[fakeUrlHash]; - if (state) { - state.discovery_progress = status.progress; - state.discoveryProgress = status.progress; - state.spotify_matches = status.spotify_matches; - state.spotifyMatches = status.spotify_matches; - state.discovery_results = status.results; - state.discoveryResults = transformedStatus.results; - state.phase = status.phase; - - updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); - - if (deezerPlaylistStates[playlistId]) { - deezerPlaylistStates[playlistId].phase = status.phase; - deezerPlaylistStates[playlistId].discovery_results = status.results; - deezerPlaylistStates[playlistId].spotify_matches = status.spotify_matches; - deezerPlaylistStates[playlistId].discovery_progress = status.progress; - updateDeezerCardPhase(playlistId, status.phase); - } - - updateDeezerCardProgress(playlistId, status); - - console.log(`🔄 Deezer discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); - } - - if (status.complete) { - console.log(`Deezer discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - } - - } catch (error) { - console.error('Error polling Deezer discovery:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - } - }, 1000); - - activeYouTubePollers[fakeUrlHash] = pollInterval; -} - -async function loadDeezerPlaylistStatesFromBackend() { - try { - console.log('🎵 Loading Deezer playlist states from backend...'); - - const response = await fetch('/api/deezer/playlists/states'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Deezer playlist states'); - } - - const data = await response.json(); - const states = data.states || []; - - console.log(`🎵 Found ${states.length} stored Deezer playlist states in backend`); - - if (states.length === 0) return; - - for (const stateInfo of states) { - await applyDeezerPlaylistState(stateInfo); - } - - // Rehydrate download modals for Deezer playlists in downloading/download_complete phases - for (const stateInfo of states) { - if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && - stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { - - const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; - - if (!activeDownloadProcesses[convertedPlaylistId]) { - console.log(`Rehydrating download modal for Deezer playlist: ${stateInfo.playlist_id}`); - try { - const playlistData = deezerPlaylists.find(p => String(p.id) === String(stateInfo.playlist_id)); - if (!playlistData) continue; - - const spotifyTracks = deezerPlaylistStates[stateInfo.playlist_id]?.discovery_results - ?.filter(result => result.spotify_data) - ?.map(result => result.spotify_data) || []; - - if (spotifyTracks.length > 0) { - await openDownloadMissingModalForTidal( - convertedPlaylistId, - playlistData.name, - spotifyTracks - ); - - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = stateInfo.download_process_id; - const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - startModalDownloadPolling(convertedPlaylistId); - } - } - } catch (error) { - console.error(`Error rehydrating Deezer download modal for ${stateInfo.playlist_id}:`, error); - } - } - } - } - - console.log('Deezer playlist states loaded and applied'); - - } catch (error) { - console.error('Error loading Deezer playlist states:', error); - } -} - -async function applyDeezerPlaylistState(stateInfo) { - const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; - - try { - console.log(`🎵 Applying saved state for Deezer playlist: ${playlist_id}, Phase: ${phase}`); - - const playlistData = deezerPlaylists.find(p => String(p.id) === String(playlist_id)); - if (!playlistData) { - console.warn(`Playlist data not found for state ${playlist_id} - skipping`); - return; - } - - if (!deezerPlaylistStates[playlist_id]) { - deezerPlaylistStates[playlist_id] = { - playlist: playlistData, - phase: 'fresh' - }; - } - - deezerPlaylistStates[playlist_id].phase = phase; - deezerPlaylistStates[playlist_id].discovery_progress = discovery_progress; - deezerPlaylistStates[playlist_id].spotify_matches = spotify_matches; - deezerPlaylistStates[playlist_id].discovery_results = discovery_results; - deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; - deezerPlaylistStates[playlist_id].download_process_id = download_process_id; - deezerPlaylistStates[playlist_id].playlist = playlistData; - - if (phase !== 'fresh' && phase !== 'discovering') { - try { - const stateResponse = await fetch(`/api/deezer/state/${playlist_id}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - if (fullState.discovery_results && deezerPlaylistStates[playlist_id]) { - deezerPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; - deezerPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; - deezerPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; - deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; - deezerPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; - } - } - } catch (error) { - console.warn(`Error fetching full discovery results for Deezer playlist ${playlistData.name}:`, error.message); - } - } - - updateDeezerCardPhase(playlist_id, phase); - - if (phase === 'discovered' && deezerPlaylistStates[playlist_id]) { - const progressInfo = { - spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, - spotify_matches: deezerPlaylistStates[playlist_id].spotify_matches || 0 - }; - updateDeezerCardProgress(playlist_id, progressInfo); - } - - if (phase === 'discovering') { - const fakeUrlHash = `deezer_${playlist_id}`; - startDeezerDiscoveryPolling(fakeUrlHash, playlist_id); - } else if (phase === 'syncing') { - const fakeUrlHash = `deezer_${playlist_id}`; - startDeezerSyncPolling(fakeUrlHash); - } - - } catch (error) { - console.error(`Error applying Deezer playlist state for ${playlist_id}:`, error); - } -} - -function updateDeezerCardPhase(playlistId, phase) { - const state = deezerPlaylistStates[playlistId]; - if (!state) return; - - state.phase = phase; - - const card = document.getElementById(`deezer-card-${playlistId}`); - if (card) { - const newCardHtml = createDeezerCard(state.playlist); - card.outerHTML = newCardHtml; - - const newCard = document.getElementById(`deezer-card-${playlistId}`); - if (newCard) { - newCard.addEventListener('click', () => handleDeezerCardClick(playlistId)); - } - - if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { - setTimeout(() => { - updateDeezerCardSyncProgress(playlistId, state.lastSyncProgress); - }, 0); - } - } -} - -function updateDeezerCardProgress(playlistId, progress) { - const state = deezerPlaylistStates[playlistId]; - if (!state) return; - - const card = document.getElementById(`deezer-card-${playlistId}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - if (!progressElement) return; - - progressElement.classList.remove('hidden'); - - const total = progress.spotify_total || 0; - const matches = progress.spotify_matches || 0; - - if (total > 0) { - progressElement.innerHTML = ` -
    - ✓ ${matches} - / - ♪ ${total} -
    - `; - } -} - -// =============================== -// DEEZER SYNC FUNCTIONALITY -// =============================== - -async function startDeezerPlaylistSync(urlHash) { - try { - console.log('🎵 Starting Deezer playlist sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_deezer_playlist) { - console.error('Invalid Deezer playlist state for sync'); - return; - } - - const playlistId = state.deezer_playlist_id; - const response = await fetch(`/api/deezer/sync/start/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error starting sync: ${result.error}`, 'error'); - return; - } - - const syncPlaylistId = result.sync_playlist_id; - if (state) state.syncPlaylistId = syncPlaylistId; - - updateDeezerCardPhase(playlistId, 'syncing'); - updateDeezerModalButtons(urlHash, 'syncing'); - - startDeezerSyncPolling(urlHash, syncPlaylistId); - - showToast('Deezer playlist sync started!', 'success'); - - } catch (error) { - console.error('Error starting Deezer sync:', error); - showToast(`Error starting sync: ${error.message}`, 'error'); - } -} - -function startDeezerSyncPolling(urlHash, syncPlaylistId) { - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - const state = youtubePlaylistStates[urlHash]; - const playlistId = state.deezer_playlist_id; - - syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); - - // WebSocket subscription - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - updateDeezerCardSyncProgress(playlistId, progress); - updateDeezerModalSyncProgress(urlHash, progress); - - if (data.status === 'finished') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; - updateDeezerCardPhase(playlistId, 'sync_complete'); - updateDeezerModalButtons(urlHash, 'sync_complete'); - showToast('Deezer playlist sync complete!', 'success'); - } else if (data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; - updateDeezerCardPhase(playlistId, 'discovered'); - updateDeezerModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - }; - } - - const pollFunction = async () => { - if (socketConnected) return; - try { - const response = await fetch(`/api/deezer/sync/status/${playlistId}`); - const status = await response.json(); - - if (status.error) { - console.error('Error polling Deezer sync status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - updateDeezerCardSyncProgress(playlistId, status.progress); - updateDeezerModalSyncProgress(urlHash, status.progress); - - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; - updateDeezerCardPhase(playlistId, 'sync_complete'); - updateDeezerModalButtons(urlHash, 'sync_complete'); - showToast('Deezer playlist sync complete!', 'success'); - } else if (status.sync_status === 'error') { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; - updateDeezerCardPhase(playlistId, 'discovered'); - updateDeezerModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); - } - } catch (error) { - console.error('Error polling Deezer sync:', error); - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - } - }; - - if (!socketConnected) pollFunction(); - - const pollInterval = setInterval(pollFunction, 1000); - activeYouTubePollers[urlHash] = pollInterval; -} - -async function cancelDeezerSync(urlHash) { - try { - console.log('Cancelling Deezer sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_deezer_playlist) { - console.error('Invalid Deezer playlist state'); - return; - } - - const playlistId = state.deezer_playlist_id; - const response = await fetch(`/api/deezer/sync/cancel/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error cancelling sync: ${result.error}`, 'error'); - return; - } - - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - const syncId = state && state.syncPlaylistId; - if (syncId && _syncProgressCallbacks[syncId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); - delete _syncProgressCallbacks[syncId]; - } - - updateDeezerCardPhase(playlistId, 'discovered'); - updateDeezerModalButtons(urlHash, 'discovered'); - - showToast('Deezer sync cancelled', 'info'); - - } catch (error) { - console.error('Error cancelling Deezer sync:', error); - showToast(`Error cancelling sync: ${error.message}`, 'error'); - } -} - -function updateDeezerCardSyncProgress(playlistId, progress) { - const state = deezerPlaylistStates[playlistId]; - if (!state || !state.playlist || !progress) return; - - state.lastSyncProgress = progress; - - const card = document.getElementById(`deezer-card-${playlistId}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - - let statusCounterHTML = ''; - if (progress && progress.total_tracks > 0) { - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const processed = matched + failed; - const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - statusCounterHTML = ` -
    - ♪ ${total} - / - ✓ ${matched} - / - ✗ ${failed} - (${percentage}%) -
    - `; - } - - if (statusCounterHTML) { - progressElement.innerHTML = statusCounterHTML; - } -} - -function updateDeezerModalSyncProgress(urlHash, progress) { - const statusDisplay = document.getElementById(`deezer-sync-status-${urlHash}`); - if (!statusDisplay || !progress) return; - - const totalEl = document.getElementById(`deezer-total-${urlHash}`); - const matchedEl = document.getElementById(`deezer-matched-${urlHash}`); - const failedEl = document.getElementById(`deezer-failed-${urlHash}`); - const percentageEl = document.getElementById(`deezer-percentage-${urlHash}`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - - if (total > 0) { - const processed = matched + failed; - const percentage = Math.round((processed / total) * 100); - if (percentageEl) percentageEl.textContent = percentage; - } -} - -function updateDeezerModalButtons(urlHash, phase) { - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (!modal) return; - - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } -} - -async function startDeezerDownloadMissing(urlHash) { - try { - console.log('🔍 Starting download missing tracks for Deezer playlist:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_deezer_playlist) { - console.error('Invalid Deezer playlist state for download'); - return; - } - - const discoveryResults = state.discoveryResults || state.discovery_results; - - if (!discoveryResults) { - showToast('No discovery results available for download', 'error'); - return; - } - - const spotifyTracks = []; - for (const result of discoveryResults) { - if (result.spotify_data) { - spotifyTracks.push(result.spotify_data); - } else if (result.spotify_track && result.status_class === 'found') { - const albumData = result.spotify_album || 'Unknown Album'; - const albumObject = typeof albumData === 'object' && albumData !== null - ? albumData - : { - name: typeof albumData === 'string' ? albumData : 'Unknown Album', - album_type: 'album', - images: [] - }; - - spotifyTracks.push({ - id: result.spotify_id || 'unknown', - name: result.spotify_track || 'Unknown Track', - artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], - album: albumObject, - duration_ms: 0 - }); - } - } - - if (spotifyTracks.length === 0) { - showToast('No Spotify matches found for download', 'error'); - return; - } - - const virtualPlaylistId = `deezer_${state.deezer_playlist_id}`; - const playlistName = state.playlist.name; - - state.convertedSpotifyPlaylistId = virtualPlaylistId; - - const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (discoveryModal) { - discoveryModal.classList.add('hidden'); - } - - await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); - - } catch (error) { - console.error('Error starting download missing tracks:', error); - showToast(`Error starting downloads: ${error.message}`, 'error'); - } -} - - -// =============================== -// SYNC PAGE FUNCTIONALITY (REDESIGNED) -// =============================== - -function initializeSyncPage() { - // Logic for tab switching - const tabButtons = document.querySelectorAll('.sync-tab-button'); - const syncSidebar = document.querySelector('.sync-sidebar'); - const syncContentArea = document.querySelector('.sync-content-area'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabId = button.dataset.tab; - - // Update button active state - tabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content active state - document.querySelectorAll('.sync-tab-content').forEach(content => { - content.classList.remove('active'); - }); - document.getElementById(`${tabId}-tab-content`).classList.add('active'); - - // Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden) - if (syncSidebar && syncContentArea) { - const isMobile = window.innerWidth <= 1300; - // Sidebar always hidden by default — shown only when sync is active - syncSidebar.style.display = 'none'; - syncContentArea.style.gridTemplateColumns = '1fr'; - } - - // Auto-load Deezer ARL playlists on first tab activation - if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) { - // Check ARL status first - fetch('/api/deezer/arl-status').then(r => r.json()).then(data => { - const container = document.getElementById('deezer-arl-playlist-container'); - if (data.authenticated) { - loadDeezerArlPlaylists(); - } else if (container) { - container.innerHTML = `
    Deezer ARL not configured. Add your ARL token in Settings > Downloads to see your playlists here.
    `; - } - }).catch(() => { }); - } - - // Auto-load mirrored playlists on first tab activation - if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) { - loadMirroredPlaylists(); - } - - // Auto-load server playlists on first tab activation - if (tabId === 'server' && !window._serverPlaylistsLoaded) { - window._serverPlaylistsLoaded = true; - loadServerPlaylists(); - } - - // Refresh Beatport download bubbles when switching to the Beatport tab - if (tabId === 'beatport') { - showBeatportDownloadsSection(); - } - }); - }); - - // Logic for the Spotify refresh button - const refreshBtn = document.getElementById('spotify-refresh-btn'); - if (refreshBtn) { - // Remove any old listeners to be safe, then add the new one - refreshBtn.removeEventListener('click', loadSpotifyPlaylists); - refreshBtn.addEventListener('click', loadSpotifyPlaylists); - } - - // Logic for the Tidal refresh button - const tidalRefreshBtn = document.getElementById('tidal-refresh-btn'); - if (tidalRefreshBtn) { - tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists); - tidalRefreshBtn.addEventListener('click', loadTidalPlaylists); - } - - // Logic for the Deezer ARL refresh button - const deezerArlRefreshBtn = document.getElementById('deezer-arl-refresh-btn'); - if (deezerArlRefreshBtn) { - deezerArlRefreshBtn.removeEventListener('click', loadDeezerArlPlaylists); - deezerArlRefreshBtn.addEventListener('click', loadDeezerArlPlaylists); - } - - // Logic for the Deezer Link parse button - const deezerParseBtn = document.getElementById('deezer-parse-btn'); - if (deezerParseBtn) { - deezerParseBtn.addEventListener('click', loadDeezerPlaylist); - } - // Also allow Enter key in the Deezer input - const deezerUrlInput = document.getElementById('deezer-url-input'); - if (deezerUrlInput) { - deezerUrlInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') loadDeezerPlaylist(); - }); - } - - // Logic for the Mirrored refresh button - const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn'); - if (mirroredRefreshBtn) { - mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists); - } - - // Initialize import file tab - _initImportFileTab(); - - // Logic for the Beatport clear button - const beatportClearBtn = document.getElementById('beatport-clear-btn'); - if (beatportClearBtn) { - beatportClearBtn.addEventListener('click', clearBeatportPlaylists); - // Set initial clear button state - updateBeatportClearButtonState(); - } - - // Logic for Beatport nested tabs - const beatportTabButtons = document.querySelectorAll('.beatport-tab-button'); - beatportTabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabId = button.dataset.beatportTab; - - // Update button active state - beatportTabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content active state - document.querySelectorAll('.beatport-tab-content').forEach(content => { - content.classList.remove('active'); - }); - document.getElementById(`beatport-${tabId}-content`).classList.add('active'); - - // Initialize rebuild slider if rebuild tab is selected - if (tabId === 'rebuild') { - initializeBeatportRebuildSlider(); - loadBeatportTop10Lists(); - loadBeatportTop10Releases(); - initializeBeatportReleasesSlider(); - initializeBeatportHypePicksSlider(); - initializeBeatportChartsSlider(); - initializeBeatportDJSlider(); - showBeatportDownloadsSection(); - } - }); - }); - - // Logic for Homepage Genre Explorer card - const genreExplorerCard = document.querySelector('[data-action="show-genres"]'); - if (genreExplorerCard) { - genreExplorerCard.addEventListener('click', () => { - console.log('🎵 Genre Explorer card clicked'); - showBeatportSubView('genres'); - loadBeatportGenres(); - }); - } - - // Setup homepage chart handlers (following genre page pattern to prevent duplicates) - setupHomepageChartTypeHandlers(); - - // Load homepage chart collections automatically (disabled since Browse Charts tab is hidden) - // loadDJChartsInline(); - // loadFeaturedChartsInline(); - - // Logic for Beatport breadcrumb back buttons - const beatportBackButtons = document.querySelectorAll('.breadcrumb-back'); - beatportBackButtons.forEach(button => { - button.addEventListener('click', () => { - // Handle different back button types - if (button.id === 'genre-detail-back') { - showBeatportGenresView(); - } else if (button.id === 'genre-charts-list-back') { - showBeatportGenreDetailViewFromBack(); - } else { - showBeatportMainView(); - } - }); - }); - - // Logic for Beatport chart items - const beatportChartItems = document.querySelectorAll('.beatport-chart-item'); - beatportChartItems.forEach(item => { - item.addEventListener('click', () => { - const chartType = item.dataset.chartType; - const chartId = item.dataset.chartId; - const chartName = item.dataset.chartName; - const chartEndpoint = item.dataset.chartEndpoint; - handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint); - }); - }); - - // Logic for Beatport genre items - const beatportGenreItems = document.querySelectorAll('.beatport-genre-item'); - beatportGenreItems.forEach(item => { - item.addEventListener('click', () => { - const genreSlug = item.dataset.genreSlug; - const genreId = item.dataset.genreId; - handleBeatportGenreClick(genreSlug, genreId); - }); - }); - - // Logic for Rebuild page Top 10 containers - Beatport Top 10 - const beatportTop10Container = document.getElementById('beatport-top10-list'); - if (beatportTop10Container) { - beatportTop10Container.addEventListener('click', () => { - console.log('🎵 Beatport Top 10 container clicked on rebuild page'); - handleRebuildBeatportTop10Click(); - }); - } - - // Logic for Rebuild page Top 10 containers - Hype Top 10 - const beatportHype10Container = document.getElementById('beatport-hype10-list'); - if (beatportHype10Container) { - beatportHype10Container.addEventListener('click', () => { - console.log('🔥 Hype Top 10 container clicked on rebuild page'); - handleRebuildHypeTop10Click(); - }); - } - - // Logic for Rebuild page Hero Slider - individual slide click handlers will be set up in populateBeatportSlider - // Container-level click handler removed to allow individual slide clicks like top 10 releases - - // Logic for the Start Sync button - const startSyncBtn = document.getElementById('start-sync-btn'); - if (startSyncBtn) { - startSyncBtn.addEventListener('click', startSequentialSync); - } - - // Logic for the YouTube parse button - const youtubeParseBtn = document.getElementById('youtube-parse-btn'); - if (youtubeParseBtn) { - youtubeParseBtn.addEventListener('click', parseYouTubePlaylist); - } - - // Logic for YouTube URL input (Enter key support) - const youtubeUrlInput = document.getElementById('youtube-url-input'); - if (youtubeUrlInput) { - youtubeUrlInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - parseYouTubePlaylist(); - } - }); - } - - // Logic for Spotify Public parse button - const spotifyPublicParseBtn = document.getElementById('spotify-public-parse-btn'); - if (spotifyPublicParseBtn) { - spotifyPublicParseBtn.addEventListener('click', parseSpotifyPublicUrl); - } - - // Logic for Spotify Public URL input (Enter key support) - const spotifyPublicUrlInput = document.getElementById('spotify-public-url-input'); - if (spotifyPublicUrlInput) { - spotifyPublicUrlInput.addEventListener('keypress', (e) => { - if (e.key === 'Enter') { - parseSpotifyPublicUrl(); - } - }); - } - - // Logic for Beatport Top 100 button - const beatportTop100Btn = document.getElementById('beatport-top100-btn'); - if (beatportTop100Btn) { - beatportTop100Btn.addEventListener('click', handleBeatportTop100Click); - } - - // Logic for Hype Top 100 button - const hypeTop100Btn = document.getElementById('hype-top100-btn'); - if (hypeTop100Btn) { - hypeTop100Btn.addEventListener('click', handleHypeTop100Click); - } - - // Initialize live log viewer - initializeLiveLogViewer(); -} - - -// --- Event Handlers --- - -// --- Find and REPLACE the existing handleDbUpdateButtonClick function --- - -async function handleDbUpdateButtonClick() { - const button = document.getElementById('db-update-button'); - const currentAction = button.textContent; - - if (currentAction === 'Update Database') { - const refreshSelect = document.getElementById('db-refresh-type'); - const isFullRefresh = refreshSelect.value === 'full'; - - if (isFullRefresh) { - // Replicates the QMessageBox confirmation from the GUI - const confirmed = await showConfirmDialog({ title: 'Full Refresh', message: 'This will clear and rebuild the database for the active server. It can take a long time.\n\nAre you sure you want to proceed?', confirmText: 'Proceed' }); - if (!confirmed) return; - } - - try { - button.disabled = true; - button.textContent = 'Starting...'; - const response = await fetch('/api/database/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ full_refresh: isFullRefresh }) - }); - - if (response.ok) { - showToast('Database update started!', 'success'); - // Start polling immediately to get live status - checkAndUpdateDbProgress(); - } else { - const errorData = await response.json(); - showToast(`Error: ${errorData.error}`, 'error'); - button.disabled = false; - button.textContent = 'Update Database'; - } - } catch (error) { - showToast('Failed to start update process.', 'error'); - button.disabled = false; - button.textContent = 'Update Database'; - } - - } else { // "Stop Update" - try { - const response = await fetch('/api/database/update/stop', { method: 'POST' }); - if (response.ok) { - showToast('Stop request sent.', 'info'); - } else { - showToast('Failed to send stop request.', 'error'); - } - } catch (error) { - showToast('Error sending stop request.', 'error'); - } - } -} - -async function handleWishlistButtonClick() { - try { - const playlistId = 'wishlist'; - - console.log('🎵 [Wishlist Button] User clicked wishlist button - checking server state first'); - - // STEP 1: Always check server state first to detect any active wishlist processes - const response = await fetch('/api/active-processes'); - if (!response.ok) { - throw new Error(`Failed to fetch active processes: ${response.status}`); - } - - const data = await response.json(); - const processes = data.active_processes || []; - const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); - - // STEP 2: Handle active server process - show current state immediately - if (serverWishlistProcess) { - console.log('🎯 [Wishlist Button] Server has active wishlist process:', { - batch_id: serverWishlistProcess.batch_id, - phase: serverWishlistProcess.phase, - auto_initiated: serverWishlistProcess.auto_initiated, - should_show: serverWishlistProcess.should_show_modal - }); - - // Clear any user-closed state since user explicitly requested to see modal - WishlistModalState.clearUserClosed(); - - // Check if we need to create/sync the frontend modal - const clientWishlistProcess = activeDownloadProcesses[playlistId]; - const needsRehydration = !clientWishlistProcess || - clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || - !clientWishlistProcess.modalElement || - !document.body.contains(clientWishlistProcess.modalElement); - - if (needsRehydration) { - console.log('🔄 [Wishlist Button] Frontend modal needs sync/creation'); - await rehydrateModal(serverWishlistProcess, true); // user-requested = true - } else { - console.log('✅ [Wishlist Button] Frontend modal already synced, showing existing modal'); - clientWishlistProcess.modalElement.style.display = 'flex'; - WishlistModalState.setVisible(); - } - return; - } - - // STEP 3: No active server process - check wishlist count and create fresh modal - console.log('📭 [Wishlist Button] No active server process, checking wishlist content'); - - const countResponse = await fetch('/api/wishlist/count'); - if (!countResponse.ok) { - throw new Error(`Failed to fetch wishlist count: ${countResponse.status}`); - } - - const countData = await countResponse.json(); - if (countData.count === 0) { - showToast('Wishlist is empty. No tracks to download.', 'info'); - return; - } - - // STEP 4: Open wishlist overview modal (NEW - category selection) - console.log(`🆕 [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`); - await openWishlistOverviewModal(); - - } catch (error) { - console.error('❌ [Wishlist Button] Error handling wishlist button click:', error); - showToast(`Error opening wishlist: ${error.message}`, 'error'); - } -} - -async function cleanupWishlist(playlistId) { - try { - // Show information dialog - const confirmed = await showConfirmDialog({ - title: 'Cleanup Wishlist', - message: 'This will check all wishlist tracks against your music library and automatically remove any tracks that already exist in your database.\n\nThis is a safe operation that only removes tracks you already have. Continue with cleanup?' - }); - - if (!confirmed) { - return; - } - - // Disable the cleanup button during the operation - const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); - if (cleanupBtn) { - cleanupBtn.disabled = true; - cleanupBtn.textContent = '🧹 Cleaning...'; - } - - const response = await fetch('/api/wishlist/cleanup', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - const result = await response.json(); - - if (result.success) { - const removedCount = result.removed_count || 0; - const processedCount = result.processed_count || 0; - - if (removedCount > 0) { - showToast(`Wishlist cleanup completed: ${removedCount} tracks removed (${processedCount} checked)`, 'success'); - - // Refresh the modal content to show updated state - setTimeout(() => { - openDownloadMissingWishlistModal(); - }, 500); - - // Update the wishlist count in the main dashboard - await updateWishlistCount(); - } else { - showToast(`Wishlist cleanup completed: No tracks to remove (${processedCount} checked)`, 'info'); - } - } else { - showToast(`Error cleaning wishlist: ${result.error}`, 'error'); - } - - } catch (error) { - console.error('Error cleaning wishlist:', error); - showToast(`Error cleaning wishlist: ${error.message}`, 'error'); - } finally { - // Re-enable the cleanup button - const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); - if (cleanupBtn) { - cleanupBtn.disabled = false; - cleanupBtn.textContent = '🧹 Cleanup Wishlist'; - } - } -} - -async function clearWishlist(playlistId) { - try { - // Show confirmation dialog - const confirmed = await showConfirmDialog({ - title: 'Clear Wishlist', - message: 'Are you sure you want to clear the entire wishlist?\n\nThis will permanently remove all failed tracks from the wishlist. This action cannot be undone.', - confirmText: 'Clear All', - destructive: true - }); - - if (!confirmed) { - return; - } - - // Disable the clear button during the operation - const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`); - if (clearBtn) { - clearBtn.disabled = true; - clearBtn.textContent = 'Clearing...'; - } - - // Call the clear API endpoint - const response = await fetch('/api/wishlist/clear', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - } - }); - - const result = await response.json(); - - if (result.success) { - showToast('Wishlist cleared successfully', 'success'); - - // Close the modal since there are no more tracks - closeDownloadMissingModal(playlistId); - - // Update the wishlist count in the main dashboard - await updateWishlistCount(); - - } else { - showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error'); - } - - } catch (error) { - console.error('Error clearing wishlist:', error); - showToast(`Error clearing wishlist: ${error.message}`, 'error'); - } finally { - // Re-enable the clear button - const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`); - if (clearBtn) { - clearBtn.disabled = false; - clearBtn.textContent = '🗑️ Clear Wishlist'; - } - } -} - - -// =============================== -// BEATPORT CHARTS FUNCTIONALITY -// =============================== - -function updateBeatportClearButtonState() { - const clearBtn = document.getElementById('beatport-clear-btn'); - if (!clearBtn) return; - - // Check if any Beatport cards are in active states - const activeCharts = Object.values(beatportChartStates).filter(state => - state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading' - ); - - const hasActiveCharts = activeCharts.length > 0; - const hasAnyCharts = Object.keys(beatportChartStates).length > 0; - - if (!hasAnyCharts) { - // No charts at all - clearBtn.disabled = true; - clearBtn.textContent = '🗑️ Clear'; - clearBtn.style.opacity = '0.5'; - clearBtn.style.cursor = 'not-allowed'; - clearBtn.title = 'No Beatport charts to clear'; - } else if (hasActiveCharts) { - // Has charts but some are active - clearBtn.disabled = true; - clearBtn.textContent = '🚫 Clear Blocked'; - clearBtn.style.opacity = '0.6'; - clearBtn.style.cursor = 'not-allowed'; - const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', '); - clearBtn.title = `Cannot clear: ${activeCharts.length} chart(s) are currently active: ${activeNames}`; - } else { - // Has charts and none are active - clearBtn.disabled = false; - clearBtn.textContent = '🗑️ Clear'; - clearBtn.style.opacity = '1'; - clearBtn.style.cursor = 'pointer'; - clearBtn.title = 'Clear all Beatport charts'; - } -} - -async function clearBeatportPlaylists() { - const container = document.getElementById('beatport-playlist-container'); - const clearBtn = document.getElementById('beatport-clear-btn'); - - if (Object.keys(beatportChartStates).length === 0) { - showToast('No Beatport playlists to clear', 'info'); - return; - } - - // Check if any Beatport cards are in active states (discovering, syncing, or downloading) - const activeCharts = Object.values(beatportChartStates).filter(state => - state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading' - ); - - if (activeCharts.length > 0) { - const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', '); - showToast(`Cannot clear: ${activeCharts.length} chart(s) are currently discovering, syncing, or downloading: ${activeNames}`, 'warning'); - return; - } - - // Show loading state - clearBtn.disabled = true; - clearBtn.textContent = '🗑️ Clearing...'; - - try { - // Clear all Beatport chart states - Object.keys(beatportChartStates).forEach(chartHash => { - // Close any open modals for this chart - const modal = document.getElementById(`youtube-discovery-modal-${chartHash}`); - if (modal) { - modal.remove(); - } - - // Remove from YouTube states (since Beatport reuses that infrastructure) - if (youtubePlaylistStates[chartHash]) { - // Clean up any active download processes for this Beatport chart - const ytState = youtubePlaylistStates[chartHash]; - if (ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) { - const downloadProcess = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]; - if (downloadProcess) { - console.log(`🗑️ Cleaning up download process for Beatport chart: ${chartHash}`); - if (downloadProcess.modalElement) { - downloadProcess.modalElement.remove(); - } - delete activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]; - } - } - - delete youtubePlaylistStates[chartHash]; - } - }); - - // Clear Beatport states - const chartHashesToClear = Object.keys(beatportChartStates); - beatportChartStates = {}; - - // Clear backend state for all charts - for (const chartHash of chartHashesToClear) { - try { - await fetch(`/api/beatport/charts/delete/${chartHash}`, { - method: 'DELETE' - }); - console.log(`🗑️ Deleted backend state for Beatport chart: ${chartHash}`); - } catch (error) { - console.warn(`⚠️ Error deleting backend state for chart ${chartHash}:`, error); - } - } - - // Reset container to placeholder - container.innerHTML = ` -
    Your created Beatport playlists will appear here.
    - `; - - console.log(`🗑️ Cleared ${chartHashesToClear.length} Beatport charts from frontend and backend`); - showToast('Cleared all Beatport playlists', 'success'); - - // Update clear button state after clearing all charts - updateBeatportClearButtonState(); - - } catch (error) { - console.error('Error clearing Beatport playlists:', error); - showToast(`Error clearing playlists: ${error.message}`, 'error'); - } finally { - clearBtn.disabled = false; - clearBtn.textContent = '🗑️ Clear'; - } -} - -function handleBeatportCategoryClick(category) { - console.log(`🎵 Beatport category clicked: ${category}`); - - // Only handle genres category now - homepage has direct chart buttons - switch (category) { - case 'genres': - showBeatportSubView('genres'); - loadBeatportGenres(); // Load genres dynamically - break; - default: - showToast(`Unknown category: ${category}`, 'error'); - } -} - -async function loadBeatportGenres() { - console.log('🔍 Loading Beatport genres dynamically...'); - - const genreGrid = document.querySelector('#beatport-genres-view .beatport-genre-grid'); - if (!genreGrid) { - console.error('❌ Could not find genre grid element'); - return; - } - - // Show loading state - genreGrid.innerHTML = ` -
    -
    -

    🔍 Discovering current Beatport genres...

    -
    - `; - - try { - // First, fetch genres quickly without images - console.log('🚀 Fetching genres without images for fast loading...'); - const fastResponse = await fetch('/api/beatport/genres'); - if (!fastResponse.ok) { - throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`); - } - - const fastData = await fastResponse.json(); - const genres = fastData.genres || []; - - if (genres.length === 0) { - genreGrid.innerHTML = ` -
    -

    ⚠️ No genres available

    - -
    - `; - return; - } - - // Generate genre cards dynamically (without images first) - const genreCardsHTML = genres.map(genre => ` -
    -
    🎵
    -

    ${genre.name}

    - Top 100 -
    - `).join(''); - - genreGrid.innerHTML = genreCardsHTML; - - // Add click handlers to dynamically created genre items - const genreItems = genreGrid.querySelectorAll('.beatport-genre-item'); - genreItems.forEach(item => { - item.addEventListener('click', () => { - const genreSlug = item.dataset.genreSlug; - const genreId = item.dataset.genreId; - const genreName = item.dataset.genreName; - handleBeatportGenreClick(genreSlug, genreId, genreName); - }); - }); - - console.log(`✅ Loaded ${genres.length} Beatport genres dynamically (fast mode)`); - showToast(`Loaded ${genres.length} current Beatport genres`, 'success'); - - // Now fetch images progressively in the background if there are many genres - if (genres.length > 10) { - console.log('🖼️ Loading genre images progressively...'); - loadGenreImagesProgressively(genres); - } - - } catch (error) { - console.error('❌ Error loading Beatport genres:', error); - genreGrid.innerHTML = ` -
    -

    ❌ Failed to load genres: ${error.message}

    - -
    - `; - showToast(`Error loading Beatport genres: ${error.message}`, 'error'); - } -} - -async function loadGenreImagesProgressively(genres) { - // Load genre images with 2 concurrent workers for faster loading - - const imageQueue = [...genres]; // Create a copy for processing - let imagesLoaded = 0; - const maxWorkers = 2; - - console.log(`🖼️ Starting progressive image loading with ${maxWorkers} workers for ${imageQueue.length} genres`); - - // Function to process a single image - async function processImage(genre) { - try { - // Fetch individual genre image from backend - const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`); - - if (response.ok) { - const data = await response.json(); - - if (data.success && data.image_url) { - // Find the genre item in the DOM - const genreItem = document.querySelector( - `[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` - ); - - if (genreItem) { - const iconElement = genreItem.querySelector('.genre-icon'); - if (iconElement) { - // Create new image element with smooth transition - const imageDiv = document.createElement('div'); - imageDiv.className = 'genre-image'; - imageDiv.style.backgroundImage = `url('${data.image_url}')`; - imageDiv.style.opacity = '0'; - imageDiv.style.transition = 'opacity 0.3s ease'; - - // Replace icon with image - iconElement.replaceWith(imageDiv); - - // Trigger fade-in animation - setTimeout(() => { - imageDiv.style.opacity = '1'; - }, 50); - - imagesLoaded++; - console.log(`🖼️ [${imagesLoaded}/${imageQueue.length}] Loaded image for ${genre.name}`); - } - } - } - } - } catch (error) { - console.warn(`⚠️ Failed to load image for ${genre.name}:`, error); - } - } - - // Worker function that processes images from the queue - async function imageWorker(workerId) { - while (imageQueue.length > 0) { - const genre = imageQueue.shift(); // Take next image from queue - if (genre) { - await processImage(genre); - - // Small delay between requests to be respectful (500ms per worker = ~2 images per second total) - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - console.log(`✅ Worker ${workerId} finished`); - } - - // Start the workers - const workers = []; - for (let i = 0; i < maxWorkers; i++) { - workers.push(imageWorker(i + 1)); - } - - // Wait for all workers to complete - await Promise.all(workers); - - console.log(`✅ Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`); -} - -function setupHomepageChartTypeHandlers() { - console.log('🔧 Setting up homepage chart type handlers...'); - - // Select all homepage chart type cards (following genre page pattern) - const chartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]'); - - chartTypeCards.forEach(card => { - // Remove existing listeners by cloning (following genre page pattern) - card.replaceWith(card.cloneNode(true)); - }); - - // Re-select after cloning to ensure clean event listeners (following genre page pattern) - const newChartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]'); - - newChartTypeCards.forEach(card => { - card.addEventListener('click', () => { - const chartType = card.dataset.chartType; - const chartEndpoint = card.dataset.chartEndpoint; - const chartName = card.querySelector('.chart-type-info h3').textContent; - console.log(`🔥 Homepage chart clicked: ${chartName} (${chartType})`); - handleHomepageChartTypeClick(chartType, chartEndpoint, chartName); - }); - }); - - console.log(`✅ Setup ${newChartTypeCards.length} homepage chart handlers`); -} - -async function handleHomepageChartTypeClick(chartType, chartEndpoint, chartName) { - console.log(`🔥 Homepage chart type clicked: ${chartType} (${chartName})`); - - // Map chart types to API endpoints and create descriptive names (following genre page pattern) - const chartTypeMap = { - 'top-10': { - endpoint: `/api/beatport/top-100`, // Use top-100 endpoint and limit to 10 - name: `Beatport Top 10`, - limit: 10 - }, - 'top-100': { - endpoint: `/api/beatport/top-100`, - name: `Beatport Top 100`, - limit: 100 - }, - 'releases-top-10': { - endpoint: `/api/beatport/homepage/top-10-releases`, // Working route - name: `Top 10 Releases`, - limit: 10 - }, - 'releases-top-100': { - endpoint: `/api/beatport/top-100-releases`, - name: `Top 100 Releases`, - limit: 100 - }, - 'latest-releases': { - endpoint: `/api/beatport/homepage/new-releases`, // Use new-releases as fallback for now - name: `Latest Releases`, - limit: 50 - }, - 'hype-top-10': { - endpoint: `/api/beatport/hype-top-100`, // Use hype-100 endpoint and limit to 10 - name: `Hype Top 10`, - limit: 10 - }, - 'hype-top-100': { - endpoint: `/api/beatport/hype-top-100`, - name: `Hype Top 100`, - limit: 100 - }, - 'hype-picks': { - endpoint: `/api/beatport/homepage/hype-picks`, // Working route - name: `Hype Picks`, - limit: 50 - } - }; - - const chartConfig = chartTypeMap[chartType]; - if (!chartConfig) { - console.error(`❌ Unknown homepage chart type: ${chartType}`); - showToast(`Unknown chart type: ${chartType}`, 'error'); - return; - } - - try { - showToast(`Loading ${chartConfig.name}...`, 'info'); - showLoadingOverlay(`Loading ${chartConfig.name}...`); - - const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`); - if (!response.ok) { - throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error(`No tracks found in ${chartConfig.name}`); - } - - console.log(`✅ Fetched ${data.tracks.length} tracks from ${chartConfig.name}`); - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(data.tracks, chartConfig.name, null); - - } catch (error) { - console.error(`❌ Error loading ${chartConfig.name}:`, error); - hideLoadingOverlay(); - showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error'); - } -} - - - -async function openBeatportDiscoveryModal(chartHash, chartData) { - console.log(`🎵 Opening Beatport discovery modal (reusing YouTube modal): ${chartData.name}`); - - // Create YouTube-style state entry for this Beatport chart - const beatportState = { - phase: 'fresh', - playlist: { - name: chartData.name, - tracks: chartData.tracks, - description: `${chartData.track_count} tracks from ${chartData.name}`, - source: 'beatport' - }, - is_beatport_playlist: true, - beatport_chart_type: chartData.chart_type, - beatport_chart_hash: chartHash // Link to Beatport card state - }; - - // Store in YouTube playlist states (reusing the infrastructure) - youtubePlaylistStates[chartHash] = beatportState; - - // Start discovery automatically (like Tidal does) - try { - console.log(`🔍 Starting Beatport discovery for: ${chartData.name}`); - - // Update card phase to discovering immediately - updateBeatportCardPhase(chartHash, 'discovering'); - - // Call the discovery start endpoint with chart data - const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - chart_data: chartData - }) - }); - - const result = await response.json(); - if (result.success) { - // Update state to discovering - youtubePlaylistStates[chartHash].phase = 'discovering'; - - // Start polling for progress - startBeatportDiscoveryPolling(chartHash); - - console.log(`✅ Started Beatport discovery for: ${chartData.name}`); - } else { - console.error('❌ Error starting Beatport discovery:', result.error); - showToast(`Error starting discovery: ${result.error}`, 'error'); - // Revert card phase on error - updateBeatportCardPhase(chartHash, 'fresh'); - } - } catch (error) { - console.error('❌ Error starting Beatport discovery:', error); - showToast(`Error starting discovery: ${error.message}`, 'error'); - // Revert card phase on error - updateBeatportCardPhase(chartHash, 'fresh'); - } - - // Open the existing YouTube discovery modal infrastructure - openYouTubeDiscoveryModal(chartHash); - - console.log(`✅ Beatport discovery modal opened for ${chartData.name} with ${chartData.tracks.length} tracks`); -} - -function startBeatportDiscoveryPolling(urlHash) { - console.log(`🔄 Starting Beatport discovery polling for: ${urlHash}`); - - // Stop any existing polling (reuse YouTube polling infrastructure) - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('discovery:subscribe', { ids: [urlHash] }); - _discoveryProgressCallbacks[urlHash] = (data) => { - if (data.error) { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; - return; - } - if (youtubePlaylistStates[urlHash]) { - const transformed = { - progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0, - results: (data.results || []).map((r, i) => ({ - index: r.index !== undefined ? r.index : i, - yt_track: r.beatport_track ? r.beatport_track.title : 'Unknown', - yt_artist: r.beatport_track ? r.beatport_track.artist : 'Unknown', - status: (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found') ? '✅ Found' : (r.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: r.status_class || ((r.status === 'found' || r.status === '✅ Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')), - spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), - spotify_artist: r.spotify_data && r.spotify_data.artists ? r.spotify_data.artists.map(a => a.name || a).join(', ') : (r.spotify_artist || '-'), - spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), - spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match - })) - }; - const st = youtubePlaylistStates[urlHash]; - st.discovery_progress = data.progress; st.discoveryProgress = data.progress; - st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; - st.discovery_results = data.results; st.discoveryResults = transformed.results; - st.phase = data.phase || 'discovering'; - const chartHash = st.beatport_chart_hash || urlHash; - updateBeatportCardPhase(chartHash, data.phase || 'discovering'); - updateBeatportCardProgress(chartHash, { spotify_total: data.spotify_total || 0, spotify_matches: data.spotify_matches || 0, failed: (data.spotify_total || 0) - (data.spotify_matches || 0) }); - if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = data.phase || 'discovering'; - updateYouTubeDiscoveryModal(urlHash, transformed); - } - if (data.phase === 'discovered' || data.phase === 'error') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; - } - }; - } - - const pollInterval = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/beatport/discovery/status/${urlHash}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling Beatport discovery status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - // Update state and modal (reuse YouTube infrastructure like Tidal) - if (youtubePlaylistStates[urlHash]) { - // Transform Beatport results to YouTube modal format (like Tidal does) - const transformedStatus = { - progress: status.progress || 0, - spotify_matches: status.spotify_matches || 0, - spotify_total: status.spotify_total || 0, - results: (status.results || []).map((result, index) => ({ - index: result.index !== undefined ? result.index : index, - yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', - yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', - status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - result.spotify_data.artists.map(a => a.name || a).join(', ') : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, // Pass through - spotify_id: result.spotify_id, // Pass through - manual_match: result.manual_match // Pass through - })) - }; - - // Update state with both backend and frontend formats (like Tidal) - const state = youtubePlaylistStates[urlHash]; - state.discovery_progress = status.progress; // Backend format - state.discoveryProgress = status.progress; // Frontend format - for modal progress display - state.spotify_matches = status.spotify_matches; // Backend format - state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic - state.discovery_results = status.results; // Backend format - state.discoveryResults = transformedStatus.results; // Frontend format - for button logic - state.phase = status.phase || 'discovering'; - - // Update Beatport card phase and progress - const chartHash = state.beatport_chart_hash || urlHash; - updateBeatportCardPhase(chartHash, status.phase || 'discovering'); - updateBeatportCardProgress(chartHash, { - spotify_total: status.spotify_total || 0, - spotify_matches: status.spotify_matches || 0, - failed: (status.spotify_total || 0) - (status.spotify_matches || 0) - }); - - // Sync with backend Beatport chart state - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = status.phase || 'discovering'; - } - - // Update modal display with transformed data - updateYouTubeDiscoveryModal(urlHash, transformedStatus); - } - - // Stop polling when discovery is complete - if (status.phase === 'discovered' || status.phase === 'error') { - console.log(`✅ Beatport discovery polling complete for: ${urlHash}`); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - } - - } catch (error) { - console.error('❌ Error polling Beatport discovery:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - } - }, 2000); // Poll every 2 seconds like Tidal - - // Store the interval so we can clean it up later - activeYouTubePollers[urlHash] = pollInterval; -} - -function showBeatportSubView(viewType) { - // Hide main category view - const mainView = document.getElementById('beatport-main-view'); - if (mainView) { - mainView.classList.remove('active'); - } - - // Hide all sub-views - document.querySelectorAll('.beatport-sub-view').forEach(view => { - view.classList.remove('active'); - }); - - // Show the requested sub-view - const targetView = document.getElementById(`beatport-${viewType}-view`); - if (targetView) { - targetView.classList.add('active'); - console.log(`🎵 Showing Beatport ${viewType} view`); - } else { - console.error(`🎵 Could not find view: beatport-${viewType}-view`); - } -} - -function showBeatportMainView() { - // Hide all sub-views - document.querySelectorAll('.beatport-sub-view').forEach(view => { - view.classList.remove('active'); - }); - - // Show main category view - const mainView = document.getElementById('beatport-main-view'); - if (mainView) { - mainView.classList.add('active'); - console.log('🎵 Showing Beatport main view'); - } -} - -// =============================== -// REBUILD PAGE TOP 10 FUNCTIONALITY -// =============================== - -// Global variable to store rebuild page track data for reuse -let rebuildPageTrackData = { - beatport_top10: null, - hype_top10: null - // hero_slider removed - now uses individual slide click handlers -}; - -async function handleRebuildBeatportTop10Click() { - console.log('🎵 Handling Beatport Top 10 click on rebuild page'); - - // Use the existing chart creation pattern from Browse Charts EXACTLY - await handleRebuildChartClick('beatport_top10', 'Beatport Top 10', 'rebuild_beatport_top10'); -} - -async function handleRebuildHypeTop10Click() { - console.log('🔥 Handling Hype Top 10 click on rebuild page'); - - // Use the existing chart creation pattern from Browse Charts EXACTLY - await handleRebuildChartClick('hype_top10', 'Hype Top 10', 'rebuild_hype_top10'); -} - -// Hero slider now uses individual slide click handlers instead of container-level clicking -// The old handleRebuildHeroSliderClick function has been removed in favor of individual release discovery - -async function handleRebuildChartClick(trackDataKey, chartName, chartType) { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - setTimeout(() => { _beatportModalOpening = false; }, 2000); - - try { - // Get basic track data from DOM - const trackData = await getRebuildPageTrackData(trackDataKey); - if (!trackData || trackData.length === 0) { - throw new Error(`No track data found for ${chartName}`); - } - - console.log(`✅ Got ${trackData.length} tracks from ${chartName}, enriching one-by-one...`); - showLoadingOverlay(`Fetching track metadata... (0/${trackData.length})`); - - const enrichedTracks = await _enrichTracksWithProgress(trackData, chartName); - - console.log(`✅ Enriched ${enrichedTracks.length} tracks`); - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); - - } catch (error) { - hideLoadingOverlay(); - console.error(`❌ Error handling ${chartName} click:`, error); - showToast(`Error loading ${chartName}: ${error.message}`, 'error'); - } -} - -async function getRebuildPageTrackData(trackDataKey) { - // First check if we have cached data from when the rebuild page was loaded - if (rebuildPageTrackData[trackDataKey]) { - console.log(`📦 Using cached ${trackDataKey} data`); - return rebuildPageTrackData[trackDataKey]; - } - - // If no cached data, extract from DOM (fallback) - console.log(`🔍 Extracting ${trackDataKey} data from rebuild page DOM`); - - let containerSelector, cardSelector; - if (trackDataKey === 'beatport_top10') { - containerSelector = '#beatport-top10-list'; - cardSelector = '.beatport-top10-card[data-url]'; - } else if (trackDataKey === 'hype_top10') { - containerSelector = '#beatport-hype10-list'; - cardSelector = '.beatport-hype10-card[data-url]'; - } else { - throw new Error(`Unknown track data key: ${trackDataKey}`); - } - - const container = document.querySelector(containerSelector); - if (!container) { - throw new Error(`Container ${containerSelector} not found`); - } - - const trackCards = container.querySelectorAll(cardSelector); - if (trackCards.length === 0) { - throw new Error(`No track cards found in ${containerSelector}`); - } - - // Extract track data from DOM cards - const tracks = Array.from(trackCards).map(card => { - const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title'; - const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist'; - const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label'; - const url = card.getAttribute('data-url') || ''; - const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || ''; - - return { - title: title, - artist: artist, - label: label, - url: url, - rank: rank - }; - }); - - console.log(`📋 Extracted ${tracks.length} tracks from ${containerSelector}`); - - // Cache for future use - rebuildPageTrackData[trackDataKey] = tracks; - - return tracks; -} - -// getHeroSliderTrackData function removed - hero slider now uses individual slide click handlers -// Each slide will create its own discovery modal using handleBeatportReleaseCardClick - -// Hook into the loadBeatportTop10Lists function to cache track data -const originalLoadBeatportTop10Lists = window.loadBeatportTop10Lists; -if (originalLoadBeatportTop10Lists) { - window.loadBeatportTop10Lists = async function () { - const result = await originalLoadBeatportTop10Lists.apply(this, arguments); - - // If the load was successful, we can potentially cache the track data - // But for now, we'll rely on DOM extraction as it's more reliable - - return result; - }; -} - -// =============================== -// BEATPORT CHART FUNCTIONALITY -// =============================== - -function createBeatportCard(chartData) { - const state = beatportChartStates[chartData.hash]; - const phase = state ? state.phase : 'fresh'; - - let buttonText = getActionButtonText(phase); - let phaseText = getPhaseText(phase); - let phaseColor = getPhaseColor(phase); - - return ` -
    -
    🎧
    -
    -
    ${escapeHtml(chartData.name)}
    -
    - ${chartData.track_count} tracks - ${phaseText} -
    -
    -
    - -
    - -
    - `; -} - -function addBeatportCardToContainer(chartData) { - const container = document.getElementById('beatport-playlist-container'); - - // Remove placeholder if it exists - const placeholder = container.querySelector('.playlist-placeholder'); - if (placeholder) { - placeholder.remove(); - } - - // Check if card already exists - const existingCard = document.getElementById(`beatport-card-${chartData.hash}`); - if (existingCard) { - console.log(`Card already exists for ${chartData.name}, updating instead`); - return; - } - - // Create and add the card - const cardHtml = createBeatportCard(chartData); - container.insertAdjacentHTML('beforeend', cardHtml); - - // Initialize state - beatportChartStates[chartData.hash] = { - phase: 'fresh', - chart: chartData, - cardElement: document.getElementById(`beatport-card-${chartData.hash}`) - }; - - // Add click handler - const card = document.getElementById(`beatport-card-${chartData.hash}`); - if (card) { - card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash)); - } - - console.log(`🃏 Created Beatport card: ${chartData.name}`); - - // Auto-mirror this Beatport chart - if (chartData.tracks && chartData.tracks.length > 0) { - mirrorPlaylist('beatport', chartData.hash, chartData.name, chartData.tracks.map(t => ({ - track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''), - album_name: t.album || '', duration_ms: t.duration_ms || 0, - source_track_id: t.id || '', image_url: t.image_url || null - }))); - } - - // Update clear button state after creating card - updateBeatportClearButtonState(); -} - -async function handleBeatportCardClick(chartHash) { - const state = beatportChartStates[chartHash]; - if (!state) { - console.error(`❌ [Card Click] No state found for Beatport chart: ${chartHash}`); - showToast('Chart state not found - try refreshing the page', 'error'); - return; - } - - if (!state.chart) { - console.error(`❌ [Card Click] No chart data found for Beatport chart: ${chartHash}`); - showToast('Chart data missing - try refreshing the page', 'error'); - return; - } - - console.log(`🎧 [Card Click] Beatport card clicked: ${chartHash}, Phase: ${state.phase}`); - - if (state.phase === 'fresh') { - // Open discovery modal and start discovery - openBeatportDiscoveryModal(chartHash, state.chart); - } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { - // Reopen existing modal with preserved discovery results - console.log(`🎧 [Card Click] Opening Beatport discovery modal for ${state.phase} phase`); - - // Check if we have the required state data - const ytState = youtubePlaylistStates[chartHash]; - if (!ytState || !ytState.playlist) { - console.log(`🔍 [Card Click] Missing playlist data for ${state.phase} phase, fetching from backend...`); - - try { - // Fetch the full state from backend - const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - - // Restore the missing playlist data - if (fullState.chart_data) { - if (!youtubePlaylistStates[chartHash]) { - youtubePlaylistStates[chartHash] = {}; - } - youtubePlaylistStates[chartHash].playlist = fullState.chart_data; - youtubePlaylistStates[chartHash].is_beatport_playlist = true; - youtubePlaylistStates[chartHash].beatport_chart_hash = chartHash; - - // Also restore discovery results if available - if (fullState.discovery_results) { - youtubePlaylistStates[chartHash].discovery_results = fullState.discovery_results; - console.log(`🔄 [Hydration] Restored ${fullState.discovery_results.length} discovery results`); - console.log(`🔄 [Hydration] First result:`, fullState.discovery_results[0]); - } - - // Restore discovery progress state - if (fullState.discovery_progress !== undefined) { - youtubePlaylistStates[chartHash].discovery_progress = fullState.discovery_progress; - } - if (fullState.spotify_matches !== undefined) { - youtubePlaylistStates[chartHash].spotify_matches = fullState.spotify_matches; - console.log(`🔄 [Hydration] Restored spotify_matches: ${fullState.spotify_matches}`); - } - if (fullState.spotify_total !== undefined) { - youtubePlaylistStates[chartHash].spotify_total = fullState.spotify_total; - } - - console.log(`✅ [Card Click] Restored playlist data for ${state.phase} phase`); - } - } else { - console.error(`❌ [Card Click] Failed to fetch state for chart: ${chartHash}`); - showToast('Error loading chart data', 'error'); - return; - } - } catch (error) { - console.error(`❌ [Card Click] Error fetching chart state:`, error); - showToast('Error loading chart data', 'error'); - return; - } - } - - openYouTubeDiscoveryModal(chartHash); - - // If still in discovering phase, start polling for live updates - if (state.phase === 'discovering') { - console.log(`🔄 [Card Click] Starting discovery polling for ${state.phase} phase`); - - // Let the polling handle all modal updates to avoid data structure mismatches - console.log(`📊 [Card Click] Starting polling - it will update modal with current progress`); - - startBeatportDiscoveryPolling(chartHash); - } - } else if (state.phase === 'downloading' || state.phase === 'download_complete') { - // Open download modal if we have the converted playlist ID (following YouTube/Tidal pattern) - const ytState = youtubePlaylistStates[chartHash]; - if (ytState && ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) { - console.log(`📥 [Card Click] Opening download modal for Beatport chart: ${ytState.playlist.name} (phase: ${state.phase})`); - - // Check if modal already exists, if not create it (like Tidal implementation) - if (activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]) { - const process = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]; - if (process.modalElement) { - console.log(`📱 [Card Click] Showing existing download modal for ${state.phase} phase`); - process.modalElement.style.display = 'flex'; - } else { - console.warn(`⚠️ [Card Click] Download process exists but modal element missing - rehydrating`); - await rehydrateBeatportDownloadModal(chartHash, ytState); - } - } else { - // Need to create the download modal - fetch the discovery results if needed - console.log(`🔧 [Card Click] Rehydrating Beatport download modal for ${state.phase} phase`); - await rehydrateBeatportDownloadModal(chartHash, ytState); - } - } else { - console.error('❌ [Card Click] No converted Spotify playlist ID found for Beatport download modal'); - console.log('📊 [Card Click] Available state data:', Object.keys(ytState || {})); - - // Fallback: try to open discovery modal if we have discovery results - if (ytState && ytState.discovery_results && ytState.discovery_results.length > 0) { - console.log(`🔄 [Card Click] Fallback: Opening discovery modal with ${ytState.discovery_results.length} results`); - openYouTubeDiscoveryModal(chartHash); - } else { - showToast('Unable to open download modal - missing playlist data', 'error'); - } - } - } -} - -async function rehydrateBeatportDownloadModal(chartHash, ytState) { - try { - console.log(`💧 [Rehydration] Attempting fallback rehydration for Beatport chart: ${chartHash}`); - - // This function is only called as a fallback when the modal wasn't created during backend loading - // In most cases, the modal should already exist from loadBeatportChartsFromBackend() - - if (!ytState || !ytState.playlist || !ytState.convertedSpotifyPlaylistId) { - console.error(`❌ [Rehydration] Invalid state data for Beatport chart: ${chartHash}`); - showToast('Cannot open download modal - invalid playlist data', 'error'); - return; - } - - // Get discovery results from backend if not already loaded - if (!ytState.discovery_results) { - console.log(`🔍 Fetching discovery results from backend for Beatport chart: ${chartHash}`); - const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - ytState.discovery_results = fullState.discovery_results; - ytState.download_process_id = fullState.download_process_id; - console.log(`✅ Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`); - } else { - console.error('❌ Failed to fetch Beatport discovery results from backend'); - showToast('Error loading playlist data', 'error'); - return; - } - } - - // Extract Spotify tracks from discovery results - const spotifyTracks = ytState.discovery_results - .filter(result => result.spotify_data) - .map(result => { - const track = result.spotify_data; - // Ensure artists is an array of strings - if (track.artists && Array.isArray(track.artists)) { - track.artists = track.artists.map(artist => - typeof artist === 'string' ? artist : (artist.name || artist) - ); - } else if (track.artists && typeof track.artists === 'string') { - track.artists = [track.artists]; - } else { - track.artists = ['Unknown Artist']; - } - return { - id: track.id, - name: track.name, - artists: track.artists, - album: track.album || 'Unknown Album', - duration_ms: track.duration_ms || 0, - external_urls: track.external_urls || {} - }; - }); - - if (spotifyTracks.length === 0) { - console.error('❌ No Spotify tracks found for download modal'); - showToast('No Spotify matches found for download', 'error'); - return; - } - - const virtualPlaylistId = ytState.convertedSpotifyPlaylistId; - const playlistName = ytState.playlist.name; - - // Create the download modal - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - // Set up the modal for the running state if we have a download process ID - if (ytState.download_process_id) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = ytState.download_process_id; - - // Update UI to reflect running state - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for this process - startModalDownloadPolling(virtualPlaylistId); - - console.log(`✅ [Rehydration] Fallback modal rehydrated for running download process`); - } - } - - } catch (error) { - console.error(`❌ [Rehydration] Error in fallback rehydration for Beatport chart:`, error); - showToast('Error opening download modal', 'error'); - hideLoadingOverlay(); - } -} - -function updateBeatportCardPhase(chartHash, phase) { - const state = beatportChartStates[chartHash]; - if (!state) return; - - state.phase = phase; - - // Re-render the card with new phase - const card = document.getElementById(`beatport-card-${chartHash}`); - if (card) { - const newCardHtml = createBeatportCard(state.chart); - card.outerHTML = newCardHtml; - - // Re-attach click handler - const newCard = document.getElementById(`beatport-card-${chartHash}`); - if (newCard) { - newCard.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); - state.cardElement = newCard; - } - } - - // Update clear button state after phase change - updateBeatportClearButtonState(); -} - -function updateBeatportCardProgress(chartHash, progress) { - const state = beatportChartStates[chartHash]; - if (!state) return; - - const card = document.getElementById(`beatport-card-${chartHash}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - if (!progressElement) return; - - const { spotify_total, spotify_matches, failed } = progress; - const percentage = spotify_total > 0 ? Math.round((spotify_matches / spotify_total) * 100) : 0; - - progressElement.textContent = `♪ ${spotify_total} / ✓ ${spotify_matches} / ✗ ${failed} / ${percentage}%`; - progressElement.classList.remove('hidden'); - - console.log('🎧 Updated Beatport card progress:', chartHash, `${spotify_matches}/${spotify_total} (${percentage}%)`); -} - -function switchToBeatportPlaylistsTab() { - // Switch from "Browse Charts" to "My Playlists" tab - const browseTab = document.querySelector('.beatport-tab-button[data-beatport-tab="browse"]'); - const playlistsTab = document.querySelector('.beatport-tab-button[data-beatport-tab="playlists"]'); - const browseContent = document.getElementById('beatport-browse-content'); - const playlistsContent = document.getElementById('beatport-playlists-content'); - - if (browseTab && playlistsTab && browseContent && playlistsContent) { - // Update tab buttons - browseTab.classList.remove('active'); - playlistsTab.classList.add('active'); - - // Update tab content - browseContent.classList.remove('active'); - playlistsContent.classList.add('active'); - - console.log('🔄 Switched to Beatport "My Playlists" tab'); - } -} - -// =============================== -// BEATPORT SYNC FUNCTIONALITY -// =============================== - -async function startBeatportPlaylistSync(urlHash) { - try { - console.log('🎧 Starting Beatport playlist sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_beatport_playlist) { - console.error('❌ Invalid Beatport playlist state for sync'); - showToast('Invalid Beatport playlist state', 'error'); - return; - } - - // Call Beatport sync endpoint - const response = await fetch(`/api/beatport/sync/start/${urlHash}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error starting sync: ${result.error}`, 'error'); - return; - } - - // Capture sync_playlist_id for WebSocket subscription (Beatport returns sync_id) - const syncPlaylistId = result.sync_id || result.sync_playlist_id; - if (state) state.syncPlaylistId = syncPlaylistId; - - // Update state to syncing - state.phase = 'syncing'; - updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'syncing'); - - // Update modal buttons and start polling - updateBeatportModalButtons(urlHash, 'syncing'); - startBeatportSyncPolling(urlHash, syncPlaylistId); - - showToast('Starting Beatport playlist sync...', 'success'); - - } catch (error) { - console.error('❌ Error starting Beatport sync:', error); - showToast(`Error starting sync: ${error.message}`, 'error'); - } -} - -function startBeatportSyncPolling(urlHash, syncPlaylistId) { - // Stop any existing polling (reuse activeYouTubePollers for Beatport) - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - // Resolve syncPlaylistId from argument or stored state - const bpState = youtubePlaylistStates[urlHash]; - syncPlaylistId = syncPlaylistId || (bpState && bpState.syncPlaylistId); - - // Phase 6: Subscribe via WebSocket - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - updateBeatportModalSyncProgress(urlHash, progress); - - if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - - const state = youtubePlaylistStates[urlHash]; - if (state) { - const chartHash = state.beatport_chart_hash || urlHash; - if (data.status === 'finished') { - state.phase = 'sync_complete'; - updateBeatportCardPhase(chartHash, 'sync_complete'); - updateBeatportModalButtons(urlHash, 'sync_complete'); - if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete'; - } else { - state.phase = 'discovered'; - updateBeatportCardPhase(chartHash, 'discovered'); - if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered'; - } - } - } - }; - } - - // Define the polling function (HTTP fallback) - const pollFunction = async () => { - if (socketConnected) return; // Phase 6: WS handles updates - try { - const response = await fetch(`/api/beatport/sync/status/${urlHash}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling Beatport sync:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - updateBeatportModalSyncProgress(urlHash, status.progress); - - if (status.complete || status.status === 'error') { - const state = youtubePlaylistStates[urlHash]; - if (state) { - const chartHash = state.beatport_chart_hash || urlHash; - if (status.complete) { - state.phase = 'sync_complete'; - state.convertedSpotifyPlaylistId = status.converted_spotify_playlist_id; - updateBeatportCardPhase(chartHash, 'sync_complete'); - updateBeatportModalButtons(urlHash, 'sync_complete'); - if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete'; - } else { - state.phase = 'discovered'; - updateBeatportCardPhase(chartHash, 'discovered'); - if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered'; - } - } - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - } - } catch (error) { - console.error('❌ Error polling Beatport sync:', error); - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - } - }; - - // Run immediately to get current status (skip if WS active) - if (!socketConnected) pollFunction(); - - // Then continue polling at regular intervals - const pollInterval = setInterval(pollFunction, 2000); - activeYouTubePollers[urlHash] = pollInterval; -} - -async function cancelBeatportSync(urlHash) { - try { - console.log('❌ Cancelling Beatport sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_beatport_playlist) { - console.error('❌ Invalid Beatport playlist state'); - return; - } - - const response = await fetch(`/api/beatport/sync/cancel/${urlHash}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error cancelling sync: ${result.error}`, 'error'); - return; - } - - // Stop polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - // Phase 6: Clean up WS subscription - const bpSyncId = state && state.syncPlaylistId; - if (bpSyncId && _syncProgressCallbacks[bpSyncId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [bpSyncId] }); - delete _syncProgressCallbacks[bpSyncId]; - } - - // Revert to discovered phase - const chartHash = state.beatport_chart_hash || urlHash; - state.phase = 'discovered'; - updateBeatportCardPhase(chartHash, 'discovered'); - updateBeatportModalButtons(urlHash, 'discovered'); - - // Sync with backend Beatport chart state - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'discovered'; - } - - showToast('Beatport sync cancelled', 'info'); - - } catch (error) { - console.error('❌ Error cancelling Beatport sync:', error); - showToast(`Error cancelling sync: ${error.message}`, 'error'); - } -} - -function updateBeatportModalSyncProgress(urlHash, progress) { - const statusDisplay = document.getElementById(`beatport-sync-status-${urlHash}`); - if (!statusDisplay || !progress) return; - - console.log(`📊 Updating Beatport modal sync progress for ${urlHash}:`, progress); - - // Update individual counters with Beatport-specific IDs - const totalEl = document.getElementById(`beatport-total-${urlHash}`); - const matchedEl = document.getElementById(`beatport-matched-${urlHash}`); - const failedEl = document.getElementById(`beatport-failed-${urlHash}`); - const percentageEl = document.getElementById(`beatport-percentage-${urlHash}`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const percentage = total > 0 ? Math.round((matched / total) * 100) : 0; - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - if (percentageEl) percentageEl.textContent = percentage; -} - -function updateBeatportModalButtons(urlHash, phase) { - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (!modal) return; - - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } -} - -async function startBeatportDownloadMissing(urlHash) { - try { - console.log('🔍 Starting download missing tracks for Beatport chart:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - // Support both camelCase and snake_case - const discoveryResults = state?.discoveryResults || state?.discovery_results; - - if (!state || !discoveryResults) { - showToast('No discovery results available for download', 'error'); - return; - } - - if (!state.is_beatport_playlist) { - console.error('❌ State is not a Beatport playlist'); - showToast('Invalid Beatport chart state', 'error'); - return; - } - - // Convert Beatport discovery results to Spotify tracks format (like Tidal does) - console.log(`🔍 Total discovery results: ${discoveryResults.length}`); - console.log(`🔍 First result (full object):`, JSON.stringify(discoveryResults[0], null, 2)); - console.log(`🔍 Second result (full object):`, JSON.stringify(discoveryResults[1], null, 2)); - console.log(`🔍 Results with spotify_data:`, discoveryResults.filter(r => r.spotify_data).length); - console.log(`🔍 Results with spotify_id:`, discoveryResults.filter(r => r.spotify_id).length); - - const spotifyTracks = discoveryResults - .filter(result => { - // Accept if has spotify_data OR if has spotify_track (from automatic discovery) - return result.spotify_data || (result.spotify_track && result.status_class === 'found'); - }) - .map(result => { - // Use spotify_data if available, otherwise build from individual fields - let track; - if (result.spotify_data) { - track = result.spotify_data; - } else { - // Build from individual fields (automatic discovery format) - // Convert album to proper object format for wishlist compatibility - const albumData = result.spotify_album || 'Unknown Album'; - const albumObject = typeof albumData === 'object' && albumData !== null - ? albumData - : { - name: typeof albumData === 'string' ? albumData : 'Unknown Album', - album_type: 'album', - images: [] - }; - - track = { - id: result.spotify_id || 'unknown', - name: result.spotify_track || 'Unknown Track', - artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], - album: albumObject, - duration_ms: 0 - }; - } - - // Ensure artists is an array of strings - if (track.artists && Array.isArray(track.artists)) { - track.artists = track.artists.map(artist => - typeof artist === 'string' ? artist : (artist.name || artist) - ); - } else if (track.artists && typeof track.artists === 'string') { - track.artists = [track.artists]; - } else { - track.artists = ['Unknown Artist']; - } - - // Ensure album is an object (in case it was converted back to string somehow) - const albumForReturn = typeof track.album === 'object' && track.album !== null - ? track.album - : { - name: typeof track.album === 'string' ? track.album : 'Unknown Album', - album_type: 'album', - images: [] - }; - - return { - id: track.id, - name: track.name, - artists: track.artists, - album: albumForReturn, - duration_ms: track.duration_ms || 0, - external_urls: track.external_urls || {} - }; - }); - - if (spotifyTracks.length === 0) { - showToast('No Spotify matches found for download', 'error'); - return; - } - - console.log(`🎧 Found ${spotifyTracks.length} Spotify tracks for Beatport download`); - - // Create a virtual playlist for the download system - const virtualPlaylistId = `beatport_${urlHash}`; - const playlistName = state.playlist.name; - - // Store reference for card navigation (but don't change phase yet) - state.convertedSpotifyPlaylistId = virtualPlaylistId; - - // Store converted playlist ID in backend but keep current phase - const chartHash = state.beatport_chart_hash || urlHash; - if (beatportChartStates[chartHash]) { - try { - await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phase: state.phase, // Keep current phase (should be 'discovered') - converted_spotify_playlist_id: virtualPlaylistId - }) - }); - console.log('✅ Updated backend with Beatport converted playlist ID (phase unchanged)'); - } catch (error) { - console.warn('⚠️ Error updating backend Beatport state:', error); - } - } - - // Close the discovery modal if it's open - const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (discoveryModal) { - discoveryModal.classList.add('hidden'); - console.log('🔄 Closed Beatport discovery modal to show download modal'); - } - - // DON'T update card phase here - let the download modal handle phase changes when "Begin Analysis" is clicked - - // Open download missing tracks modal using the same system as YouTube/Tidal - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - console.log(`✅ Opened download modal for Beatport chart: ${state.playlist.name}`); - - } catch (error) { - console.error('❌ Error starting Beatport download missing tracks:', error); - showToast(`Error starting downloads: ${error.message}`, 'error'); - } -} - -async function handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint) { - console.log(`🎵 Beatport chart clicked: ${chartType} - ${chartId} - ${chartName}`); - - try { - showToast(`Loading ${chartName}...`, 'info'); - showLoadingOverlay(`Loading ${chartName}...`); - - const response = await fetch(`${chartEndpoint}?limit=100`); - if (!response.ok) { - throw new Error(`Failed to fetch ${chartName}: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error(`No tracks found in ${chartName}`); - } - - console.log(`✅ Fetched ${data.tracks.length} tracks from ${chartName}`); - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(data.tracks, chartName, null); - - } catch (error) { - console.error(`❌ Error handling Beatport chart click:`, error); - hideLoadingOverlay(); - showToast(`Error loading ${chartName || chartId}: ${error.message}`, 'error'); - } -} - -function handleBeatportGenreClick(genreSlug, genreId, genreName) { - console.log(`🎵 Beatport genre clicked: ${genreName} (${genreSlug}/${genreId}) - SHOWING GENRE DETAIL VIEW`); - console.log(`📝 Debug: Parameters received - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`); - - // Navigate to genre detail view with proper parameters - showBeatportGenreDetailView(genreSlug, genreId, genreName); -} - -function showBeatportGenreDetailView(genreSlug, genreId, genreName) { - console.log(`🎯 Showing genre detail view for: ${genreName}`); - console.log(`📝 Debug: Function called with - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`); - - // Hide all other beatport views - document.querySelectorAll('.beatport-sub-view').forEach(view => { - view.classList.remove('active'); - }); - const mainView = document.getElementById('beatport-main-view'); - if (mainView) { - mainView.classList.remove('active'); - } - - // Show genre detail view - const genreDetailView = document.getElementById('beatport-genre-detail-view'); - if (genreDetailView) { - genreDetailView.classList.add('active'); - console.log(`📝 Debug: Genre detail view element found and activated`); - - // Update view content - const titleElement = document.getElementById('genre-detail-title'); - const breadcrumbElement = document.getElementById('genre-detail-breadcrumb'); - - console.log(`📝 Debug: Title element found: ${!!titleElement}, Breadcrumb element found: ${!!breadcrumbElement}`); - - if (titleElement) { - titleElement.textContent = genreName; - console.log(`📝 Debug: Updated title to: ${genreName}`); - } - if (breadcrumbElement) { - breadcrumbElement.textContent = `Browse Charts > Genre Explorer > ${genreName} Charts`; - console.log(`📝 Debug: Updated breadcrumb`); - } - - // Update chart type titles with genre name - const chartTitles = [ - 'genre-top-10-title', - 'genre-top-100-title', - 'genre-releases-top-10-title', - 'genre-releases-top-100-title', - 'genre-staff-picks-title', - 'genre-latest-releases-title', - 'genre-new-charts-title' - ]; - - chartTitles.forEach(titleId => { - const element = document.getElementById(titleId); - if (element) { - console.log(`📝 Debug: Found chart title element: ${titleId}`); - } else { - console.log(`📝 Debug: Missing chart title element: ${titleId}`); - } - }); - - document.getElementById('genre-top-10-title').textContent = `Top 10 ${genreName}`; - document.getElementById('genre-top-100-title').textContent = `Top 100 ${genreName}`; - document.getElementById('genre-releases-top-10-title').textContent = `Top 10 ${genreName} Releases`; - document.getElementById('genre-releases-top-100-title').textContent = `Top 100 ${genreName} Releases`; - document.getElementById('genre-staff-picks-title').textContent = `${genreName} Staff Picks`; - document.getElementById('genre-latest-releases-title').textContent = `Latest ${genreName} Releases`; - - // Update Hype section titles - document.getElementById('genre-hype-top-10-title').textContent = `${genreName} Hype Top 10`; - document.getElementById('genre-hype-top-100-title').textContent = `${genreName} Hype Top 100`; - document.getElementById('genre-hype-picks-title').textContent = `${genreName} Hype Picks`; - - // Load new charts directly (no expansion needed) - console.log(`🔄 Auto-loading new charts for ${genreName}...`); - loadNewChartsInline(genreSlug, genreId, genreName); - - // Store current genre data for chart type handlers - genreDetailView.dataset.genreSlug = genreSlug; - genreDetailView.dataset.genreId = genreId; - genreDetailView.dataset.genreName = genreName; - - // Add click handlers to chart type cards - setupGenreChartTypeHandlers(); - - console.log(`✅ Genre detail view shown for ${genreName}`); - } else { - console.error('❌ Genre detail view element not found'); - } -} - -function setupGenreChartTypeHandlers() { - const chartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card'); - - chartTypeCards.forEach(card => { - // Remove existing listeners - card.replaceWith(card.cloneNode(true)); - }); - - // Re-select after cloning - const newChartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card'); - - newChartTypeCards.forEach(card => { - card.addEventListener('click', () => { - const chartType = card.dataset.chartType; - const genreDetailView = document.getElementById('beatport-genre-detail-view'); - const genreSlug = genreDetailView.dataset.genreSlug; - const genreId = genreDetailView.dataset.genreId; - const genreName = genreDetailView.dataset.genreName; - - // All chart types now go directly to discovery modal - handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType); - }); - }); -} - -function showBeatportGenresView() { - // Hide genre detail view and show genres view - document.querySelectorAll('.beatport-sub-view').forEach(view => { - view.classList.remove('active'); - }); - - const genresView = document.getElementById('beatport-genres-view'); - if (genresView) { - genresView.classList.add('active'); - } -} - -async function toggleNewChartsExpansion(genreSlug, genreId, genreName) { - console.log(`📈 Toggling new charts expansion for: ${genreName}`); - - const expandedContent = document.getElementById('new-charts-expanded'); - const expandIndicator = document.getElementById('expand-indicator'); - const chartsCount = document.getElementById('new-charts-count'); - - if (!expandedContent || !expandIndicator) { - console.error('❌ New charts expansion elements not found'); - return; - } - - // Check if already expanded - const isExpanded = expandedContent.style.display !== 'none'; - - if (isExpanded) { - // Collapse - expandedContent.style.display = 'none'; - expandIndicator.classList.remove('expanded'); - console.log('📉 Collapsed new charts section'); - } else { - // Expand and load charts - expandedContent.style.display = 'block'; - expandIndicator.classList.add('expanded'); - - // Load charts if not already loaded - await loadNewChartsInline(genreSlug, genreId, genreName); - console.log('📈 Expanded new charts section'); - } -} - -async function loadNewChartsInline(genreSlug, genreId, genreName) { - const chartsGrid = document.getElementById('new-charts-grid'); - const loadingInline = document.getElementById('charts-loading-inline'); - - if (!chartsGrid || !loadingInline) { - console.error('❌ Inline charts elements not found'); - return; - } - - // Show loading state - loadingInline.style.display = 'block'; - chartsGrid.style.display = 'none'; - chartsGrid.innerHTML = ''; - - try { - console.log(`🔍 Loading inline charts for ${genreName}...`); - - // Fetch charts from the new-charts endpoint - const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=20`); - if (!response.ok) { - throw new Error(`Failed to fetch charts: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - // Show empty state - chartsGrid.innerHTML = ` -
    -

    No Charts Available

    -

    No curated charts found for ${genreName} at the moment.

    -
    - `; - } else { - // Populate charts grid - const chartsHTML = data.tracks.map((chart, index) => { - const chartName = chart.title || 'Untitled Chart'; - const artistName = chart.artist || 'Various Artists'; - const chartUrl = chart.url || ''; - - return ` -
    -
    -
    📈
    -
    -
    ${chartName}
    -

    by ${artistName}

    -
    -
    -
    - Curated ${genreName} chart collection -
    - -
    - `; - }).join(''); - - chartsGrid.innerHTML = chartsHTML; - - // Add click handlers to chart items - setupNewChartItemHandlers(genreSlug, genreId, genreName); - } - - // Hide loading and show grid - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - console.log(`✅ Loaded ${data.tracks?.length || 0} inline charts for ${genreName}`); - showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success'); - - } catch (error) { - console.error(`❌ Error loading inline charts for ${genreName}:`, error); - - // Show error state - chartsGrid.innerHTML = ` -
    -

    Error Loading Charts

    -

    Unable to load chart collections for ${genreName}.

    -
    - `; - - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - showToast(`Error loading charts: ${error.message}`, 'error'); - } -} - -async function loadDJChartsInline() { - const chartsGrid = document.getElementById('dj-charts-grid'); - const loadingInline = document.getElementById('dj-charts-loading-inline'); - - if (!chartsGrid || !loadingInline) { - console.error('❌ DJ charts elements not found'); - return; - } - - // Show loading state - loadingInline.style.display = 'block'; - chartsGrid.style.display = 'none'; - chartsGrid.innerHTML = ''; - - try { - console.log('🔍 Loading DJ charts...'); - - // Fetch charts from the dj-charts-improved endpoint - const response = await fetch('/api/beatport/dj-charts-improved?limit=20'); - if (!response.ok) { - throw new Error(`Failed to fetch DJ charts: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.charts || data.charts.length === 0) { - // Show empty state - chartsGrid.innerHTML = ` -
    -

    No DJ Charts Available

    -

    No DJ curated charts found at the moment.

    -
    - `; - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - return; - } - - // Create chart items using New Charts structure - const chartsHTML = data.charts.map(chart => { - const chartName = chart.name || chart.title || 'Untitled Chart'; - const artistName = chart.artist || chart.curator || 'Various Artists'; - const chartUrl = chart.url || chart.chart_url || ''; - - return ` -
    -
    -
    🎧
    -
    -
    ${chartName}
    -

    by ${artistName}

    -
    -
    -
    - DJ curated chart collection -
    - -
    - `; - }).join(''); - - chartsGrid.innerHTML = chartsHTML; - - // Hide loading, show content - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - // Setup click handlers for chart items - setupDJChartItemHandlers(); - - console.log(`✅ Loaded ${data.charts.length} DJ charts`); - - } catch (error) { - console.error('❌ Error loading DJ charts:', error); - - // Show error state - chartsGrid.innerHTML = ` -
    -

    Error Loading DJ Charts

    -

    Unable to load DJ chart collections.

    -
    - `; - - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - showToast(`Error loading DJ charts: ${error.message}`, 'error'); - } -} - -async function loadFeaturedChartsInline() { - const chartsGrid = document.getElementById('featured-charts-grid'); - const loadingInline = document.getElementById('featured-charts-loading-inline'); - - if (!chartsGrid || !loadingInline) { - console.error('❌ Featured charts elements not found'); - return; - } - - // Show loading state - loadingInline.style.display = 'block'; - chartsGrid.style.display = 'none'; - chartsGrid.innerHTML = ''; - - try { - console.log('🔍 Loading Featured charts...'); - - // Fetch charts from the homepage/featured-charts endpoint - const response = await fetch('/api/beatport/homepage/featured-charts?limit=20'); - if (!response.ok) { - throw new Error(`Failed to fetch Featured charts: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - // Show empty state - chartsGrid.innerHTML = ` -
    -

    No Featured Charts Available

    -

    No featured curated charts found at the moment.

    -
    - `; - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - return; - } - - // Create chart items using New Charts structure - const chartsHTML = data.tracks.map(chart => { - const chartName = chart.name || chart.title || 'Untitled Chart'; - const artistName = chart.artist || chart.curator || 'Various Artists'; - const chartUrl = chart.url || chart.chart_url || ''; - - return ` -
    -
    -
    -
    -
    ${chartName}
    -

    by ${artistName}

    -
    -
    -
    - Editor curated chart collection -
    - -
    - `; - }).join(''); - - chartsGrid.innerHTML = chartsHTML; - - // Hide loading, show content - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - // Setup click handlers for chart items - setupFeaturedChartItemHandlers(); - - console.log(`✅ Loaded ${data.tracks.length} Featured charts`); - - } catch (error) { - console.error('❌ Error loading Featured charts:', error); - - // Show error state - chartsGrid.innerHTML = ` -
    -

    Error Loading Featured Charts

    -

    Unable to load featured chart collections.

    -
    - `; - - loadingInline.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - showToast(`Error loading Featured charts: ${error.message}`, 'error'); - } -} - -function setupDJChartItemHandlers() { - const chartItems = document.querySelectorAll('#dj-charts-grid .new-chart-item'); - - chartItems.forEach(item => { - item.addEventListener('click', async () => { - const chartName = item.dataset.chartName; - const chartUrl = item.dataset.chartUrl; - - console.log(`🎧 DJ Chart clicked: ${chartName}`); - - try { - showToast(`Loading ${chartName}...`, 'info'); - showLoadingOverlay(`Scraping ${chartName}...`); - - const response = await fetch('/api/beatport/chart/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) - }); - - if (!response.ok) { - throw new Error(`Failed to extract chart tracks: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in chart'); - } - - console.log(`✅ Extracted ${data.tracks.length} raw tracks from DJ chart, enriching...`); - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); - - } catch (error) { - console.error('❌ Error extracting DJ chart tracks:', error); - hideLoadingOverlay(); - showToast(`Error loading chart: ${error.message}`, 'error'); - } - }); - }); -} - -function setupFeaturedChartItemHandlers() { - const chartItems = document.querySelectorAll('#featured-charts-grid .new-chart-item'); - - chartItems.forEach(item => { - item.addEventListener('click', async () => { - const chartName = item.dataset.chartName; - const chartUrl = item.dataset.chartUrl; - - console.log(`⭐ Featured Chart clicked: ${chartName}`); - - try { - showToast(`Loading ${chartName}...`, 'info'); - showLoadingOverlay(`Scraping ${chartName}...`); - - const response = await fetch('/api/beatport/chart/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) - }); - - if (!response.ok) { - throw new Error(`Failed to extract chart tracks: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in chart'); - } - - console.log(`✅ Extracted ${data.tracks.length} raw tracks from Featured chart, enriching...`); - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); - - } catch (error) { - console.error('❌ Error extracting Featured chart tracks:', error); - hideLoadingOverlay(); - showToast(`Error loading chart: ${error.message}`, 'error'); - } - }); - }); -} - -function setupNewChartItemHandlers(genreSlug, genreId, genreName) { - const chartItems = document.querySelectorAll('#new-charts-grid .new-chart-item'); - - chartItems.forEach(item => { - item.addEventListener('click', async () => { - const chartName = item.dataset.chartName; - const chartArtist = item.dataset.chartArtist; - const chartUrl = item.dataset.chartUrl; - - console.log(`🎵 Chart clicked: ${chartName} by ${chartArtist}`); - - const fullChartName = `${chartName} (${genreName})`; - - try { - showToast(`Loading ${chartName}...`, 'info'); - showLoadingOverlay(`Scraping ${chartName}...`); - - const response = await fetch('/api/beatport/chart/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) - }); - - if (!response.ok) { - throw new Error(`Failed to fetch chart content: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in chart'); - } - - console.log(`✅ Extracted ${data.tracks.length} raw tracks from ${fullChartName}, enriching...`); - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, fullChartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, fullChartName, null); - - } catch (error) { - console.error(`❌ Error loading chart: ${error.message}`); - hideLoadingOverlay(); - showToast(`Error loading chart: ${error.message}`, 'error'); - } - }); - }); -} - -function showBeatportGenreDetailViewFromBack() { - // Show genre detail view (used by charts list back button) - document.querySelectorAll('.beatport-sub-view').forEach(view => { - view.classList.remove('active'); - }); - - const genreDetailView = document.getElementById('beatport-genre-detail-view'); - if (genreDetailView) { - genreDetailView.classList.add('active'); - } -} - -async function showBeatportGenreChartsListView(genreSlug, genreId, genreName) { - console.log(`📈 Showing charts list for: ${genreName}`); - - // Hide all other beatport views - document.querySelectorAll('.beatport-sub-view').forEach(view => { - view.classList.remove('active'); - }); - const mainView = document.getElementById('beatport-main-view'); - if (mainView) { - mainView.classList.remove('active'); - } - - // Show charts list view - const chartsListView = document.getElementById('beatport-genre-charts-list-view'); - if (chartsListView) { - chartsListView.classList.add('active'); - - // Update view content - document.getElementById('genre-charts-list-title').textContent = `New ${genreName} Charts`; - document.getElementById('genre-charts-list-breadcrumb').textContent = `Browse Charts > Genre Explorer > ${genreName} Charts > New Charts`; - - // Store current genre data for individual chart handlers - chartsListView.dataset.genreSlug = genreSlug; - chartsListView.dataset.genreId = genreId; - chartsListView.dataset.genreName = genreName; - - // Load charts for this genre - await loadGenreChartsList(genreSlug, genreId, genreName); - - console.log(`✅ Charts list view shown for ${genreName}`); - } else { - console.error('❌ Charts list view element not found'); - } -} - -async function loadGenreChartsList(genreSlug, genreId, genreName) { - const chartsGrid = document.getElementById('genre-charts-grid'); - const loadingPlaceholder = document.getElementById('charts-loading-placeholder'); - - if (!chartsGrid || !loadingPlaceholder) { - console.error('❌ Charts grid or loading placeholder not found'); - return; - } - - // Show loading state - loadingPlaceholder.style.display = 'block'; - chartsGrid.style.display = 'none'; - chartsGrid.innerHTML = ''; - - try { - console.log(`🔍 Loading charts for ${genreName}...`); - - // Fetch charts from the new-charts endpoint - const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=50`); - if (!response.ok) { - throw new Error(`Failed to fetch charts: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - // Show empty state - chartsGrid.innerHTML = ` -
    -

    No Charts Available

    -

    No curated charts found for ${genreName} at the moment.
    Check back later for new DJ and artist chart collections.

    -
    - `; - } else { - // Populate charts grid - const chartsHTML = data.tracks.map((chart, index) => { - const chartName = chart.title || 'Untitled Chart'; - const artistName = chart.artist || 'Various Artists'; - const chartUrl = chart.url || ''; - - // Extract chart ID from URL for click handling - const chartId = chartUrl.split('/').pop() || `chart_${index}`; - - return ` -
    -
    -
    📈
    -
    -

    ${chartName}

    -

    by ${artistName}

    -
    -
    -
    - Curated chart collection featuring ${genreName} tracks -
    - -
    - `; - }).join(''); - - chartsGrid.innerHTML = chartsHTML; - - // Add click handlers to chart items - setupGenreChartItemHandlers(genreSlug, genreId, genreName); - } - - // Hide loading and show grid - loadingPlaceholder.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - console.log(`✅ Loaded ${data.tracks?.length || 0} charts for ${genreName}`); - showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success'); - - } catch (error) { - console.error(`❌ Error loading charts for ${genreName}:`, error); - - // Show error state - chartsGrid.innerHTML = ` -
    -

    Error Loading Charts

    -

    Unable to load chart collections for ${genreName}.
    Please try again later.

    -
    - `; - - loadingPlaceholder.style.display = 'none'; - chartsGrid.style.display = 'grid'; - - showToast(`Error loading charts: ${error.message}`, 'error'); - } -} - -function setupGenreChartItemHandlers(genreSlug, genreId, genreName) { - const chartItems = document.querySelectorAll('#genre-charts-grid .genre-chart-item'); - - chartItems.forEach(item => { - item.addEventListener('click', async () => { - const chartName = item.dataset.chartName; - const chartArtist = item.dataset.chartArtist; - const chartUrl = item.dataset.chartUrl; - - console.log(`🎵 Chart clicked: ${chartName} by ${chartArtist}`); - - const fullChartName = `${chartName} (${genreName})`; - - try { - showToast(`Loading ${chartName}...`, 'info'); - showLoadingOverlay(`Scraping ${chartName}...`); - - const response = await fetch('/api/beatport/chart/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) - }); - - if (!response.ok) { - throw new Error(`Failed to fetch chart content: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in chart'); - } - - console.log(`✅ Extracted ${data.tracks.length} raw tracks from ${fullChartName}, enriching...`); - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, fullChartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, fullChartName, null); - - } catch (error) { - console.error(`❌ Error loading chart: ${error.message}`); - hideLoadingOverlay(); - showToast(`Error loading chart: ${error.message}`, 'error'); - } - }); - }); -} - -async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType) { - console.log(`🎯 Genre chart type clicked: ${chartType} for ${genreName} (${genreSlug}/${genreId})`); - - // Map chart types to API endpoints and create descriptive names - const chartTypeMap = { - 'top-10': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/top-10`, - name: `Top 10 ${genreName}`, - limit: 10 - }, - 'top-100': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/tracks`, - name: `Top 100 ${genreName}`, - limit: 100 - }, - 'releases-top-10': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-10`, - name: `Top 10 ${genreName} Releases`, - limit: 10 - }, - 'releases-top-100': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-100`, - name: `Top 100 ${genreName} Releases`, - limit: 100 - }, - 'staff-picks': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/staff-picks`, - name: `${genreName} Staff Picks`, - limit: 50 - }, - 'latest-releases': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/latest-releases`, - name: `Latest ${genreName} Releases`, - limit: 50 - }, - 'hype-top-10': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-10`, - name: `${genreName} Hype Top 10`, - limit: 10 - }, - 'hype-top-100': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-100`, - name: `${genreName} Hype Top 100`, - limit: 100 - }, - 'hype-picks': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-picks`, - name: `${genreName} Hype Picks`, - limit: 50 - }, - 'new-charts': { - endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/new-charts`, - name: `New ${genreName} Charts`, - limit: 100 - } - }; - - const chartConfig = chartTypeMap[chartType]; - if (!chartConfig) { - console.error(`❌ Unknown chart type: ${chartType}`); - showToast(`Unknown chart type: ${chartType}`, 'error'); - return; - } - - try { - showToast(`Loading ${chartConfig.name}...`, 'info'); - showLoadingOverlay(`Loading ${chartConfig.name}...`); - - const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`); - if (!response.ok) { - throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error(`No tracks found in ${chartConfig.name}`); - } - - console.log(`✅ Fetched ${data.tracks.length} tracks from ${chartConfig.name}`); - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(data.tracks, chartConfig.name, null); - - } catch (error) { - console.error(`❌ Error loading ${chartConfig.name}:`, error); - hideLoadingOverlay(); - showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error'); - } -} - -// =============================== -// SPOTIFY PUBLIC LINK FUNCTIONALITY -// =============================== - -let spotifyPublicPlaylists = []; // Array of loaded Spotify public playlist objects -let spotifyPublicPlaylistStates = {}; // Key: url_hash, Value: state dict - -async function parseSpotifyPublicUrl() { - const urlInput = document.getElementById('spotify-public-url-input'); - const url = urlInput.value.trim(); - - if (!url) { - showToast('Please enter a Spotify URL', 'error'); - return; - } - - // Basic URL validation - if (!url.includes('open.spotify.com/playlist') && !url.includes('open.spotify.com/album') && - !url.startsWith('spotify:playlist:') && !url.startsWith('spotify:album:')) { - showToast('Please enter a valid Spotify playlist or album URL', 'error'); - return; - } - - // Check if already loaded - if (_isUrlAlreadyLoaded('spotify-public', url)) { - showToast('This playlist is already loaded', 'info'); - urlInput.value = ''; - return; - } - - const parseBtn = document.getElementById('spotify-public-parse-btn'); - if (parseBtn) { - parseBtn.disabled = true; - parseBtn.textContent = 'Loading...'; - } - - try { - console.log('🎵 Parsing public Spotify URL:', url); - - const response = await fetch('/api/spotify/parse-public', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ url }) - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error: ${result.error}`, 'error'); - return; - } - - // Check if already loaded - if (spotifyPublicPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) { - showToast('This playlist is already loaded', 'info'); - urlInput.value = ''; - return; - } - - console.log(`✅ Spotify ${result.type} parsed: ${result.name} (${result.track_count} tracks)`); - - spotifyPublicPlaylists.push(result); - - // Auto-mirror - if (result.tracks && result.tracks.length > 0) { - mirrorPlaylist('spotify_public', result.url_hash, result.name, result.tracks.map(t => ({ - track_name: t.name || '', - artist_name: Array.isArray(t.artists) ? t.artists.map(a => a.name).join(', ') : '', - album_name: t.album?.name || '', - duration_ms: t.duration_ms || 0, - source_track_id: t.id || '' - })), { owner: result.subtitle || '', image_url: '', description: result.url || '' }); - } - - // Save to URL history - saveUrlHistory('spotify-public', url, result.name); - - renderSpotifyPublicPlaylists(); - await loadSpotifyPublicPlaylistStatesFromBackend(); - - urlInput.value = ''; - showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success'); - console.log(`🎵 Loaded Spotify playlist: ${result.name}`); - - } catch (error) { - console.error('❌ Error parsing Spotify URL:', error); - showToast(`Error parsing Spotify URL: ${error.message}`, 'error'); - } finally { - if (parseBtn) { - parseBtn.disabled = false; - parseBtn.textContent = 'Load'; - } - } -} - -function renderSpotifyPublicPlaylists() { - const container = document.getElementById('spotify-public-playlist-container'); - if (spotifyPublicPlaylists.length === 0) { - container.innerHTML = `
    Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
    `; - return; - } - - container.innerHTML = spotifyPublicPlaylists.map(p => { - if (!spotifyPublicPlaylistStates[p.url_hash]) { - spotifyPublicPlaylistStates[p.url_hash] = { - phase: 'fresh', - playlist: p - }; - } - return createSpotifyPublicCard(p); - }).join(''); - - // Add click handlers to cards - spotifyPublicPlaylists.forEach(p => { - const card = document.getElementById(`spotify-public-card-${p.url_hash}`); - if (card) { - card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash)); - } - }); -} - -function createSpotifyPublicCard(playlist) { - const state = spotifyPublicPlaylistStates[playlist.url_hash]; - const phase = state ? state.phase : 'fresh'; - const isAlbum = playlist.type === 'album'; - - let buttonText = getActionButtonText(phase); - let phaseText = getPhaseText(phase); - let phaseColor = getPhaseColor(phase); - - return ` -
    -
    ${isAlbum ? '💿' : '🎵'}
    -
    -
    ${escapeHtml(playlist.name)}
    -
    - ${isAlbum ? 'Album' : 'Playlist'} - ${playlist.track_count || playlist.tracks.length} tracks - ${phaseText} -
    -
    -
    - -
    - -
    - `; -} - -async function handleSpotifyPublicCardClick(urlHash) { - const state = spotifyPublicPlaylistStates[urlHash]; - if (!state) { - console.error(`No state found for Spotify public playlist: ${urlHash}`); - showToast('Playlist state not found - try refreshing the page', 'error'); - return; - } - - if (!state.playlist) { - console.error(`No playlist data found for Spotify public playlist: ${urlHash}`); - showToast('Playlist data missing - try refreshing the page', 'error'); - return; - } - - if (!state.phase) { - state.phase = 'fresh'; - } - - console.log(`🎵 [Card Click] Spotify public card clicked: ${urlHash}, Phase: ${state.phase}`); - - if (state.phase === 'fresh') { - console.log(`🎵 Using pre-loaded Spotify public playlist data for: ${state.playlist.name}`); - openSpotifyPublicDiscoveryModal(urlHash, state.playlist); - - } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { - console.log(`🎵 [Card Click] Opening Spotify public discovery modal for ${state.phase} phase`); - - if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { - try { - const stateResponse = await fetch(`/api/spotify-public/state/${urlHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - if (fullState.discovery_results) { - state.discovery_results = fullState.discovery_results; - state.spotify_matches = fullState.spotify_matches || state.spotify_matches; - state.discovery_progress = fullState.discovery_progress || state.discovery_progress; - spotifyPublicPlaylistStates[urlHash] = { ...spotifyPublicPlaylistStates[urlHash], ...state }; - console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); - } - } - } catch (error) { - console.error(`Failed to fetch discovery results from backend: ${error}`); - } - } - - openSpotifyPublicDiscoveryModal(urlHash, state.playlist); - } else if (state.phase === 'downloading' || state.phase === 'download_complete') { - if (state.convertedSpotifyPlaylistId) { - if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { - const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; - if (process.modalElement) { - process.modalElement.style.display = 'flex'; - } else { - await rehydrateSpotifyPublicDownloadModal(urlHash, state); - } - } else { - await rehydrateSpotifyPublicDownloadModal(urlHash, state); - } - } else { - if (state.discovery_results && state.discovery_results.length > 0) { - openSpotifyPublicDiscoveryModal(urlHash, state.playlist); - } else { - showToast('Unable to open download modal - missing playlist data', 'error'); - } - } - } -} - -async function rehydrateSpotifyPublicDownloadModal(urlHash, state) { - try { - if (!state || !state.playlist) { - showToast('Cannot open download modal - invalid playlist data', 'error'); - return; - } - - const spotifyTracks = state.discovery_results - ?.filter(result => result.spotify_data) - ?.map(result => result.spotify_data) || []; - - if (spotifyTracks.length > 0) { - const virtualPlaylistId = state.convertedSpotifyPlaylistId || `spotify_public_${urlHash}`; - await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); - - if (state.download_process_id) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = state.download_process_id; - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - startModalDownloadPolling(virtualPlaylistId); - } - } - } else { - showToast('No Spotify tracks found for download', 'error'); - } - } catch (error) { - console.error(`Error rehydrating Spotify public download modal: ${error}`); - } -} - -async function openSpotifyPublicDiscoveryModal(urlHash, playlistData) { - console.log(`🎵 Opening Spotify public discovery modal (reusing YouTube modal): ${playlistData.name}`); - - const fakeUrlHash = `spotifypublic_${urlHash}`; - - const cardState = spotifyPublicPlaylistStates[urlHash]; - const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete'); - const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering'; - - let transformedResults = []; - let actualMatches = 0; - if (isAlreadyDiscovered && cardState.discovery_results) { - transformedResults = cardState.discovery_results.map((result, index) => { - const isFound = result.status === 'found' || - result.status === '✅ Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; - if (isFound) actualMatches++; - - return { - index: index, - yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown', - yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isFound ? '✅ Found' : '❌ Not Found', - status_class: isFound ? 'found' : 'not-found', - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, - spotify_id: result.spotify_id, - manual_match: result.manual_match - }; - }); - console.log(`🎵 Spotify public modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); - } - - // Normalize artist objects to strings for the discovery modal table - const normalizedTracks = playlistData.tracks.map(t => ({ - ...t, - artists: Array.isArray(t.artists) - ? t.artists.map(a => typeof a === 'object' ? a.name : a) - : t.artists - })); - - const modalPhase = cardState ? cardState.phase : 'fresh'; - youtubePlaylistStates[fakeUrlHash] = { - phase: modalPhase, - playlist: { - name: playlistData.name, - tracks: normalizedTracks - }, - is_spotify_public_playlist: true, - spotify_public_playlist_id: urlHash, - discovery_progress: isAlreadyDiscovered ? 100 : 0, - spotify_matches: isAlreadyDiscovered ? actualMatches : 0, - spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, - spotify_total: playlistData.tracks.length, - discovery_results: transformedResults, - discoveryResults: transformedResults, - discoveryProgress: isAlreadyDiscovered ? 100 : 0 - }; - - if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { - try { - console.log(`🔍 Starting Spotify public discovery for: ${playlistData.name}`); - - const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - console.error('Error starting Spotify public discovery:', result.error); - showToast(`Error starting discovery: ${result.error}`, 'error'); - return; - } - - console.log('Spotify public discovery started, beginning polling...'); - - spotifyPublicPlaylistStates[urlHash].phase = 'discovering'; - updateSpotifyPublicCardPhase(urlHash, 'discovering'); - youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; - - startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); - - } catch (error) { - console.error('Error starting Spotify public discovery:', error); - showToast(`Error starting discovery: ${error.message}`, 'error'); - } - } else if (isCurrentlyDiscovering) { - console.log(`🔄 Resuming Spotify public discovery polling for: ${playlistData.name}`); - startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); - } else if (cardState && cardState.phase === 'syncing') { - console.log(`🔄 Resuming Spotify public sync polling for: ${playlistData.name}`); - startSpotifyPublicSyncPolling(fakeUrlHash); - } else { - console.log('Using existing results - no need to re-discover'); - } - - openYouTubeDiscoveryModal(fakeUrlHash); -} - -function startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash) { - console.log(`🔄 Starting Spotify public discovery polling for: ${urlHash}`); - - if (activeYouTubePollers[fakeUrlHash]) { - clearInterval(activeYouTubePollers[fakeUrlHash]); - } - - // WebSocket subscription - if (socketConnected) { - socket.emit('discovery:subscribe', { ids: [urlHash] }); - _discoveryProgressCallbacks[urlHash] = (data) => { - if (data.error) { - if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } - socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; - return; - } - const transformed = { - progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, - complete: data.complete, - results: (data.results || []).map((r, i) => { - const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; - const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); - return { - index: i, yt_track: r.spotify_public_track ? r.spotify_public_track.name : 'Unknown', - yt_artist: r.spotify_public_track ? (r.spotify_public_track.artists ? r.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), - status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), - spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), - spotify_artist: r.spotify_data && r.spotify_data.artists ? (Array.isArray(r.spotify_data.artists) ? r.spotify_data.artists.join(', ') : r.spotify_data.artists) : (r.spotify_artist || '-'), - spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), - spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, - wing_it_fallback: isWingIt - }; - }) - }; - const st = youtubePlaylistStates[fakeUrlHash]; - if (st) { - st.discovery_progress = data.progress; st.discoveryProgress = data.progress; - st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; - st.discovery_results = data.results; st.discoveryResults = transformed.results; - st.phase = data.phase; - updateYouTubeDiscoveryModal(fakeUrlHash, transformed); - } - if (spotifyPublicPlaylistStates[urlHash]) { - spotifyPublicPlaylistStates[urlHash].phase = data.phase; - spotifyPublicPlaylistStates[urlHash].discovery_results = data.results; - spotifyPublicPlaylistStates[urlHash].spotify_matches = data.spotify_matches; - spotifyPublicPlaylistStates[urlHash].discovery_progress = data.progress; - updateSpotifyPublicCardPhase(urlHash, data.phase); - } - updateSpotifyPublicCardProgress(urlHash, data); - if (data.complete) { - if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } - socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; - } - }; - } - - const pollInterval = setInterval(async () => { - if (socketConnected) return; - try { - const response = await fetch(`/api/spotify-public/discovery/status/${urlHash}`); - const status = await response.json(); - - if (status.error) { - console.error('Error polling Spotify public discovery status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - return; - } - - const transformedStatus = { - progress: status.progress, - spotify_matches: status.spotify_matches, - spotify_total: status.spotify_total, - complete: status.complete, - results: status.results.map((result, index) => { - const isFound = result.status === 'found' || - result.status === '✅ Found' || - result.status_class === 'found' || - result.spotify_data || - result.spotify_track; - - return { - index: index, - yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown', - yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', - status: isFound ? '✅ Found' : '❌ Not Found', - status_class: isFound ? 'found' : 'not-found', - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data && result.spotify_data.artists ? - (Array.isArray(result.spotify_data.artists) ? result.spotify_data.artists.join(', ') : result.spotify_data.artists) : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), - spotify_data: result.spotify_data, - spotify_id: result.spotify_id, - manual_match: result.manual_match - }; - }) - }; - - const state = youtubePlaylistStates[fakeUrlHash]; - if (state) { - state.discovery_progress = status.progress; - state.discoveryProgress = status.progress; - state.spotify_matches = status.spotify_matches; - state.spotifyMatches = status.spotify_matches; - state.discovery_results = status.results; - state.discoveryResults = transformedStatus.results; - state.phase = status.phase; - - updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); - - if (spotifyPublicPlaylistStates[urlHash]) { - spotifyPublicPlaylistStates[urlHash].phase = status.phase; - spotifyPublicPlaylistStates[urlHash].discovery_results = status.results; - spotifyPublicPlaylistStates[urlHash].spotify_matches = status.spotify_matches; - spotifyPublicPlaylistStates[urlHash].discovery_progress = status.progress; - updateSpotifyPublicCardPhase(urlHash, status.phase); - } - - updateSpotifyPublicCardProgress(urlHash, status); - - console.log(`🔄 Spotify public discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); - } - - if (status.complete) { - console.log(`Spotify public discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - } - - } catch (error) { - console.error('Error polling Spotify public discovery:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[fakeUrlHash]; - } - }, 1000); - - activeYouTubePollers[fakeUrlHash] = pollInterval; -} - -async function loadSpotifyPublicPlaylistStatesFromBackend() { - try { - console.log('🎵 Loading Spotify public playlist states from backend...'); - - const response = await fetch('/api/spotify-public/playlists/states'); - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to fetch Spotify public playlist states'); - } - - const data = await response.json(); - const states = data.states || []; - - console.log(`🎵 Found ${states.length} stored Spotify public playlist states in backend`); - - if (states.length === 0) return; - - for (const stateInfo of states) { - await applySpotifyPublicPlaylistState(stateInfo); - } - - // Rehydrate download modals for playlists in downloading/download_complete phases - for (const stateInfo of states) { - if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && - stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { - - const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; - - if (!activeDownloadProcesses[convertedPlaylistId]) { - console.log(`Rehydrating download modal for Spotify public playlist: ${stateInfo.playlist_id}`); - try { - const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id)); - if (!playlistData) continue; - - const spotifyTracks = spotifyPublicPlaylistStates[stateInfo.playlist_id]?.discovery_results - ?.filter(result => result.spotify_data) - ?.map(result => result.spotify_data) || []; - - if (spotifyTracks.length > 0) { - await openDownloadMissingModalForTidal( - convertedPlaylistId, - playlistData.name, - spotifyTracks - ); - - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process) { - process.status = 'running'; - process.batchId = stateInfo.download_process_id; - const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - startModalDownloadPolling(convertedPlaylistId); - } - } - } catch (error) { - console.error(`Error rehydrating Spotify public download modal for ${stateInfo.playlist_id}:`, error); - } - } - } - } - - console.log('Spotify public playlist states loaded and applied'); - - } catch (error) { - console.error('Error loading Spotify public playlist states:', error); - } -} - -async function applySpotifyPublicPlaylistState(stateInfo) { - const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; - - try { - console.log(`🎵 Applying saved state for Spotify public playlist: ${playlist_id}, Phase: ${phase}`); - - const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(playlist_id)); - if (!playlistData) { - console.warn(`Playlist data not found for state ${playlist_id} - skipping`); - return; - } - - if (!spotifyPublicPlaylistStates[playlist_id]) { - spotifyPublicPlaylistStates[playlist_id] = { - playlist: playlistData, - phase: 'fresh' - }; - } - - spotifyPublicPlaylistStates[playlist_id].phase = phase; - spotifyPublicPlaylistStates[playlist_id].discovery_progress = discovery_progress; - spotifyPublicPlaylistStates[playlist_id].spotify_matches = spotify_matches; - spotifyPublicPlaylistStates[playlist_id].discovery_results = discovery_results; - spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; - spotifyPublicPlaylistStates[playlist_id].download_process_id = download_process_id; - spotifyPublicPlaylistStates[playlist_id].playlist = playlistData; - - if (phase !== 'fresh' && phase !== 'discovering') { - try { - const stateResponse = await fetch(`/api/spotify-public/state/${playlist_id}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - if (fullState.discovery_results && spotifyPublicPlaylistStates[playlist_id]) { - spotifyPublicPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; - spotifyPublicPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; - spotifyPublicPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; - spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; - spotifyPublicPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; - } - } - } catch (error) { - console.warn(`Error fetching full discovery results for Spotify public playlist ${playlistData.name}:`, error.message); - } - } - - updateSpotifyPublicCardPhase(playlist_id, phase); - - if (phase === 'discovered' && spotifyPublicPlaylistStates[playlist_id]) { - const progressInfo = { - spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, - spotify_matches: spotifyPublicPlaylistStates[playlist_id].spotify_matches || 0 - }; - updateSpotifyPublicCardProgress(playlist_id, progressInfo); - } - - if (phase === 'discovering') { - const fakeUrlHash = `spotifypublic_${playlist_id}`; - startSpotifyPublicDiscoveryPolling(fakeUrlHash, playlist_id); - } else if (phase === 'syncing') { - const fakeUrlHash = `spotifypublic_${playlist_id}`; - startSpotifyPublicSyncPolling(fakeUrlHash); - } - - } catch (error) { - console.error(`Error applying Spotify public playlist state for ${playlist_id}:`, error); - } -} - -function updateSpotifyPublicCardPhase(urlHash, phase) { - const state = spotifyPublicPlaylistStates[urlHash]; - if (!state) return; - - state.phase = phase; - - const card = document.getElementById(`spotify-public-card-${urlHash}`); - if (card) { - const newCardHtml = createSpotifyPublicCard(state.playlist); - card.outerHTML = newCardHtml; - - const newCard = document.getElementById(`spotify-public-card-${urlHash}`); - if (newCard) { - newCard.addEventListener('click', () => handleSpotifyPublicCardClick(urlHash)); - } - - if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { - setTimeout(() => { - updateSpotifyPublicCardSyncProgress(urlHash, state.lastSyncProgress); - }, 0); - } - } -} - -function updateSpotifyPublicCardProgress(urlHash, progress) { - const state = spotifyPublicPlaylistStates[urlHash]; - if (!state) return; - - const card = document.getElementById(`spotify-public-card-${urlHash}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - if (!progressElement) return; - - progressElement.classList.remove('hidden'); - - const total = progress.spotify_total || 0; - const matches = progress.spotify_matches || 0; - - if (total > 0) { - progressElement.innerHTML = ` -
    - ✓ ${matches} - / - ♪ ${total} -
    - `; - } -} - -// =============================== -// SPOTIFY PUBLIC SYNC FUNCTIONALITY -// =============================== - -async function startSpotifyPublicPlaylistSync(urlHash) { - try { - console.log('🎵 Starting Spotify public playlist sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_spotify_public_playlist) { - console.error('Invalid Spotify public playlist state for sync'); - return; - } - - const playlistId = state.spotify_public_playlist_id; - const response = await fetch(`/api/spotify-public/sync/start/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error starting sync: ${result.error}`, 'error'); - return; - } - - const syncPlaylistId = result.sync_playlist_id; - if (state) state.syncPlaylistId = syncPlaylistId; - - updateSpotifyPublicCardPhase(playlistId, 'syncing'); - updateSpotifyPublicModalButtons(urlHash, 'syncing'); - - startSpotifyPublicSyncPolling(urlHash, syncPlaylistId); - - showToast('Spotify public playlist sync started!', 'success'); - - } catch (error) { - console.error('Error starting Spotify public sync:', error); - showToast(`Error starting sync: ${error.message}`, 'error'); - } -} - -function startSpotifyPublicSyncPolling(urlHash, syncPlaylistId) { - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - const state = youtubePlaylistStates[urlHash]; - const playlistId = state.spotify_public_playlist_id; - - syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); - - // WebSocket subscription - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - updateSpotifyPublicCardSyncProgress(playlistId, progress); - updateSpotifyPublicModalSyncProgress(urlHash, progress); - - if (data.status === 'finished') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; - updateSpotifyPublicCardPhase(playlistId, 'sync_complete'); - updateSpotifyPublicModalButtons(urlHash, 'sync_complete'); - showToast('Spotify public playlist sync complete!', 'success'); - } else if (data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; - updateSpotifyPublicCardPhase(playlistId, 'discovered'); - updateSpotifyPublicModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - }; - } - - const pollFunction = async () => { - if (socketConnected) return; - try { - const response = await fetch(`/api/spotify-public/sync/status/${playlistId}`); - const status = await response.json(); - - if (status.error) { - console.error('Error polling Spotify public sync status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - updateSpotifyPublicCardSyncProgress(playlistId, status.progress); - updateSpotifyPublicModalSyncProgress(urlHash, status.progress); - - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; - updateSpotifyPublicCardPhase(playlistId, 'sync_complete'); - updateSpotifyPublicModalButtons(urlHash, 'sync_complete'); - showToast('Spotify public playlist sync complete!', 'success'); - } else if (status.sync_status === 'error') { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered'; - if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; - updateSpotifyPublicCardPhase(playlistId, 'discovered'); - updateSpotifyPublicModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); - } - } catch (error) { - console.error('Error polling Spotify public sync:', error); - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - } - }; - - if (!socketConnected) pollFunction(); - - const pollInterval = setInterval(pollFunction, 1000); - activeYouTubePollers[urlHash] = pollInterval; -} - -async function cancelSpotifyPublicSync(urlHash) { - try { - console.log('Cancelling Spotify public sync:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_spotify_public_playlist) { - console.error('Invalid Spotify public playlist state'); - return; - } - - const playlistId = state.spotify_public_playlist_id; - const response = await fetch(`/api/spotify-public/sync/cancel/${playlistId}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error cancelling sync: ${result.error}`, 'error'); - return; - } - - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - const syncId = state && state.syncPlaylistId; - if (syncId && _syncProgressCallbacks[syncId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); - delete _syncProgressCallbacks[syncId]; - } - - updateSpotifyPublicCardPhase(playlistId, 'discovered'); - updateSpotifyPublicModalButtons(urlHash, 'discovered'); - - showToast('Spotify public sync cancelled', 'info'); - - } catch (error) { - console.error('Error cancelling Spotify public sync:', error); - showToast(`Error cancelling sync: ${error.message}`, 'error'); - } -} - -function updateSpotifyPublicCardSyncProgress(urlHash, progress) { - const state = spotifyPublicPlaylistStates[urlHash]; - if (!state || !state.playlist || !progress) return; - - state.lastSyncProgress = progress; - - const card = document.getElementById(`spotify-public-card-${urlHash}`); - if (!card) return; - - const progressElement = card.querySelector('.playlist-card-progress'); - - let statusCounterHTML = ''; - if (progress && progress.total_tracks > 0) { - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const processed = matched + failed; - const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - statusCounterHTML = ` -
    - ♪ ${total} - / - ✓ ${matched} - / - ✗ ${failed} - (${percentage}%) -
    - `; - } - - if (statusCounterHTML) { - progressElement.innerHTML = statusCounterHTML; - } -} - -function updateSpotifyPublicModalSyncProgress(urlHash, progress) { - const statusDisplay = document.getElementById(`spotify-public-sync-status-${urlHash}`); - if (!statusDisplay || !progress) return; - - const totalEl = document.getElementById(`spotify-public-total-${urlHash}`); - const matchedEl = document.getElementById(`spotify-public-matched-${urlHash}`); - const failedEl = document.getElementById(`spotify-public-failed-${urlHash}`); - const percentageEl = document.getElementById(`spotify-public-percentage-${urlHash}`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - - if (total > 0) { - const processed = matched + failed; - const percentage = Math.round((processed / total) * 100); - if (percentageEl) percentageEl.textContent = percentage; - } -} - -function updateSpotifyPublicModalButtons(urlHash, phase) { - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (!modal) return; - - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } -} - -async function startSpotifyPublicDownloadMissing(urlHash) { - try { - console.log('🔍 Starting download missing tracks for Spotify public playlist:', urlHash); - - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.is_spotify_public_playlist) { - console.error('Invalid Spotify public playlist state for download'); - return; - } - - const discoveryResults = state.discoveryResults || state.discovery_results; - - if (!discoveryResults) { - showToast('No discovery results available for download', 'error'); - return; - } - - const spotifyTracks = []; - for (const result of discoveryResults) { - if (result.spotify_data) { - spotifyTracks.push(result.spotify_data); - } else if (result.spotify_track && result.status_class === 'found') { - const albumData = result.spotify_album || 'Unknown Album'; - const albumObject = typeof albumData === 'object' && albumData !== null - ? albumData - : { - name: typeof albumData === 'string' ? albumData : 'Unknown Album', - album_type: 'album', - images: [] - }; - - spotifyTracks.push({ - id: result.spotify_id || 'unknown', - name: result.spotify_track || 'Unknown Track', - artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], - album: albumObject, - duration_ms: 0 - }); - } - } - - if (spotifyTracks.length === 0) { - showToast('No Spotify matches found for download', 'error'); - return; - } - - const realUrlHash = state.spotify_public_playlist_id; - const virtualPlaylistId = `spotify_public_${realUrlHash}`; - const playlistName = state.playlist.name; - - state.convertedSpotifyPlaylistId = virtualPlaylistId; - - // Sync convertedSpotifyPlaylistId to spotifyPublicPlaylistStates for card click routing - if (realUrlHash && spotifyPublicPlaylistStates[realUrlHash]) { - spotifyPublicPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId; - } - - const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (discoveryModal) { - discoveryModal.classList.add('hidden'); - } - - await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); - - } catch (error) { - console.error('Error starting Spotify public download missing:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -// =============================== -// URL HISTORY (Saved playlist URLs) -// =============================== - -const URL_HISTORY_MAX = 10; -const URL_HISTORY_SOURCES = { - youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() }, - deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() }, - 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() } -}; - -function getUrlHistory(source) { - try { - const cfg = URL_HISTORY_SOURCES[source]; - if (!cfg) return []; - const raw = localStorage.getItem(cfg.key); - return raw ? JSON.parse(raw) : []; - } catch { return []; } -} - -function saveUrlHistory(source, url, name) { - const cfg = URL_HISTORY_SOURCES[source]; - if (!cfg || !url) return; - let history = getUrlHistory(source); - // Remove duplicate (same URL) - history = history.filter(h => h.url !== url); - // Add to front - history.unshift({ url, name: name || url, ts: Date.now() }); - // Cap - if (history.length > URL_HISTORY_MAX) history = history.slice(0, URL_HISTORY_MAX); - localStorage.setItem(cfg.key, JSON.stringify(history)); - renderUrlHistory(source); -} - -function removeUrlHistoryEntry(source, url) { - const cfg = URL_HISTORY_SOURCES[source]; - if (!cfg) return; - let history = getUrlHistory(source); - history = history.filter(h => h.url !== url); - localStorage.setItem(cfg.key, JSON.stringify(history)); - renderUrlHistory(source); -} - -function renderUrlHistory(source) { - const cfg = URL_HISTORY_SOURCES[source]; - if (!cfg) return; - const container = document.getElementById(cfg.containerId); - if (!container) return; - const history = getUrlHistory(source); - if (history.length === 0) { - container.style.display = 'none'; - container.innerHTML = ''; - return; - } - container.style.display = 'flex'; - container.innerHTML = `Recent` + - history.map(h => { - const rawName = h.name.length > 30 ? h.name.substring(0, 28) + '...' : h.name; - const safeName = escapeHtml(rawName); - const safeTitle = escapeHtml(h.name); - const safeUrl = h.url.replace(/"/g, '"'); - return `
    - ${cfg.icon} - ${safeName} - -
    `; - }).join(''); - - // Pill click → fill input and load (skip if already loaded) - container.querySelectorAll('.url-history-pill').forEach(pill => { - pill.addEventListener('click', (e) => { - // Don't trigger if clicking the X button - if (e.target.classList.contains('url-history-pill-remove')) return; - const pillUrl = pill.dataset.url; - if (_isUrlAlreadyLoaded(source, pillUrl)) { - showToast('This playlist is already loaded', 'info'); - return; - } - const input = document.getElementById(cfg.inputId); - if (input) input.value = pillUrl; - cfg.loadFn(); - }); - }); - - // X button click → remove entry - container.querySelectorAll('.url-history-pill-remove').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - removeUrlHistoryEntry(btn.dataset.source, btn.dataset.url); - }); - }); -} - -function _isUrlAlreadyLoaded(source, url) { - if (source === 'youtube') { - // Check for existing YouTube card with this URL - const container = document.getElementById('youtube-playlist-container'); - if (container) { - const cards = container.querySelectorAll('.youtube-playlist-card[data-url]'); - for (const card of cards) { - if (card.dataset.url === url) return true; - } - } - return false; - } else if (source === 'deezer') { - // Extract playlist ID from URL and check deezerPlaylists array - const match = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i); - const id = match ? match[1] : (/^\d+$/.test(url) ? url : null); - if (id && deezerPlaylists.find(p => String(p.id) === String(id))) return true; - return false; - } else if (source === 'spotify-public') { - // Extract Spotify ID from URL and compare against loaded playlists - const spMatch = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/); - const spId = spMatch ? spMatch[2] : null; - if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true; - // Fallback: direct URL comparison - return spotifyPublicPlaylists.some(p => p.url === url); - } - return false; -} - -function initUrlHistories() { - for (const source of Object.keys(URL_HISTORY_SOURCES)) { - renderUrlHistory(source); - } -} - -// =============================== -// YOUTUBE PLAYLIST FUNCTIONALITY -// =============================== - -async function parseYouTubePlaylist() { - const urlInput = document.getElementById('youtube-url-input'); - const url = urlInput.value.trim(); - - if (!url) { - showToast('Please enter a YouTube playlist URL', 'error'); - return; - } - - // Validate URL format - if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) { - showToast('Please enter a valid YouTube playlist URL', 'error'); - return; - } - - // Check if already loaded - if (_isUrlAlreadyLoaded('youtube', url)) { - showToast('This playlist is already loaded', 'info'); - urlInput.value = ''; - return; - } - - try { - console.log('🎬 Parsing YouTube playlist:', url); - - // Create card immediately in 'fresh' phase - createYouTubeCard(url, 'fresh'); - - // Parse playlist via API - const response = await fetch('/api/youtube/parse', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ url: url }) - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error parsing YouTube playlist: ${result.error}`, 'error'); - removeYouTubeCard(url); - return; - } - - console.log('✅ YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`); - - // Save to URL history - saveUrlHistory('youtube', url, result.name); - - // Update card with parsed data and stay in 'fresh' phase - updateYouTubeCardData(result.url_hash, result); - updateYouTubeCardPhase(result.url_hash, 'fresh'); - - // Auto-mirror this YouTube playlist - mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({ - track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''), - album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || '' - })), { description: url }); - - // Clear input - urlInput.value = ''; - - // Show success message - showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success'); - - } catch (error) { - console.error('❌ Error parsing YouTube playlist:', error); - showToast(`Error parsing YouTube playlist: ${error.message}`, 'error'); - removeYouTubeCard(url); - } -} - -function createYouTubeCard(url, phase = 'fresh') { - const container = document.getElementById('youtube-playlist-container'); - const placeholder = container.querySelector('.playlist-placeholder'); - - // Remove placeholder if it exists - if (placeholder) { - placeholder.style.display = 'none'; - } - - // Create temporary URL hash for initial card - const tempHash = btoa(url).substring(0, 8); - - const cardHtml = ` -
    -
    -
    -
    Parsing YouTube playlist...
    -
    - -- tracks - Loading... -
    -
    - - -
    - `; - - container.insertAdjacentHTML('beforeend', cardHtml); - - // Store temporary state - youtubePlaylistStates[tempHash] = { - phase: phase, - url: url, - cardElement: document.getElementById(`youtube-card-${tempHash}`), - tempHash: tempHash - }; - - console.log('🃏 Created YouTube card for URL:', url); -} - -function updateYouTubeCardData(urlHash, playlistData) { - // Find the card by URL or temp hash - let state = youtubePlaylistStates[urlHash]; - if (!state) { - // Look for temporary card by URL - const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url); - if (tempState) { - // Update the state with real hash - delete youtubePlaylistStates[tempState.tempHash]; - youtubePlaylistStates[urlHash] = tempState; - state = tempState; - - // Update card ID - if (state.cardElement) { - state.cardElement.id = `youtube-card-${urlHash}`; - } - } - } - - if (!state || !state.cardElement) { - console.error('❌ Could not find YouTube card for hash:', urlHash); - return; - } - - const card = state.cardElement; - - // Update card content - const nameElement = card.querySelector('.playlist-card-name'); - const trackCountElement = card.querySelector('.playlist-card-track-count'); - - nameElement.textContent = playlistData.name; - trackCountElement.textContent = `${playlistData.tracks.length} tracks`; - - // Store playlist data - state.playlist = playlistData; - state.urlHash = urlHash; - - // Add click handler for card and action button - const handleCardClick = () => handleYouTubeCardClick(urlHash); - const actionBtn = card.querySelector('.playlist-card-action-btn'); - - card.addEventListener('click', handleCardClick); - actionBtn.addEventListener('click', (e) => { - e.stopPropagation(); // Prevent card click - handleCardClick(); - }); - - console.log('🃏 Updated YouTube card data:', playlistData.name); -} - -function updateYouTubeCardPhase(urlHash, phase) { - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.cardElement) return; - - const card = state.cardElement; - const phaseTextElement = card.querySelector('.playlist-card-phase-text'); - const actionBtn = card.querySelector('.playlist-card-action-btn'); - const progressElement = card.querySelector('.playlist-card-progress'); - - state.phase = phase; - - switch (phase) { - case 'fresh': - phaseTextElement.textContent = 'Ready to discover'; - phaseTextElement.style.color = '#999'; - actionBtn.textContent = 'Start Discovery'; - actionBtn.disabled = false; - progressElement.classList.add('hidden'); - break; - - case 'discovering': - phaseTextElement.textContent = 'Discovering...'; - phaseTextElement.style.color = '#ffa500'; // Orange - actionBtn.textContent = 'View Progress'; - actionBtn.disabled = false; - progressElement.classList.remove('hidden'); - break; - - case 'discovered': - phaseTextElement.textContent = 'Discovery Complete'; - phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green - actionBtn.textContent = 'View Details'; - actionBtn.disabled = false; - progressElement.classList.add('hidden'); - break; - - case 'syncing': - phaseTextElement.textContent = 'Syncing...'; - phaseTextElement.style.color = '#ffa500'; // Orange - actionBtn.textContent = 'View Progress'; - actionBtn.disabled = false; - progressElement.classList.remove('hidden'); - break; - - case 'sync_complete': - phaseTextElement.textContent = 'Sync Complete'; - phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green - actionBtn.textContent = 'View Details'; - actionBtn.disabled = false; - progressElement.classList.add('hidden'); - break; - - case 'downloading': - phaseTextElement.textContent = 'Downloading...'; - phaseTextElement.style.color = '#ffa500'; // Orange - actionBtn.textContent = 'View Downloads'; - actionBtn.disabled = false; - progressElement.classList.remove('hidden'); - break; - - case 'download_complete': - phaseTextElement.textContent = 'Download Complete'; - phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green - actionBtn.textContent = 'View Results'; - actionBtn.disabled = false; - progressElement.classList.add('hidden'); - break; - } - - console.log('🃏 Updated YouTube card phase:', urlHash, phase); -} - -function handleYouTubeCardClick(urlHash) { - const state = youtubePlaylistStates[urlHash]; - if (!state) return; - - switch (state.phase) { - case 'fresh': - // First click: Start discovery and open modal - console.log('🎬 Starting YouTube discovery for first time:', urlHash); - updateYouTubeCardPhase(urlHash, 'discovering'); - startYouTubeDiscovery(urlHash); - openYouTubeDiscoveryModal(urlHash); - break; - - case 'discovering': - case 'discovered': - case 'syncing': - case 'sync_complete': - // Open discovery modal with current state - console.log('🎬 Opening YouTube discovery modal:', urlHash); - openYouTubeDiscoveryModal(urlHash); - break; - - case 'downloading': - case 'download_complete': - // Open download missing tracks modal - console.log('🎬 Opening download modal for YouTube playlist:', urlHash); - // Need to get playlist ID from converted Spotify data - const spotifyPlaylistId = state.convertedSpotifyPlaylistId; - if (spotifyPlaylistId) { - // Check if we have discovery results, if not load them first - if (!state.discoveryResults || state.discoveryResults.length === 0) { - console.log('🔍 Loading discovery results for download modal...'); - fetch(`/api/youtube/state/${urlHash}`) - .then(response => response.json()) - .then(fullState => { - if (fullState.discovery_results) { - state.discoveryResults = fullState.discovery_results; - console.log(`✅ Loaded ${state.discoveryResults.length} discovery results`); - - // Now open the modal with the loaded data - const playlistName = state.playlist.name; - const spotifyTracks = state.discoveryResults - .filter(result => result.spotify_data) - .map(result => result.spotify_data); - openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks); - } else { - console.error('❌ No discovery results found for downloads'); - showToast('Unable to open download modal - no discovery data', 'error'); - } - }) - .catch(error => { - console.error('❌ Error loading discovery results:', error); - showToast('Error loading playlist data', 'error'); - }); - } else { - // Use the YouTube-specific function to maintain proper state linking - const playlistName = state.playlist.name; - const spotifyTracks = state.discoveryResults - .filter(result => result.spotify_data) - .map(result => result.spotify_data); - openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks); - } - } else { - console.error('❌ No converted Spotify playlist ID found for downloads'); - showToast('Unable to open download modal - missing playlist data', 'error'); - } - break; - } -} - -function updateYouTubeCardProgress(urlHash, progress) { - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.cardElement) return; - - const card = state.cardElement; - const progressElement = card.querySelector('.playlist-card-progress'); - - const total = progress.spotify_total || 0; - const matches = progress.spotify_matches || 0; - const failed = total - matches; - const percentage = total > 0 ? Math.round((matches / total) * 100) : 0; - - progressElement.textContent = `♪ ${total} / ✓ ${matches} / ✗ ${failed} / ${percentage}%`; - - console.log('🃏 Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`); -} - -function removeYouTubeCard(url) { - const state = Object.values(youtubePlaylistStates).find(s => s.url === url); - if (state && state.cardElement) { - state.cardElement.remove(); - - // Remove from state - if (state.urlHash) { - delete youtubePlaylistStates[state.urlHash]; - } else if (state.tempHash) { - delete youtubePlaylistStates[state.tempHash]; - } - } - - // Show placeholder if no cards left - const container = document.getElementById('youtube-playlist-container'); - const cards = container.querySelectorAll('.youtube-playlist-card'); - const placeholder = container.querySelector('.playlist-placeholder'); - - if (cards.length === 0 && placeholder) { - placeholder.style.display = 'block'; - } -} - -async function startYouTubeDiscovery(urlHash) { - try { - console.log('🔍 Starting YouTube Spotify discovery for:', urlHash); - - const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error starting discovery: ${result.error}`, 'error'); - return; - } - - // Update frontend phase to match backend - const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; - if (state) { - state.phase = 'discovering'; - } - - // Update modal buttons to show "Discovering..." instead of "Start Discovery" - updateYouTubeModalButtons(urlHash, 'discovering'); - - // Start polling for progress - startYouTubeDiscoveryPolling(urlHash); - - // Open discovery modal - openYouTubeDiscoveryModal(urlHash); - - } catch (error) { - console.error('❌ Error starting YouTube discovery:', error); - showToast(`Error starting discovery: ${error.message}`, 'error'); - } -} - -function startYouTubeDiscoveryPolling(urlHash) { - // Stop any existing polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('discovery:subscribe', { ids: [urlHash] }); - _discoveryProgressCallbacks[urlHash] = (data) => { - if (data.error) { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; - return; - } - updateYouTubeCardProgress(urlHash, data); - const st = youtubePlaylistStates[urlHash]; - if (st) { st.discoveryResults = data.results || []; st.discovery_results = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; st.spotify_matches = data.spotify_matches || 0; } - updateYouTubeDiscoveryModal(urlHash, data); - if (data.complete) { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; - // Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement) - if (st) st.phase = 'discovered'; - updateYouTubeCardPhase(urlHash, 'discovered'); - updateYouTubeModalButtons(urlHash, 'discovered'); - showToast('Discovery complete!', 'success'); - } - }; - } - - const pollInterval = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/youtube/discovery/status/${urlHash}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling YouTube discovery status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - // Update card progress - updateYouTubeCardProgress(urlHash, status); - - // Store discovery results and progress in state - const state = youtubePlaylistStates[urlHash]; - if (state) { - state.discoveryResults = status.results || []; - state.discovery_results = status.results || []; - state.discoveryProgress = status.progress || 0; - state.spotifyMatches = status.spotify_matches || 0; - state.spotify_matches = status.spotify_matches || 0; - } - - // Update modal if open - updateYouTubeDiscoveryModal(urlHash, status); - - // Check if complete - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - - // Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement) - if (state) state.phase = 'discovered'; - // Update card phase to discovered - updateYouTubeCardPhase(urlHash, 'discovered'); - - // Update modal buttons to show sync and download buttons - updateYouTubeModalButtons(urlHash, 'discovered'); - - console.log('✅ Discovery complete:', urlHash); - showToast('Discovery complete!', 'success'); - } - - } catch (error) { - console.error('❌ Error polling YouTube discovery:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - } - }, 1000); - - activeYouTubePollers[urlHash] = pollInterval; -} - -function stopYouTubeDiscoveryPolling(urlHash) { - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - console.log('⏹ Stopped YouTube discovery polling for:', urlHash); - } -} - -function openYouTubeDiscoveryModal(urlHash) { - // Check ListenBrainz state first, then fallback to YouTube state - const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; - if (!state || !state.playlist) { - console.error('❌ No playlist data found for identifier:', urlHash); - return; - } - - console.log('🎵 Opening discovery modal for:', state.playlist.name); - - // Check if modal already exists - let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - - if (modal) { - // Modal exists, just show it - modal.classList.remove('hidden'); - console.log('🔄 Showing existing modal with preserved state'); - console.log('🔄 Current discovery results count:', state.discoveryResults?.length || state.discovery_results?.length || 0); - - // Resume polling if discovery or sync is in progress - if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) { - console.log('🔄 Resuming discovery polling...'); - startYouTubeDiscoveryPolling(urlHash); - } else if (state.phase === 'syncing' && !activeYouTubePollers[urlHash]) { - console.log('🔄 Resuming sync polling...'); - if (state.is_tidal_playlist) { - startTidalSyncPolling(urlHash); - } else if (state.is_deezer_playlist) { - startDeezerSyncPolling(urlHash); - } else if (state.is_spotify_public_playlist) { - startSpotifyPublicSyncPolling(urlHash); - } else if (state.is_beatport_playlist) { - startBeatportSyncPolling(urlHash); - } else if (state.is_listenbrainz_playlist) { - startListenBrainzSyncPolling(urlHash); - } else { - startYouTubeSyncPolling(urlHash); - } - } - } else { - // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, Spotify Public, and Mirrored) - const isTidal = state.is_tidal_playlist; - const isDeezer = state.is_deezer_playlist; - const isSpotifyPublic = state.is_spotify_public_playlist; - const isBeatport = state.is_beatport_playlist; - const isListenBrainz = state.is_listenbrainz_playlist; - const isMirrored = state.is_mirrored_playlist; - const isLastfmRadio = typeof urlHash === 'string' && urlHash.startsWith('lastfm_radio_'); - const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' : - isSpotifyPublic ? '🎵 Spotify Playlist Discovery' : - isDeezer ? '🎵 Deezer Playlist Discovery' : - isTidal ? '🎵 Tidal Playlist Discovery' : - isBeatport ? '🎵 Beatport Chart Discovery' : - isLastfmRadio ? '📻 Last.fm Radio Discovery' : - isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' : - '🎵 YouTube Playlist Discovery'; - const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') : - isSpotifyPublic ? 'Spotify' : - isDeezer ? 'Deezer' : - isTidal ? 'Tidal' : - isBeatport ? 'Beatport' : - isLastfmRadio ? 'Last.fm' : - isListenBrainz ? 'LB' : - 'YT'; - - const modalHtml = ` - - `; - - // Add modal to DOM - document.body.insertAdjacentHTML('beforeend', modalHtml); - modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - - // Store modal reference - state.modalElement = modal; - - // Set initial progress if we have discovery results - if (state.discoveryResults && state.discoveryResults.length > 0) { - // Compute progress from results if discoveryProgress is missing/zero - let progress = state.discoveryProgress || 0; - const matches = state.spotifyMatches || 0; - if (progress === 0 && state.discoveryResults.length > 0 && state.playlist.tracks.length > 0) { - progress = Math.min(100, Math.round((state.discoveryResults.length / state.playlist.tracks.length) * 100)); - } - const progressData = { - progress: progress, - spotify_matches: matches || state.discoveryResults.filter(r => r.status_class === 'found').length, - spotify_total: state.playlist.tracks.length, - results: state.discoveryResults - }; - updateYouTubeDiscoveryModal(urlHash, progressData); - } - - // Start polling immediately if modal is opened in syncing phase - if (state.phase === 'syncing') { - console.log('🔄 Modal opened in syncing phase - starting immediate polling...'); - if (state.is_tidal_playlist) { - startTidalSyncPolling(urlHash); - } else if (state.is_deezer_playlist) { - startDeezerSyncPolling(urlHash); - } else if (state.is_spotify_public_playlist) { - startSpotifyPublicSyncPolling(urlHash); - } else if (state.is_beatport_playlist) { - startBeatportSyncPolling(urlHash); - } else { - startYouTubeSyncPolling(urlHash); - } - } - - console.log('✨ Created new modal with current state'); - } -} - -function getModalActionButtons(urlHash, phase, state = null) { - // Get state if not provided - if (!state) { - state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; - } - - const isTidal = state && state.is_tidal_playlist; - const isDeezer = state && state.is_deezer_playlist; - const isSpotifyPublic = state && state.is_spotify_public_playlist; - const isBeatport = state && state.is_beatport_playlist; - const isListenBrainz = state && state.is_listenbrainz_playlist; - - // Validate data availability for buttons (support both naming conventions) - const hasDiscoveryResults = state && ((state.discoveryResults && state.discoveryResults.length > 0) || (state.discovery_results && state.discovery_results.length > 0)); - const hasSpotifyMatches = state && ((state.spotifyMatches > 0) || (state.spotify_matches > 0)); - const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId; - - switch (phase) { - case 'fresh': - case 'discovering': - // Show start discovery button for fresh playlists - if (phase === 'fresh') { - const wingItBtn = ` `; - - if (isListenBrainz) { - return `${wingItBtn}`; - } else { - return `${wingItBtn}`; - } - } else { - // Discovering phase - show progress - return ``; - } - - case 'discovered': - case 'downloading': - case 'download_complete': - // Only show buttons if we actually have discovery data - if (!hasDiscoveryResults) { - return ``; - } - - let buttons = ''; - - // Only show sync button if there are Spotify matches (and not standalone mode) - if (hasSpotifyMatches && !_isSoulsyncStandalone) { - if (isListenBrainz) { - buttons += ``; - } else if (isTidal) { - buttons += ``; - } else if (isDeezer) { - buttons += ``; - } else if (isSpotifyPublic) { - buttons += ``; - } else if (isBeatport) { - buttons += ``; - } else { - buttons += ``; - } - } - - // Only show download button if we have matches or a converted playlist ID - if (hasSpotifyMatches || hasConvertedPlaylistId) { - if (isListenBrainz) { - buttons += ``; - } else if (isTidal) { - buttons += ``; - } else if (isDeezer) { - buttons += ``; - } else if (isSpotifyPublic) { - buttons += ``; - } else if (isBeatport) { - buttons += ``; - } else { - buttons += ``; - } - } - - // Retry Failed button for mirrored playlists - if (state && state.is_mirrored_playlist) { - const results = state.discovery_results || state.discoveryResults || []; - const failedCount = results.filter(r => r.status_class !== 'found').length; - if (failedCount > 0) { - buttons += ``; - } - } - - // Rediscover button — reset and re-run discovery (only for sources with reset endpoints) - if (isBeatport) { - buttons += ``; - } else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) { - buttons += ``; - } - - // Wing It button — available in discovered phase - buttons += ` `; - - if (!buttons || buttons.trim().startsWith('
` + buttons; - } - - return buttons; - - case 'syncing': - if (isListenBrainz) { - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; - } else if (isTidal) { - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; - } else if (isDeezer) { - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; - } else if (isSpotifyPublic) { - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; - } else if (isBeatport) { - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; - } else { - return ` - -
- 0 - / - 0 - / - 0 - (0%) -
- `; - } - - case 'sync_complete': - let syncCompleteButtons = ''; - - // Only show sync button if there are Spotify matches (and not standalone mode) - if (hasSpotifyMatches && !_isSoulsyncStandalone) { - if (isListenBrainz) { - syncCompleteButtons += ``; - } else if (isTidal) { - syncCompleteButtons += ``; - } else if (isSpotifyPublic) { - syncCompleteButtons += ``; - } else if (isBeatport) { - syncCompleteButtons += ``; - } else { - syncCompleteButtons += ``; - } - } - - // Only show download button if we have matches or a converted playlist ID - if (hasSpotifyMatches || hasConvertedPlaylistId) { - if (isListenBrainz) { - syncCompleteButtons += ``; - } else if (isTidal) { - syncCompleteButtons += ``; - } else if (isSpotifyPublic) { - syncCompleteButtons += ``; - } else if (isBeatport) { - syncCompleteButtons += ``; - } else { - syncCompleteButtons += ``; - } - } - - // Rediscover button (only for sources with reset endpoints) - if (isBeatport) { - syncCompleteButtons += ``; - } else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) { - syncCompleteButtons += ``; - } - - // Wing It button - syncCompleteButtons += ` `; - - return syncCompleteButtons; - - case 'download_complete': - // Same options as sync_complete — allow re-sync, download missing, and reset - let dlCompleteButtons = ''; - - if (hasSpotifyMatches) { - if (isListenBrainz) { - dlCompleteButtons += ``; - } else if (isTidal) { - dlCompleteButtons += ``; - } else if (isDeezer) { - dlCompleteButtons += ``; - } else if (isSpotifyPublic) { - dlCompleteButtons += ``; - } else if (isBeatport) { - dlCompleteButtons += ``; - } else { - dlCompleteButtons += ``; - } - } - - if (hasSpotifyMatches || hasConvertedPlaylistId) { - if (isListenBrainz) { - dlCompleteButtons += ``; - } else if (isTidal) { - dlCompleteButtons += ``; - } else if (isDeezer) { - dlCompleteButtons += ``; - } else if (isSpotifyPublic) { - dlCompleteButtons += ``; - } else if (isBeatport) { - dlCompleteButtons += ``; - } else { - dlCompleteButtons += ``; - } - } - - // Rediscover button (only for sources with reset endpoints) - if (isBeatport) { - dlCompleteButtons += ``; - } else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) { - dlCompleteButtons += ``; - } - - return dlCompleteButtons; - - default: - return ''; - } -} - -function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false) { - const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))))); - switch (phase) { - case 'fresh': - return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`; - case 'discovering': - return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`; - case 'discovered': - case 'downloading': - case 'download_complete': - return 'Discovery complete! View the results below.'; - default: - return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`; - } -} - -function getInitialProgressText(phase, isTidal = false, isBeatport = false, isListenBrainz = false) { - switch (phase) { - case 'fresh': - return 'Click Start Discovery to begin...'; - case 'discovering': - return 'Starting discovery...'; - case 'discovered': - case 'downloading': - case 'download_complete': - return 'Discovery completed!'; - default: - return 'Starting discovery...'; - } -} - -function generateTableRowsFromState(state, urlHash) { - const isTidal = state.is_tidal_playlist; - const isDeezer = state.is_deezer_playlist; - const isSpotifyPublic = state.is_spotify_public_playlist; - const isBeatport = state.is_beatport_playlist; - const isListenBrainz = state.is_listenbrainz_playlist; - const isMirrored = state.is_mirrored_playlist; - const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'))))); - - // Support both camelCase and snake_case - const discoveryResults = state.discoveryResults || state.discovery_results; - - if (discoveryResults && discoveryResults.length > 0) { - // Generate rows from existing discovery results - return discoveryResults.map((result, index) => { - // Handle different field names based on platform - const trackName = result.lb_track || result.yt_track || result.track_name || '-'; - const artistName = result.lb_artist || result.yt_artist || result.artist_name || '-'; - - return ` - - ${trackName} - ${artistName} - ${result.status} - ${result.spotify_track || '-'} - ${result.spotify_artist || '-'} - ${result.spotify_album || '-'} - ${generateDiscoveryActionButton(result, urlHash, platform)} - - `; - }).join(''); - } else { - // Generate initial rows from playlist tracks - return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport, isListenBrainz); - } -} - -function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false, isListenBrainz = false) { - return tracks.map((track, index) => { - // Handle different track formats based on platform - let trackName, artistName; - - if (isListenBrainz) { - // ListenBrainz tracks have track_name and artist_name - trackName = track.track_name || 'Unknown Track'; - artistName = track.artist_name || 'Unknown Artist'; - } else { - // YouTube/Tidal/Beatport tracks have name and artists - trackName = track.name || 'Unknown Track'; - artistName = track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'; - } - - return ` - - ${trackName} - ${artistName} - 🔍 Pending... - - - - - - - - - - `; - }).join(''); -} - -function formatDuration(durationMs) { - if (!durationMs) return '0:00'; - const minutes = Math.floor(durationMs / 60000); - const seconds = Math.floor((durationMs % 60000) / 1000); - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -/** - * Generate action button for discovery table row - */ -function generateDiscoveryActionButton(result, identifier, platform) { - // Show fix button for not_found, error, or any non-found status - const isNotFound = result.status === 'not_found' || - result.status_class === 'not-found' || - result.status === '❌ Not Found' || - result.status === 'Not Found'; - - const isError = result.status === 'error' || - result.status_class === 'error' || - result.status === '❌ Error'; - - const isWingIt = result.wing_it_fallback || - result.status_class === 'wing-it'; - - const isFound = result.status === 'found' || - result.status_class === 'found' || - result.status === '✅ Found'; - - if (isNotFound || isError) { - return ``; - } - - // For wing-it fallbacks, show fix button so user can find a real match - if (isWingIt) { - return ``; - } - - // For found matches, show re-match and unmatch buttons - if (isFound) { - return ``; - } - - return '-'; -} - -function updateYouTubeDiscoveryModal(urlHash, status) { - const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`); - const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`); - const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`); - - if (!progressBar || !progressText || !tableBody) { - console.warn(`⚠️ Missing modal elements for ${urlHash}:`, { - progressBar: !!progressBar, - progressText: !!progressText, - tableBody: !!tableBody - }); - return; - } - - // Update progress bar - progressBar.style.width = `${status.progress}%`; - progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`; - - - // Update table rows - status.results.forEach(result => { - const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`); - if (!row) return; - - const statusCell = row.querySelector('.discovery-status'); - const spotifyTrackCell = row.querySelector('.spotify-track'); - const spotifyArtistCell = row.querySelector('.spotify-artist'); - const spotifyAlbumCell = row.querySelector('.spotify-album'); - const actionsCell = row.querySelector('.discovery-actions'); - - statusCell.textContent = result.status; - statusCell.className = `discovery-status ${result.status_class}`; - - spotifyTrackCell.textContent = result.spotify_track || '-'; - spotifyArtistCell.textContent = result.spotify_artist || '-'; - spotifyAlbumCell.textContent = result.spotify_album || '-'; - - // Update actions cell with appropriate button - if (actionsCell) { - const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; - const platform = state?.is_mirrored_playlist ? 'mirrored' : - (state?.is_spotify_public_playlist ? 'spotify_public' : - (state?.is_deezer_playlist ? 'deezer' : - (state?.is_listenbrainz_playlist ? 'listenbrainz' : - (state?.is_tidal_playlist ? 'tidal' : - (state?.is_beatport_playlist ? 'beatport' : 'youtube'))))); - actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); - } - }); - - // Update action buttons and description when discovery is complete. - // status.complete is explicitly set by LB/WS polling callers; only act when transitioning - // from 'discovering' to avoid interfering with download/sync phases of other playlist types. - if (status.complete) { - const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; - if (state && state.phase === 'discovering') { - state.phase = 'discovered'; - const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); - if (actionButtonsContainer) { - actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); - console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`); - } - const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`); - if (descEl) descEl.textContent = 'Discovery complete! View the results below.'; - } else if (state && state.phase === 'discovered') { - // Already discovered — ensure buttons are correct (e.g. after rehydration) - const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); - if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) { - actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); - } - } - } -} - -function refreshYouTubeDiscoveryModalTable(urlHash) { - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.modalElement) { - console.warn(`⚠️ Cannot refresh modal table: no state or modal for ${urlHash}`); - return; - } - - console.log(`🔄 Refreshing modal table with ${state.discoveryResults?.length || 0} discovery results`); - - // Update the table body with new discovery results - const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`); - if (tableBody) { - tableBody.innerHTML = generateTableRowsFromState(state, urlHash); - console.log(`✅ Modal table refreshed with discovery data`); - } else { - console.warn(`⚠️ Could not find table body for modal ${urlHash}`); - } - - // Update the progress bar and footer buttons too - if (state.discoveryResults && state.discoveryResults.length > 0) { - const progressData = { - progress: state.discoveryProgress || 100, - spotify_matches: state.spotifyMatches || 0, - spotify_total: state.playlist.tracks.length, - results: state.discoveryResults - }; - updateYouTubeDiscoveryModal(urlHash, progressData); - } -} - -function closeYouTubeDiscoveryModal(urlHash) { - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (modal) { - // Hide modal instead of removing it to preserve state - modal.classList.add('hidden'); - console.log('🚪 Hidden YouTube discovery modal (preserving state):', urlHash); - } - - // Handle phase reset for completed discovery (Tidal/Beatport pattern) - const state = youtubePlaylistStates[urlHash]; - if (state) { - const isTidal = state.is_tidal_playlist; - const isDeezer = state.is_deezer_playlist; - const isSpotifyPublic = state.is_spotify_public_playlist; - const isBeatport = state.is_beatport_playlist; - - // Reset to 'discovered' phase if modal is closed after completion (like Tidal does) - if (state.phase === 'sync_complete' || state.phase === 'download_complete') { - console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))} state after completion`); - - if (isSpotifyPublic) { - // Spotify Public: Extract url_hash and reset state - const spUrlHash = state.spotify_public_playlist_id || null; - if (spUrlHash && spotifyPublicPlaylistStates[spUrlHash]) { - const preservedData = { - playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, - discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, - spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, - discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, - convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId - }; - - delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; - delete spotifyPublicPlaylistStates[spUrlHash].phase; - - Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); - spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; - - updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); - - try { - fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - } catch (error) { - console.warn('Error updating backend Spotify Public phase:', error); - } - } - } else if (isDeezer) { - // Deezer: Extract playlist ID and reset Deezer state - const deezerPlaylistId = state.deezer_playlist_id || null; - if (deezerPlaylistId && deezerPlaylistStates[deezerPlaylistId]) { - const preservedData = { - playlist: deezerPlaylistStates[deezerPlaylistId].playlist, - discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results, - spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches, - discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress, - convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId - }; - - delete deezerPlaylistStates[deezerPlaylistId].download_process_id; - delete deezerPlaylistStates[deezerPlaylistId].phase; - - Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); - deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; - - updateDeezerCardPhase(deezerPlaylistId, 'discovered'); - - try { - fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - } catch (error) { - console.warn('Error updating backend Deezer phase:', error); - } - } - } else if (isTidal) { - // Tidal: Extract playlist ID and reset Tidal state - const tidalPlaylistId = state.tidal_playlist_id || null; - if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) { - // Preserve discovery data but reset phase - const preservedData = { - playlist: tidalPlaylistStates[tidalPlaylistId].playlist, - discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results, - spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches, - discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, - convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId - }; - - // Clear download state - delete tidalPlaylistStates[tidalPlaylistId].download_process_id; - delete tidalPlaylistStates[tidalPlaylistId].phase; - - // Restore preserved data and set to discovered phase - Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); - tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; - - updateTidalCardPhase(tidalPlaylistId, 'discovered'); - - // Update backend state - try { - fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - } catch (error) { - console.warn('⚠️ Error updating backend Tidal phase:', error); - } - } - } else if (isBeatport) { - // Beatport: Reset chart state - const chartHash = state.beatport_chart_hash || urlHash; - if (beatportChartStates[chartHash]) { - beatportChartStates[chartHash].phase = 'discovered'; - updateBeatportCardPhase(chartHash, 'discovered'); - - // Update backend state - try { - fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - } catch (error) { - console.warn('⚠️ Error updating backend Beatport phase:', error); - } - } - } else { - // YouTube: Reset to discovered phase - updateYouTubeCardPhase(urlHash, 'discovered'); - - // Update backend state - try { - fetch(`/api/youtube/update_phase/${urlHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - } catch (error) { - console.warn('⚠️ Error updating backend YouTube phase:', error); - } - } - - // Reset frontend state to discovered - state.phase = 'discovered'; - console.log(`✅ [Modal Close] Reset to discovered phase: ${urlHash}`); - } - } - - // Keep modal reference and all state intact - // Discovery polling continues in background if active -} - -// =============================== -// YOUTUBE SYNC FUNCTIONALITY -// =============================== - -async function startYouTubePlaylistSync(urlHash) { - try { - console.log('🔄 Starting YouTube playlist sync:', urlHash); - - const response = await fetch(`/api/youtube/sync/start/${urlHash}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error starting sync: ${result.error}`, 'error'); - return; - } - - // Capture sync_playlist_id for WebSocket subscription - const syncPlaylistId = result.sync_playlist_id; - const ytState = youtubePlaylistStates[urlHash]; - if (ytState) ytState.syncPlaylistId = syncPlaylistId; - - // Update card and modal to syncing phase - updateYouTubeCardPhase(urlHash, 'syncing'); - - // Update modal buttons if modal is open - updateYouTubeModalButtons(urlHash, 'syncing'); - - // Start sync polling - startYouTubeSyncPolling(urlHash, syncPlaylistId); - - showToast('YouTube playlist sync started!', 'success'); - - } catch (error) { - console.error('❌ Error starting YouTube sync:', error); - showToast(`Error starting sync: ${error.message}`, 'error'); - } -} - -function startYouTubeSyncPolling(urlHash, syncPlaylistId) { - // Stop any existing polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - } - - // Resolve syncPlaylistId from argument or stored state - const ytState = youtubePlaylistStates[urlHash]; - syncPlaylistId = syncPlaylistId || (ytState && ytState.syncPlaylistId); - - // Phase 6: Subscribe via WebSocket - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - updateYouTubeCardSyncProgress(urlHash, progress); - updateYouTubeModalSyncProgress(urlHash, progress); - - if (data.status === 'finished') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - updateYouTubeCardPhase(urlHash, 'sync_complete'); - updateYouTubeModalButtons(urlHash, 'sync_complete'); - showToast('YouTube playlist sync complete!', 'success'); - } else if (data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - updateYouTubeCardPhase(urlHash, 'discovered'); - updateYouTubeModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - }; - } - - // Define the polling function (HTTP fallback) - const pollFunction = async () => { - if (socketConnected) return; // Phase 6: WS handles updates - try { - const response = await fetch(`/api/youtube/sync/status/${urlHash}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling YouTube sync status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - return; - } - - updateYouTubeCardSyncProgress(urlHash, status.progress); - updateYouTubeModalSyncProgress(urlHash, status.progress); - - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - updateYouTubeCardPhase(urlHash, 'sync_complete'); - updateYouTubeModalButtons(urlHash, 'sync_complete'); - showToast('YouTube playlist sync complete!', 'success'); - } else if (status.sync_status === 'error') { - clearInterval(pollInterval); - delete activeYouTubePollers[urlHash]; - updateYouTubeCardPhase(urlHash, 'discovered'); - updateYouTubeModalButtons(urlHash, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); - } - } catch (error) { - console.error('❌ Error polling YouTube sync:', error); - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - } - }; - - // Run immediately to get current status (skip if WS active) - if (!socketConnected) pollFunction(); - - // Then continue polling at regular intervals - const pollInterval = setInterval(pollFunction, 1000); - activeYouTubePollers[urlHash] = pollInterval; -} - -async function cancelYouTubeSync(urlHash) { - try { - console.log('❌ Cancelling YouTube sync:', urlHash); - - const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, { - method: 'POST' - }); - - const result = await response.json(); - - if (result.error) { - showToast(`Error cancelling sync: ${result.error}`, 'error'); - return; - } - - // Stop polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - // Phase 6: Clean up WS subscription - const ytCancelState = youtubePlaylistStates[urlHash]; - const ytSyncId = ytCancelState && ytCancelState.syncPlaylistId; - if (ytSyncId && _syncProgressCallbacks[ytSyncId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [ytSyncId] }); - delete _syncProgressCallbacks[ytSyncId]; - } - - // Revert to discovered phase - updateYouTubeCardPhase(urlHash, 'discovered'); - updateYouTubeModalButtons(urlHash, 'discovered'); - - showToast('YouTube sync cancelled', 'info'); - - } catch (error) { - console.error('❌ Error cancelling YouTube sync:', error); - showToast(`Error cancelling sync: ${error.message}`, 'error'); - } -} - -function updateYouTubeCardSyncProgress(urlHash, progress) { - const state = youtubePlaylistStates[urlHash]; - if (!state || !state.cardElement || !progress) return; - - const card = state.cardElement; - const progressElement = card.querySelector('.playlist-card-progress'); - - // Build clean status counter HTML exactly like Spotify cards - let statusCounterHTML = ''; - if (progress && progress.total_tracks > 0) { - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const total = progress.total_tracks || 0; - const processed = matched + failed; - const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - statusCounterHTML = ` -
- ♪ ${total} - / - ✓ ${matched} - / - ✗ ${failed} - (${percentage}%) -
- `; - } - - // Only update if we have valid sync progress, otherwise preserve existing discovery results - if (statusCounterHTML) { - progressElement.innerHTML = statusCounterHTML; - } - - console.log(`🔄 Updated YouTube sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`); -} - -function updateYouTubeModalSyncProgress(urlHash, progress) { - // Try all source-specific element ID prefixes - const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'beatport']; - let statusDisplay = null; - let prefix = 'youtube'; - for (const p of prefixes) { - statusDisplay = document.getElementById(`${p}-sync-status-${urlHash}`); - if (statusDisplay) { prefix = p; break; } - } - if (!statusDisplay || !progress) return; - - const totalEl = document.getElementById(`${prefix}-total-${urlHash}`); - const matchedEl = document.getElementById(`${prefix}-matched-${urlHash}`); - const failedEl = document.getElementById(`${prefix}-failed-${urlHash}`); - const percentageEl = document.getElementById(`${prefix}-percentage-${urlHash}`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - - // Calculate percentage like Spotify sync - if (total > 0) { - const processed = matched + failed; - const percentage = Math.round((processed / total) * 100); - if (percentageEl) percentageEl.textContent = percentage; - } - - console.log(`📊 YouTube modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); -} - -function updateYouTubeModalButtons(urlHash, phase) { - const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (!modal) return; - - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } -} - -// =============================== -// YOUTUBE DOWNLOAD MISSING TRACKS -// =============================== - -async function startYouTubeDownloadMissing(urlHash) { - try { - console.log('🔍 Starting download missing tracks:', urlHash); - - // Check both YouTube and ListenBrainz states (like Beatport does) - const state = youtubePlaylistStates[urlHash] || listenbrainzPlaylistStates[urlHash]; - // Support both camelCase and snake_case - const discoveryResults = state?.discoveryResults || state?.discovery_results; - - if (!state || !discoveryResults) { - showToast('No discovery results available for download', 'error'); - return; - } - - // Determine source type (prefix removed - no longer needed) - const isListenBrainz = state.is_listenbrainz_playlist; - const isBeatport = state.is_beatport_playlist; - const isTidal = state.is_tidal_playlist; - const isDeezer = state.is_deezer_playlist; - - // Convert discovery results to a format compatible with the download modal - const spotifyTracks = discoveryResults - .filter(result => result.spotify_data || (result.spotify_track && result.status_class === 'found')) - .map(result => { - if (result.spotify_data) { - return result.spotify_data; - } else { - // Build from individual fields (automatic discovery format) - // Convert album to proper object format for wishlist compatibility - const albumData = result.spotify_album || 'Unknown Album'; - const albumObject = typeof albumData === 'object' && albumData !== null - ? albumData - : { - name: typeof albumData === 'string' ? albumData : 'Unknown Album', - album_type: 'album', - images: [] - }; - - return { - id: result.spotify_id || 'unknown', - name: result.spotify_track || 'Unknown Track', - artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], - album: albumObject, - duration_ms: 0 - }; - } - }); - - if (spotifyTracks.length === 0) { - showToast('No Spotify matches found for download', 'error'); - return; - } - - // Create a virtual playlist for the download system - const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isDeezer ? `deezer_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`))); - const playlistName = state.playlist.name; - - // Store reference for card navigation - state.convertedSpotifyPlaylistId = virtualPlaylistId; - - // Close the discovery modal if it's open - const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); - if (discoveryModal) { - discoveryModal.classList.add('hidden'); - console.log('🔄 Closed YouTube discovery modal to show download modal'); - } - - // Open download missing tracks modal for YouTube playlist - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - // Phase will change to 'downloading' when user clicks "Begin Analysis" button - - } catch (error) { - console.error('❌ Error starting download missing tracks:', error); - showToast(`Error starting downloads: ${error.message}`, 'error'); - } -} - -async function resetYouTubePlaylist(urlHash) { - const state = youtubePlaylistStates[urlHash]; - if (!state) return; - - try { - console.log(`🔄 Resetting YouTube playlist to fresh state: ${state.playlist.name}`); - - // Call backend reset endpoint - const response = await fetch(`/api/youtube/reset/${urlHash}`, { - method: 'POST' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to reset playlist'); - } - - // Stop any active polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - // Update client state to match backend reset - state.phase = 'fresh'; - state.discoveryResults = []; - state.discoveryProgress = 0; - state.spotifyMatches = 0; - state.syncPlaylistId = null; - state.syncProgress = {}; - state.convertedSpotifyPlaylistId = null; - - // Update card to reflect fresh state - updateYouTubeCardPhase(urlHash, 'fresh'); - updateYouTubeCardProgress(urlHash, { - discovery_progress: 0, - spotify_matches: 0, - spotify_total: state.playlist.tracks.length - }); - - // Close modal - closeYouTubeDiscoveryModal(urlHash); - - showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); - console.log(`✅ Successfully reset YouTube playlist: ${state.playlist.name}`); - - } catch (error) { - console.error(`❌ Error resetting YouTube playlist:`, error); - showToast(`Error resetting playlist: ${error.message}`, 'error'); - } -} - -async function resetBeatportChart(urlHash) { - const state = youtubePlaylistStates[urlHash]; - const chartState = beatportChartStates[urlHash]; - - if (!state || !state.is_beatport_playlist || !chartState) { - console.error('❌ Invalid Beatport chart state for reset'); - return; - } - - try { - console.log(`🔄 Resetting Beatport chart to fresh state: ${state.playlist.name}`); - - // Call backend reset endpoint for Beatport - const chartHash = state.beatport_chart_hash || urlHash; - const response = await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - phase: 'fresh', - reset: true - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to reset Beatport chart'); - } - - // Stop any active polling - if (activeYouTubePollers[urlHash]) { - clearInterval(activeYouTubePollers[urlHash]); - delete activeYouTubePollers[urlHash]; - } - - // Update client state to match backend reset - state.phase = 'fresh'; - state.discoveryResults = []; - state.discoveryProgress = 0; - state.spotifyMatches = 0; - state.discovery_results = []; - state.discovery_progress = 0; - state.spotify_matches = 0; - state.syncPlaylistId = null; - state.syncProgress = {}; - state.convertedSpotifyPlaylistId = null; - - // Update Beatport chart state - chartState.phase = 'fresh'; - - // Update card to reflect fresh state - updateBeatportCardPhase(chartHash, 'fresh'); - updateBeatportCardProgress(chartHash, { - spotify_total: state.playlist.tracks.length, - spotify_matches: 0, - failed: 0 - }); - - // Close modal - closeYouTubeDiscoveryModal(urlHash); - - showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); - console.log(`✅ Successfully reset Beatport chart: ${state.playlist.name}`); - - } catch (error) { - console.error(`❌ Error resetting Beatport chart:`, error); - showToast(`Error resetting chart: ${error.message}`, 'error'); - } -} - -// ============================================================================ -// LISTENBRAINZ PLAYLIST DISCOVERY & SYNC -// ============================================================================ - -function startListenBrainzDiscoveryPolling(playlistMbid) { - console.log(`🔄 Starting ListenBrainz discovery polling for: ${playlistMbid}`); - - // Stop any existing polling (reuse YouTube polling infrastructure) - if (activeYouTubePollers[playlistMbid]) { - clearInterval(activeYouTubePollers[playlistMbid]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('discovery:subscribe', { ids: [playlistMbid] }); - _discoveryProgressCallbacks[playlistMbid] = (data) => { - if (data.error) { - if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } - socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid]; - return; - } - if (listenbrainzPlaylistStates[playlistMbid]) { - const transformed = { - progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0, - results: (data.results || []).map((r, i) => ({ - index: r.index !== undefined ? r.index : i, - yt_track: r.lb_track || r.track_name || 'Unknown', - yt_artist: r.lb_artist || r.artist_name || 'Unknown', - status: (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found') ? '✅ Found' : (r.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: r.status_class || ((r.status === 'found' || r.status === '✅ Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')), - spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), - spotify_artist: r.spotify_data ? (r.spotify_data.artists && r.spotify_data.artists[0] ? (typeof r.spotify_data.artists[0] === 'object' ? r.spotify_data.artists[0].name : r.spotify_data.artists[0]) : '-') : (r.spotify_artist || '-'), - spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) || '-' : (r.spotify_album || '-'), - spotify_data: r.spotify_data, duration: r.duration || '0:00' - })), - complete: data.complete || data.phase === 'discovered' - }; - const st = listenbrainzPlaylistStates[playlistMbid]; - st.discovery_results = data.results || []; st.discoveryResults = transformed.results; - st.discovery_progress = data.progress || 0; st.discoveryProgress = data.progress || 0; - st.spotify_matches = data.spotify_matches || 0; st.spotifyMatches = data.spotify_matches || 0; - st.spotify_total = data.spotify_total || 0; st.spotifyTotal = data.spotify_total || 0; - updateYouTubeDiscoveryModal(playlistMbid, transformed); - } - if (data.complete || data.phase === 'discovered') { - if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } - socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid]; - if (listenbrainzPlaylistStates[playlistMbid]) listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; - updateYouTubeModalButtons(playlistMbid, 'discovered'); - const _descElWs = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`); - if (_descElWs) _descElWs.textContent = 'Discovery complete! View the results below.'; - const playlistIdEl = `discover-lb-playlist-${playlistMbid}`; - const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`); - if (syncBtn) syncBtn.style.display = 'inline-block'; - showToast('ListenBrainz discovery complete!', 'success'); - } - }; - } - - const pollInterval = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/listenbrainz/discovery/status/${playlistMbid}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling ListenBrainz discovery status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - return; - } - - // Update state and modal (reuse YouTube infrastructure like Beatport/Tidal) - if (listenbrainzPlaylistStates[playlistMbid]) { - // Transform ListenBrainz results to YouTube modal format (like Beatport does) - const transformedStatus = { - progress: status.progress || 0, - spotify_matches: status.spotify_matches || 0, - spotify_total: status.spotify_total || 0, - results: (status.results || []).map((result, index) => ({ - index: result.index !== undefined ? result.index : index, - yt_track: result.lb_track || result.track_name || 'Unknown', - yt_artist: result.lb_artist || result.artist_name || 'Unknown', - status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), - status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), - spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), - spotify_artist: result.spotify_data ? (result.spotify_data.artists && result.spotify_data.artists[0] ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : '-') : (result.spotify_artist || '-'), - spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) || '-' : (result.spotify_album || '-'), - spotify_data: result.spotify_data, - duration: result.duration || '0:00' - })), - complete: status.complete || status.phase === 'discovered' - }; - - // Store both raw and transformed results (support both naming conventions) - listenbrainzPlaylistStates[playlistMbid].discovery_results = status.results || []; - listenbrainzPlaylistStates[playlistMbid].discoveryResults = transformedStatus.results; - listenbrainzPlaylistStates[playlistMbid].discovery_progress = status.progress || 0; - listenbrainzPlaylistStates[playlistMbid].discoveryProgress = status.progress || 0; - listenbrainzPlaylistStates[playlistMbid].spotify_matches = status.spotify_matches || 0; - listenbrainzPlaylistStates[playlistMbid].spotifyMatches = status.spotify_matches || 0; // camelCase for modal - listenbrainzPlaylistStates[playlistMbid].spotify_total = status.spotify_total || 0; - listenbrainzPlaylistStates[playlistMbid].spotifyTotal = status.spotify_total || 0; // camelCase for modal - - // Update modal if open - updateYouTubeDiscoveryModal(playlistMbid, transformedStatus); - } - - // Check if complete - if (status.complete || status.phase === 'discovered') { - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - - // Update phase in backend for persistence (like Beatport does) - try { - await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ phase: 'discovered' }) - }); - console.log('✅ Updated ListenBrainz backend phase to discovered'); - } catch (error) { - console.warn('⚠️ Failed to update backend phase:', error); - } - - // Update phase in frontend state - if (listenbrainzPlaylistStates[playlistMbid]) { - listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; - } - - // Update modal buttons to show sync and download buttons - updateYouTubeModalButtons(playlistMbid, 'discovered'); - - // Update modal description to "Discovery complete!" - const descEl = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`); - if (descEl) descEl.textContent = 'Discovery complete! View the results below.'; - - // Show sync button in playlist listing (hidden by default until discovered) - const playlistId = `discover-lb-playlist-${playlistMbid}`; - const syncBtn = document.getElementById(`${playlistId}-sync-btn`); - if (syncBtn) { - syncBtn.style.display = 'inline-block'; - console.log('✅ Showing sync button after discovery completion'); - } - - console.log('✅ ListenBrainz discovery complete:', playlistMbid); - showToast('ListenBrainz discovery complete!', 'success'); - } - - } catch (error) { - console.error('❌ Error polling ListenBrainz discovery:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - } - }, 1000); - - activeYouTubePollers[playlistMbid] = pollInterval; -} - -function startListenBrainzSyncPolling(playlistMbid, syncPlaylistId) { - // Stop any existing polling - if (activeYouTubePollers[playlistMbid]) { - clearInterval(activeYouTubePollers[playlistMbid]); - } - - // Resolve syncPlaylistId from argument or stored state - const lbState = listenbrainzPlaylistStates[playlistMbid]; - syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId); - - // Phase 6: Subscribe via WebSocket - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - updateYouTubeModalSyncProgress(playlistMbid, progress); - - if (data.status === 'finished') { - if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - updateYouTubeModalButtons(playlistMbid, 'sync_complete'); - showToast('ListenBrainz playlist sync complete!', 'success'); - } else if (data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - updateYouTubeModalButtons(playlistMbid, 'discovered'); - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - }; - } - - // Define the polling function (HTTP fallback) - const pollFunction = async () => { - if (socketConnected) return; // Phase 6: WS handles updates - try { - const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling ListenBrainz sync status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - return; - } - - updateYouTubeModalSyncProgress(playlistMbid, status.progress); - - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - updateYouTubeModalButtons(playlistMbid, 'sync_complete'); - showToast('ListenBrainz playlist sync complete!', 'success'); - } else if (status.sync_status === 'error') { - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - updateYouTubeModalButtons(playlistMbid, 'discovered'); - showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); - } - } catch (error) { - console.error('❌ Error polling ListenBrainz sync:', error); - if (activeYouTubePollers[playlistMbid]) { - clearInterval(activeYouTubePollers[playlistMbid]); - delete activeYouTubePollers[playlistMbid]; - } - } - }; - - // Run immediately to get current status (skip if WS active) - if (!socketConnected) pollFunction(); - - // Then continue polling at regular intervals - const pollInterval = setInterval(pollFunction, 1000); - activeYouTubePollers[playlistMbid] = pollInterval; -} - -async function startListenBrainzDiscovery(playlistMbid) { - const state = listenbrainzPlaylistStates[playlistMbid]; - if (!state) { - console.error('❌ No ListenBrainz playlist state found'); - return; - } - - try { - console.log('🔍 Starting ListenBrainz discovery for:', state.playlist.name); - - // Update local phase to discovering - state.phase = 'discovering'; - state.status = 'discovering'; - - // Call backend to start discovery worker - const response = await fetch(`/api/listenbrainz/discovery/start/${playlistMbid}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - playlist: state.playlist - }) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to start discovery'); - } - - console.log('✅ ListenBrainz discovery started on backend'); - - // Start polling for progress - startListenBrainzDiscoveryPolling(playlistMbid); - - // Update modal to show discovering state - updateYouTubeDiscoveryModal(playlistMbid, { - phase: 'discovering', - progress: 0, - results: [] - }); - - showToast('Starting ListenBrainz discovery...', 'info'); - - } catch (error) { - console.error('❌ Error starting ListenBrainz discovery:', error); - showToast(`Error: ${error.message}`, 'error'); - - // Revert phase on error - state.phase = 'fresh'; - state.status = 'pending'; - } -} - -async function startListenBrainzPlaylistSync(playlistMbid) { - const state = listenbrainzPlaylistStates[playlistMbid]; - if (!state) { - console.error('❌ No ListenBrainz playlist state found'); - return; - } - - try { - console.log('🔄 Starting ListenBrainz sync for:', state.playlist.name); - - // Check if being called from playlist listing (has UI elements) or modal - const listingPlaylistId = `discover-lb-playlist-${playlistMbid}`; - const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); - const isFromListing = statusDisplay !== null; - - if (isFromListing) { - console.log('🔄 Sync initiated from playlist listing'); - // Show status display in listing - statusDisplay.style.display = 'block'; - const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - } - } - - // Call backend to start sync - const response = await fetch(`/api/listenbrainz/sync/start/${playlistMbid}`, { - method: 'POST' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to start sync'); - } - - // Capture sync_playlist_id for WebSocket subscription - const result = await response.json(); - const syncPlaylistId = result.sync_playlist_id; - if (state) state.syncPlaylistId = syncPlaylistId; - - // Update phase to syncing - state.phase = 'syncing'; - - // Start polling for sync progress - if (isFromListing) { - startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId); - } else { - startListenBrainzSyncPolling(playlistMbid, syncPlaylistId); - updateYouTubeModalButtons(playlistMbid, 'syncing'); - } - - showToast('Starting ListenBrainz sync...', 'info'); - - } catch (error) { - console.error('❌ Error starting ListenBrainz sync:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId) { - console.log(`🔄 Starting listing sync polling for: ${playlistMbid} (UI: ${listingPlaylistId})`); - - // Stop any existing polling - if (activeYouTubePollers[playlistMbid]) { - clearInterval(activeYouTubePollers[playlistMbid]); - } - - // Resolve syncPlaylistId from argument or stored state - const lbState = listenbrainzPlaylistStates[playlistMbid]; - syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId); - - // Phase 6: Subscribe via WebSocket - if (socketConnected && syncPlaylistId) { - socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); - _syncProgressCallbacks[syncPlaylistId] = (data) => { - const progress = data.progress || {}; - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const percentage = total > 0 ? Math.round((matched / total) * 100) : 0; - - const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`); - const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`); - const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`); - const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`); - - if (totalEl) totalEl.textContent = total; - if (matchedEl) matchedEl.textContent = matched; - if (failedEl) failedEl.textContent = failed; - if (percentageEl) percentageEl.textContent = percentage; - - if (data.status === 'finished') { - if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - - const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); - const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); - if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000); - if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; } - - if (listenbrainzPlaylistStates[playlistMbid]) { - listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete'; - } - - showToast(`Sync complete: ${matched}/${total} tracks matched`, 'success'); - } else if (data.status === 'error' || data.status === 'cancelled') { - if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } - socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); - delete _syncProgressCallbacks[syncPlaylistId]; - showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - }; - } - - const pollInterval = setInterval(async () => { - if (socketConnected) return; // Phase 6: WS handles updates - try { - const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`); - const status = await response.json(); - - if (status.error) { - console.error('❌ Error polling ListenBrainz sync status:', status.error); - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - return; - } - - const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`); - const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`); - const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`); - const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`); - - if (totalEl) totalEl.textContent = status.progress?.total_tracks || 0; - if (matchedEl) matchedEl.textContent = status.progress?.matched_tracks || 0; - if (failedEl) failedEl.textContent = status.progress?.failed_tracks || 0; - - const percentage = status.progress?.total_tracks > 0 - ? Math.round(((status.progress?.matched_tracks || 0) / status.progress.total_tracks) * 100) - : 0; - if (percentageEl) percentageEl.textContent = percentage; - - if (status.complete) { - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - - const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); - const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); - if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000); - if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; } - - if (listenbrainzPlaylistStates[playlistMbid]) { - listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete'; - } - - showToast(`Sync complete: ${status.progress?.matched_tracks || 0}/${status.progress?.total_tracks || 0} tracks matched`, 'success'); - } - } catch (error) { - console.error('❌ Error polling ListenBrainz listing sync:', error); - clearInterval(pollInterval); - delete activeYouTubePollers[playlistMbid]; - } - }, 1000); - - activeYouTubePollers[playlistMbid] = pollInterval; -} - -// ============================================================================ -// ARTISTS PAGE FUNCTIONALITY - ELEGANT SEARCH & DISCOVERY -// ============================================================================ - -/** - * Initialize the artists page when navigated to (only runs once) - */ -function initializeArtistsPage() { - console.log('🎵 Initializing Artists Page (first time)'); - - // Get DOM elements - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - const searchStatus = document.getElementById('artists-search-status'); - const backButton = document.getElementById('artists-back-button'); - const detailBackButton = document.getElementById('artist-detail-back-button'); - - // Set up event listeners (only need to do this once) - if (searchInput) { - searchInput.addEventListener('input', handleArtistsSearchInput); - searchInput.addEventListener('keypress', handleArtistsSearchKeypress); - } - - if (headerSearchInput) { - headerSearchInput.addEventListener('input', handleArtistsHeaderSearchInput); - headerSearchInput.addEventListener('keypress', handleArtistsSearchKeypress); - } - - if (backButton) { - backButton.addEventListener('click', () => showArtistsSearchState()); - } - - if (detailBackButton) { - detailBackButton.addEventListener('click', () => { - // If there are no search results (user navigated directly to artist), - // go straight to the main search view instead of showing an empty results page - if (!artistsPageState.searchResults || artistsPageState.searchResults.length === 0) { - showArtistsSearchState(); - } else { - showArtistsResultsState(); - } - }); - } - - // Initialize tabs (only need to do this once) - initializeArtistTabs(); - - // Mark as initialized - artistsPageState.isInitialized = true; - - // Restore previous state instead of always resetting to search - restoreArtistsPageState(); - console.log('✅ Artists Page initialized successfully (ready for navigation)'); -} - -/** - * Restore the artists page to its previous state - */ -function restoreArtistsPageState() { - console.log(`🔄 Restoring artists page state: ${artistsPageState.currentView}`); - - switch (artistsPageState.currentView) { - case 'results': - // Restore search results state - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - console.log(`📦 Restoring search results for: "${artistsPageState.searchQuery}"`); - - // Restore search input values - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - if (searchInput) searchInput.value = artistsPageState.searchQuery; - if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; - - // Display the cached results - displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); - } else { - // No valid results state, fall back to search - showArtistsSearchState(); - } - break; - - case 'detail': - // Restore artist detail state - if (artistsPageState.selectedArtist && artistsPageState.artistDiscography) { - console.log(`🎤 Restoring artist detail for: ${artistsPageState.selectedArtist.name}`); - - // First restore search results if they exist - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - if (searchInput) searchInput.value = artistsPageState.searchQuery; - if (headerSearchInput) headerSearchInput.value = artistsPageState.searchQuery; - } - - // Show artist detail state - showArtistDetailState(); - - // Update artist info in header - updateArtistDetailHeader(artistsPageState.selectedArtist); - - // Display cached discography - if (artistsPageState.artistDiscography.albums || artistsPageState.artistDiscography.singles) { - displayArtistDiscography(artistsPageState.artistDiscography); - // Restore cached completion data instead of re-scanning - restoreCachedCompletionData(artistsPageState.selectedArtist.id); - } - } else { - // No valid detail state, fall back to search or results - if (artistsPageState.searchQuery && artistsPageState.searchResults.length > 0) { - displayArtistsResults(artistsPageState.searchQuery, artistsPageState.searchResults); - } else { - showArtistsSearchState(); - } - } - break; - - default: - case 'search': - // Show search state (but preserve any existing search query) - if (artistsPageState.searchQuery) { - const searchInput = document.getElementById('artists-search-input'); - if (searchInput) searchInput.value = artistsPageState.searchQuery; - } - showArtistsSearchState(); - break; - } -} - -/** - * Handle search input with debouncing - */ -function handleArtistsSearchInput(event) { - const query = event.target.value.trim(); - updateArtistsSearchStatus('searching'); - - // Clear existing timeout - if (artistsSearchTimeout) { - clearTimeout(artistsSearchTimeout); - } - - // Cancel any active search - if (artistsSearchController) { - artistsSearchController.abort(); - } - - if (query === '') { - updateArtistsSearchStatus('default'); - return; - } - - // Set up new debounced search - artistsSearchTimeout = setTimeout(() => { - performArtistsSearch(query); - }, 1000); // 1 second debounce -} - -/** - * Handle header search input (already in results state) - */ -function handleArtistsHeaderSearchInput(event) { - const query = event.target.value.trim(); - - // Update main search input to match - const mainInput = document.getElementById('artists-search-input'); - if (mainInput) { - mainInput.value = query; - } - - // Trigger search with same debouncing logic - handleArtistsSearchInput(event); -} - -/** - * Handle Enter key press in search inputs - */ -function handleArtistsSearchKeypress(event) { - if (event.key === 'Enter') { - event.preventDefault(); - const query = event.target.value.trim(); - - if (query && query !== artistsPageState.searchQuery) { - // Clear timeout and search immediately - if (artistsSearchTimeout) { - clearTimeout(artistsSearchTimeout); - } - performArtistsSearch(query); - } - } -} - -/** - * Perform artist search with API call - */ -async function performArtistsSearch(query) { - console.log(`🔍 Searching for artists: "${query}"`); - - // Check cache first - if (artistsPageState.cache.searches[query]) { - console.log('📦 Using cached search results'); - displayArtistsResults(query, artistsPageState.cache.searches[query]); - return; - } - - // Update status - updateArtistsSearchStatus('searching'); - - // Show loading cards immediately if we're in results view - if (artistsPageState.currentView === 'results') { - showSearchLoadingCards(); - } - - try { - // Set up abort controller - artistsSearchController = new AbortController(); - - const response = await fetch('/api/match/search', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - query: query, - context: 'artist' - }), - signal: artistsSearchController.signal - }); - - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); - } - - const data = await response.json(); - console.log(`✅ Found ${data.results?.length || 0} artists`); - - // Transform the results to flatten the nested artist data - const transformedResults = (data.results || []).map(result => { - // Extract artist data from the nested structure - const artist = result.artist || result; - return { - id: artist.id, - name: artist.name, - image_url: artist.image_url, - genres: artist.genres, - popularity: artist.popularity, - confidence: result.confidence || 0 - }; - }); - - console.log('🔧 Transformed results:', transformedResults); - - // Cache the transformed results - artistsPageState.cache.searches[query] = transformedResults; - - // Display results - displayArtistsResults(query, transformedResults); - - } catch (error) { - if (error.name !== 'AbortError') { - console.error('❌ Artist search failed:', error); - - // Provide specific error messages based on the error type - let errorMessage = 'Search failed. Please try again.'; - if (error.message.includes('401') || error.message.includes('authentication')) { - errorMessage = 'Spotify not authenticated. Please check your API settings.'; - } else if (error.message.includes('network') || error.message.includes('fetch')) { - errorMessage = 'Network error. Please check your connection.'; - } else if (error.message.includes('timeout')) { - errorMessage = 'Search timed out. Please try again.'; - } - - updateArtistsSearchStatus('error', errorMessage); - } - } finally { - artistsSearchController = null; - } -} - -/** - * Display artist search results - */ -function displayArtistsResults(query, results) { - console.log(`📊 Displaying ${results.length} artist results`); - - // Update state - artistsPageState.searchQuery = query; - artistsPageState.searchResults = results; - artistsPageState.currentView = 'results'; - - // Update header search input if different - const headerInput = document.getElementById('artists-header-search-input'); - if (headerInput && headerInput.value !== query) { - headerInput.value = query; - } - - // Show results state - showArtistsResultsState(); - - // Populate results - const container = document.getElementById('artists-cards-container'); - if (!container) return; - - if (results.length === 0) { - container.innerHTML = ` -
-
🔍
-
No artists found
-
Try a different search term
-
- `; - return; - } - - // Create artist cards - container.innerHTML = results.map(result => createArtistCardHTML(result)).join(''); - observeLazyBackgrounds(container); - - // Add event listeners to cards - container.querySelectorAll('.artist-card').forEach((card, index) => { - card.addEventListener('click', () => selectArtistForDetail(results[index])); - - // Extract colors from artist image for dynamic glow - const artist = results[index]; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - }); - - // Update watchlist status for all cards - updateArtistCardWatchlistStatus(); - - // Lazy load missing artist images - console.log('🖼️ Starting lazy load for artist images on Artists page...'); - if (typeof lazyLoadArtistImages === 'function') { - lazyLoadArtistImages(container); - } else if (typeof window.lazyLoadArtistImages === 'function') { - window.lazyLoadArtistImages(container); - } else { - console.error('❌ lazyLoadArtistImages function not found!'); - } - - // Add mouse wheel horizontal scrolling - container.addEventListener('wheel', (event) => { - if (event.deltaY !== 0) { - event.preventDefault(); - container.scrollLeft += event.deltaY; - } - }); -} - -/** - * Lazy load artist images for cards that don't have images yet. - * Fetches images asynchronously so search results appear immediately. - */ -async function lazyLoadArtistImages(container) { - if (!container) { - console.error('❌ lazyLoadArtistImages: container is null'); - return; - } - - // Find all cards that need images - const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); - - if (cardsNeedingImages.length === 0) { - console.log('✅ All artist cards have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${cardsNeedingImages.length} artist cards`); - - // Load images in parallel (but with a small batch to avoid overwhelming the server) - const batchSize = 5; - const cards = Array.from(cardsNeedingImages); - - for (let i = 0; i < cards.length; i += batchSize) { - const batch = cards.slice(i, i + batchSize); - - await Promise.all(batch.map(async (card) => { - const artistId = card.dataset.artistId; - if (!artistId) { - console.warn('⚠️ Card missing artistId:', card); - return; - } - - try { - console.log(`🔄 Fetching image for artist ${artistId}...`); - const response = await fetch(`/api/artist/${artistId}/image`); - const data = await response.json(); - - console.log(`📥 Got response for ${artistId}:`, data); - - if (data.success && data.image_url) { - // Update the card's background image - // Handle both card types (suggestion-card and artist-card) - if (card.classList.contains('suggestion-card')) { - card.style.backgroundImage = `url(${data.image_url})`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } else if (card.classList.contains('artist-card')) { - const bgElement = card.querySelector('.artist-card-background'); - if (bgElement) { - // Clear the gradient first, then set the image - bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; - } - } - - card.dataset.needsImage = 'false'; - console.log(`✅ Loaded image for artist ${artistId}`); - } - } catch (error) { - console.error(`❌ Failed to load image for artist ${artistId}:`, error); - } - })); - } - - console.log('✅ Finished lazy loading artist images'); -} - -// Make function globally accessible -window.lazyLoadArtistImages = lazyLoadArtistImages; - -/** - * Create HTML for an artist card - */ -function createArtistCardHTML(artist) { - const imageUrl = artist.image_url || ''; - const genres = artist.genres && artist.genres.length > 0 ? - artist.genres.slice(0, 3).join(', ') : 'Various genres'; - const popularity = artist.popularity || 0; - - // Use data-bg-src for lazy background loading via IntersectionObserver - const backgroundAttr = imageUrl ? - `data-bg-src="${imageUrl}"` : - `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);"`; - - // Format popularity as a percentage for better UX - const popularityText = popularity > 0 ? `${popularity}% Popular` : 'Popularity Unknown'; - - // Track if image needs to be lazy loaded - const needsImage = imageUrl ? 'false' : 'true'; - - // Check for MusicBrainz ID - let mbIconHTML = ''; - if (artist.musicbrainz_id) { - mbIconHTML = ` -
- -
- `; - } - - return ` -
- ${mbIconHTML} -
-
-
-
${escapeHtml(artist.name)}
-
${escapeHtml(genres)}
-
- 🔥 - ${popularityText} -
-
-
- - -
-
-
-
- `; -} - -/** - * Select an artist and show their discography - */ -async function selectArtistForDetail(artist, options = {}) { - console.log(`🎤 Selected artist: ${artist.name}`); - - // Cancel any ongoing completion check from previous artist - if (artistCompletionController) { - console.log('⏹️ Canceling previous artist completion check'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream from previous artist - if (similarArtistsController) { - console.log('⏹️ Canceling previous similar artists stream'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - // Update state - artistsPageState.selectedArtist = artist; - artistsPageState.currentView = 'detail'; - artistsPageState.sourceOverride = options.source || null; - artistsPageState.pluginOverride = options.plugin || null; - - // Show detail state - showArtistDetailState(); - - // Update artist info in header - updateArtistDetailHeader(artist); - - // Load discography (pass artist name for cross-source fallback) - await loadArtistDiscography(artist.id, artist.name, options.source, options.plugin); -} - -/** - * Load artist's discography from Spotify or iTunes - * @param {string} artistId - Artist ID (Spotify or iTunes format) - * @param {string} [artistName] - Optional artist name for fallback searches - */ -async function loadArtistDiscography(artistId, artistName = null, sourceOverride = null, pluginOverride = null) { - console.log(`💿 Loading discography for artist: ${artistId} (name: ${artistName}, source: ${sourceOverride || 'auto'})`); - - // Use source-prefixed cache key to avoid ID collisions between sources - const cacheKey = sourceOverride ? `${sourceOverride}:${artistId}` : artistId; - - // Check cache first - if (artistsPageState.cache.discography[cacheKey]) { - console.log('📦 Using cached discography'); - const cachedDiscography = artistsPageState.cache.discography[cacheKey]; - displayArtistDiscography(cachedDiscography); - - // Load similar artists in parallel (don't wait) — always uses primary source - loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { - console.error('❌ Error loading similar artists:', err); - }); - - // Still check completion status for cached data - await checkDiscographyCompletion(artistId, cachedDiscography); - return; - } - - try { - // Show loading states - showDiscographyLoading(); - - // Build URL with optional artist name and source override for fallback - let url = `/api/artist/${artistId}/discography`; - const params = new URLSearchParams(); - if (artistName) params.set('artist_name', artistName); - if (sourceOverride) params.set('source', sourceOverride); - if (pluginOverride) params.set('plugin', pluginOverride); - if (params.toString()) url += `?${params.toString()}`; - - // Call the real API endpoint - const response = await fetch(url); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - throw new Error(`Failed to load discography: ${response.status}`); - } - - const data = await response.json(); - - if (data.error) { - throw new Error(data.error); - } - - const discography = { - albums: data.albums || [], - singles: data.singles || [], - source: data.source || sourceOverride || null, - }; - - // Update selected artist with full details from backend (includes MusicBrainz ID) - if (data.artist) { - console.log('✨ Updating artist details with fresh data from backend'); - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - ...data.artist - }; - } - - // Merge artist_info enrichment from discography response - if (data.artist_info) { - artistsPageState.selectedArtist = { - ...artistsPageState.selectedArtist, - artist_info: data.artist_info, - }; - } - - // Refresh header with all available data - updateArtistDetailHeader(artistsPageState.selectedArtist); - - console.log(`✅ Loaded ${discography.albums.length} albums and ${discography.singles.length} singles`); - - // Cache the results (use source-prefixed key if source override active) - artistsPageState.cache.discography[cacheKey] = discography; - artistsPageState.artistDiscography = discography; - - // Display results - displayArtistDiscography(discography); - - // Load similar artists and check completion in parallel (don't wait) - loadSimilarArtists(artistsPageState.selectedArtist?.name).catch(err => { - console.error('❌ Error loading similar artists:', err); - }); - - // Check completion status for all albums and singles - await checkDiscographyCompletion(artistId, discography); - - } catch (error) { - console.error('❌ Failed to load discography:', error); - showDiscographyError(error.message); - } -} - -/** - * Display artist's discography in tabs - */ -function displayArtistDiscography(discography) { - console.log(`📀 Displaying discography: ${discography.albums?.length || 0} albums, ${discography.singles?.length || 0} singles`); - - // Show Download Discography button(s) if there are any releases - const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); - const _discogWrap = document.getElementById('discog-download-wrap'); - if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; - const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); - if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; - - // Populate albums - const albumsContainer = document.getElementById('album-cards-container'); - if (albumsContainer) { - if (discography.albums?.length > 0) { - albumsContainer.innerHTML = discography.albums.map(album => createAlbumCardHTML(album)).join(''); - observeLazyBackgrounds(albumsContainer); - - // Add dynamic glow effects and click handlers to album cards - albumsContainer.querySelectorAll('.album-card').forEach((card, index) => { - const album = discography.albums[index]; - if (album.image_url) { - extractImageColors(album.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - - // Add click handler for download missing tracks modal - card.addEventListener('click', () => handleArtistAlbumClick(album, 'albums')); - card.style.cursor = 'pointer'; - }); - } else { - albumsContainer.innerHTML = ` -
-
💿
-
No albums found
-
- `; - } - } - - // Populate singles - const singlesContainer = document.getElementById('singles-cards-container'); - if (singlesContainer) { - if (discography.singles?.length > 0) { - singlesContainer.innerHTML = discography.singles.map(single => createAlbumCardHTML(single)).join(''); - observeLazyBackgrounds(singlesContainer); - - // Add dynamic glow effects and click handlers to singles cards - singlesContainer.querySelectorAll('.album-card').forEach((card, index) => { - const single = discography.singles[index]; - if (single.image_url) { - extractImageColors(single.image_url, (colors) => { - applyDynamicGlow(card, colors); - }); - } - - // Add click handler for download missing tracks modal - card.addEventListener('click', () => handleArtistAlbumClick(single, 'singles')); - card.style.cursor = 'pointer'; - }); - } else { - singlesContainer.innerHTML = ` -
-
🎵
-
No singles or EPs found
-
- `; - } - } - - // Auto-switch to Singles tab if no albums but has singles - if ((!discography.albums || discography.albums.length === 0) && - discography.singles && discography.singles.length > 0) { - console.log('📀 No albums found, auto-switching to Singles & EPs tab'); - - // Switch to singles tab - const albumsTab = document.getElementById('albums-tab'); - const singlesTab = document.getElementById('singles-tab'); - const albumsContent = document.getElementById('albums-content'); - const singlesContent = document.getElementById('singles-content'); - - if (albumsTab && singlesTab && albumsContent && singlesContent) { - // Remove active from albums - albumsTab.classList.remove('active'); - albumsContent.classList.remove('active'); - - // Add active to singles - singlesTab.classList.add('active'); - singlesContent.classList.add('active'); - } - } -} - -/** - * Load similar artists from MusicMap - */ -async function loadSimilarArtists(artistName) { - if (!artistName) { - console.warn('⚠️ No artist name provided for similar artists'); - return; - } - - console.log(`🔍 Loading similar artists for: ${artistName}`); - - // Get DOM elements - const section = document.getElementById('similar-artists-section'); - const loadingEl = document.getElementById('similar-artists-loading'); - const errorEl = document.getElementById('similar-artists-error'); - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!section || !loadingEl || !errorEl || !container) { - console.warn('⚠️ Similar artists section elements not found'); - return; - } - - // Show loading state - loadingEl.classList.remove('hidden'); - errorEl.classList.add('hidden'); - container.innerHTML = ''; - section.style.display = 'block'; - - try { - // Create new abort controller for this similar artists stream - similarArtistsController = new AbortController(); - - // Use streaming endpoint for real-time bubble creation - const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; - console.log(`📡 Streaming from: ${url}`); - - const response = await fetch(url, { - signal: similarArtistsController.signal - }); - - if (!response.ok) { - throw new Error(`Failed to fetch similar artists: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let artistCount = 0; - - // Read the stream - while (true) { - const { done, value } = await reader.read(); - - if (done) { - console.log('✅ Stream complete'); - break; - } - - // Decode the chunk and add to buffer - buffer += decoder.decode(value, { stream: true }); - - // Process complete messages (separated by \n\n) - const messages = buffer.split('\n\n'); - buffer = messages.pop() || ''; // Keep incomplete message in buffer - - for (const message of messages) { - if (!message.trim() || !message.startsWith('data: ')) continue; - - try { - const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix - - if (jsonData.error) { - throw new Error(jsonData.error); - } - - if (jsonData.artist) { - // Hide loading on first artist - if (artistCount === 0) { - loadingEl.classList.add('hidden'); - } - - // Create and append bubble immediately - const bubble = createSimilarArtistBubble(jsonData.artist); - container.appendChild(bubble); - artistCount++; - - console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); - } - - if (jsonData.complete) { - console.log(`🎉 Streaming complete: ${jsonData.total} artists`); - - if (artistCount === 0) { - loadingEl.classList.add('hidden'); - container.innerHTML = ` -
-
🎵
-
No similar artists found
-
- `; - } else { - // Lazy load images for similar artists that don't have them - lazyLoadSimilarArtistImages(container); - } - } - } catch (parseError) { - console.error('❌ Error parsing stream message:', parseError); - } - } - } - - // Clear the controller when done - similarArtistsController = null; - - } catch (error) { - // Don't show error if it was aborted (user navigated away) - if (error.name === 'AbortError') { - console.log('⏹️ Similar artists stream aborted (user navigated to new artist)'); - loadingEl.classList.add('hidden'); - return; - } - - console.error('❌ Error loading similar artists:', error); - - // Hide loading, show error - loadingEl.classList.add('hidden'); - errorEl.classList.remove('hidden'); - - // Also show error message in container - container.innerHTML = ` -
-
⚠️
-
${error.message}
-
- `; - } finally { - // Always clear the controller - similarArtistsController = null; - } -} - -/** - * Lazy load images for similar artist bubbles that don't have images - */ -async function lazyLoadSimilarArtistImages(container) { - if (!container) return; - - const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); - - if (bubblesNeedingImages.length === 0) { - console.log('✅ All similar artist bubbles have images'); - return; - } - - console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); - - // Load images in parallel batches - const batchSize = 5; - const bubbles = Array.from(bubblesNeedingImages); - - for (let i = 0; i < bubbles.length; i += batchSize) { - const batch = bubbles.slice(i, i + batchSize); - - await Promise.all(batch.map(async (bubble) => { - const artistId = bubble.getAttribute('data-artist-id'); - const artistSource = bubble.getAttribute('data-artist-source') || ''; - const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; - if (!artistId) return; - - try { - const params = new URLSearchParams(); - if (artistSource) params.set('source', artistSource); - if (artistPlugin) params.set('plugin', artistPlugin); - - const imageUrl = params.toString() - ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` - : `/api/artist/${encodeURIComponent(artistId)}/image`; - - const response = await fetch(imageUrl); - const data = await response.json(); - - if (data.success && data.image_url) { - const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); - if (imageContainer) { - const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; - imageContainer.innerHTML = `${artistName}`; - bubble.setAttribute('data-needs-image', 'false'); - console.log(`✅ Loaded image for similar artist ${artistId}`); - } - } - } catch (error) { - console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error); - } - })); - } - - console.log('✅ Finished lazy loading similar artist images'); -} - -/** - * Display similar artist bubble cards progressively (one at a time with delay) - */ -function displaySimilarArtistsProgressively(artists) { - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!container) { - console.warn('⚠️ Similar artists container not found'); - return; - } - - // Clear container - container.innerHTML = ''; - - // Add each bubble with a delay to simulate progressive loading - artists.forEach((artist, index) => { - setTimeout(() => { - const bubble = createSimilarArtistBubble(artist); - container.appendChild(bubble); - }, index * 100); // 100ms delay between each bubble - }); - - console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); -} - -/** - * Display similar artist bubble cards (all at once - legacy) - */ -function displaySimilarArtists(artists) { - const container = document.getElementById('similar-artists-bubbles-container'); - - if (!container) { - console.warn('⚠️ Similar artists container not found'); - return; - } - - // Clear container - container.innerHTML = ''; - - // Create bubble cards with staggered animation - artists.forEach((artist, index) => { - const bubble = createSimilarArtistBubble(artist); - - // Add staggered animation delay (50ms per bubble) - bubble.style.animationDelay = `${index * 0.05}s`; - - container.appendChild(bubble); - }); - - console.log(`✅ Displayed ${artists.length} similar artist bubbles`); -} - -/** - * Create a similar artist bubble card element - */ -function createSimilarArtistBubble(artist) { - // Create bubble container - const bubble = document.createElement('div'); - bubble.className = 'similar-artist-bubble'; - bubble.setAttribute('data-artist-id', artist.id); - bubble.setAttribute('data-artist-source', artist.source || ''); - if (artist.plugin) { - bubble.setAttribute('data-artist-plugin', artist.plugin); - } - - // Track if image needs lazy loading - const hasImage = artist.image_url && artist.image_url.trim() !== ''; - bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); - - // Create image container - const imageContainer = document.createElement('div'); - imageContainer.className = 'similar-artist-bubble-image'; - - if (hasImage) { - const img = document.createElement('img'); - img.src = artist.image_url; - img.alt = artist.name; - - // Handle image load error - img.onerror = () => { - console.log(`Failed to load image for ${artist.name}`); - imageContainer.innerHTML = `
🎵
`; - bubble.setAttribute('data-needs-image', 'true'); - }; - - imageContainer.appendChild(img); - } else { - // No image - show fallback (will be lazy loaded) - imageContainer.innerHTML = `
🎵
`; - } - - // Create name element - const name = document.createElement('div'); - name.className = 'similar-artist-bubble-name'; - name.textContent = artist.name; - name.title = artist.name; // Tooltip for full name - - // Optional: Create genres element (hidden by default in CSS) - const genres = document.createElement('div'); - genres.className = 'similar-artist-bubble-genres'; - if (artist.genres && artist.genres.length > 0) { - genres.textContent = artist.genres.slice(0, 2).join(', '); - } - - // Assemble bubble - bubble.appendChild(imageContainer); - bubble.appendChild(name); - if (artist.genres && artist.genres.length > 0) { - bubble.appendChild(genres); - } - - // Add click handler to navigate to artist detail page - bubble.addEventListener('click', () => { - console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); - // Navigate to this artist's detail page (same as clicking from search results) - selectArtistForDetail( - artist, - artist.source ? { source: artist.source, plugin: artist.plugin } : {} - ); - }); - - return bubble; -} - -/** - * Restore cached completion data without re-scanning the database - */ -function restoreCachedCompletionData(artistId) { - console.log(`📦 Restoring cached completion data for artist: ${artistId}`); - - const cachedData = artistsPageState.cache.completionData[artistId]; - if (!cachedData) { - console.log('⚠️ No cached completion data found, skipping restoration'); - return; - } - - // Restore album completion overlays - if (cachedData.albums) { - cachedData.albums.forEach(albumCompletion => { - updateAlbumCompletionOverlay(albumCompletion, 'albums'); - }); - console.log(`✅ Restored ${cachedData.albums.length} album completion overlays`); - } - - // Restore singles completion overlays - if (cachedData.singles) { - cachedData.singles.forEach(singleCompletion => { - updateAlbumCompletionOverlay(singleCompletion, 'singles'); - }); - console.log(`✅ Restored ${cachedData.singles.length} single completion overlays`); - } -} - -/** - * Check completion status for entire discography with streaming updates - */ -async function checkDiscographyCompletion(artistId, discography) { - console.log(`🔍 Starting streaming completion check for artist: ${artistId}`); - - try { - // Create new abort controller for this completion check - artistCompletionController = new AbortController(); - - // Use fetch with streaming response - const response = await fetch(`/api/artist/${artistId}/completion-stream`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - discography: discography, - artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist', - source: discography?.source || artistsPageState.sourceOverride || null, - }), - signal: artistCompletionController.signal - }); - - if (!response.ok) { - throw new Error(`Failed to start completion check: ${response.status}`); - } - - // Handle streaming response - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = decoder.decode(value); - const lines = chunk.split('\n'); - - for (const line of lines) { - if (line.startsWith('data: ')) { - try { - const data = JSON.parse(line.slice(6)); - handleStreamingCompletionUpdate(data); - } catch (e) { - console.warn('Failed to parse streaming data:', line); - } - } - } - } - - // Clear the controller when done - artistCompletionController = null; - - } catch (error) { - // Don't show error if it was aborted (user navigated away) - if (error.name === 'AbortError') { - console.log('⏹️ Completion check aborted (user navigated to new artist)'); - return; - } - - console.error('❌ Failed to check completion status:', error); - showCompletionError(); - } finally { - // Always clear the controller - artistCompletionController = null; - } -} - -/** - * Handle individual streaming completion updates - */ -function handleStreamingCompletionUpdate(data) { - console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name); - - switch (data.type) { - case 'start': - console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`); - // Initialize cache for this artist if not exists - const artistId = artistsPageState.selectedArtist?.id; - if (artistId && !artistsPageState.cache.completionData[artistId]) { - artistsPageState.cache.completionData[artistId] = { - albums: [], - singles: [] - }; - } - break; - - case 'album_completion': - updateAlbumCompletionOverlay(data, 'albums'); - // Cache the completion data - cacheCompletionData(data, 'albums'); - console.log(`📀 Updated album: ${data.name} (${data.status})`); - break; - - case 'single_completion': - updateAlbumCompletionOverlay(data, 'singles'); - // Cache the completion data - cacheCompletionData(data, 'singles'); - console.log(`🎵 Updated single: ${data.name} (${data.status})`); - break; - - case 'error': - console.error('❌ Error processing item:', data.name, data.error); - // Could show error for specific item - break; - - case 'complete': - console.log(`✅ Completion check finished (${data.processed_count} items processed)`); - break; - - default: - console.log('Unknown streaming update type:', data.type); - } -} - -/** - * Cache completion data for future restoration - */ -function cacheCompletionData(completionData, type) { - const artistId = artistsPageState.selectedArtist?.id; - if (!artistId) return; - - // Ensure cache structure exists - if (!artistsPageState.cache.completionData[artistId]) { - artistsPageState.cache.completionData[artistId] = { - albums: [], - singles: [] - }; - } - - // Add to appropriate cache array - if (type === 'albums') { - artistsPageState.cache.completionData[artistId].albums.push(completionData); - } else if (type === 'singles') { - artistsPageState.cache.completionData[artistId].singles.push(completionData); - } -} - -/** - * Update completion overlay for a specific album/single - */ -function updateAlbumCompletionOverlay(completionData, containerType) { - const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; - const container = document.getElementById(containerId); - - if (!container) { - console.warn(`Container ${containerId} not found`); - return; - } - - // Find the album card by data-album-id - const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`); - - if (!albumCard) { - console.warn(`Album card not found for ID: ${completionData.id}`); - return; - } - - // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch) - const currentType = albumCard.dataset.albumType; - const expectedTracks = completionData.expected_tracks || 0; - if (expectedTracks > 0) { - albumCard.dataset.totalTracks = expectedTracks; - let newType = currentType; - if (currentType === 'album' && expectedTracks <= 3) newType = 'single'; - else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep'; - - if (newType !== currentType) { - albumCard.dataset.albumType = newType; - const typeEl = albumCard.querySelector('.album-card-type'); - if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP'; - - // Move card from albums grid to singles grid - const singlesGrid = document.getElementById('singles-grid'); - const singlesSection = singlesGrid?.closest('.discography-section'); - if (singlesGrid) { - albumCard.remove(); - singlesGrid.appendChild(albumCard); - if (singlesSection) singlesSection.style.display = ''; - } - } - } - - const overlay = albumCard.querySelector('.completion-overlay'); - if (!overlay) { - console.warn(`Completion overlay not found for album: ${completionData.name}`); - return; - } - - // Remove existing status classes - overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'); - - // Add new status class - overlay.classList.add(completionData.status); - - // Update overlay text and content - const statusText = getCompletionStatusText(completionData); - const progressText = completionData.expected_tracks > 0 - ? `${completionData.owned_tracks}/${completionData.expected_tracks}` - : ''; - - overlay.innerHTML = progressText - ? `${statusText}${progressText}` - : `${statusText}`; - - // Add tooltip with more details - overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`; - - // Add brief flash animation to indicate update - overlay.style.animation = 'none'; - overlay.offsetHeight; // Trigger reflow - overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)'; - - console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`); -} - -/** - * Get human-readable status text for completion overlay - */ -function getCompletionStatusText(completionData) { - switch (completionData.status) { - case 'completed': - return 'Complete'; - case 'nearly_complete': - return 'Nearly Complete'; - case 'partial': - return 'Partial'; - case 'missing': - return 'Missing'; - case 'downloading': - return 'Downloading...'; - case 'downloaded': - return 'Downloaded'; - case 'error': - return 'Error'; - default: - return 'Unknown'; - } -} - -/** - * Set album to downloaded status after download finishes - */ -function setAlbumDownloadedStatus(albumId) { - console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`); - - const completionData = { - id: albumId, - status: 'downloaded', - owned_tracks: 0, - expected_tracks: 0, - name: 'Downloaded', - completion_percentage: 100 - }; - - // Find if it's in albums or singles container - let containerType = 'albums'; - let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); - if (!albumCard) { - containerType = 'singles'; - albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); - } - - if (albumCard) { - updateAlbumCompletionOverlay(completionData, containerType); - console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`); - } else { - console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`); - } -} - -/** - * Set album to downloading status - */ -function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { - console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`); - - const completionData = { - id: albumId, - status: 'downloading', - owned_tracks: downloaded, - expected_tracks: total, - name: 'Downloading', - completion_percentage: Math.round((downloaded / total) * 100) || 0 - }; - - // Find if it's in albums or singles container - let containerType = 'albums'; - let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); - if (!albumCard) { - containerType = 'singles'; - albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); - } - - if (albumCard) { - console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`); - updateAlbumCompletionOverlay(completionData, containerType); - } else { - console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`); - // Debug: List all available album cards - const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]'); - console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId)); - } -} - -/** - * Show error state on all completion overlays - */ -function showCompletionError() { - const allOverlays = document.querySelectorAll('.completion-overlay.checking'); - allOverlays.forEach(overlay => { - overlay.classList.remove('checking'); - overlay.classList.add('error'); - overlay.innerHTML = 'Error'; - overlay.title = 'Failed to check completion status'; - }); -} - -/** - * Create HTML for an album/single card - */ -function createAlbumCardHTML(album) { - const imageUrl = album.image_url || ''; - const year = album.release_date ? new Date(album.release_date).getFullYear() : ''; - const type = album.album_type === 'album' ? 'Album' : - album.album_type === 'single' ? 'Single' : 'EP'; - - // Use data-bg-src for lazy background loading via IntersectionObserver - const backgroundAttr = imageUrl ? - `data-bg-src="${imageUrl}"` : - `style="background: linear-gradient(135deg, rgba(29, 185, 84, 0.2) 0%, rgba(24, 156, 71, 0.1) 100%);"`; - - return ` -
-
-
- Checking... -
-
-
${escapeHtml(album.name)}
-
${year || 'Unknown'}
-
${type}
-
-
- `; -} - -/** - * Initialize artist detail tabs - */ -function initializeArtistTabs() { - const tabButtons = document.querySelectorAll('.artist-tab'); - const tabContents = document.querySelectorAll('.tab-content'); - - tabButtons.forEach(button => { - button.addEventListener('click', () => { - const tabName = button.getAttribute('data-tab'); - - // Update button states - tabButtons.forEach(btn => btn.classList.remove('active')); - button.classList.add('active'); - - // Update content states - tabContents.forEach(content => { - content.classList.remove('active'); - if (content.id === `${tabName}-content`) { - content.classList.add('active'); - } - }); - - console.log(`🔄 Switched to ${tabName} tab`); - }); - }); -} - -/** - * State management functions - */ -function showArtistsSearchState() { - console.log('🔄 Showing search state'); - - // Cancel any ongoing completion check when navigating back to search - if (artistCompletionController) { - console.log('⏹️ Canceling completion check (navigating back to search)'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream when navigating back to search - if (similarArtistsController) { - console.log('⏹️ Canceling similar artists stream (navigating back to search)'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.remove('hidden', 'fade-out'); - } - if (resultsState) { - resultsState.classList.add('hidden'); - resultsState.classList.remove('show'); - } - if (detailState) { - detailState.classList.add('hidden'); - detailState.classList.remove('show'); - } - - artistsPageState.currentView = 'search'; - updateArtistsSearchStatus('default'); - - // Show artist downloads section if there are active downloads - showArtistDownloadsSection(); -} - -function showArtistsResultsState() { - console.log('🔄 Showing results state'); - - // Cancel any ongoing completion check when navigating back - if (artistCompletionController) { - console.log('⏹️ Canceling completion check (navigating back to results)'); - artistCompletionController.abort(); - artistCompletionController = null; - } - - // Cancel any ongoing similar artists stream when navigating back - if (similarArtistsController) { - console.log('⏹️ Canceling similar artists stream (navigating back to results)'); - similarArtistsController.abort(); - similarArtistsController = null; - } - - // Clear artist-specific data when navigating back to results - // This ensures that selecting the same artist again will trigger a fresh scan - if (artistsPageState.selectedArtist) { - const artistId = artistsPageState.selectedArtist.id; - console.log(`🗑️ Clearing cached data for artist: ${artistsPageState.selectedArtist.name}`); - - // Clear artist-specific cache data - delete artistsPageState.cache.completionData[artistId]; - delete artistsPageState.cache.discography[artistId]; - - // Clear artist state - artistsPageState.selectedArtist = null; - artistsPageState.artistDiscography = { albums: [], singles: [] }; - } - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.add('fade-out'); - setTimeout(() => searchState.classList.add('hidden'), 200); - } - if (resultsState) { - resultsState.classList.remove('hidden'); - setTimeout(() => resultsState.classList.add('show'), 50); - } - if (detailState) { - detailState.classList.add('hidden'); - detailState.classList.remove('show'); - } - - artistsPageState.currentView = 'results'; -} - -function showArtistDetailState() { - console.log('🔄 Showing detail state'); - - const searchState = document.getElementById('artists-search-state'); - const resultsState = document.getElementById('artists-results-state'); - const detailState = document.getElementById('artist-detail-state'); - - if (searchState) { - searchState.classList.add('hidden', 'fade-out'); - } - if (resultsState) { - resultsState.classList.add('hidden'); - resultsState.classList.remove('show'); - } - if (detailState) { - detailState.classList.remove('hidden'); - setTimeout(() => detailState.classList.add('show'), 50); - } - - artistsPageState.currentView = 'detail'; -} - -/** - * Update search status text and styling - */ -function updateArtistsSearchStatus(status, message = null) { - const statusElement = document.getElementById('artists-search-status'); - if (!statusElement) return; - - // Clear all status classes - statusElement.classList.remove('searching', 'error'); - - switch (status) { - case 'default': - statusElement.textContent = 'Start typing to search for artists'; - break; - case 'searching': - statusElement.classList.add('searching'); - statusElement.textContent = 'Searching for artists...'; - break; - case 'error': - statusElement.classList.add('error'); - statusElement.innerHTML = ` -
${message || 'Search failed. Please try again.'}
- - `; - break; - } -} - -/** - * Retry the last search query - */ -function retryLastSearch() { - const searchInput = document.getElementById('artists-search-input'); - const headerSearchInput = document.getElementById('artists-header-search-input'); - - // Get the last search query from either input - const query = searchInput?.value?.trim() || headerSearchInput?.value?.trim() || artistsPageState.searchQuery; - - if (query) { - console.log(`🔄 Retrying search for: "${query}"`); - performArtistsSearch(query); - } -} - -/** - * Update artist detail header with artist info - */ -function updateArtistDetailHeader(artist) { - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - const info = artist.artist_info || {}; - const imageUrl = artist.image_url || info.image_url || ''; - - // Background blur - const heroBg = document.getElementById('artists-hero-bg'); - if (heroBg) { - heroBg.style.backgroundImage = imageUrl ? `url('${imageUrl}')` : 'none'; - } - - // Artist image - const heroImage = document.getElementById('artists-hero-image'); - if (heroImage) { - if (imageUrl) { - heroImage.style.backgroundImage = `url('${imageUrl}')`; - heroImage.innerHTML = ''; - } else { - heroImage.style.backgroundImage = 'none'; - heroImage.innerHTML = '🎤'; - // Lazy load - fetch(`/api/artist/${artist.id}/image`) - .then(r => r.json()) - .then(d => { - if (d.success && d.image_url) { - heroImage.style.backgroundImage = `url('${d.image_url}')`; - heroImage.innerHTML = ''; - if (heroBg) heroBg.style.backgroundImage = `url('${d.image_url}')`; - artist.image_url = d.image_url; - } - }).catch(() => { }); - } - } - - // Name - const heroName = document.getElementById('artists-hero-name'); - if (heroName) heroName.textContent = artist.name || 'Unknown Artist'; - - // Badges (service links — real logos matching library page) - const badgesEl = document.getElementById('artists-hero-badges'); - if (badgesEl) { - const _hb = (logo, fallback, title, url) => { - const inner = logo - ? `${fallback}` - : `${fallback}`; - if (url) return `${inner}`; - return `
${inner}
`; - }; - const badges = []; - if (info.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${info.spotify_artist_id}`)); - if (info.musicbrainz_id || artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${info.musicbrainz_id || artist.musicbrainz_id}`)); - if (info.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${info.deezer_id}`)); - if (info.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${info.itunes_artist_id}`)); - if (info.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', info.lastfm_url)); - if (info.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', info.genius_url)); - if (info.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${info.tidal_id}`)); - if (info.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${info.qobuz_id}`)); - if (info.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${info.discogs_id}`)); - badgesEl.innerHTML = badges.join(''); - } - - // Genres (pill tags — merge with Last.fm tags, deduplicated) - const genresEl = document.getElementById('artists-hero-genres'); - if (genresEl) { - let genres = info.genres || artist.genres || []; - // Merge Last.fm tags - const lfmTags = info.lastfm_tags || []; - if (Array.isArray(lfmTags) && lfmTags.length > 0) { - const existing = new Set(genres.map(g => g.toLowerCase())); - const newTags = lfmTags.filter(t => !existing.has(t.toLowerCase())); - genres = [...genres, ...newTags]; - } - if (genres.length > 0) { - genresEl.innerHTML = genres.slice(0, 8).map(g => - `${_esc(g)}` - ).join(''); - } else { - genresEl.innerHTML = ''; - } - } - - // Bio (Last.fm bio or summary fallback — matching library page pattern) - const bioEl = document.getElementById('artists-hero-bio'); - if (bioEl) { - const bio = info.lastfm_bio || info.bio || ''; - if (bio) { - // Strip HTML tags and "Read more on Last.fm" links - let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); - if (cleanBio) { - bioEl.innerHTML = `${_esc(cleanBio)} - Read more`; - bioEl.style.display = ''; - } else { - bioEl.style.display = 'none'; - } - } else { - bioEl.style.display = 'none'; - } - } - - // Stats (Last.fm listeners + playcount, with followers fallback) - const statsEl = document.getElementById('artists-hero-stats'); - if (statsEl) { - const _fmtNum = (n) => { - if (!n || n <= 0) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - let stats = ''; - if (info.lastfm_listeners) { - stats += `${_fmtNum(info.lastfm_listeners)} listeners`; - } - if (info.lastfm_playcount) { - stats += `${_fmtNum(info.lastfm_playcount)} plays`; - } - if (!stats && info.followers) { - stats += `${_fmtNum(info.followers)} followers`; - } - statsEl.innerHTML = stats; - } - - // Also update old hidden elements for any JS that references them - const oldImage = document.getElementById('search-artist-detail-image'); - if (oldImage && imageUrl) oldImage.style.backgroundImage = `url('${imageUrl}')`; - const oldName = document.getElementById('search-artist-detail-name'); - if (oldName) oldName.textContent = artist.name; - - // Initialize watchlist button - initializeArtistDetailWatchlistButton(artist); -} - -/** - * Initialize watchlist button for artist detail page - */ -async function initializeArtistDetailWatchlistButton(artist) { - const button = document.getElementById('artist-detail-watchlist-btn'); - if (!button) return; - - console.log(`🔧 Initializing watchlist button for artist: ${artist.name} (${artist.id})`); - - // Store artist info on the button for settings gear access - button.dataset.artistId = artist.id; - button.dataset.artistName = artist.name; - - // Reset button state completely - button.disabled = false; - button.classList.remove('watching'); - button.style.background = ''; - button.style.cursor = ''; - - // Remove any existing click handlers to prevent duplicates - button.onclick = null; - - // Set up new click handler - button.onclick = (event) => toggleArtistDetailWatchlist(event, artist.id, artist.name); - - // Check and update current status - await updateArtistDetailWatchlistButton(artist.id, artist.name); -} - -/** - * Toggle watchlist status for artist detail page - */ -async function toggleArtistDetailWatchlist(event, artistId, artistName) { - event.preventDefault(); - - const button = document.getElementById('artist-detail-watchlist-btn'); - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - // Show loading state - const originalText = text.textContent; - text.textContent = 'Loading...'; - button.disabled = true; - - try { - // Check current status - const checkResponse = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const checkData = await checkResponse.json(); - if (!checkData.success) { - throw new Error(checkData.error || 'Failed to check watchlist status'); - } - - const isWatching = checkData.is_watching; - - // Toggle watchlist status - const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; - const payload = isWatching ? - { artist_id: artistId } : - { artist_id: artistId, artist_name: artistName }; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to update watchlist'); - } - - // Update button appearance - if (isWatching) { - // Was watching, now removed - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - console.log(`❌ Removed ${artistName} from watchlist`); - } else { - // Was not watching, now added - icon.textContent = '👁️'; - text.textContent = 'Remove from Watchlist'; - button.classList.add('watching'); - console.log(`✅ Added ${artistName} to watchlist`); - } - - // Show/hide watchlist settings gear - const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); - if (settingsBtn) { - if (!isWatching) { - // Just added to watchlist — show gear - settingsBtn.classList.remove('hidden'); - settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, artistName); - } else { - // Just removed from watchlist — hide gear - settingsBtn.classList.add('hidden'); - settingsBtn.onclick = null; - } - } - - // Update dashboard watchlist count - updateWatchlistButtonCount(); - - // Update any visible artist cards - updateArtistCardWatchlistStatus(); - - } catch (error) { - console.error('Error toggling watchlist:', error); - text.textContent = originalText; - - // Show error feedback - const originalBackground = button.style.background; - button.style.background = 'rgba(255, 59, 48, 0.3)'; - setTimeout(() => { - button.style.background = originalBackground; - }, 2000); - } finally { - button.disabled = false; - } -} - -/** - * Update artist detail watchlist button status - */ -async function updateArtistDetailWatchlistButton(artistId, artistName) { - const button = document.getElementById('artist-detail-watchlist-btn'); - if (!button) { - console.warn('⚠️ Artist detail watchlist button not found'); - return; - } - - // Use passed name or fall back to stored data attribute - const name = artistName || button.dataset.artistName || ''; - - try { - console.log(`🔍 Checking watchlist status for artist: ${artistId}`); - - const response = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const data = await response.json(); - if (data.success) { - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - console.log(`📊 Watchlist status for ${artistId}: ${data.is_watching ? 'WATCHING' : 'NOT WATCHING'}`); - - // Ensure button is enabled - button.disabled = false; - - // Show/hide watchlist settings gear - const settingsBtn = document.getElementById('artist-detail-watchlist-settings-btn'); - if (settingsBtn) { - if (data.is_watching) { - settingsBtn.classList.remove('hidden'); - settingsBtn.onclick = () => openWatchlistArtistConfigModal(artistId, name); - } else { - settingsBtn.classList.add('hidden'); - settingsBtn.onclick = null; - } - } - - if (data.is_watching) { - icon.textContent = '👁️'; - text.textContent = 'Remove from Watchlist'; - button.classList.add('watching'); - } else { - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - } - } else { - console.error('❌ Failed to check watchlist status:', data.error); - } - } catch (error) { - console.error('❌ Error checking watchlist status:', error); - // Ensure button doesn't get stuck in bad state - button.disabled = false; - } -} - -/** - * Show loading state for discography - */ -function showDiscographyLoading() { - const albumsContainer = document.getElementById('album-cards-container'); - const singlesContainer = document.getElementById('singles-cards-container'); - - const loadingHtml = ` -
-
-
-
Loading...
-
-
-
-
-
-
- `.repeat(4); - - if (albumsContainer) albumsContainer.innerHTML = loadingHtml; - if (singlesContainer) singlesContainer.innerHTML = loadingHtml; -} - -/** - * Show error state for discography - */ -function showDiscographyError(message = 'Failed to load discography') { - const albumsContainer = document.getElementById('album-cards-container'); - const singlesContainer = document.getElementById('singles-cards-container'); - - const errorHtml = ` -
-
⚠️
-
Failed to load discography
-
${escapeHtml(message)}
-
- `; - - if (albumsContainer) albumsContainer.innerHTML = errorHtml; - if (singlesContainer) singlesContainer.innerHTML = errorHtml; -} - -/** - * Show loading cards while searching - */ -function showSearchLoadingCards() { - const container = document.getElementById('artists-cards-container'); - if (!container) return; - - const loadingCardHtml = ` -
-
-
-
-
Loading...
-
Fetching data...
-
- - Loading... -
-
-
- `; - - // Show 6 loading cards - container.innerHTML = loadingCardHtml.repeat(6); -} - -// =============================== -// ARTIST ALBUM DOWNLOAD MISSING TRACKS INTEGRATION -// =============================== - -/** - * Get the completion status of an album from cached data or DOM - * @param {string} albumId - The album ID - * @param {string} albumType - The album type ('albums' or 'singles') - * @returns {Object|null} - Completion status object or null - */ -function getAlbumCompletionStatus(albumId, albumType) { - try { - // First, check cached completion data - const artistId = artistsPageState.selectedArtist?.id; - if (artistId && artistsPageState.cache.completionData[artistId]) { - const cachedData = artistsPageState.cache.completionData[artistId]; - const dataArray = albumType === 'albums' ? cachedData.albums : cachedData.singles; - - if (dataArray) { - const completionData = dataArray.find(item => item.album_id === albumId || item.id === albumId); - if (completionData) { - console.log(`📊 Found cached completion data for album ${albumId}:`, completionData); - return completionData; - } - } - } - - // Fallback: Check DOM completion overlay - const containerId = albumType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; - const container = document.getElementById(containerId); - - if (container) { - const albumCard = container.querySelector(`[data-album-id="${albumId}"]`); - if (albumCard) { - const overlay = albumCard.querySelector('.completion-overlay'); - if (overlay) { - // Extract status from overlay classes - const classList = Array.from(overlay.classList); - const statusClasses = ['completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error']; - const status = statusClasses.find(cls => classList.includes(cls)); - - if (status) { - console.log(`📊 Found DOM completion status for album ${albumId}: ${status}`); - return { status, completion_percentage: status === 'completed' ? 100 : 0 }; - } - } - } - } - - console.warn(`⚠️ No completion status found for album ${albumId}`); - return null; - - } catch (error) { - console.error(`❌ Error getting album completion status for ${albumId}:`, error); - return null; - } -} - -/** - * Handle album/single/EP click to open download missing tracks modal - */ -async function handleArtistAlbumClick(album, albumType) { - console.log(`🎵 Album clicked: ${album.name} (${album.album_type}) from artist: ${artistsPageState.selectedArtist?.name}`); - - if (!artistsPageState.selectedArtist) { - console.error('❌ No selected artist found'); - showToast('Error: No artist selected', 'error'); - return; - } - - showLoadingOverlay('Loading album...'); - - try { - // Check completion status of the album - const completionStatus = getAlbumCompletionStatus(album.id, albumType); - console.log(`📊 Album completion status: ${completionStatus?.status || 'unknown'} (${completionStatus?.completion_percentage || 0}%)`); - - // For Artists page, always use Download Missing Tracks modal to analyze and download - console.log(`🔄 Opening download missing tracks modal for album analysis`); - - // Create virtual playlist ID - const virtualPlaylistId = `artist_album_${artistsPageState.selectedArtist.id}_${album.id}`; - - // Check if modal already exists and show it - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`📱 Reopening existing modal for ${album.name}`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - hideLoadingOverlay(); - return; - } - } - - // Create virtual playlist and open modal - // Note: Don't hide loading overlay here - let the flow continue through to the modal - await createArtistAlbumVirtualPlaylist(album, albumType); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error handling album click:', error); - showToast(`Error opening download modal: ${error.message}`, 'error'); - } -} - -/** - * Create virtual playlist for artist album and open download missing tracks modal - */ -async function createArtistAlbumVirtualPlaylist(album, albumType) { - const artist = artistsPageState.selectedArtist; - const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; - - console.log(`🎵 Creating virtual playlist for: ${artist.name} - ${album.name}`); - - try { - // Loading overlay already shown by handleArtistAlbumClick - - // Fetch album tracks from backend (pass name/artist for Hydrabase support) - const _aat1 = new URLSearchParams({ name: album.name || '', artist: artist.name || '' }); - if (artistsPageState.sourceOverride) { - _aat1.set('source', artistsPageState.sourceOverride); - } - if (artistsPageState.pluginOverride) { - _aat1.set('plugin', artistsPageState.pluginOverride); - } - const response = await fetch(`/api/album/${album.id}/tracks?${_aat1}`); - - if (!response.ok) { - if (response.status === 401) { - throw new Error('Spotify not authenticated. Please check your API settings.'); - } - const errData = await response.json().catch(() => ({})); - throw new Error(errData.error || `Failed to load album tracks: ${response.status}`); - } - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found for this album'); - } - - console.log(`✅ Loaded ${data.tracks.length} tracks for ${data.album.name}`); - - // Use album data from API response (has complete data including images array) - const fullAlbumData = data.album; - - // Format playlist name with artist and album info - const playlistName = `[${artist.name}] ${fullAlbumData.name}`; - - // Open download missing tracks modal with formatted tracks - // Pass false for showLoadingOverlay since we already have one from handleArtistAlbumClick - // Use fullAlbumData from API response instead of album parameter - await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, data.tracks, fullAlbumData, artist, false); - - // Track this download for artist bubble management - registerArtistDownload(artist, album, virtualPlaylistId, albumType); - - } catch (error) { - console.error('❌ Error creating virtual playlist:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - throw error; - } -} - -/** - * Open download missing tracks modal specifically for artist albums - * Similar to openDownloadMissingModalForYouTube but for artist albums - */ -async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { - if (showLoadingOverlayParam) { - showLoadingOverlay('Loading album...'); - } - // Check if a process is already active for this virtual playlist - if (activeDownloadProcesses[virtualPlaylistId]) { - console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - if (showLoadingOverlayParam) { - hideLoadingOverlay(); - } - } - return; - } - - console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`); - - // Create virtual playlist object for compatibility with existing modal logic - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Store the tracks in the cache for the modal to use - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - currentPlaylistTracks = spotifyTracks; - currentModalPlaylistId = virtualPlaylistId; - - let modal = document.createElement('div'); - modal.id = `download-missing-modal-${virtualPlaylistId}`; - modal.className = 'download-missing-modal'; - modal.style.display = 'none'; - document.body.appendChild(modal); - - // Register the new process in our global state tracker using the same structure as other modals - activeDownloadProcesses[virtualPlaylistId] = { - status: 'idle', - modalElement: modal, - poller: null, - batchId: null, - playlist: virtualPlaylist, - tracks: spotifyTracks, - // Additional metadata for artist albums - artist: artist, - album: album, - albumType: album.album_type - }; - - // Generate hero section — 'artist_album' for releases, 'playlist' for charts/compilations - const heroContext = contextType === 'playlist' ? { - type: 'playlist', - playlist: { name: playlistName, owner: 'Beatport' }, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - } : { - type: 'artist_album', - artist: artist, - album: album, - trackCount: spotifyTracks.length, - playlistId: virtualPlaylistId - }; - - // Use the exact same modal HTML structure as the existing modals - modal.innerHTML = ` -
-
- ${generateDownloadModalHeroSection(heroContext)} -
- -
-
-
-
- 🔍 Library Analysis - Ready to start -
-
-
-
-
-
-
- ⏬ Downloads - Waiting for analysis -
-
-
-
-
-
- -
-
-

📋 Track Analysis & Download Status

- ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected -
-
- - - - - - - - - - - - - - - ${spotifyTracks.map((track, index) => ` - - - - - - - - - - - `).join('')} - -
- - #Track NameArtist(s)DurationLibrary StatusDownload StatusActions
- - ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
-
-
-
- - - - -
- `; - - applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); - modal.style.display = 'flex'; - hideLoadingOverlay(); - - console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`); -} - -// =============================== -// ARTIST DOWNLOADS MANAGEMENT SYSTEM -// =============================== - -/** - * Register a new artist download for bubble management - */ -function registerArtistDownload(artist, album, virtualPlaylistId, albumType) { - console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`); - - const artistId = artist.id; - - // Initialize artist bubble if it doesn't exist - if (!artistDownloadBubbles[artistId]) { - artistDownloadBubbles[artistId] = { - artist: artist, - downloads: [], - element: null, - hasCompletedDownloads: false - }; - } - - // Add this download to the artist's downloads - const downloadInfo = { - virtualPlaylistId: virtualPlaylistId, - album: album, - albumType: albumType, - status: 'in_progress', // 'in_progress', 'completed', 'view_results' - startTime: new Date() - }; - - artistDownloadBubbles[artistId].downloads.push(downloadInfo); - - // Show/update the artist downloads section - updateArtistDownloadsSection(); - - // Save snapshot of current state - saveArtistBubbleSnapshot(); - - // Monitor this download for completion - monitorArtistDownload(artistId, virtualPlaylistId); -} - -/** - * Debounced update for artist downloads section to prevent rapid updates - */ -function updateArtistDownloadsSection() { - if (downloadsUpdateTimeout) { - clearTimeout(downloadsUpdateTimeout); - } - downloadsUpdateTimeout = setTimeout(() => { - showArtistDownloadsSection(); - showLibraryDownloadsSection(); - showBeatportDownloadsSection(); - updateDashboardDownloads(); - }, 300); // 300ms debounce -} - -// --- Artist Bubble Snapshot System --- - -let snapshotSaveTimeout = null; // Debounce snapshot saves - -async function saveArtistBubbleSnapshot() { - /** - * Saves current artistDownloadBubbles state to backend for persistence. - * Debounced to prevent excessive backend calls. - */ - - // Clear any existing timeout - if (snapshotSaveTimeout) { - clearTimeout(snapshotSaveTimeout); - } - - // Debounce the actual save - snapshotSaveTimeout = setTimeout(async () => { - try { - const bubbleCount = Object.keys(artistDownloadBubbles).length; - - // Don't save empty state - if (bubbleCount === 0) { - console.log('📸 Skipping snapshot save - no artist bubbles to save'); - return; - } - - console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`); - - // Prepare snapshot data (clean up DOM references) - const cleanBubbles = {}; - for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) { - cleanBubbles[artistId] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads.map(download => ({ - virtualPlaylistId: download.virtualPlaylistId, - album: download.album, - albumType: download.albumType, - status: download.status, - startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime - })), - hasCompletedDownloads: bubbleData.hasCompletedDownloads - }; - } - - const response = await fetch('/api/artist_bubbles/snapshot', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - bubbles: cleanBubbles - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`); - } else { - console.error('❌ Failed to save artist bubble snapshot:', data.error); - } - - } catch (error) { - console.error('❌ Error saving artist bubble snapshot:', error); - } - }, 1000); // 1 second debounce -} - -async function hydrateArtistBubblesFromSnapshot() { - /** - * Hydrates artist download bubbles from backend snapshot with live status. - * Called on page load to restore bubble state. - */ - try { - console.log('🔄 Loading artist bubble snapshot from backend...'); - - const response = await fetch('/api/artist_bubbles/hydrate'); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load artist bubble snapshot:', data.error); - return; - } - - const bubbles = data.bubbles || {}; - const stats = data.stats || {}; - - console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); - - if (Object.keys(bubbles).length === 0) { - console.log('ℹ️ No artist bubbles to hydrate'); - return; - } - - // Clear existing state - artistDownloadBubbles = {}; - - // Restore artistDownloadBubbles with hydrated data - for (const [artistId, bubbleData] of Object.entries(bubbles)) { - artistDownloadBubbles[artistId] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads.map(download => ({ - virtualPlaylistId: download.virtualPlaylistId, - album: download.album, - albumType: download.albumType, - status: download.status, // Live status from backend - startTime: new Date(download.startTime) - })), - element: null, // Will be created when UI updates - hasCompletedDownloads: bubbleData.hasCompletedDownloads - }; - - console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`); - - // Start monitoring for any in-progress downloads - for (const download of bubbleData.downloads) { - if (download.status === 'in_progress') { - console.log(`📡 Starting monitoring for: ${download.album.name}`); - monitorArtistDownload(artistId, download.virtualPlaylistId); - } - } - } - - // Update UI to show hydrated bubbles - updateArtistDownloadsSection(); - - const totalArtists = Object.keys(artistDownloadBubbles).length; - console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`); - - } catch (error) { - console.error('❌ Error hydrating artist bubbles from snapshot:', error); - } -} - -// --- Search Bubble Snapshot System --- - -async function saveSearchBubbleSnapshot() { - /** - * Saves current searchDownloadBubbles state to backend for persistence. - */ - try { - // Rate limit saves to avoid spamming backend - if (saveSearchBubbleSnapshot.lastSaveTime) { - const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime; - if (timeSinceLastSave < 2000) { - console.log('⏱️ Skipping search bubble snapshot save (rate limited)'); - return; - } - } - - const bubbleCount = Object.keys(searchDownloadBubbles).length; - - if (bubbleCount === 0) { - console.log('📸 Skipping snapshot save - no search bubbles to save'); - return; - } - - console.log(`📸 Saving search bubble snapshot: ${bubbleCount} artists`); - - // Convert search bubbles to plain objects for serialization - const bubblesToSave = {}; - for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) { - bubblesToSave[artistName] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - item: d.item, - type: d.type, - status: d.status, - startTime: d.startTime - })) - }; - } - - const response = await fetch('/api/search_bubbles/snapshot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bubbles: bubblesToSave }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`✅ Search bubble snapshot saved: ${bubbleCount} artists`); - saveSearchBubbleSnapshot.lastSaveTime = Date.now(); - } else { - console.error('❌ Failed to save search bubble snapshot:', data.error); - } - - } catch (error) { - console.error('❌ Error saving search bubble snapshot:', error); - } -} - -async function hydrateSearchBubblesFromSnapshot() { - /** - * Hydrates search download bubbles from backend snapshot with live status. - */ - try { - console.log('🔄 Loading search bubble snapshot from backend...'); - - const response = await fetch('/api/search_bubbles/hydrate'); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load search bubble snapshot:', data.error); - return; - } - - const bubbles = data.bubbles || {}; - const stats = data.stats || {}; - - if (Object.keys(bubbles).length === 0) { - console.log('ℹ️ No search bubbles to hydrate'); - return; - } - - // Clear and restore search bubbles - searchDownloadBubbles = {}; - - for (const [artistName, bubbleData] of Object.entries(bubbles)) { - searchDownloadBubbles[artistName] = { - artist: bubbleData.artist, - downloads: bubbleData.downloads || [] - }; - - console.log(`🔄 Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`); - - // Setup monitoring for each download - for (const download of bubbleData.downloads) { - if (download.status === 'in_progress') { - monitorSearchDownload(artistName, download.virtualPlaylistId); - } - } - } - - const totalArtists = Object.keys(searchDownloadBubbles).length; - console.log(`✅ Successfully hydrated ${totalArtists} search download bubbles`); - - // Refresh display - showSearchDownloadBubbles(); - - } catch (error) { - console.error('❌ Error hydrating search bubbles from snapshot:', error); - } -} - -/** - * Register a new search download for bubble management (grouped by artist) - */ -function registerSearchDownload(item, type, virtualPlaylistId, artistName) { - console.log(`📝 [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`); - - // Initialize artist bubble if it doesn't exist - if (!searchDownloadBubbles[artistName]) { - searchDownloadBubbles[artistName] = { - artist: { - name: artistName, - image_url: item.image_url || (item.images && item.images[0]?.url) || null - }, - downloads: [] - }; - } - - // Add this download to the artist's downloads - const downloadInfo = { - virtualPlaylistId: virtualPlaylistId, - item: item, - type: type, // 'album' or 'track' - status: 'in_progress', - startTime: new Date().toISOString() - }; - - searchDownloadBubbles[artistName].downloads.push(downloadInfo); - - console.log(`✅ [REGISTER] Registered search download for ${artistName} - ${item.name}`); - - // Save snapshot - saveSearchBubbleSnapshot(); - - // Setup monitoring - monitorSearchDownload(artistName, virtualPlaylistId); - - // Refresh display - updateSearchDownloadsSection(); -} - -/** - * Debounced update for search downloads section - */ -function updateSearchDownloadsSection() { - if (window.searchUpdateTimeout) { - clearTimeout(window.searchUpdateTimeout); - } - window.searchUpdateTimeout = setTimeout(() => { - showSearchDownloadBubbles(); - updateDashboardDownloads(); - }, 300); -} - -/** - * Monitor a search download for completion status changes - */ -function monitorSearchDownload(artistName, virtualPlaylistId) { - const checkCompletion = setInterval(() => { - const process = activeDownloadProcesses[virtualPlaylistId]; - - if (!process || !searchDownloadBubbles[artistName]) { - clearInterval(checkCompletion); - return; - } - - // Find the download in the artist's downloads - const download = searchDownloadBubbles[artistName].downloads.find( - d => d.virtualPlaylistId === virtualPlaylistId - ); - - if (!download) { - clearInterval(checkCompletion); - return; - } - - // Update status - const newStatus = process.status === 'complete' || process.status === 'view_results' - ? 'view_results' - : 'in_progress'; - - if (download.status !== newStatus) { - console.log(`🔄 [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`); - download.status = newStatus; - - // Save snapshot and refresh - saveSearchBubbleSnapshot(); - updateSearchDownloadsSection(); - } - }, 2000); -} - -/** - * Show or update the search downloads bubble section - */ -function showSearchDownloadBubbles() { - console.log(`🔄 [SHOW] showSearchDownloadBubbles() called`); - - const resultsArea = document.getElementById('enhanced-main-results-area'); - if (!resultsArea) { - console.log(`⏭️ [SHOW] Skipping - no enhanced-main-results-area found`); - return; - } - - // Count active artists (those with downloads) - const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName => - searchDownloadBubbles[artistName].downloads.length > 0 - ); - - if (activeArtists.length === 0) { - // Show placeholder - resultsArea.innerHTML = ` -
-

Search results will appear here when you select an album or track.

-
- `; - return; - } - - // Create bubbles display - const bubblesHTML = activeArtists.map(artistName => - createSearchBubbleCard(searchDownloadBubbles[artistName]) - ).join(''); - - resultsArea.innerHTML = ` -
-
-

Active Downloads

- ${activeArtists.length} -
-
- ${bubblesHTML} -
-
- `; - - console.log(`✅ [SHOW] Displayed ${activeArtists.length} search bubbles`); -} - -/** - * Create HTML for a search bubble card (grouped by artist) - */ -function createSearchBubbleCard(artistBubbleData) { - const { artist, downloads } = artistBubbleData; - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { - totalDownloads: downloads.length, - activeCount, - completedCount, - allCompleted - }); - - const imageUrl = artist.image_url || ''; - const backgroundStyle = imageUrl ? - `background-image: url('${escapeHtml(imageUrl)}');` : - `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(artist.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- -
- ` : ''} -
- `; -} - -/** - * Open modal showing all downloads for an artist - */ -async function openSearchDownloadModal(artistName) { - const artistBubbleData = searchDownloadBubbles[artistName]; - if (!artistBubbleData || searchDownloadModalOpen) return; - - console.log(`🎵 [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`); - - searchDownloadModalOpen = true; - - const modal = document.createElement('div'); - modal.id = 'search-download-management-modal'; - modal.className = 'artist-download-management-modal'; - modal.innerHTML = ` -
-
-
-
-
-
- ${artistBubbleData.artist.image_url - ? `${escapeHtml(artistBubbleData.artist.name)}` - : '
🎵
' - } -
-
-

${escapeHtml(artistBubbleData.artist.name)}

-

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

-
-
- × -
-
- -
-
- ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')} -
-
-
-
- `; - - document.body.appendChild(modal); - modal.style.display = 'flex'; - - // Start monitoring for status changes - // Start monitoring for status changes - monitorSearchDownloadModal(artistName); - - // Lazy load artist image if missing (common for iTunes) - if (!artistBubbleData.artist.image_url) { - console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`); - fetch(`/api/artist/${artistBubbleData.artist.id}/image`) - .then(response => response.json()) - .then(data => { - if (data.success && data.image_url) { - // Update header background - const headerBg = modal.querySelector('.artist-download-modal-hero-bg'); - if (headerBg) { - headerBg.style.backgroundImage = `url('${data.image_url}')`; - } - - // Update avatar - const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar'); - if (avatarContainer) { - avatarContainer.innerHTML = `${artistBubbleData.artist.name}`; - } - - // Update artist object in memory - artistBubbleData.artist.image_url = data.image_url; - } - }) - .catch(err => console.error('❌ Failed to load modal image:', err)); - } -} - -/** - * Create HTML for a download item in the search modal - */ -function createSearchDownloadItem(download, index) { - const { item, type, status, virtualPlaylistId } = download; - const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; - const buttonClass = status === 'view_results' ? 'completed' : 'active'; - const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track'; - - return ` -
-
- ${item.image_url - ? `${escapeHtml(item.name)}` - : `
- ${type === 'album' ? '💿' : '🎵'} -
` - } -
-
-
${escapeHtml(item.name)}
-
${typeLabel}
-
-
- -
-
- `; -} - -/** - * Reopen an individual download modal from the artist modal - */ -async function reopenDownloadModal(virtualPlaylistId) { - const process = activeDownloadProcesses[virtualPlaylistId]; - - // If process exists, show the existing modal - if (process && process.modalElement) { - console.log(`✅ [REOPEN] Showing existing modal for ${virtualPlaylistId}`); - closeSearchDownloadModal(); - setTimeout(() => { - process.modalElement.style.display = 'flex'; - }, 100); - return; - } - - // Process doesn't exist (after page refresh) - recreate it - console.log(`🔄 [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`); - - // Find the download in searchDownloadBubbles - let downloadData = null; - for (const artistName in searchDownloadBubbles) { - const bubble = searchDownloadBubbles[artistName]; - const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (download) { - downloadData = download; - break; - } - } - - if (!downloadData) { - console.warn(`⚠️ No download data found for ${virtualPlaylistId}`); - return; - } - - // Close search modal first - closeSearchDownloadModal(); - - // Recreate the modal based on type - const { item, type } = downloadData; - - if (type === 'album') { - // For albums, we need to fetch the tracks - console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`); - - // Fetch album tracks (pass name/artist for Hydrabase support) - showLoadingOverlay(`Loading ${item.name}...`); - - try { - const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); - const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`); - if (!response.ok) { - throw new Error('Failed to fetch album tracks'); - } - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - throw new Error('No tracks found in album'); - } - - const spotifyTracks = albumData.tracks.map(track => ({ - id: track.id, - name: track.name, - artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], - album: { - name: item.name, - images: item.image_url ? [{ url: item.image_url }] : [] - }, - duration_ms: track.duration_ms || 0 - })); - - hideLoadingOverlay(); - - // Open the modal - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - item.name, - spotifyTracks, - item, - { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, - false // Don't show loading overlay again - ); - - // Sync with backend to check for active batch process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - try { - const processResponse = await fetch('/api/active-processes'); - if (processResponse.ok) { - const processData = await processResponse.json(); - const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); - - if (activeProcess) { - console.log(`📡 [REOPEN] Found active batch for album: ${activeProcess.batch_id}`); - process.status = 'running'; - process.batchId = activeProcess.batch_id; - - // Update UI to show running state - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - } - } - } catch (err) { - console.warn('Could not check for active processes:', err); - } - } - - } catch (error) { - hideLoadingOverlay(); - showToast(`Failed to load album: ${error.message}`, 'error'); - console.error('Error loading album:', error); - } - - } else { - // For tracks, create enriched track and open modal - console.log(`🎵 [REOPEN] Recreating track modal for: ${item.name}`); - - const enrichedTrack = { - id: item.id, - name: item.name, - artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], - album: item.album || { - name: item.album?.name || 'Unknown Album', - images: item.image_url ? [{ url: item.image_url }] : [] - }, - duration_ms: item.duration_ms || 0 - }; - - await openDownloadMissingModalForYouTube( - virtualPlaylistId, - `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, - [enrichedTrack] - ); - - // Sync with backend to check for active batch process - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - try { - const processResponse = await fetch('/api/active-processes'); - if (processResponse.ok) { - const processData = await processResponse.json(); - const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); - - if (activeProcess) { - console.log(`📡 [REOPEN] Found active batch for track: ${activeProcess.batch_id}`); - process.status = 'running'; - process.batchId = activeProcess.batch_id; - - // Update UI to show running state - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - } - } - } catch (err) { - console.warn('Could not check for active processes:', err); - } - } - } -} - -/** - * Monitor search download modal for status changes - */ -function monitorSearchDownloadModal(artistName) { - const updateModal = () => { - if (!searchDownloadModalOpen) return; - - const modal = document.getElementById('search-download-management-modal'); - const itemsContainer = document.getElementById('search-download-items'); - - if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return; - - const downloads = searchDownloadBubbles[artistName].downloads; - - // If no downloads at all, close modal - if (downloads.length === 0) { - closeSearchDownloadModal(); - return; - } - - // Update modal content and sync status with active processes - let statusChanged = false; - itemsContainer.innerHTML = downloads.map((download, index) => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - - // Only update status if process exists (otherwise keep current status) - if (process) { - const newStatus = process.status === 'complete' || process.status === 'view_results' - ? 'view_results' - : 'in_progress'; - - if (download.status !== newStatus) { - console.log(`🔄 [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`); - download.status = newStatus; - statusChanged = true; - } - } - - return createSearchDownloadItem(download, index); - }).join(''); - - // If status changed, refresh bubble display and save - if (statusChanged) { - updateSearchDownloadsSection(); - saveSearchBubbleSnapshot(); - } - - // Continue monitoring - setTimeout(updateModal, 2000); - }; - - setTimeout(updateModal, 1000); -} - -/** - * Close the search download modal - */ -function closeSearchDownloadModal() { - const modal = document.getElementById('search-download-management-modal'); - if (modal) { - modal.style.display = 'none'; - if (modal.parentElement) { - modal.parentElement.removeChild(modal); - } - } - searchDownloadModalOpen = false; -} - -/** - * Bulk complete all downloads for an artist (called when user clicks green checkmark) - */ -function bulkCompleteSearchDownloads(artistName) { - console.log(`🎯 Bulk completing downloads for artist: ${artistName}`); - - const artistBubbleData = searchDownloadBubbles[artistName]; - if (!artistBubbleData) { - console.warn(`❌ No artist bubble data found for ${artistName}`); - return; - } - - // Find all completed downloads - const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); - console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, - completedDownloads.map(d => d.item.name)); - - if (completedDownloads.length === 0) { - console.warn(`⚠️ No completed downloads found for bulk close`); - showToast('No completed downloads to close', 'info'); - return; - } - - // Close all completed modals - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - console.log(`🗑️ Closing modal for: ${download.item.name}`); - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - // No modal open — clean up the bubble entry directly - console.log(`🧹 Direct cleanup (no modal) for: ${download.item.name}`); - cleanupSearchDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); -} - -/** - * Cleanup search download when modal is closed - */ -function cleanupSearchDownload(virtualPlaylistId) { - console.log(`🔍 [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`); - - // Find which artist this download belongs to - for (const artistName in searchDownloadBubbles) { - const downloads = searchDownloadBubbles[artistName].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - if (downloadIndex !== -1) { - console.log(`🧹 [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`); - - // Remove this download - downloads.splice(downloadIndex, 1); - console.log(`🗑️ [CLEANUP] Removed download from ${artistName}'s bubble`); - - // If no more downloads for this artist, remove the bubble - if (downloads.length === 0) { - delete searchDownloadBubbles[artistName]; - console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistName}`); - } - - // Save snapshot and refresh - saveSearchBubbleSnapshot(); - updateSearchDownloadsSection(); - - return; - } - } - - console.log(`⚠️ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`); -} - -/** - * Show or update the artist downloads section in search state - */ -function showArtistDownloadsSection() { - console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`); - console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`); - - // Only show in search state - if (artistsPageState.currentView !== 'search') { - console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`); - return; - } - - const artistsSearchState = document.getElementById('artists-search-state'); - if (!artistsSearchState) { - console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`); - return; - } - - let downloadsSection = document.getElementById('artist-downloads-section'); - - // Create section if it doesn't exist - if (!downloadsSection) { - downloadsSection = document.createElement('div'); - downloadsSection.id = 'artist-downloads-section'; - downloadsSection.className = 'artist-downloads-section'; - - // Insert after the search container - const searchContainer = artistsSearchState.querySelector('.artists-search-container'); - if (searchContainer) { - searchContainer.insertAdjacentElement('afterend', downloadsSection); - } - } - - // Count active artists (those with downloads) - const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => - artistDownloadBubbles[artistId].downloads.length > 0 - ); - - if (activeArtists.length === 0) { - downloadsSection.style.display = 'none'; - return; - } - - // Show and populate the section - downloadsSection.style.display = 'block'; - downloadsSection.innerHTML = ` -
-

Current Downloads

-

Active download processes

-
-
- ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} -
- `; - - // Add event listeners to bubble cards - activeArtists.forEach(artistId => { - const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); - if (bubbleCard) { - bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); - - // Add dynamic glow effect - const artist = artistDownloadBubbles[artistId].artist; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(bubbleCard, colors); - }); - } - } - }); -} - -/** - * Show download bubbles on the Library page (mirrors showArtistDownloadsSection) - */ -function showLibraryDownloadsSection() { - const libraryContent = document.querySelector('.library-content'); - if (!libraryContent) return; - - let downloadsSection = document.getElementById('library-downloads-section'); - - // Create section if it doesn't exist - if (!downloadsSection) { - downloadsSection = document.createElement('div'); - downloadsSection.id = 'library-downloads-section'; - downloadsSection.className = 'artist-downloads-section'; - - // Insert before the artist grid - const artistGrid = document.getElementById('library-artists-grid'); - if (artistGrid) { - libraryContent.insertBefore(downloadsSection, artistGrid); - } - } - - // Count active artists (reuses artistDownloadBubbles state) - const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => - artistDownloadBubbles[artistId].downloads.length > 0 - ); - - if (activeArtists.length === 0) { - downloadsSection.style.display = 'none'; - return; - } - - downloadsSection.style.display = 'block'; - downloadsSection.innerHTML = ` -
-

Current Downloads

-

Active download processes

-
-
- ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} -
- `; - - // Add click handlers + glow effects - activeArtists.forEach(artistId => { - const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); - if (bubbleCard) { - bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); - const artist = artistDownloadBubbles[artistId].artist; - if (artist.image_url) { - extractImageColors(artist.image_url, (colors) => { - applyDynamicGlow(bubbleCard, colors); - }); - } - } - }); -} - -/** - * Create HTML for an artist bubble card - */ -function createArtistBubbleCard(artistBubbleData) { - const { artist, downloads } = artistBubbleData; - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - // Enhanced debug logging for bubble card creation and green checkmark detection - console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { - totalDownloads: downloads.length, - activeCount, - completedCount, - allCompleted, - downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`) - }); - - // CRITICAL: Green checkmark detection logging - if (allCompleted) { - console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`); - console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`); - } else if (activeCount === 0 && completedCount === 0) { - console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`); - } else { - console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`); - } - - const imageUrl = artist.image_url || ''; - const backgroundStyle = imageUrl ? - `background-image: url('${imageUrl}');` : - `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(artist.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- -
- ` : ''} -
- `; -} - -/** - * Monitor an artist download for completion status changes - */ -function monitorArtistDownload(artistId, virtualPlaylistId) { - // Check if the download process exists and monitor its status - const checkStatus = () => { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (!process || !artistDownloadBubbles[artistId]) { - return; // Process or artist bubble no longer exists - } - - // Find this download in the artist's downloads - const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (!download) return; - - // Update download status based on process status - if (process.status === 'complete' && download.status === 'in_progress') { - download.status = 'view_results'; - console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`); - console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`)); - - // Update the downloads section - updateArtistDownloadsSection(); - - // Save snapshot of updated state - saveArtistBubbleSnapshot(); - - // Check if all downloads for this artist are now completed - const artistDownloads = artistDownloadBubbles[artistId].downloads; - const allCompleted = artistDownloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`); - console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`); - // Force immediate bubble refresh to show green checkmark - setTimeout(updateArtistDownloadsSection, 100); - } - } - - // Continue monitoring if still active - if (process.status !== 'complete') { - setTimeout(checkStatus, 2000); // Check every 2 seconds - } - }; - - // Start monitoring after a brief delay - setTimeout(checkStatus, 1000); -} - -/** - * Open the artist download management modal - */ -function openArtistDownloadModal(artistId) { - const artistBubbleData = artistDownloadBubbles[artistId]; - if (!artistBubbleData || artistDownloadModalOpen) return; - - console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`); - console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`)); - artistDownloadModalOpen = true; - - const modal = document.createElement('div'); - modal.id = 'artist-download-management-modal'; - modal.className = 'artist-download-management-modal'; - modal.innerHTML = ` -
-
-
-
-
-
- ${artistBubbleData.artist.image_url - ? `${escapeHtml(artistBubbleData.artist.name)}` - : '
' - } -
-
-

${escapeHtml(artistBubbleData.artist.name)}

-

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

-
-
- × -
-
- -
-
- ${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')} -
-
-
-
- `; - - document.body.appendChild(modal); - modal.style.display = 'flex'; - - // Monitor for real-time updates - startArtistDownloadModalMonitoring(artistId); -} - -/** - * Create HTML for an individual download item in the artist modal - */ -function createArtistDownloadItem(download, index) { - const { album, albumType, status, virtualPlaylistId } = download; - const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; - const buttonClass = status === 'view_results' ? 'completed' : 'active'; - - // Enhanced debugging for button text generation - console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`); - - return ` -
-
- ${album.image_url - ? `${escapeHtml(album.name)}` - : `
- -
` - } -
-
-
${escapeHtml(album.name)}
-
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
-
-
- -
-
- `; -} - -/** - * Monitor artist download modal for real-time updates - */ -function startArtistDownloadModalMonitoring(artistId) { - if (!artistDownloadModalOpen) return; - - const updateModal = () => { - const modal = document.getElementById('artist-download-management-modal'); - const itemsContainer = document.getElementById(`artist-download-items-${artistId}`); - - if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return; - - // Check for completed downloads that need to be removed - const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - // Keep if process exists or if it's completed but not yet cleaned up - return process !== undefined; - }); - - // Update the downloads array - artistDownloadBubbles[artistId].downloads = activeDownloads; - - // If no downloads left, close modal - if (activeDownloads.length === 0) { - closeArtistDownloadModal(); - return; - } - - // Update modal content and synchronize with bubble state - let statusChanged = false; - itemsContainer.innerHTML = activeDownloads.map((download, index) => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process) { - const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; - if (download.status !== newStatus) { - console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`); - download.status = newStatus; - statusChanged = true; - } - } - return createArtistDownloadItem(download, index); - }).join(''); - - // CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks - if (statusChanged) { - console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`); - updateArtistDownloadsSection(); - - // Check if all downloads for this artist are now completed - const artistDownloads = artistDownloadBubbles[artistId].downloads; - const allCompleted = artistDownloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`); - // Force additional refresh after a brief delay to ensure UI updates - setTimeout(() => { - console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`); - updateArtistDownloadsSection(); - }, 200); - } - } - - // Continue monitoring - setTimeout(updateModal, 2000); - }; - - setTimeout(updateModal, 1000); -} - -/** - * Open a specific artist download process modal - */ -function openArtistDownloadProcess(virtualPlaylistId) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process && process.modalElement) { - // Close artist management modal first - closeArtistDownloadModal(); - - // Show the download process modal - process.modalElement.style.display = 'flex'; - - if (process.status === 'complete') { - showToast('Review download results and click "Close" to finish.', 'info'); - } - } -} - -/** - * Close the artist download management modal - */ -function closeArtistDownloadModal() { - const modal = document.getElementById('artist-download-management-modal'); - if (modal) { - modal.remove(); - } - artistDownloadModalOpen = false; -} - -/** - * Bulk complete all downloads for an artist (when all are in 'view_results' state) - */ -function bulkCompleteArtistDownloads(artistId) { - console.log(`🎯 Bulk completing downloads for artist: ${artistId}`); - - const artistBubbleData = artistDownloadBubbles[artistId]; - if (!artistBubbleData) { - console.warn(`❌ No artist bubble data found for ${artistId}`); - return; - } - - // Find all downloads in 'view_results' state - const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); - console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, - completedDownloads.map(d => d.album.name)); - - if (completedDownloads.length === 0) { - console.warn(`⚠️ No completed downloads found for bulk close`); - showToast('No completed downloads to close', 'info'); - return; - } - - // Programmatically close all completed modals - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - console.log(`🗑️ Closing modal for: ${download.album.name}`); - // Trigger the close function which handles cleanup - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - // No modal open — clean up the bubble entry directly - console.log(`🧹 Direct cleanup (no modal) for: ${download.album.name}`); - cleanupArtistDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); -} - -// ======================================== -// Beatport Download Bubbles -// ======================================== - -/** - * Register a new Beatport chart download for bubble management - */ -function registerBeatportDownload(chartName, chartImage, virtualPlaylistId) { - console.log(`📝 Registering Beatport download: ${chartName}`); - - // Use chart name as key (sanitised) - const chartKey = chartName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); - - if (!beatportDownloadBubbles[chartKey]) { - beatportDownloadBubbles[chartKey] = { - chart: { name: chartName, image: chartImage || '' }, - downloads: [] - }; - } - - beatportDownloadBubbles[chartKey].downloads.push({ - virtualPlaylistId: virtualPlaylistId, - status: 'in_progress', - startTime: new Date() - }); - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - monitorBeatportDownload(chartKey, virtualPlaylistId); -} - -/** - * Debounced update for Beatport downloads section - */ -function updateBeatportDownloadsSection() { - if (beatportDownloadsUpdateTimeout) { - clearTimeout(beatportDownloadsUpdateTimeout); - } - beatportDownloadsUpdateTimeout = setTimeout(() => { - showBeatportDownloadsSection(); - updateDashboardDownloads(); - }, 300); -} - -/** - * Render Beatport download bubbles on the Beatport page - */ -function showBeatportDownloadsSection() { - const downloadsSection = document.getElementById('beatport-downloads-section'); - if (!downloadsSection) return; - - const activeCharts = Object.keys(beatportDownloadBubbles).filter(key => - beatportDownloadBubbles[key].downloads.length > 0 - ); - - if (activeCharts.length === 0) { - downloadsSection.style.display = 'none'; - return; - } - - downloadsSection.style.display = 'block'; - downloadsSection.innerHTML = ` -
-

Beatport Downloads

-

Active chart download processes

-
-
- ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} -
- `; - - // Attach click handlers + glow - activeCharts.forEach(chartKey => { - const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); - if (card) { - card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); - const chartImage = beatportDownloadBubbles[chartKey].chart.image; - if (chartImage) { - extractImageColors(chartImage, (colors) => { - applyDynamicGlow(card, colors); - }); - } - } - }); -} - -/** - * Create HTML for a Beatport bubble card (reuses artist bubble CSS) - */ -function createBeatportBubbleCard(bubbleData) { - const { chart, downloads } = bubbleData; - const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); - const activeCount = downloads.filter(d => d.status === 'in_progress').length; - const completedCount = downloads.filter(d => d.status === 'view_results').length; - const allCompleted = activeCount === 0 && completedCount > 0; - - const backgroundStyle = chart.image - ? `background-image: url('${chart.image}');` - : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; - - return ` -
-
-
-
-
${escapeHtml(chart.name)}
-
- ${activeCount > 0 ? `${activeCount} active` : ''} - ${completedCount > 0 ? `${completedCount} completed` : ''} -
-
- ${allCompleted ? ` -
- -
- ` : ''} -
- `; -} - -/** - * Monitor a Beatport download for completion - */ -function monitorBeatportDownload(chartKey, virtualPlaylistId) { - const checkStatus = () => { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (!process || !beatportDownloadBubbles[chartKey]) return; - - const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); - if (!download) return; - - if (process.status === 'complete' && download.status === 'in_progress') { - download.status = 'view_results'; - console.log(`✅ Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - - const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); - if (allCompleted) { - console.log(`🟢 All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); - setTimeout(updateBeatportDownloadsSection, 100); - } - } - - if (process.status !== 'complete') { - setTimeout(checkStatus, 2000); - } - }; - - setTimeout(checkStatus, 1000); -} - -/** - * Open the download modal for a Beatport chart bubble - */ -function openBeatportBubbleModal(chartKey) { - const bubbleData = beatportDownloadBubbles[chartKey]; - if (!bubbleData) return; - - // Find the first download with an active modal - for (const download of bubbleData.downloads) { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - process.modalElement.style.display = 'flex'; - if (process.status === 'complete') { - showToast('Review download results and click "Close" to finish.', 'info'); - } - return; - } - } - - showToast('No active download modal found for this chart', 'info'); -} - -/** - * Bulk complete all downloads for a Beatport chart - */ -function bulkCompleteBeatportDownloads(chartKey) { - console.log(`🎯 Bulk completing Beatport downloads for chart: ${chartKey}`); - - const bubbleData = beatportDownloadBubbles[chartKey]; - if (!bubbleData) return; - - const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); - if (completedDownloads.length === 0) { - showToast('No completed downloads to close', 'info'); - return; - } - - completedDownloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process && process.modalElement) { - closeDownloadMissingModal(download.virtualPlaylistId); - } else { - cleanupBeatportDownload(download.virtualPlaylistId); - } - }); - - showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); -} - -/** - * Clean up a Beatport download when its modal is closed - */ -function cleanupBeatportDownload(virtualPlaylistId) { - console.log(`🔍 [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); - - for (const chartKey in beatportDownloadBubbles) { - const downloads = beatportDownloadBubbles[chartKey].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - if (downloadIndex !== -1) { - downloads.splice(downloadIndex, 1); - console.log(`🧹 [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); - - if (downloads.length === 0) { - delete beatportDownloadBubbles[chartKey]; - console.log(`🧹 [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); - } - - updateBeatportDownloadsSection(); - saveBeatportBubbleSnapshot(); - return; - } - } -} - -// --- Beatport Bubble Snapshot System --- - -let beatportSnapshotSaveTimeout = null; - -async function saveBeatportBubbleSnapshot() { - if (beatportSnapshotSaveTimeout) { - clearTimeout(beatportSnapshotSaveTimeout); - } - - beatportSnapshotSaveTimeout = setTimeout(async () => { - try { - const bubbleCount = Object.keys(beatportDownloadBubbles).length; - if (bubbleCount === 0) return; - - const cleanBubbles = {}; - for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { - cleanBubbles[chartKey] = { - chart: bubbleData.chart, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - status: d.status, - startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime - })) - }; - } - - const response = await fetch('/api/beatport_bubbles/snapshot', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ bubbles: cleanBubbles }) - }); - - const data = await response.json(); - if (data.success) { - console.log(`✅ Beatport bubble snapshot saved: ${bubbleCount} charts`); - } - } catch (error) { - console.error('❌ Error saving Beatport bubble snapshot:', error); - } - }, 1000); -} - -async function hydrateBeatportBubblesFromSnapshot() { - try { - console.log('🔄 Loading Beatport bubble snapshot from backend...'); - - const response = await fetch('/api/beatport_bubbles/hydrate'); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load Beatport bubble snapshot:', data.error); - return; - } - - const bubbles = data.bubbles || {}; - if (Object.keys(bubbles).length === 0) { - console.log('ℹ️ No Beatport bubbles to hydrate'); - return; - } - - beatportDownloadBubbles = {}; - - for (const [chartKey, bubbleData] of Object.entries(bubbles)) { - beatportDownloadBubbles[chartKey] = { - chart: bubbleData.chart, - downloads: bubbleData.downloads.map(d => ({ - virtualPlaylistId: d.virtualPlaylistId, - status: d.status, - startTime: new Date(d.startTime) - })) - }; - - for (const download of bubbleData.downloads) { - if (download.status === 'in_progress') { - monitorBeatportDownload(chartKey, download.virtualPlaylistId); - } - } - } - - updateBeatportDownloadsSection(); - console.log(`✅ Hydrated ${Object.keys(beatportDownloadBubbles).length} Beatport download bubbles`); - } catch (error) { - console.error('❌ Error hydrating Beatport bubbles:', error); - } -} - -/** - * Clean up artist download when a modal is closed - */ -function cleanupArtistDownload(virtualPlaylistId) { - console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`); - console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles)); - - // Find which artist this download belongs to - for (const artistId in artistDownloadBubbles) { - const downloads = artistDownloadBubbles[artistId].downloads; - const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); - - console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`); - downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`)); - - if (downloadIndex !== -1) { - const downloadToRemove = downloads[downloadIndex]; - console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`); - - // Remove this download from the artist's downloads - downloads.splice(downloadIndex, 1); - console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`); - - // If no more downloads for this artist, remove the bubble - if (downloads.length === 0) { - delete artistDownloadBubbles[artistId]; - console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`); - } else { - console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`); - } - - // Update the downloads section - console.log(`🔄 [CLEANUP] Updating artist downloads section...`); - updateArtistDownloadsSection(); - - // Save snapshot of updated state - saveArtistBubbleSnapshot(); - break; - } - } - console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`); -} - -/** - * Force refresh all artist download statuses (useful for debugging) - */ -function refreshAllArtistDownloadStatuses() { - console.log('🔄 Force refreshing all artist download statuses...'); - - for (const artistId in artistDownloadBubbles) { - const artistData = artistDownloadBubbles[artistId]; - let hasChanges = false; - - artistData.downloads.forEach(download => { - const process = activeDownloadProcesses[download.virtualPlaylistId]; - if (process) { - const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; - if (download.status !== expectedStatus) { - console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`); - download.status = expectedStatus; - hasChanges = true; - } - } - }); - - if (hasChanges) { - console.log(`✅ Updated statuses for ${artistData.artist.name}`); - } - } - - // Force update the downloads section - showArtistDownloadsSection(); -} - -/** - * Extract dominant colors from an image for dynamic glow effects - */ -async function extractImageColors(imageUrl, callback) { - if (!imageUrl) { - callback(getAccentFallbackColors()); // Fallback to Spotify green - return; - } - - // Check cache first for performance - if (artistsPageState.cache.colors[imageUrl]) { - callback(artistsPageState.cache.colors[imageUrl]); - return; - } - - try { - // Create a canvas to analyze the image - const canvas = document.createElement('canvas'); - const ctx = canvas.getContext('2d'); - const img = new Image(); - - img.crossOrigin = 'anonymous'; - - img.onload = function () { - // Resize to small dimensions for faster processing - const size = 50; - canvas.width = size; - canvas.height = size; - - // Draw image to canvas - ctx.drawImage(img, 0, 0, size, size); - - try { - // Get image data - const imageData = ctx.getImageData(0, 0, size, size); - const data = imageData.data; - - // Extract colors (sample every few pixels for performance) - const colors = []; - for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel - const r = data[i]; - const g = data[i + 1]; - const b = data[i + 2]; - const alpha = data[i + 3]; - - // Skip transparent or very dark pixels - if (alpha > 128 && (r + g + b) > 150) { - colors.push({ r, g, b }); - } - } - - if (colors.length === 0) { - callback(getAccentFallbackColors()); // Fallback - return; - } - - // Find dominant colors using a simple clustering approach - const dominantColors = findDominantColors(colors, 2); - - // Convert to CSS hex colors - const hexColors = dominantColors.map(color => - `#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}` - ); - - // Cache the colors for future use - artistsPageState.cache.colors[imageUrl] = hexColors; - - callback(hexColors); - - } catch (e) { - console.warn('Color extraction failed, using fallback colors:', e); - callback(getAccentFallbackColors()); - } - }; - - img.onerror = function () { - callback(getAccentFallbackColors()); // Fallback on error - }; - - img.src = imageUrl; - - } catch (error) { - console.warn('Image color extraction error:', error); - callback(getAccentFallbackColors()); - } -} - -/** - * Simple color clustering to find dominant colors - */ -function findDominantColors(colors, numColors = 2) { - if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }]; - - // Simple k-means clustering - let centroids = []; - - // Initialize centroids randomly - for (let i = 0; i < numColors; i++) { - centroids.push(colors[Math.floor(Math.random() * colors.length)]); - } - - // Run a few iterations of k-means - for (let iteration = 0; iteration < 5; iteration++) { - const clusters = Array(numColors).fill().map(() => []); - - // Assign each color to nearest centroid - colors.forEach(color => { - let minDistance = Infinity; - let nearestCluster = 0; - - centroids.forEach((centroid, i) => { - const distance = Math.sqrt( - Math.pow(color.r - centroid.r, 2) + - Math.pow(color.g - centroid.g, 2) + - Math.pow(color.b - centroid.b, 2) - ); - - if (distance < minDistance) { - minDistance = distance; - nearestCluster = i; - } - }); - - clusters[nearestCluster].push(color); - }); - - // Update centroids - centroids = clusters.map(cluster => { - if (cluster.length === 0) return centroids[0]; // Fallback - - const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length; - const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length; - const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length; - - return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) }; - }); - } - - // Ensure we have vibrant colors by boosting saturation - return centroids.map(color => { - const max = Math.max(color.r, color.g, color.b); - const min = Math.min(color.r, color.g, color.b); - const saturation = max === 0 ? 0 : (max - min) / max; - - // Boost low saturation colors - if (saturation < 0.4) { - const factor = 1.3; - return { - r: Math.min(255, Math.round(color.r * factor)), - g: Math.min(255, Math.round(color.g * factor)), - b: Math.min(255, Math.round(color.b * factor)) - }; - } - - return color; - }); -} - -/** - * Apply dynamic glow effect to a card element - */ -function applyDynamicGlow(cardElement, colors) { - if (!cardElement || colors.length < 2) return; - - const color1 = colors[0]; - const color2 = colors[1]; - - // Add a small delay to make the effect feel more natural - setTimeout(() => { - // Create CSS custom properties for the dynamic colors - cardElement.style.setProperty('--glow-color-1', color1); - cardElement.style.setProperty('--glow-color-2', color2); - cardElement.classList.add('has-dynamic-glow'); - - console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`); - }, Math.random() * 200 + 100); // Random delay between 100-300ms -} - -/** - * Utility function to escape HTML - */ -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -// --- Service Status and System Stats Functions --- - -async function _forceServiceStatusRefresh() { - // Force an immediate status refresh (bypasses WebSocket check) — used after settings save - try { - const response = await fetch('/status'); - if (!response.ok) return; - const data = await response.json(); - handleServiceStatusUpdate(data); - } catch (error) { - console.warn('Could not force service status refresh:', error); - } -} - -async function fetchAndUpdateServiceStatus() { - if (document.hidden) return; // Skip polling when tab is not visible - if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll - try { - const response = await fetch('/status'); - if (!response.ok) return; - - const data = await response.json(); - - // Cache for library status card - _lastServiceStatus = data; - - // Update service status indicators and text (dashboard) - updateServiceStatus('spotify', data.spotify); - updateServiceStatus('media-server', data.media_server); - updateServiceStatus('soulseek', data.soulseek); - - // Update sidebar service status indicators - updateSidebarServiceStatus('spotify', data.spotify); - updateSidebarServiceStatus('media-server', data.media_server); - updateSidebarServiceStatus('soulseek', data.soulseek); - - // Update downloads nav badge - if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); - - // Hide sync buttons (not the page) for standalone mode - const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; - _isSoulsyncStandalone = isSoulsyncStandalone2; - document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { - if (isSoulsyncStandalone2) { - btn.dataset.hiddenByStandalone = '1'; - btn.style.display = 'none'; - } else if (btn.dataset.hiddenByStandalone) { - delete btn.dataset.hiddenByStandalone; - btn.style.display = ''; - } - }); - - // Update enrichment service cards - if (data.enrichment) renderEnrichmentCards(data.enrichment); - - // Check for Spotify rate limit - if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) { - handleSpotifyRateLimit(data.spotify.rate_limit); - } else if (_spotifyRateLimitShown) { - handleSpotifyRateLimit(null); - } - - } catch (error) { - console.warn('Could not fetch service status:', error); - } -} - -function updateServiceStatus(service, statusData) { - const indicator = document.getElementById(`${service}-status-indicator`); - const statusText = document.getElementById(`${service}-status-text`); - - if (indicator && statusText) { - if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) { - indicator.className = 'service-card-indicator rate-limited'; - const remaining = statusData.rate_limited - ? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0) - : formatRateLimitDuration(statusData.post_ban_cooldown); - const phase = statusData.rate_limited ? 'paused' : 'recovering'; - const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes'; - statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`; - statusText.className = 'service-card-status-text rate-limited'; - } else if (statusData.connected) { - indicator.className = 'service-card-indicator connected'; - statusText.textContent = `Connected (${statusData.response_time}ms)`; - statusText.className = 'service-card-status-text connected'; - } else { - indicator.className = 'service-card-indicator disconnected'; - statusText.textContent = 'Disconnected'; - statusText.className = 'service-card-status-text disconnected'; - } - } - - // Update music source title based on active source - if (service === 'spotify' && statusData.source) { - const musicSourceTitleElement = document.getElementById('music-source-title'); - if (musicSourceTitleElement) { - const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; - musicSourceTitleElement.textContent = sourceName; - currentMusicSourceName = sourceName; - } - - // Show/hide Spotify disconnect button based on connection state - const disconnectBtn = document.getElementById('spotify-disconnect-btn'); - if (disconnectBtn) { - disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none'; - } - } - - // Update download source title on dashboard card - if (service === 'soulseek' && statusData.source) { - const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; - const displayName = sourceNames[statusData.source] || 'Soulseek'; - const titleEl = document.getElementById('download-source-title'); - if (titleEl) titleEl.textContent = displayName; - } -} - -function updateSidebarServiceStatus(service, statusData) { - const indicator = document.getElementById(`${service}-indicator`); - if (indicator) { - const dot = indicator.querySelector('.status-dot'); - const nameElement = indicator.querySelector('.status-name'); - - if (dot) { - if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) { - dot.className = 'status-dot rate-limited'; - dot.title = statusData.rate_limited - ? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining` - : `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`; - } else if (statusData.connected) { - dot.className = 'status-dot connected'; - dot.title = ''; - } else { - dot.className = 'status-dot disconnected'; - dot.title = ''; - } - } - - // Update media server name if it's the media server indicator - if (service === 'media-server' && statusData.type) { - const mediaServerNameElement = document.getElementById('media-server-name'); - if (mediaServerNameElement) { - const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1); - mediaServerNameElement.textContent = serverName; - } - } - - // Update music source name in sidebar based on active source - if (service === 'spotify' && statusData.source) { - const musicSourceNameElement = document.getElementById('music-source-name'); - if (musicSourceNameElement) { - const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; - musicSourceNameElement.textContent = sourceName; - } - } - - // Update download source name based on configured mode - if (service === 'soulseek' && statusData.source) { - const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; - const displayName = sourceNames[statusData.source] || 'Soulseek'; - const sidebarName = document.getElementById('download-source-name'); - if (sidebarName) sidebarName.textContent = displayName; - } - } -} - -function renderEnrichmentCards(enrichment) { - const grid = document.getElementById('enrichment-status-grid'); - if (!grid || !enrichment) return; - - // Service display order - const serviceOrder = [ - 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment', - 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb', - 'acoustid', 'listenbrainz' - ]; - - // Map service keys to their settings page selector for click-to-configure - const settingsSelectors = { - 'spotify_enrichment': '.spotify-title', - 'tidal_enrichment': '.tidal-title', - 'qobuz_enrichment': '.qobuz-title', - 'lastfm': '.lastfm-title', - 'genius': '.genius-title', - 'acoustid': '.acoustid-title', - 'listenbrainz': '.listenbrainz-title', - }; - - const chips = []; - for (const key of serviceOrder) { - const svc = enrichment[key]; - if (!svc) continue; - - // Determine status class and text - let statusClass, statusLabel; - if ('running' in svc) { - if (!svc.configured) { - statusClass = 'not-configured'; - statusLabel = 'Set up'; - } else if (svc.paused) { - statusClass = 'paused'; - statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; - } else if (svc.running) { - statusClass = svc.idle ? 'idle' : 'running'; - statusLabel = svc.idle ? 'Idle' : 'Running'; - } else { - statusClass = 'stopped'; - statusLabel = 'Stopped'; - } - } else { - statusClass = svc.configured ? 'running' : 'not-configured'; - statusLabel = svc.configured ? 'Ready' : 'Set up'; - } - - const selector = settingsSelectors[key]; - const clickAttr = selector - ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"` - : ''; - - // Build activity display — human-readable, not cryptic numbers - let activityHtml = ''; - let metaHtml = ''; - const isSpotify = key === 'spotify_enrichment'; - - if ('running' in svc && svc.configured) { - const c1h = svc.calls_1h || 0; - const c24h = svc.calls_24h || 0; - - if (isSpotify && svc.daily_budget) { - // Spotify: show budget usage prominently - const b = svc.daily_budget; - const pct = Math.min(100, Math.round((b.used / b.limit) * 100)); - const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; - activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`; - metaHtml = `
-
-
`; - } else if (c24h > 0) { - // Other services: show 24h count - activityHtml = `${c24h.toLocaleString()} / 24h`; - } - } - - // Tooltip: full details including 1h breakdown - let tooltipLines = [svc.name + ' — ' + statusLabel]; - if ('running' in svc && svc.configured) { - const c1h = svc.calls_1h || 0; - const c24h = svc.calls_24h || 0; - if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h); - } - if (isSpotify && svc.daily_budget) { - const b = svc.daily_budget; - tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : '')); - } - if (selector && statusClass === 'not-configured') { - tooltipLines = ['Click to configure in Settings']; - } - - const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel; - - chips.push(` -
- - ${svc.name} - ${activityHtml} - ${statusDisplay} - ${metaHtml} -
- `); - } - - grid.innerHTML = chips.join(''); -} - -// =============================== -// == API RATE MONITOR GAUGES == -// =============================== - -const _rateMonitorState = {}; -const _RATE_GAUGE_SERVICES = [ - 'spotify', 'itunes', 'deezer', 'lastfm', 'genius', - 'musicbrainz', 'audiodb', 'tidal', 'qobuz', 'discogs', -]; -const _RATE_GAUGE_LABELS = { - spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', - lastfm: 'Last.fm', genius: 'Genius', musicbrainz: 'MusicBrainz', - audiodb: 'AudioDB', tidal: 'Tidal', qobuz: 'Qobuz', discogs: 'Discogs', -}; -const _RATE_GAUGE_COLORS = { - spotify: '#1DB954', itunes: '#FC3C44', deezer: '#A238FF', - lastfm: '#D51007', genius: '#FFFF64', musicbrainz: '#BA478F', - audiodb: '#00BCD4', tidal: '#00FFFF', qobuz: '#FF6B35', discogs: '#D4A574', -}; - -// SVG constants — 240° arc, gap at bottom -const _G = { size: 160, cx: 80, cy: 84, r: 56, stroke: 8, startAngle: 240, totalArc: 240 }; - -function _gPt(angle, radius) { - const rad = (angle - 90) * Math.PI / 180; - const r = radius || _G.r; - return { x: _G.cx + r * Math.cos(rad), y: _G.cy + r * Math.sin(rad) }; -} - -function _gArc(startDeg, endDeg, radius) { - const r = radius || _G.r; - const s = _gPt(startDeg, r), e = _gPt(endDeg, r); - const sweep = ((endDeg - startDeg + 360) % 360); - const large = sweep > 180 ? 1 : 0; - return `M${s.x},${s.y} A${r},${r} 0 ${large} 1 ${e.x},${e.y}`; -} - -function _handleRateMonitorUpdate(data) { - const grid = document.getElementById('rate-monitor-grid'); - if (!grid) return; - - if (!grid.children.length) { - for (const svc of _RATE_GAUGE_SERVICES) { - const div = document.createElement('div'); - div.className = 'rate-gauge-card'; - div.id = `rate-gauge-${svc}`; - div.onclick = () => _openRateModal(svc); - grid.appendChild(div); - } - } - - for (const svc of _RATE_GAUGE_SERVICES) { - const d = data[svc]; - if (!d) continue; - _rateMonitorState[svc] = d; - const container = document.getElementById(`rate-gauge-${svc}`); - if (!container) continue; - - const value = d.cpm || 0; - const max = d.limit || 60; - const pct = Math.min(value / max, 1); - const accent = _RATE_GAUGE_COLORS[svc] || '#888'; - const label = _RATE_GAUGE_LABELS[svc] || svc; - const worker = d.worker || {}; - const wStatus = worker.status || 'stopped'; - const isRateLimited = d.rate_limited === true; - - // Build or update the card content - let gaugeWrap = container.querySelector('.gauge-arc-wrap'); - if (!gaugeWrap) { - // Full rebuild - container.innerHTML = ` -
- - ${label} - ${_workerStatusLabel(wStatus, worker)} -
-
${_buildGaugeSVG(svc, value, max)}
-
-
${value.toFixed(0)}calls/min
-
${worker.calls_1h || 0}last hour
-
${(worker.calls_24h || 0).toLocaleString()}24h
-
- ${svc === 'spotify' && worker.daily_budget ? _buildBudgetBar(worker.daily_budget) : ''} - ${isRateLimited ? _buildRateLimitBadge(d) : ''} - `; - } else { - // Fast update — only change values - _updateGauge(gaugeWrap, value, max, svc); - - // Update status - const statusEl = container.querySelector('.gauge-card-status'); - if (statusEl) { - statusEl.dataset.status = wStatus; - statusEl.textContent = _workerStatusLabel(wStatus, worker); - } - - // Update stats - const statVals = container.querySelectorAll('.gauge-card-stat-val'); - if (statVals[0]) statVals[0].textContent = value.toFixed(0); - if (statVals[1]) statVals[1].textContent = worker.calls_1h || 0; - if (statVals[2]) statVals[2].textContent = (worker.calls_24h || 0).toLocaleString(); - - // Budget bar (Spotify) - if (svc === 'spotify' && worker.daily_budget) { - let budgetEl = container.querySelector('.gauge-budget-bar'); - if (!budgetEl) { - const div = document.createElement('div'); - div.innerHTML = _buildBudgetBar(worker.daily_budget); - const statsEl = container.querySelector('.gauge-card-stats'); - if (statsEl) statsEl.after(div.firstElementChild); - } else { - const b = worker.daily_budget; - const pctB = Math.min(100, Math.round((b.used / b.limit) * 100)); - const fill = budgetEl.querySelector('.gauge-budget-fill'); - if (fill) { fill.style.width = pctB + '%'; } - const label = budgetEl.querySelector('.gauge-budget-label'); - if (label) label.textContent = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()} daily`; - } - } - - // Rate limit badge - let badge = container.querySelector('.gauge-rl-badge'); - if (isRateLimited) { - if (!badge) { - const div = document.createElement('div'); - div.innerHTML = _buildRateLimitBadge(d); - container.appendChild(div.firstElementChild); - } else { - const mins = Math.ceil((d.rl_remaining || 0) / 60); - const timeEl = badge.querySelector('.gauge-rl-time'); - if (timeEl) timeEl.textContent = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; - } - } else if (badge) { - badge.remove(); - } - } - - container.classList.toggle('danger', pct > 0.8 || isRateLimited); - container.classList.toggle('active', value > 0 || wStatus === 'running'); - container.classList.toggle('rate-limited', isRateLimited); - } -} - -function _workerStatusLabel(status, worker) { - if (status === 'not_configured') return 'Not configured'; - if (status === 'paused') return worker.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; - if (status === 'idle') return 'Idle'; - if (status === 'running') return 'Running'; - return 'Stopped'; -} - -function _buildBudgetBar(budget) { - const pct = Math.min(100, Math.round((budget.used / budget.limit) * 100)); - const cls = budget.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; - return `
-
- ${budget.used.toLocaleString()} / ${budget.limit.toLocaleString()} daily -
`; -} - -function _buildRateLimitBadge(d) { - const mins = Math.ceil((d.rl_remaining || 0) / 60); - const text = mins > 60 ? `${Math.floor(mins / 60)}h ${mins % 60}m` : `${mins}m`; - return `
RATE LIMITED${text}
`; -} - -function _buildGaugeSVG(svc, value, max) { - const { size, cx, cy, r, stroke, startAngle, totalArc } = _G; - const label = _RATE_GAUGE_LABELS[svc] || svc; - const accent = _RATE_GAUGE_COLORS[svc] || '#888'; - const pct = Math.min(value / max, 1); - const endAngle = startAngle + pct * totalArc; - const arcEnd = startAngle + totalArc; - const glowId = `glow-${svc}`; - - // Endpoint dot position - const dot = pct > 0 ? _gPt(endAngle, r) : null; - - // Gradient ID for the colored arc - const gradId = `grad-${svc}`; - - const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; - - return ` - - - - - - - - - ${pct > 0 ? `` : ''} - - - ${dot ? `` : ''} - - - 0 - ${max} - - - ${Math.round(value)} - /min - - - ${label} - - `; -} - -function _updateGauge(container, value, max, svc) { - const { r, stroke, startAngle, totalArc } = _G; - const accent = _RATE_GAUGE_COLORS[svc] || '#888'; - const pct = Math.min(value / max, 1); - const endAngle = startAngle + pct * totalArc; - const color = pct > 0.8 ? '#ef4444' : pct > 0.6 ? '#eab308' : accent; - - // Update center value - const valText = container.querySelector('.gauge-value'); - if (valText) valText.textContent = Math.round(value); - - // Update active arc - const activeArc = container.querySelector('.gauge-active-arc'); - if (pct > 0) { - const d = _gArc(startAngle, endAngle); - if (activeArc) { - activeArc.setAttribute('d', d); - activeArc.setAttribute('stroke', color); - activeArc.style.filter = `drop-shadow(0 0 6px ${color}60)`; - } else { - // Rebuild the whole gauge when transitioning from 0 to active - container.innerHTML = _buildGaugeSVG(svc, value, max); - return; - } - } else if (activeArc) { - activeArc.remove(); - // Also remove dots - container.querySelectorAll('.gauge-dot').forEach(d => d.remove()); - const innerDot = container.querySelector('.gauge-dot + circle'); - if (innerDot) innerDot.remove(); - return; - } - - // Update endpoint dot - const gaugeDot = container.querySelector('.gauge-dot'); - if (pct > 0 && gaugeDot) { - const dot = _gPt(endAngle, r); - gaugeDot.setAttribute('cx', dot.x); - gaugeDot.setAttribute('cy', dot.y); - gaugeDot.setAttribute('fill', color); - gaugeDot.style.filter = `drop-shadow(0 0 4px ${color}80)`; - const inner = gaugeDot.nextElementSibling; - if (inner && inner.tagName === 'circle') { - inner.setAttribute('cx', dot.x); - inner.setAttribute('cy', dot.y); - } - } -} - -// ── Rate Monitor Detail Modal ── - -let _rateModalService = null; -let _rateModalInterval = null; - -function _openRateModal(serviceKey) { - _rateModalService = serviceKey; - const label = _RATE_GAUGE_LABELS[serviceKey] || serviceKey; - const accent = _RATE_GAUGE_COLORS[serviceKey] || '#888'; - - let overlay = document.getElementById('rate-modal-overlay'); - if (overlay) overlay.remove(); - - overlay = document.createElement('div'); - overlay.id = 'rate-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) _closeRateModal(); }; - - const isSpotify = serviceKey === 'spotify'; - const currentData = _rateMonitorState[serviceKey] || {}; - - overlay.innerHTML = ` -
-
-
-
-
-

${label}

- ${currentData.cpm || 0} calls/min — limit ${currentData.limit || '?'}/min -
-
- -
-
-
24-Hour Call History
-
- -
-
- ${isSpotify ? '
Per-Endpoint Breakdown
' : ''} -
-
- `; - document.body.appendChild(overlay); - - // Fetch main history + per-endpoint histories for Spotify - const historyPromises = [ - fetch(`/api/rate-monitor/history/${serviceKey}`).then(r => r.json()) - ]; - if (isSpotify) { - const activeEps = Object.keys(_rateMonitorState.spotify?.endpoints || {}); - for (const ep of activeEps) { - historyPromises.push( - fetch(`/api/rate-monitor/history/spotify:${ep}`).then(r => r.json()).catch(() => null) - ); - } - } - Promise.all(historyPromises).then(results => { - const main = results[0]; - const epHistories = isSpotify ? results.slice(1).filter(Boolean) : []; - _renderRateChart(main.history || [], main.rate_limit || 60, accent, epHistories); - }).catch(() => { }); - - if (isSpotify) { - _updateSpotifyEndpoints(); - _rateModalInterval = setInterval(_updateSpotifyEndpoints, 1000); - } -} - -function _closeRateModal() { - const overlay = document.getElementById('rate-modal-overlay'); - if (overlay) overlay.remove(); - if (_rateModalInterval) { clearInterval(_rateModalInterval); _rateModalInterval = null; } - _rateModalService = null; -} - -function _renderRateChart(history, rateLimit, accent, epHistories = []) { - const canvas = document.getElementById('rate-modal-chart'); - if (!canvas) return; - - // HiDPI support - const dpr = window.devicePixelRatio || 1; - const W = 700, H = 280; - canvas.width = W * dpr; - canvas.height = H * dpr; - canvas.style.width = W + 'px'; - canvas.style.height = H + 'px'; - const ctx = canvas.getContext('2d'); - ctx.scale(dpr, dpr); - - const pad = { top: 24, right: 24, bottom: 36, left: 50 }; - const plotW = W - pad.left - pad.right; - const plotH = H - pad.top - pad.bottom; - - ctx.clearRect(0, 0, W, H); - - // Build data points - const now = Math.floor(Date.now() / 1000); - const start = now - 86400; - const points = []; - - if (history.length > 0) { - const histMap = new Map(history.map(h => [h[0], h[1]])); - for (let t = start; t <= now; t += 300) { - const bucket = Math.floor(t / 60) * 60; - let sum = 0; - for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; - points.push({ t, v: sum / 5 }); - } - } - - const maxVal = Math.max(rateLimit * 1.15, ...points.map(p => p.v), 1); - - // Grid lines (horizontal) - ctx.strokeStyle = 'rgba(255,255,255,0.04)'; - ctx.lineWidth = 1; - for (let i = 1; i <= 4; i++) { - const y = pad.top + plotH * (1 - i / 4); - ctx.beginPath(); - ctx.moveTo(pad.left, y); - ctx.lineTo(pad.left + plotW, y); - ctx.stroke(); - } - - // Danger zone band - const dangerY = pad.top + plotH * (1 - rateLimit / maxVal); - const grad = ctx.createLinearGradient(0, pad.top, 0, dangerY); - grad.addColorStop(0, 'rgba(239, 68, 68, 0.08)'); - grad.addColorStop(1, 'rgba(239, 68, 68, 0.02)'); - ctx.fillStyle = grad; - ctx.fillRect(pad.left, pad.top, plotW, dangerY - pad.top); - - // Rate limit line - ctx.strokeStyle = 'rgba(239, 68, 68, 0.5)'; - ctx.setLineDash([8, 5]); - ctx.lineWidth = 1.5; - ctx.beginPath(); - ctx.moveTo(pad.left, dangerY); - ctx.lineTo(pad.left + plotW, dangerY); - ctx.stroke(); - ctx.setLineDash([]); - - ctx.fillStyle = 'rgba(239, 68, 68, 0.6)'; - ctx.font = '10px -apple-system, sans-serif'; - ctx.textAlign = 'left'; - ctx.fillText(`Rate limit: ${rateLimit}/min`, pad.left + 6, dangerY - 6); - - // Draw area fill + line - if (points.length > 1) { - // Area gradient fill - const areaGrad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH); - // Parse accent to rgba - areaGrad.addColorStop(0, accent + '30'); - areaGrad.addColorStop(1, accent + '05'); - - ctx.beginPath(); - points.forEach((p, i) => { - const x = pad.left + (i / (points.length - 1)) * plotW; - const y = pad.top + plotH * (1 - p.v / maxVal); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.lineTo(pad.left + plotW, pad.top + plotH); - ctx.lineTo(pad.left, pad.top + plotH); - ctx.closePath(); - ctx.fillStyle = areaGrad; - ctx.fill(); - - // Line - ctx.beginPath(); - points.forEach((p, i) => { - const x = pad.left + (i / (points.length - 1)) * plotW; - const y = pad.top + plotH * (1 - p.v / maxVal); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.strokeStyle = accent; - ctx.lineWidth = 2; - ctx.lineJoin = 'round'; - ctx.stroke(); - - // Glow effect - ctx.shadowColor = accent; - ctx.shadowBlur = 8; - ctx.stroke(); - ctx.shadowBlur = 0; - } - - // Per-endpoint lines (Spotify breakdown) - const legendEl = document.getElementById('rate-modal-chart-legend'); - if (epHistories.length > 0) { - const epColors = ['#1DB954', '#FF6B6B', '#4ECDC4', '#FFE66D', '#A78BFA', '#F97316', '#06B6D4', '#EC4899', '#F472B6', '#34D399']; - const legendItems = []; - - epHistories.forEach((epData, idx) => { - if (!epData || !epData.history || epData.history.length === 0) return; - const epName = (epData.service || '').replace('spotify:', '').replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - const color = epColors[idx % epColors.length]; - legendItems.push({ name: epName, color }); - - const histMap = new Map(epData.history.map(h => [h[0], h[1]])); - const epPoints = []; - for (let t = start; t <= now; t += 300) { - const bucket = Math.floor(t / 60) * 60; - let sum = 0; - for (let m = bucket; m < bucket + 300; m += 60) sum += histMap.get(m) || 0; - epPoints.push({ t, v: sum / 5 }); - } - - if (epPoints.length > 1) { - ctx.beginPath(); - epPoints.forEach((p, i) => { - const x = pad.left + (i / (epPoints.length - 1)) * plotW; - const y = pad.top + plotH * (1 - p.v / maxVal); - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - }); - ctx.strokeStyle = color + 'BB'; - ctx.lineWidth = 1.5; - ctx.lineJoin = 'round'; - ctx.stroke(); - } - }); - - // HTML legend below chart - if (legendEl && legendItems.length > 0) { - legendEl.innerHTML = legendItems.map(item => - `${item.name}` - ).join(''); - } - } else if (legendEl) { - legendEl.innerHTML = ''; - } - - // X-axis labels - ctx.fillStyle = 'rgba(255,255,255,0.3)'; - ctx.font = '10px -apple-system, sans-serif'; - ctx.textAlign = 'center'; - for (let i = 0; i <= 6; i++) { - const t = start + (86400 * i / 6); - const x = pad.left + (i / 6) * plotW; - const d = new Date(t * 1000); - const hr = d.getHours(); - const label = hr === 0 ? '12am' : hr < 12 ? `${hr}am` : hr === 12 ? '12pm' : `${hr - 12}pm`; - ctx.fillText(label, x, H - 10); - // Subtle vertical grid - ctx.strokeStyle = 'rgba(255,255,255,0.03)'; - ctx.lineWidth = 1; - ctx.beginPath(); - ctx.moveTo(x, pad.top); - ctx.lineTo(x, pad.top + plotH); - ctx.stroke(); - } - - // Y-axis labels - ctx.fillStyle = 'rgba(255,255,255,0.3)'; - ctx.textAlign = 'right'; - ctx.font = '10px -apple-system, sans-serif'; - for (let i = 0; i <= 4; i++) { - const v = maxVal * i / 4; - const y = pad.top + plotH * (1 - i / 4); - ctx.fillText(Math.round(v), pad.left - 8, y + 4); - } - - // Empty state - if (points.length === 0) { - ctx.fillStyle = 'rgba(255,255,255,0.15)'; - ctx.font = '13px -apple-system, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText('No call history yet — data populates as API calls are made', W / 2, H / 2); - } -} - -function _updateSpotifyEndpoints() { - const container = document.getElementById('rate-modal-endpoints'); - if (!container) return; - const endpoints = _rateMonitorState.spotify?.endpoints || {}; - const entries = Object.entries(endpoints).sort((a, b) => b[1] - a[1]); - - if (entries.length === 0) { - container.innerHTML = '
No active Spotify endpoints — start an enrichment worker or search to see activity
'; - return; - } - - const limit = _rateMonitorState.spotify?.limit || 171; - container.innerHTML = entries.map(([ep, cpm]) => { - const pct = Math.min(cpm / limit * 100, 100); - const name = ep.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - const color = pct > 80 ? '#ef4444' : pct > 60 ? '#eab308' : '#1DB954'; - return `
- ${name} -
- ${Math.round(cpm)}/min -
`; - }).join(''); -} - -async function fetchAndUpdateSystemStats() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/system/stats'); - if (!response.ok) return; - - const data = await response.json(); - - // Update all stat cards - 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'); - - } catch (error) { - console.warn('Could not fetch system stats:', error); - } -} - -function updateStatCard(cardId, value, subtitle) { - const card = document.getElementById(cardId); - if (card) { - const valueElement = card.querySelector('.stat-card-value'); - const subtitleElement = card.querySelector('.stat-card-subtitle'); - - if (valueElement) { - valueElement.textContent = value; - } - if (subtitleElement) { - subtitleElement.textContent = subtitle; - } - } -} - -async function fetchAndUpdateActivityFeed() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/activity/feed'); - if (!response.ok) { - console.warn('Activity feed response not ok:', response.status, response.statusText); - return; - } - - const data = await response.json(); - console.log('Activity feed data received:', data); - updateActivityFeed(data.activities || []); - - } catch (error) { - console.warn('Could not fetch activity feed:', error); - } -} - -// Cache last feed signature to avoid unnecessary DOM rebuilds (prevents blink) -let _lastActivityFeedSig = ''; - -function updateActivityFeed(activities) { - const feedContainer = document.getElementById('dashboard-activity-feed'); - if (!feedContainer) return; - - if (activities.length === 0) { - if (_lastActivityFeedSig === 'empty') return; - _lastActivityFeedSig = 'empty'; - feedContainer.innerHTML = ` -
- 📊 -
-

System Started

-

Dashboard initialized successfully

-
-

Just now

-
- `; - return; - } - - const items = activities.slice(0, 5); - // Build signature from titles+subtitles to detect actual changes - const sig = items.map(a => a.title + a.subtitle).join('|'); - const feedChanged = sig !== _lastActivityFeedSig; - _lastActivityFeedSig = sig; - - if (!feedChanged) { - // Just update timestamps without rebuilding DOM - const timeEls = feedContainer.querySelectorAll('.activity-time'); - items.forEach((activity, i) => { - if (timeEls[i]) timeEls[i].textContent = timeAgo(activity.time); - }); - return; - } - - // Full rebuild only when feed content actually changed - feedContainer.innerHTML = ''; - items.forEach((activity, index) => { - const activityElement = document.createElement('div'); - activityElement.className = 'activity-item'; - activityElement.innerHTML = ` - ${escapeHtml(activity.icon)} -
-

${escapeHtml(activity.title)}

-

${escapeHtml(activity.subtitle)}

-
-

${timeAgo(activity.time)}

- `; - feedContainer.appendChild(activityElement); - - if (index < items.length - 1) { - const separator = document.createElement('div'); - separator.className = 'activity-separator'; - feedContainer.appendChild(separator); - } - }); -} - -async function checkForActivityToasts() { - if (socketConnected) return; // WebSocket handles this (instant push) - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/activity/toasts'); - if (!response.ok) return; - - const data = await response.json(); - const toasts = data.toasts || []; - - toasts.forEach(activity => { - // Convert activity to toast type based on icon/title - let toastType = 'info'; - if (activity.icon === '✅' || activity.title.includes('Complete')) { - toastType = 'success'; - } else if (activity.icon === '❌' || activity.title.includes('Failed') || activity.title.includes('Error')) { - toastType = 'error'; - } else if (activity.icon === '🚫' || activity.title.includes('Cancelled')) { - toastType = 'warning'; - } - - // Show toast with activity info - showToast(`${activity.title}: ${activity.subtitle}`, toastType); - }); - - } catch (error) { - // Silently fail for toast checking to avoid spam - } -} - -// --- Watchlist Functions --- - -/** - * Toggle an artist's watchlist status - */ -async function toggleWatchlist(event, artistId, artistName) { - // Prevent event bubbling to parent card - event.stopPropagation(); - - const button = event.currentTarget; - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - // Show loading state - const originalText = text.textContent; - text.textContent = 'Loading...'; - button.disabled = true; - - try { - // Check current status - const checkResponse = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const checkData = await checkResponse.json(); - if (!checkData.success) { - throw new Error(checkData.error || 'Failed to check watchlist status'); - } - - const isWatching = checkData.is_watching; - - // Toggle watchlist status - const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; - const payload = isWatching ? - { artist_id: artistId } : - { artist_id: artistId, artist_name: artistName }; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to update watchlist'); - } - - // Update button appearance - const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn'); - if (isWatching) { - // Was watching, now removed - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - if (gearBtn) gearBtn.classList.add('hidden'); - console.log(`❌ Removed ${artistName} from watchlist`); - } else { - // Was not watching, now added - icon.textContent = '👁️'; - text.textContent = 'Watching...'; - button.classList.add('watching'); - if (gearBtn) gearBtn.classList.remove('hidden'); - console.log(`✅ Added ${artistName} to watchlist`); - } - - // Update dashboard watchlist count - updateWatchlistButtonCount(); - - } catch (error) { - console.error('Error toggling watchlist:', error); - text.textContent = originalText; - - // Show error feedback - const originalBackground = button.style.background; - button.style.background = 'rgba(255, 59, 48, 0.3)'; - setTimeout(() => { - button.style.background = originalBackground; - }, 2000); - } finally { - button.disabled = false; - } -} - -/** - * Update the watchlist button count on dashboard - */ -async function updateWatchlistButtonCount() { - if (document.hidden) return; // Skip polling when tab is not visible - if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll - try { - const response = await fetch('/api/watchlist/count'); - const data = await response.json(); - - 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}`; - } - } - } - } catch (error) { - console.error('Error updating watchlist count:', error); - } -} - -/** - * Check and update watchlist status for all visible artist cards - */ -async function updateArtistCardWatchlistStatus() { - const artistCards = document.querySelectorAll('.artist-card'); - const artistIds = []; - for (const card of artistCards) { - const artistId = card.dataset.artistId; - if (artistId) artistIds.push(artistId); - } - if (!artistIds.length) return; - - try { - const response = await fetch('/api/watchlist/check-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_ids: artistIds }) - }); - - const data = await response.json(); - if (data.success && data.results) { - for (const card of artistCards) { - const artistId = card.dataset.artistId; - if (!artistId) continue; - - const button = card.querySelector('.watchlist-toggle-btn'); - if (!button) continue; - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - const gearBtn = button.parentElement?.querySelector('.watchlist-settings-btn'); - if (data.results[artistId]) { - if (icon) icon.textContent = '👁️'; - if (text) text.textContent = 'Watching...'; - button.classList.add('watching'); - if (gearBtn) gearBtn.classList.remove('hidden'); - } else { - if (icon) icon.textContent = '👁️'; - if (text) text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - if (gearBtn) gearBtn.classList.add('hidden'); - } - } - } - } catch (error) { - console.error('Error batch checking watchlist status:', error); - } -} - -/** - * Initialize/refresh the watchlist sidebar page - */ -async function initializeWatchlistPage() { - try { - const emptyEl = document.getElementById('watchlist-page-empty'); - const gridEl = document.getElementById('watchlist-artists-list'); - const countEl = document.getElementById('watchlist-page-count'); - const overrideBanner = document.getElementById('watchlist-page-override-banner'); - - // Fetch count, artists, scan status, global config in parallel - const [countRes, artistsRes, statusRes, globalRes] = await Promise.all([ - fetch('/api/watchlist/count').then(r => r.json()), - fetch('/api/watchlist/artists').then(r => r.json()), - fetch('/api/watchlist/scan/status').then(r => r.json()), - fetch('/api/watchlist/global-config').then(r => r.json()).catch(() => ({ success: false })), - ]); - - const count = countRes.success ? countRes.count : 0; - const artists = artistsRes.success ? artistsRes.artists : []; - const scanStatus = statusRes.success ? statusRes.status : 'idle'; - const globalOverrideActive = globalRes.success && globalRes.config && globalRes.config.global_override_enabled; - - // Update count - if (countEl) countEl.textContent = `${count} artist${count !== 1 ? 's' : ''}`; - - // Update nav badge - const navBadge = document.getElementById('watchlist-nav-badge'); - if (navBadge) { - navBadge.textContent = count; - navBadge.classList.toggle('hidden', count === 0); - } - - // Empty state - if (count === 0) { - if (emptyEl) emptyEl.style.display = ''; - if (gridEl) gridEl.style.display = 'none'; - watchlistPageState.isInitialized = true; - return; - } - if (emptyEl) emptyEl.style.display = 'none'; - if (gridEl) gridEl.style.display = ''; - - // Store artists for sorting - watchlistPageState.artists = artists; - - // Last scan summary strip - const scanStrip = document.getElementById('watchlist-last-scan-strip'); - const scanText = document.getElementById('watchlist-last-scan-text'); - if (scanStrip && scanText && statusRes.completed_at && statusRes.summary) { - const completedDate = new Date(statusRes.completed_at); - const ago = _formatTimeAgo(completedDate); - const found = statusRes.summary.new_tracks_found || 0; - const added = statusRes.summary.tracks_added_to_wishlist || 0; - scanText.textContent = `Last scan: ${ago} — ${found} new track${found !== 1 ? 's' : ''} found, ${added} added to wishlist`; - scanStrip.style.display = ''; - } else if (scanStrip) { - scanStrip.style.display = 'none'; - } - - // Global override banner - if (overrideBanner) overrideBanner.style.display = globalOverrideActive ? '' : 'none'; - const settingsBtn = document.getElementById('watchlist-page-settings-btn'); - if (settingsBtn) { - settingsBtn.classList.toggle('watchlist-global-settings-active', globalOverrideActive); - settingsBtn.innerHTML = ` ${globalOverrideActive ? 'Global Override ON' : 'Global Settings'}`; - } - - // Render artist cards - if (gridEl) { - gridEl.innerHTML = artists.map(artist => { - const pills = []; - if (artist.include_albums) pills.push('Albums'); - if (artist.include_eps) pills.push('EPs'); - if (artist.include_singles) pills.push('Singles'); - if (artist.include_live) pills.push('Live'); - if (artist.include_remixes) pills.push('Remixes'); - if (artist.include_acoustic) pills.push('Acoustic'); - if (artist.include_compilations) pills.push('Compilations'); - const sourceBadges = []; - if (artist.spotify_artist_id) sourceBadges.push('Spotify'); - if (artist.itunes_artist_id) sourceBadges.push('iTunes'); - if (artist.deezer_artist_id) sourceBadges.push('Deezer'); - if (artist.discogs_artist_id) sourceBadges.push('Discogs'); - const artistPrimaryId = artist.spotify_artist_id || artist.itunes_artist_id || artist.deezer_artist_id || artist.discogs_artist_id; - return ` -
- - -
- ${artist.image_url ? `${escapeHtml(artist.artist_name)}` : '
🎤
'} -
-
- ${escapeHtml(artist.artist_name)} - ${formatRelativeScanTime(artist.last_scan_timestamp)} -
- ${sourceBadges.length > 0 ? `
${sourceBadges.join('')}
` : ''} - ${pills.length > 0 ? `
${pills.join('')}
` : ''} -
- `; - }).join(''); - - // Wire up gear buttons - gridEl.querySelectorAll('.watchlist-card-gear').forEach(button => { - button.addEventListener('click', () => { - openWatchlistArtistConfigModal(button.getAttribute('data-artist-id'), button.getAttribute('data-artist-name')); - }); - }); - - // Wire up artist card clicks - gridEl.querySelectorAll('.watchlist-artist-card').forEach(item => { - item.addEventListener('click', (e) => { - if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) return; - const artistId = item.getAttribute('data-artist-id'); - const artistName = item.querySelector('.watchlist-card-name').textContent; - openWatchlistArtistDetailView(artistId, artistName); - }); - }); - } - - // Scan status - const scanStatusEl = document.getElementById('watchlist-scan-status'); - const liveActivityEl = document.getElementById('watchlist-live-activity'); - const scanBtn = document.getElementById('scan-watchlist-btn'); - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - - if (scanStatus === 'scanning') { - if (scanStatusEl) scanStatusEl.style.display = ''; - if (liveActivityEl) liveActivityEl.style.display = 'flex'; - if (scanBtn) { scanBtn.disabled = true; scanBtn.classList.add('btn-processing'); scanBtn.innerHTML = ' Scanning...'; } - if (cancelBtn) cancelBtn.style.display = ''; - pollWatchlistScanStatus(); - } else { - if (scanStatusEl && statusRes.summary) { - scanStatusEl.style.display = ''; - const summaryEl = document.getElementById('watchlist-page-scan-summary'); - if (summaryEl) { - summaryEl.style.display = ''; - summaryEl.innerHTML = `Artists: ${statusRes.summary.total_artists || 0}New tracks: ${statusRes.summary.new_tracks_found || 0}Added to wishlist: ${statusRes.summary.tracks_added_to_wishlist || 0}`; - } - } - } - - // Start countdown timer - const nextRunSeconds = countRes.next_run_in_seconds || 0; - startWatchlistCountdownTimer(nextRunSeconds); - - watchlistPageState.isInitialized = true; - - } catch (error) { - console.error('Error initializing watchlist page:', error); - showToast('Failed to load watchlist', 'error'); - } -} - -/** - * Initialize/refresh the wishlist sidebar page - */ -async function initializeWishlistPage() { - try { - const emptyEl = document.getElementById('wishlist-page-empty'); - const nebulaEl = document.getElementById('wishlist-nebula'); - const countEl = document.getElementById('wishlist-page-count'); - const tracksSection = document.getElementById('wishlist-category-tracks'); - const statsStrip = document.getElementById('wishlist-stats-strip'); - - const [statsRes, cycleRes, albumRes, singleRes, watchlistRes] = await Promise.all([ - fetch('/api/wishlist/stats').then(r => r.json()), - fetch('/api/wishlist/cycle').then(r => r.json()), - fetch('/api/wishlist/tracks?category=albums').then(r => r.json()), - fetch('/api/wishlist/tracks?category=singles').then(r => r.json()), - fetch('/api/watchlist/artists').then(r => r.json()).catch(() => ({ success: false })), - ]); - - // Build artist name → image URL map from watchlist - const _artistImageMap = new Map(); - if (watchlistRes.success && watchlistRes.artists) { - for (const wa of watchlistRes.artists) { - if (wa.artist_name && wa.image_url) _artistImageMap.set(wa.artist_name.toLowerCase(), wa.image_url); - } - } - - const { singles = 0, albums = 0, total = 0 } = statsRes; - const currentCycle = cycleRes.cycle || 'albums'; - - if (countEl) countEl.textContent = `${total} track${total !== 1 ? 's' : ''}`; - const navBadge = document.getElementById('wishlist-nav-badge'); - if (navBadge) { navBadge.textContent = total; navBadge.classList.toggle('hidden', total === 0); } - - const statAlbums = document.getElementById('wishlist-stat-albums'); - const statSingles = document.getElementById('wishlist-stat-singles'); - const statCycle = document.getElementById('wishlist-stat-cycle'); - if (statAlbums) statAlbums.textContent = albums; - if (statSingles) statSingles.textContent = singles; - if (statCycle) statCycle.textContent = currentCycle === 'albums' ? 'Albums/EPs' : 'Singles'; - - if (total === 0) { - if (emptyEl) emptyEl.style.display = ''; - if (nebulaEl) nebulaEl.style.display = 'none'; - if (tracksSection) tracksSection.style.display = 'none'; - if (statsStrip) statsStrip.style.display = 'none'; - wishlistPageState.isInitialized = true; - return; - } - if (emptyEl) emptyEl.style.display = 'none'; - if (nebulaEl) nebulaEl.style.display = ''; - if (tracksSection) tracksSection.style.display = 'none'; - if (statsStrip) statsStrip.style.display = ''; - - _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], _artistImageMap, currentCycle); - startWishlistCountdownTimer(currentCycle, statsRes.next_run_in_seconds || 0); - - // Live processing: check if wishlist download is active and start polling - _startNebulaLivePolling(currentCycle, _artistImageMap); - - wishlistPageState.isInitialized = true; - - } catch (error) { - console.error('Error initializing wishlist page:', error); - showToast('Failed to load wishlist', 'error'); - } -} - -/* ═══════════════════════════════════════════════════════════════════ - WISHLIST NEBULA — Artist orbs with album/single satellites - ═══════════════════════════════════════════════════════════════════ */ - -function _renderWishlistNebula(albumTracks, singleTracks, artistImageMap, currentCycle) { - const field = document.getElementById('wl-nebula-field'); - if (!field) return; - artistImageMap = artistImageMap || new Map(); - - const artistMap = new Map(); - function _parse(track, type) { - let sd = track.spotify_data; - if (typeof sd === 'string') { try { sd = JSON.parse(sd); } catch (e) { return null; } } - if (!sd) return null; - const raw = sd.album; - const albumName = (typeof raw === 'string' ? raw : raw?.name) || 'Unknown'; - const albumImage = (typeof raw === 'object' && raw?.images?.[0]?.url) || ''; - let artist = 'Unknown Artist'; - if (sd.artists?.[0]?.name) artist = sd.artists[0].name; - else if (typeof sd.artists?.[0] === 'string') artist = sd.artists[0]; - return { track: sd.name || 'Unknown', artist, album: albumName, image: albumImage, type, id: track.spotify_track_id || track.id || '' }; - } - - for (const t of albumTracks) { const p = _parse(t, 'album'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); const a = artistMap.get(p.artist); if (!a.albums.has(p.album)) a.albums.set(p.album, { image: p.image, tracks: [] }); a.albums.get(p.album).tracks.push(p); } } - for (const t of singleTracks) { const p = _parse(t, 'single'); if (p) { if (!artistMap.has(p.artist)) artistMap.set(p.artist, { albums: new Map(), singles: [] }); artistMap.get(p.artist).singles.push(p); } } - - if (artistMap.size === 0) { field.innerHTML = '
Your wishlist is empty
'; return; } - - const sorted = [...artistMap.entries()].sort((a, b) => { - const ac = [...a[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + a[1].singles.length; - const bc = [...b[1].albums.values()].reduce((s, al) => s + al.tracks.length, 0) + b[1].singles.length; - return bc - ac; - }); - - function _hue(n) { let h = 0; for (let i = 0; i < n.length; i++) h = n.charCodeAt(i) + ((h << 5) - h); return Math.abs(h) % 360; } - - let html = ''; - sorted.forEach(([name, data], idx) => { - const total = [...data.albums.values()].reduce((s, a) => s + a.tracks.length, 0) + data.singles.length; - const hasAlbums = data.albums.size > 0; - const hue = _hue(name); - const sz = total >= 10 ? 'orb-lg' : total >= 4 ? 'orb-md' : 'orb-sm'; - - // Enhancement 1: prefer watchlist artist photo over album cover - let img = artistImageMap.get(name.toLowerCase()) || ''; - if (!img) { for (const [, ad] of data.albums) { if (ad.image) { img = ad.image; break; } } } - if (!img && data.singles.length) img = data.singles[0].image || ''; - - // Enhancement 3: pulse if this artist has albums and current cycle is albums - const pulseClass = (hasAlbums && currentCycle === 'albums') ? ' orb-pulse' : ''; - - // Enhancement 7: staggered entry animation - const delay = Math.min(idx * 60, 800); - - html += `
`; - - // Enhancement 2: hover tooltip - html += `
${escapeHtml(name)}
${total} track${total !== 1 ? 's' : ''}
`; - - html += `
`; - html += `
`; - html += img ? `` : `
${escapeHtml(name.substring(0, 2).toUpperCase())}
`; - html += `
`; - - // Enhancement 5: album art ring (show up to 6 album covers around the orb) - const ringCovers = []; - for (const [, ad] of data.albums) { if (ad.image && ringCovers.length < 6) ringCovers.push(ad.image); } - for (const s of data.singles) { if (s.image && ringCovers.length < 6) ringCovers.push(s.image); } - if (ringCovers.length >= 3) { - html += `
`; - ringCovers.forEach((url, i) => { - const angle = (360 / ringCovers.length) * i; - html += ``; - }); - html += `
`; - } - - html += `
`; // /orb - - // Enhancement 8: clickable artist name → navigate to artist detail - html += `
${escapeHtml(name)}
`; - html += `
${total} track${total !== 1 ? 's' : ''}
`; - - // Expanded content - html += `
`; - if (data.albums.size > 0) { - html += `
`; - for (const [an, ad] of data.albums) { - const tileId = 'wl-tile-' + an.replace(/\W/g, '_') + '_' + idx; - html += `
`; - html += `
${ad.image ? `` : `
💿
`}
`; - html += `
${escapeHtml(an)}
${ad.tracks.length} track${ad.tracks.length !== 1 ? 's' : ''}
`; - html += `${ad.tracks.length}`; - html += ``; - // Track list (hidden until tile clicked) - html += `
`; - for (const tr of ad.tracks) { - html += `
`; - html += `${escapeHtml(tr.track)}`; - html += ``; - html += `
`; - } - html += `
`; - html += `
`; - } - html += `
`; - } - if (data.singles.length > 0) { - html += `
`; - for (const s of data.singles) { - html += `
`; - html += s.image ? `` : ``; - html += `
${escapeHtml(s.track)}
`; - html += ``; - html += `
`; - } - html += `
`; - } - html += `
`; // /expanded, /group - }); - - field.innerHTML = html; -} - -// Enhancement 8: navigate to artist detail from wishlist -function _navigateToArtistFromWishlist(artistName) { - // Try to find the artist in the library DB by searching - navigateToPage('artists'); - setTimeout(() => { - const searchInput = document.querySelector('.artist-search-input, #artist-search'); - if (searchInput) { searchInput.value = artistName; searchInput.dispatchEvent(new Event('input')); } - }, 300); -} - -function _toggleAlbumTile(tileEl) { - const wasExpanded = tileEl.classList.contains('tile-expanded'); - // Collapse all tiles in this group - tileEl.closest('.wl-album-fan')?.querySelectorAll('.wl-album-tile.tile-expanded').forEach(t => t.classList.remove('tile-expanded')); - if (!wasExpanded) tileEl.classList.add('tile-expanded'); -} - -function _toggleOrbExpand(el) { - const g = el.closest('.wl-orb-group'); - if (!g) return; - const was = g.classList.contains('expanded'); - document.querySelectorAll('.wl-orb-group.expanded').forEach(o => o.classList.remove('expanded')); - if (!was) g.classList.add('expanded'); -} - -async function _removeWishlistAlbum(albumName) { - if (!await showConfirmDialog({ title: 'Remove Album', message: `Remove all tracks from "${albumName}"?`, confirmText: 'Remove', destructive: true })) return; - try { - const res = await fetch('/api/wishlist/remove-album', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album_name: albumName }) }); - const data = await res.json(); - if (data.success) { showToast(`Removed "${albumName}"`, 'success'); wishlistPageState.isInitialized = false; await initializeWishlistPage(); await updateWishlistCount(); } - else showToast(data.error || 'Failed', 'error'); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -async function _removeWishlistTrack(trackId) { - try { - const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) }); - const data = await res.json(); - if (data.success) { - showToast('Removed', 'success'); - await updateWishlistCount(); - // Re-render nebula to reflect removal - wishlistPageState.isInitialized = false; - await initializeWishlistPage(); - } - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -function _filterNebula() { - const q = (document.getElementById('wl-nebula-search')?.value || '').toLowerCase().trim(); - document.querySelectorAll('.wl-orb-group').forEach(g => { - const a = (g.dataset.artist || '').toLowerCase(); - const albums = [...g.querySelectorAll('.wl-satellite')].map(s => (s.dataset.album || '').toLowerCase()); - const match = !q || a.includes(q) || albums.some(al => al.includes(q)); - g.style.display = match ? '' : 'none'; - if (!match) g.classList.remove('expanded'); - }); -} - -async function _nebulaDownload() { - // Check if wishlist is already processing - try { - const statsResp = await fetch('/api/wishlist/stats'); - if (statsResp.ok) { - const stats = await statsResp.json(); - if (stats.is_auto_processing) { - // Navigate to downloads page so the user can see progress - navigateToPage('active-downloads'); - showToast('Wishlist is currently being auto-processed', 'info'); - return; - } - } - const procResp = await fetch('/api/active-processes'); - if (procResp.ok) { - const procData = await procResp.json(); - const wishlistBatch = (procData.active_processes || []).find(p => p.playlist_id === 'wishlist'); - if (wishlistBatch) { - // Show the existing download modal - WishlistModalState.clearUserClosed(); - const clientProcess = activeDownloadProcesses['wishlist']; - if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { - clientProcess.modalElement.style.display = 'flex'; - WishlistModalState.setVisible(); - } else { - await rehydrateModal(wishlistBatch, true); - } - return; - } - } - } catch (e) {} - - // No active process — show category choice - const choice = await _showNebulaDownloadChoice(); - if (choice) await openDownloadMissingWishlistModal(choice); -} - -function _showNebulaDownloadChoice() { - return new Promise((resolve) => { - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - overlay.style.display = 'flex'; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - - const albumCount = document.getElementById('wishlist-stat-albums')?.textContent || '0'; - const singleCount = document.getElementById('wishlist-stat-singles')?.textContent || '0'; - - overlay.innerHTML = ` -
-
- -
-

Download Wishlist

-

Choose which category to process

-
- - - -
-
- `; - - overlay.querySelector('#ndc-albums').onclick = () => { overlay.remove(); resolve('albums'); }; - overlay.querySelector('#ndc-singles').onclick = () => { overlay.remove(); resolve('singles'); }; - overlay.querySelector('#ndc-cancel').onclick = () => { overlay.remove(); resolve(null); }; - - document.addEventListener('keydown', function esc(e) { - if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); } - }); - - document.body.appendChild(overlay); - }); -} - -function _nebulaBack() { - const t = document.getElementById('wishlist-category-tracks'); - const n = document.getElementById('wishlist-nebula'); - if (t) t.style.display = 'none'; - if (n) n.style.display = ''; - window.selectedWishlistCategory = null; - wishlistPageState.isInitialized = false; - initializeWishlistPage(); -} - -// ── Live processing state for nebula ── -let _nebulaLivePollInterval = null; -let _nebulaLastTotal = null; - -function _startNebulaLivePolling(currentCycle, artistImageMap) { - _stopNebulaLivePolling(); - _nebulaLastTotal = null; - - _nebulaLivePollInterval = setInterval(async () => { - if (currentPage !== 'wishlist') { _stopNebulaLivePolling(); return; } - - try { - // Use wishlist stats which has is_auto_processing flag - const statsResp = await fetch('/api/wishlist/stats'); - if (!statsResp.ok) return; - const stats = await statsResp.json(); - const isProcessing = stats.is_auto_processing || false; - const newTotal = stats.total || 0; - - // Also check for manual wishlist download batches - let hasBatch = false; - try { - const procResp = await fetch('/api/active-processes'); - if (procResp.ok) { - const procData = await procResp.json(); - hasBatch = (procData.active_processes || []).some(p => p.playlist_id === 'wishlist'); - } - } catch (e) {} - - const active = isProcessing || hasBatch; - const nebulaField = document.getElementById('wl-nebula-field'); - if (!nebulaField) return; - - if (active) { - nebulaField.classList.add('nebula-processing'); - document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing')); - - // Tracks completed — re-render - if (_nebulaLastTotal !== null && newTotal < _nebulaLastTotal) { - const [albumRes, singleRes] = await Promise.all([ - fetch('/api/wishlist/tracks?category=albums').then(r => r.json()), - fetch('/api/wishlist/tracks?category=singles').then(r => r.json()), - ]); - _renderWishlistNebula(albumRes.tracks || [], singleRes.tracks || [], artistImageMap, currentCycle); - - const countEl = document.getElementById('wishlist-page-count'); - if (countEl) countEl.textContent = `${newTotal} track${newTotal !== 1 ? 's' : ''}`; - const sa = document.getElementById('wishlist-stat-albums'); - const ss = document.getElementById('wishlist-stat-singles'); - if (sa) sa.textContent = stats.albums || 0; - if (ss) ss.textContent = stats.singles || 0; - - // Re-add processing classes after re-render - document.getElementById('wl-nebula-field')?.classList.add('nebula-processing'); - document.querySelectorAll('.wl-orb-group').forEach(g => g.classList.add('orb-processing')); - } - _nebulaLastTotal = newTotal; - } else { - nebulaField.classList.remove('nebula-processing'); - document.querySelectorAll('.wl-orb-group.orb-processing').forEach(g => g.classList.remove('orb-processing')); - - if (_nebulaLastTotal !== null) { - _nebulaLastTotal = null; - wishlistPageState.isInitialized = false; - await initializeWishlistPage(); - await updateWishlistCount(); - } - } - } catch (e) {} - }, 5000); -} - -function _stopNebulaLivePolling() { - if (_nebulaLivePollInterval) { - clearInterval(_nebulaLivePollInterval); - _nebulaLivePollInterval = null; - } - _nebulaLastTotal = null; -} - -/** - * Sort the watchlist artist grid by the selected criteria. - */ -function sortWatchlistArtists(sortBy) { - const grid = document.getElementById('watchlist-artists-list'); - if (!grid) return; - const cards = Array.from(grid.querySelectorAll('.watchlist-artist-card')); - if (cards.length === 0) return; - - cards.sort((a, b) => { - switch (sortBy) { - case 'name-asc': - return (a.dataset.artistName || '').localeCompare(b.dataset.artistName || ''); - case 'name-desc': - return (b.dataset.artistName || '').localeCompare(a.dataset.artistName || ''); - case 'scan-oldest': { - const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0; - const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0; - return aTime - bTime; // oldest first (never scanned = 0 = top) - } - case 'scan-newest': { - const aTime = a.dataset.lastScan ? new Date(a.dataset.lastScan).getTime() : 0; - const bTime = b.dataset.lastScan ? new Date(b.dataset.lastScan).getTime() : 0; - return bTime - aTime; - } - case 'added-newest': { - const aTime = a.dataset.added ? new Date(a.dataset.added).getTime() : 0; - const bTime = b.dataset.added ? new Date(b.dataset.added).getTime() : 0; - return bTime - aTime; - } - default: - return 0; - } - }); - - // Re-append in sorted order (preserves event listeners) - cards.forEach(card => grid.appendChild(card)); -} - -/** - * Filter wishlist tracks by search query within the active track list. - */ -function filterWishlistTracks() { - const input = document.getElementById('wishlist-track-search-input'); - if (!input) return; - const query = input.value.toLowerCase().trim(); - const tracksList = document.getElementById('wishlist-tracks-list'); - if (!tracksList) return; - - // For albums view: filter album cards by album name or track names within - const albumCards = tracksList.querySelectorAll('.wishlist-album-card'); - if (albumCards.length > 0) { - albumCards.forEach(card => { - const albumHeader = card.querySelector('.wishlist-album-header'); - const albumName = (albumHeader?.querySelector('.wishlist-album-name')?.textContent || '').toLowerCase(); - const artistName = (albumHeader?.querySelector('.wishlist-album-artist')?.textContent || '').toLowerCase(); - const tracks = card.querySelectorAll('.wishlist-album-track'); - let albumHasMatch = !query || albumName.includes(query) || artistName.includes(query); - - // Also check individual track names - if (!albumHasMatch && tracks.length > 0) { - tracks.forEach(track => { - const trackName = (track.textContent || '').toLowerCase(); - if (trackName.includes(query)) albumHasMatch = true; - }); - } - - card.style.display = albumHasMatch ? '' : 'none'; - }); - return; - } - - // For singles view: filter individual track rows - const trackRows = tracksList.querySelectorAll('.playlist-track-item-with-image, .playlist-track-item'); - trackRows.forEach(row => { - const text = (row.textContent || '').toLowerCase(); - row.style.display = (!query || text.includes(query)) ? '' : 'none'; - }); -} - -/** - * Format a Date object as a relative time string (e.g. "2 hours ago") - */ -function _formatTimeAgo(date) { - const now = new Date(); - const diffMs = now - date; - const diffMins = Math.floor(diffMs / 60000); - if (diffMins < 1) return 'just now'; - if (diffMins < 60) return `${diffMins}m ago`; - const diffHours = Math.floor(diffMins / 60); - if (diffHours < 24) return `${diffHours}h ago`; - const diffDays = Math.floor(diffHours / 24); - if (diffDays === 1) return 'yesterday'; - if (diffDays < 7) return `${diffDays}d ago`; - return date.toLocaleDateString(); -} - -/** - * Show watchlist modal (legacy — kept for backward compatibility) - */ -async function showWatchlistModal() { - try { - // Check if watchlist has any artists - const countResponse = await fetch('/api/watchlist/count'); - const countData = await countResponse.json(); - - if (!countData.success) { - console.error('Error getting watchlist count:', countData.error); - return; - } - - if (countData.count === 0) { - // Show empty state message - alert('Your watchlist is empty!\n\nAdd artists to your watchlist from the Artists page to monitor them for new releases.'); - return; - } - - // Get watchlist artists - const artistsResponse = await fetch('/api/watchlist/artists'); - const artistsData = await artistsResponse.json(); - - if (!artistsData.success) { - console.error('Error getting watchlist artists:', artistsData.error); - return; - } - - // Create modal if it doesn't exist - let modal = document.getElementById('watchlist-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'watchlist-modal'; - modal.className = 'modal-overlay'; - document.body.appendChild(modal); - } - - // Get scan status and global config - const statusResponse = await fetch('/api/watchlist/scan/status'); - const statusData = await statusResponse.json(); - const scanStatus = statusData.success ? statusData.status : 'idle'; - - let globalOverrideActive = false; - try { - const globalConfigResponse = await fetch('/api/watchlist/global-config'); - const globalConfigData = await globalConfigResponse.json(); - globalOverrideActive = globalConfigData.success && globalConfigData.config.global_override_enabled; - } catch (e) { - console.debug('Could not fetch global config:', e); - } - - // Format countdown timer - const nextRunSeconds = countData.next_run_in_seconds || 0; - const countdownText = formatCountdownTime(nextRunSeconds); - - // Build modal content - modal.innerHTML = ` - - `; - - // Add event listeners for gear buttons - modal.querySelectorAll('.watchlist-card-gear').forEach(button => { - button.addEventListener('click', () => { - const artistId = button.getAttribute('data-artist-id'); - const artistName = button.getAttribute('data-artist-name'); - openWatchlistArtistConfigModal(artistId, artistName); - }); - }); - - // Add click handlers to artist cards (except for gear button or checkbox) - modal.querySelectorAll('.watchlist-artist-card').forEach(item => { - item.addEventListener('click', (e) => { - if (e.target.closest('.watchlist-card-gear') || e.target.closest('.watchlist-card-checkbox')) { - return; - } - - const artistId = item.getAttribute('data-artist-id'); - const artistName = item.querySelector('.watchlist-card-name').textContent; - - console.log(`🎵 Artist card clicked: ${artistName} (${artistId})`); - openWatchlistArtistDetailView(artistId, artistName); - }); - }); - - // Show modal - modal.style.display = 'flex'; - - // Start countdown timer update interval - startWatchlistCountdownTimer(nextRunSeconds); - - // Start polling for scan status if scanning - if (scanStatus === 'scanning') { - pollWatchlistScanStatus(); - } - - } catch (error) { - console.error('Error showing watchlist modal:', error); - } -} - -function startWatchlistCountdownTimer(initialSeconds) { - // Clear any existing interval - if (watchlistCountdownInterval) { - clearInterval(watchlistCountdownInterval); - } - - let remainingSeconds = initialSeconds; - - watchlistCountdownInterval = setInterval(async () => { - remainingSeconds--; - - if (remainingSeconds <= 0) { - // Timer expired, fetch fresh data - try { - const response = await fetch('/api/watchlist/count'); - const data = await response.json(); - remainingSeconds = data.next_run_in_seconds || 0; - - const timerElement = document.getElementById('watchlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; - } - } catch (error) { - console.debug('Error updating watchlist countdown:', error); - } - } else { - // Update the display - const timerElement = document.getElementById('watchlist-next-auto-timer'); - if (timerElement) { - const countdownText = formatCountdownTime(remainingSeconds); - timerElement.textContent = `Next Auto${countdownText ? ': ' + countdownText : ''}`; - } - } - }, 1000); // Update every second -} - -/** - * Close watchlist modal - */ -function closeWatchlistModal() { - // Stop countdown timer - if (watchlistCountdownInterval) { - clearInterval(watchlistCountdownInterval); - watchlistCountdownInterval = null; - } - - const modal = document.getElementById('watchlist-modal'); - if (modal) { - modal.style.display = 'none'; - } -} - -/** - * Populate the linked provider section in the watchlist config modal. - * Shows which Spotify/iTunes/Deezer artist is linked and allows changing it. - */ -function _populateLinkedProviderSection(artistId, artistName, spotifyId, itunesId, artistInfo, deezerId, discogsId) { - const section = document.getElementById('watchlist-linked-provider-section'); - const content = document.getElementById('watchlist-linked-provider-content'); - if (!section || !content) return; - - section.style.display = ''; - - const sources = [ - { key: 'spotify', label: 'Spotify', icon: '🟢', id: spotifyId || '', color: '#1db954' }, - { key: 'itunes', label: 'Apple Music', icon: '🔴', id: itunesId || '', color: '#fc3c44' }, - { key: 'deezer', label: 'Deezer', icon: '🟣', id: deezerId || '', color: '#a238ff' }, - { key: 'discogs', label: 'Discogs', icon: '🟤', id: discogsId || '', color: '#b08968' }, - ]; - - let html = '
'; - for (const src of sources) { - const matched = !!src.id; - const shortId = src.id ? (src.id.length > 16 ? src.id.substring(0, 14) + '...' : src.id) : ''; - html += ` -
- ${src.icon} - ${src.label} - ${matched - ? `${shortId}` - : 'Not matched' - } - - ${matched ? `` : ''} -
`; - } - html += '
'; - - // Per-source search panel (hidden, populated on Fix/Match click) - html += ``; - - content.innerHTML = html; -} - -/** - * Open per-source search panel for fixing a specific provider match. - */ -function _openSourceSearch(sourceKey, artistId, artistName) { - const panel = document.getElementById('wl-linked-search-panel'); - if (!panel) return; - const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs' }; - document.getElementById('wl-linked-search-title').textContent = `Search ${labels[sourceKey] || sourceKey}`; - const input = document.getElementById('wl-linked-search-input'); - input.value = artistName; - document.getElementById('wl-linked-search-results').innerHTML = ''; - panel.style.display = ''; - panel.dataset.source = sourceKey; - panel.dataset.artistId = artistId; - panel.dataset.artistName = artistName; - input.focus(); - input.select(); - - const doSearch = () => _searchSourceArtists(sourceKey, artistId); - document.getElementById('wl-linked-search-go').onclick = doSearch; - input.onkeydown = (e) => { if (e.key === 'Enter') doSearch(); }; -} - -async function _searchSourceArtists(sourceKey, watchlistArtistId) { - const input = document.getElementById('wl-linked-search-input'); - const container = document.getElementById('wl-linked-search-results'); - const query = input?.value?.trim(); - if (!query || !container) return; - - container.innerHTML = '
Searching...
'; - - try { - const response = await fetch('/api/library/search-service', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service: sourceKey, entity_type: 'artist', query }) - }); - const data = await response.json(); - if (!data.success) throw new Error(data.error); - - const results = data.results || []; - if (!results.length) { - container.innerHTML = '
No artists found
'; - return; - } - - let html = ''; - for (const r of results) { - html += `
- ${r.image ? `` : - `
🎵
`} -
-
${escapeHtml(r.name)}
-
${escapeHtml(r.extra || '')}
-
- -
`; - } - container.innerHTML = html; - - container.querySelectorAll('.watchlist-linked-search-result').forEach(el => { - el.querySelector('.watchlist-linked-select-btn').onclick = async (e) => { - e.stopPropagation(); - await _linkSourceArtist(sourceKey, watchlistArtistId, el.dataset.id, el.dataset.name); - }; - }); - } catch (err) { - console.error(`Error searching ${sourceKey}:`, err); - container.innerHTML = '
Search error
'; - } -} - -async function _linkSourceArtist(sourceKey, watchlistArtistId, newId, newName) { - try { - const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider_id: newId, provider: sourceKey }) - }); - const data = await response.json(); - if (!data.success) { - showToast(`Failed to link: ${data.error}`, 'error'); - return; - } - showToast(`Linked to "${newName}" on ${sourceKey}`, 'success'); - // Refresh the modal - const panel = document.getElementById('wl-linked-search-panel'); - const artistName = panel?.dataset?.artistName || newName; - closeWatchlistArtistConfigModal(); - setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300); - } catch (err) { - showToast('Failed to link artist', 'error'); - } -} - -async function _clearSourceMatch(sourceKey, watchlistArtistId, artistName) { - try { - const response = await fetch(`/api/watchlist/artist/${watchlistArtistId}/link-provider`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider_id: '', provider: sourceKey }) - }); - const data = await response.json(); - if (!data.success) { - showToast(`Failed to clear: ${data.error}`, 'error'); - return; - } - showToast(`Cleared ${sourceKey} match`, 'success'); - closeWatchlistArtistConfigModal(); - setTimeout(() => openWatchlistArtistConfigModal(watchlistArtistId, artistName), 300); - } catch (err) { - showToast('Failed to clear match', 'error'); - } -} - -/** - * Open watchlist artist configuration modal - * @param {string} artistId - Spotify artist ID - * @param {string} artistName - Artist name - */ -async function openWatchlistArtistConfigModal(artistId, artistName) { - try { - console.log(`🎨 Opening config modal for artist: ${artistName} (${artistId})`); - - // Fetch artist config and info - const response = await fetch(`/api/watchlist/artist/${artistId}/config`); - const data = await response.json(); - - if (!data.success) { - console.error('Error loading artist config:', data.error); - showToast(`Error loading artist configuration: ${data.error}`, 'error'); - return; - } - - const { config, artist, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id, watchlist_name } = data; - - // Populate linked provider section (use DB watchlist_name for mismatch comparison) - _populateLinkedProviderSection(artistId, watchlist_name || artistName, spotify_artist_id, itunes_artist_id, artist, deezer_artist_id, discogs_artist_id); - - // Check if global override is active - let globalOverrideActive = false; - try { - const globalResponse = await fetch('/api/watchlist/global-config'); - const globalData = await globalResponse.json(); - globalOverrideActive = globalData.success && globalData.config.global_override_enabled; - } catch (e) { - console.debug('Could not check global config:', e); - } - - // Generate hero section - const heroHTML = ` - ${artist.image_url ? ` - ${escapeHtml(artist.name)} - ` : ''} -
-

${escapeHtml(artist.name)}

-
-
- ${formatNumber(artist.followers)} - Followers -
-
- ${artist.popularity}/100 - Popularity -
-
- ${artist.genres && artist.genres.length > 0 ? ` -
- ${artist.genres.slice(0, 3).map(genre => - `${escapeHtml(genre)}` - ).join('')} -
- ` : ''} -
- `; - - // Populate hero section - const heroContainer = document.getElementById('watchlist-artist-config-hero'); - if (heroContainer) { - heroContainer.innerHTML = heroHTML; - } - - // Set checkbox states - document.getElementById('config-include-albums').checked = config.include_albums; - document.getElementById('config-include-eps').checked = config.include_eps; - document.getElementById('config-include-singles').checked = config.include_singles; - document.getElementById('config-include-live').checked = config.include_live || false; - document.getElementById('config-include-remixes').checked = config.include_remixes || false; - document.getElementById('config-include-acoustic').checked = config.include_acoustic || false; - document.getElementById('config-include-compilations').checked = config.include_compilations || false; - document.getElementById('config-include-instrumentals').checked = config.include_instrumentals || false; - document.getElementById('config-lookback-days').value = config.lookback_days != null ? String(config.lookback_days) : ''; - - // Populate metadata source selector - const sourceSelector = document.getElementById('config-metadata-source-selector'); - if (sourceSelector) { - const sources = [ - { key: 'spotify', label: 'Spotify', id: spotify_artist_id, color: '#1DB954' }, - { key: 'deezer', label: 'Deezer', id: deezer_artist_id, color: '#A238FF' }, - { key: 'itunes', label: 'Apple Music', id: itunes_artist_id, color: '#FC3C44' }, - { key: 'discogs', label: 'Discogs', id: discogs_artist_id, color: '#333' }, - ]; - const globalSource = data.global_metadata_source || 'deezer'; - const currentOverride = config.preferred_metadata_source; - const globalLabel = { spotify: 'Spotify', deezer: 'Deezer', itunes: 'Apple Music', discogs: 'Discogs' }[globalSource] || globalSource; - - let html = ``; - for (const src of sources) { - if (!src.id) continue; - const isActive = currentOverride === src.key; - html += ``; - } - sourceSelector.innerHTML = html; - sourceSelector.querySelectorAll('.config-msrc-btn').forEach(btn => { - btn.addEventListener('click', () => { - sourceSelector.querySelectorAll('.config-msrc-btn').forEach(b => { - b.classList.remove('active'); - b.style.borderColor = ''; - }); - btn.classList.add('active'); - const color = sources.find(s => s.key === btn.dataset.source)?.color; - if (color) btn.style.borderColor = color; - }); - }); - } - - // Show global override notice if active - const existingNotice = document.querySelector('.global-override-notice'); - if (existingNotice) existingNotice.remove(); - - if (globalOverrideActive) { - const notice = document.createElement('div'); - notice.className = 'global-override-notice watchlist-global-override-banner'; - notice.innerHTML = '⚠️Global override is active — these per-artist settings are currently ignored during scans.'; - const configBody = document.querySelector('.watchlist-artist-config-body'); - if (configBody) configBody.insertBefore(notice, configBody.firstChild); - } - - // Store artist ID for saving - const modal = document.getElementById('watchlist-artist-config-modal'); - if (modal) { - modal.setAttribute('data-artist-id', artistId); - } - - // Show modal - const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); - if (overlay) { - overlay.classList.remove('hidden'); - } - - // Add save button handler - const saveBtn = document.getElementById('save-artist-config-btn'); - if (saveBtn) { - // Remove old listeners - const newSaveBtn = saveBtn.cloneNode(true); - saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn); - - // Add new listener - newSaveBtn.addEventListener('click', () => saveWatchlistArtistConfig(artistId)); - } - - } catch (error) { - console.error('Error opening watchlist artist config modal:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -/** - * Close watchlist artist configuration modal - */ -function closeWatchlistArtistConfigModal() { - const overlay = document.getElementById('watchlist-artist-config-modal-overlay'); - if (overlay) { - overlay.classList.add('hidden'); - } - - // Clear hero content - const heroContainer = document.getElementById('watchlist-artist-config-hero'); - if (heroContainer) { - heroContainer.innerHTML = ''; - } - - // Clear linked provider section - const linkedContent = document.getElementById('watchlist-linked-provider-content'); - if (linkedContent) linkedContent.innerHTML = ''; - const linkedSection = document.getElementById('watchlist-linked-provider-section'); - if (linkedSection) linkedSection.style.display = 'none'; -} - -/** - * Open watchlist artist detail view (slides in from right) - */ -async function openWatchlistArtistDetailView(artistId, artistName) { - try { - const response = await fetch(`/api/watchlist/artist/${artistId}/config`); - const data = await response.json(); - - if (!data.success) { - showToast(`Error loading artist info: ${data.error}`, 'error'); - return; - } - - const { config, artist, recent_releases, spotify_artist_id, itunes_artist_id, deezer_artist_id, discogs_artist_id } = data; - - // Remove existing overlay if any - const existing = document.querySelector('.watchlist-artist-detail-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.className = 'watchlist-artist-detail-overlay'; - - // Build pills - const pills = []; - if (config.include_albums) pills.push('Albums'); - if (config.include_eps) pills.push('EPs'); - if (config.include_singles) pills.push('Singles'); - if (config.include_live) pills.push('Live'); - if (config.include_remixes) pills.push('Remixes'); - if (config.include_acoustic) pills.push('Acoustic'); - if (config.include_compilations) pills.push('Compilations'); - - // Build scan info - const scanTimeText = config.last_scan_timestamp ? formatRelativeScanTime(config.last_scan_timestamp) : 'Never scanned'; - const dateAddedText = config.date_added ? `Added ${new Date(config.date_added).toLocaleDateString()}` : ''; - - // Build metadata tags (style, mood, label) - const metaTags = []; - if (artist.style) metaTags.push(`${escapeHtml(artist.style)}`); - if (artist.mood) metaTags.push(`${escapeHtml(artist.mood)}`); - if (artist.label) metaTags.push(`${escapeHtml(artist.label)}`); - - overlay.innerHTML = ` - ${artist.banner_url ? ` -
- -
-
- ` : ''} - -
- - -
- ${artist.image_url ? `${escapeHtml(artist.name)}` : ''} -
-

${escapeHtml(artist.name)}

- ${artist.followers || artist.popularity ? ` -
- ${artist.followers ? ` -
- ${formatNumber(artist.followers)} - Followers -
` : ''} - ${artist.popularity ? ` -
- ${artist.popularity}/100 - Popularity -
` : ''} -
- ` : ''} - ${artist.genres && artist.genres.length > 0 ? ` -
- ${artist.genres.map(g => `${escapeHtml(g)}`).join('')} -
- ` : ''} -
-
- - ${artist.summary ? ` -
-
About
-

${escapeHtml(artist.summary)}

-
- ` : ''} - - ${metaTags.length > 0 ? ` -
-
Info
-
${metaTags.join('')}
-
- ` : ''} - - ${recent_releases && recent_releases.length > 0 ? ` -
-
Recent Releases
-
- ${recent_releases.map(r => ` -
- ${r.album_cover_url ? `` : ''} -
- ${escapeHtml(r.album_name)} - ${r.release_date}${r.track_count ? ` · ${r.track_count} tracks` : ''} -
-
- `).join('')} -
-
- ` : ''} - -
-
Watchlist
-
- ${scanTimeText} - ${dateAddedText ? `·${dateAddedText}` : ''} -
-
- ${pills.length > 0 ? pills.join('') : 'No release types enabled'} -
-
- -
- - - -
-
- `; - - // Wire up event listeners (avoids inline onclick escaping issues) - overlay.querySelector('.watchlist-detail-back-btn').addEventListener('click', () => { - closeWatchlistArtistDetailView(); - }); - - overlay.querySelector('.watchlist-detail-discog-action').addEventListener('click', () => { - // Use the ID matching the active metadata source - let discogId, source; - const activeSrc = (currentMusicSourceName || '').toLowerCase(); - if (activeSrc.includes('spotify') && spotify_artist_id) { - discogId = spotify_artist_id; source = 'spotify'; - } else if (activeSrc.includes('discogs') && discogs_artist_id) { - discogId = discogs_artist_id; source = 'discogs'; - } else if (activeSrc.includes('deezer') && deezer_artist_id) { - discogId = deezer_artist_id; source = 'deezer'; - } else if (itunes_artist_id) { - discogId = itunes_artist_id; source = 'itunes'; - } else { - discogId = spotify_artist_id || discogs_artist_id || deezer_artist_id || itunes_artist_id; - source = spotify_artist_id ? 'spotify' : discogs_artist_id ? 'discogs' : deezer_artist_id ? 'deezer' : 'itunes'; - } - if (discogId) { - // Close detail overlay and navigate to Artists page - closeWatchlistArtistDetailView(); - // Navigate to Artists page and load discography - navigateToPage('artists'); - setTimeout(() => { - selectArtistForDetail( - { id: discogId, name: artistName, image_url: artist.image_url || '' }, - { source: source } - ); - }, 200); - } - }); - - overlay.querySelector('.watchlist-detail-settings-action').addEventListener('click', () => { - // Remove overlay immediately so it doesn't block the config modal - const detailOverlay = document.querySelector('.watchlist-artist-detail-overlay'); - if (detailOverlay) detailOverlay.remove(); - openWatchlistArtistConfigModal(artistId, artistName); - }); - - overlay.querySelector('.watchlist-detail-remove-action').addEventListener('click', () => { - removeFromWatchlistModal(artistId, artistName); - }); - - // Append to body as a fixed overlay - document.body.appendChild(overlay); - // Trigger slide-in animation - requestAnimationFrame(() => overlay.classList.add('visible')); - - } catch (error) { - console.error('Error opening artist detail view:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -/** - * Close watchlist artist detail view (slides out) - */ -function closeWatchlistArtistDetailView() { - const overlay = document.querySelector('.watchlist-artist-detail-overlay'); - if (overlay) { - overlay.classList.remove('visible'); - overlay.addEventListener('transitionend', () => overlay.remove(), { once: true }); - } -} - -/** - * Open global watchlist settings modal - */ -async function openWatchlistGlobalSettingsModal() { - try { - const response = await fetch('/api/watchlist/global-config'); - const data = await response.json(); - - if (!data.success) { - showToast(`Error loading global settings: ${data.error}`, 'error'); - return; - } - - const config = data.config; - - // Populate checkboxes - document.getElementById('global-override-enabled').checked = config.global_override_enabled; - document.getElementById('global-include-albums').checked = config.include_albums; - document.getElementById('global-include-eps').checked = config.include_eps; - document.getElementById('global-include-singles').checked = config.include_singles; - document.getElementById('global-include-live').checked = config.include_live; - document.getElementById('global-include-remixes').checked = config.include_remixes; - document.getElementById('global-include-acoustic').checked = config.include_acoustic; - document.getElementById('global-include-compilations').checked = config.include_compilations; - document.getElementById('global-include-instrumentals').checked = config.include_instrumentals; - document.getElementById('global-exclude-terms').value = config.exclude_terms || ''; - - // Sync "Include Everything" checkbox - syncGlobalIncludeAllCheckbox(); - - // Update options visibility based on toggle state - toggleGlobalOverrideOptions(); - - // Update toggle label border - const toggleLabel = document.getElementById('global-override-toggle-label'); - if (toggleLabel) { - toggleLabel.style.border = config.global_override_enabled - ? '2px solid rgba(29, 185, 84, 0.5)' - : '2px solid rgba(255, 255, 255, 0.1)'; - } - - // Show modal - const overlay = document.getElementById('watchlist-global-config-modal-overlay'); - if (overlay) overlay.classList.remove('hidden'); - - } catch (error) { - console.error('Error opening global watchlist settings:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -/** - * Close global watchlist settings modal - */ -function closeWatchlistGlobalSettingsModal() { - const overlay = document.getElementById('watchlist-global-config-modal-overlay'); - if (overlay) overlay.classList.add('hidden'); -} - -/** - * Toggle global override options visibility - */ -function toggleGlobalOverrideOptions() { - const enabled = document.getElementById('global-override-enabled').checked; - const options = document.getElementById('global-override-options'); - if (options) { - options.style.opacity = enabled ? '1' : '0.4'; - options.style.pointerEvents = enabled ? 'auto' : 'none'; - } - - // Update toggle label border - const toggleLabel = document.getElementById('global-override-toggle-label'); - if (toggleLabel) { - toggleLabel.style.border = enabled - ? '2px solid rgba(29, 185, 84, 0.5)' - : '2px solid rgba(255, 255, 255, 0.1)'; - } -} - -/** - * Toggle all global include checkboxes - */ -function toggleGlobalIncludeAll() { - const checked = document.getElementById('global-include-all').checked; - ['global-include-albums', 'global-include-eps', 'global-include-singles', - 'global-include-live', 'global-include-remixes', 'global-include-acoustic', - 'global-include-compilations', 'global-include-instrumentals'].forEach(id => { - const el = document.getElementById(id); - if (el) el.checked = checked; - }); -} - -/** - * Sync the "Include Everything" checkbox based on individual checkbox states - */ -function syncGlobalIncludeAllCheckbox() { - const allIds = ['global-include-albums', 'global-include-eps', 'global-include-singles', - 'global-include-live', 'global-include-remixes', 'global-include-acoustic', - 'global-include-compilations', 'global-include-instrumentals']; - const allChecked = allIds.every(id => { - const el = document.getElementById(id); - return el && el.checked; - }); - const includeAllEl = document.getElementById('global-include-all'); - if (includeAllEl) includeAllEl.checked = allChecked; -} - -/** - * Save global watchlist configuration - */ -async function saveWatchlistGlobalConfig() { - try { - const globalOverrideEnabled = document.getElementById('global-override-enabled').checked; - const includeAlbums = document.getElementById('global-include-albums').checked; - const includeEps = document.getElementById('global-include-eps').checked; - const includeSingles = document.getElementById('global-include-singles').checked; - const includeLive = document.getElementById('global-include-live').checked; - const includeRemixes = document.getElementById('global-include-remixes').checked; - const includeAcoustic = document.getElementById('global-include-acoustic').checked; - const includeCompilations = document.getElementById('global-include-compilations').checked; - const includeInstrumentals = document.getElementById('global-include-instrumentals').checked; - const excludeTerms = (document.getElementById('global-exclude-terms').value || '').trim(); - - if (globalOverrideEnabled && !includeAlbums && !includeEps && !includeSingles) { - showToast('Please select at least one release type', 'error'); - return; - } - - const saveBtn = document.getElementById('save-global-config-btn'); - if (saveBtn) { - saveBtn.disabled = true; - saveBtn.textContent = 'Saving...'; - } - - const response = await fetch('/api/watchlist/global-config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - global_override_enabled: globalOverrideEnabled, - include_albums: includeAlbums, - include_eps: includeEps, - include_singles: includeSingles, - include_live: includeLive, - include_remixes: includeRemixes, - include_acoustic: includeAcoustic, - include_compilations: includeCompilations, - include_instrumentals: includeInstrumentals, - exclude_terms: excludeTerms, - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast('Global watchlist settings saved', 'success'); - closeWatchlistGlobalSettingsModal(); - - // Refresh the watchlist page to update the grid - if (currentPage === 'watchlist') { - watchlistPageState.isInitialized = false; - await initializeWatchlistPage(); - } - } else { - showToast(`Error: ${data.error}`, 'error'); - } - - } catch (error) { - console.error('Error saving global config:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - const saveBtn = document.getElementById('save-global-config-btn'); - if (saveBtn) { - saveBtn.disabled = false; - saveBtn.textContent = 'Save Global Settings'; - } - } -} - -/** - * Save watchlist artist configuration - * @param {string} artistId - Spotify artist ID - */ -async function saveWatchlistArtistConfig(artistId) { - try { - const includeAlbums = document.getElementById('config-include-albums').checked; - const includeEps = document.getElementById('config-include-eps').checked; - const includeSingles = document.getElementById('config-include-singles').checked; - const includeLive = document.getElementById('config-include-live').checked; - const includeRemixes = document.getElementById('config-include-remixes').checked; - const includeAcoustic = document.getElementById('config-include-acoustic').checked; - const includeCompilations = document.getElementById('config-include-compilations').checked; - const includeInstrumentals = document.getElementById('config-include-instrumentals').checked; - const lookbackDaysVal = document.getElementById('config-lookback-days').value; - const lookbackDays = lookbackDaysVal !== '' ? parseInt(lookbackDaysVal) : null; - const activeSourceBtn = document.querySelector('#config-metadata-source-selector .config-msrc-btn.active'); - const preferredMetadataSource = activeSourceBtn ? (activeSourceBtn.dataset.source || null) : null; - - // Validate at least one release type is selected - if (!includeAlbums && !includeEps && !includeSingles) { - showToast('Please select at least one release type', 'error'); - return; - } - - // Disable save button - const saveBtn = document.getElementById('save-artist-config-btn'); - if (saveBtn) { - saveBtn.disabled = true; - saveBtn.textContent = 'Saving...'; - } - - // Send update to backend - const response = await fetch(`/api/watchlist/artist/${artistId}/config`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - include_albums: includeAlbums, - include_eps: includeEps, - include_singles: includeSingles, - include_live: includeLive, - include_remixes: includeRemixes, - include_acoustic: includeAcoustic, - include_compilations: includeCompilations, - include_instrumentals: includeInstrumentals, - lookback_days: lookbackDays, - preferred_metadata_source: preferredMetadataSource, - }) - }); - - const data = await response.json(); - - if (data.success) { - showToast('Artist preferences saved successfully', 'success'); - closeWatchlistArtistConfigModal(); - - // Refresh watchlist page if we're on it - if (currentPage === 'watchlist') { - watchlistPageState.isInitialized = false; - await initializeWatchlistPage(); - } - } else { - showToast(`Error saving preferences: ${data.error}`, 'error'); - } - - } catch (error) { - console.error('Error saving watchlist artist config:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - // Re-enable save button - const saveBtn = document.getElementById('save-artist-config-btn'); - if (saveBtn) { - saveBtn.disabled = false; - saveBtn.textContent = 'Save Preferences'; - } - } -} - -/** - * Format large numbers with commas - * @param {number} num - Number to format - * @returns {string} Formatted number - */ -function formatNumber(num) { - if (!num) return '0'; - return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); -} - -/** - * Format last scan timestamp as relative time - */ -function formatRelativeScanTime(isoString) { - if (!isoString) return 'Never scanned'; - const diff = Date.now() - new Date(isoString).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 1) return 'Scanned just now'; - if (mins < 60) return `Scanned ${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `Scanned ${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 30) return `Scanned ${days}d ago`; - const months = Math.floor(days / 30); - return `Scanned ${months}mo ago`; -} - -/** - * Filter watchlist artists based on search input - */ -function filterWatchlistArtists() { - const searchInput = document.getElementById('watchlist-search-input'); - const artistsList = document.getElementById('watchlist-artists-list'); - - if (!searchInput || !artistsList) return; - - const searchTerm = searchInput.value.toLowerCase().trim(); - const artistItems = artistsList.querySelectorAll('.watchlist-artist-card'); - - artistItems.forEach(item => { - const artistName = item.getAttribute('data-artist-name'); - - if (!searchTerm || artistName.includes(searchTerm)) { - item.style.display = ''; - } else { - item.style.display = 'none'; - } - }); - - // Refresh batch bar in case visible selection changed - updateWatchlistBatchBar(); -} - -/** - * Start watchlist scan - */ -async function cancelWatchlistScan() { - try { - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - if (cancelBtn) { - cancelBtn.disabled = true; - cancelBtn.textContent = 'Cancelling...'; - } - - const response = await fetch('/api/watchlist/scan/cancel', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to cancel scan'); - } - - showToast('Cancel request sent — scan will stop after current artist', 'info'); - - } catch (error) { - console.error('Error cancelling watchlist scan:', error); - showToast(`Error cancelling scan: ${error.message}`, 'error'); - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - if (cancelBtn) { - cancelBtn.disabled = false; - cancelBtn.textContent = 'Cancel Scan'; - } - } -} - -async function startWatchlistScan() { - try { - const button = document.getElementById('scan-watchlist-btn'); - button.disabled = true; - button.textContent = 'Starting scan...'; - button.classList.add('btn-processing'); - - const response = await fetch('/api/watchlist/scan', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to start scan'); - } - - button.textContent = 'Scanning...'; - - // Show cancel button - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - if (cancelBtn) { - cancelBtn.style.display = ''; - cancelBtn.disabled = false; - cancelBtn.textContent = 'Cancel Scan'; - } - - // Show scan status - const statusDiv = document.getElementById('watchlist-scan-status'); - if (statusDiv) { - statusDiv.style.display = 'flex'; - } - - // Start polling for updates - pollWatchlistScanStatus(); - - } catch (error) { - console.error('Error starting watchlist scan:', error); - const button = document.getElementById('scan-watchlist-btn'); - button.disabled = false; - button.textContent = 'Scan for New Releases'; - button.classList.remove('btn-processing'); - alert(`Error starting scan: ${error.message}`); - } -} - -/** - * Poll watchlist scan status - */ -function handleWatchlistScanData(data) { - const button = document.getElementById('scan-watchlist-btn'); - const liveActivity = document.getElementById('watchlist-live-activity'); - - // Show/hide cancel button based on scan status - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - if (cancelBtn) { - cancelBtn.style.display = data.status === 'scanning' ? '' : 'none'; - } - - // Update live visual activity display - if (liveActivity && data.status === 'scanning') { - liveActivity.style.display = 'flex'; - - // Update artist image and name - const artistImg = document.getElementById('watchlist-artist-img'); - const artistName = document.getElementById('watchlist-artist-name'); - if (artistImg && data.current_artist_image_url) { - artistImg.src = data.current_artist_image_url; - artistImg.style.display = 'block'; - } - if (artistName) { - artistName.textContent = data.current_artist_name || 'Processing...'; - } - - // Update album image and name - const albumImg = document.getElementById('watchlist-album-img'); - const albumName = document.getElementById('watchlist-album-name'); - if (albumImg && data.current_album_image_url) { - albumImg.src = data.current_album_image_url; - albumImg.style.display = 'block'; - } else if (albumImg) { - albumImg.style.display = 'none'; - } - if (albumName) { - albumName.textContent = data.current_album || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); - } - - // Update current track - const trackName = document.getElementById('watchlist-track-name'); - if (trackName) { - trackName.textContent = data.current_track_name || (data.current_phase === 'fetching_discography' ? 'Fetching releases...' : 'Processing...'); - } - - // Update wishlist additions feed - const additionsFeed = document.getElementById('watchlist-additions-feed'); - if (additionsFeed) { - if (data.recent_wishlist_additions && data.recent_wishlist_additions.length > 0) { - additionsFeed.innerHTML = data.recent_wishlist_additions.map(item => ` -
- -
-
${item.track_name}
-
${item.artist_name}
-
-
- `).join(''); - } else { - additionsFeed.innerHTML = '
No tracks added yet...
'; - } - } - } else if (liveActivity && data.status !== 'scanning') { - liveActivity.style.display = 'none'; - } - - if (data.status === 'completed') { - if (button) { - button.disabled = false; - button.textContent = 'Scan for New Releases'; - button.classList.remove('btn-processing'); - } - - // Hide live activity - if (liveActivity) { - liveActivity.style.display = 'none'; - } - - // Show completion message in status div - const statusDiv = document.getElementById('watchlist-scan-status'); - if (statusDiv && data.summary) { - const newTracks = data.summary.new_tracks_found || 0; - const addedTracks = data.summary.tracks_added_to_wishlist || 0; - const totalArtists = data.summary.total_artists || 0; - const successfulScans = data.summary.successful_scans || 0; - - let completionMessage = `Scan completed: ${successfulScans}/${totalArtists} artists scanned`; - if (newTracks > 0) { - completionMessage += `, found ${newTracks} new track${newTracks !== 1 ? 's' : ''}`; - if (addedTracks > 0) { - completionMessage += `, added ${addedTracks} to wishlist`; - } - } else { - completionMessage += ', no new tracks found'; - } - - // Update the scan status display with completion message and summary - statusDiv.innerHTML = ` -
-
${completionMessage}
-
- Artists: ${totalArtists} - - New tracks: ${newTracks} - - Added to wishlist: ${addedTracks} -
-
- `; - } - - // Update watchlist count - updateWatchlistButtonCount(); - - console.log('Watchlist scan completed:', data.summary); - - } else if (data.status === 'cancelled') { - if (button) { - button.disabled = false; - button.textContent = 'Scan for New Releases'; - button.classList.remove('btn-processing'); - } - - // Hide cancel button - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - if (cancelBtn) { - cancelBtn.style.display = 'none'; - cancelBtn.disabled = false; - cancelBtn.textContent = 'Cancel Scan'; - } - - // Hide live activity - if (liveActivity) { - liveActivity.style.display = 'none'; - } - - // Show cancellation message - const statusDiv = document.getElementById('watchlist-scan-status'); - if (statusDiv && data.summary) { - const scanned = data.summary.total_artists || 0; - const newTracks = data.summary.new_tracks_found || 0; - const addedTracks = data.summary.tracks_added_to_wishlist || 0; - - statusDiv.innerHTML = ` -
-
Scan cancelled after ${scanned} artist${scanned !== 1 ? 's' : ''}
-
- Scanned: ${scanned} - - New tracks: ${newTracks} - - Added to wishlist: ${addedTracks} -
-
- `; - } - - // Update watchlist count - updateWatchlistButtonCount(); - - showToast('Watchlist scan cancelled', 'info'); - console.log('Watchlist scan cancelled:', data.summary); - - } else if (data.status === 'error') { - if (button) { - button.disabled = false; - button.textContent = 'Scan for New Releases'; - button.classList.remove('btn-processing'); - } - - // Hide cancel button - const cancelBtn = document.getElementById('cancel-watchlist-scan-btn'); - if (cancelBtn) { - cancelBtn.style.display = 'none'; - } - - // Hide live activity - if (liveActivity) { - liveActivity.style.display = 'none'; - } - - console.error('Watchlist scan error:', data.error); - } -} - -async function pollWatchlistScanStatus() { - if (socketConnected) return; // Phase 5: WS handles scan updates - try { - const response = await fetch('/api/watchlist/scan/status'); - const data = await response.json(); - - if (data.success) { - handleWatchlistScanData(data); - if (data.status === 'completed' || data.status === 'error' || data.status === 'cancelled') { - return; // Stop polling - } - } - - // Continue polling if still scanning - if (data.success && data.status === 'scanning') { - setTimeout(pollWatchlistScanStatus, 2000); // Poll every 2 seconds - } - - } catch (error) { - console.error('Error polling watchlist scan status:', error); - } -} - -/** - * Update similar artists for discovery feature - */ -async function updateSimilarArtists() { - try { - const button = document.getElementById('update-similar-artists-btn'); - const scanButton = document.getElementById('scan-watchlist-btn'); - - button.disabled = true; - button.textContent = 'Updating...'; - button.classList.add('btn-processing'); - if (scanButton) scanButton.disabled = true; - - const response = await fetch('/api/watchlist/update-similar-artists', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to update similar artists'); - } - - showToast('Updating similar artists in background...', 'success'); - - // Poll for completion - pollSimilarArtistsUpdate(); - - } catch (error) { - console.error('Error updating similar artists:', error); - const button = document.getElementById('update-similar-artists-btn'); - const scanButton = document.getElementById('scan-watchlist-btn'); - - button.disabled = false; - button.textContent = 'Update Similar Artists'; - button.classList.remove('btn-processing'); - if (scanButton) scanButton.disabled = false; - - showToast(`Error: ${error.message}`, 'error'); - } -} - -/** - * Poll similar artists update status - */ -async function pollSimilarArtistsUpdate() { - try { - const response = await fetch('/api/watchlist/similar-artists-status'); - const data = await response.json(); - - if (data.success) { - const button = document.getElementById('update-similar-artists-btn'); - const scanButton = document.getElementById('scan-watchlist-btn'); - - if (data.status === 'completed') { - if (button) { - button.disabled = false; - button.textContent = 'Update Similar Artists'; - button.classList.remove('btn-processing'); - } - if (scanButton) scanButton.disabled = false; - - showToast(`Updated similar artists for ${data.artists_processed || 0} artists!`, 'success'); - return; // Stop polling - - } else if (data.status === 'error') { - if (button) { - button.disabled = false; - button.textContent = 'Update Similar Artists'; - button.classList.remove('btn-processing'); - } - if (scanButton) scanButton.disabled = false; - - showToast('Error updating similar artists', 'error'); - return; // Stop polling - } else if (data.status === 'running') { - // Update button text with progress - if (button && data.current_artist) { - button.textContent = `Updating... (${data.artists_processed || 0}/${data.total_artists || 0})`; - } - } - } - - // Continue polling if still running - if (data.success && data.status === 'running') { - setTimeout(pollSimilarArtistsUpdate, 1000); // Poll every 1 second - } - - } catch (error) { - console.error('Error polling similar artists update:', error); - const button = document.getElementById('update-similar-artists-btn'); - const scanButton = document.getElementById('scan-watchlist-btn'); - - if (button) { - button.disabled = false; - button.textContent = 'Update Similar Artists'; - button.classList.remove('btn-processing'); - } - if (scanButton) scanButton.disabled = false; - } -} - -/** - * Remove artist from watchlist via modal - */ -async function removeFromWatchlistModal(artistId, artistName) { - try { - const response = await fetch('/api/watchlist/remove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to remove from watchlist'); - } - - console.log(`❌ Removed ${artistName} from watchlist`); - - // Close detail view if open - closeWatchlistArtistDetailView(); - - // Refresh the watchlist page - watchlistPageState.isInitialized = false; - await initializeWatchlistPage(); - - // Update button count - updateWatchlistButtonCount(); - - // Update any visible artist cards - updateArtistCardWatchlistStatus(); - - } catch (error) { - console.error('Error removing from watchlist:', error); - alert(`Error removing ${artistName} from watchlist: ${error.message}`); - } -} - - -/** - * Get visible checked checkboxes (not hidden by search filter) - */ -function getVisibleCheckedWatchlist() { - return Array.from(document.querySelectorAll('.watchlist-select-cb:checked')).filter(cb => { - const item = cb.closest('.watchlist-artist-card'); - return item && item.style.display !== 'none'; - }); -} - -/** - * Update the batch action bar based on checkbox selection - */ -function updateWatchlistBatchBar() { - const checked = getVisibleCheckedWatchlist(); - const countEl = document.getElementById('watchlist-batch-count'); - const removeBtn = document.getElementById('watchlist-batch-remove-btn'); - const selectAllCb = document.getElementById('watchlist-select-all-cb'); - - if (checked.length > 0) { - countEl.textContent = `${checked.length} selected`; - removeBtn.style.display = ''; - } else { - countEl.textContent = ''; - removeBtn.style.display = 'none'; - } - - // Update select-all checkbox state - if (selectAllCb) { - const visible = Array.from(document.querySelectorAll('.watchlist-select-cb')).filter(cb => { - const card = cb.closest('.watchlist-artist-card'); - return card && card.style.display !== 'none'; - }); - selectAllCb.checked = visible.length > 0 && checked.length === visible.length; - selectAllCb.indeterminate = checked.length > 0 && checked.length < visible.length; - } -} - -function toggleWatchlistSelectAll(checked) { - const checkboxes = document.querySelectorAll('.watchlist-select-cb'); - checkboxes.forEach(cb => { - const card = cb.closest('.watchlist-artist-card'); - if (card && card.style.display !== 'none') { - cb.checked = checked; - } - }); - updateWatchlistBatchBar(); -} - -/** - * Batch remove selected artists from watchlist - */ -async function batchRemoveFromWatchlist() { - const checked = getVisibleCheckedWatchlist(); - if (checked.length === 0) return; - - const count = checked.length; - if (!await showConfirmDialog({ title: 'Remove Artists', message: `Remove ${count} artist${count !== 1 ? 's' : ''} from your watchlist?`, confirmText: 'Remove', destructive: true })) return; - - const artistIds = checked.map(cb => cb.getAttribute('data-artist-id')); - - try { - const response = await fetch('/api/watchlist/remove-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_ids: artistIds }) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to remove artists'); - } - - console.log(`❌ Batch removed ${data.removed} artists from watchlist`); - - // Refresh the watchlist page - watchlistPageState.isInitialized = false; - await initializeWatchlistPage(); - - // Update button count - updateWatchlistButtonCount(); - - // Update any visible artist cards - updateArtistCardWatchlistStatus(); - - } catch (error) { - console.error('Error batch removing from watchlist:', error); - alert(`Error removing artists: ${error.message}`); - } -} - -// --- Metadata Updater Functions --- - -// Global state for metadata update polling -let metadataUpdatePolling = false; -let metadataUpdateInterval = null; - -/** - * Handle metadata update button click - */ -async function handleMetadataUpdateButtonClick() { - const button = document.getElementById('metadata-update-button'); - const currentAction = button.textContent; - - if (currentAction === 'Begin Update') { - // Get refresh interval from dropdown - const refreshSelect = document.getElementById('metadata-refresh-interval'); - const refreshIntervalDays = refreshSelect.value !== undefined ? parseInt(refreshSelect.value) : 30; - - try { - button.disabled = true; - button.textContent = 'Starting...'; - - const response = await fetch('/api/metadata/start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ refresh_interval_days: refreshIntervalDays }) - }); - - const data = await response.json(); - if (!data.success) { - throw new Error(data.error || 'Failed to start metadata update'); - } - - showToast('Metadata update started!', 'success'); - - // Start polling for status updates - startMetadataUpdatePolling(); - - } catch (error) { - console.error('Error starting metadata update:', error); - button.disabled = false; - button.textContent = 'Begin Update'; - showToast(`Error: ${error.message}`, 'error'); - } - } else { - // Stop metadata update - try { - button.disabled = true; - button.textContent = 'Stopping...'; - - const response = await fetch('/api/metadata/stop', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - if (!response.ok) { - throw new Error('Failed to stop metadata update'); - } - - } catch (error) { - console.error('Error stopping metadata update:', error); - button.disabled = false; - button.textContent = 'Stop Update'; - } - } -} - -/** - * Start polling for metadata update status - */ -function startMetadataUpdatePolling() { - if (metadataUpdatePolling) return; // Already polling - - metadataUpdatePolling = true; - metadataUpdateInterval = setInterval(checkMetadataUpdateStatus, 1000); // Poll every second - - // Also check immediately - checkMetadataUpdateStatus(); -} - -/** - * Stop polling for metadata update status - */ -function stopMetadataUpdatePolling() { - metadataUpdatePolling = false; - if (metadataUpdateInterval) { - clearInterval(metadataUpdateInterval); - metadataUpdateInterval = null; - } -} - -/** - * Check current metadata update status and update UI - */ -async function checkMetadataUpdateStatus() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/metadata/status'); - const data = await response.json(); - - if (data.success && data.status) { - updateMetadataProgressUI(data.status); - - // Stop polling if completed or error - if (data.status.status === 'completed' || data.status.status === 'error') { - stopMetadataUpdatePolling(); - } - } - - } catch (error) { - console.warn('Could not fetch metadata update status:', error); - } -} - -function updateMetadataStatusFromData(data) { - if (!data.success || !data.status) return; - const prev = _lastToolStatus['metadata']; - _lastToolStatus['metadata'] = data.status.status; - if (prev !== undefined && data.status.status === prev && data.status.status !== 'running' && data.status.status !== 'stopping') return; - updateMetadataProgressUI(data.status); - if (data.status.status === 'completed' || data.status.status === 'error') { - stopMetadataUpdatePolling(); - } -} - -/** - * Update metadata progress UI elements - */ -function updateMetadataProgressUI(status) { - const button = document.getElementById('metadata-update-button'); - const phaseLabel = document.getElementById('metadata-phase-label'); - const progressLabel = document.getElementById('metadata-progress-label'); - const progressBar = document.getElementById('metadata-progress-bar'); - const refreshSelect = document.getElementById('metadata-refresh-interval'); - - if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return; - - if (status.status === 'running') { - button.textContent = 'Stop Update'; - button.disabled = false; - refreshSelect.disabled = true; - - // Update current artist display - const currentArtist = status.current_artist || 'Processing...'; - phaseLabel.textContent = `Current Artist: ${currentArtist}`; - - // Update progress - const processed = status.processed || 0; - const total = status.total || 0; - const percentage = status.percentage || 0; - - progressLabel.textContent = `${processed} / ${total} artists (${percentage.toFixed(1)}%)`; - progressBar.style.width = `${percentage}%`; - - } else if (status.status === 'stopping') { - button.textContent = 'Stopping...'; - button.disabled = true; - phaseLabel.textContent = 'Current Artist: Stopping...'; - - } else if (status.status === 'completed') { - button.textContent = 'Begin Update'; - button.disabled = false; - refreshSelect.disabled = false; - - phaseLabel.textContent = 'Current Artist: Completed'; - - const processed = status.processed || 0; - const successful = status.successful || 0; - const failed = status.failed || 0; - - progressLabel.textContent = `Completed: ${processed} processed, ${successful} successful, ${failed} failed`; - progressBar.style.width = '100%'; - - showToast(`Metadata update completed: ${successful} artists updated, ${failed} failed`, 'success'); - - } else if (status.status === 'error') { - button.textContent = 'Begin Update'; - button.disabled = false; - refreshSelect.disabled = false; - - phaseLabel.textContent = 'Current Artist: Error occurred'; - progressLabel.textContent = status.error || 'Unknown error'; - progressBar.style.width = '0%'; - - } else { - // Idle state - button.textContent = 'Begin Update'; - button.disabled = false; - refreshSelect.disabled = false; - - phaseLabel.textContent = 'Current Artist: Not running'; - progressLabel.textContent = '0 / 0 artists (0.0%)'; - progressBar.style.width = '0%'; - } -} - -/** - * Check active media server and hide metadata updater if not Plex - */ -async function checkAndHideMetadataUpdaterForNonPlex() { - try { - const response = await fetch('/api/active-media-server'); - const data = await response.json(); - - if (data.success) { - const metadataCard = document.getElementById('metadata-updater-card'); - if (metadataCard) { - // Show metadata updater only for Plex and Jellyfin - if (data.active_server === 'plex' || data.active_server === 'jellyfin') { - metadataCard.style.display = 'flex'; - console.log(`Metadata updater shown: ${data.active_server} is active server`); - - // Update the header text to reflect the current server - const headerElement = metadataCard.querySelector('.card-header h3'); - if (headerElement) { - const serverDisplayName = data.active_server.charAt(0).toUpperCase() + data.active_server.slice(1); - headerElement.textContent = `${serverDisplayName} Metadata Updater`; - } - - // Update the description based on the server type - const descElement = metadataCard.querySelector('.metadata-updater-description'); - if (descElement) { - if (data.active_server === 'jellyfin') { - descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Jellyfin server for artists without photos.'; - } else { - descElement.textContent = 'Download and upload high-quality artist images from Spotify to your Plex server for artists without photos.'; - } - } - } else { - // Hide metadata updater for Navidrome - metadataCard.style.display = 'none'; - console.log(`Metadata updater hidden: ${data.active_server} does not support image uploads`); - } - } - } - } catch (error) { - console.warn('Could not check active media server for metadata updater visibility:', error); - } -} - -async function checkAndShowMediaScanForPlex() { - /** - * Show media scan tool only for Plex (Jellyfin/Navidrome auto-scan) - */ - try { - const response = await fetch('/api/active-media-server'); - const data = await response.json(); - - if (data.success) { - const mediaScanCard = document.getElementById('media-scan-card'); - if (mediaScanCard) { - // Show media scan tool only for Plex - if (data.active_server === 'plex') { - mediaScanCard.style.display = 'flex'; - console.log('Media scan tool shown: Plex is active server'); - } else { - // Hide for Jellyfin/Navidrome (they auto-scan) - mediaScanCard.style.display = 'none'; - console.log(`Media scan tool hidden: ${data.active_server} auto-scans`); - } - } - } - } catch (error) { - console.warn('Could not check active media server for media scan visibility:', error); - } -} - -async function handleMediaScanButtonClick() { - /** - * Trigger a manual Plex media library scan - */ - const button = document.getElementById('media-scan-button'); - const phaseLabel = document.getElementById('media-scan-phase-label'); - const progressBar = document.getElementById('media-scan-progress-bar'); - const progressLabel = document.getElementById('media-scan-progress-label'); - const statusValue = document.getElementById('media-scan-status'); - - if (!button) return; - - try { - // Disable button and update UI - button.disabled = true; - phaseLabel.textContent = 'Requesting scan...'; - progressBar.style.width = '30%'; - progressLabel.textContent = 'Sending scan request to Plex'; - statusValue.textContent = 'Scanning...'; - statusValue.style.color = 'rgb(var(--accent-rgb))'; - - // Request scan (database update handled by system automation) - const response = await fetch('/api/scan/request', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - reason: 'Manual scan triggered from dashboard' - }) - }); - - const result = await response.json(); - - if (result.success) { - // Get delay from API response (graceful fallback to 60 if not provided) - const delaySeconds = (result.scan_info && result.scan_info.delay_seconds) || 60; - let remainingSeconds = delaySeconds; - let countdownInterval = null; - let pollInterval = null; - - // Update last scan time - const lastTimeEl = document.getElementById('media-scan-last-time'); - if (lastTimeEl) { - const now = new Date(); - lastTimeEl.textContent = now.toLocaleTimeString(); - } - - // Start countdown timer (visual feedback during delay) - phaseLabel.textContent = 'Scan scheduled...'; - progressBar.style.width = '0%'; - - countdownInterval = setInterval(() => { - remainingSeconds--; - - // Update progress bar (0% -> 100% over delay period) - const progress = ((delaySeconds - remainingSeconds) / delaySeconds) * 100; - progressBar.style.width = `${progress}%`; - - // Update progress label with countdown - if (remainingSeconds > 0) { - progressLabel.textContent = `Starting scan in ${remainingSeconds}s...`; - } else { - progressLabel.textContent = 'Scan starting now...'; - } - - // When countdown reaches 0, start polling - if (remainingSeconds <= 0) { - clearInterval(countdownInterval); - - // Transition to scanning phase - phaseLabel.textContent = 'Scan in progress...'; - progressBar.style.width = '100%'; - progressLabel.textContent = 'Media server is scanning library...'; - showToast('📡 Media scan started', 'success', 3000); - - // Start polling for scan completion (5 minutes = 150 polls × 2s) - let pollCount = 0; - const maxPolls = 150; // 5 minutes - - pollInterval = setInterval(async () => { - if (socketConnected) return; // Phase 5: WS handles scan status - pollCount++; - - if (pollCount > maxPolls) { - // Polling timeout after 5 minutes - clearInterval(pollInterval); - button.disabled = false; - phaseLabel.textContent = 'Scan completed'; - progressBar.style.width = '0%'; - progressLabel.textContent = 'Ready for next scan'; - statusValue.textContent = 'Idle'; - statusValue.style.color = '#b3b3b3'; - showToast('✅ Media scan completed', 'success', 3000); - return; - } - - try { - const statusResponse = await fetch('/api/scan/status'); - const statusData = await statusResponse.json(); - - if (statusData.success && statusData.status) { - const status = statusData.status; - - // Update status display - if (status.is_scanning) { - phaseLabel.textContent = 'Media server scanning...'; - progressLabel.textContent = status.progress_message || 'Scan in progress'; - } else if (status.status === 'idle') { - // Scan completed - clearInterval(pollInterval); - button.disabled = false; - phaseLabel.textContent = 'Scan completed successfully'; - progressBar.style.width = '0%'; - progressLabel.textContent = 'Ready for next scan'; - statusValue.textContent = 'Idle'; - statusValue.style.color = '#b3b3b3'; - showToast('✅ Media scan completed', 'success', 3000); - } - } - } catch (pollError) { - console.debug('Scan status poll error:', pollError); - } - }, 2000); // Poll every 2 seconds - } - }, 1000); // Update countdown every second - - } else { - // Error occurred - showToast(`❌ Scan request failed: ${result.error}`, 'error', 5000); - button.disabled = false; - phaseLabel.textContent = 'Scan failed'; - progressBar.style.width = '0%'; - progressLabel.textContent = result.error || 'Unknown error'; - statusValue.textContent = 'Error'; - statusValue.style.color = '#f44336'; - } - - } catch (error) { - console.error('Error requesting media scan:', error); - showToast('❌ Failed to request media scan', 'error', 3000); - button.disabled = false; - phaseLabel.textContent = 'Error'; - progressBar.style.width = '0%'; - progressLabel.textContent = error.message; - statusValue.textContent = 'Error'; - statusValue.style.color = '#f44336'; - } -} - -/** - * Check for ongoing metadata update and restore state on page load - */ -async function checkAndRestoreMetadataUpdateState() { - try { - const response = await fetch('/api/metadata/status'); - const data = await response.json(); - - if (data.success && data.status) { - const status = data.status; - - // If metadata update is running, restore the UI state and start polling - if (status.status === 'running') { - console.log('Found ongoing metadata update, restoring state...'); - updateMetadataProgressUI(status); - startMetadataUpdatePolling(); - } else if (status.status === 'completed' || status.status === 'error') { - // Show final state but don't start polling - updateMetadataProgressUI(status); - } - } - } catch (error) { - console.warn('Could not check metadata update state on page load:', error); - } -} - -// --- Live Log Viewer Functions --- - -// Global state for log polling -let logPolling = false; -let logInterval = null; -let lastLogCount = 0; - -/** - * Initialize the live log viewer for sync page - */ -function initializeLiveLogViewer() { - const logArea = document.getElementById('sync-log-area'); - if (!logArea) return; - - // Set initial content - logArea.value = 'Loading activity feed...'; - - // Start log polling - startLogPolling(); - - // Initial load - loadLogs(); -} - -/** - * Start polling for logs - */ -function startLogPolling() { - if (logPolling) return; // Already polling - - logPolling = true; - logInterval = setInterval(loadLogs, 3000); // Poll every 3 seconds - console.log('📝 Started activity feed polling for sync page'); -} - -/** - * Stop polling for logs - */ -function stopLogPolling() { - logPolling = false; - if (logInterval) { - clearInterval(logInterval); - logInterval = null; - console.log('📝 Stopped log polling'); - } -} - -/** - * Load and display activity feed as logs - */ -async function loadLogs() { - if (socketConnected) return; // WebSocket handles this - try { - const response = await fetch('/api/logs'); - const data = await response.json(); - updateLogsFromData(data); - } catch (error) { - console.warn('Could not load activity logs for sync page:', error); - const logArea = document.getElementById('sync-log-area'); - if (logArea && (logArea.value === 'Loading logs...' || logArea.value === '')) { - logArea.value = 'Error loading activity feed. Check console for details.'; - } - } -} - -function updateLogsFromData(data) { - if (!data.logs || !Array.isArray(data.logs)) return; - const logArea = document.getElementById('sync-log-area'); - if (!logArea) return; - - const logText = data.logs.join('\n'); - - // Store current scroll state - const wasAtTop = logArea.scrollTop <= 10; - const wasUserScrolled = logArea.scrollTop < logArea.scrollHeight - logArea.clientHeight - 10; - - // Update content only if it has changed - if (logArea.value !== logText) { - logArea.value = logText; - - // Smart scrolling: stay at top for new entries, preserve user position if scrolled - if (wasAtTop || !wasUserScrolled) { - logArea.scrollTop = 0; // Stay at top since newest entries are now at top - } - } -} - -/** - * Stop log polling when leaving sync page - */ -function cleanupSyncPageLogs() { - stopLogPolling(); -} - -// --- Global Cleanup on Page Unload --- -// Note: Automatic wishlist processing now runs server-side and continues even when browser is closed -// =============================== -// LIBRARY PAGE FUNCTIONALITY -// =============================== - -// Library page state -const libraryPageState = { - isInitialized: false, - currentSearch: "", - currentLetter: "all", - currentPage: 1, - limit: 75, - debounceTimer: null, - watchlistFilter: "all", - sourceFilter: "" -}; - -function initializeLibraryPage() { - console.log("🔧 Initializing Library page..."); - - try { - // Initialize search functionality - initializeLibrarySearch(); - - // Initialize watchlist filter - initializeWatchlistFilter(); - - // Initialize metadata source filter - initializeSourceFilter(); - - // Initialize alphabet selector - initializeAlphabetSelector(); - - // Initialize pagination - initializeLibraryPagination(); - - // Load initial data - loadLibraryArtists(); - - // Show download bubbles if any exist - showLibraryDownloadsSection(); - - libraryPageState.isInitialized = true; - console.log("✅ Library page initialized successfully"); - - } catch (error) { - console.error("❌ Error initializing Library page:", error); - showToast("Failed to initialize Library page", "error"); - } -} - -function initializeLibrarySearch() { - const searchInput = document.getElementById("library-search-input"); - if (!searchInput) return; - - searchInput.addEventListener("input", (e) => { - const query = e.target.value.trim(); - - // Clear existing debounce timer - if (libraryPageState.debounceTimer) { - clearTimeout(libraryPageState.debounceTimer); - } - - // Debounce search requests - libraryPageState.debounceTimer = setTimeout(() => { - libraryPageState.currentSearch = query; - libraryPageState.currentPage = 1; // Reset to first page - loadLibraryArtists(); - }, 300); - }); - - // Clear search on Escape key - searchInput.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - searchInput.value = ""; - libraryPageState.currentSearch = ""; - libraryPageState.currentPage = 1; - loadLibraryArtists(); - } - }); -} - -function initializeWatchlistFilter() { - const filterButtons = document.querySelectorAll(".watchlist-filter-btn"); - const watchAllBtn = document.getElementById("library-watchlist-all-btn"); - - filterButtons.forEach(button => { - button.addEventListener("click", () => { - const filter = button.getAttribute("data-filter"); - - // Update active state - filterButtons.forEach(btn => btn.classList.remove("active")); - button.classList.add("active"); - - // Show/hide "Watch All Unwatched" button - if (watchAllBtn) { - if (filter === "unwatched") { - watchAllBtn.classList.remove("hidden"); - } else { - watchAllBtn.classList.add("hidden"); - } - } - - // Update state and reload - libraryPageState.watchlistFilter = filter; - libraryPageState.currentPage = 1; - loadLibraryArtists(); - }); - }); -} - -function initializeSourceFilter() { - const select = document.getElementById('library-source-filter'); - if (!select) return; - select.addEventListener('change', () => { - libraryPageState.sourceFilter = select.value; - libraryPageState.currentPage = 1; - loadLibraryArtists(); - }); -} - -function initializeAlphabetSelector() { - const alphabetButtons = document.querySelectorAll(".alphabet-btn"); - - alphabetButtons.forEach(button => { - button.addEventListener("click", () => { - const letter = button.getAttribute("data-letter"); - - // Update active state - alphabetButtons.forEach(btn => btn.classList.remove("active")); - button.classList.add("active"); - - // Update state and load data - libraryPageState.currentLetter = letter; - libraryPageState.currentPage = 1; // Reset to first page - loadLibraryArtists(); - }); - }); -} - -function initializeLibraryPagination() { - const prevBtn = document.getElementById("prev-page-btn"); - const nextBtn = document.getElementById("next-page-btn"); - - if (prevBtn) { - prevBtn.addEventListener("click", () => { - if (libraryPageState.currentPage > 1) { - libraryPageState.currentPage--; - loadLibraryArtists(); - } - }); - } - - if (nextBtn) { - nextBtn.addEventListener("click", () => { - libraryPageState.currentPage++; - loadLibraryArtists(); - }); - } -} - -async function loadLibraryArtists() { - try { - // Show loading state - showLibraryLoading(true); - - // Build query parameters - const params = new URLSearchParams({ - search: libraryPageState.currentSearch, - letter: libraryPageState.currentLetter, - page: libraryPageState.currentPage, - limit: libraryPageState.limit, - watchlist: libraryPageState.watchlistFilter - }); - if (libraryPageState.sourceFilter) params.set('source_filter', libraryPageState.sourceFilter); - - // Fetch artists from API - const response = await fetch(`/api/library/artists?${params}`); - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || "Failed to load artists"); - } - - // Update UI with artists - displayLibraryArtists(data.artists); - updateLibraryPagination(data.pagination); - updateLibraryStats(data.pagination.total_count); - - // Hide loading state - showLibraryLoading(false); - - // Show empty state if no artists - if (data.artists.length === 0) { - showLibraryEmpty(true); - } else { - showLibraryEmpty(false); - } - - } catch (error) { - console.error("❌ Error loading library artists:", error); - showToast("Failed to load artists", "error"); - showLibraryLoading(false); - showLibraryEmpty(true); - } -} - -function displayLibraryArtists(artists) { - const grid = document.getElementById("library-artists-grid"); - if (!grid) return; - - // Build all cards as HTML string for single DOM write (much faster than createElement loop) - grid.innerHTML = artists.map((artist, i) => { - try { return buildLibraryArtistCardHTML(artist, i); } - catch (e) { console.error('Failed to render artist card:', artist.name, e); return ''; } - }).join(''); - - // Attach click handlers via event delegation (single listener vs 75+ individual) - grid.onclick = (e) => { - // Ignore clicks on badge icons (they open external links / toggle watchlist) - const badge = e.target.closest('.source-card-icon'); - if (badge) { - e.stopPropagation(); - const url = badge.dataset.url; - if (url) { window.open(url, '_blank'); return; } - // Watchlist toggle - if (badge.classList.contains('watch-card-icon') && badge.dataset.unwatched) { - const card = badge.closest('.library-artist-card'); - if (card) { - const artistId = card.dataset.artistId; - const artistName = card.dataset.artistName; - const artist = artists.find(a => String(a.id) === artistId); - if (artist) toggleLibraryCardWatchlist(badge, artist); - } - } - return; - } - const card = e.target.closest('.library-artist-card'); - if (card) { - navigateToArtistDetail(card.dataset.artistId, card.dataset.artistName); - } - }; -} - -function buildLibraryArtistCardHTML(artist, index) { - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); - const delay = Math.min(index * 20, 600); // Cap at 600ms so last cards don't wait too long - - // Build badge icons - const badges = []; - if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify', url: `https://open.spotify.com/artist/${artist.spotify_artist_id}` }); - if (artist.musicbrainz_id) badges.push({ logo: MUSICBRAINZ_LOGO_URL, fb: 'MB', title: 'MusicBrainz', url: `https://musicbrainz.org/artist/${artist.musicbrainz_id}` }); - if (artist.deezer_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer', url: `https://www.deezer.com/artist/${artist.deezer_id}` }); - if (artist.audiodb_id) { - const slug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : ''; - badges.push({ logo: typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', fb: 'ADB', title: 'AudioDB', url: `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${slug}` }); - } - if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music', url: `https://music.apple.com/artist/${artist.itunes_artist_id}` }); - if (artist.lastfm_url) badges.push({ logo: LASTFM_LOGO_URL, fb: 'LFM', title: 'Last.fm', url: artist.lastfm_url }); - if (artist.genius_url) badges.push({ logo: GENIUS_LOGO_URL, fb: 'GEN', title: 'Genius', url: artist.genius_url }); - if (artist.tidal_id) badges.push({ logo: TIDAL_LOGO_URL, fb: 'TD', title: 'Tidal', url: `https://tidal.com/browse/artist/${artist.tidal_id}` }); - if (artist.qobuz_id) badges.push({ logo: QOBUZ_LOGO_URL, fb: 'Qz', title: 'Qobuz', url: `https://www.qobuz.com/artist/${artist.qobuz_id}` }); - if (artist.discogs_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs', url: `https://www.discogs.com/artist/${artist.discogs_id}` }); - if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push({ logo: '/static/trans2.png', fb: 'SS', title: `SoulID: ${artist.soul_id}`, url: null }); - - // Watchlist badge - const hasActiveSourceId = currentMusicSourceName === 'iTunes' - ? (artist.itunes_artist_id || artist.spotify_artist_id) - : (artist.spotify_artist_id || artist.itunes_artist_id); - let watchBadgeHTML = ''; - if (artist.is_watched) { - watchBadgeHTML = `
👁️Watching
`; - } else if (hasActiveSourceId) { - watchBadgeHTML = `
👁️Watch
`; - } - - const maxPerColumn = 6; - const needsOverflow = badges.length > maxPerColumn; - const badgeIcon = (b) => `
${b.logo ? `` : `${b.fb}`}
`; - - let badgeContainerHTML = ''; - if (badges.length > 0 || watchBadgeHTML) { - if (needsOverflow) { - badgeContainerHTML = `
-
${watchBadgeHTML}${badges.slice(maxPerColumn).map(badgeIcon).join('')}
-
${badges.slice(0, maxPerColumn).map(badgeIcon).join('')}
-
`; - } else { - badgeContainerHTML = `
${badges.map(badgeIcon).join('')}${watchBadgeHTML}
`; - } - } - - // Image - const hasImage = artist.image_url && artist.image_url.trim() !== ''; - const deezerFallback = artist.deezer_id ? `if(!this.dataset.triedDeezer){this.dataset.triedDeezer='true';this.src='https://api.deezer.com/artist/${artist.deezer_id}/image?size=big'}else{this.parentNode.innerHTML='
🎵
'}` : `this.parentNode.innerHTML='
🎵
'`; - const imageHTML = hasImage - ? `
${_esc(artist.name)}
` - : `
🎵
`; - - // Track stats - const trackStat = artist.track_count > 0 ? `${artist.track_count} track${artist.track_count !== 1 ? 's' : ''}` : ''; - - return `
- ${badgeContainerHTML} - ${imageHTML} -
-

${_esc(artist.name)}

-
${trackStat}
-
-
`; -} - -function updateLibraryPagination(pagination) { - const prevBtn = document.getElementById("prev-page-btn"); - const nextBtn = document.getElementById("next-page-btn"); - const pageInfo = document.getElementById("page-info"); - const paginationContainer = document.getElementById("library-pagination"); - - if (!paginationContainer) return; - - // Update button states - if (prevBtn) { - prevBtn.disabled = !pagination.has_prev; - } - - if (nextBtn) { - nextBtn.disabled = !pagination.has_next; - } - - // Update page info - if (pageInfo) { - pageInfo.textContent = `Page ${pagination.page} of ${pagination.total_pages}`; - } - - // Show/hide pagination based on total pages - if (pagination.total_pages > 1) { - paginationContainer.classList.remove("hidden"); - } else { - paginationContainer.classList.add("hidden"); - } -} - -function updateLibraryStats(totalCount) { - const countElement = document.getElementById("library-artist-count"); - if (countElement) { - countElement.textContent = totalCount; - } -} - -function showLibraryLoading(show) { - const loadingElement = document.getElementById("library-loading"); - if (loadingElement) { - if (show) { - loadingElement.classList.remove("hidden"); - } else { - loadingElement.classList.add("hidden"); - } - } -} - -function showLibraryEmpty(show) { - const emptyElement = document.getElementById("library-empty"); - if (emptyElement) { - if (show) { - emptyElement.classList.remove("hidden"); - } else { - emptyElement.classList.add("hidden"); - } - } -} - -async function openWatchAllUnwatchedModal() { - if (document.getElementById('watch-all-modal-overlay')) return; - - const sourceIdField = currentMusicSourceName === 'iTunes' ? 'itunes_artist_id' - : currentMusicSourceName === 'Deezer' ? 'deezer_id' : 'spotify_artist_id'; - const sourceName = currentMusicSourceName || 'Spotify'; - - const overlay = document.createElement('div'); - overlay.id = 'watch-all-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) closeWatchAllUnwatchedModal(); }; - - overlay.innerHTML = ` -
-
-
-
👁
-
-

Watch All Unwatched

-

Add unwatched artists with ${_esc(sourceName)} IDs to your watchlist

-
-
- -
-
-
-
-
Loading unwatched artists...
-
-
-
- -
- `; - document.body.appendChild(overlay); - - // Fetch all unwatched artists paginated (SQLite variable limit safe) - try { - const eligible = []; - const ineligible = []; - let page = 1; - const pageSize = 400; - const countEl = document.getElementById('watch-all-load-count'); - - while (true) { - if (!document.getElementById('watch-all-modal-overlay')) return; - if (countEl) countEl.textContent = `${eligible.length + ineligible.length} artists loaded...`; - - const params = new URLSearchParams({ search: '', letter: 'all', page, limit: pageSize, watchlist: 'unwatched' }); - const response = await fetch(`/api/library/artists?${params}`); - const data = await response.json(); - if (!data.success) throw new Error(data.error || 'Failed to load artists'); - - for (const a of (data.artists || [])) { - if (a[sourceIdField]) eligible.push(a); - else ineligible.push(a); - } - - if (!data.pagination.has_next) break; - page++; - } - - _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName); - } catch (error) { - console.error('Error loading unwatched artists:', error); - const body = overlay.querySelector('.watch-all-body'); - if (body) body.innerHTML = `
Failed to load artists
Retry
`; - } -} - -function _renderWatchAllModalContent(overlay, eligible, ineligible, sourceName) { - const body = overlay.querySelector('.watch-all-body'); - const confirmBtn = overlay.querySelector('#watch-all-confirm-btn'); - - if (eligible.length === 0 && ineligible.length === 0) { - body.innerHTML = '
🎵
No unwatched artists found
'; - return; - } - - // Store data for search filtering - overlay._watchAllEligible = eligible; - overlay._watchAllIneligible = ineligible; - - let html = ''; - - // Summary bar (sticky) - html += '
'; - html += `
${eligible.length}
Ready to watch
`; - html += `
${ineligible.length}
No ${_esc(sourceName)} ID
`; - html += `
${eligible.length + ineligible.length}
Total unwatched
`; - html += '
'; - - // Search filter - if (eligible.length > 10) { - html += '
'; - } - - // Eligible grid - if (eligible.length > 0) { - html += ''; - html += '
'; - html += _buildWatchAllRows(eligible, false); - html += '
'; - } - - // Ineligible section - if (ineligible.length > 0) { - html += `
-
-
- - ${ineligible.length} artist${ineligible.length !== 1 ? 's' : ''} without ${_esc(sourceName)} ID -
- -
-
-
These artists haven't been matched to ${_esc(sourceName)} yet. The background enrichment worker will match them over time.
-
${_buildWatchAllRows(ineligible, true)}
-
-
`; - } - - if (eligible.length === 0) { - html += `
🔌
None of your unwatched artists have a ${_esc(sourceName)} ID yet
The background enrichment worker will match them over time.
`; - } - - body.innerHTML = html; - - if (eligible.length > 0 && confirmBtn) { - confirmBtn.textContent = `Watch All (${eligible.length})`; - confirmBtn.disabled = false; - confirmBtn.onclick = () => _confirmWatchAllUnwatched(overlay, eligible.length); - } -} - -function _buildWatchAllRows(artists, dimmed) { - let html = ''; - for (const a of artists) { - const img = a.image_url - ? `` - : `
🎵
`; - html += `
-
${img}
-
${_esc(a.name)}
-
${a.track_count || 0} tracks
-
`; - } - return html; -} - -function _filterWatchAllList(query) { - const q = query.toLowerCase().trim(); - document.querySelectorAll('#watch-all-eligible-grid .watch-all-cell').forEach(cell => { - cell.style.display = !q || cell.dataset.name.includes(q) ? '' : 'none'; - }); -} - -async function _confirmWatchAllUnwatched(overlay, expectedCount) { - const confirmBtn = overlay.querySelector('#watch-all-confirm-btn'); - const cancelBtn = overlay.querySelector('.watch-all-btn-cancel'); - if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Adding...'; } - if (cancelBtn) cancelBtn.disabled = true; - - try { - const response = await fetch('/api/library/watchlist-all-unwatched', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - const data = await response.json(); - - if (data.success) { - const body = overlay.querySelector('.watch-all-body'); - body.innerHTML = `
-
-
Added ${data.added} artist${data.added !== 1 ? 's' : ''} to watchlist
- ${data.skipped_already > 0 ? `
${data.skipped_already} already watched
` : ''} - ${data.skipped_no_id > 0 ? `
${data.skipped_no_id} skipped (no external ID)
` : ''} -
`; - - if (confirmBtn) confirmBtn.style.display = 'none'; - if (cancelBtn) { cancelBtn.disabled = false; cancelBtn.textContent = 'Close'; } - overlay.dataset.needsRefresh = 'true'; - } else { - throw new Error(data.error || 'Failed to add artists'); - } - } catch (error) { - console.error('Error in watch all:', error); - if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = `Watch All (${expectedCount})`; } - if (cancelBtn) cancelBtn.disabled = false; - showToast('Failed to add artists to watchlist', 'error'); - } -} - -function closeWatchAllUnwatchedModal() { - const overlay = document.getElementById('watch-all-modal-overlay'); - if (!overlay) return; - const needsRefresh = overlay.dataset.needsRefresh === 'true'; - overlay.remove(); - if (needsRefresh) loadLibraryArtists(); -} - -async function toggleLibraryCardWatchlist(btn, artist) { - if (btn.disabled) return; - btn.disabled = true; - - // Support both badge-style (.watch-icon-label) and button-style (.watchlist-text) - const label = btn.querySelector('.watch-icon-label') || btn.querySelector('.watchlist-text'); - const isWatching = btn.classList.contains('watched') || btn.classList.contains('watching'); - - if (label) label.textContent = '...'; - - try { - // Use the ID matching the active metadata source - const artistId = currentMusicSourceName === 'iTunes' - ? (artist.itunes_artist_id || artist.spotify_artist_id) - : (artist.spotify_artist_id || artist.itunes_artist_id); - if (!artistId) throw new Error('No iTunes or Spotify ID available for this artist'); - - if (isWatching) { - const response = await fetch('/api/watchlist/remove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - const data = await response.json(); - if (!data.success) throw new Error(data.error); - - btn.classList.remove('watched', 'watching'); - btn.style.opacity = '0.4'; - btn.title = 'Add to Watchlist'; - if (label) label.textContent = 'Watch'; - showToast(`Removed ${artist.name} from watchlist`, 'success'); - } else { - const response = await fetch('/api/watchlist/add', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId, artist_name: artist.name }) - }); - const data = await response.json(); - if (!data.success) throw new Error(data.error); - - btn.classList.add('watched'); - btn.style.opacity = ''; - btn.title = 'Remove from Watchlist'; - if (label) label.textContent = 'Watching'; - showToast(`Added ${artist.name} to watchlist`, 'success'); - } - - if (typeof updateWatchlistCount === 'function') { - updateWatchlistCount(); - } - } catch (error) { - console.error('Error toggling library card watchlist:', error); - if (label) label.textContent = isWatching ? 'Watching' : 'Watch'; - showToast(`Error: ${error.message}`, 'error'); - } finally { - btn.disabled = false; - } -} - -// =============================================== -// Artist Detail Page Functions -// =============================================== - -// Artist detail page state -let artistDetailPageState = { - isInitialized: false, - currentArtistId: null, - currentArtistName: null, - enhancedView: false, - enhancedData: null, - expandedAlbums: new Set(), - selectedTracks: new Set(), - editingCell: null, - enhancedTrackSort: {} -}; - -// Discography filter state -let discographyFilterState = { - categories: { albums: true, eps: true, singles: true }, - content: { live: true, compilations: true, featured: true }, - ownership: 'all' // 'all', 'owned', 'missing' -}; - -function navigateToArtistDetail(artistId, artistName) { - console.log(`🎵 Navigating to artist detail: ${artistName} (ID: ${artistId})`); - - // Abort any in-progress completion stream - if (artistDetailPageState.completionController) { - artistDetailPageState.completionController.abort(); - artistDetailPageState.completionController = null; - } - - // Cancel any active inline edit and close manual match modal before resetting state - cancelInlineEdit(); - const existingMatchOverlay = document.getElementById('enhanced-manual-match-overlay'); - if (existingMatchOverlay) existingMatchOverlay.remove(); - - // Store current artist info and reset enhanced view state - artistDetailPageState.currentArtistId = artistId; - artistDetailPageState.currentArtistName = artistName; - artistDetailPageState.enhancedData = null; - artistDetailPageState.expandedAlbums = new Set(); - artistDetailPageState.selectedTracks = new Set(); - artistDetailPageState.enhancedTrackSort = {}; - artistDetailPageState.enhancedView = false; - - // Reset enhanced view toggle to standard - const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); - toggleBtns.forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-view') === 'standard'); - }); - const enhancedContainer = document.getElementById('enhanced-view-container'); - if (enhancedContainer) enhancedContainer.classList.add('hidden'); - const standardSections = document.querySelector('.discography-sections'); - if (standardSections) standardSections.classList.remove('hidden'); - // Restore standard view filter groups - const filterGroups = document.querySelectorAll('#discography-filters .filter-group'); - filterGroups.forEach(group => { - const label = group.querySelector('.filter-label'); - if (label && label.textContent !== 'View') group.style.display = ''; - }); - const dividers = document.querySelectorAll('#discography-filters .filter-divider'); - dividers.forEach(d => d.style.display = ''); - // Hide bulk bar - const bulkBar = document.getElementById('enhanced-bulk-bar'); - if (bulkBar) bulkBar.classList.remove('visible'); - - // Navigate to artist detail page - navigateToPage('artist-detail'); - - // Initialize if needed and load data - if (!artistDetailPageState.isInitialized) { - initializeArtistDetailPage(); - } - - // Load artist data - loadArtistDetailData(artistId, artistName); -} - -function initializeArtistDetailPage() { - console.log("🔧 Initializing Artist Detail page..."); - - // Initialize back button - const backBtn = document.getElementById("artist-detail-back-btn"); - if (backBtn) { - backBtn.addEventListener("click", () => { - console.log("🔙 Returning to Library page"); - // Abort any in-progress completion stream - if (artistDetailPageState.completionController) { - artistDetailPageState.completionController.abort(); - artistDetailPageState.completionController = null; - } - // Clear artist detail state so we go back to the list view - artistDetailPageState.currentArtistId = null; - artistDetailPageState.currentArtistName = null; - navigateToPage('library'); - }); - } - - // Initialize retry button - const retryBtn = document.getElementById("artist-detail-retry-btn"); - if (retryBtn) { - retryBtn.addEventListener("click", () => { - if (artistDetailPageState.currentArtistId && artistDetailPageState.currentArtistName) { - loadArtistDetailData(artistDetailPageState.currentArtistId, artistDetailPageState.currentArtistName); - } - }); - } - - // Initialize discography filter buttons - initializeDiscographyFilters(); - - artistDetailPageState.isInitialized = true; - console.log("✅ Artist Detail page initialized successfully"); -} - -async function loadArtistDetailData(artistId, artistName) { - console.log(`🔄 Loading artist detail data for: ${artistName} (ID: ${artistId})`); - - // Reset discography filters to defaults - resetDiscographyFilters(); - - // Show loading state and hide all content - showArtistDetailLoading(true); - showArtistDetailError(false); - showArtistDetailMain(false); - showArtistDetailHero(false); - - // Don't update header until data loads to avoid showing stale data - - try { - // Call API to get artist discography data - const response = await fetch(`/api/artist-detail/${artistId}`); - - if (!response.ok) { - throw new Error(`Failed to load artist data: ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to load artist data'); - } - - console.log(`✅ Loaded artist detail data:`, data); - - // Hide loading and show all content - showArtistDetailLoading(false); - showArtistDetailMain(true); - showArtistDetailHero(true); - - console.log(`🎨 Main content visibility:`, document.getElementById('artist-detail-main')); - console.log(`🎨 Albums section:`, document.getElementById('albums-section')); - - // Populate the page with data (which updates the hero section and sets textContent) - populateArtistDetailPage(data); - - // Update header with artist name and MusicBrainz link LAST to avoid overwrite - updateArtistDetailPageHeaderWithData(data.artist); - - // Render per-artist enrichment coverage - renderArtistEnrichmentCoverage(data.enrichment_coverage); - - // Start streaming ownership checks if we have Spotify discography with checking state - if (data.discography && data.discography.albums) { - const hasChecking = [...(data.discography.albums || []), ...(data.discography.eps || []), ...(data.discography.singles || [])] - .some(r => r.owned === null); - if (hasChecking) { - // Store discography for stream updates - artistDetailPageState.currentDiscography = data.discography; - checkLibraryCompletion(data.artist.name, data.discography); - } - } - - // Check if artist has tracks eligible for quality enhancement - checkArtistEnhanceEligibility(artistId); - - } catch (error) { - console.error(`❌ Error loading artist detail data:`, error); - - // Show error state (keep hero section hidden) - showArtistDetailLoading(false); - showArtistDetailError(true, error.message); - showArtistDetailHero(false); - - showToast(`Failed to load artist details: ${error.message}`, "error"); - } -} - -function updateArtistDetailPageHeader(artistName) { - // Update header title - const headerTitle = document.getElementById("artist-detail-name"); - if (headerTitle) { - headerTitle.textContent = artistName; - } - - // Update main artist name - const mainTitle = document.getElementById("artist-info-name"); - if (mainTitle) { - mainTitle.textContent = artistName; - } -} - -function updateArtistDetailPageHeaderWithData(artist) { - // Update name - const mainTitle = document.getElementById("artist-detail-name"); - if (mainTitle) { - mainTitle.textContent = artist.name; - // Remove any old source links that were appended to the h1 - mainTitle.querySelectorAll('.source-link-btn').forEach(el => el.remove()); - } - - // Render badges in dedicated container - const badgesContainer = document.getElementById("artist-hero-badges"); - if (badgesContainer) { - const _hb = (logo, fallback, title, url) => { - const inner = logo - ? `${fallback}` - : `${fallback}`; - if (url) return `${inner}`; - return `
${inner}
`; - }; - - const adbSlug = artist.name ? artist.name.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, '') : ''; - const badges = []; - if (artist.spotify_artist_id) badges.push(_hb(SPOTIFY_LOGO_URL, 'SP', 'Spotify', `https://open.spotify.com/artist/${artist.spotify_artist_id}`)); - if (artist.musicbrainz_id) badges.push(_hb(MUSICBRAINZ_LOGO_URL, 'MB', 'MusicBrainz', `https://musicbrainz.org/artist/${artist.musicbrainz_id}`)); - if (artist.deezer_id) badges.push(_hb(DEEZER_LOGO_URL, 'Dz', 'Deezer', `https://www.deezer.com/artist/${artist.deezer_id}`)); - if (artist.audiodb_id) badges.push(_hb(typeof getAudioDBLogoURL === 'function' ? getAudioDBLogoURL() : '', 'ADB', 'AudioDB', `https://www.theaudiodb.com/artist/${artist.audiodb_id}-${adbSlug}`)); - if (artist.itunes_artist_id) badges.push(_hb(ITUNES_LOGO_URL, 'IT', 'Apple Music', `https://music.apple.com/artist/${artist.itunes_artist_id}`)); - if (artist.lastfm_url) badges.push(_hb(LASTFM_LOGO_URL, 'LFM', 'Last.fm', artist.lastfm_url)); - if (artist.genius_url) badges.push(_hb(GENIUS_LOGO_URL, 'GEN', 'Genius', artist.genius_url)); - if (artist.tidal_id) badges.push(_hb(TIDAL_LOGO_URL, 'TD', 'Tidal', `https://tidal.com/browse/artist/${artist.tidal_id}`)); - if (artist.qobuz_id) badges.push(_hb(QOBUZ_LOGO_URL, 'Qz', 'Qobuz', `https://www.qobuz.com/artist/${artist.qobuz_id}`)); - if (artist.discogs_id) badges.push(_hb(DISCOGS_LOGO_URL, 'DC', 'Discogs', `https://www.discogs.com/artist/${artist.discogs_id}`)); - if (artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_')) badges.push(_hb('/static/trans2.png', 'SS', `SoulID: ${artist.soul_id}`, null)); - - badgesContainer.innerHTML = badges.join(''); - } -} - -function renderArtistEnrichmentCoverage(enrichment) { - const el = document.getElementById('artist-enrichment-coverage'); - if (!el) return; - - if (!enrichment || !enrichment.total_tracks) { - el.style.display = 'none'; - return; - } - - const services = [ - { name: 'Spotify', key: 'spotify', color: '#1db954' }, - { name: 'MusicBrainz', key: 'musicbrainz', color: '#ba55d3' }, - { name: 'Deezer', key: 'deezer', color: '#a238ff' }, - { name: 'Last.fm', key: 'lastfm', color: '#d51007' }, - { name: 'iTunes', key: 'itunes', color: '#fc3c44' }, - { name: 'AudioDB', key: 'audiodb', color: '#1a9fff' }, - { name: 'Discogs', key: 'discogs', color: '#D4A574' }, - { name: 'Genius', key: 'genius', color: '#ffff64' }, - { name: 'Tidal', key: 'tidal', color: '#00ffff' }, - { name: 'Qobuz', key: 'qobuz', color: '#4285f4' }, - ]; - - const r = 20, circ = 2 * Math.PI * r; - - el.style.display = ''; - el.innerHTML = ` -
Enrichment Coverage
-
- ${services.map((s, i) => { - const pct = enrichment[s.key] || 0; - const offset = circ - (circ * pct / 100); - const delay = (i * 0.08).toFixed(2); - return `
-
- - - - - ${Math.round(pct)} -
- ${s.name} -
`; - }).join('')} -
- `; -} - -function populateArtistDetailPage(data) { - const artist = data.artist; - const discography = data.discography; - - console.log(`🎨 Populating artist detail page for: ${artist.name}`); - console.log(`📀 Discography data:`, discography); - console.log(`📀 Albums:`, discography.albums); - console.log(`📀 EPs:`, discography.eps); - console.log(`📀 Singles:`, discography.singles); - - // Update hero section with image, name, and stats - updateArtistHeroSection(artist, discography); - - // Update genres (if element exists) - updateArtistGenres(artist.genres); - - // Update summary stats (if element exists) - updateArtistSummaryStats(discography); - - // Populate discography sections - populateDiscographySections(discography); - - // Initialize library watchlist button if it exists (for library page) - const libraryWatchlistBtn = document.getElementById('library-artist-watchlist-btn'); - if (libraryWatchlistBtn && data.spotify_artist && data.spotify_artist.spotify_artist_id) { - initializeLibraryWatchlistButton(data.spotify_artist.spotify_artist_id, data.spotify_artist.spotify_artist_name); - } -} - -function updateArtistDetailImage(imageUrl, artistName) { - const imageElement = document.getElementById("artist-detail-image"); - const fallbackElement = document.getElementById("artist-image-fallback"); - - if (imageUrl && imageUrl.trim() !== "") { - imageElement.src = imageUrl; - imageElement.alt = artistName; - imageElement.classList.remove("hidden"); - fallbackElement.classList.add("hidden"); - - imageElement.onerror = () => { - console.log(`Failed to load artist image for ${artistName}: ${imageUrl}`); - // Replace with fallback on error - imageElement.classList.add("hidden"); - fallbackElement.classList.remove("hidden"); - }; - - imageElement.onload = () => { - console.log(`Successfully loaded artist image for ${artistName}: ${imageUrl}`); - }; - } else { - console.log(`No image URL for ${artistName}: '${imageUrl}'`); - imageElement.classList.add("hidden"); - fallbackElement.classList.remove("hidden"); - } -} - -function updateArtistGenres(genres) { - const genresContainer = document.getElementById("artist-genres"); - if (!genresContainer) return; - - genresContainer.innerHTML = ""; - - // Clear any previous artist format tags (they arrive later via streaming) - const oldFormats = genresContainer.parentElement?.querySelector('.artist-formats'); - if (oldFormats) oldFormats.remove(); - - if (genres && genres.length > 0) { - genres.forEach(genre => { - const genreTag = document.createElement("span"); - genreTag.className = "genre-tag"; - genreTag.textContent = genre; - genresContainer.appendChild(genreTag); - }); - } -} - -function updateArtistSummaryStats(discography) { - const allReleases = [...discography.albums, ...discography.eps, ...discography.singles]; - const hasChecking = allReleases.some(r => r.owned === null); - - const ownedAlbums = discography.albums.filter(album => album.owned === true).length; - const missingAlbums = discography.albums.filter(album => album.owned === false).length; - const totalAlbums = discography.albums.length; - const completionPercentage = totalAlbums > 0 ? Math.round((ownedAlbums / totalAlbums) * 100) : 0; - - // Update owned albums count - const ownedElement = document.getElementById("owned-albums-count"); - if (ownedElement) { - ownedElement.textContent = hasChecking ? '...' : ownedAlbums; - } - - // Update missing albums count - const missingElement = document.getElementById("missing-albums-count"); - if (missingElement) { - missingElement.textContent = hasChecking ? '...' : missingAlbums; - } - - // Update completion percentage - const completionElement = document.getElementById("completion-percentage"); - if (completionElement) { - completionElement.textContent = hasChecking ? 'Checking...' : `${completionPercentage}%`; - } -} - -function updateArtistHeaderStats(albumCount, trackCount) { - // This function is deprecated - now using updateArtistHeroSection - console.log("📊 Using new hero section instead of old header stats"); -} - -function updateArtistHeroSection(artist, discography) { - console.log("🖼️ Updating artist hero section"); - - // Update artist image with detailed debugging - const imageElement = document.getElementById("artist-detail-image"); - const fallbackElement = document.getElementById("artist-detail-image-fallback"); - - console.log(`🖼️ Debug Artist image info:`); - console.log(` - URL: '${artist.image_url}'`); - console.log(` - Type: ${typeof artist.image_url}`); - console.log(` - Full artist object:`, artist); - console.log(` - Image element:`, imageElement); - console.log(` - Fallback element:`, fallbackElement); - - if (artist.image_url && artist.image_url.trim() !== "" && artist.image_url !== "null") { - console.log(`✅ Setting image src to: ${artist.image_url}`); - imageElement.src = artist.image_url; - imageElement.alt = artist.name; - imageElement.style.display = "block"; - if (fallbackElement) { - fallbackElement.style.display = "none"; - } - - imageElement.onload = () => { - console.log(`✅ Successfully loaded artist image: ${artist.image_url}`); - }; - - imageElement.onerror = () => { - console.error(`❌ Failed to load artist image: ${artist.image_url}`); - // Try Deezer fallback before emoji - if (artist.deezer_id && !imageElement.dataset.triedDeezer) { - imageElement.dataset.triedDeezer = 'true'; - imageElement.src = `https://api.deezer.com/artist/${artist.deezer_id}/image?size=big`; - } else { - imageElement.style.display = "none"; - if (fallbackElement) { - fallbackElement.style.display = "flex"; - } - } - }; - } else { - console.log(`🖼️ No valid image URL - showing fallback for ${artist.name}`); - imageElement.style.display = "none"; - if (fallbackElement) { - fallbackElement.style.display = "flex"; - } - } - - // Update artist name - const nameElement = document.getElementById("artist-detail-name"); - if (nameElement) { - nameElement.textContent = artist.name; - } - - // Calculate and update stats for each category - updateCategoryStats('albums', discography.albums); - updateCategoryStats('eps', discography.eps); - updateCategoryStats('singles', discography.singles); - - // Show Download Discography button(s) if there are any releases - const _totalReleases = (discography.albums?.length || 0) + (discography.eps?.length || 0) + (discography.singles?.length || 0); - const _discogWrap = document.getElementById('discog-download-wrap'); - if (_discogWrap) _discogWrap.style.display = _totalReleases > 0 ? '' : 'none'; - const _discogBtnArtists = document.getElementById('discog-download-btn-artists'); - if (_discogBtnArtists) _discogBtnArtists.style.display = _totalReleases > 0 ? '' : 'none'; - - // Last.fm stats (listeners / playcount) - const _fmtNum = (n) => { - if (!n || n <= 0) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - - const listenersEl = document.getElementById('artist-hero-listeners'); - if (listenersEl) { - if (artist.lastfm_listeners) { - listenersEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_listeners); - listenersEl.style.display = ''; - } else { - listenersEl.style.display = 'none'; - } - } - - const playcountEl = document.getElementById('artist-hero-playcount'); - if (playcountEl) { - if (artist.lastfm_playcount) { - playcountEl.querySelector('.hero-stat-value').textContent = _fmtNum(artist.lastfm_playcount); - playcountEl.style.display = ''; - } else { - playcountEl.style.display = 'none'; - } - } - - // Last.fm bio - const bioEl = document.getElementById('artist-hero-bio'); - if (bioEl) { - const bio = artist.lastfm_bio; - if (bio && bio.trim()) { - // Strip HTML tags and "Read more on Last.fm" links - let cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); - if (cleanBio) { - bioEl.innerHTML = `${cleanBio} - Read more`; - bioEl.style.display = ''; - } else { - bioEl.style.display = 'none'; - } - } else { - bioEl.style.display = 'none'; - } - } - - // Last.fm tags — merge with existing genres (deduplicate) - if (artist.lastfm_tags) { - try { - let lfmTags = typeof artist.lastfm_tags === 'string' ? JSON.parse(artist.lastfm_tags) : artist.lastfm_tags; - if (Array.isArray(lfmTags) && lfmTags.length > 0) { - const existingGenres = new Set((artist.genres || []).map(g => g.toLowerCase())); - const newTags = lfmTags.filter(t => !existingGenres.has(t.toLowerCase())).slice(0, 5); - if (newTags.length > 0) { - const genresContainer = document.getElementById('artist-genres'); - if (genresContainer) { - newTags.forEach(tag => { - const el = document.createElement('span'); - el.className = 'genre-tag'; - el.textContent = tag; - el.style.opacity = '0.6'; - genresContainer.appendChild(el); - }); - } - } - } - } catch (e) { - console.debug('Failed to parse Last.fm tags:', e); - } - } - - // Lazy-load top tracks sidebar - if (artist.lastfm_url || artist.lastfm_listeners) { - _loadArtistTopTracks(artist.name); - } -} - -async function _loadArtistTopTracks(artistName) { - const sidebar = document.getElementById('artist-hero-sidebar'); - const container = document.getElementById('hero-top-tracks'); - if (!sidebar || !container) return; - - try { - const resp = await fetch(`/api/artist/0/lastfm-top-tracks?name=${encodeURIComponent(artistName)}`); - const data = await resp.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - sidebar.style.display = 'none'; - return; - } - - const _fmtNum = (n) => { - if (!n || n <= 0) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - - const _escAttr = (s) => (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); - container.innerHTML = data.tracks.map((t, i) => ` -
- ${i + 1} - - ${_escAttr(t.name)} - ${_fmtNum(t.playcount)} -
- `).join(''); - - // Attach play handlers via delegation (avoids inline JS escaping issues) - container.onclick = (e) => { - const btn = e.target.closest('.hero-top-track-play'); - if (btn) { - e.stopPropagation(); - playStatsTrack(btn.dataset.track, btn.dataset.artist, ''); - } - }; - sidebar.style.display = ''; - } catch (e) { - console.debug('Failed to load top tracks:', e); - sidebar.style.display = 'none'; - } -} - -function updateCategoryStats(category, releases) { - const hasChecking = releases.some(r => r.owned === null); - const owned = releases.filter(r => r.owned === true).length; - const total = releases.length; - const completion = total > 0 ? Math.round((owned / total) * 100) : 100; - - // Update stats text (compact: "3/12") - const statsElement = document.getElementById(`${category}-stats`); - if (statsElement) { - statsElement.textContent = hasChecking ? '...' : `${owned}/${total}`; - } - - // Update completion bar - const fillElement = document.getElementById(`${category}-completion-fill`); - if (fillElement) { - if (hasChecking) { - fillElement.style.width = '100%'; - fillElement.classList.add('checking'); - } else { - fillElement.style.width = `${completion}%`; - fillElement.classList.remove('checking'); - } - } -} - -function populateDiscographySections(discography) { - // Populate albums - populateReleaseSection('albums', discography.albums); - - // Populate EPs - populateReleaseSection('eps', discography.eps); - - // Populate singles - populateReleaseSection('singles', discography.singles); - - // Apply any active filters after populating - applyDiscographyFilters(); -} - -function populateReleaseSection(sectionType, releases) { - const gridId = `${sectionType}-grid`; - const ownedCountId = `${sectionType}-owned-count`; - const missingCountId = `${sectionType}-missing-count`; - - const grid = document.getElementById(gridId); - if (!grid) return; - - // Clear existing content - grid.innerHTML = ""; - - const hasChecking = releases.some(r => r.owned === null); - const ownedCount = releases.filter(release => release.owned === true).length; - const missingCount = releases.filter(release => release.owned === false).length; - - // Update section stats - const ownedElement = document.getElementById(ownedCountId); - const missingElement = document.getElementById(missingCountId); - - if (ownedElement) { - ownedElement.textContent = hasChecking ? 'Checking...' : `${ownedCount} owned`; - } - - if (missingElement) { - missingElement.textContent = hasChecking ? '' : `${missingCount} missing`; - } - - // Create release cards - releases.forEach((release, index) => { - const card = createReleaseCard(release); - grid.appendChild(card); - }); - - console.log(`📀 Populated ${sectionType} section: ${ownedCount} owned, ${missingCount} missing`); - console.log(`📀 Grid element:`, grid); - console.log(`📀 Grid children count:`, grid.children.length); -} - -function createReleaseCard(release) { - const card = document.createElement("div"); - const isChecking = release.owned === null; - card.className = `release-card${isChecking ? " checking" : (release.owned ? "" : " missing")}`; - const releaseId = release.id || ""; - card.setAttribute("data-release-id", releaseId); - // Store mutable reference so stream updates propagate to click handler - card._releaseData = release; - - // Tag card for content-type filtering - const titleLower = (release.title || '').toLowerCase(); - const livePattern = /\b(live)\b|\(live[^)]*\)|\[live[^]]*\]/i; - const compilationPattern = /\b(greatest hits|best of|collection|anthology|essential)\b/i; - const featuredPattern = /\(?\bfeat\.?\s|\bft\.?\s|\bfeaturing\b/i; - const isLive = livePattern.test(release.title || '') || (release.album_type === 'compilation' && livePattern.test(release.title || '')); - const isCompilation = (release.album_type === 'compilation') || compilationPattern.test(release.title || ''); - const isFeatured = featuredPattern.test(release.title || ''); - card.setAttribute("data-is-live", isLive ? "true" : "false"); - card.setAttribute("data-is-compilation", isCompilation ? "true" : "false"); - card.setAttribute("data-is-featured", isFeatured ? "true" : "false"); - - // Add MusicBrainz icon if available - let mbIcon = null; - if (release.musicbrainz_release_id) { - mbIcon = document.createElement("div"); - mbIcon.className = "mb-card-icon"; - mbIcon.title = "View on MusicBrainz"; - mbIcon.innerHTML = ``; - mbIcon.onclick = (e) => { - e.stopPropagation(); - window.open(`https://musicbrainz.org/release/${release.musicbrainz_release_id}`, '_blank'); - }; - } - - // Create image - const imageContainer = document.createElement("div"); - if (release.image_url && release.image_url.trim() !== "") { - const img = document.createElement("img"); - img.src = release.image_url; - img.alt = release.title; - img.className = "release-image"; - img.loading = 'lazy'; - img.onerror = () => { - imageContainer.innerHTML = `
💿
`; - }; - imageContainer.appendChild(img); - } else { - imageContainer.innerHTML = `
💿
`; - } - - // Create title - const title = document.createElement("h4"); - title.className = "release-title"; - title.textContent = release.title; - title.title = release.title; - - // Create year - extract from release_date (Spotify format) or fall back to year field - const year = document.createElement("div"); - year.className = "release-year"; - - let yearText = "Unknown Year"; - - // DEBUG: Log the release data to see what we're working with (remove this after testing) - // console.log(`🔍 DEBUG: Release "${release.title}" data:`, { - // title: release.title, - // owned: release.owned, - // year: release.year, - // release_date: release.release_date, - // track_completion: release.track_completion - // }); - - // First try to extract year from release_date (Spotify format: "YYYY-MM-DD") - if (release.release_date) { - try { - // Extract year directly from string to avoid timezone issues - const yearMatch = release.release_date.match(/^(\d{4})/); - if (yearMatch) { - const releaseYear = parseInt(yearMatch[1]); - if (releaseYear && !isNaN(releaseYear) && releaseYear > 1900 && releaseYear <= new Date().getFullYear() + 1) { - yearText = releaseYear.toString(); - } - } else { - // Fallback to Date parsing if format is different - const releaseYear = new Date(release.release_date).getFullYear(); - if (releaseYear && !isNaN(releaseYear) && releaseYear > 1900 && releaseYear <= new Date().getFullYear() + 1) { - yearText = releaseYear.toString(); - } - } - } catch (e) { - console.warn('Error parsing release_date:', release.release_date, e); - } - } - - // Fallback to direct year field if release_date parsing failed - if (yearText === "Unknown Year" && release.year) { - yearText = release.year.toString(); - } - - year.textContent = yearText; - - // Create completion info - const completion = document.createElement("div"); - completion.className = "release-completion"; - - const completionText = document.createElement("span"); - const completionBar = document.createElement("div"); - completionBar.className = "completion-bar"; - - const completionFill = document.createElement("div"); - completionFill.className = "completion-fill"; - - if (release.owned === null || release.track_completion === 'checking') { - // Checking state - ownership not yet resolved - completionText.textContent = "Checking..."; - completionText.className = "completion-text checking"; - completionFill.className += " checking"; - completionFill.style.width = "100%"; - } else if (release.owned) { - // Handle new detailed track completion object - if (release.track_completion && typeof release.track_completion === 'object') { - const completion = release.track_completion; - const percentage = completion.percentage || 100; - const ownedTracks = completion.owned_tracks || 0; - const totalTracks = completion.total_tracks || 0; - const missingTracks = completion.missing_tracks || 0; - - completionFill.style.width = `${percentage}%`; - - if (missingTracks === 0) { - completionText.textContent = `Complete (${ownedTracks})`; - completionText.className = "completion-text complete"; - completionFill.className += " complete"; - } else { - completionText.textContent = `${ownedTracks}/${totalTracks} tracks`; - completionText.className = "completion-text partial"; - completionFill.className += " partial"; - - // Add missing tracks indicator - completionText.title = `Missing ${missingTracks} track${missingTracks !== 1 ? 's' : ''}`; - } - } else { - // Fallback for legacy simple percentage - const percentage = release.track_completion || 100; - completionFill.style.width = `${percentage}%`; - - if (percentage === 100) { - completionText.textContent = "Complete"; - completionText.className = "completion-text complete"; - completionFill.className += " complete"; - } else { - completionText.textContent = `${percentage}%`; - completionText.className = "completion-text partial"; - completionFill.className += " partial"; - } - } - } else { - const totalTr = release.total_tracks || release.track_completion?.total_tracks || 0; - completionText.textContent = totalTr > 0 ? `Missing (${totalTr} tracks)` : "Not in library"; - completionText.className = "completion-text missing"; - completionFill.className += " missing"; - completionFill.style.width = "0%"; - } - - completionBar.appendChild(completionFill); - completion.appendChild(completionText); - completion.appendChild(completionBar); - - // Assemble card - card.appendChild(imageContainer); - card.appendChild(title); - card.appendChild(year); - card.appendChild(completion); - - // Add MusicBrainz icon LAST to ensure it's on top - if (release.musicbrainz_release_id && mbIcon) { // Check if mbIcon was created - card.appendChild(mbIcon); - } - - // Add click handler for release card (uses card._releaseData for mutable reference) - card.addEventListener("click", async () => { - const rel = card._releaseData; - console.log(`Clicked on release: ${rel.title} (Owned: ${rel.owned})`); - - // Still checking - ignore click - if (rel.owned === null) { - showToast(`Still checking ownership for ${rel.title}...`, "info"); - return; - } - - showLoadingOverlay('Loading album...'); - - // For missing or incomplete releases, open wishlist modal - try { - // Convert release object to album format expected by our function - const albumData = { - id: rel.id, - name: rel.title, - image_url: rel.image_url, - release_date: rel.year ? `${rel.year}-01-01` : '', - album_type: rel.album_type || rel.type || 'album', - total_tracks: (rel.track_completion && typeof rel.track_completion === 'object') - ? rel.track_completion.total_tracks : (rel.track_count || 1) - }; - - // Get current artist from artist detail page state - const currentArtist = artistDetailPageState.currentArtistName ? { - id: artistDetailPageState.currentArtistId, - name: artistDetailPageState.currentArtistName, - image_url: getArtistImageFromPage() || '' // Get artist image from page - } : null; - - if (!currentArtist) { - console.error('❌ No current artist found for release click'); - showToast('Error: No artist information available', 'error'); - return; - } - - // Load tracks for the album (pass name/artist for Hydrabase support) - const _aat2 = new URLSearchParams({ name: albumData.name || '', artist: currentArtist.name || '' }); - const response = await fetch(`/api/album/${albumData.id}/tracks?${_aat2}`); - if (!response.ok) { - throw new Error(`Failed to load album tracks: ${response.status}`); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found for this release'); - } - - // Use the actual album type from release data - const albumType = rel.album_type || rel.type || 'album'; - - // Open the Add to Wishlist modal immediately (no waiting for ownership check) - hideLoadingOverlay(); - await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType); - - // Always lazy-load track ownership + metadata (non-blocking) - lazyLoadTrackOwnership(currentArtist.name, data.tracks, card, albumData.name); - - } catch (error) { - hideLoadingOverlay(); - console.error('❌ Error handling release click:', error); - showToast(`Error opening wishlist modal: ${error.message}`, 'error'); - } - }); - - return card; -} - -/** - * Helper function to get artist image from the current artist detail page - */ -function getArtistImageFromPage() { - try { - // Try to get from artist detail image element - const artistDetailImage = document.getElementById('artist-detail-image'); - if (artistDetailImage && artistDetailImage.src && artistDetailImage.src !== window.location.href) { - return artistDetailImage.src; - } - - // Try to get from artist hero image - const artistImage = document.getElementById('artist-image'); - if (artistImage) { - const bgImage = window.getComputedStyle(artistImage).backgroundImage; - if (bgImage && bgImage !== 'none') { - // Extract URL from CSS background-image - const urlMatch = bgImage.match(/url\(["']?(.*?)["']?\)/); - if (urlMatch && urlMatch[1]) { - return urlMatch[1]; - } - } - } - - return null; - } catch (error) { - console.warn('Error getting artist image from page:', error); - return null; - } -} - -// ================================================================================================ -// LIBRARY COMPLETION STREAMING - Two-phase lazy-load pattern -// ================================================================================================ - -async function checkLibraryCompletion(artistName, discography) { - // Abort any in-progress check - if (artistDetailPageState.completionController) { - artistDetailPageState.completionController.abort(); - } - artistDetailPageState.completionController = new AbortController(); - - const payload = { - artist_name: artistName, - albums: discography.albums || [], - eps: discography.eps || [], - singles: discography.singles || [], - source: discography?.source || null - }; - - try { - const response = await fetch('/api/library/completion-stream', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - signal: artistDetailPageState.completionController.signal - }); - - if (!response.ok) { - console.error(`❌ Completion stream failed: ${response.status}`); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let ownedCounts = { albums: 0, eps: 0, singles: 0 }; - let totalCounts = { albums: 0, eps: 0, singles: 0 }; - const artistFormatSet = new Set(); - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); // Keep incomplete line in buffer - - for (const line of lines) { - if (!line.startsWith('data: ')) continue; - try { - const eventData = JSON.parse(line.slice(6)); - if (eventData.type === 'completion') { - updateLibraryReleaseCard(eventData); - totalCounts[eventData.category]++; - if (eventData.status !== 'missing' && eventData.status !== 'error') { - ownedCounts[eventData.category]++; - // Accumulate formats for artist-level summary - if (eventData.formats) { - eventData.formats.forEach(f => artistFormatSet.add(f)); - } - } - // Update stats incrementally - updateCategoryStatsFromStream( - eventData.category, - ownedCounts[eventData.category], - totalCounts[eventData.category] - ownedCounts[eventData.category] - ); - } else if (eventData.type === 'complete') { - console.log(`✅ Library completion stream done: ${eventData.processed_count} items`); - // Final stats recalculation - recalculateSummaryStats(artistFormatSet); - } - } catch (parseError) { - console.warn('Error parsing SSE event:', parseError, line); - } - } - } - } catch (error) { - if (error.name === 'AbortError') { - console.log('🛑 Library completion stream aborted (navigation)'); - } else { - console.error('❌ Error in library completion stream:', error); - } - } -} - -function updateLibraryReleaseCard(data) { - const releaseId = data.id || ""; - const card = document.querySelector(`[data-release-id="${releaseId}"]`); - if (!card) return; - - const isOwned = data.status !== 'missing' && data.status !== 'error'; - - // Update card class - card.classList.remove('checking', 'missing'); - if (!isOwned) { - card.classList.add('missing'); - } - - // Use real numbers — no rounding or overrides - const isComplete = data.owned_tracks >= data.expected_tracks && data.owned_tracks > 0; - const effectiveMissing = data.expected_tracks - data.owned_tracks; - - // Update the mutable release data on the card - if (card._releaseData) { - card._releaseData.owned = isOwned; - if (isOwned && data.expected_tracks > 0) { - card._releaseData.track_completion = { - owned_tracks: data.owned_tracks, - total_tracks: isComplete ? data.owned_tracks : data.expected_tracks, - percentage: isComplete ? 100 : data.completion_percentage, - missing_tracks: effectiveMissing - }; - } else if (isOwned) { - card._releaseData.track_completion = { - owned_tracks: data.owned_tracks, - total_tracks: data.owned_tracks, - percentage: 100, - missing_tracks: 0 - }; - } else { - card._releaseData.track_completion = 0; - } - } - - // Update completion text element in-place - const completionText = card.querySelector('.completion-text'); - if (completionText) { - completionText.classList.remove('checking', 'complete', 'partial', 'missing'); - if (isOwned) { - if (effectiveMissing <= 0) { - completionText.textContent = `Complete (${data.owned_tracks})`; - completionText.className = 'completion-text complete'; - } else { - completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`; - completionText.className = 'completion-text partial'; - completionText.title = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`; - } - } else { - completionText.textContent = 'Missing'; - completionText.className = 'completion-text missing'; - } - } - - // Update completion fill bar in-place - const completionFill = card.querySelector('.completion-fill'); - if (completionFill) { - completionFill.classList.remove('checking', 'complete', 'partial', 'missing'); - if (isOwned) { - const pct = isComplete ? 100 : (data.completion_percentage || 100); - completionFill.style.width = `${pct}%`; - completionFill.classList.add(effectiveMissing <= 0 ? 'complete' : 'partial'); - } else { - completionFill.style.width = '0%'; - completionFill.classList.add('missing'); - } - } - - // Display format tags on owned releases - if (isOwned && data.formats && data.formats.length > 0) { - // Store formats on release data for modal use - if (card._releaseData) { - card._releaseData.formats = data.formats; - } - // Remove any existing format tags - const existingFormats = card.querySelector('.release-formats'); - if (existingFormats) existingFormats.remove(); - - const formatsDiv = document.createElement('div'); - formatsDiv.className = 'release-formats'; - formatsDiv.innerHTML = data.formats.map(f => `${f}`).join(''); - card.appendChild(formatsDiv); - } - - // Re-apply filters so newly resolved cards respect active filters - applyDiscographyFilters(); -} - -function updateCategoryStatsFromStream(category, ownedCount, missingCount) { - const total = ownedCount + missingCount; - const completion = total > 0 ? Math.round((ownedCount / total) * 100) : 100; - - const statsElement = document.getElementById(`${category}-stats`); - if (statsElement) { - statsElement.textContent = `${ownedCount}/${total}`; - } - - const fillElement = document.getElementById(`${category}-completion-fill`); - if (fillElement) { - fillElement.classList.remove('checking'); - fillElement.style.width = `${completion}%`; - } -} - -function recalculateSummaryStats(artistFormatSet) { - const disc = artistDetailPageState.currentDiscography; - if (!disc) return; - - // Recalculate from the live card data - const categories = ['albums', 'eps', 'singles']; - for (const cat of categories) { - const grid = document.getElementById(`${cat}-grid`); - if (!grid) continue; - let owned = 0, missing = 0; - grid.querySelectorAll('.release-card').forEach(card => { - if (card._releaseData) { - if (card._releaseData.owned === true) owned++; - else if (card._releaseData.owned === false) missing++; - } - }); - updateCategoryStatsFromStream(cat, owned, missing); - } - - // Update summary stats (albums only, matches original behavior) - const albumGrid = document.getElementById('albums-grid'); - if (albumGrid) { - let ownedAlbums = 0, missingAlbums = 0; - albumGrid.querySelectorAll('.release-card').forEach(card => { - if (card._releaseData) { - if (card._releaseData.owned === true) ownedAlbums++; - else if (card._releaseData.owned === false) missingAlbums++; - } - }); - const total = ownedAlbums + missingAlbums; - const pct = total > 0 ? Math.round((ownedAlbums / total) * 100) : 0; - - const ownedEl = document.getElementById("owned-albums-count"); - if (ownedEl) ownedEl.textContent = ownedAlbums; - const missingEl = document.getElementById("missing-albums-count"); - if (missingEl) missingEl.textContent = missingAlbums; - const completionEl = document.getElementById("completion-percentage"); - if (completionEl) completionEl.textContent = `${pct}%`; - } - - // Display artist-level format summary - if (artistFormatSet && artistFormatSet.size > 0) { - const heroInfo = document.querySelector('.artist-hero-section .artist-info'); - if (heroInfo) { - // Remove any existing artist format tag - const existing = heroInfo.querySelector('.artist-formats'); - if (existing) existing.remove(); - - const formatsDiv = document.createElement('div'); - formatsDiv.className = 'artist-formats'; - formatsDiv.innerHTML = [...artistFormatSet].sort() - .map(f => `${f}`) - .join(''); - // Insert after genres container - const genresContainer = heroInfo.querySelector('.artist-genres-container'); - if (genresContainer && genresContainer.nextSibling) { - heroInfo.insertBefore(formatsDiv, genresContainer.nextSibling); - } else { - heroInfo.appendChild(formatsDiv); - } - } - } -} - -// =============================================== -// Discography Filter Functions -// =============================================== - -function initializeDiscographyFilters() { - const container = document.getElementById('discography-filters'); - if (!container) return; - - container.addEventListener('click', (e) => { - const btn = e.target.closest('.discography-filter-btn'); - if (!btn) return; - - const filterType = btn.dataset.filter; - const value = btn.dataset.value; - - if (filterType === 'category') { - // Multi-toggle: toggle this category on/off - btn.classList.toggle('active'); - discographyFilterState.categories[value] = btn.classList.contains('active'); - } else if (filterType === 'content') { - // Multi-toggle: toggle this content type on/off - btn.classList.toggle('active'); - discographyFilterState.content[value] = btn.classList.contains('active'); - } else if (filterType === 'ownership') { - // Single-select: deactivate siblings, activate this one - container.querySelectorAll('[data-filter="ownership"]').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - discographyFilterState.ownership = value; - } - - applyDiscographyFilters(); - }); -} - -function resetDiscographyFilters() { - discographyFilterState.categories = { albums: true, eps: true, singles: true }; - discographyFilterState.content = { live: true, compilations: true, featured: true }; - discographyFilterState.ownership = 'all'; - - // Reset button visual states - const container = document.getElementById('discography-filters'); - if (!container) return; - container.querySelectorAll('.discography-filter-btn').forEach(btn => { - const filterType = btn.dataset.filter; - const value = btn.dataset.value; - if (filterType === 'ownership') { - btn.classList.toggle('active', value === 'all'); - } else { - btn.classList.add('active'); - } - }); -} - -function applyDiscographyFilters() { - const categories = ['albums', 'eps', 'singles']; - - for (const cat of categories) { - const section = document.getElementById(`${cat}-section`); - if (!section) continue; - - // Category toggle — hide entire section - if (!discographyFilterState.categories[cat]) { - section.style.display = 'none'; - continue; - } - section.style.display = ''; - - // Filter individual cards within the section - const grid = document.getElementById(`${cat}-grid`); - if (!grid) continue; - - let visibleOwned = 0; - let visibleMissing = 0; - let visibleCount = 0; - - grid.querySelectorAll('.release-card').forEach(card => { - let hidden = false; - - // Content filters - if (!discographyFilterState.content.live && card.getAttribute('data-is-live') === 'true') { - hidden = true; - } - if (!discographyFilterState.content.compilations && card.getAttribute('data-is-compilation') === 'true') { - hidden = true; - } - if (!discographyFilterState.content.featured && card.getAttribute('data-is-featured') === 'true') { - hidden = true; - } - - // Ownership filter (only apply if card is not still checking) - if (!hidden && discographyFilterState.ownership !== 'all' && card._releaseData) { - const owned = card._releaseData.owned; - if (owned !== null) { // Don't hide cards still being checked - if (discographyFilterState.ownership === 'owned' && !owned) hidden = true; - if (discographyFilterState.ownership === 'missing' && owned) hidden = true; - } - } - - card.style.display = hidden ? 'none' : ''; - - // Count visible cards for stats - if (!hidden && card._releaseData) { - visibleCount++; - if (card._releaseData.owned === true) visibleOwned++; - else if (card._releaseData.owned === false) visibleMissing++; - } - }); - - // Update section stats to reflect filtered view - const ownedEl = document.getElementById(`${cat}-owned-count`); - const missingEl = document.getElementById(`${cat}-missing-count`); - if (ownedEl) ownedEl.textContent = `${visibleOwned} owned`; - if (missingEl) missingEl.textContent = `${visibleMissing} missing`; - - // Hide section entirely if all cards are hidden - section.style.display = visibleCount === 0 ? 'none' : ''; - } -} - -// ==================== Download Discography Modal ==================== - -async function openDiscographyModal() { - // Support both Artists search page and Library artist detail page - let artist = artistsPageState.selectedArtist; - let discography = artistsPageState.artistDiscography; - let completionCache = artistsPageState.cache.completionData; - - // Fallback to Library page state if Artists page has no data for THIS artist - const libId = artistDetailPageState.currentArtistId; - const libName = artistDetailPageState.currentArtistName; - const isLibraryPage = libId && libName; - const artistsPageMatchesLibrary = artist && isLibraryPage && artist.name?.toLowerCase() === libName?.toLowerCase(); - - if (isLibraryPage && (!artist || !discography || !artistsPageMatchesLibrary)) { - // On library page — don't trust stale artistsPageState from a previous Artists page search - artist = { id: libId, name: libName, image_url: document.getElementById('artist-detail-image')?.src || '' }; - discography = null; - - let metadataArtistId = null; - try { - showToast('Loading discography...', 'info'); - - // Fetch the artist's metadata IDs from the DB (enhanced view may not be loaded) - let lookupId = libId; - try { - const idRes = await fetch(`/api/library/artist/${libId}/enhanced`); - const idData = await idRes.json(); - if (idData.success && idData.artist) { - const a = idData.artist; - metadataArtistId = a.spotify_artist_id || a.itunes_artist_id || a.deezer_id || null; - lookupId = metadataArtistId || libId; - } - } catch (e) { - console.debug('[Discography] Could not fetch artist IDs, using DB id'); - } - - const res = await fetch(`/api/artist/${encodeURIComponent(lookupId)}/discography?artist_name=${encodeURIComponent(libName)}`); - const data = await res.json(); - - if (!data.error) { - discography = { albums: data.albums || [], singles: data.singles || [] }; - if (discography.albums.length > 0 || discography.singles.length > 0) { - artistsPageState.artistDiscography = discography; - // Use metadata source ID for the modal (needed for download API calls) - if (metadataArtistId) artist.id = metadataArtistId; - artistsPageState.selectedArtist = artist; - } else { - discography = null; - } - } - } catch (e) { - console.error('Failed to load discography:', e); - } - } - - if (!artist || !discography) { - showToast('No discography found. Try searching this artist on the Artists page instead.', 'error'); - return; - } - - const completionData = (completionCache || {})[artist.id] || {}; - const allReleases = [ - ...(discography.albums || []).map(a => ({ ...a, _type: 'album' })), - ...(discography.eps || []).map(a => ({ ...a, _type: 'ep' })), - ...(discography.singles || []).map(a => ({ ...a, _type: 'single' })), - ]; - - // Build modal - const overlay = document.createElement('div'); - overlay.className = 'discog-modal-overlay'; - overlay.id = 'discog-modal-overlay'; - - const artistImg = artist.image_url || ''; - - overlay.innerHTML = ` -
-
-
-
-

Download Discography

-

${_esc(artist.name)}

-
- -
-
-
- - - -
-
- - -
-
-
- ${allReleases.map((r, i) => _renderDiscogCard(r, i, completionData)).join('')} -
- - -
- `; - - document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add('visible')); - _updateDiscogFooterCount(); - - // Bind submit button (avoids onclick being intercepted by helper system) - document.getElementById('discog-submit-btn')?.addEventListener('click', (e) => { - e.stopPropagation(); - startDiscographyDownload(); - }); -} - -function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } - -function _renderDiscogCard(release, index, completionData) { - const comp = completionData?.albums?.find(c => c.id === release.id) || completionData?.singles?.find(c => c.id === release.id); - const status = comp?.status || 'unknown'; - const isOwned = status === 'completed'; - const isPartial = status === 'partial' || status === 'nearly_complete'; - const year = release.release_date ? release.release_date.substring(0, 4) : ''; - const tracks = release.total_tracks || 0; - const img = release.image_url || ''; - const checked = !isOwned; - const statusClass = isOwned ? 'owned' : isPartial ? 'partial' : ''; - const statusIcon = isOwned ? '✓' : isPartial ? '◐' : ''; - - return ` - - `; -} - -function toggleDiscogFilter(btn) { - btn.classList.toggle('active'); - const type = btn.dataset.type; - document.querySelectorAll(`.discog-card[data-type="${type}"]`).forEach(card => { - card.style.display = btn.classList.contains('active') ? '' : 'none'; - }); - _updateDiscogFooterCount(); -} - -function discogSelectAll(select) { - document.querySelectorAll('.discog-card-cb').forEach(cb => { - if (cb.closest('.discog-card').style.display !== 'none') { - cb.checked = select; - } - }); - _updateDiscogFooterCount(); -} - -function _updateDiscogFooterCount() { - const checked = document.querySelectorAll('.discog-card-cb:checked'); - let releases = 0, tracks = 0; - checked.forEach(cb => { - if (cb.closest('.discog-card').style.display !== 'none') { - releases++; - tracks += parseInt(cb.dataset.tracks) || 0; - } - }); - const info = document.getElementById('discog-footer-info'); - const btn = document.getElementById('discog-submit-text'); - if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} · ${tracks} tracks`; - if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases'; - const submitBtn = document.getElementById('discog-submit-btn'); - if (submitBtn) submitBtn.disabled = releases === 0; -} - -async function startDiscographyDownload() { - let artist = artistsPageState.selectedArtist; - // Fallback to library page state - if (!artist && artistDetailPageState.currentArtistId) { - artist = { id: artistDetailPageState.currentArtistId, name: artistDetailPageState.currentArtistName || 'Unknown' }; - } - if (!artist || !artist.id) { - showToast('No artist data available', 'error'); - return; - } - - const checked = document.querySelectorAll('.discog-card-cb:checked'); - const albumEntries = []; - checked.forEach(cb => { - if (cb.closest('.discog-card').style.display !== 'none') { - albumEntries.push({ - id: cb.dataset.albumId, - tracks: parseInt(cb.dataset.tracks) || 0 - }); - } - }); - // Sort by track count descending — process Deluxe/expanded editions first - // so their tracks get added before standard editions (which then get deduped) - albumEntries.sort((a, b) => b.tracks - a.tracks); - const albumIds = albumEntries.map(e => e.id); - - if (albumIds.length === 0) return; - - // Switch to progress view - const grid = document.getElementById('discog-grid'); - const progress = document.getElementById('discog-progress'); - const footer = document.getElementById('discog-footer'); - const filterBar = document.querySelector('.discog-filter-bar'); - - if (grid) grid.style.display = 'none'; - if (filterBar) filterBar.style.display = 'none'; - if (progress) { - progress.style.display = ''; - progress.innerHTML = ''; - } - - // Build progress items - const albumMap = {}; - checked.forEach(cb => { - if (cb.closest('.discog-card').style.display !== 'none') { - const card = cb.closest('.discog-card'); - const id = cb.dataset.albumId; - const title = card.querySelector('.discog-card-title')?.textContent || ''; - const img = card.querySelector('.discog-card-art img')?.src || ''; - albumMap[id] = { title, img }; - - const item = document.createElement('div'); - item.className = 'discog-progress-item'; - item.id = `discog-prog-${id}`; - item.innerHTML = ` -
${img ? `` : '🎵'}
-
-
${_esc(title)}
-
Waiting...
-
-
- `; - progress.appendChild(item); - } - }); - - // Update footer - const submitBtn = document.getElementById('discog-submit-btn'); - if (submitBtn) submitBtn.style.display = 'none'; - if (footer) { - const info = document.getElementById('discog-footer-info'); - if (info) info.textContent = 'Processing... this may take a moment'; - } - - // Mark all items as active - document.querySelectorAll('.discog-progress-item').forEach(item => item.classList.add('active')); - - try { - const response = await fetch(`/api/artist/${artist.id}/download-discography`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ album_ids: albumIds, artist_name: artist.name }) - }); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); // Keep incomplete line in buffer - - for (const line of lines) { - if (!line.trim()) continue; - try { - const data = JSON.parse(line); - - if (data.status === 'complete') { - _handleDiscogProgress({ type: 'complete', total_added: data.total_added, total_skipped: data.total_skipped }); - } else { - // Per-album update - const item = document.getElementById(`discog-prog-${data.album_id}`); - if (!item) continue; - - const statusEl = item.querySelector('.discog-prog-status'); - const iconEl = item.querySelector('.discog-prog-icon'); - item.classList.remove('active'); - - if (data.status === 'done') { - const parts = []; - if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`); - if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`); - statusEl.textContent = parts.join(', ') || 'No new tracks'; - iconEl.innerHTML = data.tracks_added > 0 ? '' : ''; - item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped'); - } else if (data.status === 'error') { - statusEl.textContent = data.message || 'Error'; - iconEl.innerHTML = ''; - item.classList.add('error'); - } - } - } catch (e) { /* skip malformed line */ } - } - } - } catch (err) { - showToast(`Discography download failed: ${err.message}`, 'error'); - } -} - -function _handleDiscogProgress(data) { - if (data.type === 'album') { - const item = document.getElementById(`discog-prog-${data.album_id}`); - if (!item) return; - - const statusEl = item.querySelector('.discog-prog-status'); - const iconEl = item.querySelector('.discog-prog-icon'); - - if (data.status === 'processing') { - statusEl.textContent = `Processing ${data.tracks_total} tracks...`; - item.classList.add('active'); - } else if (data.status === 'done') { - const parts = []; - if (data.tracks_added > 0) parts.push(`${data.tracks_added} added`); - if (data.tracks_skipped > 0) parts.push(`${data.tracks_skipped} skipped`); - statusEl.textContent = parts.join(', ') || 'No new tracks'; - iconEl.innerHTML = data.tracks_added > 0 ? '' : ''; - item.classList.remove('active'); - item.classList.add(data.tracks_added > 0 ? 'done' : 'skipped'); - } else if (data.status === 'error') { - statusEl.textContent = data.message || 'Error'; - iconEl.innerHTML = ''; - item.classList.add('error'); - } - } else if (data.type === 'complete') { - const info = document.getElementById('discog-footer-info'); - if (info) info.textContent = `Done — ${data.total_added} tracks added, ${data.total_skipped} skipped`; - - // Show "Process Wishlist" button - const footer = document.querySelector('.discog-footer-actions'); - if (footer && data.total_added > 0) { - footer.innerHTML = ` - - - `; - } else if (footer) { - footer.innerHTML = ''; - } - } -} - -function closeDiscographyModal() { - const overlay = document.getElementById('discog-modal-overlay'); - if (overlay) { - overlay.classList.remove('visible'); - setTimeout(() => overlay.remove(), 300); - } -} - -// ==================== Enhanced Library Management View ==================== - -function isEnhancedAdmin() { - return currentProfile && currentProfile.is_admin; -} - -function toggleEnhancedView(enabled) { - - const standardSections = document.querySelector('.discography-sections'); - const enhancedContainer = document.getElementById('enhanced-view-container'); - const toggleBtns = document.querySelectorAll('.enhanced-view-toggle-btn'); - - if (!standardSections || !enhancedContainer) return; - - artistDetailPageState.enhancedView = enabled; - - // Update toggle button states - toggleBtns.forEach(btn => { - const view = btn.getAttribute('data-view'); - btn.classList.toggle('active', (view === 'enhanced') === enabled); - }); - - // Hide/show standard filter groups (not relevant in enhanced view) - const filterGroups = document.querySelectorAll('#discography-filters .filter-group'); - filterGroups.forEach(group => { - const label = group.querySelector('.filter-label'); - if (label && label.textContent !== 'View') { - group.style.display = enabled ? 'none' : ''; - } - }); - const dividers = document.querySelectorAll('#discography-filters .filter-divider'); - dividers.forEach((d, i) => { - if (i < dividers.length - 1) d.style.display = enabled ? 'none' : ''; - }); - - if (enabled) { - standardSections.classList.add('hidden'); - enhancedContainer.classList.remove('hidden'); - - if (!artistDetailPageState.enhancedData) { - loadEnhancedViewData(artistDetailPageState.currentArtistId); - } else { - renderEnhancedView(); - } - } else { - standardSections.classList.remove('hidden'); - enhancedContainer.classList.add('hidden'); - const bulkBar = document.getElementById('enhanced-bulk-bar'); - if (bulkBar) bulkBar.classList.remove('visible'); - } -} - -async function loadEnhancedViewData(artistId) { - const container = document.getElementById('enhanced-view-container'); - if (!container) return; - - container.innerHTML = '
Loading library data...
'; - - try { - const response = await fetch(`/api/library/artist/${artistId}/enhanced`); - const data = await response.json(); - - if (!data.success) throw new Error(data.error || 'Failed to load enhanced data'); - - artistDetailPageState.enhancedData = data; - artistDetailPageState.expandedAlbums = new Set(); - artistDetailPageState.selectedTracks = new Set(); - artistDetailPageState.enhancedTrackSort = {}; - artistDetailPageState.serverType = data.server_type || null; - _tagPreviewServerType = data.server_type || null; - _rebuildAlbumMap(); - renderEnhancedView(); - - } catch (error) { - console.error('Error loading enhanced view data:', error); - container.innerHTML = `
Failed to load: ${escapeHtml(error.message)}
`; - } -} - -function renderEnhancedView() { - const container = document.getElementById('enhanced-view-container'); - const data = artistDetailPageState.enhancedData; - if (!container || !data) return; - - container.innerHTML = ''; - - // Artist metadata card (visual + editable) - container.appendChild(renderArtistMetaPanel(data.artist)); - - // Library stats summary bar - container.appendChild(renderEnhancedStatsBar(data)); - - // Group albums by type - const grouped = { album: [], ep: [], single: [] }; - (data.albums || []).forEach(album => { - const type = (album.record_type || 'album').toLowerCase(); - if (grouped[type]) grouped[type].push(album); - else grouped[type] = [album]; - }); - - const sectionLabels = { album: 'Albums', ep: 'EPs', single: 'Singles' }; - for (const [type, label] of Object.entries(sectionLabels)) { - const albums = grouped[type] || []; - if (albums.length === 0) continue; - container.appendChild(renderEnhancedSection(type, label, albums)); - } -} - -function renderEnhancedStatsBar(data) { - const bar = document.createElement('div'); - bar.className = 'enhanced-stats-bar'; - - const albums = data.albums || []; - const totalAlbums = albums.filter(a => (a.record_type || 'album') === 'album').length; - const totalEps = albums.filter(a => a.record_type === 'ep').length; - const totalSingles = albums.filter(a => a.record_type === 'single').length; - const totalTracks = albums.reduce((s, a) => s + (a.tracks ? a.tracks.length : 0), 0); - - // Calculate total duration - let totalDurationMs = 0; - albums.forEach(a => (a.tracks || []).forEach(t => { totalDurationMs += (t.duration || 0); })); - const totalHours = Math.floor(totalDurationMs / 3600000); - const totalMins = Math.floor((totalDurationMs % 3600000) / 60000); - - // Calculate format breakdown - const formatCounts = {}; - albums.forEach(a => (a.tracks || []).forEach(t => { - const fmt = extractFormat(t.file_path); - if (fmt !== '-') formatCounts[fmt] = (formatCounts[fmt] || 0) + 1; - })); - - const statsItems = [ - { value: totalAlbums, label: 'Albums', icon: '💿' }, - { value: totalEps, label: 'EPs', icon: '📀' }, - { value: totalSingles, label: 'Singles', icon: '♪' }, - { value: totalTracks, label: 'Tracks', icon: '🎵' }, - { value: totalHours > 0 ? `${totalHours}h ${totalMins}m` : `${totalMins}m`, label: 'Duration', icon: '⏲' }, - ]; - - let statsHtml = statsItems.map(s => - `
- ${s.value} - ${s.label} -
` - ).join(''); - - // Format badges - const formatBadges = Object.entries(formatCounts) - .sort((a, b) => b[1] - a[1]) - .map(([fmt, count]) => { - const cls = fmt === 'FLAC' ? 'flac' : (fmt === 'MP3' ? 'mp3' : 'other'); - return `${fmt} (${count})`; - }).join(''); - - bar.innerHTML = ` -
${statsHtml}
-
${formatBadges}
- `; - - return bar; -} - -function renderArtistMetaPanel(artist) { - const panel = document.createElement('div'); - panel.className = 'enhanced-artist-meta'; - panel.id = 'enhanced-artist-meta'; - - // Build using DOM to avoid innerHTML escaping issues - const header = document.createElement('div'); - header.className = 'enhanced-artist-meta-header'; - - // Left side: artist image + name display - const headerLeft = document.createElement('div'); - headerLeft.className = 'enhanced-artist-meta-header-left'; - - if (artist.thumb_url) { - const img = document.createElement('img'); - img.className = 'enhanced-artist-meta-image'; - img.src = artist.thumb_url; - img.alt = artist.name || ''; - img.onerror = function () { this.style.display = 'none'; }; - headerLeft.appendChild(img); - } - - const headerInfo = document.createElement('div'); - headerInfo.className = 'enhanced-artist-meta-info'; - const artistTitle = document.createElement('div'); - artistTitle.className = 'enhanced-artist-meta-name'; - artistTitle.textContent = artist.name || 'Unknown Artist'; - headerInfo.appendChild(artistTitle); - - // ID badges row (clickable links) - const idBadges = document.createElement('div'); - idBadges.className = 'enhanced-artist-id-badges'; - const idSources = [ - { key: 'spotify_artist_id', label: 'Spotify', svc: 'spotify' }, - { key: 'musicbrainz_id', label: 'MusicBrainz', svc: 'musicbrainz' }, - { key: 'deezer_id', label: 'Deezer', svc: 'deezer' }, - { key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' }, - { key: 'discogs_id', label: 'Discogs', svc: 'discogs' }, - { key: 'itunes_artist_id', label: 'iTunes', svc: 'itunes' }, - { key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' }, - { key: 'genius_url', label: 'Genius', svc: 'genius' }, - { key: 'tidal_id', label: 'Tidal', svc: 'tidal' }, - { key: 'qobuz_id', label: 'Qobuz', svc: 'qobuz' }, - ]; - idSources.forEach(src => { - if (artist[src.key]) { - idBadges.appendChild(makeClickableBadge(src.svc, 'artist', artist[src.key], src.label)); - } - }); - headerInfo.appendChild(idBadges); - headerLeft.appendChild(headerInfo); - header.appendChild(headerLeft); - - // Right side: admin actions - const headerRight = document.createElement('div'); - headerRight.className = 'enhanced-artist-meta-actions'; - - if (isEnhancedAdmin()) { - const editToggle = document.createElement('button'); - editToggle.className = 'enhanced-meta-edit-toggle'; - editToggle.textContent = 'Edit Metadata'; - editToggle.onclick = () => { - const form = document.getElementById('enhanced-artist-meta-form'); - if (form) { - const isVisible = !form.classList.contains('hidden'); - form.classList.toggle('hidden'); - editToggle.textContent = isVisible ? 'Edit Metadata' : 'Hide Editor'; - editToggle.classList.toggle('active', !isVisible); - } - }; - headerRight.appendChild(editToggle); - - // Enrich dropdown button - const enrichWrap = document.createElement('div'); - enrichWrap.className = 'enhanced-enrich-wrap'; - const enrichBtn = document.createElement('button'); - enrichBtn.className = 'enhanced-enrich-btn'; - enrichBtn.textContent = 'Enrich ▾'; - enrichBtn.onclick = (e) => { - e.stopPropagation(); - enrichMenu.classList.toggle('visible'); - }; - enrichWrap.appendChild(enrichBtn); - - const enrichMenu = document.createElement('div'); - enrichMenu.className = 'enhanced-enrich-menu'; - const services = [ - { id: 'spotify', label: 'Spotify', icon: '🟢' }, - { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, - { id: 'deezer', label: 'Deezer', icon: '🟣' }, - { id: 'discogs', label: 'Discogs', icon: '🟤' }, - { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, - { id: 'itunes', label: 'iTunes', icon: '🔴' }, - { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, - { id: 'genius', label: 'Genius', icon: '🟡' }, - { id: 'tidal', label: 'Tidal', icon: '⬛' }, - { id: 'qobuz', label: 'Qobuz', icon: '🔷' }, - ]; - services.forEach(svc => { - const item = document.createElement('div'); - item.className = 'enhanced-enrich-menu-item'; - item.textContent = `${svc.icon} ${svc.label}`; - item.onclick = (e) => { - e.stopPropagation(); - enrichMenu.classList.remove('visible'); - runEnrichment('artist', artist.id, svc.id, artist.name, '', artist.id); - }; - enrichMenu.appendChild(item); - }); - enrichWrap.appendChild(enrichMenu); - headerRight.appendChild(enrichWrap); - } - - // Sync / Validate button - const syncBtn = document.createElement('button'); - syncBtn.className = 'enhanced-sync-btn'; - syncBtn.innerHTML = '🔄 Sync'; - syncBtn.title = 'Validate files — removes stale entries for tracks no longer on disk'; - syncBtn.onclick = async (e) => { - e.stopPropagation(); - syncBtn.disabled = true; - syncBtn.textContent = 'Syncing...'; - try { - const res = await fetch(`/api/library/artist/${artist.id}/sync`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { - const parts = []; - if (data.new_albums > 0) parts.push(`+${data.new_albums} albums`); - if (data.new_tracks > 0) parts.push(`+${data.new_tracks} tracks`); - if (data.stale_removed > 0) parts.push(`${data.stale_removed} stale removed`); - if (data.empty_albums_removed > 0) parts.push(`${data.empty_albums_removed} empty albums cleaned`); - if (data.name_updated) parts.push('name updated'); - if (parts.length === 0) parts.push('Already in sync'); - showToast(`${data.artist_name}: ${parts.join(', ')}`, 'success'); - // Refresh enhanced view if anything changed - if (data.stale_removed > 0 || data.empty_albums_removed > 0) { - loadEnhancedViewData(artist.id); - } - } else { - showToast(`Sync failed: ${data.error}`, 'error'); - } - } catch (err) { - showToast(`Sync failed: ${err.message}`, 'error'); - } - syncBtn.disabled = false; - syncBtn.innerHTML = '🔄 Sync'; - }; - headerRight.appendChild(syncBtn); - - const reorgAllBtn = document.createElement('button'); - reorgAllBtn.className = 'enhanced-sync-btn'; - reorgAllBtn.innerHTML = '📁 Reorganize All'; - reorgAllBtn.title = 'Reorganize all albums for this artist using path template'; - reorgAllBtn.onclick = () => _showReorganizeAllModal(); - headerRight.appendChild(reorgAllBtn); - - header.appendChild(headerRight); - - panel.appendChild(header); - - // Match status row (clickable to rematch) - const statusRow = document.createElement('div'); - statusRow.className = 'enhanced-match-status-row'; - const statusServices = [ - { key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' }, - { key: 'musicbrainz_match_status', label: 'MusicBrainz', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' }, - { key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' }, - { key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' }, - { key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' }, - { key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' }, - { key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' }, - { key: 'genius_match_status', label: 'Genius', attempted: 'genius_last_attempted', svc: 'genius' }, - { key: 'tidal_match_status', label: 'Tidal', attempted: 'tidal_last_attempted', svc: 'tidal' }, - { key: 'qobuz_match_status', label: 'Qobuz', attempted: 'qobuz_last_attempted', svc: 'qobuz' }, - ]; - statusServices.forEach(s => { - const status = artist[s.key]; - const attempted = artist[s.attempted]; - const chip = document.createElement('span'); - chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`; - chip.textContent = `${s.label}: ${status || 'pending'}`; - const tipParts = []; - if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`); - tipParts.push('Click to rematch'); - chip.title = tipParts.join(' · '); - chip.onclick = () => openManualMatchModal('artist', artist.id, s.svc, artist.name, artist.id); - statusRow.appendChild(chip); - }); - panel.appendChild(statusRow); - - // Collapsible edit form (hidden by default) - const form = document.createElement('div'); - form.className = 'enhanced-artist-meta-form hidden'; - form.id = 'enhanced-artist-meta-form'; - - const editableFields = [ - { key: 'name', label: 'Artist Name', type: 'text' }, - { key: 'genres', label: 'Genres (comma separated)', type: 'text', isArray: true }, - { key: 'label', label: 'Label', type: 'text' }, - { key: 'style', label: 'Style', type: 'text' }, - { key: 'mood', label: 'Mood', type: 'text' }, - { key: 'summary', label: 'Summary / Bio', type: 'textarea', wide: true }, - ]; - - const grid = document.createElement('div'); - grid.className = 'enhanced-artist-meta-grid'; - - editableFields.forEach(f => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'enhanced-meta-field' + (f.wide ? ' wide' : ''); - - const label = document.createElement('label'); - label.className = 'enhanced-meta-field-label'; - label.textContent = f.label; - fieldDiv.appendChild(label); - - const val = f.isArray - ? (Array.isArray(artist[f.key]) ? artist[f.key].join(', ') : (artist[f.key] || '')) - : (artist[f.key] || ''); - - if (f.type === 'textarea') { - const ta = document.createElement('textarea'); - ta.className = 'enhanced-meta-field-input'; - ta.dataset.field = f.key; - ta.placeholder = f.label + '...'; - ta.textContent = val; - fieldDiv.appendChild(ta); - } else { - const inp = document.createElement('input'); - inp.type = 'text'; - inp.className = 'enhanced-meta-field-input'; - inp.dataset.field = f.key; - inp.value = val; - inp.placeholder = f.label + '...'; - fieldDiv.appendChild(inp); - } - - grid.appendChild(fieldDiv); - }); - - form.appendChild(grid); - - // Save/revert buttons - const formActions = document.createElement('div'); - formActions.className = 'enhanced-artist-form-actions'; - const revertBtn = document.createElement('button'); - revertBtn.className = 'enhanced-meta-cancel-btn'; - revertBtn.textContent = 'Revert'; - revertBtn.onclick = () => revertArtistMetadata(); - const saveBtn = document.createElement('button'); - saveBtn.className = 'enhanced-meta-save-btn'; - saveBtn.textContent = 'Save Changes'; - saveBtn.onclick = () => saveArtistMetadata(); - formActions.appendChild(revertBtn); - formActions.appendChild(saveBtn); - form.appendChild(formActions); - - panel.appendChild(form); - - return panel; -} - -function renderEnhancedSection(type, label, albums) { - const section = document.createElement('div'); - section.className = 'enhanced-section'; - - const totalTracks = albums.reduce((sum, a) => sum + (a.tracks ? a.tracks.length : 0), 0); - - const sectionHeader = document.createElement('div'); - sectionHeader.className = 'enhanced-section-header'; - sectionHeader.innerHTML = ` - ${label} - ${albums.length} release${albums.length !== 1 ? 's' : ''} · ${totalTracks} tracks - `; - section.appendChild(sectionHeader); - - const grid = document.createElement('div'); - grid.className = 'enhanced-album-grid'; - - albums.forEach(album => { - const wrapper = document.createElement('div'); - wrapper.className = 'enhanced-album-wrapper'; - wrapper.id = `enhanced-album-wrapper-${album.id}`; - const isExpanded = artistDetailPageState.expandedAlbums.has(album.id); - if (isExpanded) wrapper.classList.add('expanded'); - - wrapper.appendChild(renderAlbumRow(album, type)); - - const tracksPanel = document.createElement('div'); - tracksPanel.className = 'enhanced-tracks-panel'; - tracksPanel.id = `enhanced-tracks-panel-${album.id}`; - if (isExpanded) tracksPanel.classList.add('visible'); - const inner = document.createElement('div'); - inner.className = 'enhanced-tracks-panel-inner'; - if (isExpanded) { - inner.dataset.rendered = 'true'; - inner.appendChild(renderExpandedAlbumHeader(album)); - inner.appendChild(renderAlbumMetaRow(album)); - inner.appendChild(renderTrackTable(album)); - } - tracksPanel.appendChild(inner); - wrapper.appendChild(tracksPanel); - - grid.appendChild(wrapper); - }); - section.appendChild(grid); - - return section; -} - -function renderAlbumRow(album, type) { - const row = document.createElement('div'); - row.className = 'enhanced-album-row'; - row.id = `enhanced-album-row-${album.id}`; - - if (artistDetailPageState.expandedAlbums.has(album.id)) row.classList.add('expanded'); - - const trackCount = album.tracks ? album.tracks.length : 0; - const typeClass = (type || 'album').toLowerCase(); - - // Total duration for this album - let albumDurMs = 0; - (album.tracks || []).forEach(t => { albumDurMs += (t.duration || 0); }); - const albumDur = formatDurationMs(albumDurMs); - - // Format breakdown for this album - const fmts = {}; - (album.tracks || []).forEach(t => { - const f = extractFormat(t.file_path); - if (f !== '-') fmts[f] = (fmts[f] || 0) + 1; - }); - const primaryFormat = Object.keys(fmts).sort((a, b) => fmts[b] - fmts[a])[0] || ''; - - // Build with DOM for safety - const expandIcon = document.createElement('span'); - expandIcon.className = 'enhanced-album-expand-icon'; - expandIcon.innerHTML = '▶'; - row.appendChild(expandIcon); - - // Album art - larger, prominent - const artWrap = document.createElement('div'); - artWrap.className = 'enhanced-album-art-wrap'; - if (album.thumb_url) { - const img = document.createElement('img'); - img.className = 'enhanced-album-thumb'; - img.src = album.thumb_url; - img.alt = ''; - img.loading = 'lazy'; - img.onerror = function () { - const fallback = document.createElement('div'); - fallback.className = 'enhanced-album-thumb-fallback'; - fallback.innerHTML = '🎵'; - this.replaceWith(fallback); - }; - artWrap.appendChild(img); - } else { - const fallback = document.createElement('div'); - fallback.className = 'enhanced-album-thumb-fallback'; - fallback.innerHTML = '🎵'; - artWrap.appendChild(fallback); - } - row.appendChild(artWrap); - - // Info block (title + meta line) - const infoBlock = document.createElement('div'); - infoBlock.className = 'enhanced-album-info-block'; - - const titleEl = document.createElement('span'); - titleEl.className = 'enhanced-album-title'; - titleEl.textContent = album.title || 'Unknown'; - titleEl.title = album.title || ''; - infoBlock.appendChild(titleEl); - - const metaLine = document.createElement('span'); - metaLine.className = 'enhanced-album-meta-line'; - const metaParts = []; - if (album.year) metaParts.push(String(album.year)); - metaParts.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`); - if (albumDur !== '-') metaParts.push(albumDur); - if (album.label) metaParts.push(album.label); - metaLine.textContent = metaParts.join(' \u00B7 '); - infoBlock.appendChild(metaLine); - - row.appendChild(infoBlock); - - // Type badge - const badge = document.createElement('span'); - badge.className = `enhanced-album-type-badge ${typeClass}`; - badge.textContent = type; - row.appendChild(badge); - - // Format badge inline - if (primaryFormat) { - const fmtBadge = document.createElement('span'); - const fmtClass = primaryFormat === 'FLAC' ? 'flac' : (primaryFormat === 'MP3' ? 'mp3' : 'other'); - fmtBadge.className = `enhanced-format-badge ${fmtClass}`; - fmtBadge.textContent = primaryFormat; - row.appendChild(fmtBadge); - } - - row.addEventListener('click', () => toggleAlbumExpand(album.id)); - - return row; -} - -function toggleAlbumExpand(albumId) { - const row = document.getElementById(`enhanced-album-row-${albumId}`); - const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); - const wrapper = document.getElementById(`enhanced-album-wrapper-${albumId}`); - if (!row || !panel) return; - - const isExpanded = artistDetailPageState.expandedAlbums.has(albumId); - - if (isExpanded) { - artistDetailPageState.expandedAlbums.delete(albumId); - row.classList.remove('expanded'); - panel.classList.remove('visible'); - if (wrapper) wrapper.classList.remove('expanded'); - } else { - artistDetailPageState.expandedAlbums.add(albumId); - row.classList.add('expanded'); - panel.classList.add('visible'); - if (wrapper) wrapper.classList.add('expanded'); - - // Lazy render - const inner = panel.querySelector('.enhanced-tracks-panel-inner'); - if (inner && !inner.dataset.rendered) { - const album = findEnhancedAlbum(albumId); - if (album) { - inner.innerHTML = ''; - inner.appendChild(renderExpandedAlbumHeader(album)); - inner.appendChild(renderAlbumMetaRow(album)); - inner.appendChild(renderTrackTable(album)); - inner.dataset.rendered = 'true'; - } - } - } -} - -function findEnhancedAlbum(albumId) { - // Use cached map for O(1) lookups instead of O(n) array scan - if (artistDetailPageState._albumMap) { - return artistDetailPageState._albumMap.get(String(albumId)) || null; - } - const data = artistDetailPageState.enhancedData; - if (!data || !data.albums) return null; - return data.albums.find(a => String(a.id) === String(albumId)); -} - -function _rebuildAlbumMap() { - const data = artistDetailPageState.enhancedData; - if (!data || !data.albums) { artistDetailPageState._albumMap = null; return; } - const map = new Map(); - data.albums.forEach(a => map.set(String(a.id), a)); - artistDetailPageState._albumMap = map; -} - -function renderExpandedAlbumHeader(album) { - const header = document.createElement('div'); - header.className = 'enhanced-expanded-header'; - - // Large album art - if (album.thumb_url) { - const img = document.createElement('img'); - img.className = 'enhanced-expanded-art'; - img.src = album.thumb_url; - img.alt = album.title || ''; - img.onerror = function () { this.style.display = 'none'; }; - header.appendChild(img); - } - - const info = document.createElement('div'); - info.className = 'enhanced-expanded-info'; - - const title = document.createElement('div'); - title.className = 'enhanced-expanded-title'; - title.textContent = album.title || 'Unknown'; - info.appendChild(title); - - const meta = document.createElement('div'); - meta.className = 'enhanced-expanded-meta'; - - const details = []; - if (album.year) details.push(String(album.year)); - const trackCount = album.tracks ? album.tracks.length : 0; - details.push(`${trackCount} track${trackCount !== 1 ? 's' : ''}`); - let durMs = 0; - (album.tracks || []).forEach(t => { durMs += (t.duration || 0); }); - if (durMs > 0) details.push(formatDurationMs(durMs)); - if (album.label) details.push(album.label); - if (album.record_type) details.push(album.record_type.toUpperCase()); - - meta.textContent = details.join(' \u00B7 '); - info.appendChild(meta); - - // Genre tags - const genres = Array.isArray(album.genres) ? album.genres : []; - if (genres.length > 0) { - const genreRow = document.createElement('div'); - genreRow.className = 'enhanced-expanded-genres'; - genres.forEach(g => { - const tag = document.createElement('span'); - tag.className = 'enhanced-genre-tag'; - tag.textContent = g; - genreRow.appendChild(tag); - }); - info.appendChild(genreRow); - } - - // External ID badges (clickable links) - const ids = document.createElement('div'); - ids.className = 'enhanced-expanded-ids'; - const idFields = [ - { key: 'spotify_album_id', label: 'Spotify', svc: 'spotify' }, - { key: 'musicbrainz_release_id', label: 'MusicBrainz', svc: 'musicbrainz' }, - { key: 'deezer_id', label: 'Deezer', svc: 'deezer' }, - { key: 'audiodb_id', label: 'AudioDB', svc: 'audiodb' }, - { key: 'discogs_id', label: 'Discogs', svc: 'discogs' }, - { key: 'itunes_album_id', label: 'iTunes', svc: 'itunes' }, - { key: 'lastfm_url', label: 'Last.fm', svc: 'lastfm' }, - ]; - idFields.forEach(f => { - if (album[f.key]) { - ids.appendChild(makeClickableBadge(f.svc, 'album', album[f.key], f.label)); - } - }); - if (ids.children.length > 0) info.appendChild(ids); - - // Resolve artist name for enrichment calls - const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - - // Match status chips (clickable to rematch) - const statusRow = document.createElement('div'); - statusRow.className = 'enhanced-match-status-row compact'; - const statusSvcs = [ - { key: 'spotify_match_status', label: 'Spotify', attempted: 'spotify_last_attempted', svc: 'spotify' }, - { key: 'musicbrainz_match_status', label: 'MB', attempted: 'musicbrainz_last_attempted', svc: 'musicbrainz' }, - { key: 'deezer_match_status', label: 'Deezer', attempted: 'deezer_last_attempted', svc: 'deezer' }, - { key: 'audiodb_match_status', label: 'AudioDB', attempted: 'audiodb_last_attempted', svc: 'audiodb' }, - { key: 'discogs_match_status', label: 'Discogs', attempted: 'discogs_last_attempted', svc: 'discogs' }, - { key: 'itunes_match_status', label: 'iTunes', attempted: 'itunes_last_attempted', svc: 'itunes' }, - { key: 'lastfm_match_status', label: 'Last.fm', attempted: 'lastfm_last_attempted', svc: 'lastfm' }, - ]; - statusSvcs.forEach(s => { - const status = album[s.key]; - const attempted = album[s.attempted]; - const chip = document.createElement('span'); - chip.className = `enhanced-match-chip clickable ${status === 'matched' ? 'matched' : (status === 'not_found' ? 'not-found' : 'pending')}`; - chip.textContent = `${s.label}: ${status || '—'}`; - const tipParts = []; - if (attempted) tipParts.push(`Last: ${new Date(attempted).toLocaleString()}`); - tipParts.push('Click to rematch'); - chip.title = tipParts.join(' · '); - chip.onclick = (e) => { - e.stopPropagation(); - const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; - openManualMatchModal('album', album.id, s.svc, album.title || '', aId); - }; - statusRow.appendChild(chip); - }); - info.appendChild(statusRow); - - // Action buttons row - const enrichRow = document.createElement('div'); - enrichRow.className = 'enhanced-expanded-actions'; - - if (isEnhancedAdmin()) { - const albumEnrichWrap = document.createElement('div'); - albumEnrichWrap.className = 'enhanced-enrich-wrap'; - const albumEnrichBtn = document.createElement('button'); - albumEnrichBtn.className = 'enhanced-enrich-btn small'; - albumEnrichBtn.textContent = 'Enrich Album ▾'; - albumEnrichBtn.onclick = (e) => { e.stopPropagation(); albumEnrichMenu.classList.toggle('visible'); }; - albumEnrichWrap.appendChild(albumEnrichBtn); - const albumEnrichMenu = document.createElement('div'); - albumEnrichMenu.className = 'enhanced-enrich-menu'; - [ - { id: 'spotify', label: 'Spotify', icon: '🟢' }, - { id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' }, - { id: 'deezer', label: 'Deezer', icon: '🟣' }, - { id: 'discogs', label: 'Discogs', icon: '🟤' }, - { id: 'audiodb', label: 'AudioDB', icon: '🔵' }, - { id: 'itunes', label: 'iTunes', icon: '🔴' }, - { id: 'lastfm', label: 'Last.fm', icon: '⚪' }, - { id: 'genius', label: 'Genius', icon: '🟡' }, - ].forEach(svc => { - const item = document.createElement('div'); - item.className = 'enhanced-enrich-menu-item'; - item.textContent = `${svc.icon} ${svc.label}`; - item.onclick = (e) => { - e.stopPropagation(); - albumEnrichMenu.classList.remove('visible'); - const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : ''; - runEnrichment('album', album.id, svc.id, album.title || '', artistName, aId); - }; - albumEnrichMenu.appendChild(item); - }); - albumEnrichWrap.appendChild(albumEnrichMenu); - enrichRow.appendChild(albumEnrichWrap); - - const writeTagsBtn = document.createElement('button'); - writeTagsBtn.className = 'enhanced-write-tags-album-btn'; - writeTagsBtn.innerHTML = '✎ Write All Tags'; - writeTagsBtn.title = 'Write DB metadata to file tags for all tracks in this album'; - writeTagsBtn.onclick = (e) => { e.stopPropagation(); writeAlbumTags(album.id); }; - enrichRow.appendChild(writeTagsBtn); - - const rgAlbumBtn = document.createElement('button'); - rgAlbumBtn.className = 'enhanced-rg-album-btn'; - rgAlbumBtn.innerHTML = '♫ ReplayGain'; - rgAlbumBtn.title = 'Analyze ReplayGain for all tracks in this album (writes track + album gain)'; - rgAlbumBtn.dataset.albumId = album.id; - rgAlbumBtn.onclick = (e) => { e.stopPropagation(); analyzeAlbumReplayGain(album.id, rgAlbumBtn); }; - enrichRow.appendChild(rgAlbumBtn); - - const reorganizeBtn = document.createElement('button'); - reorganizeBtn.className = 'enhanced-reorganize-album-btn'; - reorganizeBtn.innerHTML = '📁 Reorganize'; - reorganizeBtn.title = 'Reorganize album files using a custom path template'; - reorganizeBtn.onclick = (e) => { e.stopPropagation(); showReorganizeModal(album.id); }; - enrichRow.appendChild(reorganizeBtn); - - const redownloadBtn = document.createElement('button'); - redownloadBtn.className = 'enhanced-redownload-album-btn'; - redownloadBtn.innerHTML = '↻ Redownload'; - redownloadBtn.title = 'Redownload this album (opens Download Missing modal with force-download)'; - redownloadBtn.onclick = (e) => { - e.stopPropagation(); - const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - redownloadLibraryAlbum(album, aName, redownloadBtn); - }; - enrichRow.appendChild(redownloadBtn); - - const deleteAlbumBtn = document.createElement('button'); - deleteAlbumBtn.className = 'enhanced-delete-album-btn'; - deleteAlbumBtn.textContent = 'Delete Album'; - deleteAlbumBtn.onclick = (e) => { e.stopPropagation(); deleteLibraryAlbum(album.id); }; - enrichRow.appendChild(deleteAlbumBtn); - } - - // Report Issue button (available to all users) - const reportBtn = document.createElement('button'); - reportBtn.className = 'enhanced-report-issue-btn'; - reportBtn.innerHTML = '⚑ Report Issue'; - reportBtn.title = 'Report a problem with this album'; - reportBtn.onclick = (e) => { - e.stopPropagation(); - const aName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - showReportIssueModal('album', album.id, album.title || '', aName); - }; - enrichRow.appendChild(reportBtn); - - info.appendChild(enrichRow); - - header.appendChild(info); - return header; -} - -function renderAlbumMetaRow(album) { - const row = document.createElement('div'); - row.className = 'enhanced-album-meta-row'; - row.id = `enhanced-album-meta-${album.id}`; - - const fields = [ - { key: 'title', label: 'Title', value: album.title || '' }, - { key: 'year', label: 'Year', value: album.year || '', type: 'number' }, - { key: 'genres', label: 'Genres', value: Array.isArray(album.genres) ? album.genres.join(', ') : (album.genres || '') }, - { key: 'label', label: 'Label', value: album.label || '' }, - { key: 'style', label: 'Style', value: album.style || '' }, - { key: 'mood', label: 'Mood', value: album.mood || '' }, - { key: 'record_type', label: 'Type', value: album.record_type || 'album' }, - { key: 'explicit', label: 'Explicit', value: album.explicit ? '1' : '0' }, - ]; - - const admin = isEnhancedAdmin(); - fields.forEach(f => { - const fieldDiv = document.createElement('div'); - fieldDiv.className = 'enhanced-album-meta-field'; - const label = document.createElement('label'); - label.className = 'enhanced-album-meta-label'; - label.textContent = f.label; - fieldDiv.appendChild(label); - if (admin) { - const input = document.createElement('input'); - input.className = 'enhanced-album-meta-input'; - input.type = f.type || 'text'; - input.dataset.albumId = album.id; - input.dataset.field = f.key; - input.value = String(f.value); - input.addEventListener('click', e => e.stopPropagation()); - fieldDiv.appendChild(input); - } else { - const span = document.createElement('span'); - span.className = 'enhanced-album-meta-value'; - span.textContent = String(f.value) || '—'; - fieldDiv.appendChild(span); - } - row.appendChild(fieldDiv); - }); - - if (admin) { - const saveDiv = document.createElement('div'); - saveDiv.className = 'enhanced-album-meta-field'; - const spacer = document.createElement('label'); - spacer.className = 'enhanced-album-meta-label'; - spacer.innerHTML = ' '; - saveDiv.appendChild(spacer); - const saveBtn = document.createElement('button'); - saveBtn.className = 'enhanced-album-save-btn'; - saveBtn.textContent = 'Save Album'; - saveBtn.onclick = (e) => { e.stopPropagation(); saveAlbumMetadata(album.id); }; - saveDiv.appendChild(saveBtn); - row.appendChild(saveDiv); - } - - return row; -} - -function _buildTrackRow(track, album, admin) { - const tr = document.createElement('tr'); - tr.dataset.trackId = track.id; - tr.dataset.albumId = album.id; - if (artistDetailPageState.selectedTracks.has(String(track.id))) tr.classList.add('selected'); - - // Checkbox (admin only) - if (admin) { - const cbTd = document.createElement('td'); - const cb = document.createElement('input'); - cb.type = 'checkbox'; - cb.className = 'enhanced-track-checkbox'; - cb.checked = artistDetailPageState.selectedTracks.has(String(track.id)); - cbTd.appendChild(cb); - tr.appendChild(cbTd); - } - - // Play button - const playTd = document.createElement('td'); - playTd.className = 'col-play'; - const playBtn = document.createElement('button'); - playBtn.className = 'enhanced-play-btn'; - playBtn.innerHTML = '▶'; - playBtn.title = track.file_path ? 'Play track' : 'No file available'; - if (!track.file_path) playBtn.disabled = true; - playTd.appendChild(playBtn); - tr.appendChild(playTd); - - // Track number - const numTd = document.createElement('td'); - numTd.className = 'col-num' + (admin ? ' editable' : ''); - numTd.textContent = track.track_number || '-'; - tr.appendChild(numTd); - - // Disc number - const discTd = document.createElement('td'); - discTd.className = 'col-disc'; - discTd.textContent = track.disc_number || '-'; - tr.appendChild(discTd); - - // Title - const titleTd = document.createElement('td'); - titleTd.className = 'col-title' + (admin ? ' editable' : ''); - titleTd.textContent = track.title || 'Unknown'; - tr.appendChild(titleTd); - - // Duration - const durTd = document.createElement('td'); - durTd.className = 'col-duration'; - durTd.textContent = formatDurationMs(track.duration); - tr.appendChild(durTd); - - // Format - const fmtTd = document.createElement('td'); - fmtTd.className = 'col-format'; - const format = extractFormat(track.file_path); - const fmtSpan = document.createElement('span'); - const fmtClass = format === 'FLAC' ? 'flac' : (format === 'MP3' ? 'mp3' : 'other'); - fmtSpan.className = `enhanced-format-badge ${fmtClass}`; - fmtSpan.textContent = format; - fmtTd.appendChild(fmtSpan); - tr.appendChild(fmtTd); - - // Bitrate - const brTd = document.createElement('td'); - brTd.className = 'col-bitrate'; - const brSpan = document.createElement('span'); - const brClass = (track.bitrate || 0) >= 320 ? 'high' : ((track.bitrate || 0) >= 192 ? 'medium' : 'low'); - brSpan.className = `enhanced-bitrate ${brClass}`; - brSpan.textContent = track.bitrate ? track.bitrate + ' kbps' : '-'; - brTd.appendChild(brSpan); - tr.appendChild(brTd); - - // BPM - const bpmTd = document.createElement('td'); - bpmTd.className = 'col-bpm' + (admin ? ' editable' : ''); - bpmTd.textContent = track.bpm || '-'; - tr.appendChild(bpmTd); - - // File path - const pathTd = document.createElement('td'); - pathTd.className = 'col-path'; - const filePath = track.file_path || '-'; - const fileName = filePath !== '-' ? filePath.split(/[\\/]/).pop() : '-'; - pathTd.textContent = fileName; - pathTd.title = filePath; - tr.appendChild(pathTd); - - // Match status chips - const matchTd = document.createElement('td'); - matchTd.className = 'col-match'; - const matchCell = document.createElement('div'); - matchCell.className = 'enhanced-track-match-cell'; - const trackServices = [ - { svc: 'spotify', col: 'spotify_track_id', label: 'SP' }, - { svc: 'musicbrainz', col: 'musicbrainz_recording_id', label: 'MB' }, - { svc: 'deezer', col: 'deezer_id', label: 'Dz' }, - { svc: 'audiodb', col: 'audiodb_id', label: 'ADB' }, - { svc: 'itunes', col: 'itunes_track_id', label: 'iT' }, - { svc: 'lastfm', col: 'lastfm_url', label: 'LFM' }, - { svc: 'genius', col: 'genius_id', label: 'Gen' }, - ]; - trackServices.forEach(s => { - const hasId = !!track[s.col]; - const chip = document.createElement('span'); - chip.className = 'enhanced-track-match-chip' + (hasId ? ' matched' : ' not-found'); - chip.textContent = s.label; - chip.title = hasId ? `${s.svc}: ${track[s.col]}` : `${s.svc}: no match`; - chip.dataset.service = s.svc; - matchCell.appendChild(chip); - }); - matchTd.appendChild(matchCell); - tr.appendChild(matchTd); - - // Add to Queue button - const queueTd = document.createElement('td'); - queueTd.className = 'col-queue'; - if (track.file_path) { - const queueBtn = document.createElement('button'); - queueBtn.className = 'enhanced-queue-btn'; - queueBtn.innerHTML = '+'; - queueBtn.title = 'Add to queue'; - queueTd.appendChild(queueBtn); - } - tr.appendChild(queueTd); - - if (admin) { - // Write Tags button (admin only) - const tagTd = document.createElement('td'); - tagTd.className = 'col-writetag'; - if (track.file_path) { - const tagBtn = document.createElement('button'); - tagBtn.className = 'enhanced-write-tag-btn'; - tagBtn.innerHTML = '✎'; - tagBtn.title = 'Write tags to file'; - tagTd.appendChild(tagBtn); - - const rgBtn = document.createElement('button'); - rgBtn.className = 'enhanced-rg-btn'; - rgBtn.textContent = 'RG'; - rgBtn.title = 'Analyze & write ReplayGain (track gain)'; - tagTd.appendChild(rgBtn); - } - tr.appendChild(tagTd); - - // Track actions cell — source info, redownload, delete (admin only) - const actionsTd = document.createElement('td'); - actionsTd.className = 'col-track-actions'; - actionsTd.innerHTML = ` -
- - - -
- `; - tr.appendChild(actionsTd); - } else { - // Report Issue button per track (non-admin) - const reportTd = document.createElement('td'); - reportTd.className = 'col-report'; - const reportBtn = document.createElement('button'); - reportBtn.className = 'enhanced-track-report-btn'; - reportBtn.innerHTML = '⚑'; - reportBtn.title = 'Report issue with this track'; - reportTd.appendChild(reportBtn); - tr.appendChild(reportTd); - } - - // Mobile actions column (visible only on mobile via CSS) - const mobileTd = document.createElement('td'); - mobileTd.className = 'col-mobile-actions'; - const mobileBtn = document.createElement('button'); - mobileBtn.className = 'enhanced-mobile-actions-btn'; - mobileBtn.innerHTML = '⋯'; - mobileBtn.title = 'Actions'; - mobileTd.appendChild(mobileBtn); - tr.appendChild(mobileTd); - - return tr; -} - -function _getTrackDataFromRow(tr) { - const trackId = tr.dataset.trackId; - const albumId = tr.dataset.albumId; - const album = findEnhancedAlbum(albumId); - if (!album) return null; - const track = (album.tracks || []).find(t => String(t.id) === String(trackId)); - return track ? { track, album, trackId, albumId } : null; -} - -function _attachTableDelegation(table, album) { - // Single click handler for the entire table — replaces 12-16 per-row handlers - const admin = isEnhancedAdmin(); - table.addEventListener('click', (e) => { - const target = e.target; - const tr = target.closest('tr[data-track-id]'); - - // Header checkbox (select all) - if (target.closest('thead') && target.classList.contains('enhanced-track-checkbox')) { - toggleSelectAllTracks(album.id, target.checked); - return; - } - - // Sort header click - const th = target.closest('th[data-sort-field]'); - if (th) { - cancelInlineEdit(); - const sortField = th.dataset.sortField; - const current = artistDetailPageState.enhancedTrackSort[album.id]; - const ascending = current && current.field === sortField ? !current.ascending : true; - artistDetailPageState.enhancedTrackSort[album.id] = { field: sortField, ascending }; - sortEnhancedTracks(album, sortField, ascending); - _rebuildTbody(table, album); - // Update header sort indicators - table.querySelectorAll('th[data-sort-field]').forEach(h => { - const sf = h.dataset.sortField; - const baseLabel = h.dataset.label || ''; - const sort = artistDetailPageState.enhancedTrackSort[album.id]; - h.textContent = sort && sort.field === sf ? baseLabel + (sort.ascending ? ' \u25B2' : ' \u25BC') : baseLabel; - }); - return; - } - - if (!tr) return; - const info = _getTrackDataFromRow(tr); - if (!info) return; - const { track, trackId } = info; - - // Checkbox - if (target.classList.contains('enhanced-track-checkbox')) { - toggleTrackSelection(String(trackId)); - return; - } - - // Play button - if (target.closest('.enhanced-play-btn')) { - e.stopPropagation(); - if (track.file_path) { - const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - playLibraryTrack(track, album.title || '', artistName); - } - return; - } - - // Inline editable cells (admin) - if (admin) { - const cell = target.closest('td.editable'); - if (cell) { - e.stopPropagation(); - if (cell.classList.contains('col-num')) { - startInlineEdit(cell, 'track', track.id, 'track_number', track.track_number || ''); - } else if (cell.classList.contains('col-title')) { - startInlineEdit(cell, 'track', track.id, 'title', track.title || ''); - } else if (cell.classList.contains('col-bpm')) { - startInlineEdit(cell, 'track', track.id, 'bpm', track.bpm || ''); - } - return; - } - } - - // Match chip click (admin — open manual match modal) - if (admin) { - const chip = target.closest('.enhanced-track-match-chip'); - if (chip) { - e.stopPropagation(); - const svc = chip.dataset.service; - const aId = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null; - openManualMatchModal('track', track.id, svc, track.title || '', aId); - return; - } - } - - // Queue button - if (target.closest('.enhanced-queue-btn')) { - e.stopPropagation(); - if (track.file_path) { - const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - let albumArt = album.thumb_url || null; - if (!albumArt && artistDetailPageState.enhancedData) { - albumArt = artistDetailPageState.enhancedData.artist?.thumb_url; - } - addToQueue({ - title: track.title || 'Unknown Track', - artist: artistName || 'Unknown Artist', - album: album.title || 'Unknown Album', - file_path: track.file_path, - filename: track.file_path, - is_library: true, - image_url: albumArt, - id: track.id, - artist_id: artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null, - album_id: album.id, - bitrate: track.bitrate, - sample_rate: track.sample_rate - }); - } - return; - } - - // Write tags button (admin) - if (target.closest('.enhanced-write-tag-btn')) { - e.stopPropagation(); - showTagPreview(track.id); - return; - } - - // ReplayGain analyze button (admin) - if (target.closest('.enhanced-rg-btn')) { - e.stopPropagation(); - analyzeTrackReplayGain(track.id, target.closest('.enhanced-rg-btn')); - return; - } - - // Source info button (admin) - if (target.closest('.enhanced-source-info-btn')) { - e.stopPropagation(); - showTrackSourceInfo(track, target.closest('.enhanced-source-info-btn')); - return; - } - - // Redownload button (admin) - if (target.closest('.enhanced-redownload-btn')) { - e.stopPropagation(); - showTrackRedownloadModal(track, album); - return; - } - - // Delete button (admin) - if (target.closest('.enhanced-delete-btn')) { - e.stopPropagation(); - deleteLibraryTrack(track.id, album.id); - return; - } - - // Report button (non-admin) - if (target.closest('.enhanced-track-report-btn')) { - e.stopPropagation(); - const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - showReportIssueModal('track', track.id, track.title || 'Unknown', artistName, album.title || ''); - return; - } - - // Mobile actions button (⋯) - if (target.closest('.enhanced-mobile-actions-btn')) { - e.stopPropagation(); - _showMobileTrackActions(track, album); - return; - } - }); -} - -function _showMobileTrackActions(track, album) { - // Remove any existing popover - document.querySelectorAll('.mobile-popover-overlay, .enhanced-mobile-actions-popover').forEach(el => el.remove()); - - const overlay = document.createElement('div'); - overlay.className = 'mobile-popover-overlay'; - - const popover = document.createElement('div'); - popover.className = 'enhanced-mobile-actions-popover'; - - const title = document.createElement('div'); - title.className = 'popover-title'; - title.textContent = track.title || 'Track'; - popover.appendChild(title); - - const admin = isEnhancedAdmin(); - const artistName = artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.name : ''; - const albumArt = album.thumb_url || (artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist?.thumb_url : null); - - const actions = []; - if (track.file_path) { - actions.push({ - icon: '▶', label: 'Play', action: () => { - playLibraryTrack({ id: track.id, title: track.title, file_path: track.file_path, bitrate: track.bitrate, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id }, album.title || '', artistName); - } - }); - actions.push({ - icon: '+', label: 'Add to Queue', action: () => { - addToQueue({ title: track.title || 'Unknown', artist: artistName, album: album.title || '', file_path: track.file_path, filename: track.file_path, is_library: true, image_url: albumArt, id: track.id, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id, bitrate: track.bitrate }); - } - }); - } - if (admin && track.file_path) { - actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) }); - } - if (admin) { - actions.push({ icon: 'ℹ', label: 'Source Info', action: () => showTrackSourceInfo(track, null) }); - actions.push({ icon: '↻', label: 'Redownload Track', action: () => showTrackRedownloadModal(track, album) }); - actions.push({ icon: '✕', label: 'Delete Track', cls: 'popover-delete', action: () => deleteLibraryTrack(track.id, album.id) }); - } - - actions.forEach(a => { - const btn = document.createElement('button'); - if (a.cls) btn.className = a.cls; - btn.innerHTML = `${a.icon}${a.label}`; - btn.addEventListener('click', () => { close(); a.action(); }); - popover.appendChild(btn); - }); - - const cancelBtn = document.createElement('button'); - cancelBtn.className = 'popover-cancel'; - cancelBtn.textContent = 'Cancel'; - cancelBtn.addEventListener('click', close); - popover.appendChild(cancelBtn); - - function close() { - overlay.remove(); - popover.remove(); - } - overlay.addEventListener('click', close); - - document.body.appendChild(overlay); - document.body.appendChild(popover); -} - -function _rebuildTbody(table, album) { - // Replace only the tbody — keeps thead and event delegation intact - const admin = isEnhancedAdmin(); - const oldTbody = table.querySelector('tbody'); - const newTbody = document.createElement('tbody'); - (album.tracks || []).forEach(track => { - newTbody.appendChild(_buildTrackRow(track, album, admin)); - }); - if (oldTbody) table.replaceChild(newTbody, oldTbody); - else table.appendChild(newTbody); -} - -function renderTrackTable(album) { - const wrapper = document.createElement('div'); - const tracks = album.tracks || []; - - // Re-apply stored sort order if any - const activeSort = artistDetailPageState.enhancedTrackSort[album.id]; - if (activeSort) { - sortEnhancedTracks(album, activeSort.field, activeSort.ascending); - } - - if (tracks.length === 0) { - wrapper.innerHTML = '
No tracks in database
'; - return wrapper; - } - - const table = document.createElement('table'); - table.className = 'enhanced-track-table'; - table.dataset.albumId = album.id; - - const admin = isEnhancedAdmin(); - // Clear stale selections for non-admin to prevent ghost state - if (!admin) { - artistDetailPageState.selectedTracks.clear(); - } - - // Header - const thead = document.createElement('thead'); - const headRow = document.createElement('tr'); - if (admin) { - const selectAllTh = document.createElement('th'); - const selectAllCb = document.createElement('input'); - selectAllCb.type = 'checkbox'; - selectAllCb.className = 'enhanced-track-checkbox'; - selectAllTh.appendChild(selectAllCb); - headRow.appendChild(selectAllTh); - } - - const columns = [ - { label: '', cls: 'col-play' }, - { label: '#', cls: 'col-num', sortField: 'track_number' }, - { label: 'Disc', cls: 'col-disc', sortField: 'disc_number' }, - { label: 'Title', cls: 'col-title', sortField: 'title' }, - { label: 'Duration', cls: 'col-duration', sortField: 'duration' }, - { label: 'Format', cls: 'col-format', sortField: 'format' }, - { label: 'Bitrate', cls: 'col-bitrate', sortField: 'bitrate' }, - { label: 'BPM', cls: 'col-bpm', sortField: 'bpm' }, - { label: 'File', cls: 'col-path' }, - { label: 'Match', cls: 'col-match' }, - { label: '', cls: 'col-queue' }, - ...(admin ? [ - { label: '', cls: 'col-writetag' }, - { label: '', cls: 'col-delete' }, - ] : [ - { label: '', cls: 'col-report' }, - ]), - { label: '', cls: 'col-mobile-actions' }, - ]; - const currentSort = artistDetailPageState.enhancedTrackSort[album.id]; - columns.forEach(col => { - const th = document.createElement('th'); - th.className = col.cls; - if (col.sortField) { - let headerText = col.label; - if (currentSort && currentSort.field === col.sortField) { - headerText += currentSort.ascending ? ' \u25B2' : ' \u25BC'; - } - th.textContent = headerText; - th.style.cursor = 'pointer'; - th.dataset.sortField = col.sortField; - th.dataset.label = col.label; - } else { - th.textContent = col.label; - } - headRow.appendChild(th); - }); - thead.appendChild(headRow); - table.appendChild(thead); - - // Body - const tbody = document.createElement('tbody'); - tracks.forEach(track => { - tbody.appendChild(_buildTrackRow(track, album, admin)); - }); - table.appendChild(tbody); - - // Single delegated event listener for the whole table - _attachTableDelegation(table, album); - - wrapper.appendChild(table); - return wrapper; -} - -function sortEnhancedTracks(album, field, ascending) { - const tracks = album.tracks || []; - tracks.sort((a, b) => { - let valA, valB; - if (field === 'format') { - valA = extractFormat(a.file_path); - valB = extractFormat(b.file_path); - } else { - valA = a[field]; - valB = b[field]; - } - if (valA == null) return 1; - if (valB == null) return -1; - if (['track_number', 'disc_number', 'bpm', 'bitrate', 'duration'].includes(field)) { - return ascending ? (Number(valA) - Number(valB)) : (Number(valB) - Number(valA)); - } - valA = String(valA).toLowerCase(); - valB = String(valB).toLowerCase(); - return ascending ? valA.localeCompare(valB) : valB.localeCompare(valA); - }); -} - -async function deleteLibraryTrack(trackId, albumId) { - cancelInlineEdit(); - - // Smart delete dialog — three options - const choice = await _showSmartDeleteDialog(); - if (!choice) return; - - const params = new URLSearchParams(); - if (choice === 'delete_file') params.set('delete_file', 'true'); - - try { - const response = await fetch(`/api/library/track/${trackId}?${params}`, { method: 'DELETE' }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - let msg = 'Track removed from library'; - let toastType = 'success'; - if (result.file_deleted) { - msg = 'Track deleted from library and disk'; - } else if (result.file_error) { - msg = 'Track removed from library but file could not be deleted'; - toastType = 'warning'; - } - if (result.blacklisted) msg += ' (source blacklisted)'; - showToast(msg, toastType); - if (result.file_error) { - showToast(result.file_error, 'error', 8000); - } - - if (artistDetailPageState.enhancedData) { - const albums = artistDetailPageState.enhancedData.albums || []; - const album = albums.find(a => a.id === albumId); - if (album) { - album.tracks = (album.tracks || []).filter(t => t.id !== trackId); - } - } - artistDetailPageState.selectedTracks.delete(String(trackId)); - renderEnhancedView(); - } catch (error) { - showToast(`Delete failed: ${error.message}`, 'error'); - } -} - -function _showSmartDeleteDialog() { - return new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = '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;'; - - const close = (val) => { overlay.remove(); resolve(val); }; - overlay.onclick = e => { if (e.target === overlay) close(null); }; - - overlay.innerHTML = ` -
-
-

Delete Track

- -
-

How should this track be deleted?

-
- - - -
-
- `; - - overlay.querySelectorAll('.smart-delete-option').forEach(btn => { - btn.addEventListener('click', () => close(btn.dataset.choice)); - }); - overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); - - // Escape to close - const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } }; - document.addEventListener('keydown', escHandler); - - document.body.appendChild(overlay); - }); -} - -// ================================================================================== -// TRACK SOURCE INFO — View download provenance and blacklist sources -// ================================================================================== - -async function showTrackSourceInfo(track, anchorEl) { - // Remove existing popover - const existing = document.getElementById('source-info-popover'); - if (existing) existing.remove(); - - const popover = document.createElement('div'); - popover.id = 'source-info-popover'; - popover.className = 'source-info-popover'; - popover.innerHTML = '
Loading source info...
'; - - document.body.appendChild(popover); - - // Position near the button or center on mobile - if (anchorEl) { - const rect = anchorEl.getBoundingClientRect(); - const popW = 360; - let left = rect.left - popW - 8; - if (left < 10) left = rect.right + 8; - let top = rect.top - 20; - if (top + 300 > window.innerHeight) top = window.innerHeight - 310; - popover.style.left = `${left}px`; - popover.style.top = `${Math.max(10, top)}px`; - } else { - popover.style.left = '50%'; - popover.style.top = '50%'; - popover.style.transform = 'translate(-50%, -50%)'; - } - - requestAnimationFrame(() => popover.classList.add('visible')); - - // Close on outside click - const closeHandler = e => { - if (!popover.contains(e.target) && e.target !== anchorEl) { - popover.remove(); - document.removeEventListener('click', closeHandler); - } - }; - setTimeout(() => document.addEventListener('click', closeHandler), 100); - - // Escape to close - const escH = e => { if (e.key === 'Escape') { popover.remove(); document.removeEventListener('keydown', escH); document.removeEventListener('click', closeHandler); } }; - document.addEventListener('keydown', escH); - - try { - const res = await fetch(`/api/library/track/${track.id}/source-info`); - const data = await res.json(); - - if (!data.success || !data.downloads || data.downloads.length === 0) { - popover.innerHTML = ` -
- Source Info - -
-
No download source data available for this track. Source tracking starts with new downloads.
- `; - return; - } - - const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer: '💜' }; - const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer: 'Deezer' }; - - const dl = data.downloads[0]; // Most recent download - const icon = serviceIcons[dl.source_service] || '📦'; - const label = serviceLabels[dl.source_service] || dl.source_service; - const displayFile = dl.source_filename ? dl.source_filename.replace(/\\/g, '/').split('/').pop() : 'Unknown'; - const sizeStr = dl.source_size ? `${(dl.source_size / 1048576).toFixed(1)} MB` : ''; - const dateStr = dl.created_at ? timeAgo(dl.created_at) : ''; - - popover.innerHTML = ` -
- Source Info - -
-
-
- Service - ${icon} ${label} -
- ${dl.source_service === 'soulseek' && dl.source_username ? `
- User - ${_esc(dl.source_username)} -
` : ''} -
- Original File - ${_esc(displayFile)} -
- ${sizeStr ? `
- Size - ${sizeStr} -
` : ''} - ${dl.audio_quality ? `
- Quality - ${_esc(dl.audio_quality)} -
` : ''} - ${dl.bit_depth || dl.sample_rate || dl.bitrate ? `
- Audio - ${[dl.bit_depth ? `${dl.bit_depth}-bit` : '', dl.sample_rate ? `${(dl.sample_rate / 1000).toFixed(1)}kHz` : '', dl.bitrate ? `${Math.round(dl.bitrate / 1000)}kbps` : ''].filter(Boolean).join(' · ')} -
` : ''} - ${dateStr ? `
- Downloaded - ${dateStr} -
` : ''} - ${dl.status !== 'completed' ? `
- Status - ${dl.status} -
` : ''} -
- ${dl.source_username && dl.source_filename ? ` -
- -
` : ''} - ${data.downloads.length > 1 ? `
${data.downloads.length} download records for this track
` : ''} - `; - - // Blacklist button handler - const blBtn = document.getElementById('source-info-blacklist-btn'); - if (blBtn) { - blBtn.addEventListener('click', async () => { - if (!await showConfirmDialog({ title: 'Blacklist Source', message: `Blacklist "${displayFile}" from ${dl.source_service === 'soulseek' ? dl.source_username : label}? This source will be skipped in future downloads.`, confirmText: 'Blacklist', destructive: true })) return; - - try { - const db_res = await fetch('/api/library/blacklist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_title: dl.track_title || track.title, - track_artist: dl.track_artist || '', - blocked_filename: dl.source_filename, - blocked_username: dl.source_username, - reason: 'user_rejected' - }) - }); - const result = await db_res.json(); - if (result.success) { - showToast('Source blacklisted', 'success'); - blBtn.disabled = true; - blBtn.textContent = '⛔ Blacklisted'; - } else { - showToast(result.error || 'Failed to blacklist', 'error'); - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - } - }); - } - - } catch (e) { - popover.innerHTML = `
Error loading source info: ${_esc(e.message)}
`; - } -} - - -// ================================================================================== -// TRACK REDOWNLOAD MODAL — Multi-step: metadata selection → source selection → download -// ================================================================================== - -async function showTrackRedownloadModal(track, album) { - const overlay = document.createElement('div'); - overlay.id = 'redownload-overlay'; - overlay.className = 'redownload-overlay'; - overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; - - const artistName = artistDetailPageState.enhancedData?.artist?.name || ''; - const ext = (track.file_path || '').split('.').pop().toUpperCase(); - const fmt = ['FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'WAV'].includes(ext) ? ext : ''; - - overlay.innerHTML = ` -
-
-
-

Redownload Track

-

Find the correct version and download from your preferred source

-
- -
-
-
-
🎵
-
-
-
${_esc(track.title)}
-
${_esc(artistName)} · ${_esc(album?.title || '')}
-
-
- ${fmt ? `${fmt}` : ''} - ${track.bitrate ? `${track.bitrate}k` : ''} -
-
-
-
1 Choose Metadata
-
-
2 Choose Source
-
-
3 Downloading
-
-
-
-
- Searching metadata sources... -
-
-
- `; - - // Escape to close - const escH = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escH); overlay.remove(); } }; - document.addEventListener('keydown', escH); - - document.body.appendChild(overlay); - - // Auto-search metadata - try { - const res = await fetch(`/api/library/track/${track.id}/redownload/search-metadata`, { method: 'POST' }); - const data = await res.json(); - if (!data.success) throw new Error(data.error); - - // Set album art in header if available - const artEl = document.getElementById('redownload-current-art'); - if (artEl && data.current_track?.thumb_url) { - artEl.innerHTML = ``; - } - - _renderRedownloadStep1(overlay, track, data); - } catch (e) { - document.getElementById('redownload-body').innerHTML = `
Error: ${_esc(e.message)}
`; - } -} - -function _renderRedownloadStep1(overlay, track, data) { - const body = document.getElementById('redownload-body'); - if (!body) return; - - const sources = Object.keys(data.metadata_results); - if (sources.length === 0) { - body.innerHTML = '
No metadata sources available. Check your Spotify/iTunes/Deezer connections.
'; - return; - } - - const bestSource = data.best_match?.source || sources[0]; - const sourceIcons = { spotify: '🟢', itunes: '🍎', deezer: '🟣', hydrabase: '🔷' }; - const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', discogs: 'Discogs', hydrabase: 'Hydrabase' }; - - // Build columns — one per source, side by side - const columnsHtml = sources.map(source => { - const results = data.metadata_results[source] || []; - const icon = sourceIcons[source] || '📋'; - const label = sourceLabels[source] || source; - - let itemsHtml; - if (results.length === 0) { - itemsHtml = `
No results
`; - } else { - itemsHtml = results.slice(0, 8).map((r, i) => { - const pct = Math.round((r.match_score || 0) * 100); - const cls = pct >= 90 ? 'high' : pct >= 70 ? 'medium' : 'low'; - const dur = r.duration_ms ? `${Math.floor(r.duration_ms / 60000)}:${String(Math.floor((r.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - const checked = (source === bestSource && i === 0) ? 'checked' : ''; - return ` - `; - }).join(''); - } - - return ` -
-
- ${icon} - ${label} - ${results.length} -
-
${itemsHtml}
-
`; - }).join(''); - - body.innerHTML = `
${columnsHtml}
`; - - // Add sticky footer for Step 1 - const modal = overlay.querySelector('.redownload-modal'); - const oldFooter = modal.querySelector('.redownload-sticky-footer'); - if (oldFooter) oldFooter.remove(); - const footer = document.createElement('div'); - footer.className = 'redownload-sticky-footer'; - footer.innerHTML = ` -
- - -
- `; - modal.appendChild(footer); - - // Next button - document.getElementById('redownload-next-btn').addEventListener('click', async () => { - const checked = body.querySelector('input[name="metadata-choice"]:checked'); - if (!checked) { showToast('Select a metadata source first', 'error'); return; } - const [source, idx] = checked.value.split('|'); - selectedMeta = data.metadata_results[source][parseInt(idx)]; - selectedMeta._source = source; - - // Update step indicator - overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); - overlay.querySelector('.redownload-step[data-step="2"]').classList.add('active'); - - // Stream results from all download sources — columns appear as each source responds - // Body gets the scrollable content, footer is sticky outside the scroll - body.innerHTML = ` -
-
Searching download sources...
-
- `; - // Add sticky footer outside the scrollable body - const existingFooter = overlay.querySelector('.redownload-sticky-footer'); - if (existingFooter) existingFooter.remove(); - const modal = overlay.querySelector('.redownload-modal'); - const footer = document.createElement('div'); - footer.className = 'redownload-sticky-footer'; - footer.innerHTML = ` - -
- - -
- `; - modal.appendChild(footer); - - // Wire up download button IMMEDIATELY (before streaming starts) - // so it works as soon as results appear - window._redownloadCandidates = []; - window._redownloadMetadata = selectedMeta; - document.getElementById('redownload-start-btn').addEventListener('click', async () => { - const checked = document.querySelector('input[name="source-choice"]:checked'); - if (!checked) { showToast('Select a download source', 'error'); return; } - const cand = window._redownloadCandidates[parseInt(checked.value)]; - if (!cand) { showToast('Invalid selection', 'error'); return; } - const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; - - overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); - overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); - - // Remove sticky footer for step 3 - const ft = overlay.querySelector('.redownload-sticky-footer'); - if (ft) ft.remove(); - - const body = document.getElementById('redownload-body'); - body.innerHTML = ` -
-
Downloading: ${_esc(cand.display_name)}
-
from ${_esc(cand.source_service === 'soulseek' ? cand.username : (cand.source_service || 'unknown'))}
-
-
Starting download...
-
- `; - - try { - const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ metadata: window._redownloadMetadata, candidate: cand, delete_old_file: deleteOld }) - }); - const startData = await res.json(); - if (!startData.success) throw new Error(startData.error); - _pollRedownloadProgress(startData.task_id, overlay); - } catch (e) { - body.innerHTML = `
Download failed: ${_esc(e.message)}
`; - } - }); - - _streamRedownloadSources(overlay, track, selectedMeta); - }); -} - -async function _streamRedownloadSources(overlay, track, metadata) { - const columnsEl = document.getElementById('rdl-src-columns'); - const loadingEl = document.getElementById('rdl-src-loading'); - const startBtn = document.getElementById('redownload-start-btn'); - if (!columnsEl) return; - - const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' }; - const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' }; - - let allCandidates = []; - let firstResult = true; - let bestGlobalIdx = -1; - - try { - const res = await fetch(`/api/library/track/${track.id}/redownload/search-sources`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ metadata }) - }); - - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - - const lines = buffer.split('\n'); - buffer = lines.pop(); // keep incomplete line - - for (const line of lines) { - if (!line.trim()) continue; - try { - const data = JSON.parse(line); - if (data.done) continue; - - const svc = data.source; - const candidates = data.candidates || []; - - // Remove loading spinner on first result - if (firstResult && loadingEl) { loadingEl.remove(); firstResult = false; } - - // Assign global indices - const startIdx = allCandidates.length; - candidates.forEach((c, i) => { c._globalIdx = startIdx + i; }); - allCandidates.push(...candidates); - window._redownloadCandidates = allCandidates; // Keep global ref updated for button handler - - // Find best overall candidate - bestGlobalIdx = -1; - let bestConf = 0; - allCandidates.forEach((c, i) => { - if (!c.blacklisted && c.confidence > bestConf) { bestConf = c.confidence; bestGlobalIdx = i; } - }); - - // Render column for this source - const icon = serviceIcons[svc] || '📦'; - const label = serviceLabels[svc] || svc; - - const itemsHtml = candidates.length === 0 - ? '
No results
' - : candidates.slice(0, 10).map(c => { - const confPct = Math.round((c.confidence || 0) * 100); - const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; - const isRec = c._globalIdx === bestGlobalIdx; - const blClass = c.blacklisted ? ' blacklisted' : ''; - const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : ''; - return ` - `; - }).join(''); - - const colEl = document.createElement('div'); - colEl.className = 'rdl-src-col'; - colEl.style.animation = 'fadeSlideUp 0.3s ease both'; - colEl.innerHTML = ` -
- ${icon} - ${label} - ${candidates.length} -
-
${itemsHtml}
- `; - columnsEl.appendChild(colEl); - - // Enable the download button - if (startBtn && allCandidates.some(c => !c.blacklisted)) { - startBtn.disabled = false; - startBtn.textContent = 'Download Selected'; - } - - } catch (e) { /* skip malformed lines */ } - } - } - } catch (e) { - if (loadingEl) loadingEl.innerHTML = `
Error: ${_esc(e.message)}
`; - } - - // If no results at all - if (allCandidates.length === 0 && loadingEl) { - loadingEl.innerHTML = '
No download sources found for this track.
'; - } - - // Update the shared candidates array (button handler reads from window._redownloadCandidates) - window._redownloadCandidates = allCandidates; -} - -/* _renderRedownloadStep2 removed — replaced by _streamRedownloadSources above */ -if (false) { - const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜', hybrid: '⚡' }; - const serviceLabels = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Auto' }; - - // Group candidates by source service - const grouped = {}; - candidates.forEach((c, i) => { - c._origIdx = i; // preserve original index for radio value - const svc = c.source_service || 'unknown'; - if (!grouped[svc]) grouped[svc] = []; - grouped[svc].push(c); - }); - - // Build columns — one per source - const sourceColumnsHtml = Object.entries(grouped).map(([svc, items]) => { - const icon = serviceIcons[svc] || '📦'; - const label = serviceLabels[svc] || svc; - - const itemsHtml = items.slice(0, 10).map(c => { - const confPct = Math.round((c.confidence || 0) * 100); - const confCls = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; - const isRecommended = c._origIdx === bestIdx && !c.blacklisted; - const checked = isRecommended ? 'checked' : ''; - const blClass = c.blacklisted ? ' blacklisted' : ''; - const dur = c.duration ? `${Math.floor(c.duration / 60000)}:${String(Math.floor((c.duration % 60000) / 1000)).padStart(2, '0')}` : ''; - - return ` - `; - }).join(''); - - return ` -
-
- ${icon} - ${label} - ${items.length} -
-
${itemsHtml}
-
`; - }).join(''); - - body.innerHTML = ` -
${sourceColumnsHtml}
- -
- - -
- `; - - document.getElementById('redownload-start-btn').addEventListener('click', async () => { - const checked = body.querySelector('input[name="source-choice"]:checked'); - if (!checked) { showToast('Select a download source', 'error'); return; } - const candidate = candidates[parseInt(checked.value)]; - const deleteOld = document.getElementById('redownload-delete-old-check')?.checked ?? true; - - // Update step indicator - overlay.querySelectorAll('.redownload-step').forEach(s => s.classList.remove('active')); - overlay.querySelector('.redownload-step[data-step="3"]').classList.add('active'); - - body.innerHTML = ` -
-
Downloading: ${_esc(candidate.display_name)}
-
from ${_esc(candidate.username)}
-
-
Starting download...
-
- `; - - try { - const res = await fetch(`/api/library/track/${track.id}/redownload/start`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ metadata, candidate, delete_old_file: deleteOld }) - }); - const startData = await res.json(); - if (!startData.success) throw new Error(startData.error); - - // Poll for progress - _pollRedownloadProgress(startData.task_id, overlay); - } catch (e) { - body.innerHTML = `
Download failed: ${_esc(e.message)}
`; - } - }); -} - -function _pollRedownloadProgress(taskId, overlay) { - let completed = false; - - const poll = setInterval(async () => { - if (completed) return; - - // Get fresh DOM references every tick (in case DOM was rebuilt) - const bar = document.getElementById('redownload-progress-bar'); - const status = document.getElementById('redownload-progress-status'); - - try { - // Poll real download progress from /api/downloads/status - const dlRes = await fetch('/api/downloads/status'); - const dlData = await dlRes.json(); - const transfers = dlData.transfers || []; - - // Find any active transfer - let bestTransfer = null; - for (const t of transfers) { - const st = (t.state || '').toLowerCase(); - if (st.includes('inprogress') || st.includes('queued') || st.includes('initializing')) { - bestTransfer = t; - break; - } - } - - if (bestTransfer) { - const pct = bestTransfer.percentComplete || 0; - const transferred = bestTransfer.bytesTransferred || 0; - const total = bestTransfer.size || 0; - const transferredMB = (transferred / 1048576).toFixed(1); - const totalMB = (total / 1048576).toFixed(1); - - if (bar) bar.style.width = `${Math.min(95, pct)}%`; - if (status) { - status.textContent = total > 0 - ? `Downloading... ${Math.round(pct)}% (${transferredMB} / ${totalMB} MB)` - : `Downloading... ${Math.round(pct)}%`; - } - } else { - // No active slskd transfer — streaming source or post-processing - if (bar) bar.style.width = '80%'; - if (status) status.textContent = 'Processing...'; - } - - // Check for batch completion - const procRes = await fetch('/api/active-processes'); - const procData = await procRes.json(); - const procs = procData.active_processes || []; - const ourBatch = procs.find(p => p.batch_id && p.batch_id.includes('redownload_batch_')); - - if (!ourBatch) { - completed = true; - clearInterval(poll); - if (bar) bar.style.width = '100%'; - if (status) status.textContent = 'Complete! File replaced successfully.'; - showToast('Track redownloaded successfully', 'success'); - setTimeout(() => { - overlay.remove(); - if (artistDetailPageState.enhancedData?.artist?.id) { - loadEnhancedViewData(artistDetailPageState.enhancedData.artist.id); - } - }, 2000); - } - } catch (e) { /* ignore poll errors */ } - }, 1500); - - // Safety timeout — 5 minutes - setTimeout(() => { - if (!completed) { - clearInterval(poll); - const status = document.getElementById('redownload-progress-status'); - if (status) status.textContent = 'Download may still be in progress. Check the dashboard.'; - } - }, 300000); -} - -async function deleteLibraryAlbum(albumId) { - const choice = await _showAlbumDeleteDialog(); - if (!choice) return; - - const deleteFiles = choice === 'delete_files'; - const params = deleteFiles ? '?delete_files=true' : ''; - - try { - const response = await fetch(`/api/library/album/${albumId}${params}`, { method: 'DELETE' }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - let msg = `Album removed from library (${result.tracks_deleted || 0} tracks)`; - let toastType = 'success'; - if (deleteFiles) { - if (result.files_deleted > 0) { - msg = `Album deleted — ${result.files_deleted} files removed from disk`; - } - if (result.files_failed > 0) { - msg += ` (${result.files_failed} files could not be deleted)`; - toastType = 'warning'; - } - } - showToast(msg, toastType); - - if (artistDetailPageState.enhancedData) { - const album = (artistDetailPageState.enhancedData.albums || []).find(a => a.id === albumId); - if (album && album.tracks) { - album.tracks.forEach(t => artistDetailPageState.selectedTracks.delete(String(t.id))); - } - artistDetailPageState.enhancedData.albums = (artistDetailPageState.enhancedData.albums || []).filter(a => a.id !== albumId); - _rebuildAlbumMap(); - } - artistDetailPageState.expandedAlbums.delete(albumId); - delete artistDetailPageState.enhancedTrackSort[albumId]; - renderEnhancedView(); - } catch (error) { - showToast(`Delete failed: ${error.message}`, 'error'); - } -} - -function _showAlbumDeleteDialog() { - return new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = '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;'; - - const close = (val) => { overlay.remove(); resolve(val); }; - overlay.onclick = e => { if (e.target === overlay) close(null); }; - - overlay.innerHTML = ` -
-
-

Delete Album

- -
-

How should this album be deleted?

-
- - -
-
- `; - - overlay.querySelectorAll('.smart-delete-option').forEach(btn => { - btn.addEventListener('click', () => close(btn.dataset.choice)); - }); - overlay.querySelector('.smart-delete-close').addEventListener('click', () => close(null)); - - const escHandler = e => { if (e.key === 'Escape') { document.removeEventListener('keydown', escHandler); close(null); } }; - document.addEventListener('keydown', escHandler); - - document.body.appendChild(overlay); - }); -} - -function extractFormat(filePath) { - if (!filePath) return '-'; - const ext = filePath.split('.').pop().toLowerCase(); - const formatMap = { mp3: 'MP3', flac: 'FLAC', m4a: 'AAC', ogg: 'OGG', opus: 'OPUS', wav: 'WAV', wma: 'WMA', aac: 'AAC' }; - return formatMap[ext] || ext.toUpperCase(); -} - -function formatDurationMs(ms) { - if (!ms) return '-'; - const totalSeconds = Math.floor(ms / 1000); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; -} - -function getServiceUrl(service, entityType, id) { - if (!id) return null; - const urls = { - spotify: { - artist: `https://open.spotify.com/artist/${id}`, - album: `https://open.spotify.com/album/${id}`, - track: `https://open.spotify.com/track/${id}`, - }, - musicbrainz: { - artist: `https://musicbrainz.org/artist/${id}`, - album: `https://musicbrainz.org/release/${id}`, - track: `https://musicbrainz.org/recording/${id}`, - }, - deezer: { - artist: `https://www.deezer.com/artist/${id}`, - album: `https://www.deezer.com/album/${id}`, - track: `https://www.deezer.com/track/${id}`, - }, - audiodb: { - artist: `https://www.theaudiodb.com/artist/${id}`, - album: `https://www.theaudiodb.com/album/${id}`, - track: `https://www.theaudiodb.com/track/${id}`, - }, - itunes: { - artist: `https://music.apple.com/artist/${id}`, - album: `https://music.apple.com/album/${id}`, - track: `https://music.apple.com/song/${id}`, - }, - lastfm: { - artist: id, // lastfm_url is already a full URL - album: id, - track: id, - }, - genius: { - artist: id, // genius_url is already a full URL - track: id, // genius_url on tracks is already a full URL - }, - tidal: { - artist: `https://tidal.com/browse/artist/${id}`, - album: `https://tidal.com/browse/album/${id}`, - track: `https://tidal.com/browse/track/${id}`, - }, - qobuz: { - artist: `https://www.qobuz.com/artist/${id}`, - album: `https://www.qobuz.com/album/${id}`, - track: `https://www.qobuz.com/track/${id}`, - }, - }; - return urls[service] && urls[service][entityType] || null; -} - -function makeClickableBadge(service, entityType, id, label) { - const url = getServiceUrl(service, entityType, id); - if (url) { - const a = document.createElement('a'); - a.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`; - a.href = url; - a.target = '_blank'; - a.rel = 'noopener noreferrer'; - a.textContent = label; - a.title = `${label}: ${id} (click to open)`; - a.onclick = (e) => e.stopPropagation(); - return a; - } - const span = document.createElement('span'); - span.className = `enhanced-id-badge ${service === 'musicbrainz' ? 'mb' : service}`; - span.textContent = label; - span.title = `${label}: ${id}`; - return span; -} - -// ---- Inline Editing ---- - -function startInlineEdit(cell, type, id, field, currentValue) { - if (cell.querySelector('.enhanced-inline-input')) return; - cancelInlineEdit(); - - const isNumeric = ['track_number', 'bpm'].includes(field); - const originalContent = cell.innerHTML; - cell.dataset.originalContent = originalContent; - - const input = document.createElement('input'); - input.type = isNumeric ? 'number' : 'text'; - input.className = 'enhanced-inline-input' + (isNumeric ? ' num' : ''); - input.value = currentValue || ''; - if (field === 'bpm') input.step = '0.1'; - if (field === 'track_number') { input.min = '1'; input.step = '1'; } - - cell.innerHTML = ''; - cell.appendChild(input); - input.focus(); - input.select(); - - artistDetailPageState.editingCell = { cell, type, id, field, originalContent }; - - input.addEventListener('click', e => e.stopPropagation()); - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveInlineEdit(type, id, field, input.value); - } else if (e.key === 'Escape') { - cancelInlineEdit(); - } - e.stopPropagation(); - }); - input.addEventListener('blur', () => { - setTimeout(() => { - if (artistDetailPageState.editingCell && artistDetailPageState.editingCell.cell === cell) { - saveInlineEdit(type, id, field, input.value); - } - }, 150); - }); -} - -async function saveInlineEdit(type, id, field, newValue) { - const editInfo = artistDetailPageState.editingCell; - if (!editInfo) return; - artistDetailPageState.editingCell = null; - - let parsedValue = newValue; - if (field === 'track_number') parsedValue = parseInt(newValue) || null; - else if (field === 'bpm') parsedValue = parseFloat(newValue) || null; - else if (field === 'explicit') parsedValue = parseInt(newValue) || 0; - - const url = type === 'track' ? `/api/library/track/${id}` : `/api/library/album/${id}`; - - try { - const response = await fetch(url, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ [field]: parsedValue }) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - const displayValue = parsedValue !== null && parsedValue !== '' ? String(parsedValue) : '-'; - editInfo.cell.textContent = displayValue; - updateLocalEnhancedData(type, id, field, parsedValue); - showToast(`Updated ${field}`, 'success'); - } catch (error) { - console.error('Failed to save inline edit:', error); - editInfo.cell.innerHTML = editInfo.originalContent; - showToast(`Failed to update: ${error.message}`, 'error'); - } -} - -function cancelInlineEdit() { - const editInfo = artistDetailPageState.editingCell; - if (!editInfo) return; - editInfo.cell.innerHTML = editInfo.originalContent; - artistDetailPageState.editingCell = null; -} - -function updateLocalEnhancedData(type, id, field, value) { - const data = artistDetailPageState.enhancedData; - if (!data) return; - - if (type === 'track') { - for (const album of data.albums) { - const track = (album.tracks || []).find(t => String(t.id) === String(id)); - if (track) { track[field] = value; break; } - } - } else if (type === 'album') { - const album = data.albums.find(a => String(a.id) === String(id)); - if (album) album[field] = value; - } else if (type === 'artist') { - data.artist[field] = value; - } -} - -// ---- Track Selection & Bulk Operations ---- - -function toggleTrackSelection(trackId) { - trackId = String(trackId); - if (artistDetailPageState.selectedTracks.has(trackId)) { - artistDetailPageState.selectedTracks.delete(trackId); - } else { - artistDetailPageState.selectedTracks.add(trackId); - } - const row = document.querySelector(`tr[data-track-id="${trackId}"]`); - if (row) row.classList.toggle('selected', artistDetailPageState.selectedTracks.has(trackId)); - updateBulkBar(); -} - -function toggleSelectAllTracks(albumId, checked) { - const album = findEnhancedAlbum(albumId); - if (!album || !album.tracks) return; - - // Batch update state - album.tracks.forEach(track => { - const tid = String(track.id); - if (checked) artistDetailPageState.selectedTracks.add(tid); - else artistDetailPageState.selectedTracks.delete(tid); - }); - - // Scoped DOM query — only search within this album's panel, not entire document - const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); - if (panel) { - panel.querySelectorAll('tr[data-track-id]').forEach(row => { - row.classList.toggle('selected', checked); - const cb = row.querySelector('.enhanced-track-checkbox'); - if (cb) cb.checked = checked; - }); - } - updateBulkBar(); -} - -function clearTrackSelection() { - // Scoped batch clear — query the container once instead of per-track - const container = document.getElementById('enhanced-view-container'); - if (container) { - container.querySelectorAll('tr[data-track-id].selected').forEach(row => { - row.classList.remove('selected'); - const cb = row.querySelector('.enhanced-track-checkbox'); - if (cb) cb.checked = false; - }); - container.querySelectorAll('.enhanced-track-table thead .enhanced-track-checkbox').forEach(cb => cb.checked = false); - } - artistDetailPageState.selectedTracks.clear(); - updateBulkBar(); -} - -function updateBulkBar() { - const bar = document.getElementById('enhanced-bulk-bar'); - const count = document.getElementById('enhanced-bulk-count'); - if (!bar || !count) return; - if (!isEnhancedAdmin()) { - bar.classList.remove('visible'); - return; - } - const n = artistDetailPageState.selectedTracks.size; - count.textContent = n; - bar.classList.toggle('visible', n > 0); -} - -function showBulkEditModal() { - const overlay = document.getElementById('enhanced-bulk-edit-overlay'); - const body = document.getElementById('enhanced-bulk-modal-body'); - const title = document.getElementById('enhanced-bulk-modal-title'); - if (!overlay || !body) return; - - const count = artistDetailPageState.selectedTracks.size; - title.textContent = `Batch Edit ${count} Track${count !== 1 ? 's' : ''}`; - - body.innerHTML = ` -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- `; - - overlay.classList.remove('hidden'); -} - -function closeBulkEditModal() { - const overlay = document.getElementById('enhanced-bulk-edit-overlay'); - if (overlay) overlay.classList.add('hidden'); -} - -async function executeBulkEdit() { - const trackIds = Array.from(artistDetailPageState.selectedTracks); - if (trackIds.length === 0) return; - - const updates = {}; - const trackNum = document.getElementById('bulk-edit-track-number'); - const bpm = document.getElementById('bulk-edit-bpm'); - const style = document.getElementById('bulk-edit-style'); - const mood = document.getElementById('bulk-edit-mood'); - const explicit = document.getElementById('bulk-edit-explicit'); - - if (trackNum && trackNum.value !== '') updates.track_number = parseInt(trackNum.value); - if (bpm && bpm.value !== '') updates.bpm = parseFloat(bpm.value); - if (style && style.value !== '') updates.style = style.value; - if (mood && mood.value !== '') updates.mood = mood.value; - if (explicit && explicit.value !== '') updates.explicit = parseInt(explicit.value); - - if (Object.keys(updates).length === 0) { - showToast('No changes to apply', 'error'); - return; - } - - try { - const response = await fetch('/api/library/tracks/batch', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_ids: trackIds, updates }) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - showToast(`Updated ${result.updated_count} tracks`, 'success'); - closeBulkEditModal(); - - for (const [field, val] of Object.entries(updates)) { - trackIds.forEach(tid => updateLocalEnhancedData('track', tid, field, val)); - } - - reRenderExpandedPanels(); - clearTrackSelection(); - - } catch (error) { - console.error('Bulk edit failed:', error); - showToast(`Bulk edit failed: ${error.message}`, 'error'); - } -} - -// ---- Save Artist / Album Metadata ---- - -async function saveArtistMetadata() { - const form = document.getElementById('enhanced-artist-meta-form'); - if (!form) return; - - const inputs = form.querySelectorAll('.enhanced-meta-field-input'); - const updates = {}; - const original = artistDetailPageState.enhancedData.artist; - - inputs.forEach(input => { - const field = input.dataset.field; - if (!field) return; - let value = (input.tagName === 'TEXTAREA' ? input.value : input.value).trim(); - - let origVal = original[field]; - if (field === 'genres') { - const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : []; - const origGenres = Array.isArray(origVal) ? origVal : []; - if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres; - } else { - if ((value || '') !== (origVal || '')) updates[field] = value || null; - } - }); - - if (Object.keys(updates).length === 0) { - showToast('No changes to save', 'error'); - return; - } - - try { - const response = await fetch(`/api/library/artist/${original.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - for (const [field, value] of Object.entries(updates)) { - artistDetailPageState.enhancedData.artist[field] = value; - } - - // Update the display name in the header - if (updates.name) { - const nameEl = document.querySelector('.enhanced-artist-meta-name'); - if (nameEl) nameEl.textContent = updates.name; - } - - showToast(`Artist metadata saved (${(result.updated_fields || []).join(', ')})`, 'success'); - } catch (error) { - console.error('Failed to save artist metadata:', error); - showToast(`Failed to save: ${error.message}`, 'error'); - } -} - -function revertArtistMetadata() { - const data = artistDetailPageState.enhancedData; - if (!data) return; - - const panel = document.getElementById('enhanced-artist-meta'); - if (!panel) return; - - const parent = panel.parentNode; - const newPanel = renderArtistMetaPanel(data.artist); - parent.replaceChild(newPanel, panel); - showToast('Reverted to saved values', 'success'); -} - -async function saveAlbumMetadata(albumId) { - const metaRow = document.getElementById(`enhanced-album-meta-${albumId}`); - if (!metaRow) return; - - const album = findEnhancedAlbum(albumId); - if (!album) return; - - const inputs = metaRow.querySelectorAll('.enhanced-album-meta-input'); - const updates = {}; - - inputs.forEach(input => { - const field = input.dataset.field; - if (!field) return; - let value = input.value.trim(); - - if (field === 'genres') { - const newGenres = value ? value.split(',').map(g => g.trim()).filter(Boolean) : []; - const origGenres = Array.isArray(album.genres) ? album.genres : []; - if (JSON.stringify(newGenres) !== JSON.stringify(origGenres)) updates[field] = newGenres; - } else if (field === 'year' || field === 'explicit' || field === 'track_count') { - const numVal = value !== '' ? parseInt(value) : null; - if (numVal !== (album[field] || null)) updates[field] = numVal; - } else { - if ((value || '') !== (album[field] || '')) updates[field] = value || null; - } - }); - - if (Object.keys(updates).length === 0) { - showToast('No album changes to save', 'error'); - return; - } - - try { - const response = await fetch(`/api/library/album/${albumId}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - for (const [field, value] of Object.entries(updates)) { - album[field] = value; - } - - // Update album row display - const albumRow = document.getElementById(`enhanced-album-row-${albumId}`); - if (albumRow) { - if (updates.title) { - const titleEl = albumRow.querySelector('.enhanced-album-title'); - if (titleEl) { titleEl.textContent = updates.title; titleEl.title = updates.title; } - } - if (updates.year !== undefined) { - const yearEl = albumRow.querySelector('.enhanced-album-year'); - if (yearEl) yearEl.textContent = updates.year || '-'; - } - } - - showToast(`Album metadata saved (${(result.updated_fields || []).join(', ')})`, 'success'); - } catch (error) { - console.error('Failed to save album metadata:', error); - showToast(`Failed to save: ${error.message}`, 'error'); - } -} - -function reRenderExpandedPanels() { - artistDetailPageState.expandedAlbums.forEach(albumId => { - const panel = document.getElementById(`enhanced-tracks-panel-${albumId}`); - if (!panel) return; - const inner = panel.querySelector('.enhanced-tracks-panel-inner'); - if (!inner) return; - - const album = findEnhancedAlbum(albumId); - if (album) { - inner.innerHTML = ''; - inner.appendChild(renderExpandedAlbumHeader(album)); - inner.appendChild(renderAlbumMetaRow(album)); - inner.appendChild(renderTrackTable(album)); - } - }); -} - -// ---- Manual Match Modal ---- - -function openManualMatchModal(entityType, entityId, service, defaultQuery, artistId) { - // Remove existing modal if any - const existing = document.getElementById('enhanced-manual-match-overlay'); - if (existing) existing.remove(); - - const serviceLabels = { - spotify: 'Spotify', musicbrainz: 'MusicBrainz', deezer: 'Deezer', - audiodb: 'AudioDB', itunes: 'iTunes', lastfm: 'Last.fm', genius: 'Genius' - }; - - const overlay = document.createElement('div'); - overlay.id = 'enhanced-manual-match-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - const modal = document.createElement('div'); - modal.className = 'enhanced-manual-match-modal'; - - // Header - const header = document.createElement('div'); - header.className = 'enhanced-bulk-modal-header'; - const title = document.createElement('h3'); - title.textContent = `Match ${entityType} on ${serviceLabels[service] || service}`; - header.appendChild(title); - const closeBtn = document.createElement('button'); - closeBtn.className = 'enhanced-bulk-modal-close'; - closeBtn.innerHTML = '×'; - closeBtn.onclick = () => overlay.remove(); - header.appendChild(closeBtn); - modal.appendChild(header); - - // Search bar - const searchRow = document.createElement('div'); - searchRow.className = 'enhanced-match-search-row'; - const searchInput = document.createElement('input'); - searchInput.type = 'text'; - searchInput.className = 'enhanced-match-search-input'; - searchInput.placeholder = `Search ${serviceLabels[service] || service}...`; - searchInput.value = defaultQuery; - searchRow.appendChild(searchInput); - const searchBtn = document.createElement('button'); - searchBtn.className = 'enhanced-enrich-btn'; - searchBtn.textContent = 'Search'; - searchBtn.onclick = () => doManualMatchSearch(service, entityType, searchInput.value, resultsContainer, entityId, artistId); - searchRow.appendChild(searchBtn); - - // Clear Match button — lets user revert a wrong match to not_found - const clearBtn = document.createElement('button'); - clearBtn.className = 'enhanced-enrich-btn'; - clearBtn.style.cssText = 'background:rgba(255,80,80,0.12);color:#ff6b6b;margin-left:6px'; - clearBtn.textContent = 'Clear Match'; - clearBtn.title = 'Remove the current match — reverts to Not Found'; - clearBtn.onclick = async () => { - if (!confirm(`Clear ${serviceLabels[service] || service} match for this ${entityType}? It will revert to "Not Found".`)) return; - try { - const res = await fetch('/api/library/clear-match', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ entity_type: entityType, entity_id: entityId, service, artist_id: artistId }) - }); - const data = await res.json(); - if (data.success) { - showToast(`Cleared ${serviceLabels[service] || service} match`, 'success'); - overlay.remove(); - if (data.updated_data) { - artistDetailPageState.enhancedData = data.updated_data; - renderEnhancedArtistView(data.updated_data, true); - } - } else { - showToast(data.error || 'Failed to clear match', 'error'); - } - } catch (e) { - showToast('Error clearing match', 'error'); - } - }; - searchRow.appendChild(clearBtn); - - modal.appendChild(searchRow); - - // Handle Enter key - searchInput.onkeydown = (e) => { - if (e.key === 'Enter') searchBtn.click(); - }; - - // Results container - const resultsContainer = document.createElement('div'); - resultsContainer.className = 'enhanced-match-results'; - resultsContainer.innerHTML = '
Press Search or Enter to find matches
'; - modal.appendChild(resultsContainer); - - overlay.appendChild(modal); - document.body.appendChild(overlay); - - // Auto-search on open - searchInput.focus(); - searchBtn.click(); -} - -async function doManualMatchSearch(service, entityType, query, container, entityId, artistId) { - if (!query.trim()) { - container.innerHTML = '
Enter a search term
'; - return; - } - - container.innerHTML = '
Searching...
'; - - try { - const response = await fetch('/api/library/search-service', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ service, entity_type: entityType, query: query.trim() }) - }); - - const data = await response.json(); - if (!data.success) throw new Error(data.error); - - const results = data.results || []; - container.innerHTML = ''; - - if (results.length === 0) { - container.innerHTML = '
No results found. Try a different search.
'; - return; - } - - results.forEach(result => { - const row = document.createElement('div'); - row.className = 'enhanced-match-result-row'; - - if (result.image) { - const img = document.createElement('img'); - img.className = 'enhanced-match-result-img'; - img.src = result.image; - img.alt = ''; - img.onerror = function () { this.style.display = 'none'; }; - row.appendChild(img); - } else { - const placeholder = document.createElement('div'); - placeholder.className = 'enhanced-match-result-img-placeholder'; - placeholder.innerHTML = '🎵'; - row.appendChild(placeholder); - } - - const info = document.createElement('div'); - info.className = 'enhanced-match-result-info'; - const name = document.createElement('div'); - name.className = 'enhanced-match-result-name'; - name.textContent = result.name || 'Unknown'; - info.appendChild(name); - if (result.extra) { - const extra = document.createElement('div'); - extra.className = 'enhanced-match-result-extra'; - extra.textContent = result.extra; - info.appendChild(extra); - } - const idLine = document.createElement('div'); - idLine.className = 'enhanced-match-result-id'; - const providerLabel = result.provider && result.provider !== service ? ` (${result.provider})` : ''; - idLine.textContent = `ID: ${result.id}${providerLabel}`; - info.appendChild(idLine); - row.appendChild(info); - - const matchBtn = document.createElement('button'); - matchBtn.className = 'enhanced-meta-save-btn'; - matchBtn.textContent = 'Match'; - matchBtn.onclick = () => applyManualMatch(entityType, entityId, result.provider || service, result.id, artistId); - row.appendChild(matchBtn); - - container.appendChild(row); - }); - - } catch (error) { - container.innerHTML = `
Error: ${escapeHtml(error.message)}
`; - } -} - -async function applyManualMatch(entityType, entityId, service, serviceId, artistId) { - try { - showToast(`Matching ${entityType} to ${service}...`, 'info'); - - const response = await fetch('/api/library/manual-match', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - entity_type: entityType, - entity_id: entityId, - service: service, - service_id: serviceId, - artist_id: artistId - }) - }); - - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - showToast(`Manually matched to ${service} ID: ${serviceId}`, 'success'); - - // Close modal - const overlay = document.getElementById('enhanced-manual-match-overlay'); - if (overlay) overlay.remove(); - - // Update view with fresh data - if (result.updated_data && result.updated_data.success) { - artistDetailPageState.enhancedData = result.updated_data; - _rebuildAlbumMap(); - renderEnhancedView(); - } else if (artistDetailPageState.currentArtistId) { - await loadEnhancedViewData(artistDetailPageState.currentArtistId); - } - - } catch (error) { - showToast(`Match failed: ${error.message}`, 'error'); - } -} - -// ---- Enrichment ---- - -let _enrichmentInFlight = false; - -async function runEnrichment(entityType, entityId, service, name, artistName, artistId) { - if (_enrichmentInFlight) { - showToast('An enrichment is already in progress', 'error'); - return; - } - - _enrichmentInFlight = true; - - // Add loading class to all match chips for this service - const chipPrefixes = { - 'spotify': ['spotify', 'sp'], - 'musicbrainz': ['musicbrainz', 'mb'], - 'deezer': ['deezer', 'dz'], - 'audiodb': ['audiodb', 'adb'], - 'itunes': ['itunes', 'it'], - 'lastfm': ['last.fm', 'lfm'], - 'genius': ['genius', 'gen'], - }; - const prefixes = chipPrefixes[service] || [service]; - document.querySelectorAll('.enhanced-match-chip').forEach(chip => { - const chipText = chip.textContent.toLowerCase(); - if (prefixes.some(p => chipText.startsWith(p))) { - chip.classList.add('loading'); - } - }); - - showToast(`Enriching ${entityType} from ${service}...`, 'info'); - - try { - const response = await fetch('/api/library/enrich', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - entity_type: entityType, - entity_id: entityId, - service: service, - name: name, - artist_name: artistName, - artist_id: artistId - }) - }); - - const result = await response.json(); - - if (response.status === 429) { - showToast(result.error || 'Another enrichment is in progress', 'error'); - return; - } - - if (!result.success) { - throw new Error(result.error || 'Enrichment failed'); - } - - // Show per-service results - const results = result.results || {}; - const successes = Object.entries(results).filter(([, r]) => r.success).map(([s]) => s); - const failures = Object.entries(results).filter(([, r]) => !r.success).map(([s, r]) => `${s}: ${r.error}`); - - if (successes.length > 0) { - showToast(`Enriched from: ${successes.join(', ')}`, 'success'); - } - if (failures.length > 0) { - showToast(`Failed: ${failures.join('; ')}`, 'error'); - } - - // Update local data with fresh response and re-render (preserves expanded state) - if (result.updated_data && result.updated_data.success) { - artistDetailPageState.enhancedData = result.updated_data; - _rebuildAlbumMap(); - renderEnhancedView(); - } else if (artistDetailPageState.currentArtistId) { - await loadEnhancedViewData(artistDetailPageState.currentArtistId); - } - - } catch (error) { - console.error('Enrichment error:', error); - showToast(`Enrichment error: ${error.message}`, 'error'); - } finally { - _enrichmentInFlight = false; - document.querySelectorAll('.enhanced-match-chip.loading').forEach(c => c.classList.remove('loading')); - } -} - -// Close enrich dropdowns when clicking outside (early bail when enhanced view isn't active) -document.addEventListener('click', (e) => { - if (!artistDetailPageState.enhancedView) return; - if (!e.target.closest('.enhanced-enrich-wrap')) { - document.querySelectorAll('.enhanced-enrich-menu.visible').forEach(m => m.classList.remove('visible')); - } -}); - -// ---- Write Tags to File ---- - -let _tagPreviewTrackId = null; -let _tagPreviewServerType = null; - -async function showTagPreview(trackId) { - _tagPreviewTrackId = trackId; - _tagPreviewServerType = null; - const overlay = document.getElementById('tag-preview-overlay'); - const body = document.getElementById('tag-preview-body'); - const title = document.getElementById('tag-preview-title'); - if (!overlay || !body) return; - - title.textContent = 'Write Tags to File'; - body.innerHTML = '
Loading tag comparison...
'; - overlay.classList.remove('hidden'); - - // Hide sync checkbox until we know server type - const syncLabel = document.getElementById('tag-preview-sync-label'); - if (syncLabel) syncLabel.classList.add('hidden'); - - try { - const response = await fetch(`/api/library/track/${trackId}/tag-preview`); - const result = await response.json(); - if (!result.success) { - body.innerHTML = `
${escapeHtml(result.error)}
`; - return; - } - - const diff = result.diff || []; - const hasChanges = result.has_changes; - - // Show server sync checkbox if a server is connected (not navidrome — it auto-detects) - _tagPreviewServerType = result.server_type || null; - if (syncLabel && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome') { - const syncText = document.getElementById('tag-preview-sync-text'); - if (syncText) syncText.textContent = `Sync to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`; - syncLabel.classList.remove('hidden'); - } - - let html = ''; - html += ''; - html += ''; - - diff.forEach(d => { - const rowClass = d.changed ? 'tag-diff-changed' : 'tag-diff-same'; - const arrow = d.changed ? '' : ''; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ''; - }); - - html += '
FieldCurrent File TagDB Value
${d.field}${escapeHtml(d.file_value) || 'empty'}${arrow}${escapeHtml(d.db_value) || 'empty'}
'; - - if (!hasChanges) { - html += '
File tags already match DB metadata
'; - } - - body.innerHTML = html; - - const writeBtn = document.getElementById('tag-preview-write-btn'); - if (writeBtn) { - writeBtn.disabled = !hasChanges && !document.getElementById('tag-preview-embed-cover')?.checked; - } - - } catch (error) { - body.innerHTML = `
Failed to load preview: ${escapeHtml(error.message)}
`; - } -} - -function closeTagPreviewModal() { - const overlay = document.getElementById('tag-preview-overlay'); - if (overlay) overlay.classList.add('hidden'); - _tagPreviewTrackId = null; -} - -async function executeWriteTags() { - if (!_tagPreviewTrackId) return; - - const writeBtn = document.getElementById('tag-preview-write-btn'); - if (writeBtn) { - writeBtn.disabled = true; - writeBtn.textContent = 'Writing...'; - } - - const embedCover = document.getElementById('tag-preview-embed-cover')?.checked ?? true; - const syncToServer = document.getElementById('tag-preview-sync-server')?.checked && _tagPreviewServerType && _tagPreviewServerType !== 'navidrome'; - - try { - const response = await fetch(`/api/library/track/${_tagPreviewTrackId}/write-tags`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ embed_cover: embedCover, sync_to_server: syncToServer }) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - const fieldCount = (result.written_fields || []).length; - let msg = `Tags written successfully (${fieldCount} fields)`; - if (result.server_sync) { - const ss = result.server_sync; - if (ss.synced > 0) msg += ` — synced to ${_tagPreviewServerType === 'plex' ? 'Plex' : 'Jellyfin'}`; - else if (ss.failed > 0) msg += ` — server sync failed`; - } - showToast(msg, 'success'); - closeTagPreviewModal(); - - } catch (error) { - showToast(`Failed to write tags: ${error.message}`, 'error'); - } finally { - if (writeBtn) { - writeBtn.disabled = false; - writeBtn.textContent = 'Write Tags'; - } - } -} - -async function writeAlbumTags(albumId) { - const album = findEnhancedAlbum(albumId); - if (!album) return; - - const tracks = (album.tracks || []).filter(t => t.file_path); - if (tracks.length === 0) { - showToast('No tracks with files in this album', 'error'); - return; - } - - await showBatchTagPreview(tracks.map(t => t.id), album.title); -} - -async function batchWriteTagsSelected() { - const trackIds = Array.from(artistDetailPageState.selectedTracks); - if (trackIds.length === 0) return; - - await showBatchTagPreview(trackIds, null); -} - -async function showBatchTagPreview(trackIds, albumTitle) { - const overlay = document.getElementById('batch-tag-preview-overlay'); - const body = document.getElementById('batch-tag-preview-body'); - const titleEl = document.getElementById('batch-tag-preview-title'); - const summary = document.getElementById('batch-tag-preview-summary'); - const writeBtn = document.getElementById('batch-tag-preview-write-btn'); - if (!overlay || !body) return; - - titleEl.textContent = albumTitle ? `Write Tags — ${albumTitle}` : `Write Tags — ${trackIds.length} Tracks`; - body.innerHTML = '
Loading tag previews...
'; - summary.innerHTML = ''; - writeBtn.disabled = true; - overlay.classList.remove('hidden'); - - // Hide sync checkbox until we know server type - const syncLabel = document.getElementById('batch-tag-preview-sync-label'); - if (syncLabel) syncLabel.classList.add('hidden'); - - try { - const response = await fetch('/api/library/tracks/tag-preview-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_ids: trackIds }) - }); - const result = await response.json(); - if (!result.success) { - body.innerHTML = `
${escapeHtml(result.error)}
`; - return; - } - - const tracks = result.tracks || []; - const serverType = result.server_type || null; - - // Show sync checkbox if server connected - if (syncLabel && serverType && serverType !== 'navidrome') { - const syncText = document.getElementById('batch-tag-preview-sync-text'); - if (syncText) syncText.textContent = `Sync to ${serverType === 'plex' ? 'Plex' : 'Jellyfin'}`; - syncLabel.classList.remove('hidden'); - } - - // Categorize tracks - const withChanges = tracks.filter(t => t.has_changes); - const noChanges = tracks.filter(t => !t.error && !t.has_changes); - const errors = tracks.filter(t => t.error); - - // Summary bar - let summaryHtml = '
'; - if (withChanges.length > 0) summaryHtml += `${withChanges.length} with changes`; - if (noChanges.length > 0) summaryHtml += `${noChanges.length} unchanged`; - if (errors.length > 0) summaryHtml += `${errors.length} unavailable`; - summaryHtml += '
'; - summary.innerHTML = summaryHtml; - - // Build track accordion - let html = ''; - - // Tracks with changes (expanded by default) - withChanges.forEach(track => { - html += _renderBatchTrackDiff(track, true); - }); - - // Errors - errors.forEach(track => { - html += `
`; - html += `
`; - html += `${track.track_number || '—'}`; - html += `${escapeHtml(track.title)}`; - html += `${escapeHtml(track.error)}`; - html += `
`; - }); - - // Unchanged tracks (collapsed) - if (noChanges.length > 0) { - html += `
`; - html += `
`; - html += `${noChanges.length} track${noChanges.length !== 1 ? 's' : ''} already up to date`; - html += ``; - html += `
`; - html += `
`; - noChanges.forEach(track => { - html += `
`; - html += `${track.track_number || '—'}`; - html += `${escapeHtml(track.title)}`; - html += `✓ Tags match`; - html += `
`; - }); - html += `
`; - } - - if (withChanges.length === 0 && errors.length === 0) { - html += '
All file tags already match DB metadata
'; - } - - body.innerHTML = html; - - // Store state for write action - overlay._batchTrackIds = trackIds; - overlay._batchServerType = serverType; - writeBtn.disabled = withChanges.length === 0; - - } catch (error) { - body.innerHTML = `
Failed to load previews: ${escapeHtml(error.message)}
`; - } -} - -function _renderBatchTrackDiff(track, expanded) { - let html = `
`; - html += `
`; - html += `${track.track_number || '—'}`; - html += `${escapeHtml(track.title)}`; - html += `${track.changed_count} field${track.changed_count !== 1 ? 's' : ''} changed`; - html += ``; - html += `
`; - html += `
`; - html += ''; - html += ''; - html += ''; - - (track.diff || []).forEach(d => { - if (!d.changed) return; // Only show changed fields in batch view - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ''; - }); - - html += '
FieldCurrent FileNew Value
${d.field}${escapeHtml(d.file_value) || 'empty'}${escapeHtml(d.db_value) || 'empty'}
'; - return html; -} - -function closeBatchTagPreviewModal() { - const overlay = document.getElementById('batch-tag-preview-overlay'); - if (overlay) { - overlay.classList.add('hidden'); - overlay._batchTrackIds = null; - overlay._batchServerType = null; - } -} - -async function executeBatchWriteTags() { - const overlay = document.getElementById('batch-tag-preview-overlay'); - const trackIds = overlay?._batchTrackIds; - if (!trackIds || trackIds.length === 0) return; - - const writeBtn = document.getElementById('batch-tag-preview-write-btn'); - if (writeBtn) { - writeBtn.disabled = true; - writeBtn.textContent = 'Writing...'; - } - - const embedCover = document.getElementById('batch-tag-preview-embed-cover')?.checked ?? true; - const serverType = overlay._batchServerType; - const syncToServer = document.getElementById('batch-tag-preview-sync-server')?.checked && serverType && serverType !== 'navidrome'; - - closeBatchTagPreviewModal(); - await _startBatchWriteTags(trackIds, embedCover, syncToServer); - - if (writeBtn) { - writeBtn.disabled = false; - writeBtn.textContent = 'Write Tags'; - } -} - -async function _startBatchWriteTags(trackIds, embedCover, syncToServer = false) { - try { - const response = await fetch('/api/library/tracks/write-tags-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_ids: trackIds, embed_cover: embedCover, sync_to_server: syncToServer }) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - showToast(`Writing tags for ${trackIds.length} tracks...`, 'info'); - _pollBatchWriteTagsStatus(); - - } catch (error) { - showToast(`Failed to start tag write: ${error.message}`, 'error'); - } -} - -let _batchWriteTagsPollTimer = null; - -function _pollBatchWriteTagsStatus() { - if (_batchWriteTagsPollTimer) clearTimeout(_batchWriteTagsPollTimer); - - async function poll() { - try { - const response = await fetch('/api/library/tracks/write-tags-batch/status'); - const state = await response.json(); - - if (state.status === 'running') { - if (state.sync_phase === 'syncing') { - const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server; - showToast(`Syncing to ${serverName}...`, 'info'); - } else { - const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; - showToast(`Writing tags: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); - } - _batchWriteTagsPollTimer = setTimeout(poll, 1000); - } else if (state.status === 'done') { - let msg = `Tags written: ${state.written} succeeded, ${state.failed} failed`; - if (state.sync_phase === 'done') { - const serverName = state.sync_server === 'plex' ? 'Plex' : state.sync_server === 'jellyfin' ? 'Jellyfin' : state.sync_server; - if (state.sync_synced > 0 && state.sync_failed === 0) { - msg += ` — synced to ${serverName}`; - } else if (state.sync_failed > 0) { - msg += ` — ${serverName} sync: ${state.sync_synced} synced, ${state.sync_failed} failed`; - } - } - // Surface the first error reason so users can diagnose (e.g. "File not found") - if (state.failed > 0 && state.errors && state.errors.length > 0) { - const firstErr = state.errors[0].error || 'Unknown error'; - msg += ` (${firstErr})`; - } - showToast(msg, state.failed > 0 || state.sync_failed > 0 ? 'warning' : 'success'); - _batchWriteTagsPollTimer = null; - } - } catch (error) { - console.error('Poll write-tags status failed:', error); - _batchWriteTagsPollTimer = null; - } - } - - _batchWriteTagsPollTimer = setTimeout(poll, 800); -} - -// ── ReplayGain Analysis ── - -let _rgBatchPollTimer = null; -let _rgAlbumPollTimer = null; - -/** - * Analyze a single track and write track-level ReplayGain tags. - * Synchronous on the server side (~1–3 s). Shows spinner on the button. - */ -async function analyzeTrackReplayGain(trackId, btn) { - if (btn) { - btn.disabled = true; - btn.textContent = '…'; - } - try { - const res = await fetch(`/api/library/track/${trackId}/analyze-replaygain`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`ReplayGain written: ${data.track_gain} (${data.lufs} LUFS)`, 'success'); - } else { - showToast(`ReplayGain failed: ${data.error}`, 'error'); - } - } catch (err) { - showToast('ReplayGain analysis failed', 'error'); - } finally { - if (btn) { - btn.disabled = false; - btn.textContent = 'RG'; - } - } -} - -/** - * Analyze all tracks in an album and write track + album ReplayGain tags. - * Kicks off a background job; polls for progress. - */ -async function analyzeAlbumReplayGain(albumId, btn) { - if (btn) { - btn.disabled = true; - btn.innerHTML = '♫ Analyzing…'; - } - try { - const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain`, { method: 'POST' }); - const data = await res.json(); - if (!data.success) { - showToast(`ReplayGain: ${data.error}`, 'error'); - if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } - return; - } - showToast('Album ReplayGain analysis started…', 'info'); - _pollAlbumRgStatus(albumId, btn); - } catch (err) { - showToast('Failed to start album ReplayGain analysis', 'error'); - if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } - } -} - -function _pollAlbumRgStatus(albumId, btn) { - if (_rgAlbumPollTimer) clearTimeout(_rgAlbumPollTimer); - - async function poll() { - try { - const res = await fetch(`/api/library/album/${albumId}/analyze-replaygain/status`); - const state = await res.json(); - - if (state.status === 'running') { - const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; - showToast(`ReplayGain: ${state.processed}/${state.total} tracks (${pct}%)`, 'info'); - _rgAlbumPollTimer = setTimeout(poll, 1200); - } else if (state.status === 'done') { - const msg = `ReplayGain done: ${state.analyzed} analyzed, ${state.failed} failed`; - showToast(msg, state.failed > 0 ? 'warning' : 'success'); - if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } - _rgAlbumPollTimer = null; - } - } catch (err) { - console.error('ReplayGain album poll failed:', err); - if (btn) { btn.disabled = false; btn.innerHTML = '♫ ReplayGain'; } - _rgAlbumPollTimer = null; - } - } - - _rgAlbumPollTimer = setTimeout(poll, 1000); -} - -/** - * Analyze selected tracks (track gain only — they may span albums). - */ -async function batchAnalyzeReplayGainSelected() { - const trackIds = Array.from(artistDetailPageState.selectedTracks); - if (trackIds.length === 0) return; - - try { - const res = await fetch('/api/library/tracks/analyze-replaygain-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_ids: trackIds }), - }); - const data = await res.json(); - if (!data.success) { - showToast(`ReplayGain: ${data.error}`, 'error'); - return; - } - showToast(`ReplayGain analysis started for ${trackIds.length} tracks…`, 'info'); - _pollBatchRgStatus(); - } catch (err) { - showToast('Failed to start batch ReplayGain analysis', 'error'); - } -} - -function _pollBatchRgStatus() { - if (_rgBatchPollTimer) clearTimeout(_rgBatchPollTimer); - - async function poll() { - try { - const res = await fetch('/api/library/tracks/analyze-replaygain-batch/status'); - const state = await res.json(); - - if (state.status === 'running') { - const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; - showToast(`ReplayGain: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); - _rgBatchPollTimer = setTimeout(poll, 1000); - } else if (state.status === 'done') { - const msg = `ReplayGain done: ${state.analyzed} written, ${state.failed} failed`; - showToast(msg, state.failed > 0 ? 'warning' : 'success'); - _rgBatchPollTimer = null; - } - } catch (err) { - console.error('ReplayGain batch poll failed:', err); - _rgBatchPollTimer = null; - } - } - - _rgBatchPollTimer = setTimeout(poll, 800); -} - -// ── Reorganize Album Files ── - -let _reorganizeAlbumId = null; -let _reorganizePollTimer = null; - -async function showReorganizeModal(albumId) { - _reorganizeAlbumId = albumId; - const overlay = document.getElementById('reorganize-overlay'); - const body = document.getElementById('reorganize-modal-body'); - const title = document.getElementById('reorganize-modal-title'); - const applyBtn = document.getElementById('reorganize-apply-btn'); - if (!overlay || !body) return; - - // Find album data from enhanced view state - let albumData = null; - let artistName = ''; - if (artistDetailPageState.enhancedData) { - artistName = artistDetailPageState.enhancedData.artist.name || ''; - const allAlbums = artistDetailPageState.enhancedData.albums || []; - albumData = allAlbums.find(a => String(a.id) === String(albumId)); - } - - title.textContent = `Reorganize: ${albumData ? albumData.title : 'Album'}`; - if (applyBtn) { - applyBtn.disabled = true; - applyBtn.textContent = 'Apply'; - applyBtn.onclick = () => executeReorganize(); - } - - // Build modal content - const variables = [ - { var: '$artist', desc: 'Track artist', example: artistName || 'Artist' }, - { var: '$albumartist', desc: 'Album artist', example: artistName || 'Album Artist' }, - { var: '$artistletter', desc: 'First letter of artist', example: (artistName || 'A')[0].toUpperCase() }, - { var: '$album', desc: 'Album title', example: albumData ? albumData.title : 'Album' }, - { var: '$albumtype', desc: 'Album/EP/Single', example: 'Album' }, - { var: '$title', desc: 'Track title', example: 'Track Name' }, - { var: '$track', desc: 'Track number (zero-padded)', example: '01' }, - { var: '$disc', desc: 'Disc number (filename only)', example: '01' }, - { var: '$year', desc: 'Release year', example: albumData && albumData.year ? String(albumData.year) : '2024' }, - { var: '$quality', desc: 'Audio quality (filename only)', example: 'FLAC 16bit/44kHz' }, - ]; - - let html = '
'; - - // Template input - html += '
'; - html += ''; - html += '
Use / to separate folders. The last segment becomes the filename.
'; - // Load saved template from settings, fall back to default - let savedTemplate = '$albumartist/$albumartist - $album/$track - $title'; - try { - const settingsResp = await fetch('/api/settings'); - if (settingsResp.ok) { - const settings = await settingsResp.json(); - savedTemplate = settings.file_organization?.templates?.album_path || savedTemplate; - } - } catch (_) { } - html += ' { - html += `
`; - html += `${v.var}${v.desc}`; - html += '
'; - }); - html += '
'; - - // Preview area - html += '
'; - html += '
'; - html += ''; - html += ''; - html += '
'; - html += '
'; - html += '
Click "Generate Preview" to see how files will be reorganized.
'; - html += '
'; - - html += '
'; - body.innerHTML = html; - overlay.classList.remove('hidden'); - - // Wire up live preview on enter key - setTimeout(() => { - const input = document.getElementById('reorganize-template-input'); - if (input) { - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - loadReorganizePreview(); - } - }); - input.focus(); - } - }, 50); -} - -function insertReorganizeVar(varName) { - const input = document.getElementById('reorganize-template-input'); - if (!input) return; - const start = input.selectionStart; - const end = input.selectionEnd; - const val = input.value; - input.value = val.substring(0, start) + varName + val.substring(end); - input.focus(); - const newPos = start + varName.length; - input.setSelectionRange(newPos, newPos); -} - -function closeReorganizeModal() { - const overlay = document.getElementById('reorganize-overlay'); - if (overlay) overlay.classList.add('hidden'); - _reorganizeAlbumId = null; -} - -async function loadReorganizePreview() { - const template = document.getElementById('reorganize-template-input')?.value?.trim(); - const previewBody = document.getElementById('reorganize-preview-body'); - const applyBtn = document.getElementById('reorganize-apply-btn'); - if (!template || !previewBody || !_reorganizeAlbumId) return; - - if (applyBtn) applyBtn.disabled = true; - previewBody.innerHTML = '
Loading preview...
'; - - try { - const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize/preview`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }) - }); - const result = await response.json(); - if (!result.success) { - previewBody.innerHTML = `
${escapeHtml(result.error || 'Preview failed')}
`; - return; - } - - const tracks = result.tracks || []; - if (tracks.length === 0) { - previewBody.innerHTML = '
No tracks found.
'; - return; - } - - let hasChanges = false; - let hasCollisions = false; - let html = ''; - html += ''; - html += ''; - - tracks.forEach(t => { - const unchanged = t.unchanged; - const noFile = !t.file_exists; - const collision = t.collision; - if (!unchanged && t.file_exists) hasChanges = true; - if (collision) hasCollisions = true; - - const rowClass = collision ? 'reorganize-row-collision' : noFile ? 'reorganize-row-missing' : unchanged ? 'reorganize-row-unchanged' : 'reorganize-row-changed'; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ``; - html += ''; - }); - - html += '
#TitleCurrent PathNew Path
${t.track_number || ''}${escapeHtml(t.title)}${noFile ? 'File not found' : escapeHtml(t.current_path)}${collision ? '!!' : unchanged ? '=' : noFile ? '' : '→'}${noFile ? '' : escapeHtml(t.new_path)}${collision ? ' (collision)' : ''}
'; - - const changedCount = tracks.filter(t => !t.unchanged && t.file_exists && !t.collision).length; - const skippedCount = tracks.filter(t => t.unchanged).length; - const missingCount = tracks.filter(t => !t.file_exists).length; - const collisionCount = tracks.filter(t => t.collision).length; - - let summary = `
`; - if (changedCount > 0) summary += `${changedCount} will move`; - if (skippedCount > 0) summary += `${skippedCount} unchanged`; - if (missingCount > 0) summary += `${missingCount} missing`; - if (collisionCount > 0) summary += `${collisionCount} collision${collisionCount !== 1 ? 's' : ''} — add $track or $disc to fix`; - summary += '
'; - - previewBody.innerHTML = summary + html; - - // Block apply if collisions exist - if (applyBtn) applyBtn.disabled = !hasChanges || hasCollisions; - - } catch (error) { - previewBody.innerHTML = `
Error: ${escapeHtml(error.message)}
`; - } -} - -async function executeReorganize() { - const template = document.getElementById('reorganize-template-input')?.value?.trim(); - if (!template || !_reorganizeAlbumId) return; - - const applyBtn = document.getElementById('reorganize-apply-btn'); - if (applyBtn) { - applyBtn.disabled = true; - applyBtn.textContent = 'Reorganizing...'; - } - - try { - const response = await fetch(`/api/library/album/${_reorganizeAlbumId}/reorganize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }) - }); - const result = await response.json(); - if (!result.success) throw new Error(result.error); - - closeReorganizeModal(); - showToast(`Reorganizing ${result.total} tracks...`, 'info'); - _pollReorganizeStatus(); - - } catch (error) { - showToast(`Reorganize failed: ${error.message}`, 'error'); - if (applyBtn) { - applyBtn.disabled = false; - applyBtn.textContent = 'Apply'; - } - } -} - -function _pollReorganizeStatus() { - if (_reorganizePollTimer) clearTimeout(_reorganizePollTimer); - - async function poll() { - try { - const response = await fetch('/api/library/album/reorganize/status'); - const state = await response.json(); - - if (state.status === 'running') { - const pct = state.total > 0 ? Math.round(state.processed / state.total * 100) : 0; - showToast(`Reorganizing: ${state.processed}/${state.total} (${pct}%) — ${state.current_track}`, 'info'); - _reorganizePollTimer = setTimeout(poll, 800); - } else if (state.status === 'done') { - let msg = `Reorganized: ${state.moved} moved`; - if (state.skipped > 0) msg += `, ${state.skipped} skipped`; - if (state.failed > 0) msg += `, ${state.failed} failed`; - if (state.failed > 0 && state.errors && state.errors.length > 0) { - msg += ` (${state.errors[0].error})`; - } - showToast(msg, state.failed > 0 ? 'warning' : 'success'); - _reorganizePollTimer = null; - - // Refresh the enhanced view to show updated paths - if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { - loadEnhancedViewData(artistDetailPageState.currentArtistId); - } - } - } catch (error) { - console.error('Poll reorganize status failed:', error); - _reorganizePollTimer = null; - } - } - - _reorganizePollTimer = setTimeout(poll, 600); -} - -// ── Reorganize All Albums for Artist ── - -let _reorganizeAllRunning = false; - -async function _showReorganizeAllModal() { - if (!artistDetailPageState.enhancedData) { - showToast('No album data loaded', 'error'); - return; - } - const albums = artistDetailPageState.enhancedData.albums || []; - const artistName = artistDetailPageState.enhancedData.artist.name || 'Artist'; - - if (albums.length === 0) { - showToast('No albums to reorganize', 'error'); - return; - } - - const overlay = document.getElementById('reorganize-overlay'); - const body = document.getElementById('reorganize-modal-body'); - const title = document.getElementById('reorganize-modal-title'); - const applyBtn = document.getElementById('reorganize-apply-btn'); - if (!overlay || !body) return; - - title.textContent = `Reorganize All Albums — ${artistName}`; - - // Load saved template - let savedTemplate = '$albumartist/$albumartist - $album/$track - $title'; - try { - const settingsResp = await fetch('/api/settings'); - if (settingsResp.ok) { - const settings = await settingsResp.json(); - savedTemplate = settings.file_organization?.templates?.album_path || savedTemplate; - } - } catch (_) { } - - let html = '
'; - - // Template input - html += '
'; - html += ''; - html += '
This template will be applied to all albums below. Use / to separate folders.
'; - html += ``; - html += '
'; - - // Album list - html += '
'; - html += ``; - html += '
'; - albums.forEach((a, i) => { - const trackCount = a.tracks ? a.tracks.length : '?'; - html += `
`; - html += `${escapeHtml(a.title)} (${trackCount} tracks)`; - html += '
'; - }); - html += '
'; - - html += '
'; - body.innerHTML = html; - - // Wire apply button for bulk mode - if (applyBtn) { - applyBtn.disabled = false; - applyBtn.textContent = 'Reorganize All'; - applyBtn.onclick = () => _executeReorganizeAll(); - } - - overlay.classList.remove('hidden'); -} - -async function _executeReorganizeAll() { - if (_reorganizeAllRunning) return; - - const templateInput = document.getElementById('reorganize-template-input'); - const template = templateInput ? templateInput.value.trim() : ''; - if (!template) { - showToast('Template cannot be empty', 'error'); - return; - } - - const albums = artistDetailPageState.enhancedData.albums || []; - const total = albums.length; - const artistName = artistDetailPageState.enhancedData.artist?.name || 'this artist'; - - const confirmed = await showConfirmDialog({ - title: 'Reorganize All Albums', - message: `This will reorganize ${total} album${total !== 1 ? 's' : ''} for ${artistName} using the template:\n\n${template}\n\nFiles will be moved and renamed. This cannot be undone.`, - confirmText: 'Reorganize All', - destructive: false, - }); - if (!confirmed) return; - - _reorganizeAllRunning = true; - const applyBtn = document.getElementById('reorganize-apply-btn'); - if (applyBtn) { applyBtn.disabled = true; applyBtn.textContent = 'Working...'; } - - // Close modal - const overlay = document.getElementById('reorganize-overlay'); - if (overlay) overlay.classList.add('hidden'); - - let succeeded = 0, failed = 0; - - for (let i = 0; i < total; i++) { - const album = albums[i]; - showToast(`Reorganizing album ${i + 1}/${total}: ${album.title}`, 'info'); - - try { - const resp = await fetch(`/api/library/album/${album.id}/reorganize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ template }), - }); - const result = await resp.json(); - if (!result.success) { - showToast(`Failed: ${album.title} — ${result.error || 'unknown error'}`, 'error'); - failed++; - continue; - } - - // Wait for this album to finish - await _waitForReorganizeComplete(); - succeeded++; - } catch (err) { - showToast(`Error: ${album.title} — ${err.message}`, 'error'); - failed++; - } - } - - let msg = `Reorganized ${succeeded} of ${total} album${total !== 1 ? 's' : ''}`; - if (failed > 0) msg += ` (${failed} failed)`; - showToast(msg, failed > 0 ? 'warning' : 'success'); - - _reorganizeAllRunning = false; - if (applyBtn) { applyBtn.disabled = false; applyBtn.textContent = 'Reorganize All'; } - - // Refresh enhanced view - if (artistDetailPageState.currentArtistId && artistDetailPageState.enhancedView) { - loadEnhancedViewData(artistDetailPageState.currentArtistId); - } -} - -function _waitForReorganizeComplete() { - return new Promise(resolve => { - const poll = setInterval(async () => { - try { - const resp = await fetch('/api/library/album/reorganize/status'); - const state = await resp.json(); - if (state.status === 'done' || state.status === 'idle') { - clearInterval(poll); - resolve(); - } - } catch { - clearInterval(poll); - resolve(); - } - }, 800); - }); -} - -async function playLibraryTrack(track, albumTitle, artistName) { - if (!track.file_path) { - showToast('No file available for this track', 'error'); - return; - } - - try { - // Stop any current playback first - if (audioPlayer && !audioPlayer.paused) { - audioPlayer.pause(); - } - - // Get album art from enhanced data if available - let albumArt = null; - if (artistDetailPageState.enhancedData) { - const albums = artistDetailPageState.enhancedData.albums || []; - for (const a of albums) { - if ((a.tracks || []).some(t => t.id === track.id)) { - albumArt = a.thumb_url; - break; - } - } - if (!albumArt) albumArt = artistDetailPageState.enhancedData.artist?.thumb_url; - } - if (!albumArt && track._stats_image) albumArt = track._stats_image; - - // Set track info in the media player UI - setTrackInfo({ - title: track.title || 'Unknown Track', - artist: artistName || 'Unknown Artist', - album: albumTitle || 'Unknown Album', - filename: track.file_path, - is_library: true, - image_url: albumArt, - id: track.id, - artist_id: track.artist_id, - album_id: track.album_id, - bitrate: track.bitrate, - sample_rate: track.sample_rate - }); - - // Show loading state - showLoadingAnimation(); - const loadingText = document.querySelector('.loading-text'); - if (loadingText) { - loadingText.textContent = 'Loading library track...'; - } - - // POST to library play endpoint - const response = await fetch('/api/library/play', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - file_path: track.file_path, - title: track.title || '', - artist: artistName || '', - album: albumTitle || '' - }) - }); - - const result = await response.json(); - if (!result.success) { - // File not on disk — fall back to streaming from configured source - console.warn('Library file not found, falling back to stream source'); - hideLoadingAnimation(); - const streamRes = await fetch('/api/enhanced-search/stream-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_name: track.title || '', - artist_name: artistName || '', - album_name: albumTitle || '', - }) - }); - const streamData = await streamRes.json(); - if (streamData.success && streamData.result) { - streamData.result.artist = artistName; - streamData.result.title = track.title; - streamData.result.album = albumTitle; - streamData.result.image_url = track._stats_image || null; - startStream(streamData.result); - return; - } - throw new Error(result.error || 'Failed to start library playback'); - } - - // Re-apply repeat-one loop property - if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one'); - // Stream state is already "ready" — start audio playback directly - await startAudioPlayback(); - - } catch (error) { - console.error('Library playback error:', error); - showToast(`Playback error: ${error.message}`, 'error'); - hideLoadingAnimation(); - clearTrack(); - } -} - -// ==================== End Enhanced Library Management View ==================== - -// UI state management functions -function showArtistDetailLoading(show) { - const loadingElement = document.getElementById("artist-detail-loading"); - if (loadingElement) { - if (show) { - loadingElement.classList.remove("hidden"); - } else { - loadingElement.classList.add("hidden"); - } - } -} - -function showArtistDetailError(show, message = "") { - const errorElement = document.getElementById("artist-detail-error"); - const errorMessageElement = document.getElementById("artist-detail-error-message"); - - if (errorElement) { - if (show) { - errorElement.classList.remove("hidden"); - if (errorMessageElement && message) { - errorMessageElement.textContent = message; - } - } else { - errorElement.classList.add("hidden"); - } - } -} - -function showArtistDetailMain(show) { - const mainElement = document.getElementById("artist-detail-main"); - if (mainElement) { - if (show) { - mainElement.classList.remove("hidden"); - } else { - mainElement.classList.add("hidden"); - } - } -} - -function showArtistDetailHero(show) { - const heroElement = document.getElementById("artist-hero-section"); - if (heroElement) { - if (show) { - heroElement.classList.remove("hidden"); - } else { - heroElement.classList.add("hidden"); - } - } -} - -/** - * Initialize the library page watchlist button - */ -async function initializeLibraryWatchlistButton(artistId, artistName) { - const button = document.getElementById('library-artist-watchlist-btn'); - if (!button) return; - - console.log(`🔧 Initializing library watchlist button for: ${artistName} (${artistId})`); - - // Reset button state - button.disabled = false; - button.classList.remove('watching'); - - // Set up click handler - button.onclick = (e) => toggleLibraryWatchlist(e, artistId, artistName); - - // Check and update current status - await updateLibraryWatchlistButtonStatus(artistId); -} - -/** - * Toggle watchlist status for library page - */ -async function toggleLibraryWatchlist(event, artistId, artistName) { - event.preventDefault(); - - const button = document.getElementById('library-artist-watchlist-btn'); - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - // Show loading state - const originalText = text.textContent; - text.textContent = 'Loading...'; - button.disabled = true; - - try { - // Check current status - const checkResponse = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const checkData = await checkResponse.json(); - if (!checkData.success) { - throw new Error(checkData.error || 'Failed to check watchlist status'); - } - - const isWatching = checkData.is_watching; - - // Toggle watchlist status - const endpoint = isWatching ? '/api/watchlist/remove' : '/api/watchlist/add'; - const payload = isWatching ? - { artist_id: artistId } : - { artist_id: artistId, artist_name: artistName }; - - const response = await fetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload) - }); - - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to update watchlist'); - } - - // Update button state based on new status - if (isWatching) { - // Was watching, now removed - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - console.log(`❌ Removed ${artistName} from watchlist`); - } else { - // Was not watching, now added - icon.textContent = '👁️'; - text.textContent = 'Watching...'; - button.classList.add('watching'); - console.log(`✅ Added ${artistName} to watchlist`); - } - - // Update dashboard watchlist count if function exists - if (typeof updateWatchlistCount === 'function') { - updateWatchlistCount(); - } - - showToast(data.message, 'success'); - - } catch (error) { - console.error('Error toggling library watchlist:', error); - - // Restore button state - text.textContent = originalText; - showToast(`Error: ${error.message}`, 'error'); - - } finally { - button.disabled = false; - } -} - -/** - * Update library watchlist button status based on current state - */ -async function updateLibraryWatchlistButtonStatus(artistId) { - const button = document.getElementById('library-artist-watchlist-btn'); - if (!button) return; - - try { - const response = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const data = await response.json(); - - if (data.success) { - const icon = button.querySelector('.watchlist-icon'); - const text = button.querySelector('.watchlist-text'); - - if (data.is_watching) { - icon.textContent = '👁️'; - text.textContent = 'Watching...'; - button.classList.add('watching'); - } else { - icon.textContent = '👁️'; - text.textContent = 'Add to Watchlist'; - button.classList.remove('watching'); - } - } - } catch (error) { - console.warn('Failed to check library watchlist status:', error); - } -} - -// ================================= -// BEATPORT REBUILD SLIDER FUNCTIONALITY -// ================================= - -let beatportRebuildSliderState = { - currentSlide: 0, - totalSlides: 4, - autoPlayInterval: null, - autoPlayDelay: 5000 -}; - -/** - * Initialize the beatport rebuild slider functionality - */ -function initializeBeatportRebuildSlider() { - console.log('🔄 Initializing beatport rebuild slider...'); - - const slider = document.getElementById('beatport-rebuild-slider'); - if (!slider) { - console.warn('Beatport rebuild slider not found'); - return; - } - - // Check if already initialized to prevent duplicate event listeners - if (slider.dataset.initialized === 'true') { - console.log('Beatport rebuild slider already initialized, skipping...'); - startBeatportRebuildSliderAutoPlay(); // Just restart autoplay - return; - } - - // Mark as initialized - slider.dataset.initialized = 'true'; - - // Load real Beatport data first - loadBeatportHeroTracks(); - - console.log('✅ Beatport rebuild slider initialized successfully'); -} - -/** - * Load real Beatport hero tracks and populate the slider - */ -async function loadBeatportHeroTracks() { - console.log('🎯 Loading real Beatport hero tracks...'); - - try { - const response = await fetch('/api/beatport/hero-tracks'); - const data = await response.json(); - - if (data.success && data.tracks && data.tracks.length > 0) { - console.log(`✅ Loaded ${data.tracks.length} Beatport tracks`); - populateBeatportSlider(data.tracks); - } else { - console.warn('❌ No tracks received from Beatport API, using placeholder data'); - setupBeatportSliderWithPlaceholders(); - } - } catch (error) { - console.error('❌ Error loading Beatport tracks:', error); - setupBeatportSliderWithPlaceholders(); - } -} - -/** - * Populate the slider with real Beatport track data - */ -function populateBeatportSlider(tracks) { - const sliderTrack = document.getElementById('beatport-rebuild-slider-track'); - const indicatorsContainer = document.querySelector('.beatport-rebuild-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) { - console.warn('Slider elements not found'); - return; - } - - // Clear existing content - sliderTrack.innerHTML = ''; - indicatorsContainer.innerHTML = ''; - - // Update state - beatportRebuildSliderState.totalSlides = tracks.length; - beatportRebuildSliderState.currentSlide = 0; - - // Generate slides HTML - tracks.forEach((track, index) => { - const slideHtml = ` -
-
-
-
-
-
-

${track.title}

-

${track.artist}

-

New on Beatport

-
-
-
- `; - sliderTrack.insertAdjacentHTML('beforeend', slideHtml); - - // Add indicator - const indicatorHtml = ``; - indicatorsContainer.insertAdjacentHTML('beforeend', indicatorHtml); - }); - - // Now set up all the functionality - setupBeatportSliderFunctionality(); - - // Add individual click handlers for each slide (like top 10 releases pattern) - setupHeroSliderIndividualClickHandlers(tracks); - - console.log(`✅ Populated slider with ${tracks.length} real Beatport tracks`); -} - -/** - * Set up individual click handlers for hero slider slides (like top 10 releases) - */ -function setupHeroSliderIndividualClickHandlers(tracks) { - const slides = document.querySelectorAll('.beatport-rebuild-slide[data-url]'); - - slides.forEach((slide, index) => { - const releaseUrl = slide.getAttribute('data-url'); - if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') { - // Create release data object from the track data (similar to top 10 releases) - const track = tracks[index]; - if (track) { - const releaseData = { - url: releaseUrl, - title: track.title || 'Unknown Title', - artist: track.artist || 'Unknown Artist', - label: track.label || 'Unknown Label', - image_url: track.image_url || '' - }; - - // Add click handler that mimics the top 10 releases behavior - slide.addEventListener('click', (event) => { - // Prevent navigation button clicks from triggering this - if (event.target.closest('.beatport-rebuild-nav-btn') || - event.target.closest('.beatport-rebuild-indicator')) { - return; - } - - console.log(`🎯 Hero slider slide clicked: ${releaseData.title} by ${releaseData.artist}`); - handleBeatportReleaseCardClick(slide, releaseData); - }); - - slide.style.cursor = 'pointer'; - } - } - }); - - console.log(`✅ Set up individual click handlers for ${slides.length} hero slider slides`); -} - -/** - * Set up placeholder data if API fails - */ -function setupBeatportSliderWithPlaceholders() { - console.log('🔄 Setting up slider with placeholder data...'); - - // The HTML already has placeholder slides, just set up functionality - setupBeatportSliderFunctionality(); -} - -/** - * Set up all slider functionality after content is loaded - */ -function setupBeatportSliderFunctionality() { - // Set up navigation buttons - setupBeatportRebuildSliderNavigation(); - - // Set up indicators - setupBeatportRebuildSliderIndicators(); - - - // Start auto-play - startBeatportRebuildSliderAutoPlay(); - - // Set up pause on hover - setupBeatportRebuildSliderHoverPause(); -} - -/** - * Set up navigation button functionality - */ -function setupBeatportRebuildSliderNavigation() { - const prevBtn = document.getElementById('beatport-rebuild-prev-btn'); - const nextBtn = document.getElementById('beatport-rebuild-next-btn'); - - if (prevBtn) { - prevBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Previous button clicked, current slide:', beatportRebuildSliderState.currentSlide); - goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide - 1); - resetBeatportRebuildSliderAutoPlay(); - }); - } - - if (nextBtn) { - nextBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Next button clicked, current slide:', beatportRebuildSliderState.currentSlide); - goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide + 1); - resetBeatportRebuildSliderAutoPlay(); - }); - } -} - -/** - * Set up indicator functionality - */ -function setupBeatportRebuildSliderIndicators() { - const indicators = document.querySelectorAll('.beatport-rebuild-indicator'); - - indicators.forEach((indicator, index) => { - indicator.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - goToBeatportRebuildSlide(index); - resetBeatportRebuildSliderAutoPlay(); - }); - }); -} - -/** - * Navigate to a specific slide - */ -function goToBeatportRebuildSlide(slideIndex) { - console.log('goToBeatportRebuildSlide called with:', slideIndex, 'current:', beatportRebuildSliderState.currentSlide); - - // Wrap around if out of bounds - if (slideIndex < 0) { - slideIndex = beatportRebuildSliderState.totalSlides - 1; - } else if (slideIndex >= beatportRebuildSliderState.totalSlides) { - slideIndex = 0; - } - - console.log('After wrapping, slideIndex:', slideIndex); - - // Update current slide - beatportRebuildSliderState.currentSlide = slideIndex; - - // Update slide visibility - const slides = document.querySelectorAll('.beatport-rebuild-slide'); - slides.forEach((slide, index) => { - slide.classList.remove('active', 'prev', 'next'); - - if (index === slideIndex) { - slide.classList.add('active'); - } else if (index < slideIndex) { - slide.classList.add('prev'); - } else { - slide.classList.add('next'); - } - }); - - // Update indicators - const indicators = document.querySelectorAll('.beatport-rebuild-indicator'); - indicators.forEach((indicator, index) => { - indicator.classList.toggle('active', index === slideIndex); - }); - - console.log('Slide updated to:', beatportRebuildSliderState.currentSlide); -} - -/** - * Start auto-play functionality - */ -function startBeatportRebuildSliderAutoPlay() { - if (beatportRebuildSliderState.autoPlayInterval) { - clearInterval(beatportRebuildSliderState.autoPlayInterval); - } - - beatportRebuildSliderState.autoPlayInterval = setInterval(() => { - goToBeatportRebuildSlide(beatportRebuildSliderState.currentSlide + 1); - }, beatportRebuildSliderState.autoPlayDelay); -} - -/** - * Reset auto-play timer - */ -function resetBeatportRebuildSliderAutoPlay() { - startBeatportRebuildSliderAutoPlay(); -} - -/** - * Set up hover pause functionality - */ -function setupBeatportRebuildSliderHoverPause() { - const sliderContainer = document.querySelector('.beatport-rebuild-slider-container'); - - if (sliderContainer) { - sliderContainer.addEventListener('mouseenter', () => { - if (beatportRebuildSliderState.autoPlayInterval) { - clearInterval(beatportRebuildSliderState.autoPlayInterval); - } - }); - - sliderContainer.addEventListener('mouseleave', () => { - startBeatportRebuildSliderAutoPlay(); - }); - } -} - - -/** - * Clean up beatport rebuild slider when switching away - */ -function cleanupBeatportRebuildSlider() { - if (beatportRebuildSliderState.autoPlayInterval) { - clearInterval(beatportRebuildSliderState.autoPlayInterval); - beatportRebuildSliderState.autoPlayInterval = null; - } -} - -// =================================== -// BEATPORT NEW RELEASES SLIDER -// =================================== - -// State management for new releases slider (copied from hero slider) -let beatportReleasesSliderState = { - currentSlide: 0, - totalSlides: 0, - autoPlayInterval: null, - autoPlayDelay: 8000, - isInitialized: false -}; - -/** - * Initialize the beatport new releases slider functionality (based on hero slider) - */ -function initializeBeatportReleasesSlider() { - console.log('🆕 Initializing beatport new releases slider...'); - - const slider = document.getElementById('beatport-releases-slider'); - if (!slider) { - console.warn('Beatport releases slider not found'); - return; - } - - // Prevent double initialization - if (slider.dataset.initialized === 'true') { - console.log('Releases slider already initialized'); - return; - } - - const sliderTrack = document.getElementById('beatport-releases-slider-track'); - const indicatorsContainer = document.getElementById('beatport-releases-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) { - console.warn('Releases slider elements not found'); - return; - } - - // Load data and initialize - loadBeatportNewReleases().then(success => { - if (success) { - setupBeatportReleasesSliderNavigation(); - setupBeatportReleasesSliderIndicators(); - setupBeatportReleasesSliderHoverPause(); - startBeatportReleasesSliderAutoPlay(); - slider.dataset.initialized = 'true'; - beatportReleasesSliderState.isInitialized = true; - console.log('✅ New releases slider initialized successfully'); - } - }); -} - -/** - * Load new releases data from API - */ -async function loadBeatportNewReleases() { - try { - console.log('📡 Fetching new releases data...'); - - const response = await fetch('/api/beatport/new-releases'); - const data = await response.json(); - - if (data.success && data.releases && data.releases.length > 0) { - console.log(`📀 Loaded ${data.releases.length} releases`); - populateBeatportReleasesSlider(data.releases); - return true; - } else { - console.error('Failed to load releases:', data.error || 'No releases found'); - showBeatportReleasesError(data.error || 'No releases available'); - return false; - } - } catch (error) { - console.error('Error loading new releases:', error); - showBeatportReleasesError('Failed to load releases'); - return false; - } -} - -/** - * Populate the releases slider with data (based on hero slider) - */ -function populateBeatportReleasesSlider(releases) { - const sliderTrack = document.getElementById('beatport-releases-slider-track'); - const indicatorsContainer = document.getElementById('beatport-releases-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) return; - - // Calculate slides needed (10 cards per slide) - const cardsPerSlide = 10; - const totalSlides = Math.ceil(releases.length / cardsPerSlide); - - // Clear existing content - sliderTrack.innerHTML = ''; - indicatorsContainer.innerHTML = ''; - - // Update state - beatportReleasesSliderState.totalSlides = totalSlides; - beatportReleasesSliderState.currentSlide = 0; - - console.log(`🎯 Creating ${totalSlides} slides with ${cardsPerSlide} cards each`); - - // Generate slides HTML (similar to hero slider) - for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) { - const startIndex = slideIndex * cardsPerSlide; - const endIndex = Math.min(startIndex + cardsPerSlide, releases.length); - const slideReleases = releases.slice(startIndex, endIndex); - - // Create grid HTML for this slide - let gridHtml = ''; - for (let i = 0; i < cardsPerSlide; i++) { - if (i < slideReleases.length) { - const release = slideReleases[i]; - gridHtml += ` -
-
-
- ${release.image_url ? `${release.title}` : ''} -
-
-
${release.title}
-
${release.artist}
-
${release.label}
-
-
-
- `; - } else { - // Placeholder card - gridHtml += ` -
-
-
-
📀
-
-
-
More Releases
-
Coming Soon
-
Beatport
-
-
-
- `; - } - } - - const slideHtml = ` -
-
- ${gridHtml} -
-
- `; - - sliderTrack.innerHTML += slideHtml; - - // Create indicator - const indicatorHtml = ``; - indicatorsContainer.innerHTML += indicatorHtml; - } - - console.log(`✅ Created ${totalSlides} slides for releases slider`); - - // Add click handlers for individual release discovery (matching Top 10 Releases pattern) - const releaseCards = sliderTrack.querySelectorAll('.beatport-release-card[data-url]:not(.beatport-release-placeholder)'); - releaseCards.forEach((card) => { - const releaseUrl = card.getAttribute('data-url'); - if (releaseUrl && releaseUrl !== '#') { - // Find the corresponding release data - const releaseData = releases.find(release => release.url === releaseUrl); - if (releaseData) { - card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releaseData)); - card.style.cursor = 'pointer'; - } - } - }); -} - -/** - * Set up navigation functionality (copied from hero slider) - */ -function setupBeatportReleasesSliderNavigation() { - const prevBtn = document.getElementById('beatport-releases-prev-btn'); - const nextBtn = document.getElementById('beatport-releases-next-btn'); - - if (prevBtn) { - // Clone button to remove all existing event listeners - const newPrevBtn = prevBtn.cloneNode(true); - prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); - - newPrevBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Previous releases button clicked, current slide:', beatportReleasesSliderState.currentSlide); - goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide - 1); - resetBeatportReleasesSliderAutoPlay(); - }); - } - - if (nextBtn) { - // Clone button to remove all existing event listeners - const newNextBtn = nextBtn.cloneNode(true); - nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); - - newNextBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Next releases button clicked, current slide:', beatportReleasesSliderState.currentSlide); - goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide + 1); - resetBeatportReleasesSliderAutoPlay(); - }); - } -} - -/** - * Set up indicator functionality (copied from hero slider) - */ -function setupBeatportReleasesSliderIndicators() { - const indicators = document.querySelectorAll('.beatport-releases-indicator'); - - indicators.forEach((indicator, index) => { - indicator.addEventListener('click', () => { - goToBeatportReleasesSlide(index); - resetBeatportReleasesSliderAutoPlay(); - }); - }); -} - -/** - * Navigate to a specific slide (copied from hero slider) - */ -function goToBeatportReleasesSlide(slideIndex) { - console.log('goToBeatportReleasesSlide called with:', slideIndex, 'current:', beatportReleasesSliderState.currentSlide); - - // Wrap around if out of bounds - if (slideIndex < 0) { - slideIndex = beatportReleasesSliderState.totalSlides - 1; - } else if (slideIndex >= beatportReleasesSliderState.totalSlides) { - slideIndex = 0; - } - - console.log('After wrapping, slideIndex:', slideIndex); - - // Update current slide - beatportReleasesSliderState.currentSlide = slideIndex; - - // Update slide visibility - const slides = document.querySelectorAll('.beatport-releases-slide'); - slides.forEach((slide, index) => { - slide.classList.remove('active', 'prev', 'next'); - - if (index === slideIndex) { - slide.classList.add('active'); - } else if (index < slideIndex) { - slide.classList.add('prev'); - } else { - slide.classList.add('next'); - } - }); - - // Update indicators - const indicators = document.querySelectorAll('.beatport-releases-indicator'); - indicators.forEach((indicator, index) => { - indicator.classList.toggle('active', index === slideIndex); - }); - - console.log('Releases slide updated to:', beatportReleasesSliderState.currentSlide); -} - -/** - * Start auto-play functionality (copied from hero slider) - */ -function startBeatportReleasesSliderAutoPlay() { - if (beatportReleasesSliderState.autoPlayInterval) { - clearInterval(beatportReleasesSliderState.autoPlayInterval); - } - - beatportReleasesSliderState.autoPlayInterval = setInterval(() => { - goToBeatportReleasesSlide(beatportReleasesSliderState.currentSlide + 1); - }, beatportReleasesSliderState.autoPlayDelay); -} - -/** - * Reset auto-play timer (copied from hero slider) - */ -function resetBeatportReleasesSliderAutoPlay() { - startBeatportReleasesSliderAutoPlay(); -} - -/** - * Set up hover pause functionality (copied from hero slider) - */ -function setupBeatportReleasesSliderHoverPause() { - const sliderContainer = document.querySelector('.beatport-releases-slider-container'); - - if (sliderContainer) { - sliderContainer.addEventListener('mouseenter', () => { - if (beatportReleasesSliderState.autoPlayInterval) { - clearInterval(beatportReleasesSliderState.autoPlayInterval); - beatportReleasesSliderState.autoPlayInterval = null; - } - }); - - sliderContainer.addEventListener('mouseleave', () => { - startBeatportReleasesSliderAutoPlay(); - }); - } -} - -/** - * Show error state - */ -function showBeatportReleasesError(errorMessage) { - const sliderTrack = document.getElementById('beatport-releases-slider-track'); - if (!sliderTrack) return; - - sliderTrack.innerHTML = ` -
-
-

❌ Error Loading Releases

-

${errorMessage}

-
-
- `; -} - -/** - * Clean up releases slider when switching away (copied from hero slider) - */ -function cleanupBeatportReleasesSlider() { - if (beatportReleasesSliderState.autoPlayInterval) { - clearInterval(beatportReleasesSliderState.autoPlayInterval); - beatportReleasesSliderState.autoPlayInterval = null; - } -} - -// =================================== -// BEATPORT HYPE PICKS SLIDER -// =================================== - -// Hype Picks Slider State -let beatportHypePicksSliderState = { - currentSlide: 0, - totalSlides: 0, - autoPlayInterval: null, - autoPlayDelay: 4000, - isInitialized: false -}; - -/** - * Initialize the beatport hype picks slider functionality (based on releases slider) - */ -function initializeBeatportHypePicksSlider() { - console.log('🔥 Initializing beatport hype picks slider...'); - - const slider = document.getElementById('beatport-hype-picks-slider'); - if (!slider) { - console.warn('Beatport hype picks slider not found'); - return; - } - - // Check if already initialized - if (beatportHypePicksSliderState.isInitialized) { - console.log('Beatport hype picks slider already initialized, skipping...'); - startBeatportHypePicksSliderAutoPlay(); // Just restart autoplay - return; - } - - // Mark as initialized - beatportHypePicksSliderState.isInitialized = true; - - // Reset state - beatportHypePicksSliderState.currentSlide = 0; - beatportHypePicksSliderState.totalSlides = 0; - - // Load data and initialize - loadBeatportHypePicks().then(success => { - if (success) { - setupBeatportHypePicksSliderNavigation(); - setupBeatportHypePicksSliderIndicators(); - setupBeatportHypePicksSliderHoverPause(); - startBeatportHypePicksSliderAutoPlay(); - } - }); - - console.log('✅ Beatport hype picks slider initialized successfully'); -} - -/** - * Load hype picks data from API - */ -async function loadBeatportHypePicks() { - try { - console.log('🔥 Fetching hype picks data...'); - - const response = await fetch('/api/beatport/hype-picks'); - const data = await response.json(); - - if (data.success && data.releases && data.releases.length > 0) { - console.log(`🔥 Loaded ${data.releases.length} hype picks releases`); - populateBeatportHypePicksSlider(data.releases); - return true; - } else { - console.error('Failed to load hype picks:', data.error || 'No hype picks found'); - showBeatportHypePicksError(data.error || 'No hype picks available'); - return false; - } - } catch (error) { - console.error('Error loading hype picks:', error); - showBeatportHypePicksError('Failed to load hype picks'); - return false; - } -} - -/** - * Populate the hype picks slider with data (based on releases slider) - */ -function populateBeatportHypePicksSlider(releases) { - const sliderTrack = document.getElementById('beatport-hype-picks-slider-track'); - const indicatorsContainer = document.getElementById('beatport-hype-picks-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) return; - - // Clear existing content - sliderTrack.innerHTML = ''; - indicatorsContainer.innerHTML = ''; - - // Group releases into slides (10 releases per slide in 5x2 grid) - const releasesPerSlide = 10; - const slides = []; - for (let i = 0; i < releases.length; i += releasesPerSlide) { - slides.push(releases.slice(i, i + releasesPerSlide)); - } - - console.log(`🔥 Hype Picks: Got ${releases.length} releases, creating ${slides.length} slides`); - beatportHypePicksSliderState.totalSlides = slides.length; - beatportHypePicksSliderState.currentSlide = 0; - - // Create slides - slides.forEach((slideReleases, slideIndex) => { - const slideHtml = ` -
-
- ${slideReleases.map(release => createBeatportHypePickCard(release)).join('')} - ${slideReleases.length < releasesPerSlide ? - Array(releasesPerSlide - slideReleases.length).fill(0).map(() => - `
-
🔥
-
` - ).join('') : '' - } -
-
- `; - sliderTrack.insertAdjacentHTML('beforeend', slideHtml); - console.log(`🔥 Created slide ${slideIndex + 1}/${slides.length} with ${slideReleases.length} releases`); - - // Create indicator - const indicatorHtml = ``; - indicatorsContainer.insertAdjacentHTML('beforeend', indicatorHtml); - }); - - // Add click handlers to track cards - setupBeatportHypePickCardHandlers(); -} - -/** - * Create a hype pick card HTML (for release cards, same as new releases) - */ -function createBeatportHypePickCard(release) { - const artworkUrl = release.image_url || ''; - const bgStyle = artworkUrl ? `style="--card-bg-image: url('${artworkUrl}')"` : ''; - - return ` -
-
-
- ${artworkUrl ? `${release.title || 'Release'}` : ''} -
-
-
${release.title || 'Unknown Title'}
-
${release.artist || 'Unknown Artist'}
-
${release.label || 'Hype Pick'}
-
-
-
- `; -} - -/** - * Setup navigation for hype picks slider (same pattern as releases) - */ -function setupBeatportHypePicksSliderNavigation() { - const prevBtn = document.getElementById('beatport-hype-picks-prev-btn'); - const nextBtn = document.getElementById('beatport-hype-picks-next-btn'); - - if (prevBtn) { - // Clone button to remove all existing event listeners - const newPrevBtn = prevBtn.cloneNode(true); - prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); - - newPrevBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Previous hype picks button clicked, current slide:', beatportHypePicksSliderState.currentSlide); - goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide - 1); - resetBeatportHypePicksSliderAutoPlay(); - }); - } - - if (nextBtn) { - // Clone button to remove all existing event listeners - const newNextBtn = nextBtn.cloneNode(true); - nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); - - newNextBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Next hype picks button clicked, current slide:', beatportHypePicksSliderState.currentSlide); - goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide + 1); - resetBeatportHypePicksSliderAutoPlay(); - }); - } -} - -/** - * Setup indicators for hype picks slider - */ -function setupBeatportHypePicksSliderIndicators() { - const indicators = document.querySelectorAll('.beatport-hype-picks-indicator'); - - indicators.forEach((indicator, index) => { - indicator.addEventListener('click', () => { - goToBeatportHypePicksSlide(index); - resetBeatportHypePicksSliderAutoPlay(); - }); - }); -} - -/** - * Navigate to specific slide - */ -function goToBeatportHypePicksSlide(slideIndex) { - console.log('goToBeatportHypePicksSlide called with:', slideIndex, 'current:', beatportHypePicksSliderState.currentSlide); - - // Handle wrap around - if (slideIndex < 0) { - slideIndex = beatportHypePicksSliderState.totalSlides - 1; - } else if (slideIndex >= beatportHypePicksSliderState.totalSlides) { - slideIndex = 0; - } - - // Update current slide - beatportHypePicksSliderState.currentSlide = slideIndex; - - // Update slides - const slides = document.querySelectorAll('.beatport-hype-picks-slide'); - slides.forEach((slide, index) => { - slide.classList.remove('active', 'prev', 'next'); - if (index === slideIndex) { - slide.classList.add('active'); - } else if (index < slideIndex) { - slide.classList.add('prev'); - } else { - slide.classList.add('next'); - } - }); - - // Update indicators - const indicators = document.querySelectorAll('.beatport-hype-picks-indicator'); - indicators.forEach((indicator, index) => { - indicator.classList.toggle('active', index === slideIndex); - }); - - console.log('Slide updated to:', beatportHypePicksSliderState.currentSlide); -} - -/** - * Start auto-play for hype picks slider - */ -function startBeatportHypePicksSliderAutoPlay() { - if (beatportHypePicksSliderState.autoPlayInterval) { - clearInterval(beatportHypePicksSliderState.autoPlayInterval); - } - - beatportHypePicksSliderState.autoPlayInterval = setInterval(() => { - goToBeatportHypePicksSlide(beatportHypePicksSliderState.currentSlide + 1); - }, beatportHypePicksSliderState.autoPlayDelay); - - console.log('🔥 Hype picks slider autoplay started'); -} - -/** - * Reset auto-play for hype picks slider - */ -function resetBeatportHypePicksSliderAutoPlay() { - startBeatportHypePicksSliderAutoPlay(); -} - -/** - * Setup hover pause for hype picks slider - */ -function setupBeatportHypePicksSliderHoverPause() { - const sliderContainer = document.querySelector('.beatport-hype-picks-slider-container'); - if (sliderContainer) { - sliderContainer.addEventListener('mouseenter', () => { - if (beatportHypePicksSliderState.autoPlayInterval) { - clearInterval(beatportHypePicksSliderState.autoPlayInterval); - } - }); - - sliderContainer.addEventListener('mouseleave', () => { - startBeatportHypePicksSliderAutoPlay(); - }); - } -} - -/** - * Setup click handlers for hype pick cards - */ -function setupBeatportHypePickCardHandlers() { - const cards = document.querySelectorAll('.beatport-hype-pick-card:not(.beatport-hype-pick-placeholder)'); - - cards.forEach(card => { - const releaseUrl = card.getAttribute('data-url'); - if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') { - // Extract release data from the card elements - const titleElement = card.querySelector('.beatport-hype-pick-title'); - const artistElement = card.querySelector('.beatport-hype-pick-artist'); - const labelElement = card.querySelector('.beatport-hype-pick-label'); - const imageElement = card.querySelector('.beatport-hype-pick-artwork img'); - - const releaseData = { - url: releaseUrl, - title: titleElement ? titleElement.textContent.trim() : 'Unknown Title', - artist: artistElement ? artistElement.textContent.trim() : 'Unknown Artist', - label: labelElement ? labelElement.textContent.trim() : 'Unknown Label', - image_url: imageElement ? imageElement.src : '' - }; - - card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releaseData)); - card.style.cursor = 'pointer'; - } - }); -} - -/** - * Show error state for hype picks slider - */ -function showBeatportHypePicksError(errorMessage) { - const sliderTrack = document.getElementById('beatport-hype-picks-slider-track'); - if (sliderTrack) { - sliderTrack.innerHTML = ` -
-
-

❌ Error Loading Hype Picks

-

${errorMessage}

-
-
- `; - } -} - -/** - * Clean up hype picks slider when switching away - */ -function cleanupBeatportHypePicksSlider() { - if (beatportHypePicksSliderState.autoPlayInterval) { - clearInterval(beatportHypePicksSliderState.autoPlayInterval); - beatportHypePicksSliderState.autoPlayInterval = null; - } -} - -// =================================== -// BEATPORT FEATURED CHARTS SLIDER -// =================================== - -// State management for featured charts slider (copied from releases slider) -let beatportChartsSliderState = { - currentSlide: 0, - totalSlides: 0, - autoPlayInterval: null, - autoPlayDelay: 10000, // Slightly longer auto-play for charts - isInitialized: false -}; - -/** - * Initialize the beatport featured charts slider functionality (based on releases slider) - */ -function initializeBeatportChartsSlider() { - console.log('🔥 Initializing beatport featured charts slider...'); - - const slider = document.getElementById('beatport-charts-slider'); - if (!slider) { - console.warn('Beatport charts slider not found'); - return; - } - - // Prevent double initialization - if (slider.dataset.initialized === 'true') { - console.log('Charts slider already initialized'); - return; - } - - const sliderTrack = document.getElementById('beatport-charts-slider-track'); - const indicatorsContainer = document.getElementById('beatport-charts-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) { - console.warn('Charts slider elements not found'); - return; - } - - // Load data and initialize - loadBeatportFeaturedCharts().then(success => { - if (success) { - setupBeatportChartsSliderNavigation(); - setupBeatportChartsSliderIndicators(); - setupBeatportChartsSliderHoverPause(); - startBeatportChartsSliderAutoPlay(); - slider.dataset.initialized = 'true'; - beatportChartsSliderState.isInitialized = true; - console.log('✅ Featured charts slider initialized successfully'); - } - }); -} - -/** - * Load featured charts data from API - */ -async function loadBeatportFeaturedCharts() { - try { - console.log('📊 Loading featured charts data...'); - const response = await fetch('/api/beatport/featured-charts'); - const data = await response.json(); - - if (data.success && data.charts && data.charts.length > 0) { - console.log(`📈 Loaded ${data.charts.length} featured charts`); - createBeatportChartsSlides(data.charts); - return true; - } else { - console.warn('No featured charts data available'); - return false; - } - } catch (error) { - console.error('❌ Error loading featured charts:', error); - return false; - } -} - -/** - * Create chart slides with grid layout (copied from releases slider) - */ -function createBeatportChartsSlides(charts) { - const sliderTrack = document.getElementById('beatport-charts-slider-track'); - const indicatorsContainer = document.getElementById('beatport-charts-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) { - console.error('Charts slider elements not found'); - return; - } - - const cardsPerSlide = 10; // 5x2 grid - const totalSlides = Math.ceil(charts.length / cardsPerSlide); - - // Clear existing content - sliderTrack.innerHTML = ''; - indicatorsContainer.innerHTML = ''; - - // Update state - beatportChartsSliderState.totalSlides = totalSlides; - beatportChartsSliderState.currentSlide = 0; - - console.log(`🎯 Creating ${totalSlides} chart slides with ${cardsPerSlide} cards each`); - - // Generate slides HTML - for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) { - const startIndex = slideIndex * cardsPerSlide; - const endIndex = Math.min(startIndex + cardsPerSlide, charts.length); - const slideCharts = charts.slice(startIndex, endIndex); - - // Create grid HTML for this slide - const gridHtml = slideCharts.map(chart => { - const bgImageStyle = chart.image ? `--chart-bg-image: url('${chart.image}')` : ''; - return ` -
-
-
${chart.name || 'Unknown Chart'}
-
${chart.creator || 'Unknown Creator'}
-
-
- `; - }).join(''); - - // Create slide HTML - const slideHtml = ` -
-
- ${gridHtml} -
-
- `; - - sliderTrack.innerHTML += slideHtml; - - // Create indicator - const indicatorHtml = ``; - indicatorsContainer.innerHTML += indicatorHtml; - } - - console.log(`✅ Created ${totalSlides} chart slides`); - - // Add click handlers for individual chart discovery (matching chart pattern) - const chartCards = sliderTrack.querySelectorAll('.beatport-chart-card[data-url]'); - chartCards.forEach((card) => { - const chartUrl = card.getAttribute('data-url'); - if (chartUrl && chartUrl !== '') { - // Find the corresponding chart data - const chartData = charts.find(chart => chart.url === chartUrl); - if (chartData) { - card.addEventListener('click', () => handleBeatportChartCardClick(card, chartData)); - card.style.cursor = 'pointer'; - } - } - }); -} - -/** - * Set up navigation functionality (copied from releases slider with button cloning) - */ -function setupBeatportChartsSliderNavigation() { - const prevBtn = document.getElementById('beatport-charts-prev-btn'); - const nextBtn = document.getElementById('beatport-charts-next-btn'); - - if (prevBtn) { - // Clone button to remove all existing event listeners - const newPrevBtn = prevBtn.cloneNode(true); - prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); - - newPrevBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Previous charts button clicked, current slide:', beatportChartsSliderState.currentSlide); - goToBeatportChartsSlide(beatportChartsSliderState.currentSlide - 1); - resetBeatportChartsSliderAutoPlay(); - }); - } - - if (nextBtn) { - // Clone button to remove all existing event listeners - const newNextBtn = nextBtn.cloneNode(true); - nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); - - newNextBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Next charts button clicked, current slide:', beatportChartsSliderState.currentSlide); - goToBeatportChartsSlide(beatportChartsSliderState.currentSlide + 1); - resetBeatportChartsSliderAutoPlay(); - }); - } -} - -/** - * Set up indicator functionality (copied from releases slider) - */ -function setupBeatportChartsSliderIndicators() { - const indicators = document.querySelectorAll('.beatport-charts-indicator'); - - indicators.forEach((indicator, index) => { - indicator.addEventListener('click', () => { - goToBeatportChartsSlide(index); - resetBeatportChartsSliderAutoPlay(); - }); - }); -} - -/** - * Navigate to a specific slide (copied from releases slider) - */ -function goToBeatportChartsSlide(slideIndex) { - console.log('goToBeatportChartsSlide called with:', slideIndex, 'current:', beatportChartsSliderState.currentSlide); - - // Wrap around if out of bounds - if (slideIndex < 0) { - slideIndex = beatportChartsSliderState.totalSlides - 1; - } else if (slideIndex >= beatportChartsSliderState.totalSlides) { - slideIndex = 0; - } - - console.log('After wrapping, slideIndex:', slideIndex); - - // Update current slide - beatportChartsSliderState.currentSlide = slideIndex; - - // Update slide visibility - const slides = document.querySelectorAll('.beatport-charts-slide'); - slides.forEach((slide, index) => { - slide.classList.remove('active', 'prev', 'next'); - - if (index === slideIndex) { - slide.classList.add('active'); - } else if (index < slideIndex) { - slide.classList.add('prev'); - } else { - slide.classList.add('next'); - } - }); - - // Update indicators - const indicators = document.querySelectorAll('.beatport-charts-indicator'); - indicators.forEach((indicator, index) => { - indicator.classList.toggle('active', index === slideIndex); - }); - - console.log('Charts slide updated to:', beatportChartsSliderState.currentSlide); -} - -/** - * Start auto-play functionality (copied from releases slider) - */ -function startBeatportChartsSliderAutoPlay() { - if (beatportChartsSliderState.autoPlayInterval) { - clearInterval(beatportChartsSliderState.autoPlayInterval); - } - - beatportChartsSliderState.autoPlayInterval = setInterval(() => { - goToBeatportChartsSlide(beatportChartsSliderState.currentSlide + 1); - }, beatportChartsSliderState.autoPlayDelay); -} - -/** - * Reset auto-play timer (copied from releases slider) - */ -function resetBeatportChartsSliderAutoPlay() { - startBeatportChartsSliderAutoPlay(); -} - -/** - * Set up hover pause functionality (copied from releases slider) - */ -function setupBeatportChartsSliderHoverPause() { - const sliderContainer = document.querySelector('.beatport-charts-slider-container'); - - if (sliderContainer) { - sliderContainer.addEventListener('mouseenter', () => { - if (beatportChartsSliderState.autoPlayInterval) { - clearInterval(beatportChartsSliderState.autoPlayInterval); - beatportChartsSliderState.autoPlayInterval = null; - } - }); - - sliderContainer.addEventListener('mouseleave', () => { - startBeatportChartsSliderAutoPlay(); - }); - } -} - -/** - * Clean up charts slider when switching away (copied from releases slider) - */ -function cleanupBeatportChartsSlider() { - if (beatportChartsSliderState.autoPlayInterval) { - clearInterval(beatportChartsSliderState.autoPlayInterval); - beatportChartsSliderState.autoPlayInterval = null; - } -} - -// =================================== -// BEATPORT DJ CHARTS SLIDER -// =================================== - -// State management for DJ charts slider (3 cards per slide) -let beatportDJSliderState = { - currentSlide: 0, - totalSlides: 0, - autoPlayInterval: null, - autoPlayDelay: 12000, // Longer auto-play for DJ charts - isInitialized: false -}; - -/** - * Initialize the beatport DJ charts slider functionality (based on charts slider) - */ -function initializeBeatportDJSlider() { - console.log('🎧 Initializing beatport DJ charts slider...'); - - const slider = document.getElementById('beatport-dj-slider'); - if (!slider) { - console.warn('Beatport DJ slider not found'); - return; - } - - // Prevent double initialization - if (slider.dataset.initialized === 'true') { - console.log('DJ slider already initialized'); - return; - } - - const sliderTrack = document.getElementById('beatport-dj-slider-track'); - const indicatorsContainer = document.getElementById('beatport-dj-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) { - console.warn('DJ slider elements not found'); - return; - } - - // Load data and initialize - loadBeatportDJCharts().then(success => { - if (success) { - setupBeatportDJSliderNavigation(); - setupBeatportDJSliderIndicators(); - setupBeatportDJSliderHoverPause(); - startBeatportDJSliderAutoPlay(); - slider.dataset.initialized = 'true'; - beatportDJSliderState.isInitialized = true; - console.log('✅ DJ charts slider initialized successfully'); - } - }); -} - -/** - * Load DJ charts data from API - */ -async function loadBeatportDJCharts() { - try { - console.log('🎧 Loading DJ charts data...'); - const response = await fetch('/api/beatport/dj-charts'); - const data = await response.json(); - - if (data.success && data.charts && data.charts.length > 0) { - console.log(`📈 Loaded ${data.charts.length} DJ charts`); - createBeatportDJSlides(data.charts); - return true; - } else { - console.warn('No DJ charts data available'); - return false; - } - } catch (error) { - console.error('❌ Error loading DJ charts:', error); - return false; - } -} - -/** - * Create DJ chart slides with 3 cards per slide layout - */ -function createBeatportDJSlides(charts) { - const sliderTrack = document.getElementById('beatport-dj-slider-track'); - const indicatorsContainer = document.getElementById('beatport-dj-slider-indicators'); - - if (!sliderTrack || !indicatorsContainer) { - console.error('DJ slider elements not found'); - return; - } - - const cardsPerSlide = 3; // 3 cards per slide for DJ charts - const totalSlides = Math.ceil(charts.length / cardsPerSlide); - - // Clear existing content - sliderTrack.innerHTML = ''; - indicatorsContainer.innerHTML = ''; - - // Update state - beatportDJSliderState.totalSlides = totalSlides; - beatportDJSliderState.currentSlide = 0; - - console.log(`🎯 Creating ${totalSlides} DJ chart slides with ${cardsPerSlide} cards each`); - - // Generate slides HTML - for (let slideIndex = 0; slideIndex < totalSlides; slideIndex++) { - const startIndex = slideIndex * cardsPerSlide; - const endIndex = Math.min(startIndex + cardsPerSlide, charts.length); - const slideCharts = charts.slice(startIndex, endIndex); - - // Create grid HTML for this slide - const gridHtml = slideCharts.map(chart => { - const bgImageStyle = chart.image ? `--dj-bg-image: url('${chart.image}')` : ''; - return ` -
-
-
${chart.name || 'Unknown Chart'}
-
${chart.creator || 'Unknown Creator'}
-
-
- `; - }).join(''); - - // Create slide HTML - const slideHtml = ` -
-
- ${gridHtml} -
-
- `; - - sliderTrack.innerHTML += slideHtml; - - // Create indicator - const indicatorHtml = ``; - indicatorsContainer.innerHTML += indicatorHtml; - } - - console.log(`✅ Created ${totalSlides} DJ chart slides`); - - // Add click handlers for individual DJ chart discovery (matching chart pattern) - const djChartCards = sliderTrack.querySelectorAll('.beatport-dj-card[data-url]'); - djChartCards.forEach((card) => { - const chartUrl = card.getAttribute('data-url'); - if (chartUrl && chartUrl !== '') { - // Find the corresponding chart data - const chartData = charts.find(chart => chart.url === chartUrl); - if (chartData) { - card.addEventListener('click', () => handleBeatportDJChartCardClick(card, chartData)); - card.style.cursor = 'pointer'; - } - } - }); -} - -/** - * Set up navigation functionality (copied from charts slider with button cloning) - */ -function setupBeatportDJSliderNavigation() { - const prevBtn = document.getElementById('beatport-dj-prev-btn'); - const nextBtn = document.getElementById('beatport-dj-next-btn'); - - if (prevBtn) { - // Clone button to remove all existing event listeners - const newPrevBtn = prevBtn.cloneNode(true); - prevBtn.parentNode.replaceChild(newPrevBtn, prevBtn); - - newPrevBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Previous DJ button clicked, current slide:', beatportDJSliderState.currentSlide); - goToBeatportDJSlide(beatportDJSliderState.currentSlide - 1); - resetBeatportDJSliderAutoPlay(); - }); - } - - if (nextBtn) { - // Clone button to remove all existing event listeners - const newNextBtn = nextBtn.cloneNode(true); - nextBtn.parentNode.replaceChild(newNextBtn, nextBtn); - - newNextBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - console.log('Next DJ button clicked, current slide:', beatportDJSliderState.currentSlide); - goToBeatportDJSlide(beatportDJSliderState.currentSlide + 1); - resetBeatportDJSliderAutoPlay(); - }); - } -} - -/** - * Set up indicator functionality (copied from charts slider) - */ -function setupBeatportDJSliderIndicators() { - const indicators = document.querySelectorAll('.beatport-dj-indicator'); - - indicators.forEach((indicator, index) => { - indicator.addEventListener('click', () => { - goToBeatportDJSlide(index); - resetBeatportDJSliderAutoPlay(); - }); - }); -} - -/** - * Navigate to a specific slide (copied from charts slider) - */ -function goToBeatportDJSlide(slideIndex) { - console.log('goToBeatportDJSlide called with:', slideIndex, 'current:', beatportDJSliderState.currentSlide); - - // Wrap around if out of bounds - if (slideIndex < 0) { - slideIndex = beatportDJSliderState.totalSlides - 1; - } else if (slideIndex >= beatportDJSliderState.totalSlides) { - slideIndex = 0; - } - - console.log('After wrapping, slideIndex:', slideIndex); - - // Update current slide - beatportDJSliderState.currentSlide = slideIndex; - - // Update slide visibility - const slides = document.querySelectorAll('.beatport-dj-slide'); - slides.forEach((slide, index) => { - slide.classList.remove('active', 'prev', 'next'); - - if (index === slideIndex) { - slide.classList.add('active'); - } else if (index < slideIndex) { - slide.classList.add('prev'); - } else { - slide.classList.add('next'); - } - }); - - // Update indicators - const indicators = document.querySelectorAll('.beatport-dj-indicator'); - indicators.forEach((indicator, index) => { - indicator.classList.toggle('active', index === slideIndex); - }); - - console.log('DJ slide updated to:', beatportDJSliderState.currentSlide); -} - -/** - * Start auto-play functionality (copied from charts slider) - */ -function startBeatportDJSliderAutoPlay() { - if (beatportDJSliderState.autoPlayInterval) { - clearInterval(beatportDJSliderState.autoPlayInterval); - } - - beatportDJSliderState.autoPlayInterval = setInterval(() => { - goToBeatportDJSlide(beatportDJSliderState.currentSlide + 1); - }, beatportDJSliderState.autoPlayDelay); -} - -/** - * Reset auto-play timer (copied from charts slider) - */ -function resetBeatportDJSliderAutoPlay() { - startBeatportDJSliderAutoPlay(); -} - -/** - * Set up hover pause functionality (copied from charts slider) - */ -function setupBeatportDJSliderHoverPause() { - const sliderContainer = document.querySelector('.beatport-dj-slider-container'); - - if (sliderContainer) { - sliderContainer.addEventListener('mouseenter', () => { - if (beatportDJSliderState.autoPlayInterval) { - clearInterval(beatportDJSliderState.autoPlayInterval); - beatportDJSliderState.autoPlayInterval = null; - } - }); - - sliderContainer.addEventListener('mouseleave', () => { - startBeatportDJSliderAutoPlay(); - }); - } -} - -/** - * Clean up DJ slider when switching away (copied from charts slider) - */ -function cleanupBeatportDJSlider() { - if (beatportDJSliderState.autoPlayInterval) { - clearInterval(beatportDJSliderState.autoPlayInterval); - beatportDJSliderState.autoPlayInterval = null; - } -} - -/** - * Load top 10 lists data from API and populate both lists - */ -async function loadBeatportTop10Lists() { - try { - console.log('🏆 Loading top 10 lists data...'); - const response = await fetch('/api/beatport/homepage/top-10-lists'); - const data = await response.json(); - - if (data.success) { - console.log(`🎵 Loaded ${data.beatport_count} Beatport Top 10 + ${data.hype_count} Hype Top 10 tracks`); - - // Populate both lists - populateBeatportTop10List(data.beatport_top10); - populateHypeTop10List(data.hype_top10); - return true; - } else { - console.error('Failed to load top 10 lists:', data.error); - showTop10ListsError(data.error || 'No data available'); - return false; - } - } catch (error) { - console.error('Error loading top 10 lists:', error); - showTop10ListsError('Failed to load top 10 lists'); - return false; - } -} - -/** - * Clean track/artist text for proper spacing - */ -function cleanTrackText(text) { - if (!text) return text; - - // Fix common spacing issues - text = text.replace(/([a-z$!@#%&*])([A-Z])/g, '$1 $2'); // Add space between lowercase/symbols and uppercase - text = text.replace(/([a-zA-Z]),([a-zA-Z])/g, '$1, $2'); // Add space after comma - text = text.replace(/([a-zA-Z])(Mix|Remix|Extended|Version)\b/g, '$1 $2'); // Fix mix types - text = text.replace(/\s+/g, ' '); // Collapse multiple spaces - text = text.trim(); - - return text; -} - -/** - * Populate Beatport Top 10 list with data - */ -function populateBeatportTop10List(tracks) { - const container = document.getElementById('beatport-top10-list'); - if (!container || !tracks || tracks.length === 0) return; - - // Generate HTML for the tracks - let tracksHtml = ` -
-

🎵 Beatport Top 10

-

Most popular tracks on Beatport

-
-
- `; - - tracks.forEach((track, index) => { - // Clean the text data before injection - const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); - const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); - const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); - - tracksHtml += ` -
-
${track.rank || index + 1}
-
- ${track.artwork_url ? - `${cleanTitle}` : - '
🎵
' - } -
-
-

${cleanTitle}

-

${cleanArtist}

-

${cleanLabel}

-
-
- `; - }); - - tracksHtml += '
'; - container.innerHTML = tracksHtml; -} - -/** - * Populate Hype Top 10 list with data - */ -function populateHypeTop10List(tracks) { - const container = document.getElementById('beatport-hype10-list'); - if (!container || !tracks || tracks.length === 0) return; - - // Generate HTML for the tracks - let tracksHtml = ` -
-

🔥 Hype Top 10

-

Editor's trending picks

-
-
- `; - - tracks.forEach((track, index) => { - // Clean the text data before injection - const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); - const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); - const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); - - tracksHtml += ` -
-
${track.rank || index + 1}
-
- ${track.artwork_url ? - `${cleanTitle}` : - '
🔥
' - } -
-
-

${cleanTitle}

-

${cleanArtist}

-

${cleanLabel}

-
-
- `; - }); - - tracksHtml += '
'; - container.innerHTML = tracksHtml; -} - -/** - * Show error message for top 10 lists - */ -function showTop10ListsError(errorMessage) { - const beatportContainer = document.getElementById('beatport-top10-list'); - const hypeContainer = document.getElementById('beatport-hype10-list'); - - const errorHtml = ` -
-

❌ Error Loading Data

-

${errorMessage}

-
- `; - - if (beatportContainer) beatportContainer.innerHTML = errorHtml; - if (hypeContainer) hypeContainer.innerHTML = errorHtml; -} - -/** - * Load top 10 releases data from API and populate the list - */ -async function loadBeatportTop10Releases() { - try { - console.log('💿 Loading top 10 releases data...'); - const response = await fetch('/api/beatport/homepage/top-10-releases-cards'); - const data = await response.json(); - - if (data.success) { - console.log(`💿 Loaded ${data.releases_count} Top 10 Releases`); - populateBeatportTop10Releases(data.releases); - return true; - } else { - console.error('Failed to load top 10 releases:', data.error); - showTop10ReleasesError(data.error || 'No data available'); - return false; - } - } catch (error) { - console.error('Error loading top 10 releases:', error); - showTop10ReleasesError('Failed to load top 10 releases'); - return false; - } -} - -/** - * Populate Top 10 Releases list with data - */ -function populateBeatportTop10Releases(releases) { - const container = document.getElementById('beatport-releases-top10-list'); - if (!container || !releases || releases.length === 0) return; - - // Generate HTML for the releases - let releasesHtml = ` -
- `; - - releases.forEach((release, index) => { - releasesHtml += ` -
-
${release.rank || index + 1}
-
- ${release.image_url ? - `${release.title}` : - '
💿
' - } -
-
-

${release.title || 'Unknown Title'}

-

${release.artist || 'Unknown Artist'}

-

${release.label || 'Unknown Label'}

-
-
- `; - }); - - releasesHtml += '
'; - container.innerHTML = releasesHtml; - - // Set background images for cards - const cards = container.querySelectorAll('.beatport-releases-top10-card[data-bg-image]'); - cards.forEach(card => { - const bgImage = card.getAttribute('data-bg-image'); - if (bgImage) { - // Transform image URL from 95x95 to 500x500 for higher quality background - const highResImage = bgImage.replace('/image_size/95x95/', '/image_size/500x500/'); - card.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.8)), url('${highResImage}')`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } - }); - - // Add click handlers for individual release discovery - const releaseCards = container.querySelectorAll('.beatport-releases-top10-card[data-url]'); - releaseCards.forEach((card, index) => { - card.addEventListener('click', () => handleBeatportReleaseCardClick(card, releases[index])); - card.style.cursor = 'pointer'; - }); -} - -/** - * Show error message for top 10 releases - */ -function showTop10ReleasesError(errorMessage) { - const container = document.getElementById('beatport-releases-top10-list'); - - const errorHtml = ` -
-

❌ Error Loading Releases

-

${errorMessage}

-
- `; - - if (container) container.innerHTML = errorHtml; -} - -/** - * Handle click on individual Top 10 Release card - create discovery process for single release - */ -async function handleBeatportReleaseCardClick(cardElement, release) { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - - console.log(`💿 Individual release card clicked: ${release.title} by ${release.artist}`); - - if (!release.url || release.url === '#') { - _beatportModalOpening = false; - showToast('No release URL available', 'error'); - return; - } - - try { - showToast(`Loading ${release.title}...`, 'info'); - showLoadingOverlay(`Getting tracks from ${release.title}...`); - - // Fetch structured release metadata for direct download modal - console.log(`🎵 Fetching release metadata: ${release.url}`); - const response = await fetch('/api/beatport/release-metadata', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ release_url: release.url }) - }); - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error(data.error || 'No tracks found in this release'); - } - - console.log(`✅ Got ${data.tracks.length} tracks from ${data.album.name}`); - - // Format artists as array of strings for compatibility with download modal - const formattedTracks = data.tracks.map(track => ({ - ...track, - artists: track.artists.map(a => typeof a === 'object' ? a.name : a) - })); - - const virtualPlaylistId = `beatport_release_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const playlistName = data.album.name; - - // Open download modal directly - same as clicking an album on the Artists page - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - playlistName, - formattedTracks, - data.album, - data.artist, - false - ); - - // Register Beatport download bubble for releases (albums, EPs, singles) - const releaseImage = (data.album.images && data.album.images.length > 0) ? data.album.images[0].url : (release.image_url || ''); - registerBeatportDownload(playlistName, releaseImage, virtualPlaylistId); - - hideLoadingOverlay(); - _beatportModalOpening = false; - console.log(`✅ Opened download modal for ${playlistName}`); - - } catch (error) { - console.error(`❌ Error handling release click for ${release.title}:`, error); - hideLoadingOverlay(); - _beatportModalOpening = false; - showToast(`Error loading ${release.title}: ${error.message}`, 'error'); - } -} - -/** - * Convert scraped Beatport tracks into download-modal-compatible format and open the modal. - * Used by all chart/playlist handlers (Top 100, Hype 100, Featured Charts, DJ Charts, genre charts). - * Charts open as compilations — each track is searched independently on Soulseek. - */ -// Guard against multiple rapid clicks opening duplicate modals -let _beatportModalOpening = false; - -/** - * Enrich tracks via a single batch request to the backend. - * Progress is reported via WebSocket (beatport:enrich_progress) and updates the loading overlay. - * Returns the enriched tracks array. - */ -async function _enrichTracksWithProgress(tracks, chartName) { - const enrichmentId = `enrich_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`; - - try { - const resp = await fetch('/api/beatport/enrich-tracks', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tracks, enrichment_id: enrichmentId }) - }); - const data = await resp.json(); - - // Synchronous path — all tracks were cached, results returned inline - if (data.success && data.tracks) { - return data.tracks; - } - - // Async path — poll for progress until done - if (data.success && data.async) { - while (true) { - await new Promise(r => setTimeout(r, 800)); - try { - const progressResp = await fetch(`/api/beatport/enrich-progress/${enrichmentId}?_=${Date.now()}`); - const progress = await progressResp.json(); - if (!progress.success) break; - - // Update loading overlay with live progress - const overlayText = document.querySelector('#loading-overlay .loading-message'); - if (overlayText) { - overlayText.textContent = `Fetching track metadata... (${progress.completed}/${progress.total}) ${progress.current_track || ''}`; - } - - if (progress.done) { - if (progress.tracks) { - return progress.tracks; - } - console.warn('⚠️ Async enrichment failed:', progress.error); - return tracks; - } - } catch (pollErr) { - console.warn('⚠️ Progress poll error:', pollErr); - } - } - } - - console.warn('⚠️ Enrichment failed, returning original tracks'); - return tracks; - } catch (e) { - console.warn('⚠️ Failed to enrich tracks:', e); - return tracks; - } -} - -function parseBeatportDuration(raw) { - if (!raw) return 0; - if (typeof raw === 'string' && raw.includes(':')) { - const parts = raw.split(':'); - return (parseInt(parts[0], 10) * 60 + parseInt(parts[1], 10)) * 1000 || 0; - } - return (parseInt(raw, 10) || 0) * 1000; -} - -function openBeatportChartAsDownloadModal(tracks, chartName, chartImage) { - // Note: callers already guard against double-clicks via _beatportModalOpening. - // Reset the flag here so the modal can open even after fast (cached) enrichment. - _beatportModalOpening = false; - - const albumObj = { - id: `beatport_chart_${Date.now()}`, - name: chartName, - album_type: 'compilation', - images: chartImage ? [{ url: chartImage }] : [], - total_tracks: tracks.length - }; - - const formattedTracks = tracks.map((track, index) => { - // Use per-track release metadata if available (from JSON extraction) - const hasRelease = track.release_name && track.release_name.length > 0; - const trackAlbum = hasRelease ? { - id: `beatport_release_${track.release_id || index}`, - name: cleanTrackText(track.release_name), - album_type: 'single', - images: track.release_image ? [{ url: track.release_image }] : [], - release_date: track.release_date || '', - total_tracks: 1 - } : albumObj; - - // Combine title + mix_name - let trackName = cleanTrackText(track.title || 'Unknown Title'); - if (track.mix_name && track.mix_name.toLowerCase() !== 'original mix') { - trackName = `${trackName} (${cleanTrackText(track.mix_name)})`; - } - - // Split combined artist string into individual names for proper folder structure - const rawArtist = cleanTrackText(track.artist || 'Unknown Artist'); - const artistList = rawArtist.includes(',') - ? rawArtist.split(',').map(a => a.trim()).filter(a => a) - : [rawArtist]; - - return { - id: `beatport_chart_${index}`, - name: trackName, - artists: artistList, - duration_ms: parseBeatportDuration(track.duration), - track_number: index + 1, - disc_number: 1, - album: trackAlbum - }; - }); - - const virtualPlaylistId = `beatport_chart_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - // Compilation artist - const artistObj = { id: 'beatport_various', name: 'Various Artists' }; - - openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - chartName, - formattedTracks, - albumObj, - artistObj, - false, - 'playlist' - ); - - // Register Beatport download bubble - registerBeatportDownload(chartName, chartImage, virtualPlaylistId); -} - -/** - * Handle click on individual chart card - open download modal directly - */ -async function handleBeatportChartCardClick(cardElement, chart) { - console.log(`📊 Individual chart card clicked: ${chart.name} by ${chart.creator}`); - - if (!chart.url || chart.url === '') { - showToast('No chart URL available', 'error'); - return; - } - - try { - const chartName = `${chart.name} - ${chart.creator}`; - showToast(`Loading ${chart.name}...`, 'info'); - showLoadingOverlay(`Scraping ${chart.name}...`); - - const response = await fetch('/api/beatport/chart/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chart_url: chart.url, - chart_name: `Featured Chart: ${chart.name}`, - limit: 100, - enrich: false - }) - }); - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in this chart'); - } - - console.log(`✅ Fetched ${data.tracks.length} raw tracks from ${chart.name}, enriching...`); - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, chart.image); - - } catch (error) { - console.error(`❌ Error handling chart click for ${chart.name}:`, error); - hideLoadingOverlay(); - showToast(`Error loading ${chart.name}: ${error.message}`, 'error'); - } -} - -/** - * Handle click on individual DJ chart card - open download modal directly - */ -async function handleBeatportDJChartCardClick(cardElement, chart) { - console.log(`🎧 Individual DJ chart card clicked: ${chart.name} by ${chart.creator}`); - - if (!chart.url || chart.url === '') { - showToast('No DJ chart URL available', 'error'); - return; - } - - try { - const chartName = `${chart.name} - ${chart.creator}`; - showToast(`Loading ${chart.name}...`, 'info'); - showLoadingOverlay(`Scraping ${chart.name}...`); - - const response = await fetch('/api/beatport/chart/extract', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chart_url: chart.url, - chart_name: `DJ Chart: ${chart.name}`, - limit: 100, - enrich: false - }) - }); - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in this DJ chart'); - } - - console.log(`✅ Fetched ${data.tracks.length} raw tracks from ${chart.name}, enriching...`); - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, chart.image); - - } catch (error) { - console.error(`❌ Error handling DJ chart click for ${chart.name}:`, error); - hideLoadingOverlay(); - showToast(`Error loading ${chart.name}: ${error.message}`, 'error'); - } -} - -/** - * Handle click on Beatport Top 100 button - open download modal directly - */ -async function handleBeatportTop100Click() { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - setTimeout(() => { _beatportModalOpening = false; }, 2000); - - console.log('💯 Beatport Top 100 button clicked'); - - try { - showLoadingOverlay('Scraping Beatport Top 100...'); - - // Fetch track list without enrichment (fast) - const response = await fetch('/api/beatport/top-100?enrich=false', { method: 'GET' }); - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in Beatport Top 100'); - } - - console.log(`✅ Fetched ${data.tracks.length} tracks, enriching one-by-one...`); - - // Enrich one-by-one with live progress - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, 'Beatport Top 100'); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, 'Beatport Top 100', null); - - } catch (error) { - console.error('❌ Error handling Beatport Top 100 click:', error); - hideLoadingOverlay(); - showToast(`Error loading Beatport Top 100: ${error.message}`, 'error'); - } -} - -/** - * Handle click on Hype Top 100 button - open download modal directly - */ -async function handleHypeTop100Click() { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - setTimeout(() => { _beatportModalOpening = false; }, 2000); - - console.log('🔥 Hype Top 100 button clicked'); - - try { - showLoadingOverlay('Scraping Hype Top 100...'); - - // Fetch track list without enrichment (fast) - const response = await fetch('/api/beatport/hype-top-100?enrich=false', { method: 'GET' }); - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error('No tracks found in Hype Top 100'); - } - - console.log(`✅ Fetched ${data.tracks.length} tracks, enriching one-by-one...`); - - // Enrich one-by-one with live progress - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, 'Hype Top 100'); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, 'Hype Top 100', null); - - } catch (error) { - console.error('❌ Error handling Hype Top 100 click:', error); - hideLoadingOverlay(); - showToast(`Error loading Hype Top 100: ${error.message}`, 'error'); - } -} - -// ================================= // -// GENRE BROWSER MODAL FUNCTIONS // -// ================================= // - -// Cache for genre browser data to avoid re-loading -let genreBrowserCache = { - genres: null, - imagesLoaded: false, - lastLoaded: null, - imageLoadingActive: false, - imageWorkers: null -}; - -function initializeGenreBrowserModal() { - console.log('🎵 Initializing Genre Browser Modal...'); - - // Browse by Genre button click handler - const browseByGenreBtn = document.getElementById('browse-by-genre-btn'); - if (browseByGenreBtn) { - browseByGenreBtn.addEventListener('click', () => { - console.log('🎵 Browse by Genre button clicked'); - openGenreBrowserModal(); - }); - } - - // Modal close button handler - const modalCloseBtn = document.getElementById('genre-browser-modal-close'); - if (modalCloseBtn) { - modalCloseBtn.addEventListener('click', closeGenreBrowserModal); - } - - // Click outside modal to close - const modalOverlay = document.getElementById('genre-browser-modal'); - if (modalOverlay) { - modalOverlay.addEventListener('click', (e) => { - if (e.target === modalOverlay) { - closeGenreBrowserModal(); - } - }); - } - - // Search functionality - const searchInput = document.getElementById('genre-browser-search'); - if (searchInput) { - searchInput.addEventListener('input', (e) => { - filterGenreBrowserCards(e.target.value); - }); - } - - // ESC key to close modal - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && isGenreBrowserModalOpen()) { - closeGenreBrowserModal(); - } - }); - - console.log('✅ Genre Browser Modal initialized'); -} - -function openGenreBrowserModal() { - console.log('🎵 Opening Genre Browser Modal...'); - - const modal = document.getElementById('genre-browser-modal'); - if (modal) { - modal.classList.add('active'); - document.body.style.overflow = 'hidden'; // Prevent background scrolling - - // Check cache before loading genres - if (genreBrowserCache.genres && genreBrowserCache.genres.length > 0) { - console.log('💾 Using cached genres data'); - displayCachedGenres(); - } else { - console.log('🔄 No cached data, loading genres...'); - loadGenreBrowserGenres(); - } - - console.log('✅ Genre Browser Modal opened'); - } -} - -function closeGenreBrowserModal() { - console.log('🎵 Closing Genre Browser Modal...'); - - const modal = document.getElementById('genre-browser-modal'); - if (modal) { - modal.classList.remove('active'); - document.body.style.overflow = ''; // Restore scrolling - - // Clear search input but keep the genre data cached - const searchInput = document.getElementById('genre-browser-search'); - if (searchInput) { - searchInput.value = ''; - // Also reset the display filter to show all genres - filterGenreBrowserCards(''); - } - - // Pause image loading workers if they're running - if (genreBrowserCache.imageLoadingActive) { - console.log('⏸️ Pausing image loading workers...'); - genreBrowserCache.imageLoadingActive = false; - } - - console.log('✅ Genre Browser Modal closed (data preserved in cache)'); - } -} - -function isGenreBrowserModalOpen() { - const modal = document.getElementById('genre-browser-modal'); - return modal && modal.classList.contains('active'); -} - -async function loadGenreBrowserGenres() { - console.log('🔍 Loading genres for Genre Browser Modal...'); - - const genresGrid = document.getElementById('genre-browser-genres-grid'); - if (!genresGrid) { - console.error('❌ Genre browser grid not found'); - return; - } - - // Show loading state - genresGrid.innerHTML = ` -
-
-

🔍 Discovering current Beatport genres...

-
- `; - - try { - // First, fetch genres quickly without images - console.log('🚀 Fetching genres without images for fast loading...'); - const fastResponse = await fetch('/api/beatport/genres'); - if (!fastResponse.ok) { - throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`); - } - - const fastData = await fastResponse.json(); - const genres = fastData.genres || []; - - if (genres.length === 0) { - genresGrid.innerHTML = ` -
-

⚠️ No genres available

- -
- `; - return; - } - - // Filter out unwanted genres (section titles, etc.) - const filteredGenres = genres.filter(genre => { - const name = genre.name.toLowerCase().trim(); - const unwantedGenres = [ - 'open format', - 'electronic', - 'genres', - 'browse', - 'charts', - 'new releases', - 'trending', - 'featured', - 'popular' - ]; - - const isUnwanted = unwantedGenres.includes(name); - if (isUnwanted) { - console.log(`🚫 Filtered out unwanted genre: "${genre.name}"`); - } - return !isUnwanted; - }); - - console.log(`📋 Filtered genres: ${genres.length} → ${filteredGenres.length} (removed ${genres.length - filteredGenres.length} unwanted)`); - - // Generate genre cards dynamically (without images first) - const genreCardsHTML = filteredGenres.map(genre => ` -
-
🎵
-
-

${genre.name}

-

Top 10 & Top 100 Charts

-
-
- `).join(''); - - genresGrid.innerHTML = genreCardsHTML; - - // Add click event listeners to genre cards - addGenreBrowserCardClickListeners(); - - // Cache the filtered genres data - genreBrowserCache.genres = filteredGenres; - genreBrowserCache.lastLoaded = new Date(); - genreBrowserCache.imagesLoaded = false; - - console.log(`✅ Loaded ${filteredGenres.length} Beatport genres for modal (fast mode)`); - console.log(`💾 Cached ${filteredGenres.length} genres for future use`); - showToast(`Loaded ${filteredGenres.length} genres for browsing`, 'success'); - - // Now fetch images progressively in the background - if (filteredGenres.length > 5) { - console.log('🖼️ Loading genre images progressively for modal...'); - loadGenreBrowserImagesProgressively(filteredGenres); - } - - } catch (error) { - console.error('❌ Error loading genres for modal:', error); - genresGrid.innerHTML = ` -
-

❌ Failed to load genres: ${error.message}

- -
- `; - showToast(`Error loading genres: ${error.message}`, 'error'); - } -} - -function displayCachedGenres() { - console.log('💾 Displaying cached genres...'); - - const genresGrid = document.getElementById('genre-browser-genres-grid'); - if (!genresGrid) { - console.error('❌ Genre browser grid not found'); - return; - } - - const genres = genreBrowserCache.genres; - if (!genres || genres.length === 0) { - console.error('❌ No cached genres available'); - return; - } - - // Generate genre cards from cached data - const genreCardsHTML = genres.map(genre => ` -
-
🎵
-
-

${genre.name}

-

Top 10 & Top 100 Charts

-
-
- `).join(''); - - genresGrid.innerHTML = genreCardsHTML; - - // Add click event listeners to genre cards - addGenreBrowserCardClickListeners(); - - console.log(`✅ Displayed ${genres.length} cached genres instantly`); - - // Handle image loading based on current state - if (genreBrowserCache.imagesLoaded) { - console.log('🖼️ Images already loaded, restoring them...'); - restoreCachedImages(genres); - } else if (!genreBrowserCache.imageLoadingActive && genres.length > 5) { - // Resume or start image loading - const cachedCount = genres.filter(g => g.imageUrl).length; - if (cachedCount > 0) { - console.log(`🔄 Resuming image loading (${cachedCount}/${genres.length} already cached)...`); - restoreCachedImages(genres); // Show already cached images - } else { - console.log('🖼️ Starting fresh image loading for cached genres...'); - } - loadGenreBrowserImagesProgressively(genres); - } else { - console.log('📷 Image loading in progress, showing cached images...'); - restoreCachedImages(genres); - } -} - -function restoreCachedImages(genres) { - // Restore images that were already loaded in previous sessions - genres.forEach(genre => { - if (genre.imageUrl) { - const genreCard = document.querySelector( - `.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` - ); - - if (genreCard) { - const imageElement = genreCard.querySelector('.genre-browser-card-image'); - if (imageElement) { - imageElement.innerHTML = `${genre.name}`; - genreCard.classList.remove('genre-browser-card-fallback'); - } - } - } - }); -} - -async function loadGenreBrowserImagesProgressively(genres) { - // Load genre images with 2 concurrent workers for faster loading - // Only process genres that don't already have cached images - const imageQueue = genres.filter(genre => !genre.imageUrl); - let imagesLoaded = 0; - const maxWorkers = 2; - - // Mark loading as active - genreBrowserCache.imageLoadingActive = true; - - console.log(`🖼️ Starting progressive image loading for modal with ${maxWorkers} workers for ${imageQueue.length} remaining genres (${genres.length - imageQueue.length} already cached)`); - - // If all images are already cached, mark as complete - if (imageQueue.length === 0) { - console.log('✅ All images already cached, marking as complete'); - genreBrowserCache.imagesLoaded = true; - genreBrowserCache.imageLoadingActive = false; - return; - } - - // Function to process a single image - async function processImage(genre) { - try { - // Fetch individual genre image from backend - const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`); - - if (response.ok) { - const data = await response.json(); - - if (data.success && data.image_url) { - // Cache the image URL in the genre object - genre.imageUrl = data.image_url; - - // Find the genre card in the modal - const genreCard = document.querySelector( - `.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` - ); - - if (genreCard) { - const imageElement = genreCard.querySelector('.genre-browser-card-image'); - if (imageElement) { - // Replace the fallback emoji with the actual image - imageElement.innerHTML = `${genre.name}`; - genreCard.classList.remove('genre-browser-card-fallback'); - - console.log(`✅ Loaded and cached image for ${genre.name} in modal`); - } - } - } - } - - imagesLoaded++; - console.log(`📷 Progress: ${imagesLoaded}/${genres.length} images loaded for modal`); - - } catch (error) { - console.log(`⚠️ Could not load image for ${genre.name} in modal: ${error.message}`); - imagesLoaded++; - } - } - - // Worker function to process images from the queue - async function worker() { - while (imageQueue.length > 0 && genreBrowserCache.imageLoadingActive) { - const genre = imageQueue.shift(); - if (genre) { - await processImage(genre); - // Small delay to prevent overwhelming the server - await new Promise(resolve => setTimeout(resolve, 100)); - } - - // Check if we should pause - if (!genreBrowserCache.imageLoadingActive) { - console.log('⏸️ Worker paused - modal closed'); - break; - } - } - } - - // Start the workers - const workers = []; - for (let i = 0; i < maxWorkers; i++) { - workers.push(worker()); - } - - // Wait for all workers to complete - await Promise.all(workers); - - // Check if loading was completed or paused - if (genreBrowserCache.imageLoadingActive) { - // Completed successfully - genreBrowserCache.imagesLoaded = true; - genreBrowserCache.imageLoadingActive = false; - console.log(`🎉 Completed loading all genre images for modal (${imagesLoaded}/${genres.length})`); - console.log(`💾 Marked images as loaded in cache`); - } else { - // Was paused - console.log(`⏸️ Image loading paused (${imagesLoaded}/${genres.length} completed)`); - console.log(`💾 Partial progress saved in cache`); - } -} - -function filterGenreBrowserCards(searchTerm) { - const genreCards = document.querySelectorAll('.genre-browser-card'); - const searchLower = searchTerm.toLowerCase(); - - genreCards.forEach(card => { - const genreName = card.dataset.genreName?.toLowerCase() || ''; - const shouldShow = genreName.includes(searchLower); - - card.style.display = shouldShow ? 'block' : 'none'; - }); - - console.log(`🔍 Filtered genre cards with search term: "${searchTerm}"`); -} - -// === GENRE BROWSER CARD CLICK HANDLERS === - -function addGenreBrowserCardClickListeners() { - const genreCards = document.querySelectorAll('.genre-browser-card'); - genreCards.forEach(card => { - card.addEventListener('click', () => { - const genreSlug = card.dataset.genreSlug; - const genreId = card.dataset.genreId; - const genreName = card.dataset.genreName; - - console.log(`🎵 Genre card clicked: ${genreName} (${genreSlug})`); - handleGenreBrowserCardClick(genreSlug, genreId, genreName); - }); - }); - - console.log(`🔗 Added click listeners to ${genreCards.length} genre browser cards`); -} - -async function handleGenreBrowserCardClick(genreSlug, genreId, genreName) { - console.log(`🎠 Loading hero slider for ${genreName}...`); - - try { - // Show the genre page view - showGenrePageView(genreSlug, genreId, genreName); - - // Load the hero slider data - // Load hero slider, Top 10 lists, and Top 10 releases in parallel - await Promise.all([ - loadGenreHeroSlider(genreSlug, genreId, genreName), - loadGenreTop10Lists(genreSlug, genreId, genreName), - loadGenreTop10Releases(genreSlug, genreId, genreName) - ]); - - } catch (error) { - console.error(`❌ Error loading genre page for ${genreName}:`, error); - showToast(`Error loading ${genreName}: ${error.message}`, 'error'); - - // Return to genre list on error - showGenreListView(); - } -} - -function showGenrePageView(genreSlug, genreId, genreName) { - console.log(`🎯 Showing genre page view for ${genreName}`); - - // CRITICAL: Stop all other slider auto-play to prevent conflicts - if (typeof beatportRebuildSliderState !== 'undefined' && beatportRebuildSliderState.autoPlayInterval) { - clearInterval(beatportRebuildSliderState.autoPlayInterval); - console.log('🛑 Stopped main slider auto-play to prevent conflicts'); - } - - const modal = document.getElementById('genre-browser-modal'); - if (!modal) return; - - // Hide genre list elements - const searchSection = modal.querySelector('.genre-browser-search-section'); - const genresSection = modal.querySelector('.genre-browser-genres-section'); - - if (searchSection) searchSection.style.display = 'none'; - if (genresSection) genresSection.style.display = 'none'; - - // Create or show genre page content - let genrePageContent = modal.querySelector('.genre-page-content'); - if (!genrePageContent) { - genrePageContent = document.createElement('div'); - genrePageContent.className = 'genre-page-content'; - genrePageContent.innerHTML = ` -
- -

-
-
-
-
-

🎠 Loading hero releases...

-
-
-
-
- -
-
-
-
-
-

🎵 Loading Top 10 lists...

-
-
-
-
-
-

💿 Loading Top 10 releases...

-
-
- `; - - modal.querySelector('.genre-browser-modal-content').appendChild(genrePageContent); - - // Add back button listener - const backButton = genrePageContent.querySelector('#genre-back-button'); - if (backButton) { - backButton.addEventListener('click', showGenreListView); - } - - // Add genre top 100 button listener - const genreTop100Button = genrePageContent.querySelector('#genre-top100-btn'); - if (genreTop100Button) { - genreTop100Button.addEventListener('click', () => { - handleGenreTop100Click(genreSlug, genreId, genreName); - }); - } - } - - // Update title and show genre page - const titleElement = genrePageContent.querySelector('.genre-page-title'); - if (titleElement) titleElement.textContent = genreName; - - genrePageContent.style.display = 'block'; - - // Store current genre info for potential back navigation - genrePageContent.dataset.genreSlug = genreSlug; - genrePageContent.dataset.genreId = genreId; - genrePageContent.dataset.genreName = genreName; -} - -function showGenreListView() { - console.log(`🔙 Returning to genre list view`); - - // Clean up genre hero slider - if (window.genreHeroSliderState && window.genreHeroSliderState.autoPlayInterval) { - clearInterval(window.genreHeroSliderState.autoPlayInterval); - console.log('🧹 Cleaned up genre hero slider auto-play'); - } - - // CRITICAL: Restart main slider auto-play - if (typeof beatportRebuildSliderState !== 'undefined' && !beatportRebuildSliderState.autoPlayInterval) { - if (typeof startBeatportRebuildSliderAutoPlay === 'function') { - startBeatportRebuildSliderAutoPlay(); - console.log('🔄 Restarted main slider auto-play'); - } - } - - const modal = document.getElementById('genre-browser-modal'); - if (!modal) return; - - // Show genre list elements - const searchSection = modal.querySelector('.genre-browser-search-section'); - const genresSection = modal.querySelector('.genre-browser-genres-section'); - const genrePageContent = modal.querySelector('.genre-page-content'); - - if (searchSection) searchSection.style.display = 'block'; - if (genresSection) genresSection.style.display = 'block'; - if (genrePageContent) genrePageContent.style.display = 'none'; -} - -async function loadGenreHeroSlider(genreSlug, genreId, genreName) { - console.log(`🎠 Loading hero slider data for ${genreName}...`); - - const container = document.getElementById('genre-hero-slider-container'); - if (!container) return; - - try { - // Show loading state - container.innerHTML = ` -
-
-

🎠 Loading ${genreName} hero releases...

-
- `; - - // Fetch hero slider data from API - const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/hero`); - if (!response.ok) { - throw new Error(`API returned ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - if (!data.success || !data.releases || data.releases.length === 0) { - throw new Error(data.message || 'No hero releases found'); - } - - console.log(`✅ Loaded ${data.count} hero releases for ${genreName} (cached: ${data.cached})`); - - // Create hero slider HTML - const heroSliderHTML = createGenreHeroSliderHTML(data.releases, genreName); - container.innerHTML = heroSliderHTML; - - // Add click handlers to individual releases (for future download functionality) - addGenreHeroReleaseClickHandlers(data.releases); - - showToast(`Loaded ${data.count} ${genreName} releases`, 'success'); - - } catch (error) { - console.error(`❌ Error loading hero slider for ${genreName}:`, error); - - container.innerHTML = ` -
-

❌ Failed to load ${genreName} releases

-

${error.message}

- -
- `; - - throw error; - } -} - -function createGenreHeroSliderHTML(releases, genreName) { - const slidesHTML = releases.map((release, index) => { - // Convert relative URL to absolute URL - const absoluteUrl = release.url.startsWith('http') - ? release.url - : `https://www.beatport.com${release.url}`; - - return ` -
-
-
-
-
-
-

${release.title}

-

${release.artists_string}

-

${release.label || genreName + ' Hero Release'}

-
-
-
`; - }).join(''); - - const indicatorsHTML = releases.map((_, index) => ` - - `).join(''); - - return ` -
-
-
- ${slidesHTML} -
- - -
- - -
- - -
- ${indicatorsHTML} -
-
-
- `; -} - -function addGenreHeroReleaseClickHandlers(releases) { - // Clear any existing intervals first - if (window.genreHeroSliderState && window.genreHeroSliderState.autoPlayInterval) { - clearInterval(window.genreHeroSliderState.autoPlayInterval); - console.log('🧹 Cleared previous genre hero auto-play interval'); - } - - // CRITICAL: Clear ALL possible conflicting intervals - if (typeof beatportRebuildSliderState !== 'undefined' && beatportRebuildSliderState.autoPlayInterval) { - clearInterval(beatportRebuildSliderState.autoPlayInterval); - console.log('🛑 Cleared main rebuild slider auto-play interval'); - } - - // Initialize global slider state for genre hero slider - window.genreHeroSliderState = { - currentSlide: 0, - totalSlides: releases.length, - autoPlayInterval: null - }; - - console.log(`🎠 Initializing genre hero slider with ${releases.length} slides`); - - // Set up navigation button handlers - const prevBtn = document.getElementById('genre-hero-prev-btn'); - const nextBtn = document.getElementById('genre-hero-next-btn'); - - if (prevBtn) { - prevBtn.addEventListener('click', () => { - window.genreHeroSliderState.currentSlide = window.genreHeroSliderState.currentSlide > 0 - ? window.genreHeroSliderState.currentSlide - 1 - : window.genreHeroSliderState.totalSlides - 1; - updateGenreHeroSlide(window.genreHeroSliderState.currentSlide); - console.log(`⬅️ Previous: Moving to slide ${window.genreHeroSliderState.currentSlide}`); - }); - } - - if (nextBtn) { - nextBtn.addEventListener('click', () => { - window.genreHeroSliderState.currentSlide = (window.genreHeroSliderState.currentSlide + 1) % window.genreHeroSliderState.totalSlides; - updateGenreHeroSlide(window.genreHeroSliderState.currentSlide); - console.log(`➡️ Next: Moving to slide ${window.genreHeroSliderState.currentSlide}`); - }); - } - - // Set up indicator handlers - const indicators = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-indicator'); - indicators.forEach((indicator, index) => { - indicator.addEventListener('click', () => { - window.genreHeroSliderState.currentSlide = index; - updateGenreHeroSlide(index); - console.log(`🎯 Indicator: Jumping to slide ${index}`); - }); - }); - - // Set up individual slide click handlers (like the main hero slider) - const slides = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-slide[data-url]'); - console.log(`🔗 Found ${slides.length} slides to set up click handlers for`); - - slides.forEach((slide, index) => { - const releaseUrl = slide.getAttribute('data-url'); - if (releaseUrl && releaseUrl !== '#' && releaseUrl !== '') { - const release = releases[index]; - if (release) { - // Ensure we use the absolute URL and match the expected data structure - const releaseData = { - url: releaseUrl, // This is already the absolute URL from data-url - title: release.title || 'Unknown Title', - artist: release.artists_string || 'Unknown Artist', // handleBeatportReleaseCardClick expects 'artist' - label: release.label || 'Unknown Label', - image_url: release.image_url || '', - // Include all original data for completeness - artists_string: release.artists_string, - type: release.type, - source: release.source, - badges: release.badges || [] - }; - - slide.addEventListener('click', async (event) => { - // Prevent navigation button clicks from triggering this - if (event.target.closest('.beatport-rebuild-nav-btn') || - event.target.closest('.beatport-rebuild-indicator')) { - return; - } - - console.log(`🎵 Genre hero slide clicked: ${releaseData.title} by ${releaseData.artist}`); - - // Use the exact same functionality as the main hero slider - await handleBeatportReleaseCardClick(slide, releaseData); - }); - - slide.style.cursor = 'pointer'; - } - } - }); - - // Ensure first slide is active BEFORE starting auto-play - updateGenreHeroSlide(0); - - // Delay auto-play start to let DOM settle - setTimeout(() => { - startGenreHeroSliderAutoPlay(); - }, 100); - - // Pause on hover - const sliderContainer = document.querySelector('#genre-hero-slider'); - if (sliderContainer) { - sliderContainer.addEventListener('mouseenter', () => { - if (window.genreHeroSliderState.autoPlayInterval) { - clearInterval(window.genreHeroSliderState.autoPlayInterval); - console.log('⏸️ Paused auto-play on hover'); - } - }); - - sliderContainer.addEventListener('mouseleave', () => { - // Delay restart to avoid rapid state changes - setTimeout(() => { - startGenreHeroSliderAutoPlay(); - }, 100); - console.log('▶️ Resumed auto-play after hover'); - }); - } - - console.log(`✅ Set up slider functionality for ${releases.length} genre hero releases`); -} - -function updateGenreHeroSlide(slideIndex) { - if (!window.genreHeroSliderState) { - console.error('❌ Genre hero slider state not initialized'); - return; - } - - // First update the state - window.genreHeroSliderState.currentSlide = slideIndex; - - // Update slide visibility - use the exact same logic as main slider - const slides = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-slide'); - console.log(`🔄 Updating slide to index ${slideIndex}, found ${slides.length} slides`); - - if (slideIndex >= slides.length || slideIndex < 0) { - console.error(`❌ Invalid slide index ${slideIndex}, max is ${slides.length - 1}`); - return; - } - - slides.forEach((slide, index) => { - slide.classList.remove('active', 'prev', 'next'); - - if (index === slideIndex) { - slide.classList.add('active'); - console.log(`✅ Activated slide ${index}: ${slide.getAttribute('data-slide')} - Title: ${slide.querySelector('.beatport-rebuild-track-title')?.textContent}`); - } else if (index < slideIndex) { - slide.classList.add('prev'); - } else { - slide.classList.add('next'); - } - }); - - // Update indicators - const indicators = document.querySelectorAll('#genre-hero-slider .beatport-rebuild-indicator'); - indicators.forEach((indicator, index) => { - indicator.classList.toggle('active', index === slideIndex); - }); - - console.log(`Genre slide updated to: ${window.genreHeroSliderState.currentSlide}`); -} - -function startGenreHeroSliderAutoPlay() { - if (!window.genreHeroSliderState) { - console.error('❌ Cannot start auto-play: Genre hero slider state not initialized'); - return; - } - - // Clear any existing intervals first - if (window.genreHeroSliderState.autoPlayInterval) { - clearInterval(window.genreHeroSliderState.autoPlayInterval); - console.log('🧹 Cleared existing auto-play interval'); - } - - window.genreHeroSliderState.autoPlayInterval = setInterval(() => { - if (!window.genreHeroSliderState) { - console.error('❌ Auto-play fired but state is gone, clearing interval'); - clearInterval(window.genreHeroSliderState.autoPlayInterval); - return; - } - - const currentSlide = window.genreHeroSliderState.currentSlide; - const totalSlides = window.genreHeroSliderState.totalSlides; - const nextSlide = (currentSlide + 1) % totalSlides; - - console.log(`⏰ Auto-play: Current=${currentSlide}, Total=${totalSlides}, Next=${nextSlide}`); - - // Validate the next slide index - if (nextSlide >= 0 && nextSlide < totalSlides) { - updateGenreHeroSlide(nextSlide); - } else { - console.error(`❌ Invalid nextSlide calculated: ${nextSlide}, resetting to 0`); - updateGenreHeroSlide(0); - } - }, 5000); // 5 second intervals like the main slider - - console.log(`▶️ Started auto-play for genre hero slider (${window.genreHeroSliderState.totalSlides} slides)`); -} - -/** - * Load Top 10 lists for a specific genre (Beatport + Hype) - */ -async function loadGenreTop10Lists(genreSlug, genreId, genreName) { - console.log(`🎵 Loading Top 10 lists for ${genreName}...`); - - const container = document.getElementById('genre-top10-lists-container'); - if (!container) { - console.error('❌ Genre Top 10 lists container not found'); - return; - } - - try { - const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-lists`); - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to load Top 10 lists'); - } - - console.log(`✅ Loaded ${data.beatport_count} Beatport + ${data.hype_count} Hype Top 10 tracks for ${genreName}`); - - // Generate HTML using exact same structure as main page (but unique IDs) - const top10ListsHTML = createGenreTop10ListsHTML(data, genreName); - container.innerHTML = top10ListsHTML; - - // Add container-level click handlers exactly like main page - addGenreTop10ClickHandlers(); - - console.log(`✅ Successfully populated genre Top 10 lists for ${genreName}`); - - } catch (error) { - console.error(`❌ Error loading Top 10 lists for ${genreName}:`, error); - - // Show error state - container.innerHTML = ` -
-

❌ Error Loading Top 10 Lists

-

Could not load Top 10 tracks for ${genreName}

-

${error.message}

-
- `; - } -} - -/** - * Create HTML for genre Top 10 lists (exact structure as main page, unique IDs) - */ -function createGenreTop10ListsHTML(data, genreName) { - const { beatport_top10, hype_top10, has_hype_section } = data; - - // Use exact same structure as main page but with genre-specific IDs - let html = ` -
-
-

🏆 ${genreName} Top 10 Lists

-

Current trending ${genreName.toLowerCase()} tracks

-
- -
- -
-
-

🎵 Beatport Top 10

-

Most popular ${genreName.toLowerCase()} tracks

-
-
- `; - - // Add Beatport Top 10 tracks (same classes as main page) - beatport_top10.forEach((track, index) => { - const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); - const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); - const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); - - html += ` -
-
${track.rank || index + 1}
-
- ${track.artwork_url ? - `${cleanTitle}` : - '
🎵
' - } -
-
-

${cleanTitle}

-

${cleanArtist}

-

${cleanLabel}

-
-
- `; - }); - - html += ` -
-
- `; - - // Add Hype Top 10 section (same classes, unique ID) - if (has_hype_section && hype_top10.length > 0) { - html += ` - -
-
-

🔥 Hype Top 10

-

Editor's trending ${genreName.toLowerCase()} picks

-
-
- `; - - // Add Hype Top 10 tracks (same classes as main page) - hype_top10.forEach((track, index) => { - const cleanTitle = cleanTrackText(track.title || 'Unknown Title'); - const cleanArtist = cleanTrackText(track.artist || 'Unknown Artist'); - const cleanLabel = cleanTrackText(track.label || 'Unknown Label'); - - html += ` -
-
${track.rank || index + 1}
-
- ${track.artwork_url ? - `${cleanTitle}` : - '
🔥
' - } -
-
-

${cleanTitle}

-

${cleanArtist}

-

${cleanLabel}

-
-
- `; - }); - - html += ` -
-
- `; - } - // No else block - completely hide hype section when no hype tracks available - - html += ` -
-
- `; - - return html; -} - -/** - * Add container-level click handlers for genre Top 10 lists (exact parity with main page) - */ -function addGenreTop10ClickHandlers() { - console.log('🔗 Adding container-level click handlers for genre Top 10 lists...'); - - // Add container-level click handler for Beatport Top 10 (exact match to main page) - const beatportContainer = document.getElementById('genre-beatport-top10-list'); - if (beatportContainer) { - beatportContainer.addEventListener('click', () => { - console.log('🎵 Genre Beatport Top 10 container clicked'); - handleGenreBeatportTop10Click(); - }); - console.log('✅ Added Beatport Top 10 container click handler'); - } - - // Add container-level click handler for Hype Top 10 (exact match to main page) - const hypeContainer = document.getElementById('genre-beatport-hype10-list'); - if (hypeContainer) { - hypeContainer.addEventListener('click', () => { - console.log('🔥 Genre Hype Top 10 container clicked'); - handleGenreHypeTop10Click(); - }); - console.log('✅ Added Hype Top 10 container click handler'); - } - - console.log(`✅ Set up container-level click handlers for genre Top 10 lists`); -} - -/** - * Handle genre Beatport Top 10 container click (exact parity with main page) - */ -async function handleGenreBeatportTop10Click() { - console.log('🎵 Handling Genre Beatport Top 10 click'); - - // Get the actual genre name from the page title - const genreName = document.querySelector('.genre-page-title')?.textContent?.trim() || 'Genre'; - - // Use actual genre name in chart title - await handleGenreChartClick('genre_beatport_top10', `${genreName} Beatport Top 10`, 'genre_beatport_top10'); -} - -/** - * Handle genre Hype Top 10 container click (exact parity with main page) - */ -async function handleGenreHypeTop10Click() { - console.log('🔥 Handling Genre Hype Top 10 click'); - - // Get the actual genre name from the page title - const genreName = document.querySelector('.genre-page-title')?.textContent?.trim() || 'Genre'; - - // Use actual genre name in chart title - await handleGenreChartClick('genre_hype_top10', `${genreName} Hype Top 10`, 'genre_hype_top10'); -} - -/** - * Handle genre chart click (based on main page handleRebuildChartClick) - */ -async function handleGenreChartClick(trackDataKey, chartName, chartType) { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - setTimeout(() => { _beatportModalOpening = false; }, 2000); - - try { - // Extract track data from DOM cards - const trackData = await getGenrePageTrackData(trackDataKey); - if (!trackData || trackData.length === 0) { - throw new Error(`No track data found for ${chartName}`); - } - - console.log(`✅ Got ${trackData.length} tracks from ${chartName}, enriching one-by-one...`); - showLoadingOverlay(`Fetching track metadata... (0/${trackData.length})`); - - const enrichedTracks = await _enrichTracksWithProgress(trackData, chartName); - - console.log(`✅ Enriched ${enrichedTracks.length} tracks`); - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); - - } catch (error) { - hideLoadingOverlay(); - console.error(`❌ Error handling ${chartName} click:`, error); - showToast(`Error loading ${chartName}: ${error.message}`, 'error'); - } -} - -/** - * Extract track data from genre page DOM (based on main page getRebuildPageTrackData) - */ -async function getGenrePageTrackData(trackDataKey) { - console.log(`🔍 Extracting ${trackDataKey} data from genre page DOM`); - - let containerSelector, cardSelector; - if (trackDataKey === 'genre_beatport_top10') { - containerSelector = '#genre-beatport-top10-list'; - cardSelector = '.beatport-top10-card[data-url]'; - } else if (trackDataKey === 'genre_hype_top10') { - containerSelector = '#genre-beatport-hype10-list'; - cardSelector = '.beatport-hype10-card[data-url]'; - } else { - throw new Error(`Unknown track data key: ${trackDataKey}`); - } - - const container = document.querySelector(containerSelector); - if (!container) { - throw new Error(`Container ${containerSelector} not found`); - } - - const trackCards = container.querySelectorAll(cardSelector); - if (trackCards.length === 0) { - throw new Error(`No track cards found in ${containerSelector}`); - } - - // Extract track data from DOM cards (exact same pattern as main page) - const tracks = Array.from(trackCards).map(card => { - const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title'; - const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist'; - const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label'; - const url = card.getAttribute('data-url') || ''; - const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || ''; - - return { - title: title, - artist: artist, - label: label, - url: url, - rank: rank - }; - }); - - console.log(`📋 Extracted ${tracks.length} tracks from ${containerSelector}`); - return tracks; -} - -/** - * Handle genre-specific Top 100 button click - create discovery process for genre top 100 tracks - */ -async function handleGenreTop100Click(genreSlug, genreId, genreName) { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - setTimeout(() => { _beatportModalOpening = false; }, 2000); - - console.log(`💯 Genre Top 100 button clicked for ${genreName}`); - - const chartName = `${genreName} Top 100`; - - try { - showLoadingOverlay(`Scraping ${chartName}...`); - - // Use the genre tracks endpoint without enrichment - const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/tracks?enrich=false`, { method: 'GET' }); - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error(`No tracks found in ${chartName}`); - } - - console.log(`✅ Fetched ${data.tracks.length} tracks, enriching one-by-one...`); - - // Enrich one-by-one with live progress - const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); - - hideLoadingOverlay(); - openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); - - } catch (error) { - console.error(`❌ Error handling ${chartName} click:`, error); - hideLoadingOverlay(); - showToast(`Error loading ${chartName}: ${error.message}`, 'error'); - } -} - -/** - * Load Top 10 releases for a specific genre - */ -async function loadGenreTop10Releases(genreSlug, genreId, genreName) { - console.log(`💿 Loading Top 10 releases for ${genreName}...`); - - const container = document.getElementById('genre-top10-releases-container'); - if (!container) { - console.error('❌ Genre Top 10 releases container not found'); - return; - } - - try { - const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/top-10-releases`); - const data = await response.json(); - - if (!data.success) { - throw new Error(data.error || 'Failed to load Top 10 releases'); - } - - console.log(`💿 Loaded ${data.releases.length} Top 10 releases for ${genreName}`); - createGenreTop10ReleasesHTML(data.releases, genreName); - - } catch (error) { - console.error(`❌ Error loading Top 10 releases for ${genreName}:`, error); - showGenreTop10ReleasesError(error.message || 'Failed to load Top 10 releases'); - } -} - -/** - * Create HTML for genre Top 10 releases section (exact parity with main page) - */ -function createGenreTop10ReleasesHTML(releases, genreName) { - const container = document.getElementById('genre-top10-releases-container'); - if (!container || !releases || releases.length === 0) return; - - // Create section with unique ID but exact same structure as main page - const sectionHtml = ` -
-
-

💿 Top 10 ${genreName} Releases

-

Most popular albums and EPs for ${genreName}

-
-
-
- ${createGenreTop10ReleasesCardsHTML(releases)} -
-
-
- `; - - container.innerHTML = sectionHtml; - - // Add background images and click handlers - addGenreTop10ReleasesInteractivity(releases); -} - -/** - * Create release cards HTML for genre Top 10 releases - */ -function createGenreTop10ReleasesCardsHTML(releases) { - let cardsHtml = '
'; - - releases.forEach((release, index) => { - cardsHtml += ` -
-
${release.rank || index + 1}
-
- ${release.image_url ? - `${release.title}` : - '
💿
' - } -
-
-

${release.title || 'Unknown Title'}

-

${release.artist || 'Unknown Artist'}

-

${release.label || 'Unknown Label'}

-
-
- `; - }); - - cardsHtml += '
'; - return cardsHtml; -} - -/** - * Add interactivity to genre Top 10 releases cards - */ -function addGenreTop10ReleasesInteractivity(releases) { - const container = document.getElementById('genre-beatport-releases-top10-list'); - if (!container) return; - - // Set background images for cards - const cards = container.querySelectorAll('.beatport-releases-top10-card[data-bg-image]'); - cards.forEach(card => { - const bgImage = card.getAttribute('data-bg-image'); - if (bgImage) { - // Transform image URL from 95x95 to 500x500 for higher quality background - const highResImage = bgImage.replace('/image_size/95x95/', '/image_size/500x500/'); - card.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.8)), url('${highResImage}')`; - card.style.backgroundSize = 'cover'; - card.style.backgroundPosition = 'center'; - } - }); - - // Add click handlers for individual release discovery (exact same pattern as main page) - const releaseCards = container.querySelectorAll('.beatport-releases-top10-card[data-url]'); - releaseCards.forEach((card, index) => { - card.addEventListener('click', () => handleGenreReleaseCardClick(card, releases[index])); - card.style.cursor = 'pointer'; - }); -} - -/** - * Handle click on individual genre Top 10 Release card (exact parity with main page) - */ -async function handleGenreReleaseCardClick(cardElement, release) { - if (_beatportModalOpening) return; - _beatportModalOpening = true; - - console.log(`💿 Individual genre release card clicked: ${release.title} by ${release.artist}`); - - if (!release.url || release.url === '#') { - _beatportModalOpening = false; - showToast('No release URL available', 'error'); - return; - } - - try { - showToast(`Loading ${release.title}...`, 'info'); - showLoadingOverlay(`Getting tracks from ${release.title}...`); - - // Fetch structured release metadata for direct download modal - console.log(`🎵 Fetching release metadata: ${release.url}`); - const response = await fetch('/api/beatport/release-metadata', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ release_url: release.url }) - }); - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - throw new Error(data.error || 'No tracks found in this release'); - } - - console.log(`✅ Got ${data.tracks.length} tracks from ${data.album.name}`); - - const formattedTracks = data.tracks.map(track => ({ - ...track, - artists: track.artists.map(a => typeof a === 'object' ? a.name : a) - })); - - const virtualPlaylistId = `beatport_release_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const playlistName = data.album.name; - - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, - playlistName, - formattedTracks, - data.album, - data.artist, - false - ); - - hideLoadingOverlay(); - _beatportModalOpening = false; - console.log(`✅ Opened download modal for ${playlistName}`); - - } catch (error) { - console.error(`❌ Error handling release click for ${release.title}:`, error); - hideLoadingOverlay(); - _beatportModalOpening = false; - showToast(`Error loading ${release.title}: ${error.message}`, 'error'); - } -} - -/** - * Show error message for genre Top 10 releases - */ -function showGenreTop10ReleasesError(errorMessage) { - const container = document.getElementById('genre-top10-releases-container'); - - const errorHtml = ` -
-
-

💿 Top 10 Releases

-

Error loading releases

-
-
-
-

❌ Error Loading Releases

-

${errorMessage}

-
-
-
- `; - - if (container) container.innerHTML = errorHtml; -} - -// Initialize the Genre Browser Modal when the page loads -document.addEventListener('DOMContentLoaded', () => { - initializeGenreBrowserModal(); -}); - -// ============ Plex Music Library Selection ============ - -async function loadPlexMusicLibraries() { - try { - const response = await fetch('/api/plex/music-libraries'); - const data = await response.json(); - - if (data.success && data.libraries && data.libraries.length > 0) { - const selector = document.getElementById('plex-music-library'); - const container = document.getElementById('plex-library-selector-container'); - - // Clear existing options - selector.innerHTML = ''; - - // Add options for each library - data.libraries.forEach(library => { - const option = document.createElement('option'); - option.value = library.title; - option.textContent = library.title; - - // Mark the currently selected library - if (library.title === data.current || library.title === data.selected) { - option.selected = true; - } - - selector.appendChild(option); - }); - - // Show the container - container.style.display = 'block'; - } else { - // Hide if no libraries found or not connected - document.getElementById('plex-library-selector-container').style.display = 'none'; - } - } catch (error) { - console.error('Error loading Plex music libraries:', error); - document.getElementById('plex-library-selector-container').style.display = 'none'; - } -} - -async function selectPlexLibrary() { - const selector = document.getElementById('plex-music-library'); - const selectedLibrary = selector.value; - - if (!selectedLibrary) return; - - try { - const response = await fetch('/api/plex/select-music-library', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - library_name: selectedLibrary - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`Plex music library switched to: ${selectedLibrary}`); - } else { - console.error('Failed to switch library:', data.error); - alert(`Failed to switch library: ${data.error}`); - } - } catch (error) { - console.error('Error selecting Plex library:', error); - alert('Error selecting library. Please try again.'); - } -} - -// ============ Jellyfin User Selection ============ - -async function loadJellyfinUsers() { - try { - const response = await fetch('/api/jellyfin/users'); - const data = await response.json(); - - if (data.success && data.users && data.users.length > 0) { - const selector = document.getElementById('jellyfin-user'); - const container = document.getElementById('jellyfin-user-selector-container'); - - // Clear existing options - selector.innerHTML = ''; - - // Add options for each user - data.users.forEach(user => { - const option = document.createElement('option'); - option.value = user.name; - option.textContent = user.name; - - // Mark the currently selected user - if (user.name === data.current || user.name === data.selected) { - option.selected = true; - } - - selector.appendChild(option); - }); - - // Show the container - container.style.display = 'block'; - } else { - // Hide if no users found or not connected - document.getElementById('jellyfin-user-selector-container').style.display = 'none'; - } - } catch (error) { - console.error('Error loading Jellyfin users:', error); - document.getElementById('jellyfin-user-selector-container').style.display = 'none'; - } -} - -async function selectJellyfinUser() { - const selector = document.getElementById('jellyfin-user'); - const selectedUser = selector.value; - - if (!selectedUser) return; - - try { - const response = await fetch('/api/jellyfin/select-user', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - username: selectedUser - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`Jellyfin user switched to: ${selectedUser}`); - // Refresh library dropdown for the new user - loadJellyfinMusicLibraries(); - } else { - console.error('Failed to switch user:', data.error); - alert(`Failed to switch user: ${data.error}`); - } - } catch (error) { - console.error('Error selecting Jellyfin user:', error); - alert('Error selecting user. Please try again.'); - } -} - -// ============ Jellyfin Music Library Selection ============ - -async function loadJellyfinMusicLibraries() { - try { - const response = await fetch('/api/jellyfin/music-libraries'); - const data = await response.json(); - - if (data.success && data.libraries && data.libraries.length > 0) { - const selector = document.getElementById('jellyfin-music-library'); - const container = document.getElementById('jellyfin-library-selector-container'); - - // Clear existing options - selector.innerHTML = ''; - - // Add options for each library - data.libraries.forEach(library => { - const option = document.createElement('option'); - option.value = library.title; - option.textContent = library.title; - - // Mark the currently selected library - if (library.title === data.current || library.title === data.selected) { - option.selected = true; - } - - selector.appendChild(option); - }); - - // Show the container - container.style.display = 'block'; - } else { - // Hide if no libraries found or not connected - document.getElementById('jellyfin-library-selector-container').style.display = 'none'; - } - } catch (error) { - console.error('Error loading Jellyfin music libraries:', error); - document.getElementById('jellyfin-library-selector-container').style.display = 'none'; - } -} - -async function selectJellyfinLibrary() { - const selector = document.getElementById('jellyfin-music-library'); - const selectedLibrary = selector.value; - - if (!selectedLibrary) return; - - try { - const response = await fetch('/api/jellyfin/select-music-library', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - library_name: selectedLibrary - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`Jellyfin music library switched to: ${selectedLibrary}`); - } else { - console.error('Failed to switch library:', data.error); - alert(`Failed to switch library: ${data.error}`); - } - } catch (error) { - console.error('Error selecting Jellyfin library:', error); - alert('Error selecting library. Please try again.'); - } -} - -// ============ Navidrome Music Folder Selection ============ - -async function loadNavidromeMusicFolders() { - try { - const response = await fetch('/api/navidrome/music-folders'); - const data = await response.json(); - - if (data.success && data.folders && data.folders.length > 0) { - const selector = document.getElementById('navidrome-music-folder'); - const container = document.getElementById('navidrome-folder-selector-container'); - - selector.innerHTML = ''; - - data.folders.forEach(folder => { - const option = document.createElement('option'); - option.value = folder.title; - option.textContent = folder.title; - - if (folder.title === data.current || folder.title === data.selected) { - option.selected = true; - } - - selector.appendChild(option); - }); - - container.style.display = 'block'; - } else { - document.getElementById('navidrome-folder-selector-container').style.display = 'none'; - } - } catch (error) { - console.error('Error loading Navidrome music folders:', error); - document.getElementById('navidrome-folder-selector-container').style.display = 'none'; - } -} - -async function selectNavidromeMusicFolder() { - const selector = document.getElementById('navidrome-music-folder'); - const selectedFolder = selector.value; - - try { - const response = await fetch('/api/navidrome/select-music-folder', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folder_name: selectedFolder }) - }); - - const data = await response.json(); - - if (data.success) { - showToast(data.message, 'success'); - } else { - console.error('Failed to set music folder:', data.error); - showToast(`Failed to set music folder: ${data.error}`, 'error', 'set-media'); - } - } catch (error) { - console.error('Error selecting Navidrome music folder:', error); - showToast('Error selecting music folder. Please try again.', 'error', 'set-media'); - } -} - -// ============================================ -// == DISCOVER PAGE == -// ============================================ - -let discoverHeroIndex = 0; -let discoverHeroArtists = []; -let discoverHeroInterval = null; -let discoverPageInitialized = false; - -// Store discover playlist tracks for download/sync functionality -let discoverReleaseRadarTracks = []; -let discoverWeeklyTracks = []; -let discoverRecentAlbums = []; -let discoverSeasonalAlbums = []; -let discoverSeasonalTracks = []; -let currentSeasonKey = null; - -// Personalized playlists storage -let personalizedRecentlyAdded = []; -let personalizedTopTracks = []; -let personalizedForgottenFavorites = []; -let personalizedPopularPicks = []; -let personalizedHiddenGems = []; -let personalizedDailyMixes = []; -let personalizedDiscoveryShuffle = []; -let personalizedFamiliarFavorites = []; -let buildPlaylistSelectedArtists = []; - -async function loadDiscoverPage() { - console.log('Loading discover page...'); - - // Load all sections - await Promise.all([ - loadDiscoverHero(), - loadYourArtists(), - loadYourAlbums(), - loadSpotifyLibrarySection(), - loadDiscoverRecentReleases(), - loadSeasonalContent(), // Seasonal discovery - loadPersonalizedRecentlyAdded(), // NEW: Recently added from library - // loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists (HIDDEN) - loadDiscoverReleaseRadar(), - loadDiscoverWeekly(), - loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool - loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool - loadPersonalizedTopTracks(), // NEW: Your top tracks - loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites - loadDiscoveryShuffle(), // NEW: Discovery Shuffle - loadFamiliarFavorites(), // NEW: Familiar Favorites - loadBecauseYouListenTo(), // Personalized by listening stats - loadCacheUndiscoveredAlbums(), // From metadata cache - loadCacheGenreNewReleases(), // From metadata cache - loadCacheLabelExplorer(), // From metadata cache - loadCacheDeepCuts(), // From metadata cache - loadCacheGenreExplorer(), // From metadata cache - initializeLastfmRadioSection(), // Last.fm Radio section (gated on API key) - initializeListenBrainzTabs(), // ListenBrainz playlists (tabbed) - loadDecadeBrowserTabs(), // Time Machine (tabbed by decade) - loadGenreBrowserTabs(), // Browse by Genre (tabbed by genre) - loadListenBrainzPlaylistsFromBackend(), // Load ListenBrainz playlist states for persistence - loadDiscoveryBlacklist() // Blocked artists list - ]); - - // Check for active syncs after page load - checkForActiveDiscoverSyncs(); -} - -async function checkForActiveDiscoverSyncs() { - // Check if Fresh Tape sync is active - try { - const releaseRadarResponse = await fetch('/api/sync/status/discover_release_radar'); - if (releaseRadarResponse.ok) { - const data = await releaseRadarResponse.json(); - if (data.status === 'syncing' || data.status === 'starting') { - console.log('🔄 Resuming Fresh Tape sync polling after page refresh'); - - // Show status display - const statusDisplay = document.getElementById('release-radar-sync-status'); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - // Disable button - const syncButton = document.getElementById('release-radar-sync-btn'); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Resume polling - startDiscoverSyncPolling('release_radar', 'discover_release_radar'); - } - } - } catch (error) { - // Sync not active, ignore - } - - // Check if The Archives sync is active - try { - const discoveryWeeklyResponse = await fetch('/api/sync/status/discover_discovery_weekly'); - if (discoveryWeeklyResponse.ok) { - const data = await discoveryWeeklyResponse.json(); - if (data.status === 'syncing' || data.status === 'starting') { - console.log('🔄 Resuming The Archives sync polling after page refresh'); - - // Show status display - const statusDisplay = document.getElementById('discovery-weekly-sync-status'); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - // Disable button - const syncButton = document.getElementById('discovery-weekly-sync-btn'); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Resume polling - startDiscoverSyncPolling('discovery_weekly', 'discover_discovery_weekly'); - } - } - } catch (error) { - // Sync not active, ignore - } - - // Check if Seasonal Playlist sync is active - try { - const seasonalResponse = await fetch('/api/sync/status/discover_seasonal_playlist'); - if (seasonalResponse.ok) { - const data = await seasonalResponse.json(); - if (data.status === 'syncing' || data.status === 'starting') { - console.log('🔄 Resuming Seasonal Playlist sync polling after page refresh'); - - const statusDisplay = document.getElementById('seasonal-playlist-sync-status'); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - const syncButton = document.getElementById('seasonal-playlist-sync-btn'); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - startDiscoverSyncPolling('seasonal_playlist', 'discover_seasonal_playlist'); - } - } - } catch (error) { - // Sync not active, ignore - } -} - -async function loadDiscoverHero() { - try { - const response = await fetch('/api/discover/hero'); - if (!response.ok) { - console.error('Failed to fetch discover hero'); - return; - } - - const data = await response.json(); - if (!data.success || !data.artists || data.artists.length === 0) { - console.log('No hero artists available'); - showDiscoverHeroEmpty(); - return; - } - - discoverHeroArtists = data.artists; - discoverHeroIndex = 0; - - // Display first artist - displayDiscoverHeroArtist(discoverHeroArtists[0]); - - // Start slideshow (change every 8 seconds) - if (discoverHeroInterval) { - clearInterval(discoverHeroInterval); - } - if (discoverHeroArtists.length > 1) { - discoverHeroInterval = setInterval(() => { - discoverHeroIndex = (discoverHeroIndex + 1) % discoverHeroArtists.length; - displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); - }, 8000); - } - - // Check if all hero artists are already watched - checkAllHeroWatchlistStatus(); - - } catch (error) { - console.error('Error loading discover hero:', error); - showDiscoverHeroEmpty(); - } -} - -function displayDiscoverHeroArtist(artist) { - const titleEl = document.getElementById('discover-hero-title'); - const subtitleEl = document.getElementById('discover-hero-subtitle'); - const metaEl = document.getElementById('discover-hero-meta'); - const imageEl = document.getElementById('discover-hero-image'); - const bgEl = document.getElementById('discover-hero-bg'); - - if (titleEl) { - titleEl.textContent = artist.artist_name; - } - - if (subtitleEl) { - // Show recommendation context based on occurrence count - let subtitle = ''; - if (artist.occurrence_count > 1) { - subtitle = `Similar to ${artist.occurrence_count} artists in your watchlist`; - } else { - subtitle = 'Similar to an artist in your watchlist'; - } - subtitleEl.textContent = subtitle; - } - - // Build metadata section with popularity and genres - if (metaEl) { - let metaHTML = '
'; - - // Add popularity indicator - if (artist.popularity !== undefined && artist.popularity > 0) { - const popularityClass = artist.popularity >= 80 ? 'high' : - artist.popularity >= 50 ? 'medium' : 'low'; - metaHTML += ` -
- - ${artist.popularity}/100 - Popularity -
- `; - } - - // Add genre tags - if (artist.genres && artist.genres.length > 0) { - metaHTML += '
'; - artist.genres.slice(0, 3).forEach(genre => { - metaHTML += `${genre}`; - }); - metaHTML += '
'; - } - - metaHTML += '
'; - metaEl.innerHTML = metaHTML; - } - - if (imageEl && artist.image_url) { - imageEl.innerHTML = `${artist.artist_name}`; - } else if (imageEl) { - imageEl.innerHTML = '
🎧
'; - } - - if (bgEl && artist.image_url) { - bgEl.style.backgroundImage = `url('${artist.image_url}')`; - bgEl.style.backgroundSize = 'cover'; - bgEl.style.backgroundPosition = 'center'; - } - - // Store artist ID for both buttons and update watchlist state - // Use artist_id which is set by the backend to the appropriate ID for the active source - const addBtn = document.getElementById('discover-hero-add'); - const discographyBtn = document.getElementById('discover-hero-discography'); - const artistId = artist.artist_id || artist.spotify_artist_id || artist.itunes_artist_id; - - if (addBtn && artistId) { - addBtn.setAttribute('data-artist-id', artistId); - addBtn.setAttribute('data-artist-name', artist.artist_name); - // Also store both IDs for cross-source operations - if (artist.spotify_artist_id) addBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); - if (artist.itunes_artist_id) addBtn.setAttribute('data-itunes-id', artist.itunes_artist_id); - - // Check if this artist is already in watchlist and update button appearance - checkAndUpdateDiscoverHeroWatchlistButton(artistId); - } - - if (discographyBtn && artistId) { - discographyBtn.setAttribute('data-artist-id', artistId); - discographyBtn.setAttribute('data-artist-name', artist.artist_name); - // Also store both IDs for cross-source operations - if (artist.spotify_artist_id) discographyBtn.setAttribute('data-spotify-id', artist.spotify_artist_id); - if (artist.itunes_artist_id) discographyBtn.setAttribute('data-itunes-id', artist.itunes_artist_id); - } - - // Update slideshow indicators - updateDiscoverHeroIndicators(); -} - -async function checkAndUpdateDiscoverHeroWatchlistButton(artistId) { - try { - const response = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - - const data = await response.json(); - if (!data.success) return; - - const addBtn = document.getElementById('discover-hero-add'); - if (!addBtn) return; - - const icon = addBtn.querySelector('.watchlist-icon'); - const text = addBtn.querySelector('.watchlist-text'); - - if (data.is_watching) { - // Artist is in watchlist - if (icon) icon.textContent = '👁️'; - if (text) text.textContent = 'Watching...'; - addBtn.classList.add('watching'); - } else { - // Artist not in watchlist - if (icon) icon.textContent = '👁️'; - if (text) text.textContent = 'Add to Watchlist'; - addBtn.classList.remove('watching'); - } - } catch (error) { - console.error('Error checking watchlist status for hero:', error); - } -} - -function toggleDiscoverHeroWatchlist(event) { - event.stopPropagation(); - - const button = document.getElementById('discover-hero-add'); - if (!button) return; - - const artistId = button.getAttribute('data-artist-id'); - const artistName = button.getAttribute('data-artist-name'); - - if (!artistId || !artistName) { - console.error('No artist data found on discover hero button'); - return; - } - - // Call the existing toggleWatchlist function - toggleWatchlist(event, artistId, artistName); -} - -async function watchAllHeroArtists(btn) { - if (!discoverHeroArtists || discoverHeroArtists.length === 0) return; - if (btn.classList.contains('all-watched')) return; - - const textEl = btn.querySelector('.watch-all-text'); - const originalText = textEl ? textEl.textContent : ''; - - // Loading state - btn.disabled = true; - if (textEl) textEl.textContent = 'Adding...'; - - try { - const artists = discoverHeroArtists.map(a => ({ - artist_id: a.artist_id, - artist_name: a.artist_name - })); - - const response = await fetch('/api/watchlist/add-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artists }) - }); - - const data = await response.json(); - if (data.success) { - if (textEl) textEl.textContent = 'All Watched'; - btn.classList.add('all-watched'); - btn.disabled = true; - - // Sync the per-slide watchlist button for current artist - const currentArtist = discoverHeroArtists[discoverHeroIndex]; - if (currentArtist) { - checkAndUpdateDiscoverHeroWatchlistButton(currentArtist.artist_id); - } - - // Update watchlist count badge - if (typeof updateWatchlistButtonCount === 'function') { - updateWatchlistButtonCount(); - } - } else { - if (textEl) textEl.textContent = originalText; - btn.disabled = false; - } - } catch (error) { - console.error('Error watching all hero artists:', error); - if (textEl) textEl.textContent = originalText; - btn.disabled = false; - } -} - -// Cache for recommended artists data so reopening is instant -let _recommendedArtistsCache = null; - -async function openRecommendedArtistsModal() { - let modal = document.getElementById('recommended-artists-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'recommended-artists-modal'; - modal.className = 'modal-overlay'; - document.body.appendChild(modal); - - modal.addEventListener('click', function (e) { - if (e.target === modal) closeRecommendedArtistsModal(); - }); - } - - // If cached, render instantly and refresh watchlist statuses - if (_recommendedArtistsCache) { - modal.style.display = 'flex'; - renderRecommendedArtistsModal(modal, _recommendedArtistsCache); - checkRecommendedWatchlistStatuses(_recommendedArtistsCache); - return; - } - - // Show loading - modal.innerHTML = ` - - `; - modal.style.display = 'flex'; - - try { - // Phase 1: Fetch basic data (instant — no API enrichment) - const response = await fetch('/api/discover/similar-artists'); - const data = await response.json(); - - if (!data.success || !data.artists || data.artists.length === 0) { - modal.querySelector('.playlist-modal-body').innerHTML = ` - - `; - modal.querySelector('.playlist-track-count').textContent = '0 artists'; - return; - } - - // Render cards immediately with fallback images - _recommendedArtistsCache = data.artists; - renderRecommendedArtistsModal(modal, data.artists); - - // Phase 2: Enrich with images/genres progressively in batches of 50 - // Skip artists that already have cached metadata from the initial response - const source = data.source || 'spotify'; - const idKey = source === 'spotify' ? 'spotify_artist_id' : source === 'deezer' ? 'deezer_artist_id' : 'itunes_artist_id'; - const allIds = data.artists - .filter(a => !a.image_url) // Only enrich artists without cached images - .map(a => a[idKey]).filter(Boolean); - - for (let i = 0; i < allIds.length; i += 50) { - const batchIds = allIds.slice(i, i + 50); - try { - const enrichResp = await fetch('/api/discover/similar-artists/enrich', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_ids: batchIds, source }) - }); - const enrichData = await enrichResp.json(); - if (enrichData.success && enrichData.artists) { - // Update cards and cache as each batch arrives - for (const [aid, info] of Object.entries(enrichData.artists)) { - // Update the card in DOM - const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`); - if (card && info.image_url) { - const imgContainer = card.querySelector('.recommended-card-image'); - if (imgContainer) { - imgContainer.innerHTML = ``; - } - } - if (card && info.genres && info.genres.length > 0) { - const genresContainer = card.querySelector('.recommended-card-genres'); - if (genresContainer) { - genresContainer.innerHTML = info.genres.map(g => - `${escapeHtml(g)}` - ).join(''); - } else { - const infoDiv = card.querySelector('.recommended-card-info'); - if (infoDiv) { - const genreDiv = document.createElement('div'); - genreDiv.className = 'recommended-card-genres'; - genreDiv.innerHTML = info.genres.map(g => - `${escapeHtml(g)}` - ).join(''); - infoDiv.appendChild(genreDiv); - } - } - } - - // Update cache - const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid); - if (cached) { - if (info.image_url) cached.image_url = info.image_url; - if (info.genres) cached.genres = info.genres; - if (info.artist_name) cached.artist_name = info.artist_name; - } - } - } - } catch (enrichErr) { - console.error('Error enriching batch:', enrichErr); - } - } - - // Phase 3: Check watchlist statuses - checkRecommendedWatchlistStatuses(data.artists); - - } catch (error) { - console.error('Error loading recommended artists:', error); - modal.querySelector('.playlist-modal-body').innerHTML = ` - - `; - } -} - -function renderRecommendedArtistsModal(modal, artists) { - modal.innerHTML = ` - - `; - - // Event delegation for card clicks and watchlist buttons - const grid = modal.querySelector('#recommended-artists-grid'); - if (grid) { - grid.addEventListener('click', function (e) { - const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn'); - if (watchlistBtn) { - e.stopPropagation(); - toggleRecommendedWatchlist(watchlistBtn); - return; - } - - const card = e.target.closest('.recommended-artist-card'); - if (card) { - const artistId = card.getAttribute('data-artist-id'); - const nameEl = card.querySelector('.recommended-card-name'); - const artistName = nameEl ? nameEl.textContent : ''; - viewRecommendedArtistDiscography(artistId, artistName); - } - }); - } -} - -async function addAllRecommendedToWatchlist(btn) { - if (!_recommendedArtistsCache || _recommendedArtistsCache.length === 0) return; - if (btn.classList.contains('all-added')) return; - - const originalText = btn.textContent; - btn.disabled = true; - btn.textContent = 'Adding...'; - - try { - const artists = _recommendedArtistsCache.map(a => ({ - artist_id: a.artist_id, - artist_name: a.artist_name - })); - - const resp = await fetch('/api/watchlist/add-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artists }) - }); - const data = await resp.json(); - - if (data.success) { - btn.textContent = `All Added (${data.added} new)`; - btn.classList.add('all-added'); - btn.disabled = true; - - // Update all watchlist buttons in the modal to "Watching" - document.querySelectorAll('.recommended-card-watchlist-btn').forEach(wBtn => { - wBtn.classList.add('watching'); - wBtn.textContent = 'Watching'; - }); - - if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount(); - } else { - btn.textContent = originalText; - btn.disabled = false; - } - } catch (error) { - console.error('Error adding all recommended to watchlist:', error); - btn.textContent = originalText; - btn.disabled = false; - } -} - -function closeRecommendedArtistsModal() { - const modal = document.getElementById('recommended-artists-modal'); - if (modal) modal.style.display = 'none'; -} - -function filterRecommendedArtists() { - const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase(); - const cards = document.querySelectorAll('.recommended-artist-card'); - cards.forEach(card => { - const name = card.getAttribute('data-artist-name') || ''; - card.style.display = name.includes(query) ? '' : 'none'; - }); -} - -async function toggleRecommendedWatchlist(btn) { - const artistId = btn.getAttribute('data-artist-id'); - const artistName = btn.getAttribute('data-artist-name'); - if (!artistId || !artistName) return; - - btn.disabled = true; - const wasWatching = btn.classList.contains('watching'); - - try { - if (wasWatching) { - const resp = await fetch('/api/watchlist/remove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId }) - }); - const data = await resp.json(); - if (data.success) { - btn.classList.remove('watching'); - btn.textContent = 'Add to Watchlist'; - } - } else { - const resp = await fetch('/api/watchlist/add', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artistId, artist_name: artistName }) - }); - const data = await resp.json(); - if (data.success) { - btn.classList.add('watching'); - btn.textContent = 'Watching'; - } - } - if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount(); - } catch (error) { - console.error('Error toggling recommended watchlist:', error); - } finally { - btn.disabled = false; - } -} - -async function checkRecommendedWatchlistStatuses(artists) { - try { - const artistIds = artists.map(a => a.artist_id).filter(Boolean); - if (!artistIds.length) return; - - const resp = await fetch('/api/watchlist/check-batch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_ids: artistIds }) - }); - const data = await resp.json(); - if (data.success && data.results) { - for (const [aid, isWatching] of Object.entries(data.results)) { - if (isWatching) { - const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${aid}"]`); - if (btn) { - btn.classList.add('watching'); - btn.textContent = 'Watching'; - } - } - } - } - } catch (e) { - // Non-critical - } -} - -async function viewRecommendedArtistDiscography(artistId, artistName) { - closeRecommendedArtistsModal(); - - const artist = { - id: artistId, - name: artistName - }; - - // Use same navigation pattern as hero slider - navigateToPage('artists'); - await new Promise(resolve => setTimeout(resolve, 100)); - await selectArtistForDetail(artist); -} - -async function checkAllHeroWatchlistStatus() { - const btn = document.getElementById('discover-hero-watch-all'); - if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return; - - try { - let allWatched = true; - for (const artist of discoverHeroArtists) { - const response = await fetch('/api/watchlist/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: artist.artist_id }) - }); - const data = await response.json(); - if (!data.success || !data.is_watching) { - allWatched = false; - break; - } - } - - const textEl = btn.querySelector('.watch-all-text'); - if (allWatched) { - if (textEl) textEl.textContent = 'All Watched'; - btn.classList.add('all-watched'); - btn.disabled = true; - } else { - if (textEl) textEl.textContent = 'Watch All'; - btn.classList.remove('all-watched'); - btn.disabled = false; - } - } catch (error) { - console.error('Error checking hero watchlist status:', error); - } -} - -function navigateDiscoverHero(direction) { - if (!discoverHeroArtists || discoverHeroArtists.length === 0) return; - - // Update index with wrapping - discoverHeroIndex = (discoverHeroIndex + direction + discoverHeroArtists.length) % discoverHeroArtists.length; - - // Display the artist - displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); - - // Update indicators - updateDiscoverHeroIndicators(); -} - -function updateDiscoverHeroIndicators() { - const indicatorsContainer = document.getElementById('discover-hero-indicators'); - if (!indicatorsContainer || !discoverHeroArtists || discoverHeroArtists.length === 0) return; - - // Create indicator dots - indicatorsContainer.innerHTML = discoverHeroArtists.map((_, index) => ` - - `).join(''); -} - -function jumpToDiscoverHeroSlide(index) { - if (!discoverHeroArtists || index < 0 || index >= discoverHeroArtists.length) return; - - discoverHeroIndex = index; - displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); - updateDiscoverHeroIndicators(); -} - -async function viewDiscoverHeroDiscography() { - const button = document.getElementById('discover-hero-discography'); - if (!button) return; - - const artistId = button.getAttribute('data-artist-id'); - const artistName = button.getAttribute('data-artist-name'); - - if (!artistId || !artistName) { - console.error('No artist data found for discography view'); - return; - } - - // Create artist object matching the expected format - const artist = { - id: artistId, - name: artistName, - image_url: discoverHeroArtists[discoverHeroIndex]?.image_url || '', - genres: discoverHeroArtists[discoverHeroIndex]?.genres || [], - popularity: discoverHeroArtists[discoverHeroIndex]?.popularity || 0 - }; - - console.log(`🎵 Navigating to artist detail for: ${artistName}`); - - // Navigate to Artists page - navigateToPage('artists'); - - // Small delay to let the page load - await new Promise(resolve => setTimeout(resolve, 100)); - - // Load the artist details - await selectArtistForDetail(artist); -} - -function showDiscoverHeroEmpty() { - const titleEl = document.getElementById('discover-hero-title'); - const subtitleEl = document.getElementById('discover-hero-subtitle'); - - if (titleEl) titleEl.textContent = 'No Recommendations Yet'; - if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations'; -} - -async function loadDiscoverRecentReleases() { - try { - const carousel = document.getElementById('recent-releases-carousel'); - if (!carousel) return; - - carousel.innerHTML = '

Loading recent releases...

'; - - const response = await fetch('/api/discover/recent-releases'); - if (!response.ok) { - throw new Error('Failed to fetch recent releases'); - } - - const data = await response.json(); - if (!data.success || !data.albums || data.albums.length === 0) { - carousel.innerHTML = '

No recent releases found

'; - return; - } - - // Store albums for download functionality - discoverRecentAlbums = data.albums; - - // Build carousel HTML - let html = ''; - data.albums.forEach((album, index) => { - const coverUrl = album.album_cover_url || '/static/placeholder-album.png'; - html += ` -
-
- ${album.album_name} -
-
-

${album.album_name}

-

${album.artist_name}

-

${album.release_date}

-
-
- `; - }); - - carousel.innerHTML = html; - - } catch (error) { - console.error('Error loading recent releases:', error); - const carousel = document.getElementById('recent-releases-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load recent releases

'; - } - } -} - -// =============================== -// =============================== -// YOUR ALBUMS SECTION -// =============================== - -let yourAlbums = []; -let yourAlbumsPage = 1; -let yourAlbumsTotal = 0; -const YOUR_ALBUMS_PAGE_SIZE = 48; -let _yourAlbumsSearchTimeout = null; - -function debouncedYourAlbumsSearch() { - clearTimeout(_yourAlbumsSearchTimeout); - _yourAlbumsSearchTimeout = setTimeout(() => { - yourAlbumsPage = 1; - loadYourAlbumsGrid(); - }, 400); -} - -async function loadYourAlbums() { - const section = document.getElementById('your-albums-section'); - if (!section) return; - try { - const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success) return; - - const totalCount = (data.stats && data.stats.total) || 0; - if (totalCount === 0 && !data.stale) return; // Nothing to show yet - - section.style.display = ''; - yourAlbums = data.albums || []; - yourAlbumsTotal = data.total || 0; - yourAlbumsPage = 1; - - const subtitle = document.getElementById('your-albums-subtitle'); - if (subtitle && data.stats) { - const s = data.stats; - subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; - } - - const filters = document.getElementById('your-albums-filters'); - if (filters && totalCount > 0) filters.style.display = ''; - - const downloadBtn = document.getElementById('your-albums-download-btn'); - if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = ''; - - _renderYourAlbumsGrid(yourAlbums); - _renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage); - - if (data.stale && totalCount === 0) { - const grid = document.getElementById('your-albums-grid'); - if (grid) grid.innerHTML = '

Fetching your albums from connected services...

'; - _pollYourAlbums(); - } - } catch (e) { - console.error('Error loading your albums:', e); - } -} - -function _pollYourAlbums() { - let attempts = 0; - const poll = setInterval(async () => { - attempts++; - if (attempts > 12) { clearInterval(poll); return; } - try { - const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success) return; - const total = (data.stats && data.stats.total) || 0; - if (total > 0) { - clearInterval(poll); - loadYourAlbums(); - } - } catch (e) { } - }, 5000); -} - -async function loadYourAlbumsGrid() { - const grid = document.getElementById('your-albums-grid'); - if (!grid) return; - grid.innerHTML = '

Loading...

'; - try { - const search = (document.getElementById('your-albums-search')?.value || '').trim(); - const status = document.getElementById('your-albums-status-filter')?.value || 'all'; - const sort = document.getElementById('your-albums-sort')?.value || 'artist_name'; - const params = new URLSearchParams({ page: yourAlbumsPage, per_page: YOUR_ALBUMS_PAGE_SIZE, sort, status }); - if (search) params.set('search', search); - const resp = await fetch(`/api/discover/your-albums?${params}`); - const data = await resp.json(); - if (!data.success) throw new Error(data.error); - yourAlbums = data.albums || []; - yourAlbumsTotal = data.total || 0; - const subtitle = document.getElementById('your-albums-subtitle'); - if (subtitle && data.stats) { - const s = data.stats; - subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; - } - _renderYourAlbumsGrid(yourAlbums); - _renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage); - } catch (e) { - console.error('Error loading your albums grid:', e); - grid.innerHTML = '

Failed to load albums

'; - } -} - -function _renderYourAlbumsGrid(albums) { - const grid = document.getElementById('your-albums-grid'); - if (!grid) return; - if (!albums || albums.length === 0) { - grid.innerHTML = '

No albums found

'; - return; - } - let html = ''; - albums.forEach((album, index) => { - const coverUrl = album.image_url || '/static/placeholder-album.png'; - const year = album.release_date ? album.release_date.substring(0, 4) : ''; - const badgeClass = album.in_library ? 'owned' : 'missing'; - const badgeIcon = album.in_library ? '\u2713' : '\u2193'; - const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : ''; - const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 '); - const sources = (album.source_services || []).join(', '); - html += ` -
-
- ${escapeHtml(album.album_name)} -
${badgeIcon}
-
-
-

${escapeHtml(album.album_name)}

-

${escapeHtml(album.artist_name)}

-

${escapeHtml(meta)}

-
-
`; - }); - grid.innerHTML = html; -} - -function _renderYourAlbumsPagination(total, page) { - const container = document.getElementById('your-albums-pagination'); - if (!container) return; - if (total <= YOUR_ALBUMS_PAGE_SIZE) { container.style.display = 'none'; return; } - container.style.display = ''; - const totalPages = Math.ceil(total / YOUR_ALBUMS_PAGE_SIZE); - const start = (page - 1) * YOUR_ALBUMS_PAGE_SIZE + 1; - const end = Math.min(page * YOUR_ALBUMS_PAGE_SIZE, total); - container.innerHTML = ` - - ${start}\u2013${end} of ${total} - - `; -} - -function _yourAlbumsPrevPage() { - if (yourAlbumsPage > 1) { yourAlbumsPage--; loadYourAlbumsGrid(); } -} -function _yourAlbumsNextPage() { - const totalPages = Math.ceil(yourAlbumsTotal / YOUR_ALBUMS_PAGE_SIZE); - if (yourAlbumsPage < totalPages) { yourAlbumsPage++; loadYourAlbumsGrid(); } -} - -async function openYourAlbumDownload(index) { - const album = yourAlbums[index]; - if (!album) { showToast('Album data not found', 'error'); return; } - showLoadingOverlay(`Loading tracks for ${album.album_name}...`); - try { - // Prefer Spotify ID, fall back to Deezer, then search by name - let albumData = null; - const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - if (album.spotify_album_id) { - const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`); - if (r.ok) albumData = await r.json(); - } - if (!albumData && album.deezer_album_id) { - const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`); - if (r.ok) albumData = await r.json(); - } - if (!albumData) { - // Last resort — search by name - const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`); - if (r.ok) albumData = await r.json(); - } - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - throw new Error('No tracks found for this album'); - } - const tracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; - if (Array.isArray(artists)) artists = artists.map(a => a.name || a); - return { - id: track.id, name: track.name, artists, - album: { - id: albumData.id, name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }, - duration_ms: track.duration_ms || 0, - track_number: track.track_number || 0 - }; - }); - const virtualId = `discover_album_${album.spotify_album_id || album.deezer_album_id || album.tidal_album_id || index}`; - const albumObj = { - id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', - images: albumData.images || [], artists: [{ name: album.artist_name }] - }; - const artistObj = { id: null, name: album.artist_name }; - await openDownloadMissingModalForArtistAlbum(virtualId, albumData.name, tracks, albumObj, artistObj, false); - hideLoadingOverlay(); - } catch (e) { - console.error('Error opening your album download:', e); - showToast(`Failed to load album: ${e.message}`, 'error'); - hideLoadingOverlay(); - } -} - -async function refreshYourAlbums() { - const btn = document.getElementById('your-albums-refresh-btn'); - if (btn) btn.disabled = true; - const subtitle = document.getElementById('your-albums-subtitle'); - if (subtitle) subtitle.textContent = 'Refreshing from connected services...'; - try { - await fetch('/api/discover/your-albums/refresh?clear=true', { method: 'POST' }); - showToast('Refresh started — checking for new albums...', 'info'); - const poll = setInterval(async () => { - try { - const resp = await fetch('/api/discover/your-albums?page=1&per_page=48'); - const data = await resp.json(); - if (data.success && data.stats && data.stats.total > 0) { - clearInterval(poll); - loadYourAlbums(); - if (btn) btn.disabled = false; - } - } catch (e) { } - }, 4000); - setTimeout(() => { clearInterval(poll); if (btn) btn.disabled = false; }, 60000); - } catch (e) { - showToast('Failed to start refresh', 'error'); - if (btn) btn.disabled = false; - } -} - -async function openYourAlbumsSourcesModal() { - const existing = document.getElementById('ya-albums-sources-modal-overlay'); - if (existing) existing.remove(); - - let enabled = ['spotify', 'tidal', 'deezer']; - let connected = []; - try { - const resp = await fetch('/api/discover/your-albums/sources'); - if (resp.ok) { - const data = await resp.json(); - if (data.enabled) enabled = data.enabled; - if (data.connected) connected = data.connected; - } - } catch (e) { } - - const sourceInfo = [ - { id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' }, - { id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' }, - { id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' }, - ]; - const state = {}; - sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); }); - - const overlay = document.createElement('div'); - overlay.id = 'ya-albums-sources-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - const rows = sourceInfo.map(s => { - const isConnected = connected.includes(s.id); - const isOn = state[s.id]; - return ` -
-
- ${s.icon} -
-
${s.label}
-
${isConnected ? 'Connected' : 'Not connected'}
-
-
- -
`; - }).join(''); - - overlay.innerHTML = ` -
-

Your Albums Sources

-

Choose which connected services contribute albums to this section.

-
${rows}
- -
- `; - document.body.appendChild(overlay); - window._yaaSourcesState = state; -} - -function _yaaSourceRowClick(id) { - const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`); - if (row && row.classList.contains('disconnected')) return; - _yaaSourceToggle(id); -} -function _yaaSourceToggle(id) { - const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`); - if (row && row.classList.contains('disconnected')) return; - window._yaaSourcesState[id] = !window._yaaSourcesState[id]; - const btn = document.getElementById(`yaa-toggle-${id}`); - if (btn) btn.classList.toggle('on', window._yaaSourcesState[id]); -} -async function _yaaSourcesSave() { - const enabledArr = Object.entries(window._yaaSourcesState).filter(([, v]) => v).map(([k]) => k); - if (enabledArr.length === 0) { showToast('Select at least one source', 'error'); return; } - try { - const resp = await fetch('/api/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ discover: { your_albums_sources: enabledArr.join(',') } }) - }); - if (resp.ok) { - document.getElementById('ya-albums-sources-modal-overlay')?.remove(); - showToast('Sources saved — refresh to apply', 'success'); - const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer' }; - const subtitle = document.getElementById('your-albums-subtitle'); - if (subtitle) { - const names = enabledArr.map(s => sourceNames[s] || s).join(' and '); - subtitle.textContent = `Albums you\u2019ve saved on ${names}`; - } - } else { - showToast('Failed to save sources', 'error'); - } - } catch (e) { - showToast('Failed to save sources', 'error'); - } -} - -async function downloadMissingYourAlbums() { - try { - const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing'); - const data = await resp.json(); - if (!data.success || !data.albums || data.albums.length === 0) { - showToast('No missing albums to download', 'info'); - return; - } - const missing = data.albums.filter(a => !a.in_library); - if (missing.length === 0) { showToast('All albums are already in your library!', 'success'); return; } - if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your saved albums?`)) return; - showToast(`Starting download for ${missing.length} albums...`, 'info'); - for (let i = 0; i < missing.length; i++) { - const album = missing[i]; - try { - showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info'); - const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - let albumData = null; - if (album.spotify_album_id) { - const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`); - if (r.ok) albumData = await r.json(); - } - if (!albumData && album.deezer_album_id) { - const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`); - if (r.ok) albumData = await r.json(); - } - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) continue; - const tracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; - if (Array.isArray(artists)) artists = artists.map(a => a.name || a); - return { - id: track.id, name: track.name, artists, - album: { - id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', - images: albumData.images || [] - }, - duration_ms: track.duration_ms || 0, track_number: track.track_number || 0 - }; - }); - const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || i}`; - await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks, - { name: album.artist_name, source: albumData.source || 'spotify' }, - { - id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', - images: albumData.images || [] - } - ); - } catch (err) { console.error(`Error queuing ${album.album_name}:`, err); } - } - } catch (e) { - console.error('Error downloading missing your albums:', e); - showToast(`Error: ${e.message}`, 'error'); - } -} - -// =============================== -// SPOTIFY LIBRARY SECTION -// =============================== - -let spotifyLibraryAlbums = []; -let spotifyLibraryPage = 0; -let spotifyLibraryTotal = 0; -const SPOTIFY_LIBRARY_PAGE_SIZE = 48; -let _spotifyLibrarySearchTimeout = null; - -function debouncedSpotifyLibrarySearch() { - clearTimeout(_spotifyLibrarySearchTimeout); - _spotifyLibrarySearchTimeout = setTimeout(() => { - spotifyLibraryPage = 0; - loadSpotifyLibraryAlbums(); - }, 400); -} - -async function loadSpotifyLibrarySection() { - try { - const section = document.getElementById('spotify-library-section'); - if (!section) return; - - const response = await fetch(`/api/discover/spotify-library?offset=0&limit=${SPOTIFY_LIBRARY_PAGE_SIZE}`); - if (!response.ok) throw new Error('Failed to fetch'); - - const data = await response.json(); - if (!data.success || !data.albums || data.albums.length === 0) { - section.style.display = 'none'; - return; - } - - section.style.display = ''; - spotifyLibraryAlbums = data.albums; - spotifyLibraryTotal = data.total; - spotifyLibraryPage = 0; - - // Update subtitle with stats - const subtitle = document.getElementById('spotify-library-subtitle'); - if (subtitle && data.stats) { - const s = data.stats; - subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; - } - - // Show download missing button if there are missing albums - const dlBtn = document.getElementById('spotify-library-download-missing-btn'); - if (dlBtn && data.stats && data.stats.missing > 0) { - dlBtn.style.display = ''; - } - - // Show filters - const filters = document.getElementById('spotify-library-filters'); - if (filters) filters.style.display = ''; - - renderSpotifyLibraryGrid(data.albums); - renderSpotifyLibraryPagination(data.total, 0); - - } catch (error) { - console.error('Error loading Spotify library section:', error); - const section = document.getElementById('spotify-library-section'); - if (section) section.style.display = 'none'; - } -} - -async function loadSpotifyLibraryAlbums() { - const grid = document.getElementById('spotify-library-grid'); - if (!grid) return; - - grid.innerHTML = '

Loading...

'; - - try { - const search = (document.getElementById('spotify-library-search')?.value || '').trim(); - const status = document.getElementById('spotify-library-status-filter')?.value || 'all'; - const sort = document.getElementById('spotify-library-sort')?.value || 'date_saved'; - const offset = spotifyLibraryPage * SPOTIFY_LIBRARY_PAGE_SIZE; - - const params = new URLSearchParams({ - offset, limit: SPOTIFY_LIBRARY_PAGE_SIZE, sort, sort_dir: 'desc', status - }); - if (search) params.set('search', search); - - const response = await fetch(`/api/discover/spotify-library?${params}`); - const data = await response.json(); - - if (!data.success) throw new Error(data.error); - - spotifyLibraryAlbums = data.albums; - spotifyLibraryTotal = data.total; - - // Update subtitle - const subtitle = document.getElementById('spotify-library-subtitle'); - if (subtitle && data.stats) { - const s = data.stats; - subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`; - } - - renderSpotifyLibraryGrid(data.albums); - renderSpotifyLibraryPagination(data.total, offset); - - } catch (error) { - console.error('Error loading Spotify library albums:', error); - grid.innerHTML = '

Failed to load albums

'; - } -} - -function renderSpotifyLibraryGrid(albums) { - const grid = document.getElementById('spotify-library-grid'); - if (!grid) return; - - if (!albums || albums.length === 0) { - grid.innerHTML = '

No albums found

'; - return; - } - - let html = ''; - albums.forEach((album, index) => { - const coverUrl = album.image_url || '/static/placeholder-album.png'; - const year = album.release_date ? album.release_date.substring(0, 4) : ''; - const badgeClass = album.in_library ? 'owned' : 'missing'; - const badgeIcon = album.in_library ? '\u2713' : '\u2193'; - const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : ''; - const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 '); - - html += ` -
-
- ${album.album_name} -
${badgeIcon}
-
-
-

${album.album_name}

-

${album.artist_name}

-

${meta}

-
-
- `; - }); - - grid.innerHTML = html; -} - -function renderSpotifyLibraryPagination(total, offset) { - const container = document.getElementById('spotify-library-pagination'); - if (!container) return; - - if (total <= SPOTIFY_LIBRARY_PAGE_SIZE) { - container.style.display = 'none'; - return; - } - - container.style.display = ''; - const totalPages = Math.ceil(total / SPOTIFY_LIBRARY_PAGE_SIZE); - const currentPage = Math.floor(offset / SPOTIFY_LIBRARY_PAGE_SIZE) + 1; - const showEnd = Math.min(offset + SPOTIFY_LIBRARY_PAGE_SIZE, total); - - container.innerHTML = ` - - ${offset + 1}\u2013${showEnd} of ${total} - - `; -} - -function spotifyLibraryPrevPage() { - if (spotifyLibraryPage > 0) { - spotifyLibraryPage--; - loadSpotifyLibraryAlbums(); - } -} - -function spotifyLibraryNextPage() { - const totalPages = Math.ceil(spotifyLibraryTotal / SPOTIFY_LIBRARY_PAGE_SIZE); - if (spotifyLibraryPage < totalPages - 1) { - spotifyLibraryPage++; - loadSpotifyLibraryAlbums(); - } -} - -async function openSpotifyLibraryAlbumDownload(index) { - const album = spotifyLibraryAlbums[index]; - if (!album) { - showToast('Album data not found', 'error'); - return; - } - - console.log(`\u{1F4E5} Opening download modal for Spotify library album: ${album.album_name}`); - showLoadingOverlay(`Loading tracks for ${album.album_name}...`); - - try { - const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`); - if (!response.ok) throw new Error('Failed to fetch album tracks'); - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - throw new Error('No tracks found in album'); - } - - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; - if (Array.isArray(artists)) { - artists = artists.map(a => a.name || a); - } - return { - id: track.id, - name: track.name, - artists: artists, - album: { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }, - duration_ms: track.duration_ms || 0, - track_number: track.track_number || 0 - }; - }); - - const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`; - const artistContext = { - id: album.artist_id, - name: album.artist_name, - source: 'spotify' - }; - const albumContext = { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }; - - await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); - hideLoadingOverlay(); - - } catch (error) { - console.error('Error opening Spotify library album download:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - hideLoadingOverlay(); - } -} - -async function refreshSpotifyLibraryCache() { - try { - showToast('Refreshing Spotify library...', 'info'); - const response = await fetch('/api/discover/spotify-library/refresh', { method: 'POST' }); - const data = await response.json(); - if (data.success) { - showToast('Spotify library refresh started — will update shortly', 'success'); - // Reload after a delay to let the sync run - setTimeout(() => loadSpotifyLibrarySection(), 10000); - } else { - showToast(`Error: ${data.error}`, 'error'); - } - } catch (error) { - showToast(`Error: ${error.message}`, 'error'); - } -} - -async function downloadMissingSpotifyLibraryAlbums() { - // Fetch all missing albums (no pagination limit) - try { - const response = await fetch('/api/discover/spotify-library?status=missing&limit=500&offset=0'); - const data = await response.json(); - if (!data.success || !data.albums || data.albums.length === 0) { - showToast('No missing albums to download', 'info'); - return; - } - - const missing = data.albums.filter(a => !a.in_library); - if (missing.length === 0) { - showToast('All albums are already in your library!', 'success'); - return; - } - - if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your Spotify library?`)) { - return; - } - - showToast(`Starting download for ${missing.length} albums...`, 'info'); - - // Download one at a time to avoid overwhelming the system - for (let i = 0; i < missing.length; i++) { - const album = missing[i]; - try { - showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info'); - - const _params = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - const response = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${_params}`); - if (!response.ok) continue; - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) continue; - - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; - if (Array.isArray(artists)) artists = artists.map(a => a.name || a); - return { - id: track.id, - name: track.name, - artists: artists, - album: { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }, - duration_ms: track.duration_ms || 0, - track_number: track.track_number || 0 - }; - }); - - const virtualPlaylistId = `spotify_library_${album.spotify_album_id}`; - await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, { - id: album.artist_id, name: album.artist_name, source: 'spotify' - }, { - id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', - images: albumData.images || [] - }); - - } catch (err) { - console.error(`Error downloading album ${album.album_name}:`, err); - } - } - - } catch (error) { - console.error('Error downloading missing Spotify library albums:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -async function loadDiscoverReleaseRadar() { - try { - const playlistContainer = document.getElementById('release-radar-playlist'); - if (!playlistContainer) return; - - playlistContainer.innerHTML = '

Loading release radar...

'; - - const response = await fetch('/api/discover/release-radar'); - if (!response.ok) { - throw new Error('Failed to fetch release radar'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No new releases available

'; - return; - } - - // Store tracks for download/sync functionality - discoverReleaseRadarTracks = data.tracks; - - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; - - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `; - }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading release radar:', error); - const playlistContainer = document.getElementById('release-radar-playlist'); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load release radar

'; - } - } -} - -async function loadDiscoverWeekly() { - try { - const playlistContainer = document.getElementById('discovery-weekly-playlist'); - if (!playlistContainer) return; - - playlistContainer.innerHTML = '

Curating your discovery playlist...

'; - - const response = await fetch('/api/discover/weekly'); - if (!response.ok) { - throw new Error('Failed to fetch discovery weekly'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No tracks available yet

'; - return; - } - - // Store tracks for download/sync functionality - discoverWeeklyTracks = data.tracks; - - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; - - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `; - }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading discovery weekly:', error); - const playlistContainer = document.getElementById('discovery-weekly-playlist'); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load discovery weekly

'; - } - } -} - -// =============================== -// DECADE BROWSER -// =============================== - -let selectedDecade = null; -let decadeTracks = []; - -async function loadDecadeBrowser() { - try { - const carousel = document.getElementById('decade-browser-carousel'); - if (!carousel) return; - - // Fetch available decades from backend - const response = await fetch('/api/discover/decades/available'); - if (!response.ok) { - throw new Error('Failed to fetch available decades'); - } - - const data = await response.json(); - if (!data.success || !data.decades || data.decades.length === 0) { - carousel.innerHTML = '

No decade content available yet. Run a watchlist scan to populate your discovery pool!

'; - return; - } - - // Build decade cards matching Recent Releases style - let html = ''; - data.decades.forEach(decade => { - const icon = getDecadeIcon(decade.year); - const label = `${decade.year}s`; - html += ` -
-
-
${icon}
-
-
-

${label}

-

${decade.track_count} tracks

-

Classics

-
-
- `; - }); - - carousel.innerHTML = html; - - } catch (error) { - console.error('Error loading decade browser:', error); - const carousel = document.getElementById('decade-browser-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load decades

'; - } - } -} - -function getDecadeIcon(year) { - const icons = { - 1950: '🎺', - 1960: '🎸', - 1970: '🕺', - 1980: '📻', - 1990: '💿', - 2000: '📱', - 2010: '🎧', - 2020: '🌐' - }; - return icons[year] || '🎵'; -} - -async function openDecadePlaylist(decade) { - try { - showLoadingOverlay(`Loading ${decade}s playlist...`); - - const response = await fetch(`/api/discover/decade/${decade}`); - if (!response.ok) { - throw new Error('Failed to fetch decade playlist'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - const message = data.message || `No tracks found for the ${decade}s`; - showToast(message, 'info'); - hideLoadingOverlay(); - return; - } - - selectedDecade = decade; - decadeTracks = data.tracks; - - // Open download modal - const playlistName = `${decade}s Classics`; - const virtualPlaylistId = `decade_${decade}`; - - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks); - hideLoadingOverlay(); - - } catch (error) { - console.error(`Error opening ${decade}s playlist:`, error); - showToast(`Failed to load ${decade}s playlist`, 'error'); - hideLoadingOverlay(); - } -} - -// =============================== -// GENRE BROWSER -// =============================== - -let selectedGenre = null; -let genreTracks = []; - -async function loadGenreBrowser() { - try { - const carousel = document.getElementById('genre-browser-carousel'); - if (!carousel) return; - - // Fetch available genres from backend - const response = await fetch('/api/discover/genres/available'); - if (!response.ok) { - throw new Error('Failed to fetch available genres'); - } - - const data = await response.json(); - if (!data.success || !data.genres || data.genres.length === 0) { - carousel.innerHTML = '

No genre content available yet. Run a watchlist scan to populate your discovery pool!

'; - return; - } - - // Build genre cards matching Recent Releases style - let html = ''; - data.genres.forEach(genre => { - const icon = getGenreIcon(genre.name); - const displayName = capitalizeGenre(genre.name); - html += ` -
-
-
${icon}
-
-
-

${displayName}

-

${genre.track_count} tracks

-

Curated

-
-
- `; - }); - - carousel.innerHTML = html; - - } catch (error) { - console.error('Error loading genre browser:', error); - const carousel = document.getElementById('genre-browser-carousel'); - if (carousel) { - carousel.innerHTML = '

Failed to load genres

'; - } - } -} - -function getGenreIcon(genreName) { - const genre = genreName.toLowerCase(); - - // Parent genre exact matches (consolidated categories) - if (genre === 'electronic/dance') return '🎹'; - if (genre === 'hip hop/rap') return '🎤'; - if (genre === 'rock') return '🎸'; - if (genre === 'pop') return '🎵'; - if (genre === 'r&b/soul') return '🎙️'; - if (genre === 'jazz') return '🎺'; - if (genre === 'classical') return '🎻'; - if (genre === 'metal') return '🤘'; - if (genre === 'country') return '🪕'; - if (genre === 'folk/indie') return '🎧'; - if (genre === 'latin') return '💃'; - if (genre === 'reggae/dancehall') return '🌴'; - if (genre === 'world') return '🌍'; - if (genre === 'alternative') return '🎭'; - if (genre === 'blues') return '🎸'; - if (genre === 'funk/disco') return '🕺'; - - // Fallback: partial matching for specific genres - if (genre.includes('house') || genre.includes('techno') || genre.includes('edm') || - genre.includes('electro') || genre.includes('trance') || genre.includes('electronic')) { - return '🎹'; - } - if (genre.includes('hip hop') || genre.includes('rap') || genre.includes('trap')) { - return '🎤'; - } - if (genre.includes('rock') || genre.includes('punk')) { - return '🎸'; - } - if (genre.includes('metal')) { - return '🤘'; - } - if (genre.includes('jazz') || genre.includes('blues')) { - return '🎺'; - } - if (genre.includes('pop')) { - return '🎵'; - } - if (genre.includes('r&b') || genre.includes('soul')) { - return '🎙️'; - } - if (genre.includes('country') || genre.includes('folk')) { - return '🪕'; - } - if (genre.includes('classical') || genre.includes('orchestra')) { - return '🎻'; - } - if (genre.includes('indie') || genre.includes('alternative')) { - return '🎧'; - } - if (genre.includes('latin') || genre.includes('reggaeton') || genre.includes('salsa')) { - return '💃'; - } - if (genre.includes('reggae') || genre.includes('dancehall')) { - return '🌴'; - } - if (genre.includes('funk') || genre.includes('disco')) { - return '🕺'; - } - - // Default - return '🎶'; -} - -function capitalizeGenre(genre) { - // Capitalize each word in genre, handling both spaces and slashes - return genre.split(/(\s|\/)/g) - .map(part => { - if (part === ' ' || part === '/') return part; - return part.charAt(0).toUpperCase() + part.slice(1); - }) - .join(''); -} - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -async function openGenrePlaylist(genre) { - try { - showLoadingOverlay(`Loading ${capitalizeGenre(genre)} playlist...`); - - const response = await fetch(`/api/discover/genre/${encodeURIComponent(genre)}`); - if (!response.ok) { - throw new Error('Failed to fetch genre playlist'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - const message = data.message || `No tracks found for ${genre}`; - showToast(message, 'info'); - hideLoadingOverlay(); - return; - } - - selectedGenre = genre; - genreTracks = data.tracks; - - // Open download modal - const playlistName = `${capitalizeGenre(genre)} Mix`; - const virtualPlaylistId = `genre_${genre.replace(/\s+/g, '_')}`; - - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, data.tracks); - hideLoadingOverlay(); - - } catch (error) { - console.error(`Error opening ${genre} playlist:`, error); - showToast(`Failed to load ${genre} playlist`, 'error'); - hideLoadingOverlay(); - } -} - -// =============================== -// TIME MACHINE (TABBED BY DECADE) -// =============================== - -let decadeTracksCache = {}; // Store tracks for each decade -let activeDecade = null; - -async function loadDecadeBrowserTabs() { - try { - const tabsContainer = document.getElementById('decade-tabs'); - const contentsContainer = document.getElementById('decade-tab-contents'); - - if (!tabsContainer || !contentsContainer) return; - - // Fetch available decades from backend - const response = await fetch('/api/discover/decades/available'); - if (!response.ok) { - throw new Error('Failed to fetch available decades'); - } - - const data = await response.json(); - if (!data.success || !data.decades || data.decades.length === 0) { - tabsContainer.innerHTML = '

No decade content available yet. Run a watchlist scan to populate your discovery pool!

'; - return; - } - - // Build decade tabs - let tabsHTML = ''; - let contentsHTML = ''; - - data.decades.forEach((decade, index) => { - const isActive = index === 0; - const icon = getDecadeIcon(decade.year); - const tabId = `decade-${decade.year}`; - - // Tab button - tabsHTML += ` - - `; - - // Tab content - contentsHTML += ` -
- -
-
-
-

${decade.year}s Classics

-

${decade.track_count} tracks

-
-
- - -
-
- - - -
- - -
-

Loading ${decade.year}s tracks...

-
-
- `; - }); - - tabsContainer.innerHTML = tabsHTML; - contentsContainer.innerHTML = contentsHTML; - - // Load first decade's tracks - if (data.decades.length > 0) { - await loadDecadeTracks(data.decades[0].year); - } - - } catch (error) { - console.error('Error loading decade browser tabs:', error); - const tabsContainer = document.getElementById('decade-tabs'); - if (tabsContainer) { - tabsContainer.innerHTML = '

Failed to load decades

'; - } - } -} - -function switchDecadeTab(decade) { - // Update tab buttons - const tabs = document.querySelectorAll('.decade-tab'); - tabs.forEach(tab => { - if (parseInt(tab.getAttribute('data-decade')) === decade) { - tab.classList.add('active'); - } else { - tab.classList.remove('active'); - } - }); - - // Update tab content - const tabContents = document.querySelectorAll('.decade-tab-content'); - tabContents.forEach(content => { - if (content.id === `decade-${decade}-content`) { - content.classList.add('active'); - } else { - content.classList.remove('active'); - } - }); - - // Load tracks if not already loaded - if (!decadeTracksCache[decade]) { - loadDecadeTracks(decade); - } -} - -async function loadDecadeTracks(decade) { - try { - const playlistContainer = document.getElementById(`decade-${decade}-playlist`); - if (!playlistContainer) return; - - const response = await fetch(`/api/discover/decade/${decade}`); - if (!response.ok) { - throw new Error('Failed to fetch decade playlist'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No tracks found for the ' + decade + 's

'; - return; - } - - // Store tracks in cache - decadeTracksCache[decade] = data.tracks; - activeDecade = decade; - - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - // Extract track data from track_data_json if available - let trackData = track; - if (track.track_data_json) { - trackData = track.track_data_json; - } - - // Get track properties with fallbacks - const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track'; - const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist'; - const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album'; - const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png'; - const durationMs = trackData.duration_ms || track.duration_ms || 0; - - const durationMin = Math.floor(durationMs / 60000); - const durationSec = Math.floor((durationMs % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; - - html += ` -
-
${index + 1}
-
- ${albumName} -
-
-
${trackName}
-
${artistName}
-
-
${albumName}
-
${duration}
-
- `; - }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading decade tracks:', error); - const playlistContainer = document.getElementById(`decade-${decade}-playlist`); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load decade tracks

'; - } - } -} - -async function startDecadeSync(decade) { - const tracks = decadeTracksCache[decade]; - if (!tracks || tracks.length === 0) { - showToast('No tracks available for this decade', 'warning'); - return; - } - - // Convert to format expected by sync API - const spotifyTracks = tracks.map(track => { - // Extract track data from track_data_json if available - let trackData = track; - if (track.track_data_json) { - trackData = track.track_data_json; - } - - // Build properly formatted Spotify track object - let spotifyTrack = { - id: trackData.id || track.spotify_track_id, - name: trackData.name || trackData.track_name || track.track_name, - artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], - album: trackData.album || { - name: trackData.album_name || track.album_name, - images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) - }, - duration_ms: trackData.duration_ms || track.duration_ms || 0 - }; - - // Normalize artists to array of strings for sync compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; - }); - - const virtualPlaylistId = `discover_decade_${decade}`; - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - - const virtualPlaylist = { - id: virtualPlaylistId, - name: `${decade}s Classics`, - track_count: spotifyTracks.length - }; - - if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { - spotifyPlaylists.push(virtualPlaylist); - } - - // Show sync status display - const statusDisplay = document.getElementById(`decade-${decade}-sync-status`); - if (statusDisplay) statusDisplay.style.display = 'block'; - - // Disable sync button - const syncButton = document.getElementById(`decade-${decade}-sync-btn`); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Start sync - await startPlaylistSync(virtualPlaylistId); - - // Start polling - startDecadeSyncPolling(decade, virtualPlaylistId); -} - -function startDecadeSyncPolling(decade, virtualPlaylistId) { - const pollerId = `decade_${decade}`; - - if (discoverSyncPollers[pollerId]) { - clearInterval(discoverSyncPollers[pollerId]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); - _syncProgressCallbacks[virtualPlaylistId] = (data) => { - const progress = data.progress || {}; - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const processed = matched + failed; - const pending = total - processed; - const pct = total > 0 ? Math.round((processed / total) * 100) : 0; - const el = (id) => document.getElementById(id); - if (el(`decade-${decade}-sync-completed`)) el(`decade-${decade}-sync-completed`).textContent = matched; - if (el(`decade-${decade}-sync-pending`)) el(`decade-${decade}-sync-pending`).textContent = pending; - if (el(`decade-${decade}-sync-failed`)) el(`decade-${decade}-sync-failed`).textContent = failed; - if (el(`decade-${decade}-sync-percentage`)) el(`decade-${decade}-sync-percentage`).textContent = pct; - if (data.status === 'finished') { - if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; } - socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); - delete _syncProgressCallbacks[virtualPlaylistId]; - const syncButton = el(`decade-${decade}-sync-btn`); - if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - showToast(`${decade}s Classics sync complete!`, 'success'); - setTimeout(() => { const sd = el(`decade-${decade}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); - } - }; - } - - discoverSyncPollers[pollerId] = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); - if (!response.ok) return; - - const data = await response.json(); - const progress = data.progress || {}; - - const completedEl = document.getElementById(`decade-${decade}-sync-completed`); - const pendingEl = document.getElementById(`decade-${decade}-sync-pending`); - const failedEl = document.getElementById(`decade-${decade}-sync-failed`); - const percentageEl = document.getElementById(`decade-${decade}-sync-percentage`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const processed = matched + failed; - const pending = total - processed; - const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - if (completedEl) completedEl.textContent = matched; - if (pendingEl) pendingEl.textContent = pending; - if (failedEl) failedEl.textContent = failed; - if (percentageEl) percentageEl.textContent = completionPercentage; - - if (data.status === 'finished') { - clearInterval(discoverSyncPollers[pollerId]); - delete discoverSyncPollers[pollerId]; - - const syncButton = document.getElementById(`decade-${decade}-sync-btn`); - if (syncButton) { - syncButton.disabled = false; - syncButton.style.opacity = '1'; - syncButton.style.cursor = 'pointer'; - } - - showToast(`${decade}s Classics sync complete!`, 'success'); - - setTimeout(() => { - const statusDisplay = document.getElementById(`decade-${decade}-sync-status`); - if (statusDisplay) statusDisplay.style.display = 'none'; - }, 3000); - } - } catch (error) { - console.error(`Error polling sync status for decade ${decade}:`, error); - } - }, 500); -} - -async function openDownloadModalForDecade(decade) { - const tracks = decadeTracksCache[decade]; - if (!tracks || tracks.length === 0) { - showToast('No tracks available for this decade', 'warning'); - return; - } - - // Convert to format expected by download modal - const spotifyTracks = tracks.map(track => { - // Extract track data from track_data_json if available - let trackData = track; - if (track.track_data_json) { - trackData = track.track_data_json; - } - - // Build properly formatted Spotify track object - let spotifyTrack = { - id: trackData.id || track.spotify_track_id, - name: trackData.name || trackData.track_name || track.track_name, - artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], - album: trackData.album || { - name: trackData.album_name || track.album_name, - images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) - }, - duration_ms: trackData.duration_ms || track.duration_ms || 0 - }; - - return spotifyTrack; - }); - - const playlistName = `${decade}s Classics`; - const virtualPlaylistId = `decade_${decade}`; - - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); -} - -// =============================== -// BROWSE BY GENRE (TABBED BY GENRE) -// =============================== - -let genreTracksCache = {}; // Store tracks for each genre -let activeGenre = null; -let availableGenres = []; - -async function loadGenreBrowserTabs() { - try { - const tabsContainer = document.getElementById('genre-tabs'); - const contentsContainer = document.getElementById('genre-tab-contents'); - - if (!tabsContainer || !contentsContainer) return; - - // Fetch available genres from backend - const response = await fetch('/api/discover/genres/available'); - if (!response.ok) { - throw new Error('Failed to fetch available genres'); - } - - const data = await response.json(); - if (!data.success || !data.genres || data.genres.length === 0) { - tabsContainer.innerHTML = '

No genre content available yet. Run a watchlist scan to populate your discovery pool!

'; - return; - } - - availableGenres = data.genres; - - // Build genre tabs (limit to first 8-10 to avoid overcrowding) - const displayGenres = data.genres.slice(0, 10); - let tabsHTML = ''; - let contentsHTML = ''; - - displayGenres.forEach((genre, index) => { - const isActive = index === 0; - const icon = getGenreIcon(genre.name); - const genreName = genre.name; - const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); - const tabId = `genre-${genreId}`; - - // Tab button - tabsHTML += ` - - `; - - // Tab content - contentsHTML += ` -
- -
-
-
-

${capitalizeGenre(genreName)} Mix

-

${genre.track_count} tracks

-
-
- - -
-
- - - -
- - -
-

Loading ${capitalizeGenre(genreName)} tracks...

-
-
- `; - }); - - tabsContainer.innerHTML = tabsHTML; - contentsContainer.innerHTML = contentsHTML; - - // Load first genre's tracks - if (displayGenres.length > 0) { - await loadGenreTracks(displayGenres[0].name); - } - - } catch (error) { - console.error('Error loading genre browser tabs:', error); - const tabsContainer = document.getElementById('genre-tabs'); - if (tabsContainer) { - tabsContainer.innerHTML = '

Failed to load genres

'; - } - } -} - -function switchGenreTab(genreName) { - // Update tab buttons - const tabs = document.querySelectorAll('.genre-tab'); - tabs.forEach(tab => { - if (tab.getAttribute('data-genre') === genreName) { - tab.classList.add('active'); - } else { - tab.classList.remove('active'); - } - }); - - // Update tab content - const tabContents = document.querySelectorAll('.genre-tab-content'); - tabContents.forEach(content => { - if (content.getAttribute('data-genre') === genreName) { - content.classList.add('active'); - } else { - content.classList.remove('active'); - } - }); - - // Load tracks if not already loaded - if (!genreTracksCache[genreName]) { - loadGenreTracks(genreName); - } -} - -async function loadGenreTracks(genreName) { - try { - const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); - const playlistContainer = document.getElementById(`genre-${genreId}-playlist`); - if (!playlistContainer) return; - - const response = await fetch(`/api/discover/genre/${encodeURIComponent(genreName)}`); - if (!response.ok) { - throw new Error('Failed to fetch genre playlist'); - } - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = `

No tracks found for ${capitalizeGenre(genreName)}

`; - return; - } - - // Store tracks in cache - genreTracksCache[genreName] = data.tracks; - activeGenre = genreName; - - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - // Extract track data from track_data_json if available - let trackData = track; - if (track.track_data_json) { - trackData = track.track_data_json; - } - - // Get track properties with fallbacks - const trackName = trackData.name || trackData.track_name || track.track_name || 'Unknown Track'; - const artistName = trackData.artists?.[0]?.name || trackData.artists?.[0] || trackData.artist_name || track.artist_name || 'Unknown Artist'; - const albumName = trackData.album?.name || trackData.album_name || track.album_name || 'Unknown Album'; - const coverUrl = trackData.album?.images?.[0]?.url || track.album_cover_url || '/static/placeholder-album.png'; - const durationMs = trackData.duration_ms || track.duration_ms || 0; - - const durationMin = Math.floor(durationMs / 60000); - const durationSec = Math.floor((durationMs % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; - - html += ` -
-
${index + 1}
-
- ${albumName} -
-
-
${trackName}
-
${artistName}
-
-
${albumName}
-
${duration}
-
- `; - }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading genre tracks:', error); - const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); - const playlistContainer = document.getElementById(`genre-${genreId}-playlist`); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load genre tracks

'; - } - } -} - -async function startGenreSync(genreName) { - const tracks = genreTracksCache[genreName]; - if (!tracks || tracks.length === 0) { - showToast('No tracks available for this genre', 'warning'); - return; - } - - const genreId = genreName.replace(/\s+/g, '-').replace(/[^a-zA-Z0-9-]/g, ''); - - // Convert to format expected by sync API - const spotifyTracks = tracks.map(track => { - // Extract track data from track_data_json if available - let trackData = track; - if (track.track_data_json) { - trackData = track.track_data_json; - } - - // Build properly formatted Spotify track object - let spotifyTrack = { - id: trackData.id || track.spotify_track_id, - name: trackData.name || trackData.track_name || track.track_name, - artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], - album: trackData.album || { - name: trackData.album_name || track.album_name, - images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) - }, - duration_ms: trackData.duration_ms || track.duration_ms || 0 - }; - - // Normalize artists to array of strings for sync compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; - }); - - const virtualPlaylistId = `discover_genre_${genreName.replace(/\s+/g, '_')}`; - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - - const virtualPlaylist = { - id: virtualPlaylistId, - name: `${capitalizeGenre(genreName)} Mix`, - track_count: spotifyTracks.length - }; - - if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { - spotifyPlaylists.push(virtualPlaylist); - } - - // Show sync status display - const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`); - if (statusDisplay) statusDisplay.style.display = 'block'; - - // Disable sync button - const syncButton = document.getElementById(`genre-${genreId}-sync-btn`); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Start sync - await startPlaylistSync(virtualPlaylistId); - - // Start polling - startGenreSyncPolling(genreName, genreId, virtualPlaylistId); -} - -function startGenreSyncPolling(genreName, genreId, virtualPlaylistId) { - const pollerId = `genre_${genreId}`; - - if (discoverSyncPollers[pollerId]) { - clearInterval(discoverSyncPollers[pollerId]); - } - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); - _syncProgressCallbacks[virtualPlaylistId] = (data) => { - const progress = data.progress || {}; - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const processed = matched + failed; - const pending = total - processed; - const pct = total > 0 ? Math.round((processed / total) * 100) : 0; - const el = (id) => document.getElementById(id); - if (el(`genre-${genreId}-sync-completed`)) el(`genre-${genreId}-sync-completed`).textContent = matched; - if (el(`genre-${genreId}-sync-pending`)) el(`genre-${genreId}-sync-pending`).textContent = pending; - if (el(`genre-${genreId}-sync-failed`)) el(`genre-${genreId}-sync-failed`).textContent = failed; - if (el(`genre-${genreId}-sync-percentage`)) el(`genre-${genreId}-sync-percentage`).textContent = pct; - if (data.status === 'finished') { - if (discoverSyncPollers[pollerId]) { clearInterval(discoverSyncPollers[pollerId]); delete discoverSyncPollers[pollerId]; } - socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); - delete _syncProgressCallbacks[virtualPlaylistId]; - const syncButton = el(`genre-${genreId}-sync-btn`); - if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); - setTimeout(() => { const sd = el(`genre-${genreId}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); - } - }; - } - - discoverSyncPollers[pollerId] = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); - if (!response.ok) return; - - const data = await response.json(); - const progress = data.progress || {}; - - const completedEl = document.getElementById(`genre-${genreId}-sync-completed`); - const pendingEl = document.getElementById(`genre-${genreId}-sync-pending`); - const failedEl = document.getElementById(`genre-${genreId}-sync-failed`); - const percentageEl = document.getElementById(`genre-${genreId}-sync-percentage`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const processed = matched + failed; - const pending = total - processed; - const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - if (completedEl) completedEl.textContent = matched; - if (pendingEl) pendingEl.textContent = pending; - if (failedEl) failedEl.textContent = failed; - if (percentageEl) percentageEl.textContent = completionPercentage; - - if (data.status === 'finished') { - clearInterval(discoverSyncPollers[pollerId]); - delete discoverSyncPollers[pollerId]; - - const syncButton = document.getElementById(`genre-${genreId}-sync-btn`); - if (syncButton) { - syncButton.disabled = false; - syncButton.style.opacity = '1'; - syncButton.style.cursor = 'pointer'; - } - - showToast(`${capitalizeGenre(genreName)} Mix sync complete!`, 'success'); - - setTimeout(() => { - const statusDisplay = document.getElementById(`genre-${genreId}-sync-status`); - if (statusDisplay) statusDisplay.style.display = 'none'; - }, 3000); - } - } catch (error) { - console.error(`Error polling sync status for genre ${genreName}:`, error); - } - }, 500); -} - -async function openDownloadModalForGenre(genreName) { - const tracks = genreTracksCache[genreName]; - if (!tracks || tracks.length === 0) { - showToast('No tracks available for this genre', 'warning'); - return; - } - - // Convert to format expected by download modal - const spotifyTracks = tracks.map(track => { - // Extract track data from track_data_json if available - let trackData = track; - if (track.track_data_json) { - trackData = track.track_data_json; - } - - // Build properly formatted Spotify track object - let spotifyTrack = { - id: trackData.id || track.spotify_track_id, - name: trackData.name || trackData.track_name || track.track_name, - artists: trackData.artists || [{ name: trackData.artist_name || track.artist_name }], - album: trackData.album || { - name: trackData.album_name || track.album_name, - images: trackData.album?.images || (track.album_cover_url ? [{ url: track.album_cover_url }] : []) - }, - duration_ms: trackData.duration_ms || track.duration_ms || 0 - }; - - return spotifyTrack; - }); - - const playlistName = `${capitalizeGenre(genreName)} Mix`; - const virtualPlaylistId = `genre_${genreName.replace(/\s+/g, '_')}`; - - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); -} - -// =============================== -// LISTENBRAINZ PLAYLISTS -// =============================== - -let listenbrainzPlaylistsCache = {}; // Store playlists by type -let listenbrainzTracksCache = {}; // Store tracks for each playlist -let activeListenBrainzTab = 'recommendations'; // Track active tab -let activeListenBrainzSubTab = null; // Track active sub-tab within recommendations - -// ── Last.fm Track Radio ────────────────────────────────────────────────────── - -let _lastfmRadioDebounceTimer = null; -let _lastfmRadioSelected = null; // {name, artist} - -function debouncedLastfmTrackSearch(query) { - clearTimeout(_lastfmRadioDebounceTimer); - const q = (query || '').trim(); - if (!q) { - document.getElementById('lastfm-radio-dropdown').style.display = 'none'; - return; - } - _lastfmRadioDebounceTimer = setTimeout(() => _runLastfmTrackSearch(q), 400); -} - -async function _runLastfmTrackSearch(q) { - if (q.length < 2) return; - const dropdown = document.getElementById('lastfm-radio-dropdown'); - // Show a mini spinner while fetching - dropdown.innerHTML = '
'; - dropdown.style.display = 'block'; - try { - const res = await fetch(`/api/lastfm/search/tracks?q=${encodeURIComponent(q)}`); - if (!res.ok) { dropdown.style.display = 'none'; return; } - const data = await res.json(); - if (!data.results || data.results.length === 0) { - dropdown.style.display = 'none'; - return; - } - dropdown.innerHTML = data.results.map(t => { - const imgHtml = t.image_url - ? `` - : '
'; - const listeners = t.listeners > 0 - ? `${(t.listeners / 1000).toFixed(0)}k listeners` - : ''; - return ` -
-
${imgHtml}
-
- ${t.name} - ${t.artist}${listeners ? ' · ' + t.listeners.toLocaleString() + ' listeners' : ''} -
-
`; - }).join(''); - dropdown.style.display = 'block'; - } catch (e) { - console.error('Last.fm search error:', e); - dropdown.style.display = 'none'; - } -} - -function selectLastfmRadioTrack(name, artist) { - // Close dropdown and update input to show selection - document.getElementById('lastfm-radio-dropdown').style.display = 'none'; - document.getElementById('lastfm-radio-input').value = `${name} — ${artist}`; - document.getElementById('lastfm-radio-input').blur(); - // Immediately kick off generation - _generateLastfmRadioFor(name, artist); -} - -function clearLastfmRadioSelection() { - document.getElementById('lastfm-radio-input').value = ''; - document.getElementById('lastfm-radio-dropdown').style.display = 'none'; -} - -// Keep generateLastfmRadio as public alias (called by nothing now but harmless) -async function generateLastfmRadio() { - const input = (document.getElementById('lastfm-radio-input').value || '').trim(); - if (!input) return; - // Parse "Track — Artist" format if present - const parts = input.split(' — '); - if (parts.length >= 2) { - await _generateLastfmRadioFor(parts[0].trim(), parts[1].trim()); - } -} - -async function _generateLastfmRadioFor(name, artist) { - const container = document.getElementById('lastfm-radio-playlists'); - const input = document.getElementById('lastfm-radio-input'); - - // Show loading state in the playlists area - if (container) { - container.innerHTML = ` -
-
-

Building radio for ${name} by ${artist}

-
`; - } - if (input) input.disabled = true; - - try { - const res = await fetch('/api/lastfm/radio/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_name: name, artist_name: artist }), - }); - const data = await res.json(); - if (!data.success) { - if (container) container.innerHTML = ''; - showToast(data.error || 'Failed to generate radio', 'error'); - return; - } - // Reload all radio playlist cards - await _loadLastfmRadioPlaylists(); - } catch (e) { - if (container) container.innerHTML = ''; - showToast('Error generating Last.fm radio', 'error'); - console.error(e); - } finally { - if (input) input.disabled = false; - } -} - -async function initializeLastfmRadioSection() { - try { - const cfgRes = await fetch('/api/lastfm/configured'); - if (!cfgRes.ok) return; - const { configured } = await cfgRes.json(); - const section = document.getElementById('lastfm-radio-section'); - if (!section) return; - if (!configured) { - section.style.display = 'none'; - return; - } - section.style.display = ''; - await _loadLastfmRadioPlaylists(); - } catch (e) { - console.error('Error initializing Last.fm Radio section:', e); - } -} - -async function _loadLastfmRadioPlaylists() { - const container = document.getElementById('lastfm-radio-playlists'); - if (!container) return; - try { - const res = await fetch('/api/discover/listenbrainz/lastfm-radio'); - if (!res.ok) return; - const data = await res.json(); - if (!data.success || !data.playlists || data.playlists.length === 0) { - container.innerHTML = ''; - return; - } - // Reuse the same LB playlist card builder — cards are identical - container.innerHTML = buildListenBrainzPlaylistsHtml(data.playlists, 'lastfm_radio'); - loadTracksForPlaylists(data.playlists); - } catch (e) { - console.error('Error loading Last.fm radio playlists:', e); - } -} - -// Close dropdown when clicking outside -document.addEventListener('click', (e) => { - const section = document.getElementById('lastfm-radio-search-section'); - if (section && !section.contains(e.target)) { - const dd = document.getElementById('lastfm-radio-dropdown'); - if (dd) dd.style.display = 'none'; - } -}); - -// ──────────────────────────────────────────────────────────────────────────── - -async function initializeListenBrainzTabs() { - try { - console.log('🧠 Initializing ListenBrainz tabs...'); - - // Fetch all playlist types - const [createdForRes, userPlaylistsRes, collaborativeRes] = await Promise.all([ - fetch('/api/discover/listenbrainz/created-for'), - fetch('/api/discover/listenbrainz/user-playlists'), - fetch('/api/discover/listenbrainz/collaborative'), - ]); - - console.log('📡 API Responses:', { - createdFor: createdForRes.status, - userPlaylists: userPlaylistsRes.status, - collaborative: collaborativeRes.status, - }); - - const tabs = [ - { id: 'recommendations', label: '🎁 Recommendations', hasData: false }, - { id: 'user', label: '📚 Your Playlists', hasData: false }, - { id: 'collaborative', label: '🤝 Collaborative', hasData: false }, - ]; - - // Track LB username for header display - let lbUsername = null; - - // Check which tabs have data - if (createdForRes.ok) { - const data = await createdForRes.json(); - console.log('📋 Created For data:', data); - if (data.username) lbUsername = data.username; - if (data.success && data.playlists && data.playlists.length > 0) { - listenbrainzPlaylistsCache['recommendations'] = data.playlists; - tabs[0].hasData = true; - console.log(`✅ Found ${data.playlists.length} recommendation playlists`); - } - } - - if (userPlaylistsRes.ok) { - const data = await userPlaylistsRes.json(); - console.log('📚 User Playlists data:', data); - if (data.username && !lbUsername) lbUsername = data.username; - if (data.success && data.playlists && data.playlists.length > 0) { - listenbrainzPlaylistsCache['user'] = data.playlists; - tabs[1].hasData = true; - console.log(`✅ Found ${data.playlists.length} user playlists`); - } - } - - if (collaborativeRes.ok) { - const data = await collaborativeRes.json(); - console.log('🤝 Collaborative data:', data); - if (data.username && !lbUsername) lbUsername = data.username; - if (data.success && data.playlists && data.playlists.length > 0) { - listenbrainzPlaylistsCache['collaborative'] = data.playlists; - tabs[2].hasData = true; - console.log(`✅ Found ${data.playlists.length} collaborative playlists`); - } - } - - // Build tabs HTML - const tabsContainer = document.getElementById('listenbrainz-tabs'); - console.log('🔧 Building tabs. Available tabs:', tabs.filter(t => t.hasData).map(t => t.label)); - - let tabsHtml = '
'; // Reuse decade tabs styling - - tabs.forEach(tab => { - if (tab.hasData) { - const isActive = tab.id === activeListenBrainzTab; - tabsHtml += ` - - `; - } - }); - tabsHtml += '
'; - - if (tabs.every(t => !t.hasData)) { - console.log('⚠️ No tabs have data'); - tabsContainer.innerHTML = ` -
-
🧠
-

Connect ListenBrainz

-

Link your ListenBrainz account to see personalized playlists, recommendations, and collaborative playlists.

- -

Get your token from listenbrainz.org/profile

-
`; - return; - } - - tabsContainer.innerHTML = tabsHtml; - - // Update section subtitle with username - const lbSubtitle = document.getElementById('listenbrainz-section-subtitle'); - if (lbSubtitle) { - lbSubtitle.textContent = lbUsername ? `Playlists for ${lbUsername}` : 'Playlists from ListenBrainz'; - } - - // Load first available tab - const firstTab = tabs.find(t => t.hasData); - if (firstTab) { - console.log(`🎯 Loading first tab: ${firstTab.label} (${firstTab.id})`); - activeListenBrainzTab = firstTab.id; - loadListenBrainzTabContent(firstTab.id); - } else { - console.log('❌ No first tab found'); - } - - } catch (error) { - console.error('Error initializing ListenBrainz tabs:', error); - const tabsContainer = document.getElementById('listenbrainz-tabs'); - if (tabsContainer) { - tabsContainer.innerHTML = '

Failed to load playlists

'; - } - } -} - -function switchListenBrainzTab(tabId) { - // Update active tab - activeListenBrainzTab = tabId; - - // Update tab buttons - const tabs = document.querySelectorAll('#listenbrainz-tabs .decade-tab'); - tabs.forEach(tab => { - if (tab.dataset.tab === tabId) { - tab.classList.add('active'); - } else { - tab.classList.remove('active'); - } - }); - - // Load content - loadListenBrainzTabContent(tabId); -} - -function groupListenBrainzPlaylists(playlists) { - const groups = {}; - const groupOrder = []; - - playlists.forEach(playlist => { - const playlistData = playlist.playlist || playlist; - const title = (playlistData.title || '').toLowerCase(); - - let groupName; - if (title.includes('weekly jams')) { - groupName = 'Weekly Jams'; - } else if (title.includes('weekly exploration')) { - groupName = 'Weekly Exploration'; - } else if (title.includes('top discoveries')) { - groupName = 'Top Discoveries'; - } else if (title.includes('top missed recordings')) { - groupName = 'Top Missed Recordings'; - } else if (title.includes('daily jams')) { - groupName = 'Daily Jams'; - } else { - groupName = 'Other'; - } - - if (!groups[groupName]) { - groups[groupName] = []; - groupOrder.push(groupName); - } - groups[groupName].push(playlist); - }); - - // Move "Other" to the end if it exists - const otherIdx = groupOrder.indexOf('Other'); - if (otherIdx !== -1 && otherIdx !== groupOrder.length - 1) { - groupOrder.splice(otherIdx, 1); - groupOrder.push('Other'); - } - - return { groups, groupOrder }; -} - -function buildListenBrainzPlaylistsHtml(playlists, tabId) { - let html = ''; - playlists.forEach((playlist, index) => { - const playlistData = playlist.playlist || playlist; - const identifier = playlistData.identifier?.split('/').pop() || ''; - console.log(`📋 Playlist ${index}:`, { - title: playlistData.title, - fullIdentifier: playlistData.identifier, - extractedIdentifier: identifier - }); - const title = playlistData.title || 'Untitled Playlist'; - const creator = playlistData.creator || 'ListenBrainz'; - - let trackCount = 50; - if (playlistData.annotation?.track_count && playlistData.annotation.track_count > 0) { - trackCount = playlistData.annotation.track_count; - } else if (playlistData.track && Array.isArray(playlistData.track) && playlistData.track.length > 0) { - trackCount = playlistData.track.length; - } - - const playlistId = `discover-lb-playlist-${identifier}`; // Use consistent MBID-based ID - const virtualPlaylistId = `discover_lb_${tabId}_${identifier}`; - - html += ` -
-
-
-

${title}

- -
-
- - - - - -
-
- - -
-

Loading tracks...

-
-
- `; - }); - return html; -} - -function loadTracksForPlaylists(playlists) { - playlists.forEach((playlist) => { - const playlistData = playlist.playlist || playlist; - const identifier = playlistData.identifier?.split('/').pop() || ''; - const playlistId = `discover-lb-playlist-${identifier}`; - loadListenBrainzPlaylistTracks(identifier, playlistId); - }); -} - -function switchListenBrainzSubTab(groupId) { - activeListenBrainzSubTab = groupId; - - // Update sub-tab buttons - const subTabs = document.querySelectorAll('#lb-subtabs-bar .lb-subtab'); - subTabs.forEach(tab => { - if (tab.dataset.group === groupId) { - tab.classList.add('active'); - } else { - tab.classList.remove('active'); - } - }); - - // Show/hide sub-tab content panels - const panels = document.querySelectorAll('.lb-subtab-panel'); - panels.forEach(panel => { - if (panel.dataset.group === groupId) { - panel.style.display = 'block'; - // Load tracks for playlists in this panel if not already loaded - const unloaded = panel.querySelectorAll('.discover-loading'); - if (unloaded.length > 0) { - const groupPlaylists = panel._playlists; - if (groupPlaylists) { - loadTracksForPlaylists(groupPlaylists); - } - } - } else { - panel.style.display = 'none'; - } - }); -} - -async function loadListenBrainzTabContent(tabId) { - const container = document.getElementById('listenbrainz-tab-content'); - if (!container) return; - - const playlists = listenbrainzPlaylistsCache[tabId] || []; - if (playlists.length === 0) { - container.innerHTML = '

No playlists in this category

'; - return; - } - - // For recommendations tab with multiple playlists, group into sub-tabs - if (tabId === 'recommendations' && playlists.length > 1) { - const { groups, groupOrder } = groupListenBrainzPlaylists(playlists); - - // If only one group, no need for sub-tabs - if (groupOrder.length <= 1) { - const html = buildListenBrainzPlaylistsHtml(playlists, tabId); - container.innerHTML = html; - loadTracksForPlaylists(playlists); - return; - } - - // Build sub-tabs bar - const firstGroup = activeListenBrainzSubTab && groupOrder.includes(activeListenBrainzSubTab) - ? activeListenBrainzSubTab - : groupOrder[0]; - activeListenBrainzSubTab = firstGroup; - - let subTabsHtml = '
'; - groupOrder.forEach(groupName => { - const isActive = groupName === firstGroup; - const count = groups[groupName].length; - subTabsHtml += ` - - `; - }); - subTabsHtml += '
'; - - // Build content panels for each group - let panelsHtml = ''; - groupOrder.forEach(groupName => { - const isActive = groupName === firstGroup; - panelsHtml += `
`; - panelsHtml += buildListenBrainzPlaylistsHtml(groups[groupName], tabId); - panelsHtml += '
'; - }); - - container.innerHTML = subTabsHtml + panelsHtml; - - // Store playlist references on panels for lazy loading - groupOrder.forEach(groupName => { - const panel = container.querySelector(`.lb-subtab-panel[data-group="${groupName}"]`); - if (panel) { - panel._playlists = groups[groupName]; - } - }); - - // Load tracks only for the active sub-tab - loadTracksForPlaylists(groups[firstGroup]); - return; - } - - // Default: flat list for user/collaborative tabs (or single-group recommendations) - const html = buildListenBrainzPlaylistsHtml(playlists, tabId); - container.innerHTML = html; - loadTracksForPlaylists(playlists); -} - -async function loadListenBrainzPlaylistTracks(identifier, playlistId) { - try { - const playlistContainer = document.getElementById(`${playlistId}-playlist`); - if (!playlistContainer) return; - - // Check cache first - if (listenbrainzTracksCache[identifier]) { - displayListenBrainzTracks(listenbrainzTracksCache[identifier], playlistId); - return; - } - - console.log(`🔄 Fetching tracks for playlist: ${identifier}`); - const response = await fetch(`/api/discover/listenbrainz/playlist/${identifier}`); - console.log(`📡 Response status: ${response.status}`); - - if (!response.ok) { - const errorText = await response.text(); - console.error(`❌ Failed to fetch playlist: ${response.status} - ${errorText}`); - throw new Error('Failed to fetch playlist tracks'); - } - - const data = await response.json(); - console.log(`📋 Received data:`, data); - console.log(`📊 Tracks count: ${data.tracks?.length || 0}`); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No tracks available

'; - return; - } - - // Cache the tracks - listenbrainzTracksCache[identifier] = data.tracks; - - // Display tracks - displayListenBrainzTracks(data.tracks, playlistId); - - } catch (error) { - console.error('Error loading ListenBrainz playlist tracks:', error); - const playlistContainer = document.getElementById(`${playlistId}-playlist`); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load tracks

'; - } - } -} - -/** - * Clean artist name by removing featured artists - * e.g., "Blackstreet feat. Dr. Dre & Queen Pen" -> "Blackstreet" - */ -function cleanArtistName(artistName) { - if (!artistName) return artistName; - - // Remove everything after common featuring patterns (case insensitive) - const patterns = [ - /\s+feat\.?\s+.*/i, // "feat." or "feat" - /\s+featuring\s+.*/i, // "featuring" - /\s+ft\.?\s+.*/i, // "ft." or "ft" - /\s+with\s+.*/i, // "with" - /\s+x\s+.*/i // " x " (common in collaborations) - ]; - - let cleaned = artistName; - for (const pattern of patterns) { - cleaned = cleaned.replace(pattern, ''); - } - - return cleaned.trim(); -} - -function displayListenBrainzTracks(tracks, playlistId) { - const playlistContainer = document.getElementById(`${playlistId}-playlist`); - if (!playlistContainer) return; - - console.log(`🎨 Displaying ${tracks.length} tracks for ${playlistId}`); - if (tracks.length > 0) { - console.log('Sample track data:', tracks[0]); - } - - // Update track count in the metadata section - const metaElement = document.getElementById(`${playlistId}-meta`); - if (metaElement) { - // Extract creator from existing text (before the bullet) - const currentText = metaElement.textContent; - const creatorMatch = currentText.match(/by (.+?) •/); - const creator = creatorMatch ? creatorMatch[1] : 'ListenBrainz'; - metaElement.textContent = `by ${creator} • ${tracks.length} track${tracks.length !== 1 ? 's' : ''}`; - } - - // Simple SVG placeholder for missing album art (music note icon) - const placeholderImage = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiMyYTJhMmEiLz48cGF0aCBkPSJNMjQgMTJ2MTIuNUEzLjUgMy41IDAgMSAxIDIwLjUgMjFWMTZsLTUgMXY5YTMuNSAzLjUgMCAxIDEtMy41LTMuNVYxM2wxMi0zeiIgZmlsbD0iIzU1NSIvPjwvc3ZnPg=='; - - let html = '
'; - tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || placeholderImage; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = track.duration_ms > 0 ? `${durationMin}:${durationSec.toString().padStart(2, '0')}` : ''; - - const albumName = track.album_name ? escapeHtml(track.album_name) : ''; - - html += ` -
-
${index + 1}
-
- ${albumName} -
-
-
${escapeHtml(track.track_name || 'Unknown Track')}
-
${escapeHtml(cleanArtistName(track.artist_name) || 'Unknown Artist')}
-
-
${albumName}
-
${duration}
-
- `; - }); - html += '
'; - - playlistContainer.innerHTML = html; -} - -function _toggleWingItDropdownLB(btn, identifier, title) { - const existing = document.querySelector('.wing-it-dropdown.visible'); - if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; } - - const wrap = btn.closest('.wing-it-wrap'); - if (!wrap) return; - - const dropdown = document.createElement('div'); - dropdown.className = 'wing-it-dropdown'; - dropdown.innerHTML = ` - - - `; - - dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => { - item.addEventListener('click', () => { - dropdown.classList.remove('visible'); - setTimeout(() => dropdown.remove(), 150); - const tracks = listenbrainzTracksCache[identifier]; - if (!tracks || tracks.length === 0) { - showToast('No tracks cached. Try opening the playlist first.', 'error'); - return; - } - if (item.dataset.action === 'download') { - wingItDownload(tracks, title, 'ListenBrainz', identifier, true); - } else { - _wingItSync(tracks, title, 'ListenBrainz', identifier); - } - }); - }); - - const btnRect2 = btn.getBoundingClientRect(); - if (btnRect2.top < 200) dropdown.classList.add('flip-down'); - - wrap.appendChild(dropdown); - requestAnimationFrame(() => dropdown.classList.add('visible')); - - setTimeout(() => { - const closeHandler = e => { - if (!dropdown.contains(e.target) && e.target !== btn) { - dropdown.classList.remove('visible'); - setTimeout(() => dropdown.remove(), 150); - document.removeEventListener('click', closeHandler); - } - }; - document.addEventListener('click', closeHandler); - }, 50); -} - -async function _wingItFromLBCard(identifier, title) { - // Legacy — kept for backward compat - const tracks = listenbrainzTracksCache[identifier]; - if (!tracks || tracks.length === 0) { - showToast('No tracks cached for this playlist. Try opening the discovery modal first.', 'error'); - return; - } - wingItDownload(tracks, title, 'ListenBrainz', identifier); -} - -async function openDownloadModalForListenBrainzPlaylist(identifier, title) { - try { - const tracks = listenbrainzTracksCache[identifier]; - if (!tracks || tracks.length === 0) { - showToast('No tracks to download', 'error'); - return; - } - - console.log(`🎵 Opening ListenBrainz discovery modal: ${title}`); - console.log(`🔍 Looking for existing state with identifier: ${identifier}`); - console.log(`📋 All ListenBrainz states:`, Object.keys(listenbrainzPlaylistStates)); - - // Check if state already exists from backend hydration (like Beatport does) - const existingState = listenbrainzPlaylistStates[identifier]; - console.log(`🔍 Existing state found:`, existingState ? `Phase: ${existingState.phase}` : 'None'); - - if (existingState && existingState.phase !== 'fresh') { - // State exists - rehydrate the modal with existing data - console.log(`🔄 Rehydrating existing ListenBrainz state (Phase: ${existingState.phase})`); - - // If downloading/download_complete, rehydrate download modal instead - if ((existingState.phase === 'downloading' || existingState.phase === 'download_complete') && - existingState.convertedSpotifyPlaylistId && existingState.download_process_id) { - - console.log(`📥 Rehydrating download modal for ListenBrainz playlist: ${title}`); - - // Implement download modal rehydration (like Beatport does) - const convertedPlaylistId = existingState.convertedSpotifyPlaylistId; - - try { - // Check if modal already exists (user just closed it) - if (activeDownloadProcesses[convertedPlaylistId]) { - console.log(`✅ Download modal already exists, just showing it`); - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process.modalElement) { - process.modalElement.style.display = 'flex'; - } - return; - } - - // Create the download modal using the ListenBrainz state - console.log(`🆕 Creating new download modal for rehydration`); - // Get tracks from the existing state - let spotifyTracks = []; - - if (existingState && existingState.discovery_results) { - spotifyTracks = existingState.discovery_results - .filter(result => result.spotify_data) - .map(result => { - const track = result.spotify_data; - // Ensure artists is an array of strings - if (track.artists && Array.isArray(track.artists)) { - track.artists = track.artists.map(artist => - typeof artist === 'string' ? artist : (artist.name || artist) - ); - } else if (track.artists && typeof track.artists === 'string') { - track.artists = [track.artists]; - } else { - track.artists = ['Unknown Artist']; - } - return { - id: track.id, - name: track.name, - artists: track.artists, - album: track.album || 'Unknown Album', - duration_ms: track.duration_ms || 0, - external_urls: track.external_urls || {} - }; - }); - } - - if (spotifyTracks.length > 0) { - await openDownloadMissingModalForYouTube( - convertedPlaylistId, - title, - spotifyTracks - ); - - // Set the modal to running state with the correct batch ID - const process = activeDownloadProcesses[convertedPlaylistId]; - if (process) { - process.status = existingState.phase === 'download_complete' ? 'complete' : 'running'; - process.batchId = existingState.download_process_id; - - // Update UI to running state - const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for this process - startModalDownloadPolling(convertedPlaylistId); - - // Add to discover download sidebar if this has discoverMetadata - if (process.discoverMetadata) { - const playlistName = title; - const imageUrl = process.discoverMetadata.imageUrl; - const type = process.discoverMetadata.type || 'album'; - addDiscoverDownload(convertedPlaylistId, playlistName, type, imageUrl); - console.log(`📥 [REHYDRATION] Added ListenBrainz download to sidebar: ${playlistName}`); - } - - // Show modal since user clicked the download button (different from background rehydration) - if (process.modalElement) { - process.modalElement.style.display = 'flex'; - } - console.log(`✅ Rehydrated download modal for ListenBrainz playlist: ${title}`); - } - } else { - console.warn(`⚠️ No Spotify tracks found for ListenBrainz download modal: ${title}`); - } - } catch (error) { - console.warn(`⚠️ Error setting up download process for ListenBrainz playlist "${title}":`, error.message); - } - - return; - } - - // Open discovery modal with existing state - openYouTubeDiscoveryModal(identifier); - - // If still discovering, resume polling - if (existingState.phase === 'discovering') { - console.log(`🔄 Resuming discovery polling for: ${title}`); - startListenBrainzDiscoveryPolling(identifier); - } - - return; - } - - // No existing state - create fresh state and start discovery - console.log(`🆕 Creating fresh ListenBrainz state for: ${title}`); - - // Create YouTube-style state entry for this ListenBrainz playlist (like Beatport does) - const listenbrainzState = { - phase: 'fresh', - playlist: { - name: title, - tracks: tracks.map(track => ({ - track_name: track.track_name, - artist_name: track.artist_name, - album_name: track.album_name, - duration_ms: track.duration_ms || 0, - mbid: track.mbid, - release_mbid: track.release_mbid, - album_cover_url: track.album_cover_url - })), - description: `${tracks.length} tracks from ${title}`, - source: 'listenbrainz' - }, - is_listenbrainz_playlist: true, - playlist_mbid: identifier, // Link to ListenBrainz playlist - // Initialize discovery state properties (both naming conventions for modal compatibility) - discovery_results: [], - discoveryResults: [], - discovery_progress: 0, - discoveryProgress: 0, - spotify_matches: 0, - spotifyMatches: 0, - spotify_total: tracks.length, - spotifyTotal: tracks.length - }; - - // Store in ListenBrainz playlist states - listenbrainzPlaylistStates[identifier] = listenbrainzState; - - // Start discovery automatically (like Beatport and Tidal do) - try { - console.log(`🔍 Starting ListenBrainz discovery for: ${title}`); - - // Call the discovery start endpoint with playlist data - const response = await fetch(`/api/listenbrainz/discovery/start/${identifier}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - playlist: listenbrainzState.playlist - }) - }); - - const result = await response.json(); - if (result.success) { - // Update state to discovering - listenbrainzPlaylistStates[identifier].phase = 'discovering'; - - // Start polling for progress - startListenBrainzDiscoveryPolling(identifier); - - console.log(`✅ Started ListenBrainz discovery for: ${title}`); - } else { - console.error('❌ Error starting ListenBrainz discovery:', result.error); - showToast(`Error starting discovery: ${result.error}`, 'error'); - } - } catch (error) { - console.error('❌ Error starting ListenBrainz discovery:', error); - showToast(`Error starting discovery: ${error.message}`, 'error'); - } - - // Open the existing YouTube discovery modal infrastructure - openYouTubeDiscoveryModal(identifier); - - console.log(`✅ ListenBrainz discovery modal opened for ${title} with ${tracks.length} tracks`); - - } catch (error) { - console.error('Error opening discovery modal for ListenBrainz playlist:', error); - showToast('Failed to open discovery modal', 'error'); - } -} - -async function openListenBrainzPlaylist(playlistMbid, playlistName) { - try { - showLoadingOverlay(`Loading ${playlistName}...`); - - const response = await fetch(`/api/discover/listenbrainz/playlist/${playlistMbid}`); - if (!response.ok) { - throw new Error('Failed to fetch playlist'); - } - - const data = await response.json(); - if (!data.success || !data.playlist) { - showToast('Failed to load playlist', 'error'); - hideLoadingOverlay(); - return; - } - - const playlist = data.playlist; - const tracks = playlist.tracks || []; - - if (tracks.length === 0) { - showToast('This playlist is empty', 'info'); - hideLoadingOverlay(); - return; - } - - // Convert to Spotify-like format for compatibility with download modal - const spotifyTracks = tracks.map(track => ({ - id: track.recording_mbid || `listenbrainz_${track.title}_${track.creator}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing - name: track.title || 'Unknown', - artists: [{ name: cleanArtistName(track.creator || 'Unknown') }], // Proper Spotify format - album: { - name: track.album || 'Unknown Album', - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0, - listenbrainz_metadata: track.additional_metadata - })); - - const virtualPlaylistId = `listenbrainz_${playlistMbid}`; - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - hideLoadingOverlay(); - - } catch (error) { - console.error(`Error opening ListenBrainz playlist:`, error); - showToast(`Failed to load playlist`, 'error'); - hideLoadingOverlay(); - } -} - -async function refreshListenBrainzPlaylists() { - const button = document.getElementById('listenbrainz-refresh-btn'); - if (!button) return; - - try { - // Show loading state on button - const originalContent = button.innerHTML; - button.disabled = true; - button.innerHTML = 'Refreshing...'; - - console.log('🔄 Refreshing ListenBrainz playlists...'); - showToast('Refreshing ListenBrainz playlists...', 'info'); - - const response = await fetch('/api/discover/listenbrainz/refresh', { - method: 'POST' - }); - - if (!response.ok) { - throw new Error(`Failed to refresh: ${response.statusText}`); - } - - const data = await response.json(); - - if (data.success) { - const summary = data.summary || {}; - let message = 'ListenBrainz playlists refreshed!'; - - // Build summary message - const updates = []; - for (const [type, stats] of Object.entries(summary)) { - const total = (stats.new || 0) + (stats.updated || 0); - if (total > 0) { - updates.push(`${total} ${type}`); - } - } - - if (updates.length > 0) { - message += ` Updated: ${updates.join(', ')}`; - } else { - message = 'All playlists are up to date'; - } - - console.log('✅ Refresh complete:', data.summary); - showToast(message, 'success'); - - // Reload the tabs to show updated data - await initializeListenBrainzTabs(); - - } else { - throw new Error(data.error || 'Unknown error'); - } - - // Restore button - button.disabled = false; - button.innerHTML = originalContent; - - } catch (error) { - console.error('Error refreshing ListenBrainz playlists:', error); - showToast(`Failed to refresh: ${error.message}`, 'error'); - - // Restore button - button.disabled = false; - button.innerHTML = '🔄Refresh'; - } -} - -// =============================== -// SEASONAL DISCOVERY -// =============================== - -async function loadSeasonalContent() { - try { - const response = await fetch('/api/discover/seasonal/current'); - if (!response.ok) { - console.error('Failed to fetch seasonal content'); - return; - } - - const data = await response.json(); - - // If no active season, hide seasonal sections - if (!data.success || !data.season) { - hideSeasonalSections(); - return; - } - - currentSeasonKey = data.season; - - // Load seasonal albums - await loadSeasonalAlbums(data); - - // Load seasonal playlist if available - if (data.playlist_available) { - await loadSeasonalPlaylist(data); - } - - } catch (error) { - console.error('Error loading seasonal content:', error); - hideSeasonalSections(); - } -} - -async function loadSeasonalAlbums(seasonData) { - try { - const carousel = document.getElementById('seasonal-albums-carousel'); - if (!carousel) return; - - // Show seasonal section - const seasonalSection = document.getElementById('seasonal-albums-section'); - if (seasonalSection) { - seasonalSection.style.display = 'block'; - } - - // Update header - const seasonalTitle = document.getElementById('seasonal-albums-title'); - const seasonalSubtitle = document.getElementById('seasonal-albums-subtitle'); - - if (seasonalTitle) { - seasonalTitle.textContent = `${seasonData.icon} ${seasonData.name}`; - } - if (seasonalSubtitle) { - seasonalSubtitle.textContent = seasonData.description; - } - - // Store albums for download functionality - discoverSeasonalAlbums = seasonData.albums || []; - - if (discoverSeasonalAlbums.length === 0) { - carousel.innerHTML = '

No seasonal albums found

'; - return; - } - - // Build carousel HTML - let html = ''; - discoverSeasonalAlbums.forEach((album, index) => { - const coverUrl = album.album_cover_url || '/static/placeholder-album.png'; - html += ` -
-
- ${album.album_name} -
-
-

${album.album_name}

-

${album.artist_name}

- ${album.release_date ? `

${album.release_date}

` : ''} -
-
- `; - }); - - carousel.innerHTML = html; - - } catch (error) { - console.error('Error loading seasonal albums:', error); - } -} - -async function loadSeasonalPlaylist(seasonData) { - try { - const playlistContainer = document.getElementById('seasonal-playlist'); - if (!playlistContainer) return; - - // Show seasonal playlist section - const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section'); - if (seasonalPlaylistSection) { - seasonalPlaylistSection.style.display = 'block'; - } - - // Update header - const playlistTitle = document.getElementById('seasonal-playlist-title'); - const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle'); - - if (playlistTitle) { - playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`; - } - if (playlistSubtitle) { - playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`; - } - - playlistContainer.innerHTML = '

Loading playlist...

'; - - // Fetch playlist tracks - const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`); - if (!response.ok) { - throw new Error('Failed to fetch seasonal playlist'); - } - - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - playlistContainer.innerHTML = '

No tracks available yet

'; - return; - } - - // Store tracks for download/sync functionality - discoverSeasonalTracks = data.tracks; - - // Build compact playlist HTML - let html = '
'; - data.tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; - - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
-
- `; - }); - html += '
'; - - playlistContainer.innerHTML = html; - - } catch (error) { - console.error('Error loading seasonal playlist:', error); - const playlistContainer = document.getElementById('seasonal-playlist'); - if (playlistContainer) { - playlistContainer.innerHTML = '

Failed to load playlist

'; - } - } -} - -function hideSeasonalSections() { - const seasonalAlbumsSection = document.getElementById('seasonal-albums-section'); - const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section'); - - if (seasonalAlbumsSection) { - seasonalAlbumsSection.style.display = 'none'; - } - if (seasonalPlaylistSection) { - seasonalPlaylistSection.style.display = 'none'; - } -} - -async function openDownloadModalForSeasonalAlbum(albumIndex) { - const album = discoverSeasonalAlbums[albumIndex]; - if (!album) { - showToast('Album data not found', 'error'); - return; - } - - console.log(`📥 Opening Download Missing Tracks modal for seasonal album: ${album.album_name}`); - showLoadingOverlay(`Loading tracks for ${album.album_name}...`); - - try { - // Determine source and album ID - use source-agnostic endpoint (matches Recent Releases) - const source = album.source || (album.spotify_album_id && !album.spotify_album_id.match(/^\d+$/) ? 'spotify' : 'itunes'); - const albumId = album.spotify_album_id; - - if (!albumId) { - throw new Error('No album ID available'); - } - - // Fetch album tracks from appropriate source (pass name/artist for Hydrabase support) - const _dap1 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap1}`); - if (!response.ok) { - throw new Error('Failed to fetch album tracks'); - } - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - throw new Error('No tracks found in album'); - } - - // Convert to expected format with full album context (matches Recent Releases) - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; - if (Array.isArray(artists)) { - artists = artists.map(a => a.name || a); - } - - return { - id: track.id, - name: track.name, - artists: artists, - album: { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }, - duration_ms: track.duration_ms || 0, - track_number: track.track_number || 0 - }; - }); - - // Create virtual playlist ID - const virtualPlaylistId = `seasonal_album_${albumId}`; - - // Pass proper artist/album context for album download (1 worker + source reuse) - const artistContext = { - name: album.artist_name, - source: source - }; - - const albumContext = { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }; - - // Open download modal with album context (same as Recent Releases) - await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); - - hideLoadingOverlay(); - - } catch (error) { - console.error(`Error loading seasonal album: ${error.message}`); - hideLoadingOverlay(); - showToast(`Failed to load album tracks: ${error.message}`, 'error'); - } -} - -async function openDownloadModalForSeasonalPlaylist() { - if (!discoverSeasonalTracks || discoverSeasonalTracks.length === 0) { - alert('No seasonal tracks available'); - return; - } - - // Convert to track format expected by modal - const tracks = discoverSeasonalTracks.map(track => ({ - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { name: track.album_name } - })); - - openDownloadMissingModal(tracks, `${currentSeasonKey} Seasonal Mix`); -} - -async function syncSeasonalPlaylist() { - if (!currentSeasonKey) { - alert('No active season'); - return; - } - - // Use the same sync logic as other discover playlists - // Create a virtual playlist ID for tracking - const virtualPlaylistId = `discover_seasonal_${currentSeasonKey}`; - - // Build playlist data from seasonal tracks - const playlistData = { - id: virtualPlaylistId, - name: `${currentSeasonKey.charAt(0).toUpperCase() + currentSeasonKey.slice(1)} Mix`, - tracks: discoverSeasonalTracks.map(track => ({ - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { name: track.album_name }, - duration_ms: track.duration_ms - })) - }; - - // Trigger sync (reuse existing sync infrastructure) - await syncPlaylistToLibrary(playlistData); -} - -// =============================== -// PERSONALIZED PLAYLISTS -// =============================== - -async function loadPersonalizedRecentlyAdded() { - try { - const container = document.getElementById('personalized-recently-added'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/recently-added'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedRecentlyAdded = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading recently added:', error); - } -} - -async function loadPersonalizedTopTracks() { - try { - const container = document.getElementById('personalized-top-tracks'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/top-tracks'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedTopTracks = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading top tracks:', error); - } -} - -async function loadPersonalizedForgottenFavorites() { - try { - const container = document.getElementById('personalized-forgotten-favorites'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/forgotten-favorites'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedForgottenFavorites = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading forgotten favorites:', error); - } -} - -async function loadPersonalizedPopularPicks() { - try { - const container = document.getElementById('personalized-popular-picks'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/popular-picks'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedPopularPicks = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading popular picks:', error); - } -} - -async function loadPersonalizedHiddenGems() { - try { - const container = document.getElementById('personalized-hidden-gems'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/hidden-gems'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedHiddenGems = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading hidden gems:', error); - } -} - -async function loadPersonalizedDailyMixes() { - try { - const container = document.getElementById('daily-mixes-grid'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/daily-mixes'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.mixes || data.mixes.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedDailyMixes = data.mixes; - - // Render Daily Mix cards - let html = ''; - data.mixes.forEach((mix, index) => { - const coverUrl = mix.tracks && mix.tracks.length > 0 ? - (mix.tracks[0].album_cover_url || '/static/placeholder-album.png') : - '/static/placeholder-album.png'; - - html += ` -
-
- ${mix.name} -
-
-
-

${mix.name}

-

${mix.description}

-

${mix.track_count} tracks

-
-
- `; - }); - - container.innerHTML = html; - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading daily mixes:', error); - } -} - -function renderCompactPlaylist(container, tracks) { - let html = '
'; - - tracks.forEach((track, index) => { - const coverUrl = track.album_cover_url || '/static/placeholder-album.png'; - const durationMin = Math.floor(track.duration_ms / 60000); - const durationSec = Math.floor((track.duration_ms % 60000) / 1000); - const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`; - const artistEsc = (track.artist_name || '').replace(/'/g, "\\'").replace(/"/g, '"'); - - html += ` -
-
${index + 1}
-
- ${track.album_name} -
-
-
${track.track_name}
-
${track.artist_name}
-
-
${track.album_name}
-
${duration}
- -
- `; - }); - - html += '
'; - container.innerHTML = html; -} - -async function blockDiscoveryArtist(artistName) { - if (!confirm(`Block "${artistName}" from all discovery playlists?`)) return; - try { - const res = await fetch('/api/discover/artist-blacklist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_name: artistName }) - }); - const data = await res.json(); - if (data.success) { - showToast(`Blocked ${artistName} from discovery`, 'success'); - // Refresh all discovery sections to remove the artist - loadPersonalizedHiddenGems(); - loadDiscoveryShuffle(); - loadPersonalizedDailyMixes(); - } else { - showToast(data.error || 'Failed to block artist', 'error'); - } - } catch (e) { - showToast('Error blocking artist', 'error'); - } -} - -async function openDiscoveryBlacklistModal() { - if (document.getElementById('discovery-blacklist-modal-overlay')) return; - - const overlay = document.createElement('div'); - overlay.id = 'discovery-blacklist-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - overlay.innerHTML = ` -
-
-

Blocked Artists

-

These artists won't appear in any discovery playlist across all sources

- -
- -
-
Loading...
-
- -
- `; - document.body.appendChild(overlay); - - // Wire up search - let searchTimer = null; - const input = document.getElementById('dbl-search-input'); - input.addEventListener('input', () => { - clearTimeout(searchTimer); - const q = input.value.trim(); - if (q.length < 2) { document.getElementById('dbl-search-results').style.display = 'none'; return; } - searchTimer = setTimeout(() => _dblSearch(q), 300); - }); - - _dblLoadList(); -} - -async function _dblSearch(query) { - const resultsEl = document.getElementById('dbl-search-results'); - if (!resultsEl) return; - try { - // Use existing enhanced search to find artists - const res = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query, limit: 8 }) - }); - const data = await res.json(); - const artists = data.spotify_artists || data.artists || []; - if (artists.length === 0) { - resultsEl.innerHTML = '
No artists found
'; - resultsEl.style.display = 'block'; - return; - } - resultsEl.innerHTML = artists.map(a => { - const name = _escToast(a.name || ''); - const img = a.image_url ? `` : '
🎤
'; - return `
- ${img} - ${name} - Block -
`; - }).join(''); - resultsEl.style.display = 'block'; - } catch (e) { - resultsEl.style.display = 'none'; - } -} - -async function _dblBlockFromSearch(artistName) { - try { - const res = await fetch('/api/discover/artist-blacklist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_name: artistName }) - }); - const data = await res.json(); - if (data.success) { - showToast(`Blocked ${artistName} from discovery`, 'success'); - document.getElementById('dbl-search-results').style.display = 'none'; - const input = document.getElementById('dbl-search-input'); - if (input) input.value = ''; - _dblLoadList(); - } - } catch (e) { - showToast('Error blocking artist', 'error'); - } -} - -async function _dblLoadList() { - const container = document.getElementById('dbl-list'); - if (!container) return; - try { - const res = await fetch('/api/discover/artist-blacklist'); - const data = await res.json(); - if (!data.success || !data.entries || data.entries.length === 0) { - container.innerHTML = '
No blocked artists yet — search above to block one
'; - return; - } - container.innerHTML = data.entries.map(e => ` -
- ${_escToast(e.artist_name)} - ${e.created_at ? new Date(e.created_at).toLocaleDateString() : ''} - -
- `).join(''); - } catch (e) { - container.innerHTML = '
Failed to load
'; - } -} - -async function unblockDiscoveryArtist(id, name) { - try { - const res = await fetch(`/api/discover/artist-blacklist/${id}`, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - showToast(`Unblocked ${name}`, 'success'); - _dblLoadList(); - } - } catch (e) { - showToast('Error unblocking artist', 'error'); - } -} - -// Backwards compat — called during page init but now a no-op (modal handles it) -// ── Your Artists (Liked Artists Pool) ── - -async function loadYourArtists() { - const section = document.getElementById('your-artists-section'); - const carousel = document.getElementById('your-artists-carousel'); - const subtitle = document.getElementById('your-artists-subtitle'); - if (!section || !carousel) return; - - try { - const resp = await fetch('/api/discover/your-artists'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.artists || data.artists.length === 0) { - if (data.stale) { - // First load — show section with loading state, poll until ready - section.style.display = ''; - if (subtitle) subtitle.textContent = 'Discovering your artists across connected services...'; - carousel.innerHTML = ` -
-
- Fetching and matching artists from your services... -
- `; - _pollYourArtists(); - } else { - section.style.display = 'none'; - } - return; - } - - // Show section - section.style.display = ''; - - // Update subtitle with source info - const sources = new Set(); - data.artists.forEach(a => (a.source_services || []).forEach(s => sources.add(s))); - const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' }; - const sourceList = [...sources].map(s => sourceNames[s] || s).join(' and '); - if (subtitle) subtitle.textContent = `Artists you follow on ${sourceList || 'your music services'}`; - - if (data.stale) { - if (subtitle) subtitle.textContent += ' (updating...)'; - _pollYourArtists(); - } - - // Store for modal access and render carousel cards - window._yaArtists = {}; - window._yaActiveSource = data.active_source || 'spotify'; - data.artists.forEach(a => { window._yaArtists[a.id] = a; }); - carousel.innerHTML = data.artists.map(a => _renderYourArtistCard(a)).join(''); - - } catch (err) { - console.error('Error loading Your Artists:', err); - } -} - -function _pollYourArtists() { - // Poll every 5s until artists appear, then stop - if (window._yaPoller) clearInterval(window._yaPoller); - let attempts = 0; - window._yaPoller = setInterval(async () => { - attempts++; - if (attempts > 60) { clearInterval(window._yaPoller); window._yaPoller = null; return; } - try { - const resp = await fetch('/api/discover/your-artists'); - if (!resp.ok) return; - const data = await resp.json(); - if (data.artists && data.artists.length > 0) { - clearInterval(window._yaPoller); - window._yaPoller = null; - loadYourArtists(); // Re-render with real data - } - } catch (e) { } - }, 5000); -} - -function _renderYourArtistCard(artist) { - const _esc = (s) => escapeHtml(s || ''); - const img = artist.image_url || ''; - - // Build metadata source badges (same pattern as library page) - const badges = []; - if (artist.spotify_artist_id) badges.push({ logo: SPOTIFY_LOGO_URL, fb: 'SP', title: 'Spotify' }); - if (artist.itunes_artist_id) badges.push({ logo: ITUNES_LOGO_URL, fb: 'IT', title: 'Apple Music' }); - if (artist.deezer_artist_id) badges.push({ logo: DEEZER_LOGO_URL, fb: 'Dz', title: 'Deezer' }); - if (artist.discogs_artist_id) badges.push({ logo: DISCOGS_LOGO_URL, fb: 'DC', title: 'Discogs' }); - const badgeHTML = badges.map(b => - `
${b.logo ? `` : `${b.fb}`}
` - ).join(''); - - // Origin dots (which services the artist came from) - const sources = artist.source_services || []; - const sourceColors = { spotify: '#1DB954', lastfm: '#D51007', tidal: '#00FFFF', deezer: '#A238FF' }; - const originDots = sources.map(s => - `` - ).join(''); - - const watchlistClass = artist.on_watchlist ? 'active' : ''; - const hasId = artist.active_source_id && artist.active_source_id !== ''; - - // Navigate to artist page (name click) - const navAction = hasId - ? `event.stopPropagation(); navigateToPage('artists'); setTimeout(() => selectArtistForDetail({id:'${escapeForInlineJs(artist.active_source_id)}', name:'${escapeForInlineJs(artist.artist_name)}', image_url:'${escapeForInlineJs(img)}'}), 200)` - : ''; - - // Open info modal (card body click) — pass pool ID so we can look up all data - const infoAction = hasId - ? `openYourArtistInfoModal(${artist.id})` - : ''; - - // Deezer fallback for images - const deezerFb = artist.deezer_artist_id ? `onerror="if(!this.dataset.tried){this.dataset.tried='1';this.src='https://api.deezer.com/artist/${artist.deezer_artist_id}/image?size=big'}else{this.style.display='none';this.nextElementSibling.style.display='flex'}"` : `onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"`; - - return ` -
-
- ${img ? `` : ''} -
-
-
-
${badgeHTML}
- -
-
-
${originDots}
-
-
${_esc(artist.artist_name)}
-
-
- `; -} - -async function openYourArtistInfoModal(poolId) { - const pool = (window._yaArtists || {})[poolId]; - if (!pool) return; - - const artistId = pool.active_source_id; - const artistName = pool.artist_name; - const imageUrl = pool.image_url || ''; - - const existing = document.getElementById('ya-info-modal-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'ya-info-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.style.zIndex = '10001'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - // Build matched source badges from pool data - const _mb = (logo, fb, title) => `
${logo ? `` : `${fb}`}
`; - const matchBadges = []; - if (pool.spotify_artist_id) matchBadges.push(_mb(SPOTIFY_LOGO_URL, 'SP', 'Matched on Spotify')); - if (pool.itunes_artist_id) matchBadges.push(_mb(ITUNES_LOGO_URL, 'IT', 'Matched on Apple Music')); - if (pool.deezer_artist_id) matchBadges.push(_mb(DEEZER_LOGO_URL, 'Dz', 'Matched on Deezer')); - if (pool.discogs_artist_id) matchBadges.push(_mb(DISCOGS_LOGO_URL, 'DC', 'Matched on Discogs')); - - // Origin info - const sources = pool.source_services || []; - const sourceNames = { spotify: 'Spotify', lastfm: 'Last.fm', tidal: 'Tidal', deezer: 'Deezer' }; - const originText = sources.map(s => sourceNames[s] || s).join(', '); - - overlay.innerHTML = ` -
- -
-
-
-
- ${imageUrl ? `` : '
'} -
-
-

${escapeHtml(artistName)}

-
${matchBadges.join('')}
- ${originText ? `
Followed on ${escapeHtml(originText)}
` : ''} -
-
-
-
-
Loading artist info...
-
- -
- `; - document.body.appendChild(overlay); - - // Fetch enrichment data (with timeout) - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 8000); - const lookupId = artistId || encodeURIComponent(artistName); - const resp = await fetch(`/api/discover/your-artists/info/${lookupId}?name=${encodeURIComponent(artistName)}`, { signal: controller.signal }); - clearTimeout(timeout); - const artist = resp.ok ? await resp.json() : {}; - const bodyEl = document.getElementById('ya-info-body'); - const footerEl = document.getElementById('ya-info-footer'); - - const genres = artist.genres || []; - const bio = artist.summary || ''; - const listeners = artist.lastfm_listeners || artist.followers || 0; - const playcount = artist.lastfm_playcount || 0; - const popularity = artist.popularity || 0; - - let bodyHTML = ''; - - // Stats - if (listeners || playcount || popularity) { - bodyHTML += `
- ${listeners ? `
${Number(listeners).toLocaleString()}listeners
` : ''} - ${playcount ? `
${Number(playcount).toLocaleString()}plays
` : ''} - ${popularity ? `
${popularity}popularity
` : ''} -
`; - } - - // Genres - if (genres.length > 0) { - bodyHTML += `
-
${genres.map(g => `${escapeHtml(g)}`).join('')}
-
`; - } - - // Bio - if (bio) { - const cleanBio = bio.replace(/]*>.*?<\/a>/gi, '').replace(/<[^>]+>/g, '').trim(); - if (cleanBio) { - bodyHTML += `
-
About
-
${escapeHtml(cleanBio.length > 600 ? cleanBio.substring(0, 600) + '...' : cleanBio)}
-
`; - } - } - - // Related artists from map connections - const related = pool._related || []; - if (related.length > 0) { - const relLabel = pool.on_watchlist ? 'Similar Artists' : 'Connected To'; - bodyHTML += `
-
${relLabel}
- -
`; - } - - if (!bodyHTML) bodyHTML = '
No additional info available
'; - if (bodyEl) bodyEl.innerHTML = bodyHTML; - - // Footer - if (footerEl) { - const watchBtn = pool.on_watchlist - ? `` - : ``; - footerEl.innerHTML = ` - ${watchBtn} - - - `; - } - } catch (err) { - console.error('[Artist Info] Error loading artist info:', err); - const bodyEl = document.getElementById('ya-info-body'); - if (bodyEl) bodyEl.innerHTML = `
Could not load artist info
`; - } -} - -async function toggleYourArtistWatchlist(poolId, artistName, sourceId, source, btnEl) { - const isWatched = btnEl && btnEl.classList.contains('active'); - try { - if (isWatched) { - const resp = await fetch('/api/watchlist/remove', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: sourceId }) - }); - if (resp.ok) { - if (btnEl) { - btnEl.classList.remove('active'); - const svg = btnEl.querySelector('svg'); - if (svg) svg.setAttribute('fill', 'none'); - } - showToast(`Removed ${artistName} from watchlist`, 'info'); - // Sync card eye icon - _syncYaCardWatchlist(poolId, false); - } - } else { - const resp = await fetch('/api/watchlist/add', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artist_id: sourceId, artist_name: artistName, source: source }) - }); - if (resp.ok) { - if (btnEl) { - btnEl.classList.add('active'); - const svg = btnEl.querySelector('svg'); - if (svg) svg.setAttribute('fill', 'currentColor'); - } - showToast(`Added ${artistName} to watchlist`, 'success'); - _syncYaCardWatchlist(poolId, true); - } - } - } catch (err) { - showToast('Failed to update watchlist', 'error'); - } -} - -function _syncYaCardWatchlist(poolId, watched) { - // Sync the card's eye icon with watchlist state (covers modal → card sync) - document.querySelectorAll('.ya-card .ya-watchlist-btn').forEach(btn => { - const card = btn.closest('.ya-card'); - if (!card) return; - // Match by onclick containing the poolId - const onclick = btn.getAttribute('onclick') || ''; - if (onclick.includes(`(${poolId},`)) { - if (watched) { - btn.classList.add('active'); - const svg = btn.querySelector('svg'); - if (svg) svg.setAttribute('fill', 'currentColor'); - } else { - btn.classList.remove('active'); - const svg = btn.querySelector('svg'); - if (svg) svg.setAttribute('fill', 'none'); - } - } - }); - // Update pool data - if (window._yaArtists && window._yaArtists[poolId]) { - window._yaArtists[poolId].on_watchlist = watched ? 1 : 0; - } -} - -async function refreshYourArtists() { - const btn = document.getElementById('your-artists-refresh-btn'); - if (btn) { btn.disabled = true; btn.style.opacity = '0.5'; } - const subtitle = document.getElementById('your-artists-subtitle'); - if (subtitle) subtitle.textContent = 'Refreshing from your services...'; - - try { - await fetch('/api/discover/your-artists/refresh?clear=true', { method: 'POST' }); - // Poll until done - let attempts = 0; - const poll = setInterval(async () => { - attempts++; - if (attempts > 60) { clearInterval(poll); return; } // 5 min max - try { - const resp = await fetch('/api/discover/your-artists'); - const data = await resp.json(); - if (!data.stale && data.artists && data.artists.length > 0) { - clearInterval(poll); - loadYourArtists(); - if (btn) { btn.disabled = false; btn.style.opacity = ''; } - showToast(`Found ${data.total} artists from your services`, 'success'); - } - } catch (e) { } - }, 5000); - } catch (err) { - showToast('Failed to start refresh', 'error'); - if (btn) { btn.disabled = false; btn.style.opacity = ''; } - } -} - -async function openYourArtistsSourcesModal() { - const existing = document.getElementById('ya-sources-modal-overlay'); - if (existing) existing.remove(); - - // Fetch current config + connected services - let enabled = ['spotify', 'tidal', 'lastfm', 'deezer']; - let connected = []; - try { - const resp = await fetch('/api/discover/your-artists/sources'); - if (resp.ok) { - const data = await resp.json(); - if (data.enabled) enabled = data.enabled; - if (data.connected) connected = data.connected; - } - } catch (e) { } - - const sourceInfo = [ - { id: 'spotify', label: 'Spotify', icon: '🎵' }, - { id: 'tidal', label: 'Tidal', icon: '🌊' }, - { id: 'lastfm', label: 'Last.fm', icon: '📻' }, - { id: 'deezer', label: 'Deezer', icon: '🎶' }, - ]; - - const state = {}; - sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); }); - - const overlay = document.createElement('div'); - overlay.id = 'ya-sources-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - const rows = sourceInfo.map(s => { - const isConnected = connected.includes(s.id); - const isOn = state[s.id]; - return ` -
-
- ${s.icon} -
-
${s.label}
-
${isConnected ? 'Connected' : 'Not connected'}
-
-
- -
`; - }).join(''); - - overlay.innerHTML = ` -
-

Your Artists Sources

-

Choose which connected services contribute artists to this section.

-
${rows}
- -
- `; - document.body.appendChild(overlay); - window._yaSourcesState = state; -} - -function _yaSourceRowClick(id) { - // Don't allow toggling disconnected services - const row = document.querySelector(`.ya-source-row[data-source="${id}"]`); - if (row && row.classList.contains('disconnected')) return; - _yaSourceToggle(id); -} - -function _yaSourceToggle(id) { - // Don't allow toggling disconnected services - const row = document.querySelector(`.ya-source-row[data-source="${id}"]`); - if (row && row.classList.contains('disconnected')) return; - window._yaSourcesState[id] = !window._yaSourcesState[id]; - const btn = document.getElementById(`ya-toggle-${id}`); - if (btn) btn.classList.toggle('on', window._yaSourcesState[id]); -} - -async function _yaSourcesSave() { - const enabledArr = Object.entries(window._yaSourcesState) - .filter(([, v]) => v).map(([k]) => k); - if (enabledArr.length === 0) { - showToast('Select at least one source', 'error'); - return; - } - const enabled = enabledArr.join(','); - try { - const resp = await fetch('/api/settings', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ discover: { your_artists_sources: enabled } }) - }); - if (resp.ok) { - document.getElementById('ya-sources-modal-overlay')?.remove(); - showToast('Sources saved — refresh to apply', 'success'); - // Update subtitle immediately - const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', lastfm: 'Last.fm', deezer: 'Deezer' }; - const subtitle = document.getElementById('your-artists-subtitle'); - if (subtitle) { - const names = enabledArr.map(s => sourceNames[s] || s).join(' and '); - subtitle.textContent = `Artists you follow on ${names}`; - } - } else { - showToast('Failed to save sources', 'error'); - } - } catch (e) { - showToast('Failed to save sources', 'error'); - } -} - -async function openYourArtistsModal() { - const existing = document.getElementById('your-artists-modal-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'your-artists-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - overlay.innerHTML = ` -
-
-
-

Your Artists

-

Loading...

-
- -
-
- -
- - - - - -
- -
-
-
Loading...
-
- -
- `; - document.body.appendChild(overlay); - - // Search debounce - let searchTimer = null; - overlay.querySelector('#ya-modal-search').addEventListener('input', () => { - clearTimeout(searchTimer); - searchTimer = setTimeout(() => _yaLoadModal(), 300); - }); - - window._yaModalState = { page: 1, source: '', sort: 'name' }; - _yaLoadModal(); -} - -function _yaFilterSource(source) { - window._yaModalState.source = source; - window._yaModalState.page = 1; - document.querySelectorAll('.ya-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.source === source)); - _yaLoadModal(); -} - -async function _yaLoadModal() { - const body = document.getElementById('ya-modal-body'); - const footer = document.getElementById('ya-modal-footer'); - const subtitle = document.getElementById('ya-modal-subtitle'); - if (!body) return; - - const state = window._yaModalState || { page: 1, source: '', sort: 'name' }; - const search = document.getElementById('ya-modal-search')?.value || ''; - const sort = document.getElementById('ya-modal-sort')?.value || 'name'; - state.sort = sort; - - const params = new URLSearchParams({ page: state.page, per_page: 60, sort: state.sort }); - if (state.source) params.set('source', state.source); - if (search) params.set('search', search); - - try { - const resp = await fetch(`/api/discover/your-artists/all?${params}`); - const data = await resp.json(); - - if (subtitle) subtitle.textContent = `${data.total} artists matched`; - - if (!data.artists || data.artists.length === 0) { - body.innerHTML = '
No artists found
'; - if (footer) footer.innerHTML = ''; - return; - } - - // Store for info modal access - if (!window._yaArtists) window._yaArtists = {}; - data.artists.forEach(a => { window._yaArtists[a.id] = a; }); - body.innerHTML = `
${data.artists.map(a => _renderYourArtistCard(a)).join('')}
`; - - // Pagination - const totalPages = Math.ceil(data.total / 60); - if (footer && totalPages > 1) { - footer.innerHTML = ` -
- - Page ${state.page} of ${totalPages} - -
- `; - } else if (footer) { - footer.innerHTML = ''; - } - } catch (err) { - body.innerHTML = '
Failed to load
'; - } -} - -function loadDiscoveryBlacklist() { } - -// ── Artist Map — Circle-packed staged canvas visualization ── -const _artMap = { - placed: [], - edges: [], - images: {}, - canvas: null, ctx: null, - offscreen: null, offCtx: null, // offscreen buffer for fast pan/zoom - width: 0, height: 0, - offsetX: 0, offsetY: 0, zoom: 0.15, - hoveredNode: null, animFrame: null, - dirty: true, // true = need to rebuild offscreen buffer - WATCHLIST_R: 320, - BUFFER: 8, -}; - -async function openArtistMap() { - const container = document.getElementById('artist-map-container'); - if (!container) return; - - // Hide discover sections, show map - document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { - el._prevDisplay = el.style.display; - el.style.display = 'none'; - }); - container.style.display = 'flex'; - - const canvas = document.getElementById('artist-map-canvas'); - _artMap.canvas = canvas; - _artMap.ctx = canvas.getContext('2d'); - _artMap.width = container.clientWidth; - _artMap.height = container.clientHeight - 50; - canvas.width = _artMap.width * window.devicePixelRatio; - canvas.height = _artMap.height * window.devicePixelRatio; - canvas.style.width = _artMap.width + 'px'; - canvas.style.height = _artMap.height + 'px'; - _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); - _artMap.offsetX = _artMap.width / 2; - _artMap.offsetY = _artMap.height / 2; - _artMap.placed = []; - _artMap.images = {}; - _artMap._nodeById = null; - - // Loading screen - _artMap.ctx.fillStyle = '#0a0a14'; - _artMap.ctx.fillRect(0, 0, _artMap.width, _artMap.height); - _artMap.ctx.fillStyle = 'rgba(255,255,255,0.3)'; - _artMap.ctx.font = '14px system-ui'; - _artMap.ctx.textAlign = 'center'; - _artMap.ctx.fillText('Building artist map...', _artMap.width / 2, _artMap.height / 2); - - try { - const resp = await fetch('/api/discover/artist-map'); - const data = await resp.json(); - if (!data.success || !data.nodes.length) { - _artMap.ctx.fillText('No watchlist artists. Add artists to your watchlist first.', _artMap.width / 2, _artMap.height / 2 + 30); - return; - } - - document.getElementById('artist-map-stats').textContent = - `${data.watchlist_count} watchlist · ${data.similar_count} similar`; - - _artMap.edges = data.edges; - const WR = _artMap.WATCHLIST_R; - const BUF = _artMap.BUFFER; - - // ── PHASE 1: Place watchlist artists with guaranteed no-overlap ── - const wNodes = data.nodes.filter(n => n.type === 'watchlist'); - // Minimum center-to-center distance between watchlist nodes - const minCenterDist = WR * 3.5; // WR*2 for radii + WR*1.5 gap — similar artists fill the gaps via spiral packing - - // Place watchlist nodes in a spiral — deterministic, guaranteed spacing - wNodes.forEach((n, i) => { - n.radius = WR; - n.opacity = 0; - if (i === 0) { - n.x = 0; n.y = 0; - } else { - // Golden angle spiral for even distribution - const angle = i * 2.399963; // golden angle in radians - const r = minCenterDist * Math.sqrt(i) * 0.7; - n.x = Math.cos(angle) * r; - n.y = Math.sin(angle) * r; - } - }); - - // Post-process: push apart any watchlist nodes that ended up too close - for (let pass = 0; pass < 50; pass++) { - let moved = false; - for (let i = 0; i < wNodes.length; i++) { - for (let j = i + 1; j < wNodes.length; j++) { - const dx = wNodes[j].x - wNodes[i].x; - const dy = wNodes[j].y - wNodes[i].y; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - if (dist < minCenterDist) { - const push = (minCenterDist - dist) / 2 + 1; - const nx = (dx / dist) * push; - const ny = (dy / dist) * push; - wNodes[i].x -= nx; wNodes[i].y -= ny; - wNodes[j].x += nx; wNodes[j].y += ny; - moved = true; - } - } - } - if (!moved) break; - } - - wNodes.forEach(n => { _artMap.placed.push(n); }); - - // ── PHASE 2: Place similar artists around their source watchlist nodes ── - const sNodes = data.nodes.filter(n => n.type === 'similar'); - sNodes.forEach(n => { - const occ = n.occurrence || 1; - const rank = n.rank || 5; - // Bigger overall: min 25% of WR, max 55%, scaled by relevance - n.radius = Math.min(WR * 0.55, Math.max(WR * 0.25, WR * 0.2 + occ * WR * 0.06 + (10 - rank) * WR * 0.025)); - }); - sNodes.sort((a, b) => b.radius - a.radius); - - // Build edge lookup: target_id → source node (O(1) instead of .find()) - const edgeMap = {}; - _artMap.edges.forEach(e => { edgeMap[e.target] = e.source; }); - const nodeById = {}; - _artMap.placed.forEach(n => { nodeById[n.id] = n; }); - - // Spatial grid for fast collision detection - // Cell size must cover the largest possible bubble diameter + buffer - const CELL = WR * 2 + BUF * 2; - const grid = {}; - function _gridKey(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; } - function _gridAdd(n) { - const k = _gridKey(n.x, n.y); - if (!grid[k]) grid[k] = []; - grid[k].push(n); - } - function _gridCheck(x, y, r) { - const cx = Math.floor(x / CELL); - const cy = Math.floor(y / CELL); - // Search wider radius to catch large watchlist bubbles - for (let dx = -3; dx <= 3; dx++) { - for (let dy = -3; dy <= 3; dy++) { - const cell = grid[`${cx + dx},${cy + dy}`]; - if (!cell) continue; - for (const p of cell) { - const ddx = x - p.x, ddy = y - p.y; - const minD = r + p.radius + BUF; - if (ddx * ddx + ddy * ddy < minD * minD) return true; - } - } - } - return false; - } - // Add watchlist nodes to grid - _artMap.placed.forEach(n => _gridAdd(n)); - - // Place similar nodes with spatial grid collision - for (const sn of sNodes) { - const srcId = edgeMap[sn.id]; - const src = srcId != null ? nodeById[srcId] : null; - const cx = src ? src.x : 0; - const cy = src ? src.y : 0; - const startDist = (src ? src.radius : WR) + sn.radius + BUF; - - let placed = false; - for (let dist = startDist; dist < startDist + WR * 3; dist += sn.radius * 0.5) { - const steps = Math.max(8, Math.floor(dist * 0.1)); - const off = Math.random() * Math.PI * 2; - for (let a = 0; a < steps; a++) { - const angle = off + (a / steps) * Math.PI * 2; - const tx = cx + Math.cos(angle) * dist; - const ty = cy + Math.sin(angle) * dist; - if (!_gridCheck(tx, ty, sn.radius)) { - sn.x = tx; sn.y = ty; sn.opacity = 0; - _artMap.placed.push(sn); - nodeById[sn.id] = sn; - _gridAdd(sn); - placed = true; - break; - } - } - if (placed) break; - } - } - - // Auto-zoom to fit all nodes - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 200; - const mapH = maxY - minY + 200; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; - _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; - - // Setup interaction - _artMapSetupInteraction(canvas); - - // ── PHASE 3: Set all visible, build buffer, render ── - // Show loading overlay while buffer builds - const loadingEl = document.createElement('div'); - loadingEl.id = 'artist-map-loading'; - loadingEl.innerHTML = ` -
-
-
Placing ${_artMap.placed.length} artists on the map...
-
- `; - container.appendChild(loadingEl); - - // Defer heavy work so loading overlay renders first - setTimeout(async () => { - _artMap.placed.forEach(n => { n.opacity = 1; }); - - // Load images in parallel using createImageBitmap (non-blocking) - const loadingText = container.querySelector('.artist-map-loading-text'); - const imgNodes = _artMap.placed.filter(n => n.image_url); - let loaded = 0; - - if (loadingText) loadingText.textContent = `Loading ${imgNodes.length} artist images...`; - - // Batch image loading — 20 concurrent fetches - const CONCURRENT = 20; - let idx = 0; - - async function loadNextBatch() { - const batch = []; - while (idx < imgNodes.length && batch.length < CONCURRENT) { - const n = imgNodes[idx++]; - if (_artMap.images[n.id]) { loaded++; continue; } - batch.push( - _artMapLoadImage(n.image_url) - .then(bmp => { if (bmp) _artMap.images[n.id] = bmp; }) - .finally(() => { - loaded++; - if (loadingText && loaded % 50 === 0) { - loadingText.textContent = `Loading images... ${loaded}/${imgNodes.length}`; - } - }) - ); - } - if (batch.length) await Promise.all(batch); - if (idx < imgNodes.length) return loadNextBatch(); - } - - await loadNextBatch(); - - // Build buffer and render - if (loadingText) loadingText.textContent = 'Rendering map...'; - await new Promise(r => setTimeout(r, 20)); // let text update render - - _artMap.dirty = true; - _artMapRender(); - - const le = document.getElementById('artist-map-loading'); - if (le) le.remove(); - }, 50); - - } catch (err) { - console.error('Artist map error:', err); - } -} - -function artMapZoom(factor) { - const cx = _artMap.width / 2; - const cy = _artMap.height / 2; - const targetZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor)); - const targetOX = cx - (cx - _artMap.offsetX) * (targetZoom / _artMap.zoom); - const targetOY = cy - (cy - _artMap.offsetY) * (targetZoom / _artMap.zoom); - _artMapAnimateTo(targetZoom, targetOX, targetOY); -} - -function artMapFitToView() { - if (!_artMap.placed.length) return; - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - if ((n.opacity || 0) < 0.01) return; - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 100; - const mapH = maxY - minY + 100; - const targetZoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - const targetOX = _artMap.width / 2 - ((minX + maxX) / 2) * targetZoom; - const targetOY = _artMap.height / 2 - ((minY + maxY) / 2) * targetZoom; - _artMapAnimateTo(targetZoom, targetOX, targetOY); -} - -function _artMapAnimateTo(targetZoom, targetOX, targetOY) { - if (_artMap._animating) cancelAnimationFrame(_artMap._animating); - const startZoom = _artMap.zoom; - const startOX = _artMap.offsetX; - const startOY = _artMap.offsetY; - const duration = 250; - const start = performance.now(); - - function step(now) { - const t = Math.min(1, (now - start) / duration); - // Ease out cubic - const e = 1 - Math.pow(1 - t, 3); - _artMap.zoom = startZoom + (targetZoom - startZoom) * e; - _artMap.offsetX = startOX + (targetOX - startOX) * e; - _artMap.offsetY = startOY + (targetOY - startOY) * e; - _artMapRender(); // blit only, no rebuild - if (t < 1) { - _artMap._animating = requestAnimationFrame(step); - } else { - _artMap._animating = null; - _artMap.dirty = true; - _artMapRender(); // rebuild at final zoom level - } - } - _artMap._animating = requestAnimationFrame(step); -} - -function closeArtistMap() { - const container = document.getElementById('artist-map-container'); - if (container) container.style.display = 'none'; - const sidebar = document.getElementById('artmap-genre-sidebar'); - if (sidebar) sidebar.style.display = 'none'; - if (_artMap.animFrame) cancelAnimationFrame(_artMap.animFrame); - if (_artMap._keyHandler) window.removeEventListener('keydown', _artMap._keyHandler); - _artMapHideContextMenu(); - - // Restore discover sections - document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { - el.style.display = el._prevDisplay !== undefined ? el._prevDisplay : ''; - }); -} - -// No force simulation — layout is pre-computed via circle packing - -function _artMapRebuildBuffer() { - /**Render ALL nodes once to offscreen canvas. Only called on data changes, not pan/zoom.**/ - const placed = _artMap.placed; - if (!placed.length) return; - - const visible = placed.filter(n => (n.opacity || 0) > 0.01); - if (!visible.length) return; - - // World bounds - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - visible.forEach(n => { - minX = Math.min(minX, n.x - n.radius - 10); - maxX = Math.max(maxX, n.x + n.radius + 10); - minY = Math.min(minY, n.y - n.radius - 10); - maxY = Math.max(maxY, n.y + n.radius + 10); - }); - - const bw = maxX - minX; - const bh = maxY - minY; - // Scale based on zoom — higher zoom = higher res buffer, capped for memory - const z = _artMap.zoom || 0.1; - const scale = Math.min(z * 2, 1.0, 10240 / Math.max(bw, bh)); - - if (!_artMap.offscreen) _artMap.offscreen = document.createElement('canvas'); - const oc = _artMap.offscreen; - oc.width = Math.ceil(bw * scale); - oc.height = Math.ceil(bh * scale); - const octx = oc.getContext('2d'); - _artMap._bufferScale = scale; - _artMap._bufferMinX = minX; - _artMap._bufferMinY = minY; - - octx.scale(scale, scale); - octx.translate(-minX, -minY); - - // Build node lookup - if (!_artMap._nodeById) { - _artMap._nodeById = {}; - placed.forEach(n => { _artMap._nodeById[n.id] = n; }); - } - - // Draw edges (connection lines between related nodes) - if (_artMap.edges && _artMap.edges.length > 0) { - octx.lineWidth = 1; - octx.strokeStyle = 'rgba(138,43,226,0.08)'; - octx.beginPath(); - for (const edge of _artMap.edges) { - const s = _artMap._nodeById[edge.source]; - const t = _artMap._nodeById[edge.target]; - if (!s || !t || (s.opacity || 0) < 0.05 || (t.opacity || 0) < 0.05) continue; - octx.moveTo(s.x, s.y); - octx.lineTo(t.x, t.y); - } - octx.stroke(); - } - - // Draw ALL nodes — genre labels first, similar next, watchlist on top - const hideSimilar = _artMap._hideSimilar || false; - // Pass 0: genre labels, Pass 1: similar/ring2, Pass 2: watchlist/center/ring1 - for (let pass = 0; pass < 3; pass++) { - for (const n of visible) { - if (pass === 0 && n._isLabel) { /* draw */ } - else if (pass === 1 && !n._isLabel && n.type !== 'watchlist' && n.type !== 'center' && n.ring !== 1) { /* draw */ } - else if (pass === 2 && !n._isLabel && (n.type === 'watchlist' || n.type === 'center' || n.ring === 1)) { /* draw */ } - else continue; - if (hideSimilar && n.type !== 'watchlist' && n.type !== 'center' && !n._isLabel) continue; - const op = n.opacity || 0; - if (op < 0.01) continue; - const r = n.radius; - const isW = n.type === 'watchlist' || n.type === 'center'; - octx.globalAlpha = op; - - // Genre label node — transparent circle with large text - if (n._isLabel) { - octx.globalAlpha = 0.6; - octx.beginPath(); - octx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); - octx.fillStyle = 'rgba(138,43,226,0.04)'; - octx.fill(); - octx.strokeStyle = 'rgba(138,43,226,0.08)'; - octx.lineWidth = 1; - octx.stroke(); - const labelSize = Math.max(12, n.radius * 0.25); - octx.font = `800 ${labelSize}px system-ui`; - octx.textAlign = 'center'; - octx.textBaseline = 'middle'; - octx.fillStyle = 'rgba(138,43,226,0.35)'; - octx.fillText(n.name, n.x, n.y - labelSize * 0.3); - octx.font = `500 ${labelSize * 0.5}px system-ui`; - octx.fillStyle = 'rgba(255,255,255,0.15)'; - octx.fillText(`${n._count || 0} artists`, n.x, n.y + labelSize * 0.5); - octx.globalAlpha = 1; - continue; - } - - // Render quality based on node size in buffer pixels - const rScaled = r * scale; - const isSmall = rScaled < 8; - const isTiny = rScaled < 3; - - // Tiny nodes: just a colored dot (no clip, no image, no text) - if (isTiny) { - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.fillStyle = isW ? '#6b21a8' : '#2a2a40'; - octx.fill(); - continue; - } - - // Small nodes: filled circle + border, no image clip - if (isSmall) { - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - const img = _artMap.images[n.id]; - if (img) { - octx.save(); octx.clip(); - octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); - octx.restore(); - } else { - octx.fillStyle = isW ? '#1a0a30' : '#141420'; - octx.fill(); - } - octx.strokeStyle = isW ? 'rgba(138,43,226,0.3)' : 'rgba(255,255,255,0.06)'; - octx.lineWidth = isW ? 1.5 : 0.5; - octx.stroke(); - continue; - } - - // Full quality: glow + clip + image + text - if (isW) { - octx.beginPath(); - octx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); - octx.strokeStyle = 'rgba(138,43,226,0.25)'; - octx.lineWidth = 5; - octx.stroke(); - } - - octx.save(); - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.closePath(); - octx.clip(); - - const img = _artMap.images[n.id]; - if (img) { - octx.drawImage(img, n.x - r, n.y - r, r * 2, r * 2); - octx.fillStyle = 'rgba(0,0,0,0.45)'; - octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); - } else { - octx.fillStyle = isW ? '#1a0a30' : '#141420'; - octx.fillRect(n.x - r, n.y - r, r * 2, r * 2); - } - octx.restore(); - - octx.beginPath(); - octx.arc(n.x, n.y, r, 0, Math.PI * 2); - octx.strokeStyle = isW ? 'rgba(138,43,226,0.4)' : 'rgba(255,255,255,0.08)'; - octx.lineWidth = isW ? 2 : 0.5; - octx.stroke(); - - const fontSize = isW ? Math.max(16, r * 0.14) : Math.max(8, r * 0.3); - octx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; - octx.textAlign = 'center'; - octx.textBaseline = 'middle'; - octx.fillStyle = '#fff'; - const maxC = isW ? 20 : 12; - const label = n.name.length > maxC ? n.name.substring(0, maxC - 1) + '…' : n.name; - octx.fillText(label, n.x, n.y); - } - } - - octx.globalAlpha = 1; - - _artMap.dirty = false; -} - -function _artMapRender() { - /**Blit offscreen buffer to screen canvas with pan/zoom. Near-zero cost.**/ - const ctx = _artMap.ctx; - const w = _artMap.width; - const h = _artMap.height; - - ctx.fillStyle = '#0a0a14'; - ctx.fillRect(0, 0, w, h); - - if (_artMap.dirty || !_artMap.offscreen) _artMapRebuildBuffer(); - if (!_artMap.offscreen) return; - - const oc = _artMap.offscreen; - const s = _artMap._bufferScale; - const mx = _artMap._bufferMinX; - const my = _artMap._bufferMinY; - const z = _artMap.zoom; - - // Blit offscreen buffer: the buffer was drawn with scale(s) + translate(-minX,-minY) - // So buffer pixel (bx,by) corresponds to world (bx/s + minX, by/s + minY) - // Screen position of world (wx,wy) = offsetX + wx*zoom, offsetY + wy*zoom - // Therefore buffer origin on screen = offsetX + minX*zoom, offsetY + minY*zoom - // And buffer is drawn at size (bufferWidth * zoom/s, bufferHeight * zoom/s) - ctx.drawImage(oc, - _artMap.offsetX + mx * z, - _artMap.offsetY + my * z, - oc.width * z / s, - oc.height * z / s - ); - - // ── Interactive overlay (drawn on main canvas, not buffer) ── - const cFade = _artMap._constellationFade || 0; - if (cFade > 0 && (_artMap.hoveredNode || _artMap._constellationCache)) { - const n = _artMap.hoveredNode || (_artMap._constellationCache ? (_artMap._nodeById || {})[_artMap._constellationCache.nodeId] : null); - if (!n) { _artMap._constellationFade = 0; _artMap._constellationCache = null; } - if (n) { - ctx.save(); - ctx.translate(_artMap.offsetX, _artMap.offsetY); - ctx.scale(z, z); - - // Cache connected node lookup (don't recompute every frame) - if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) { - const connectedIds = new Set(); - if (n.type === 'watchlist') { - for (const e of _artMap.edges) { - if (e.source === n.id) connectedIds.add(e.target); - } - } else { - const sourceIds = new Set(); - for (const e of _artMap.edges) { - if (e.target === n.id) sourceIds.add(e.source); - } - for (const sid of sourceIds) { - connectedIds.add(sid); - for (const e of _artMap.edges) { - if (e.source === sid) connectedIds.add(e.target); - } - } - } - const nById = _artMap._nodeById || {}; - _artMap._constellationCache = { - nodeId: n.id, - nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)], - }; - } - - const highlightNodes = _artMap._constellationCache.nodes; - - if (highlightNodes.length > 1) { - // Semi-transparent dark overlay on entire visible area - ctx.save(); - ctx.resetTransform(); - ctx.globalAlpha = 0.6 * cFade; - ctx.fillStyle = '#0a0a14'; - ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height); - ctx.globalAlpha = 1; - ctx.restore(); - - // Draw glowing connection lines - for (const cn of highlightNodes) { - if (cn === n) continue; - ctx.beginPath(); - ctx.moveTo(n.x, n.y); - ctx.lineTo(cn.x, cn.y); - // Gradient line - const lineGrad = ctx.createLinearGradient(n.x, n.y, cn.x, cn.y); - lineGrad.addColorStop(0, `rgba(138,43,226,${0.5 * cFade})`); - lineGrad.addColorStop(1, `rgba(138,43,226,${0.15 * cFade})`); - ctx.strokeStyle = lineGrad; - ctx.lineWidth = 2; - ctx.stroke(); - } - - // Redraw highlighted nodes on top - ctx.globalAlpha = cFade; - for (const hn of highlightNodes) { - const r = hn.radius; - const isW = hn.type === 'watchlist'; - const isHov = hn === n; - - // Glow - if (isHov) { - ctx.beginPath(); - ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(138,43,226,0.4)'; - ctx.lineWidth = 6; - ctx.stroke(); - } - - // Circle + image - ctx.save(); - ctx.beginPath(); - ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); - ctx.closePath(); - ctx.clip(); - - const img = _artMap.images[hn.id]; - if (img) { - ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2); - ctx.fillStyle = 'rgba(0,0,0,0.35)'; - ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); - } else { - ctx.fillStyle = isW ? '#1a0a30' : '#141420'; - ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); - } - ctx.restore(); - - // Border - ctx.beginPath(); - ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); - ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)'; - ctx.lineWidth = isHov ? 3 : 1.5; - ctx.stroke(); - - // Name - const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3); - ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = '#fff'; - const maxC = isW ? 20 : 12; - const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + '…' : hn.name; - ctx.fillText(label, hn.x, hn.y); - } - ctx.globalAlpha = 1; - } else { - // Single node, no connections - ctx.beginPath(); - ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - ctx.lineWidth = 3; - ctx.stroke(); - } - - ctx.restore(); - } // end if(n) - } else if (_artMap.hoveredNode && !_artMap._constellationActive) { - // Pre-constellation: just show a simple highlight ring (instant, no delay) - const n = _artMap.hoveredNode; - ctx.save(); - ctx.translate(_artMap.offsetX, _artMap.offsetY); - ctx.scale(z, z); - ctx.beginPath(); - ctx.arc(n.x, n.y, n.radius + 3, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(255,255,255,0.35)'; - ctx.lineWidth = 2; - ctx.stroke(); - ctx.restore(); - } - - // Click ripple animation - if (_artMap._ripple) { - const rip = _artMap._ripple; - const elapsed = performance.now() - rip.start; - const progress = elapsed / 400; // 400ms duration - if (progress < 1) { - ctx.save(); - ctx.translate(_artMap.offsetX, _artMap.offsetY); - ctx.scale(z, z); - const ripR = rip.radius + rip.radius * progress * 0.5; - ctx.beginPath(); - ctx.arc(rip.x, rip.y, ripR, 0, Math.PI * 2); - ctx.strokeStyle = `rgba(138,43,226,${0.5 * (1 - progress)})`; - ctx.lineWidth = 3 * (1 - progress); - ctx.stroke(); - ctx.restore(); - requestAnimationFrame(() => _artMapRender()); - } else { - _artMap._ripple = null; - } - } -} - -function artMapSearch(query) { - const results = document.getElementById('artist-map-search-results'); - if (!results) return; - if (!query || query.length < 2) { results.style.display = 'none'; return; } - - const q = query.toLowerCase(); - const matches = _artMap.placed.filter(n => (n.opacity || 0) > 0.5 && n.name.toLowerCase().includes(q)).slice(0, 8); - - if (!matches.length) { results.style.display = 'none'; return; } - - results.style.display = 'block'; - results.innerHTML = matches.map(n => - `
- ${n.type === 'watchlist' ? '★' : '○'} - ${escapeHtml(n.name)} -
` - ).join(''); -} - -function artMapZoomToNode(nodeId) { - const n = _artMap.placed.find(p => p.id === nodeId); - if (!n) return; - // Zoom to show this node centered, at a comfortable zoom level - const targetZoom = Math.max(0.3, Math.min(1, 200 / n.radius)); - const targetOX = _artMap.width / 2 - n.x * targetZoom; - const targetOY = _artMap.height / 2 - n.y * targetZoom; - _artMapAnimateTo(targetZoom, targetOX, targetOY); - // Highlight briefly after animation - setTimeout(() => { _artMap.hoveredNode = n; _artMapRender(); }, 300); - setTimeout(() => { _artMap.hoveredNode = null; _artMapRender(); }, 2500); - // Close search - const results = document.getElementById('artist-map-search-results'); - if (results) results.style.display = 'none'; - const input = document.getElementById('artist-map-search'); - if (input) input.value = ''; -} - -function _artMapShowTooltip(e, node) { - const tip = document.getElementById('artist-map-tooltip'); - if (!tip) return; - if (!node) { tip.style.display = 'none'; return; } - - const img = node.image_url ? `` : '
'; - const genres = (node.genres || []).slice(0, 3); - const genreHTML = genres.length ? `
${genres.map(g => `${escapeHtml(g)}`).join('')}
` : ''; - const typeLabel = node.type === 'watchlist' ? '★ Watchlist' : ''; - - tip.innerHTML = ` -
- ${img} -
-
${escapeHtml(node.name)}
- ${typeLabel} - ${genreHTML} -
-
- `; - tip.style.display = 'block'; - - // Position — keep on screen - const x = Math.min(e.clientX + 16, window.innerWidth - tip.offsetWidth - 10); - const y = Math.min(e.clientY - 10, window.innerHeight - tip.offsetHeight - 10); - tip.style.left = x + 'px'; - tip.style.top = y + 'px'; -} - -function _artMapAnimateConstellation() { - if (_artMap._constellationActive && _artMap._constellationFade < 1) { - _artMap._constellationFade = Math.min(1, (_artMap._constellationFade || 0) + 0.08); - _artMapRender(); - requestAnimationFrame(_artMapAnimateConstellation); - } else if (!_artMap._constellationActive && _artMap._constellationFade > 0) { - _artMap._constellationFade = Math.max(0, _artMap._constellationFade - 0.1); - _artMapRender(); - if (_artMap._constellationFade > 0) { - requestAnimationFrame(_artMapAnimateConstellation); - } else { - _artMap._constellationCache = null; - } - } -} - -function artMapShowShortcuts() { - const existing = document.getElementById('artmap-shortcuts-overlay'); - if (existing) { existing.remove(); return; } - - const overlay = document.createElement('div'); - overlay.id = 'artmap-shortcuts-overlay'; - overlay.className = 'modal-overlay'; - overlay.style.zIndex = '10002'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - overlay.innerHTML = ` -
-
-

Keyboard Shortcuts

- -
-
-
EscClose map
-
+ / -Zoom in / out
-
FFit to view
-
SFocus search
-
HToggle similar artists
-
ScrollZoom at cursor
-
ClickArtist info
-
Right-clickContext menu
-
DragPan around
-
Hover 1sShow connections
-
-
- `; - document.body.appendChild(overlay); -} - -async function openArtistMapGenre() { - // Show picker immediately — uses lightweight genre list endpoint - const genre = await _showGenrePickerModal(); - if (!genre) return; - _openGenreMapWithSelection(genre); -} - -async function _showGenrePickerModal() { - return new Promise(resolve => { - const existing = document.getElementById('artmap-genre-picker'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'artmap-genre-picker'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - - overlay.innerHTML = ` -
-
- - - -
-

Select a Genre

-

Choose a genre to explore its artists

-
-
- -
-
Loading genres...
-
-
- `; - document.body.appendChild(overlay); - - // Use cached data or fetch - const renderGenreList = (data) => { - if (!data?.success || !data?.genres?.length) { - document.getElementById('artmap-genre-picker-list').innerHTML = '
No genres found
'; - return; - } - const list = document.getElementById('artmap-genre-picker-list'); - list.innerHTML = data.genres.map(g => ` -
-
${escapeHtml(g.name)}
-
${g.count} artists
-
- `).join(''); - }; - - if (window._artMapGenreList) { - renderGenreList(window._artMapGenreList); - } else { - fetch('/api/discover/artist-map/genre-list') - .then(r => r.json()) - .then(data => { window._artMapGenreList = data; renderGenreList(data); }) - .catch(() => { document.getElementById('artmap-genre-picker-list').innerHTML = '
Error loading genres
'; }); - } - - overlay._resolve = (genre) => { overlay.remove(); resolve(genre); }; - }); -} - -function _switchGenre(genre) { - _artMap._skipSectionToggle = true; - _openGenreMapWithSelection(genre); -} - -function _filterGenreSidebar(query) { - const q = query.toLowerCase(); - document.querySelectorAll('.artmap-genre-sidebar-item').forEach(el => { - el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none'; - }); -} - -async function _changeGenre() { - const genre = await _showGenrePickerModal(); - if (!genre) return; - _artMap._skipSectionToggle = true; - _openGenreMapWithSelection(genre); -} - -function _filterGenrePicker(query) { - const q = query.toLowerCase(); - document.querySelectorAll('.artmap-genre-picker-item').forEach(el => { - el.style.display = el.dataset.genre.toLowerCase().includes(q) ? '' : 'none'; - }); -} - -async function _openGenreMapWithSelection(selectedGenre) { - const container = document.getElementById('artist-map-container'); - if (!container) return; - - const skipToggle = _artMap._skipSectionToggle; - _artMap._skipSectionToggle = false; - - if (!skipToggle) { - document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { - el._prevDisplay = el.style.display; - el.style.display = 'none'; - }); - } - container.style.display = 'flex'; - - // Show + populate genre sidebar - const sidebar = document.getElementById('artmap-genre-sidebar'); - const genreListData = window._artMapGenreList || window._artMapGenreData; - if (sidebar && genreListData?.genres) { - sidebar.style.display = 'flex'; - const list = document.getElementById('artmap-genre-sidebar-list'); - if (list) { - list.innerHTML = genreListData.genres.map(g => ` -
- ${escapeHtml(g.name)} - ${g.count} -
- `).join(''); - } - } - - const canvas = document.getElementById('artist-map-canvas'); - const contentRow = canvas.parentElement; - _artMap.canvas = canvas; - _artMap.ctx = canvas.getContext('2d'); - _artMap.width = canvas.clientWidth || (container.clientWidth - (sidebar?.offsetWidth || 0)); - _artMap.height = contentRow?.clientHeight || (container.clientHeight - 50); - canvas.width = _artMap.width * window.devicePixelRatio; - canvas.height = _artMap.height * window.devicePixelRatio; - canvas.style.width = _artMap.width + 'px'; - canvas.style.height = _artMap.height + 'px'; - _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); - _artMap.offsetX = _artMap.width / 2; - _artMap.offsetY = _artMap.height / 2; - _artMap.placed = []; - _artMap.edges = []; - _artMap.images = {}; - _artMap._nodeById = null; - _artMap.dirty = true; - - // Show loading - const loadingEl = document.createElement('div'); - loadingEl.id = 'artist-map-loading'; - loadingEl.innerHTML = `
Loading genre map...
`; - container.appendChild(loadingEl); - - // Update toolbar - document.querySelector('.artmap-brand-text').textContent = 'Genre Map'; - document.getElementById('artist-map-stats').textContent = 'Loading...'; - - try { - // Use cached data from picker or fetch fresh - const data = window._artMapGenreData || await fetch('/api/discover/artist-map/genres').then(r => r.json()); - const loadingText = document.getElementById('artmap-genre-loading-text'); - if (!data.success || !data.nodes.length) { - if (loadingText) loadingText.textContent = 'No artists with genre data found.'; - return; - } - - // Find the selected genre + closely related genres (high artist overlap) - const allGenres = data.genres; - const primary = allGenres.find(g => g.name === selectedGenre); - if (!primary) { - if (loadingText) loadingText.textContent = `Genre "${selectedGenre}" not found.`; - return; - } - const primarySet = new Set(primary.artist_ids); - - // Find up to 4 related genres by artist overlap - const related = allGenres - .filter(g => g.name !== selectedGenre) - .map(g => { - const overlap = g.artist_ids.filter(id => primarySet.has(id)).length; - return { ...g, overlap }; - }) - .filter(g => g.overlap > primarySet.size * 0.1) // At least 10% overlap - .sort((a, b) => b.overlap - a.overlap) - .slice(0, 4); - - const genres = [primary, ...related]; - const totalArtists = genres.reduce((sum, g) => sum + g.artist_ids.length, 0); - - document.getElementById('artist-map-stats').innerHTML = - `${escapeHtml(selectedGenre)} ▾ · ${genres.length} genre${genres.length > 1 ? 's' : ''} · ${totalArtists} artists`; - - const WR = _artMap.WATCHLIST_R; - const BUF = _artMap.BUFFER; - - const maxPerGenre = 500; - const nodeR = WR * 0.2; - - // Calculate actual cluster radius for each genre based on ring count - function getClusterRadius(artistCount) { - const count = Math.min(artistCount, maxPerGenre); - let ringDist = WR + nodeR * 2 + BUF; - let placed = 0; - while (placed < count) { - const circ = 2 * Math.PI * ringDist; - const inRing = Math.max(1, Math.floor(circ / (nodeR * 2 + BUF))); - placed += Math.min(inRing, count - placed); - ringDist += nodeR * 2 + BUF; - } - return ringDist; - } - - // Pre-compute cluster radii - genres.forEach(g => { g._clusterR = getClusterRadius(g.artist_ids.length); }); - - // Golden spiral placement - genres.forEach((g, i) => { - if (i === 0) { g._cx = 0; g._cy = 0; } - else { - const angle = i * 2.399963; - const r = g._clusterR * Math.sqrt(i) * 0.9; - g._cx = Math.cos(angle) * r; - g._cy = Math.sin(angle) * r; - } - }); - - // Push apart using actual cluster radii — no overlap possible - for (let pass = 0; pass < 80; pass++) { - let moved = false; - for (let i = 0; i < genres.length; i++) { - for (let j = i + 1; j < genres.length; j++) { - const dx = genres[j]._cx - genres[i]._cx; - const dy = genres[j]._cy - genres[i]._cy; - const dist = Math.sqrt(dx * dx + dy * dy) || 1; - const minDist = genres[i]._clusterR + genres[j]._clusterR + BUF * 4; - if (dist < minDist) { - const push = (minDist - dist) / 2 + 1; - genres[i]._cx -= (dx / dist) * push; genres[i]._cy -= (dy / dist) * push; - genres[j]._cx += (dx / dist) * push; genres[j]._cy += (dy / dist) * push; - moved = true; - } - } - } - if (!moved) break; - } - - let placedCount = 0; - - // Place genre labels as big watchlist-style bubbles - for (const g of genres) { - _artMap.placed.push({ - id: `genre_${g.name}`, name: g.name.toUpperCase(), - x: g._cx, y: g._cy, radius: WR, opacity: 1, - type: 'genre_label', image_url: '', genres: [g.name], - _isLabel: true, _count: g.count - }); - } - - // Place artists in concentric rings — deterministic O(1) per node, handles 10K+ instantly - let genreIdx = 0; - - async function placeGenreArtists() { - for (; genreIdx < genres.length; genreIdx++) { - const genre = genres[genreIdx]; - const artists = genre.artist_ids.slice(0, maxPerGenre); - const sorted = artists.map(nid => data.nodes[nid]).filter(Boolean).sort((a, b) => (b.popularity || 0) - (a.popularity || 0)); - - let ringDist = WR + nodeR * 2 + BUF; - let ringNum = 0; - let placed = 0; - - while (placed < sorted.length) { - const circumference = 2 * Math.PI * ringDist; - const nodesInRing = Math.max(1, Math.floor(circumference / (nodeR * 2 + BUF))); - const count = Math.min(nodesInRing, sorted.length - placed); - const angleStep = (2 * Math.PI) / nodesInRing; - const angleOffset = ringNum * 0.618; - - for (let i = 0; i < count; i++) { - const n = sorted[placed + i]; - if (!n) continue; - const isW = n.type === 'watchlist' || n.type === 'center'; - const r = isW ? nodeR * 1.5 : nodeR; - const angle = angleOffset + i * angleStep; - - _artMap.placed.push({ - id: placedCount + 1000, _origId: n.id, name: n.name, - x: genre._cx + Math.cos(angle) * ringDist, - y: genre._cy + Math.sin(angle) * ringDist, - radius: r, opacity: 1, - type: isW ? 'watchlist' : 'similar', - image_url: n.image_url || '', genres: n.genres || [], - spotify_id: n.spotify_id || '', itunes_id: n.itunes_id || '', - deezer_id: n.deezer_id || '', discogs_id: n.discogs_id || '', - }); - placedCount++; - } - placed += count; - ringDist += nodeR * 2 + BUF; - ringNum++; - } - - if (loadingText) loadingText.textContent = `Placing artists... ${genreIdx + 1}/${genres.length} genres (${placedCount} placed)`; - if (genreIdx % 5 === 0) await new Promise(r => setTimeout(r, 0)); - } - } - await placeGenreArtists(); - - // Build edges: connect artists that appear in multiple genre clusters - _artMap.edges = []; - const artistNodes = {}; - _artMap.placed.forEach(n => { - if (n._origId != null) { - if (!artistNodes[n._origId]) artistNodes[n._origId] = []; - artistNodes[n._origId].push(n.id); - } - }); - Object.values(artistNodes).forEach(ids => { - if (ids.length > 1) { - for (let i = 0; i < ids.length - 1; i++) { - _artMap.edges.push({ source: ids[i], target: ids[i + 1], weight: 5 }); - } - } - }); - - _artMap._nodeById = {}; - _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); - - // Auto-zoom - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 200, mapH = maxY - minY + 200; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; - _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; - - _artMapSetupInteraction(canvas); - - // Load images + render - if (loadingText) loadingText.textContent = `Rendering ${placedCount} artists...`; - - setTimeout(async () => { - const imgNodes = _artMap.placed.filter(n => n.image_url && !n._isLabel); - let loaded = 0; - const CONCURRENT = 20; - let idx = 0; - async function loadBatch() { - const batch = []; - while (idx < imgNodes.length && batch.length < CONCURRENT) { - const n = imgNodes[idx++]; - batch.push(_artMapLoadImage(n.image_url) - .then(bmp => { if (bmp) _artMap.images[n.id] = bmp; }) - .finally(() => { loaded++; })); - } - if (batch.length) await Promise.all(batch); - if (idx < imgNodes.length) return loadBatch(); - } - await loadBatch(); - _artMap.dirty = true; - _artMapRender(); - const le = document.getElementById('artist-map-loading'); - if (le) le.remove(); - }, 50); - - _artMap.dirty = true; - _artMapRender(); - - } catch (err) { - console.error('Genre map error:', err); - const lt = container.querySelector('.artist-map-loading-text'); - if (lt) lt.textContent = 'Error loading genre map'; - } -} - -function openArtistMapExplorerDirect(name) { - if (!name) return; - // Already in map — just reload with new data, don't re-hide sections - _artMap._skipSectionToggle = true; - _openArtistMapExplorerWithName(name); -} - -async function openArtistMapExplorer() { - const name = await _showArtistMapSearchPrompt(); - if (!name) return; - _openArtistMapExplorerWithName(name); -} - -async function _openArtistMapExplorerWithName(name) { - - const container = document.getElementById('artist-map-container'); - if (!container) return; - - const skipToggle = _artMap._skipSectionToggle; - _artMap._skipSectionToggle = false; - - if (!skipToggle) { - document.querySelectorAll('#discover-page > .discover-container > *:not(#artist-map-container)').forEach(el => { - el._prevDisplay = el.style.display; - el.style.display = 'none'; - }); - } - container.style.display = 'flex'; - - const canvas = document.getElementById('artist-map-canvas'); - _artMap.canvas = canvas; - _artMap.ctx = canvas.getContext('2d'); - _artMap.width = container.clientWidth; - _artMap.height = container.clientHeight - 50; - canvas.width = _artMap.width * window.devicePixelRatio; - canvas.height = _artMap.height * window.devicePixelRatio; - canvas.style.width = _artMap.width + 'px'; - canvas.style.height = _artMap.height + 'px'; - _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); - _artMap.offsetX = _artMap.width / 2; - _artMap.offsetY = _artMap.height / 2; - _artMap.placed = []; - _artMap.edges = []; - _artMap.images = {}; - _artMap._nodeById = null; - _artMap.dirty = true; - - const loadingEl = document.createElement('div'); - loadingEl.id = 'artist-map-loading'; - loadingEl.innerHTML = `
Exploring ${escapeHtml(name)}...
`; - container.appendChild(loadingEl); - - document.querySelector('.artmap-brand-text').textContent = 'Artist Explorer'; - - try { - const resp = await fetch(`/api/discover/artist-map/explore?name=${encodeURIComponent(name.trim())}`); - const data = await resp.json(); - if (!data.success || !data.nodes.length) { - const lt = document.querySelector('.artist-map-loading-text'); - if (lt) { - lt.textContent = resp.status === 404 - ? `"${name}" doesn't appear to be a real artist. Try a different name.` - : `No data found for "${name}". Try a different artist.`; - } - setTimeout(() => { - const le = document.getElementById('artist-map-loading'); - if (le) le.remove(); - closeArtistMap(); - }, 2500); - return; - } - - const ring1Count = data.nodes.filter(n => n.ring === 1).length; - const ring2Count = data.nodes.filter(n => n.ring === 2).length; - document.getElementById('artist-map-stats').textContent = - `${data.center} · ${ring1Count} similar · ${ring2Count} extended`; - - _artMap.edges = data.edges; - const WR = _artMap.WATCHLIST_R; - const BUF = _artMap.BUFFER; - - // Layout: center node at origin, ring 1 in circle around it, ring 2 around ring 1 - const centerNode = data.nodes[0]; - centerNode.x = 0; centerNode.y = 0; - centerNode.radius = WR * 1.2; // Extra large center - centerNode.opacity = 1; - centerNode.type = 'center'; - _artMap.placed.push(centerNode); - - const CELL = WR * 2 + BUF * 2; - const grid = {}; - function _gk(x, y) { return `${Math.floor(x / CELL)},${Math.floor(y / CELL)}`; } - function _ga(n) { const k = _gk(n.x, n.y); if (!grid[k]) grid[k] = []; grid[k].push(n); } - function _gc(x, y, r) { - const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL); - for (let dx = -3; dx <= 3; dx++) for (let dy = -3; dy <= 3; dy++) { - const cell = grid[`${cx + dx},${cy + dy}`]; - if (!cell) continue; - for (const p of cell) { - const ddx = x - p.x, ddy = y - p.y; - if (ddx * ddx + ddy * ddy < (r + p.radius + BUF) * (r + p.radius + BUF)) return true; - } - } - return false; - } - _ga(centerNode); - - // Place ring 1 in a circle - const ring1 = data.nodes.filter(n => n.ring === 1); - const ring1Dist = WR * 2.5; - ring1.forEach((n, i) => { - const angle = (i / ring1.length) * Math.PI * 2; - const rank = n.rank || 5; - n.radius = WR * 0.4 + (10 - rank) * WR * 0.03; - n.opacity = 1; - - // Find non-colliding position near ideal - let placed = false; - for (let dist = ring1Dist; dist < ring1Dist + WR * 3; dist += n.radius * 0.5) { - for (let ao = 0; ao < 6; ao++) { - const a = angle + (ao * 0.1 * (ao % 2 ? 1 : -1)); - const tx = Math.cos(a) * dist; - const ty = Math.sin(a) * dist; - if (!_gc(tx, ty, n.radius)) { - n.x = tx; n.y = ty; - _artMap.placed.push(n); - _ga(n); - placed = true; - break; - } - } - if (placed) break; - } - }); - - // Place ring 2 around their ring 1 sources - const ring2 = data.nodes.filter(n => n.ring === 2); - const nodeById = {}; - _artMap.placed.forEach(n => { nodeById[n.id] = n; }); - - ring2.forEach(n => { - // Find the ring 1 node that connects to this - const edge = data.edges.find(e => e.target === n.id); - const src = edge ? nodeById[edge.source] : null; - const cx = src ? src.x : 0; - const cy = src ? src.y : 0; - - n.radius = WR * 0.2 + (n.popularity || 0) / 100 * WR * 0.1; - n.opacity = 1; - - const startDist = (src ? src.radius : WR) + n.radius + BUF; - let placed = false; - for (let dist = startDist; dist < startDist + WR * 2; dist += n.radius * 0.5) { - const steps = Math.max(8, Math.floor(dist * 0.08)); - const off = Math.random() * Math.PI * 2; - for (let a = 0; a < steps; a++) { - const angle = off + (a / steps) * Math.PI * 2; - const tx = cx + Math.cos(angle) * dist; - const ty = cy + Math.sin(angle) * dist; - if (!_gc(tx, ty, n.radius)) { - n.x = tx; n.y = ty; - _artMap.placed.push(n); - _ga(n); - placed = true; - break; - } - } - if (placed) break; - } - }); - - // Build node lookup for edges - _artMap._nodeById = {}; - _artMap.placed.forEach(n => { _artMap._nodeById[n.id] = n; }); - - // Auto-zoom - let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; - _artMap.placed.forEach(n => { - minX = Math.min(minX, n.x - n.radius); - maxX = Math.max(maxX, n.x + n.radius); - minY = Math.min(minY, n.y - n.radius); - maxY = Math.max(maxY, n.y + n.radius); - }); - const mapW = maxX - minX + 200, mapH = maxY - minY + 200; - _artMap.zoom = Math.min(_artMap.width / mapW, _artMap.height / mapH, 1); - _artMap.offsetX = _artMap.width / 2 - ((minX + maxX) / 2) * _artMap.zoom; - _artMap.offsetY = _artMap.height / 2 - ((minY + maxY) / 2) * _artMap.zoom; - - _artMapSetupInteraction(canvas); - - // Load images - const loadingText = container.querySelector('.artist-map-loading-text'); - if (loadingText) loadingText.textContent = `Loading ${_artMap.placed.length} artists...`; - - setTimeout(async () => { - const imgNodes = _artMap.placed.filter(n => n.image_url); - let loaded = 0; - const CONCURRENT = 20; - let idx = 0; - async function loadBatch() { - const batch = []; - while (idx < imgNodes.length && batch.length < CONCURRENT) { - const n = imgNodes[idx++]; - batch.push(_artMapLoadImage(n.image_url) - .then(bmp => { if (bmp) _artMap.images[n.id] = bmp; }) - .finally(() => { loaded++; })); - } - if (batch.length) await Promise.all(batch); - if (idx < imgNodes.length) return loadBatch(); - } - await loadBatch(); - _artMap.dirty = true; - _artMapRender(); - const le = document.getElementById('artist-map-loading'); - if (le) le.remove(); - }, 50); - - _artMap.dirty = true; - _artMapRender(); - - } catch (err) { - console.error('Artist explorer error:', err); - const lt = container.querySelector('.artist-map-loading-text'); - if (lt) lt.textContent = 'Error loading explorer'; - } -} - -function _showArtistMapSearchPrompt() { - return new Promise(resolve => { - const existing = document.getElementById('artmap-search-prompt'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'artmap-search-prompt'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - - overlay.innerHTML = ` -
-
- - - - -
-

Artist Explorer

-

Enter an artist to explore their connections

-
-
- -
- - -
-
- `; - document.body.appendChild(overlay); - - const input = overlay.querySelector('#artmap-explore-input'); - const goBtn = overlay.querySelector('#artmap-explore-go'); - - const submit = () => { - const val = input.value.trim(); - overlay.remove(); - resolve(val || null); - }; - - goBtn.onclick = submit; - input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); }); - setTimeout(() => input.focus(), 50); - }); -} - -function artMapToggleSimilar() { - _artMap._hideSimilar = !_artMap._hideSimilar; - _artMap.dirty = true; - _artMapRender(); - const btn = document.getElementById('artmap-toggle-similar'); - if (btn) btn.style.opacity = _artMap._hideSimilar ? '0.4' : '1'; - showToast(_artMap._hideSimilar ? 'Showing watchlist only' : 'Showing all artists', 'info', 1500); -} - -function _artMapLoadImage(url) { - // Try direct CORS fetch first (zero server load, works for Spotify/iTunes/Discogs) - return fetch(url, { mode: 'cors' }) - .then(r => r.ok ? r.blob() : Promise.reject('not ok')) - .then(b => createImageBitmap(b)) - .catch(() => { - // Fallback: server proxy for CDNs without CORS headers - return fetch('/api/image-proxy?url=' + encodeURIComponent(url)) - .then(r => r.ok ? r.blob() : null) - .then(b => b ? createImageBitmap(b) : null) - .catch(() => null); - }); -} - -function _artMapHideContextMenu() { - const m = document.getElementById('artist-map-context'); - if (m) m.style.display = 'none'; -} - -function _artMapSetupInteraction(canvas) { - // Prevent stacking listeners on repeated opens - if (canvas._artMapListenersAttached) return; - canvas._artMapListenersAttached = true; - - let isPanning = false, panStartX = 0, panStartY = 0; - - canvas.addEventListener('wheel', (e) => { - e.preventDefault(); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const newZoom = Math.max(0.02, Math.min(5, _artMap.zoom * delta)); - // Zoom toward mouse - const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; - _artMap.offsetX = mx - (mx - _artMap.offsetX) * (newZoom / _artMap.zoom); - _artMap.offsetY = my - (my - _artMap.offsetY) * (newZoom / _artMap.zoom); - _artMap.zoom = newZoom; - _artMapRender(); // fast blit - // Debounce hi-res rebuild after zoom settles - clearTimeout(_artMap._zoomRebuild); - _artMap._zoomRebuild = setTimeout(() => { _artMap.dirty = true; _artMapRender(); }, 300); - }, { passive: false }); - - let clickStart = null; - - // Keyboard shortcuts - function _artMapKeyHandler(e) { - if (!document.getElementById('artist-map-container') || document.getElementById('artist-map-container').style.display === 'none') return; - if (e.target.tagName === 'INPUT') return; // don't intercept search typing - if (e.key === 'Escape') { closeArtistMap(); e.preventDefault(); } - else if (e.key === '=' || e.key === '+') { artMapZoom(1.3); e.preventDefault(); } - else if (e.key === '-') { artMapZoom(0.7); e.preventDefault(); } - else if (e.key === '0') { artMapFitToView(); e.preventDefault(); } - else if (e.key === 'f' || e.key === 'F') { artMapFitToView(); e.preventDefault(); } - else if (e.key === 's' || e.key === 'S') { - const input = document.getElementById('artist-map-search'); - if (input) { input.focus(); e.preventDefault(); } - } - else if (e.key === 'h' || e.key === 'H') { - // Toggle similar artists visibility - _artMap._hideSimilar = !_artMap._hideSimilar; - _artMap.dirty = true; - _artMapRender(); - } - } - window.addEventListener('keydown', _artMapKeyHandler); - _artMap._keyHandler = _artMapKeyHandler; - - // Right-click context menu - canvas.addEventListener('contextmenu', (e) => { - e.preventDefault(); - const { nx, ny } = _artMapScreenToWorld(e, canvas); - const node = _artMapHitTest(nx, ny); - if (!node || node._isLabel) { _artMapHideContextMenu(); return; } - - const menu = document.getElementById('artist-map-context') || (() => { - const m = document.createElement('div'); - m.id = 'artist-map-context'; - m.className = 'artmap-context-menu'; - document.getElementById('artist-map-container').appendChild(m); - return m; - })(); - - const hasId = node.spotify_id || node.itunes_id || node.deezer_id; - const activeSource = window._yaActiveSource || 'spotify'; - const bestId = node[activeSource + '_id'] || node.spotify_id || node.itunes_id || node.deezer_id || ''; - const bestSource = node[activeSource + '_id'] ? activeSource : node.spotify_id ? 'spotify' : node.itunes_id ? 'itunes' : 'deezer'; - - menu.innerHTML = ` -
- Artist Info -
-
- 💿 View Discography -
-
- 👁 ${node.type === 'watchlist' ? 'On Watchlist' : 'Add to Watchlist'} -
- `; - menu.style.display = 'block'; - menu.style.left = Math.min(e.clientX, window.innerWidth - 200) + 'px'; - menu.style.top = Math.min(e.clientY, window.innerHeight - 200) + 'px'; - - // Close on next click anywhere - setTimeout(() => { - window.addEventListener('click', _artMapHideContextMenu, { once: true }); - }, 10); - }); - - canvas.addEventListener('mousedown', (e) => { - if (e.button !== 0) return; // left button only - clickStart = { x: e.clientX, y: e.clientY, time: Date.now() }; - isPanning = true; - panStartX = e.clientX; - panStartY = e.clientY; - }); - - canvas.addEventListener('mousemove', (e) => { - if (isPanning) { - _artMap.offsetX += e.clientX - panStartX; - _artMap.offsetY += e.clientY - panStartY; - panStartX = e.clientX; - panStartY = e.clientY; - _artMapRender(); - } else { - const { nx, ny } = _artMapScreenToWorld(e, canvas); - const prev = _artMap.hoveredNode; - _artMap.hoveredNode = _artMapHitTest(nx, ny); - canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab'; - _artMapShowTooltip(e, _artMap.hoveredNode); - if (prev !== _artMap.hoveredNode) { - // Reset constellation highlight timer - clearTimeout(_artMap._constellationTimer); - if (_artMap._constellationActive) { - _artMap._constellationActive = false; - _artMapAnimateConstellation(); // fade out - } - if (_artMap.hoveredNode) { - // Delay constellation effect by 800ms of sustained hover - _artMap._constellationTimer = setTimeout(() => { - if (_artMap.hoveredNode) { - _artMap._constellationActive = true; - _artMap._constellationFade = 0; - _artMap._constellationCache = null; - _artMapAnimateConstellation(); - } - }, 800); - } - _artMapRender(); - } - } - }); - - canvas.addEventListener('mouseup', (e) => { - if (e.button !== 0) return; // left button only - const wasDrag = clickStart && (Math.abs(e.clientX - clickStart.x) > 5 || Math.abs(e.clientY - clickStart.y) > 5); - isPanning = false; - - if (!wasDrag && clickStart) { - // It was a click — find the node under cursor - const { nx, ny } = _artMapScreenToWorld(e, canvas); - const node = _artMapHitTest(nx, ny); - if (node) { - _artMap._ripple = { x: node.x, y: node.y, radius: node.radius, start: performance.now() }; - _artMapRender(); - if (node.spotify_id || node.itunes_id || node.deezer_id) { - setTimeout(() => openYourArtistInfoModal_direct(node), 200); - } - } - } - - clickStart = null; - _artMapShowTooltip(e, null); - }); - - canvas.addEventListener('mouseleave', () => { - _artMapShowTooltip(null, null); - clearTimeout(_artMap._constellationTimer); - if (_artMap._constellationActive) { - _artMap._constellationActive = false; - _artMapAnimateConstellation(); - } - _artMap.hoveredNode = null; - _artMapRender(); - }); - - // Touch support — single finger pan, pinch to zoom - let lastTouches = null; - canvas.addEventListener('touchstart', (e) => { - e.preventDefault(); - lastTouches = [...e.touches]; - }, { passive: false }); - - canvas.addEventListener('touchmove', (e) => { - e.preventDefault(); - if (!lastTouches) return; - const touches = [...e.touches]; - - if (touches.length === 1 && lastTouches.length === 1) { - // Pan - _artMap.offsetX += touches[0].clientX - lastTouches[0].clientX; - _artMap.offsetY += touches[0].clientY - lastTouches[0].clientY; - _artMapRender(); - } else if (touches.length === 2 && lastTouches.length === 2) { - // Pinch zoom - const prevDist = Math.hypot(lastTouches[1].clientX - lastTouches[0].clientX, lastTouches[1].clientY - lastTouches[0].clientY); - const curDist = Math.hypot(touches[1].clientX - touches[0].clientX, touches[1].clientY - touches[0].clientY); - const factor = curDist / prevDist; - const cx = (touches[0].clientX + touches[1].clientX) / 2; - const cy = (touches[0].clientY + touches[1].clientY) / 2; - const newZoom = Math.max(0.02, Math.min(3, _artMap.zoom * factor)); - _artMap.offsetX = cx - (cx - _artMap.offsetX) * (newZoom / _artMap.zoom); - _artMap.offsetY = cy - (cy - _artMap.offsetY) * (newZoom / _artMap.zoom); - _artMap.zoom = newZoom; - _artMap.dirty = true; - _artMapRender(); - } - lastTouches = touches; - }, { passive: false }); - - canvas.addEventListener('touchend', (e) => { - e.preventDefault(); - // Tap to click - if (lastTouches && lastTouches.length === 1 && e.changedTouches.length === 1) { - const t = e.changedTouches[0]; - const rect = canvas.getBoundingClientRect(); - const wx = (t.clientX - rect.left - _artMap.offsetX) / _artMap.zoom; - const wy = (t.clientY - rect.top - _artMap.offsetY) / _artMap.zoom; - const node = _artMapHitTest(wx, wy); - if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { - openYourArtistInfoModal_direct(node); - } - } - lastTouches = null; - }, { passive: false }); - - // Handle resize - window.addEventListener('resize', () => { - const container = document.getElementById('artist-map-container'); - if (!container || container.style.display === 'none') return; - _artMap.width = container.clientWidth; - _artMap.height = container.clientHeight - 50; - canvas.width = _artMap.width * window.devicePixelRatio; - canvas.height = _artMap.height * window.devicePixelRatio; - canvas.style.width = _artMap.width + 'px'; - canvas.style.height = _artMap.height + 'px'; - _artMap.ctx.scale(window.devicePixelRatio, window.devicePixelRatio); - }); -} - -function _artMapScreenToWorld(e, canvas) { - const rect = canvas.getBoundingClientRect(); - const sx = e.clientX - rect.left; - const sy = e.clientY - rect.top; - // Inverse of: translate(offsetX, offsetY) → scale(zoom) - return { - nx: (sx - _artMap.offsetX) / _artMap.zoom, - ny: (sy - _artMap.offsetY) / _artMap.zoom, - }; -} - -function _artMapHitTest(wx, wy) { - // Check watchlist first (drawn on top), then similar - const sorted = [..._artMap.placed].sort((a, b) => - (b.type === 'watchlist' ? 1 : 0) - (a.type === 'watchlist' ? 1 : 0)); - for (const n of sorted) { - if ((n.opacity || 0) < 0.3) continue; - const dx = wx - n.x; - const dy = wy - n.y; - if (dx * dx + dy * dy <= n.radius * n.radius) return n; - } - return null; -} - -async function openYourArtistInfoModal_direct(node) { - // Determine best source ID — prefer active metadata source - let bestId = '', bestSource = ''; - // Check what the active source is - const activeSource = window._yaActiveSource || 'spotify'; - const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id'] - : activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id'] - : activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id'] - : ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']; - const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs' }; - for (const key of sourceOrder) { - if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; } - } - - // Gather ALL connected artists from map edges (both directions) - const related = []; - const relatedIds = new Set(); - const nById = _artMap._nodeById || {}; - _artMap.edges.forEach(e => { - if (e.source === node.id && nById[e.target] && !relatedIds.has(e.target)) { - related.push(nById[e.target]); - relatedIds.add(e.target); - } - if (e.target === node.id && nById[e.source] && !relatedIds.has(e.source)) { - related.push(nById[e.source]); - relatedIds.add(e.source); - } - }); - - const poolEntry = { - id: node.id, - artist_name: node.name, - active_source_id: bestId, - active_source: bestSource, - image_url: node.image_url || '', - spotify_artist_id: node.spotify_id || '', - itunes_artist_id: node.itunes_id || '', - deezer_artist_id: node.deezer_id || '', - discogs_artist_id: node.discogs_id || '', - source_services: [], - on_watchlist: node.type === 'watchlist' ? 1 : 0, - _related: related, - }; - if (!window._yaArtists) window._yaArtists = {}; - window._yaArtists[node.id] = poolEntry; - openYourArtistInfoModal(node.id); -} - -async function loadDiscoveryShuffle() { - try { - const container = document.getElementById('personalized-discovery-shuffle'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/discovery-shuffle?limit=50'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedDiscoveryShuffle = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading discovery shuffle:', error); - } -} - -async function loadFamiliarFavorites() { - try { - const container = document.getElementById('personalized-familiar-favorites'); - if (!container) return; - - const response = await fetch('/api/discover/personalized/familiar-favorites?limit=50'); - if (!response.ok) return; - - const data = await response.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) { - container.closest('.discover-section').style.display = 'none'; - return; - } - - personalizedFamiliarFavorites = data.tracks; - renderCompactPlaylist(container, data.tracks); - container.closest('.discover-section').style.display = 'block'; - - } catch (error) { - console.error('Error loading familiar favorites:', error); - } -} - -// =============================== -// BECAUSE YOU LISTEN TO -// =============================== - -async function loadBecauseYouListenTo() { - try { - const resp = await fetch('/api/discover/because-you-listen-to'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.sections || data.sections.length === 0) return; - - // Find or create the BYLT container - let byltContainer = document.getElementById('discover-bylt-sections'); - if (!byltContainer) { - // Insert after the release radar section - const releaseRadar = document.getElementById('discover-release-radar'); - if (!releaseRadar) return; - const parent = releaseRadar.closest('.discover-section'); - if (!parent) return; - - byltContainer = document.createElement('div'); - byltContainer.id = 'discover-bylt-sections'; - parent.parentNode.insertBefore(byltContainer, parent.nextSibling); - } - - byltContainer.innerHTML = data.sections.map((section, idx) => ` -
-
-
- ${section.artist_image ? `` : ''} -
-
Because you listen to
-

${_esc(section.artist_name)}

-
-
-
- -
- `).join(''); - - // Render track cards in each carousel - data.sections.forEach((section, idx) => { - const carousel = document.getElementById(`bylt-carousel-${idx}`); - if (!carousel) return; - carousel.innerHTML = section.tracks.map(t => ` -
-
- ${t.image_url ? `` : '
🎵
'} -
-
${_esc(t.name)}
-
${_esc(t.artist)}
-
- `).join(''); - }); - - } catch (error) { - console.debug('Error loading Because You Listen To:', error); - } -} - -// =============================== -// CACHE DISCOVERY SECTIONS -// =============================== - -// Global arrays for cache discovery click handlers -let _cacheDiscoverData = {}; - -function _cacheDiscoverCard(item, type, sectionKey, index) { - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); - const coverUrl = item.image_url || '/static/placeholder-album.png'; - const title = item.name || ''; - const subtitle = item.artist_name || ''; - const meta = item.release_date ? item.release_date.substring(0, 10) : (item.label || ''); - const onclick = `openCacheDiscoverAlbum('${sectionKey}',${index})`; - const libBadge = item.in_library ? '
In Library
' : ''; - return `
-
- ${_esc(title)} - ${libBadge} -
-
-

${_esc(title)}

-

${_esc(subtitle)}

- ${meta ? `

${_esc(meta)}

` : ''} -
-
`; -} - -async function openCacheDiscoverAlbum(sectionKey, index) { - const items = _cacheDiscoverData[sectionKey]; - if (!items || !items[index]) return; - const item = items[index]; - const source = item.source || 'spotify'; - const albumId = item.entity_id; - - // Deep cuts / genre dive tracks — find the real album by searching the cache - if (sectionKey === 'deep_cuts' || sectionKey === 'genre_dive_tracks') { - document.getElementById('genre-deep-dive-modal')?.remove(); - const albumName = item.album_name || item.name || ''; - const artistName = item.artist_name || ''; - const trackAlbumId = item.album_id || ''; - const trackSource = item.source || source; - - if (!artistName) { - showToast('No artist data available for this track', 'error'); - return; - } - - showLoadingOverlay(`Loading ${albumName}...`); - try { - let resolvedSource = trackSource; - let resolvedId = trackAlbumId; - let response; - - // If we have an album_id, use it directly - if (trackAlbumId) { - const _params = new URLSearchParams({ name: albumName, artist: artistName }); - response = await fetch(`/api/discover/album/${trackSource}/${trackAlbumId}?${_params}`); - } - - // Fallback: resolve by name+artist if no album_id or direct fetch failed - if (!trackAlbumId || (response && !response.ok)) { - const searchResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(albumName)}&artist=${encodeURIComponent(artistName)}`); - if (searchResp.ok) { - const searchData = await searchResp.json(); - if (searchData.success && searchData.entity_id) { - resolvedSource = searchData.source || trackSource; - resolvedId = searchData.entity_id; - const _params = new URLSearchParams({ name: albumName, artist: artistName }); - response = await fetch(`/api/discover/album/${resolvedSource}/${resolvedId}?${_params}`); - } - } - } - - if (!response || !response.ok) throw new Error('Failed to fetch album tracks'); - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found'); - - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: artistName }]; - if (Array.isArray(artists)) artists = artists.map(a => a.name || a); - return { - id: track.id, name: track.name, artists, - album: { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] }, - duration_ms: track.duration_ms || 0, track_number: track.track_number || 0, - }; - }); - const artistContext = { id: albumData.artists?.[0]?.id || '', name: artistName, source: resolvedSource }; - const albumContext = { id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album', total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '', images: albumData.images || [] }; - await openDownloadMissingModalForYouTube(`discover_cache_${resolvedId}`, albumData.name, spotifyTracks, artistContext, albumContext); - hideLoadingOverlay(); - } catch (error) { - console.error('Error opening deep cut album:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - hideLoadingOverlay(); - } - return; - } - - if (!albumId) { - showToast('No album ID available', 'error'); - return; - } - - // Close genre deep dive modal if open - document.getElementById('genre-deep-dive-modal')?.remove(); - - showLoadingOverlay(`Loading ${item.name || 'album'}...`); - try { - const _params = new URLSearchParams({ name: item.name || '', artist: item.artist_name || '' }); - let response = await fetch(`/api/discover/album/${source}/${albumId}?${_params}`); - - // If 404 (stale cache entry), try resolving via name+artist - if (response.status === 404) { - const resolveResp = await fetch(`/api/discover/resolve-cache-album?name=${encodeURIComponent(item.name || '')}&artist=${encodeURIComponent(item.artist_name || '')}`); - if (resolveResp.ok) { - const resolved = await resolveResp.json(); - if (resolved.success && resolved.entity_id && resolved.entity_id !== albumId) { - response = await fetch(`/api/discover/album/${resolved.source || source}/${resolved.entity_id}?${_params}`); - } - } - } - - if (!response.ok) throw new Error('Album not available — it may have been removed from the source'); - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) throw new Error('No tracks found'); - - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || albumData.artists || [{ name: item.artist_name }]; - if (Array.isArray(artists)) artists = artists.map(a => a.name || a); - return { - id: track.id, - name: track.name, - artists: artists, - album: { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [], - }, - duration_ms: track.duration_ms || 0, - track_number: track.track_number || 0, - }; - }); - - const artistContext = { - id: albumData.artists?.[0]?.id || '', - name: item.artist_name || albumData.artists?.[0]?.name || '', - source: source, - }; - const albumContext = { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [], - }; - - await openDownloadMissingModalForYouTube( - `discover_cache_${albumId}`, albumData.name, spotifyTracks, artistContext, albumContext - ); - hideLoadingOverlay(); - } catch (error) { - console.error('Error opening cache discover album:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - hideLoadingOverlay(); - } -} - -function _insertCacheSection(id, title, subtitle, html, position) { - const container = document.getElementById('discover-bylt-sections') || document.querySelector('.discover-container'); - if (!container) return; - let section = document.getElementById(id); - if (!section) { - section = document.createElement('div'); - section.id = id; - section.className = 'discover-section'; - if (position === 'top') { - // Insert after the hero section (first child), not before it - const hero = container.querySelector('.discover-hero'); - if (hero && hero.nextSibling) { - container.insertBefore(section, hero.nextSibling); - } else { - container.prepend(section); - } - } else { - container.appendChild(section); - } - } - section.innerHTML = ` -
-
-
${subtitle}
-

${title}

-
-
- - `; -} - -async function loadCacheUndiscoveredAlbums() { - try { - const resp = await fetch('/api/discover/undiscovered-albums'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.albums || !data.albums.length) return; - _cacheDiscoverData['undiscovered'] = data.albums; - _insertCacheSection('cache-undiscovered', - 'Undiscovered Albums', 'From artists you love', - data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'undiscovered', i)).join('')); - } catch (e) { console.debug('Cache undiscovered albums:', e); } -} - -async function loadCacheGenreNewReleases() { - try { - const resp = await fetch('/api/discover/genre-new-releases'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.albums || !data.albums.length) return; - _cacheDiscoverData['genre_releases'] = data.albums; - _insertCacheSection('cache-genre-releases', - 'New In Your Genres', 'Released in the last 90 days', - data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'genre_releases', i)).join('')); - } catch (e) { console.debug('Cache genre new releases:', e); } -} - -async function loadCacheLabelExplorer() { - try { - const resp = await fetch('/api/discover/label-explorer'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.albums || !data.albums.length) return; - _cacheDiscoverData['label_explorer'] = data.albums; - _insertCacheSection('cache-label-explorer', - 'From Your Labels', 'Popular on labels in your library', - data.albums.map((a, i) => _cacheDiscoverCard(a, 'album', 'label_explorer', i)).join('')); - } catch (e) { console.debug('Cache label explorer:', e); } -} - -async function loadCacheDeepCuts() { - try { - const resp = await fetch('/api/discover/deep-cuts'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.tracks || !data.tracks.length) return; - _cacheDiscoverData['deep_cuts'] = data.tracks; - _insertCacheSection('cache-deep-cuts', - 'Deep Cuts', 'Hidden tracks from artists you know', - data.tracks.map((t, i) => _cacheDiscoverCard(t, 'track', 'deep_cuts', i)).join('')); - } catch (e) { console.debug('Cache deep cuts:', e); } -} - -async function loadCacheGenreExplorer() { - try { - const resp = await fetch('/api/discover/genre-explorer'); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.genres || !data.genres.length) return; - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/'/g, '''); - const html = `
${data.genres.map(g => ` -
- ${_esc(g.genre)} - ${g.artist_count} artist${g.artist_count !== 1 ? 's' : ''} - ${!g.explored ? 'New' : ''} -
- `).join('')}
`; - _insertCacheSection('cache-genre-explorer', - 'Genre Explorer', 'Tap a genre to explore', html, 'top'); - } catch (e) { console.debug('Cache genre explorer:', e); } -} - -async function openGenreDeepDive(genre) { - document.getElementById('genre-deep-dive-modal')?.remove(); - - const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); - const _fmtNum = (n) => { - if (!n) return ''; - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(0) + 'K'; - return n.toString(); - }; - const _fmtDur = (ms) => { - if (!ms) return ''; - const m = Math.floor(ms / 60000); - const s = Math.floor((ms % 60000) / 1000); - return `${m}:${s.toString().padStart(2, '0')}`; - }; - - const overlay = document.createElement('div'); - overlay.id = 'genre-deep-dive-modal'; - overlay.className = 'genre-dive-overlay'; - overlay.innerHTML = ` -
-
-
-
Genre Deep Dive
-

${_esc(genre)}

-
- -
-
-
Exploring ${_esc(genre)}...
-
-
- `; - overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); - document.body.appendChild(overlay); - - try { - const resp = await fetch(`/api/discover/genre-deep-dive?genre=${encodeURIComponent(genre)}`); - if (!resp.ok) throw new Error('Failed to load'); - const data = await resp.json(); - if (!data.success) throw new Error('Failed'); - - const body = document.getElementById('genre-dive-body'); - if (!body) return; - - // Update header with counts - const subtitle = document.querySelector('.genre-dive-subtitle'); - if (subtitle) { - const parts = []; - if (data.artists?.length) parts.push(`${data.artists.length} artist${data.artists.length !== 1 ? 's' : ''}`); - if (data.tracks?.length) parts.push(`${data.tracks.length} track${data.tracks.length !== 1 ? 's' : ''}`); - if (data.albums?.length) parts.push(`${data.albums.length} album${data.albums.length !== 1 ? 's' : ''}`); - subtitle.textContent = parts.length ? parts.join(' · ') : 'Genre Deep Dive'; - } - - let html = ''; - - // Related genres — clickable pills that reload the modal - if (data.related_genres && data.related_genres.length) { - html += ``; - } - - // Artists section — clickable, navigates to artist page - // Uses library_id for in-library artists (source-agnostic), falls back to search by name - if (data.artists && data.artists.length) { - html += `
-

🎤 Artists in ${_esc(genre)}

-
- ${data.artists.map(a => { - // Always open on Artists page with discography — pass source for correct routing - const imgUrl = _esc(a.image_url || ''); - const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; - const srcClass = (a.source || '').toLowerCase(); - return `
-
- ${!a.image_url ? '🎤' : ''} -
- -
${_esc(a.name)}
- ${a.followers ? `
${_fmtNum(a.followers)} followers
` : ''} - ${a.library_id ? '
In Library
' : ''} -
`; - }).join('')} -
-
`; - } - - // Tracks section — clickable, opens album download - if (data.tracks && data.tracks.length) { - _cacheDiscoverData['genre_dive_tracks'] = data.tracks; - html += `
-

🎵 Popular Tracks

-
- ${data.tracks.map((t, i) => { - const tSrcClass = (t.source || '').toLowerCase(); - return ` -
-
${i + 1}
-
- ${!t.image_url ? '🎵' : ''} -
-
-
${_esc(t.name)}
-
${_esc(t.artist_name)}${t.album_name ? ' · ' + _esc(t.album_name) : ''}
-
- -
${_fmtDur(t.duration_ms)}
-
- `}).join('')} -
-
`; - } - - // Albums section - if (data.albums && data.albums.length) { - _cacheDiscoverData['genre_dive_albums'] = data.albums; - html += `
-

💿 Albums

- -
`; - } - - if (!html) { - html = '
🔍

No cached data found for this genre yet

Search for artists in this genre to build up the cache

'; - } - - body.innerHTML = html; - } catch (e) { - const body = document.getElementById('genre-dive-body'); - if (body) body.innerHTML = '
Failed to load genre data
'; - } -} - -// =============================== -// BUILD A PLAYLIST FEATURE -// =============================== - -let buildPlaylistSearchTimeout = null; - -async function searchBuildPlaylistArtists() { - const searchInput = document.getElementById('build-playlist-search'); - const resultsContainer = document.getElementById('build-playlist-search-results'); - const spinner = document.getElementById('bp-search-spinner'); - const query = searchInput.value.trim(); - - if (!query) { - resultsContainer.innerHTML = ''; - resultsContainer.style.display = 'none'; - if (spinner) spinner.style.display = 'none'; - return; - } - - // Debounce search - clearTimeout(buildPlaylistSearchTimeout); - buildPlaylistSearchTimeout = setTimeout(async () => { - if (spinner) spinner.style.display = 'flex'; - try { - const response = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(query)}`); - const data = await response.json(); - if (!response.ok) { - showToast(data.error || 'Search failed', 'error'); - return; - } - if (!data.success || !data.artists || data.artists.length === 0) { - resultsContainer.innerHTML = '
No artists found for "' + query.replace(/'; - resultsContainer.style.display = 'block'; - return; - } - - // Filter out already-selected artists - const selectedIds = new Set(buildPlaylistSelectedArtists.map(a => a.id)); - const filtered = data.artists.filter(a => !selectedIds.has(a.id)); - - if (filtered.length === 0) { - resultsContainer.innerHTML = '
All results already selected
'; - resultsContainer.style.display = 'block'; - return; - } - - // Render search results - let html = ''; - filtered.forEach(artist => { - const imageUrl = artist.image_url || '/static/placeholder-album.png'; - const escapedName = artist.name.replace(/'/g, "\\'").replace(/"/g, '"'); - html += ` -
- ${artist.name} - ${artist.name} - + Add -
- `; - }); - - resultsContainer.innerHTML = html; - resultsContainer.style.display = 'block'; - - } catch (error) { - console.error('Error searching artists:', error); - } finally { - if (spinner) spinner.style.display = 'none'; - } - }, 400); -} - -function addBuildPlaylistArtist(artistId, artistName, imageUrl) { - if (buildPlaylistSelectedArtists.some(a => a.id === artistId)) { - showToast('Artist already selected', 'warning'); - return; - } - if (buildPlaylistSelectedArtists.length >= 5) { - showToast('Maximum 5 seed artists', 'warning'); - return; - } - - buildPlaylistSelectedArtists.push({ - id: artistId, - name: artistName, - image_url: imageUrl - }); - - renderBuildPlaylistSelectedArtists(); - - // Clear search - document.getElementById('build-playlist-search').value = ''; - document.getElementById('build-playlist-search-results').innerHTML = ''; - document.getElementById('build-playlist-search-results').style.display = 'none'; -} - -function removeBuildPlaylistArtist(artistId) { - buildPlaylistSelectedArtists = buildPlaylistSelectedArtists.filter(a => a.id !== artistId); - renderBuildPlaylistSelectedArtists(); -} - -function renderBuildPlaylistSelectedArtists() { - const container = document.getElementById('build-playlist-selected-artists'); - const generateBtn = document.getElementById('build-playlist-generate-btn'); - const counter = document.getElementById('bp-selected-counter'); - const count = buildPlaylistSelectedArtists.length; - - if (counter) counter.textContent = `${count} / 5`; - - if (count === 0) { - container.innerHTML = ` -
- - Search above to add seed artists -
`; - generateBtn.disabled = true; - return; - } - - let html = ''; - buildPlaylistSelectedArtists.forEach(artist => { - const escapedId = artist.id.replace(/'/g, "\\'"); - html += ` -
- ${artist.name} - ${artist.name} - -
- `; - }); - - container.innerHTML = html; - generateBtn.disabled = false; -} - -let buildPlaylistTracks = []; - -async function generateBuildPlaylist() { - if (buildPlaylistSelectedArtists.length === 0) { - showToast('Please select at least 1 artist', 'warning'); - return; - } - - const generateBtn = document.getElementById('build-playlist-generate-btn'); - const resultsContainer = document.getElementById('build-playlist-results'); - const resultsWrapper = document.getElementById('build-playlist-results-wrapper'); - const loadingIndicator = document.getElementById('build-playlist-loading'); - const metadataDisplay = document.getElementById('build-playlist-metadata-display'); - const titleEl = document.getElementById('build-playlist-results-title'); - const subtitleEl = document.getElementById('build-playlist-results-subtitle'); - - // Show loading, hide search area - generateBtn.disabled = true; - loadingIndicator.style.display = 'flex'; - resultsWrapper.style.display = 'none'; - resultsContainer.innerHTML = ''; - - try { - const seedIds = buildPlaylistSelectedArtists.map(a => a.id); - const response = await fetch('/api/discover/build-playlist/generate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - seed_artist_ids: seedIds, - playlist_size: 50 - }) - }); - - const data = await response.json(); - if (!response.ok || !data.success) { - throw new Error(data.error || 'Failed to generate playlist'); - } - if (!data.playlist || !data.playlist.tracks || data.playlist.tracks.length === 0) { - throw new Error(data.playlist?.error || 'No tracks found. Try different seed artists.'); - } - - // Store tracks globally - buildPlaylistTracks = data.playlist.tracks; - - // Update title and subtitle - const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', '); - titleEl.textContent = 'Custom Playlist'; - subtitleEl.textContent = `Based on: ${artistNames}`; - - // Render metadata - const metadata = data.playlist.metadata; - metadataDisplay.innerHTML = ` - - `; - - // Render playlist - renderCompactPlaylist(resultsContainer, data.playlist.tracks); - - // Show results wrapper - resultsWrapper.style.display = 'block'; - - } catch (error) { - console.error('Error generating playlist:', error); - resultsWrapper.style.display = 'none'; - showToast(error.message || 'Failed to generate playlist', 'error'); - } finally { - loadingIndicator.style.display = 'none'; - generateBtn.disabled = false; - } -} - -async function openDownloadModalForBuildPlaylist() { - if (!buildPlaylistTracks || buildPlaylistTracks.length === 0) { - showToast('No playlist tracks available', 'warning'); - return; - } - - const artistNames = buildPlaylistSelectedArtists.map(a => a.name).join(', '); - const playlistName = `Custom Playlist - ${artistNames}`; - const virtualPlaylistId = 'build_playlist_custom'; - - // Open download modal - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, buildPlaylistTracks); -} - -function openDailyMix(mixIndex) { - const mix = personalizedDailyMixes[mixIndex]; - if (!mix || !mix.tracks) return; - - // TODO: Open modal or dedicated view for Daily Mix - console.log('Opening Daily Mix:', mix.name); -} - -// =============================== -// DISCOVER PLAYLIST ACTIONS -// =============================== - -async function openDownloadModalForDiscoverPlaylist(playlistType, playlistName) { - console.log(`📥 Opening Download Missing Tracks modal for ${playlistName}`); - - try { - // Get tracks based on playlist type - let tracks = []; - if (playlistType === 'release_radar') { - tracks = discoverReleaseRadarTracks; - } else if (playlistType === 'discovery_weekly') { - tracks = discoverWeeklyTracks; - } else if (playlistType === 'seasonal_playlist') { - tracks = discoverSeasonalTracks; - } else if (playlistType === 'popular_picks') { - tracks = personalizedPopularPicks; - } else if (playlistType === 'hidden_gems') { - tracks = personalizedHiddenGems; - } else if (playlistType === 'discovery_shuffle') { - tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; - } else if (playlistType === 'recently_added') { - tracks = personalizedRecentlyAdded; - } else if (playlistType === 'top_tracks') { - tracks = personalizedTopTracks; - } else if (playlistType === 'forgotten_favorites') { - tracks = personalizedForgottenFavorites; - } else if (playlistType === 'build_playlist') { - tracks = buildPlaylistTracks; - } - - if (!tracks || tracks.length === 0) { - showToast(`No tracks available for ${playlistName}`, 'warning'); - return; - } - - // Convert discover tracks to format expected by download modal - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - - // Use track_data_json if available, otherwise construct from track data - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - // Fallback: construct track object from available data - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - - // Normalize artists to array of strings for modal compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; - }); - - // Create virtual playlist ID - const virtualPlaylistId = `discover_${playlistType}`; - - // Use existing modal system (same as YouTube/Tidal playlists) - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - } catch (error) { - console.error('Error opening download modal for discover playlist:', error); - showToast(`Failed to open download modal: ${error.message}`, 'error'); - hideLoadingOverlay(); // Ensure overlay is hidden on error - } -} - -function updateDiscoverDownloadButton(playlistType, state) { - /** - * Update the download button appearance based on download state - * @param {string} playlistType - 'release_radar' or 'discovery_weekly' - * @param {string} state - 'idle', 'downloading', or 'complete' - */ - const buttonId = `${playlistType}-download-btn`; - const button = document.getElementById(buttonId); - - if (!button) return; - - const icon = button.querySelector('.button-icon'); - const text = button.querySelector('.button-text'); - - if (state === 'downloading') { - if (icon) icon.textContent = '⏳'; - if (text) text.textContent = 'View Progress'; - button.title = 'View download progress'; - } else { - if (icon) icon.textContent = '↓'; - if (text) text.textContent = 'Download'; - button.title = 'Download missing tracks'; - } -} - -function checkForActiveDiscoverDownloads() { - /** - * Check for active download processes and update button states - * Only runs if discover page is actually loaded in the DOM - */ - // Check if discover page is loaded by looking for a discover-specific element - const discoverPage = document.getElementById('release-radar-download-btn') || - document.getElementById('discovery-weekly-download-btn'); - - if (!discoverPage) return; - - const discoverPlaylists = [ - { id: 'discover_release_radar', type: 'release_radar' }, - { id: 'discover_discovery_weekly', type: 'discovery_weekly' } - ]; - - discoverPlaylists.forEach(({ id, type }) => { - if (activeDownloadProcesses[id]) { - const process = activeDownloadProcesses[id]; - if (process.status === 'running' || process.status === 'idle') { - updateDiscoverDownloadButton(type, 'downloading'); - } - } - }); -} - -async function startDiscoverPlaylistSync(playlistType, playlistName) { - console.log(`🔄 Starting sync for ${playlistName}`); - - // Get tracks based on playlist type - let tracks = []; - if (playlistType === 'release_radar') { - tracks = discoverReleaseRadarTracks; - } else if (playlistType === 'discovery_weekly') { - tracks = discoverWeeklyTracks; - } else if (playlistType === 'seasonal_playlist') { - tracks = discoverSeasonalTracks; - } else if (playlistType === 'popular_picks') { - tracks = personalizedPopularPicks; - } else if (playlistType === 'hidden_gems') { - tracks = personalizedHiddenGems; - } else if (playlistType === 'discovery_shuffle') { - tracks = personalizedDiscoveryShuffle; - } else if (playlistType === 'familiar_favorites') { - tracks = personalizedFamiliarFavorites; - } else if (playlistType === 'build_playlist') { - tracks = buildPlaylistTracks; - } - - if (!tracks || tracks.length === 0) { - showToast(`No tracks available for ${playlistName}`, 'warning'); - return; - } - - // Convert to format expected by sync API - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - - // Use track_data_json if available - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - // Fallback: construct track object - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - - // Normalize artists to array of strings for sync compatibility - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - - return spotifyTrack; - }); - - // Create virtual playlist ID - const virtualPlaylistId = `discover_${playlistType}`; - - // Store in cache for sync function - playlistTrackCache[virtualPlaylistId] = spotifyTracks; - - // Create virtual playlist object - const virtualPlaylist = { - id: virtualPlaylistId, - name: playlistName, - track_count: spotifyTracks.length - }; - - // Add to spotify playlists array if not already there - if (!spotifyPlaylists.find(p => p.id === virtualPlaylistId)) { - spotifyPlaylists.push(virtualPlaylist); - } - - // Show sync status display (convert underscores to hyphens for ID) - const statusId = playlistType.replace(/_/g, '-') + '-sync-status'; - const statusDisplay = document.getElementById(statusId); - if (statusDisplay) { - statusDisplay.style.display = 'block'; - } - - // Disable sync button to prevent duplicate syncs (convert underscores to hyphens for ID) - const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; - const syncButton = document.getElementById(buttonId); - if (syncButton) { - syncButton.disabled = true; - syncButton.style.opacity = '0.5'; - syncButton.style.cursor = 'not-allowed'; - } - - // Start sync using existing function - await startPlaylistSync(virtualPlaylistId); - - // Extract image URL from first track for download bar bubble - let imageUrl = null; - if (spotifyTracks && spotifyTracks.length > 0) { - const firstTrack = spotifyTracks[0]; - if (firstTrack.album && firstTrack.album.images && firstTrack.album.images.length > 0) { - imageUrl = firstTrack.album.images[0].url; - } - } - - // Add to discover download bar - addDiscoverDownload(virtualPlaylistId, playlistName, playlistType, imageUrl); - - // Start polling for progress updates - startDiscoverSyncPolling(playlistType, virtualPlaylistId); -} - -// Track active discover sync pollers -const discoverSyncPollers = {}; - -function startDiscoverSyncPolling(playlistType, virtualPlaylistId) { - // Stop any existing poller for this playlist type - if (discoverSyncPollers[playlistType]) { - clearInterval(discoverSyncPollers[playlistType]); - } - - console.log(`🔄 Starting sync polling for ${playlistType} (${virtualPlaylistId})`); - - // Phase 5: Subscribe via WebSocket - if (socketConnected) { - socket.emit('sync:subscribe', { playlist_ids: [virtualPlaylistId] }); - _syncProgressCallbacks[virtualPlaylistId] = (data) => { - const prefix = playlistType.replace(/_/g, '-'); - const progress = data.progress || {}; - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const processed = matched + failed; - const pending = total - processed; - const pct = total > 0 ? Math.round((processed / total) * 100) : 0; - const el = (id) => document.getElementById(id); - if (el(`${prefix}-sync-completed`)) el(`${prefix}-sync-completed`).textContent = matched; - if (el(`${prefix}-sync-pending`)) el(`${prefix}-sync-pending`).textContent = pending; - if (el(`${prefix}-sync-failed`)) el(`${prefix}-sync-failed`).textContent = failed; - if (el(`${prefix}-sync-percentage`)) el(`${prefix}-sync-percentage`).textContent = pct; - if (data.status === 'finished') { - if (discoverSyncPollers[playlistType]) { clearInterval(discoverSyncPollers[playlistType]); delete discoverSyncPollers[playlistType]; } - socket.emit('sync:unsubscribe', { playlist_ids: [virtualPlaylistId] }); - delete _syncProgressCallbacks[virtualPlaylistId]; - const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; - const syncButton = el(buttonId); - if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; syncButton.style.cursor = 'pointer'; } - const playlistNames = { - 'release_radar': 'Fresh Tape', 'discovery_weekly': 'The Archives', - 'seasonal_playlist': 'Seasonal Mix', 'popular_picks': 'Popular Picks', - 'hidden_gems': 'Hidden Gems', 'discovery_shuffle': 'Discovery Shuffle', - 'familiar_favorites': 'Familiar Favorites', 'build_playlist': 'Custom Playlist' - }; - showToast(`${playlistNames[playlistType] || playlistType} sync complete!`, 'success'); - setTimeout(() => { const sd = el(`${prefix}-sync-status`); if (sd) sd.style.display = 'none'; }, 3000); - } - }; - } - - // Poll every 500ms for progress updates - discoverSyncPollers[playlistType] = setInterval(async () => { - // Always poll — no dedicated WebSocket events for discovery progress - try { - const response = await fetch(`/api/sync/status/${virtualPlaylistId}`); - if (!response.ok) { - console.log(`⚠️ Sync status response not OK: ${response.status}`); - return; - } - - const data = await response.json(); - console.log(`📊 Sync status for ${playlistType}:`, data); - - // Update UI with progress (data structure: {status: ..., progress: {...}}) - // Convert underscores to hyphens for HTML IDs - const prefix = playlistType.replace(/_/g, '-'); - const progress = data.progress || {}; - - const completedEl = document.getElementById(`${prefix}-sync-completed`); - const pendingEl = document.getElementById(`${prefix}-sync-pending`); - const failedEl = document.getElementById(`${prefix}-sync-failed`); - const percentageEl = document.getElementById(`${prefix}-sync-percentage`); - - const total = progress.total_tracks || 0; - const matched = progress.matched_tracks || 0; - const failed = progress.failed_tracks || 0; - const processed = matched + failed; - const pending = total - processed; - const completionPercentage = total > 0 ? Math.round((processed / total) * 100) : 0; - - if (completedEl) completedEl.textContent = matched; - if (pendingEl) pendingEl.textContent = pending; - if (failedEl) failedEl.textContent = failed; - if (percentageEl) percentageEl.textContent = completionPercentage; - - // If complete, stop polling and hide status after delay - if (data.status === 'finished') { - console.log(`✅ Sync complete for ${playlistType}`); - clearInterval(discoverSyncPollers[playlistType]); - delete discoverSyncPollers[playlistType]; - - // Re-enable sync button - const buttonId = playlistType.replace(/_/g, '-') + '-sync-btn'; - const syncButton = document.getElementById(buttonId); - if (syncButton) { - syncButton.disabled = false; - syncButton.style.opacity = '1'; - syncButton.style.cursor = 'pointer'; - } - - // Show completion toast with playlist name - const playlistNames = { - 'release_radar': 'Fresh Tape', - 'discovery_weekly': 'The Archives', - 'seasonal_playlist': 'Seasonal Mix', - 'popular_picks': 'Popular Picks', - 'hidden_gems': 'Hidden Gems', - 'discovery_shuffle': 'Discovery Shuffle', - 'familiar_favorites': 'Familiar Favorites', - 'build_playlist': 'Custom Playlist' - }; - const displayName = playlistNames[playlistType] || playlistType; - showToast(`${displayName} sync complete!`, 'success'); - - // Hide status display after 3 seconds - setTimeout(() => { - const statusDisplay = document.getElementById(`${prefix}-sync-status`); - if (statusDisplay) { - statusDisplay.style.display = 'none'; - } - }, 3000); - } - - } catch (error) { - console.error(`❌ Error polling sync status for ${playlistType}:`, error); - } - }, 500); -} - -async function openDownloadModalForRecentAlbum(albumIndex) { - const album = discoverRecentAlbums[albumIndex]; - if (!album) { - showToast('Album data not found', 'error'); - return; - } - - console.log(`📥 Opening Download Missing Tracks modal for album: ${album.album_name}`); - showLoadingOverlay(`Loading tracks for ${album.album_name}...`); - - try { - // Determine source and album ID - use source-agnostic endpoint - const source = album.source || (album.album_spotify_id ? 'spotify' : album.album_deezer_id ? 'deezer' : 'itunes'); - const albumId = source === 'spotify' ? album.album_spotify_id : source === 'deezer' ? album.album_deezer_id : album.album_itunes_id; - - if (!albumId) { - throw new Error(`No ${source} album ID available`); - } - - // Fetch album tracks from appropriate source (pass name/artist for Hydrabase support) - const _dap2 = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' }); - const response = await fetch(`/api/discover/album/${source}/${albumId}?${_dap2}`); - if (!response.ok) { - throw new Error('Failed to fetch album tracks'); - } - - const albumData = await response.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - throw new Error('No tracks found in album'); - } - - // Convert to expected format - CRITICAL FIX: Use fresh albumData from Spotify, not cached album - const spotifyTracks = albumData.tracks.map(track => { - // Normalize artists to array of strings - let artists = track.artists || albumData.artists || [{ name: album.artist_name }]; - if (Array.isArray(artists)) { - artists = artists.map(a => a.name || a); - } - - return { - id: track.id, - name: track.name, - artists: artists, - album: { - id: albumData.id, // ✅ Album ID for proper tracking - name: albumData.name, // ✅ Use fresh data, not cached - album_type: albumData.album_type || 'album', // ✅ Critical: Album type for classification - total_tracks: albumData.total_tracks || 0, // ✅ Total tracks for context - release_date: albumData.release_date || '', // ✅ Release date - images: albumData.images || [] // ✅ Use Spotify images - }, - duration_ms: track.duration_ms || 0, - track_number: track.track_number || 0 - }; - }); - - // Create virtual playlist ID using the appropriate album ID - const virtualPlaylistId = `discover_album_${albumId}`; - - // CRITICAL FIX: Pass proper artist/album context for modal display - const artistContext = { - id: source === 'spotify' ? album.artist_spotify_id : source === 'deezer' ? album.artist_deezer_id : album.artist_itunes_id, - name: album.artist_name, - source: source - }; - - const albumContext = { - id: albumData.id, - name: albumData.name, - album_type: albumData.album_type || 'album', - total_tracks: albumData.total_tracks || 0, - release_date: albumData.release_date || '', - images: albumData.images || [] - }; - - // Open download modal with artist/album context - await openDownloadMissingModalForYouTube(virtualPlaylistId, albumData.name, spotifyTracks, artistContext, albumContext); - - hideLoadingOverlay(); - - } catch (error) { - console.error('Error opening album download modal:', error); - showToast(`Failed to load album: ${error.message}`, 'error'); - hideLoadingOverlay(); - } -} - -// =============================== -// DISCOVER DOWNLOAD BAR -// =============================== - -// Track discover page downloads -let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlaylistId, startTime } - -/** - * Add a download to the discover download bar - */ -function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) { - console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`); - - // Always register the download in state (needed for dashboard even when not on discover page) - discoverDownloads[playlistId] = { - name: playlistName, - type: playlistType, - status: 'in_progress', - virtualPlaylistId: playlistId, - imageUrl: imageUrl, - startTime: new Date() - }; - - console.log(`📊 [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads)); - - // Update discover page sidebar if it exists (user is on discover page) - const downloadSidebar = document.getElementById('discover-download-sidebar'); - if (downloadSidebar) { - updateDiscoverDownloadBar(); // Also saves snapshot internally - } else { - console.log('ℹ️ [DOWNLOAD SIDEBAR] Sidebar not present - skipping sidebar UI update'); - saveDiscoverDownloadSnapshot(); // Persist state even when sidebar is absent - } - - updateDashboardDownloads(); - monitorDiscoverDownload(playlistId); -} - -/** - * Monitor a discover download for completion - */ -function monitorDiscoverDownload(playlistId) { - let notFoundCount = 0; - const maxNotFoundAttempts = 5; // Give sync 10 seconds to start (5 checks * 2 seconds) - - // Phase 5: Subscribe via WebSocket for sync status updates - if (socketConnected) { - socket.emit('sync:subscribe', { playlist_ids: [playlistId] }); - _syncProgressCallbacks[playlistId] = (data) => { - if (!discoverDownloads[playlistId]) return; - if (data.status === 'complete' || data.status === 'finished') { - discoverDownloads[playlistId].status = 'completed'; - updateDiscoverDownloadBar(); - updateDashboardDownloads(); - socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); - delete _syncProgressCallbacks[playlistId]; - setTimeout(() => { - if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { - removeDiscoverDownload(playlistId); - } - }, 30000); - } - }; - } - - const checkInterval = setInterval(async () => { - try { - // Check if download still exists - if (!discoverDownloads[playlistId]) { - clearInterval(checkInterval); - if (_syncProgressCallbacks[playlistId]) { - if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [playlistId] }); - delete _syncProgressCallbacks[playlistId]; - } - return; - } - - // First check if there's an active download process (modal-based downloads) - const activeProcess = activeDownloadProcesses[playlistId]; - if (activeProcess) { - console.log(`📂 [DOWNLOAD BAR] Found active process for ${playlistId}, status: ${activeProcess.status}`); - - if (activeProcess.status === 'complete') { - console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`); - discoverDownloads[playlistId].status = 'completed'; - updateDiscoverDownloadBar(); - updateDashboardDownloads(); - clearInterval(checkInterval); - - // Auto-remove completed downloads after 30 seconds - setTimeout(() => { - if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { - removeDiscoverDownload(playlistId); - } - }, 30000); - } - return; // Continue monitoring - } - - // Check sync status API (for sync-based downloads) - if (socketConnected) return; // Phase 5: WS handles sync status - const response = await fetch(`/api/sync/status/${playlistId}`); - if (response.ok) { - const data = await response.json(); - notFoundCount = 0; // Reset counter if found - - console.log(`🔄 [DOWNLOAD BAR] Sync status for ${playlistId}: ${data.status}`); - - if (data.status === 'complete') { - console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`); - discoverDownloads[playlistId].status = 'completed'; - updateDiscoverDownloadBar(); - updateDashboardDownloads(); - clearInterval(checkInterval); - - // Auto-remove completed downloads after 30 seconds - setTimeout(() => { - if (discoverDownloads[playlistId] && discoverDownloads[playlistId].status === 'completed') { - removeDiscoverDownload(playlistId); - } - }, 30000); - } - } else if (response.status === 404) { - notFoundCount++; - console.log(`🔍 [DOWNLOAD BAR] Sync not found for ${playlistId} (attempt ${notFoundCount}/${maxNotFoundAttempts})`); - - // Only remove after multiple attempts (give it time to start) - if (notFoundCount >= maxNotFoundAttempts) { - console.log(`⏹️ [DOWNLOAD BAR] Sync not found after ${maxNotFoundAttempts} attempts, removing`); - clearInterval(checkInterval); - removeDiscoverDownload(playlistId); - } - } - } catch (error) { - console.error(`❌ [DOWNLOAD BAR] Error monitoring ${playlistId}:`, error); - } - }, 2000); // Check every 2 seconds -} - -/** - * Remove a download from the bar - */ -function removeDiscoverDownload(playlistId) { - console.log(`🗑️ Removing discover download: ${playlistId}`); - delete discoverDownloads[playlistId]; - updateDiscoverDownloadBar(); - updateDashboardDownloads(); - saveDiscoverDownloadSnapshot(); // Save state after removal -} - -/** - * Update the discover download sidebar UI - */ -function updateDiscoverDownloadBar() { - const downloadSidebar = document.getElementById('discover-download-sidebar'); - const bubblesContainer = document.getElementById('discover-download-bubbles'); - const countElement = document.getElementById('discover-download-count'); - - console.log(`🔄 [DOWNLOAD SIDEBAR] Updating sidebar - found elements:`, { - downloadSidebar: !!downloadSidebar, - bubblesContainer: !!bubblesContainer, - countElement: !!countElement - }); - - if (!downloadSidebar || !bubblesContainer || !countElement) { - console.warn('⚠️ [DOWNLOAD SIDEBAR] Missing elements, cannot update'); - return; - } - - const activeDownloads = Object.keys(discoverDownloads); - const count = activeDownloads.length; - - console.log(`📊 [DOWNLOAD SIDEBAR] Updating with ${count} active downloads`); - - // Update count - countElement.textContent = count; - - // Show/hide sidebar - if (count === 0) { - console.log(`👁️ [DOWNLOAD SIDEBAR] No downloads, hiding sidebar`); - downloadSidebar.classList.add('hidden'); - return; - } else { - console.log(`👁️ [DOWNLOAD SIDEBAR] ${count} downloads, showing sidebar`); - downloadSidebar.classList.remove('hidden'); - } - - // Update bubbles - bubblesContainer.innerHTML = activeDownloads.map(playlistId => { - const download = discoverDownloads[playlistId]; - const isCompleted = download.status === 'completed'; - const icon = isCompleted ? '✅' : '⏳'; - - // Use image if available, otherwise gradient background - const imageUrl = download.imageUrl || ''; - const backgroundStyle = imageUrl ? - `background-image: url('${imageUrl}');` : - `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; - - return ` -
-
-
-
-
- ${icon} -
-
-
${escapeHtml(download.name)}
-
- `; - }).join(''); - - console.log(`📊 Updated discover download sidebar: ${count} active downloads`); - - // Save snapshot after UI update - saveDiscoverDownloadSnapshot(); -} - -/** - * Open download modal for a discover playlist - */ -async function openDiscoverDownloadModal(playlistId) { - console.log(`📂 [DOWNLOAD BAR] Opening download modal for: ${playlistId}`); - - // Check if there's an active download process with modal - let process = activeDownloadProcesses[playlistId]; - - console.log(`📋 [DOWNLOAD BAR] Process found:`, { - exists: !!process, - hasModalElement: !!(process && process.modalElement), - hasModalId: !!(process && process.modalId) - }); - - if (process) { - // Try modalElement first (album downloads) - if (process.modalElement) { - console.log(`✅ [DOWNLOAD BAR] Opening modal via modalElement`); - process.modalElement.style.display = 'flex'; - return; - } - - // Try modalId (sync downloads) - if (process.modalId) { - const modal = document.getElementById(process.modalId); - if (modal) { - console.log(`✅ [DOWNLOAD BAR] Opening modal via modalId: ${process.modalId}`); - modal.style.display = 'flex'; - return; - } - } - } - - // If no process found, try to rehydrate from backend - console.log(`💧 [DOWNLOAD BAR] No modal found, attempting to rehydrate from backend...`); - const rehydrated = await rehydrateDiscoverDownloadModal(playlistId); - - if (rehydrated) { - console.log(`✅ [DOWNLOAD BAR] Successfully rehydrated modal, opening it...`); - // Try again after rehydration - process = activeDownloadProcesses[playlistId]; - if (process && process.modalElement) { - process.modalElement.style.display = 'flex'; - return; - } - } - - // Fallback: show toast - const download = discoverDownloads[playlistId]; - if (download) { - console.log(`ℹ️ [DOWNLOAD BAR] No modal found after rehydration attempt, showing toast`); - showToast(`Download: ${download.name} - ${download.status}`, 'info'); - } else { - console.warn(`⚠️ [DOWNLOAD BAR] No download or process found for: ${playlistId}`); - } -} - -/** - * Initialize discover download sidebar on page load - */ -function initializeDiscoverDownloadBar() { - console.log('🎵 Initializing discover download sidebar...'); - - // Start with sidebar hidden (will be shown if downloads exist after hydration) - const downloadSidebar = document.getElementById('discover-download-sidebar'); - if (downloadSidebar) { - downloadSidebar.classList.add('hidden'); - } -} - -// --- Discover Download Modal Rehydration --- - -async function rehydrateDiscoverDownloadModal(playlistId) { - /** - * Rehydrates a discover download modal from backend process data. - * Fetches tracks from backend API and recreates the modal (user-requested). - */ - try { - console.log(`💧 [REHYDRATE] Attempting to rehydrate modal for: ${playlistId}`); - - // Check if there's an active backend process for this playlist - const batchResponse = await fetch(`/api/download_status/batch`); - if (!batchResponse.ok) { - console.log(`⚠️ [REHYDRATE] Failed to fetch batch info`); - return false; - } - - const batchData = await batchResponse.json(); - const batches = batchData.batches || {}; - - // Find the batch for this playlist (batches is an object with batch_id keys) - let batchId = null; - let batch = null; - for (const [id, batchStatus] of Object.entries(batches)) { - if (batchStatus.playlist_id === playlistId) { - batchId = id; - batch = batchStatus; - break; - } - } - - if (!batch || !batchId) { - console.log(`⚠️ [REHYDRATE] No active batch found for ${playlistId}`); - return false; - } - - console.log(`✅ [REHYDRATE] Found active batch for ${playlistId}: ${batchId}`, batch); - - // Get the download metadata from discoverDownloads - const downloadData = discoverDownloads[playlistId]; - if (!downloadData) { - console.log(`⚠️ [REHYDRATE] No download metadata found for ${playlistId}`); - return false; - } - - // Handle album downloads from Recent Releases - if (playlistId.startsWith('discover_album_')) { - const albumId = playlistId.replace('discover_album_', ''); - console.log(`💧 [REHYDRATE] Album download - fetching album ${albumId}...`); - - try { - const albumResponse = await fetch(`/api/spotify/album/${albumId}`); - if (!albumResponse.ok) { - console.error(`❌ [REHYDRATE] Failed to fetch album: ${albumResponse.status}`); - return false; - } - - const albumData = await albumResponse.json(); - if (!albumData.tracks || albumData.tracks.length === 0) { - console.error(`❌ [REHYDRATE] No tracks in album`); - return false; - } - - // Convert tracks to expected format - const spotifyTracks = albumData.tracks.map(track => { - let artists = track.artists || []; - if (Array.isArray(artists)) { - artists = artists.map(a => a.name || a); - } - - return { - id: track.id, - name: track.name, - artists: artists, - album: { - name: albumData.name || downloadData.name.split(' - ')[0], - images: downloadData.imageUrl ? [{ url: downloadData.imageUrl }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - }); - - console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks for album`); - - // Create modal - await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); - - // Update process - const process = activeDownloadProcesses[playlistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for status updates - startModalDownloadPolling(playlistId); - console.log(`✅ [REHYDRATE] Successfully rehydrated album modal with polling`); - return true; - } - return false; - - } catch (error) { - console.error(`❌ [REHYDRATE] Error fetching album:`, error); - return false; - } - } - - // Determine API endpoint based on playlist ID - let apiEndpoint; - if (playlistId === 'discover_release_radar') { - apiEndpoint = '/api/discover/release-radar'; - } else if (playlistId === 'discover_discovery_weekly') { - apiEndpoint = '/api/discover/discovery-weekly'; - } else if (playlistId === 'discover_seasonal_playlist') { - apiEndpoint = '/api/discover/seasonal-playlist'; - } else if (playlistId === 'discover_popular_picks') { - apiEndpoint = '/api/discover/popular-picks'; - } else if (playlistId === 'discover_hidden_gems') { - apiEndpoint = '/api/discover/hidden-gems'; - } else if (playlistId === 'discover_discovery_shuffle') { - apiEndpoint = '/api/discover/discovery-shuffle'; - } else if (playlistId === 'discover_familiar_favorites') { - apiEndpoint = '/api/discover/familiar-favorites'; - } else if (playlistId === 'build_playlist_custom') { - apiEndpoint = '/api/discover/build-playlist'; - } else if (playlistId.startsWith('discover_lb_')) { - // ListenBrainz playlist - fetch from cache - const identifier = playlistId.replace('discover_lb_', ''); - const tracks = listenbrainzTracksCache[identifier]; - if (!tracks || tracks.length === 0) { - console.log(`⚠️ [REHYDRATE] No ListenBrainz tracks in cache for ${identifier}`); - return false; - } - - // Convert to Spotify format - const spotifyTracks = tracks.map(track => ({ - id: track.mbid || `listenbrainz_${track.track_name}_${track.artist_name}`.replace(/[^a-z0-9]/gi, '_'), // Generate ID if missing - name: track.track_name, - artists: [{ name: cleanArtistName(track.artist_name) }], // Proper Spotify format - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0, - mbid: track.mbid - })); - - // Create modal and update process - await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); - const process = activeDownloadProcesses[playlistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for status updates - startModalDownloadPolling(playlistId); - console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz modal with polling`); - return true; - } - return false; - } else if (playlistId.startsWith('listenbrainz_')) { - // ListenBrainz download from discovery modal - get from backend state - const mbid = playlistId.replace('listenbrainz_', ''); - console.log(`💧 [REHYDRATE] ListenBrainz download - fetching state for MBID: ${mbid}`); - - try { - // Fetch ListenBrainz state from backend - const stateResponse = await fetch(`/api/listenbrainz/state/${mbid}`); - if (!stateResponse.ok) { - console.log(`⚠️ [REHYDRATE] Failed to fetch ListenBrainz state`); - return false; - } - - const stateData = await stateResponse.json(); - if (!stateData || !stateData.discovery_results) { - console.log(`⚠️ [REHYDRATE] No discovery results in ListenBrainz state`); - return false; - } - - // Convert discovery results to Spotify tracks - const spotifyTracks = stateData.discovery_results - .filter(result => result.spotify_data) - .map(result => { - const track = result.spotify_data; - // Ensure artists is in proper Spotify format: [{name: ...}] - let artistsArray = []; - if (track.artists && Array.isArray(track.artists)) { - artistsArray = track.artists.map(artist => { - if (typeof artist === 'string') { - return { name: artist }; - } else if (artist && artist.name) { - return { name: artist.name }; - } else { - return { name: String(artist || 'Unknown Artist') }; - } - }); - } else if (track.artists && typeof track.artists === 'string') { - artistsArray = [{ name: track.artists }]; - } else { - artistsArray = [{ name: 'Unknown Artist' }]; - } - return { - id: track.id, - name: track.name, - artists: artistsArray, - album: track.album || { name: 'Unknown Album', images: [] }, - duration_ms: track.duration_ms || 0, - external_urls: track.external_urls || {} - }; - }); - - if (spotifyTracks.length === 0) { - console.log(`⚠️ [REHYDRATE] No Spotify tracks in ListenBrainz discovery results`); - return false; - } - - console.log(`✅ [REHYDRATE] Retrieved ${spotifyTracks.length} tracks from ListenBrainz state`); - - // Create modal and update process - await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); - const process = activeDownloadProcesses[playlistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for status updates - startModalDownloadPolling(playlistId); - console.log(`✅ [REHYDRATE] Successfully rehydrated ListenBrainz download modal with polling`); - return true; - } - return false; - - } catch (error) { - console.error(`❌ [REHYDRATE] Error fetching ListenBrainz state:`, error); - return false; - } - } else { - console.error(`❌ [REHYDRATE] Unknown discover playlist type: ${playlistId}`); - return false; - } - - // Fetch tracks from API - console.log(`📡 [REHYDRATE] Fetching tracks from ${apiEndpoint}...`); - const response = await fetch(apiEndpoint); - if (!response.ok) { - console.error(`❌ [REHYDRATE] Failed to fetch tracks: ${response.status}`); - return false; - } - - const data = await response.json(); - if (!data.success || !data.tracks) { - console.error(`❌ [REHYDRATE] Invalid track data:`, data); - return false; - } - - const tracks = data.tracks; - console.log(`✅ [REHYDRATE] Retrieved ${tracks.length} tracks`); - - // Transform tracks to Spotify format - const spotifyTracks = tracks.map(track => { - let spotifyTrack; - if (track.track_data_json) { - spotifyTrack = track.track_data_json; - } else { - spotifyTrack = { - id: track.spotify_track_id, - name: track.track_name, - artists: [{ name: track.artist_name }], - album: { - name: track.album_name, - images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] - }, - duration_ms: track.duration_ms || 0 - }; - } - if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { - spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); - } - return spotifyTrack; - }); - - // Create the modal - await openDownloadMissingModalForYouTube(playlistId, downloadData.name, spotifyTracks); - - // Update process with batch info - const process = activeDownloadProcesses[playlistId]; - if (process) { - process.status = 'running'; - process.batchId = batchId; - subscribeToDownloadBatch(batchId); - - // Update button states - const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for status updates - startModalDownloadPolling(playlistId); - - // Don't hide the modal - user clicked to open it - console.log(`✅ [REHYDRATE] Successfully rehydrated modal for ${downloadData.name} with polling`); - return true; - } else { - console.error(`❌ [REHYDRATE] Failed to find rehydrated process for ${playlistId}`); - return false; - } - - } catch (error) { - console.error(`❌ [REHYDRATE] Error rehydrating discover download modal:`, error); - return false; - } -} - -// --- Discover Download Snapshot System --- - -let discoverSnapshotSaveTimeout = null; // Debounce snapshot saves - -async function saveDiscoverDownloadSnapshot() { - /** - * Saves current discoverDownloads state to backend for persistence. - * Debounced to prevent excessive backend calls. - */ - - // Clear any existing timeout - if (discoverSnapshotSaveTimeout) { - clearTimeout(discoverSnapshotSaveTimeout); - } - - // Debounce the actual save - discoverSnapshotSaveTimeout = setTimeout(async () => { - try { - const downloadCount = Object.keys(discoverDownloads).length; - - // Don't save empty state - if (downloadCount === 0) { - console.log('📸 Skipping discover snapshot save - no downloads to save'); - return; - } - - console.log(`📸 Saving discover download snapshot: ${downloadCount} downloads`); - - // Prepare snapshot data (clean format) - const cleanDownloads = {}; - for (const [playlistId, downloadData] of Object.entries(discoverDownloads)) { - cleanDownloads[playlistId] = { - name: downloadData.name, - type: downloadData.type, - status: downloadData.status, - virtualPlaylistId: downloadData.virtualPlaylistId, - imageUrl: downloadData.imageUrl, - startTime: downloadData.startTime instanceof Date ? downloadData.startTime.toISOString() : downloadData.startTime - }; - } - - const response = await fetch('/api/discover_downloads/snapshot', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - downloads: cleanDownloads - }) - }); - - const data = await response.json(); - - if (data.success) { - console.log(`✅ Discover download snapshot saved: ${downloadCount} downloads`); - } else { - console.error('❌ Failed to save discover download snapshot:', data.error); - } - - } catch (error) { - console.error('❌ Error saving discover download snapshot:', error); - } - }, 1000); // 1 second debounce -} - -async function hydrateDiscoverDownloadsFromSnapshot() { - /** - * Hydrates discover downloads from backend snapshot with live status. - * Called on page load to restore download state. - */ - try { - console.log('🔄 Loading discover download snapshot from backend...'); - - const response = await fetch('/api/discover_downloads/hydrate'); - const data = await response.json(); - - if (!data.success) { - console.error('❌ Failed to load discover download snapshot:', data.error); - return; - } - - const downloads = data.downloads || {}; - const stats = data.stats || {}; - - console.log(`🔄 Loaded discover snapshot: ${stats.total_downloads || 0} downloads, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); - - if (Object.keys(downloads).length === 0) { - console.log('ℹ️ No discover downloads to hydrate'); - return; - } - - // Clear existing state - discoverDownloads = {}; - - // Restore discoverDownloads with hydrated data - for (const [playlistId, downloadData] of Object.entries(downloads)) { - discoverDownloads[playlistId] = { - name: downloadData.name, - type: downloadData.type, - status: downloadData.status, // Live status from backend - virtualPlaylistId: downloadData.virtualPlaylistId, - imageUrl: downloadData.imageUrl, - startTime: new Date(downloadData.startTime) - }; - - console.log(`🔄 Hydrated download: ${downloadData.name} (${downloadData.status})`); - - // Start monitoring for any in-progress downloads - if (downloadData.status === 'in_progress') { - console.log(`📡 Starting monitoring for: ${downloadData.name}`); - monitorDiscoverDownload(playlistId); - } - } - - // Don't update UI here - it will be updated when user navigates to discover page - // This allows hydration to work even if page loads on a different tab - - const totalDownloads = Object.keys(discoverDownloads).length; - console.log(`✅ Successfully hydrated ${totalDownloads} discover downloads (UI will update on discover page navigation)`); - - } catch (error) { - console.error('❌ Error hydrating discover downloads from snapshot:', error); - } -} - -// Initialize on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initializeDiscoverDownloadBar); -} else { - initializeDiscoverDownloadBar(); -} - -// ============================================================================ -// MUSICBRAINZ ENRICHMENT UI - PHASE 5 WEB UI -// ============================================================================ - -/** - * Poll MusicBrainz status every 2 seconds and update UI - */ -async function updateMusicBrainzStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/musicbrainz/status'); - if (!response.ok) { console.warn('MusicBrainz status endpoint unavailable'); return; } - const data = await response.json(); - updateMusicBrainzStatusFromData(data); - } catch (error) { - console.error('Error updating MusicBrainz status:', error); - } -} - -function updateMusicBrainzStatusFromData(data) { - const button = document.getElementById('musicbrainz-button'); - if (!button) return; - - // Update button state classes - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - // Update tooltip content - const tooltipStatus = document.getElementById('mb-tooltip-status'); - const tooltipCurrent = document.getElementById('mb-tooltip-current'); - const tooltipProgress = document.getElementById('mb-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { - tooltipStatus.textContent = 'Complete'; - } else if (data.running && !data.paused) { - tooltipStatus.textContent = 'Running'; - } else if (data.paused) { - tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; - } else { - tooltipStatus.textContent = 'Idle'; - } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - const type = data.current_item.type || 'item'; - const name = data.current_item.name; - tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; - } else { - tooltipCurrent.textContent = 'No active matches'; - } - } - - if (tooltipProgress && data.progress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total} (${tracks.percent || 0}%)`; - } else { - progressText = `Artists: ${artists.matched || 0} / ${artists.total} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } -} - -/** - * Toggle MusicBrainz enrichment pause/resume - */ -async function toggleMusicBrainzEnrichment() { - try { - const button = document.getElementById('musicbrainz-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/musicbrainz/pause' : '/api/musicbrainz/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} MusicBrainz enrichment`); - } - - // Immediately update UI - await updateMusicBrainzStatus(); - - console.log(`✅ MusicBrainz enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling MusicBrainz enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -// Initialize MusicBrainz UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('musicbrainz-button'); - if (button) { - button.addEventListener('click', toggleMusicBrainzEnrichment); - // Start polling - updateMusicBrainzStatus(); - setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds - console.log('✅ MusicBrainz UI initialized'); - } - }); -} else { - const button = document.getElementById('musicbrainz-button'); - if (button) { - button.addEventListener('click', toggleMusicBrainzEnrichment); - // Start polling - updateMusicBrainzStatus(); - setInterval(updateMusicBrainzStatus, 2000); // Poll every 2 seconds - console.log('✅ MusicBrainz UI initialized'); - } -} - -// ============================================================================ -// AUDIODB ENRICHMENT UI -// ============================================================================ - -/** - * Poll AudioDB status every 2 seconds and update UI - */ -async function updateAudioDBStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/audiodb/status'); - if (!response.ok) { console.warn('AudioDB status endpoint unavailable'); return; } - const data = await response.json(); - updateAudioDBStatusFromData(data); - } catch (error) { - console.error('Error updating AudioDB status:', error); - } -} - -function updateAudioDBStatusFromData(data) { - const button = document.getElementById('audiodb-button'); - if (!button) return; - - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - const tooltipStatus = document.getElementById('audiodb-tooltip-status'); - const tooltipCurrent = document.getElementById('audiodb-tooltip-current'); - const tooltipProgress = document.getElementById('audiodb-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } - else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - const type = data.current_item.type || 'item'; - const name = data.current_item.name; - tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; - } else { - tooltipCurrent.textContent = 'No active matches'; - } - } - - if (tooltipProgress && data.progress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } -} - -function updateDiscogsStatusFromData(data) { - const button = document.getElementById('discogs-button'); - if (!button) return; - - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - const tooltipStatus = document.getElementById('discogs-tooltip-status'); - const tooltipCurrent = document.getElementById('discogs-tooltip-current'); - const tooltipProgress = document.getElementById('discogs-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) tooltipStatus.textContent = 'Complete'; - else if (data.running && !data.paused) tooltipStatus.textContent = 'Running'; - else if (data.paused) tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; - else tooltipStatus.textContent = 'Idle'; - } - - if (tooltipCurrent) { - if (data.idle) tooltipCurrent.textContent = 'All items processed'; - else if (data.current_item) tooltipCurrent.textContent = `Processing: "${data.current_item}"`; - else tooltipCurrent.textContent = 'No active matches'; - } - - if (tooltipProgress && data.stats) { - const s = data.stats; - tooltipProgress.textContent = `Matched: ${s.matched || 0} | Not found: ${s.not_found || 0} | Pending: ${s.pending || 0}`; - } -} - -async function toggleDiscogsEnrichment() { - try { - const button = document.getElementById('discogs-button'); - if (!button) return; - const isPaused = button.classList.contains('paused') || button.classList.contains('complete'); - const endpoint = isPaused ? '/api/discogs/resume' : '/api/discogs/pause'; - const response = await fetch(endpoint, { method: 'POST' }); - if (response.ok) { - showToast(isPaused ? 'Discogs enrichment resumed' : 'Discogs enrichment paused', 'info'); - } - } catch (e) { - showToast('Failed to toggle Discogs enrichment', 'error'); - } -} - -/** - * Toggle AudioDB enrichment pause/resume - */ -async function toggleAudioDBEnrichment() { - try { - const button = document.getElementById('audiodb-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/audiodb/pause' : '/api/audiodb/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} AudioDB enrichment`); - } - - // Immediately update UI - await updateAudioDBStatus(); - - console.log(`✅ AudioDB enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling AudioDB enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -// Initialize AudioDB UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('audiodb-button'); - if (button) { - button.addEventListener('click', toggleAudioDBEnrichment); - updateAudioDBStatus(); - setInterval(updateAudioDBStatus, 2000); - console.log('✅ AudioDB UI initialized'); - } - }); -} else { - const button = document.getElementById('audiodb-button'); - if (button) { - button.addEventListener('click', toggleAudioDBEnrichment); - updateAudioDBStatus(); - setInterval(updateAudioDBStatus, 2000); - console.log('✅ AudioDB UI initialized'); - } -} - -// =================================================================== -// DEEZER ENRICHMENT STATUS -// =================================================================== - -async function updateDeezerStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/deezer/status'); - if (!response.ok) { console.warn('Deezer status endpoint unavailable'); return; } - const data = await response.json(); - updateDeezerStatusFromData(data); - } catch (error) { - console.error('Error updating Deezer status:', error); - } -} - -function updateDeezerStatusFromData(data) { - const button = document.getElementById('deezer-button'); - if (!button) return; - - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - const tooltipStatus = document.getElementById('deezer-tooltip-status'); - const tooltipCurrent = document.getElementById('deezer-tooltip-current'); - const tooltipProgress = document.getElementById('deezer-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } - else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } -} - -async function toggleDeezerEnrichment() { - try { - const button = document.getElementById('deezer-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/deezer/pause' : '/api/deezer/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Deezer enrichment`); - } - - // Immediately update UI - await updateDeezerStatus(); - - console.log(`✅ Deezer enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling Deezer enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -// Initialize Deezer UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('deezer-button'); - if (button) { - button.addEventListener('click', toggleDeezerEnrichment); - updateDeezerStatus(); - setInterval(updateDeezerStatus, 2000); - console.log('✅ Deezer UI initialized'); - } - }); -} else { - const button = document.getElementById('deezer-button'); - if (button) { - button.addEventListener('click', toggleDeezerEnrichment); - updateDeezerStatus(); - setInterval(updateDeezerStatus, 2000); - console.log('✅ Deezer UI initialized'); - } -} - -// =================================================================== -// SPOTIFY ENRICHMENT STATUS -// =================================================================== - -async function updateSpotifyEnrichmentStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/spotify-enrichment/status'); - if (!response.ok) { console.warn('Spotify enrichment status endpoint unavailable'); return; } - const data = await response.json(); - updateSpotifyEnrichmentStatusFromData(data); - } catch (error) { - console.error('Error updating Spotify enrichment status:', error); - } -} - -function updateSpotifyEnrichmentStatusFromData(data) { - const button = document.getElementById('spotify-enrich-button'); - if (!button) return; - - const notAuthenticated = data.authenticated === false; - const isRateLimited = data.rate_limited === true; - const budgetExhausted = data.daily_budget && data.daily_budget.exhausted; - - button.classList.remove('active', 'paused', 'complete', 'no-auth'); - if (data.paused) { - button.classList.add('paused'); - } else if (notAuthenticated) { - button.classList.add('no-auth'); - } else if (isRateLimited || budgetExhausted) { - button.classList.add('paused'); - } else if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } - - const tooltipStatus = document.getElementById('spotify-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('spotify-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('spotify-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.paused) { tooltipStatus.textContent = 'Paused'; } - else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } - else if (isRateLimited) { tooltipStatus.textContent = 'Rate Limited'; } - else if (budgetExhausted) { tooltipStatus.textContent = 'Daily Limit Reached'; } - else if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running) { tooltipStatus.textContent = 'Running'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.paused) { - tooltipCurrent.textContent = notAuthenticated ? 'Connect Spotify in Settings to enrich' : 'Click to resume'; - } else if (notAuthenticated) { - tooltipCurrent.textContent = 'Connect Spotify in Settings to enrich'; - } else if (isRateLimited) { - const info = data.rate_limit || {}; - const remaining = info.remaining_seconds || 0; - tooltipCurrent.textContent = remaining > 0 ? `Waiting ${Math.ceil(remaining / 60)}m for rate limit to clear` : 'Waiting for rate limit to clear'; - } else if (budgetExhausted) { - const resets = data.daily_budget.resets_in_seconds || 0; - const hours = Math.floor(resets / 3600); - const mins = Math.floor((resets % 3600) / 60); - tooltipCurrent.textContent = `Resets in ${hours}h ${mins}m`; - } else if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } else { - tooltipCurrent.textContent = 'Waiting for next item...'; - } - } - - if (data.progress && tooltipProgress) { - if (notAuthenticated) { - tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; - } else { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type || ''; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist') { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType.includes('album')) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType.includes('track')) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else if (!artistsComplete) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (!albumsComplete) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - } -} - -async function toggleSpotifyEnrichment() { - try { - const button = document.getElementById('spotify-enrich-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/spotify-enrichment/pause' : '/api/spotify-enrichment/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - if (data.rate_limited) { - showToast('Cannot resume — Spotify is rate limited', 'warning'); - return; - } - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Spotify enrichment`); - } - - await updateSpotifyEnrichmentStatus(); - console.log(`Spotify enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling Spotify enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -// Initialize Spotify Enrichment UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('spotify-enrich-button'); - if (button) { - button.addEventListener('click', toggleSpotifyEnrichment); - updateSpotifyEnrichmentStatus(); - setInterval(updateSpotifyEnrichmentStatus, 2000); - } - }); -} else { - const button = document.getElementById('spotify-enrich-button'); - if (button) { - button.addEventListener('click', toggleSpotifyEnrichment); - updateSpotifyEnrichmentStatus(); - setInterval(updateSpotifyEnrichmentStatus, 2000); - } -} - -// =================================================================== -// ITUNES ENRICHMENT STATUS -// =================================================================== - -async function updateiTunesEnrichmentStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/itunes-enrichment/status'); - if (!response.ok) { console.warn('iTunes enrichment status endpoint unavailable'); return; } - const data = await response.json(); - updateiTunesEnrichmentStatusFromData(data); - } catch (error) { - console.error('Error updating iTunes enrichment status:', error); - } -} - -function updateiTunesEnrichmentStatusFromData(data) { - const button = document.getElementById('itunes-enrich-button'); - if (!button) return; - - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - const tooltipStatus = document.getElementById('itunes-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('itunes-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('itunes-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } - else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type || ''; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist') { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType.includes('album')) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType.includes('track')) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else if (!artistsComplete) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (!albumsComplete) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } -} - -async function toggleiTunesEnrichment() { - try { - const button = document.getElementById('itunes-enrich-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/itunes-enrichment/pause' : '/api/itunes-enrichment/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} iTunes enrichment`); - } - - await updateiTunesEnrichmentStatus(); - console.log(`iTunes enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling iTunes enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -// Initialize iTunes Enrichment UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('itunes-enrich-button'); - if (button) { - button.addEventListener('click', toggleiTunesEnrichment); - updateiTunesEnrichmentStatus(); - setInterval(updateiTunesEnrichmentStatus, 2000); - } - }); -} else { - const button = document.getElementById('itunes-enrich-button'); - if (button) { - button.addEventListener('click', toggleiTunesEnrichment); - updateiTunesEnrichmentStatus(); - setInterval(updateiTunesEnrichmentStatus, 2000); - } -} - -// =================================================================== -// LAST.FM ENRICHMENT STATUS -// =================================================================== - -async function updateLastFMEnrichmentStatus() { - if (socketConnected) return; - if (document.hidden) return; - try { - const response = await fetch('/api/lastfm-enrichment/status'); - if (!response.ok) { console.warn('Last.fm status endpoint unavailable'); return; } - const data = await response.json(); - updateLastFMEnrichmentStatusFromData(data); - } catch (error) { - console.error('Error updating Last.fm status:', error); - } -} - -function updateLastFMEnrichmentStatusFromData(data) { - const button = document.getElementById('lastfm-enrich-button'); - if (!button) return; - - const notAuthenticated = data.authenticated === false; - - button.classList.remove('active', 'paused', 'complete', 'no-auth'); - if (data.paused) { - button.classList.add('paused'); - } else if (notAuthenticated) { - button.classList.add('no-auth'); - } else if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } - - const tooltipStatus = document.getElementById('lastfm-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('lastfm-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('lastfm-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.paused) { tooltipStatus.textContent = 'Paused'; } - else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } - else if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running) { tooltipStatus.textContent = 'Running'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.paused) { - tooltipCurrent.textContent = notAuthenticated ? 'Add Last.fm API key in Settings to enrich' : 'Click to resume'; - } else if (notAuthenticated) { - tooltipCurrent.textContent = 'Add Last.fm API key in Settings to enrich'; - } else if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - if (notAuthenticated) { - tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; - } else { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (artistsComplete && !albumsComplete)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else if (currentType === 'track' || (artistsComplete && albumsComplete)) { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } else { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - } -} - -async function toggleLastFMEnrichment() { - try { - const button = document.getElementById('lastfm-enrich-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/lastfm-enrichment/pause' : '/api/lastfm-enrichment/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Last.fm enrichment`); - } - - await updateLastFMEnrichmentStatus(); - console.log(`Last.fm enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling Last.fm enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('lastfm-enrich-button'); - if (button) { - button.addEventListener('click', toggleLastFMEnrichment); - updateLastFMEnrichmentStatus(); - setInterval(updateLastFMEnrichmentStatus, 2000); - } - }); -} else { - const button = document.getElementById('lastfm-enrich-button'); - if (button) { - button.addEventListener('click', toggleLastFMEnrichment); - updateLastFMEnrichmentStatus(); - setInterval(updateLastFMEnrichmentStatus, 2000); - } -} - -// =================================================================== -// GENIUS ENRICHMENT STATUS -// =================================================================== - -async function updateGeniusEnrichmentStatus() { - if (socketConnected) return; - if (document.hidden) return; - try { - const response = await fetch('/api/genius-enrichment/status'); - if (!response.ok) { console.warn('Genius status endpoint unavailable'); return; } - const data = await response.json(); - updateGeniusEnrichmentStatusFromData(data); - } catch (error) { - console.error('Error updating Genius status:', error); - } -} - -function updateGeniusEnrichmentStatusFromData(data) { - const button = document.getElementById('genius-enrich-button'); - if (!button) return; - - const notAuthenticated = data.authenticated === false; - - button.classList.remove('active', 'paused', 'complete', 'no-auth'); - if (data.paused) { - button.classList.add('paused'); - } else if (notAuthenticated) { - button.classList.add('no-auth'); - } else if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } - - const tooltipStatus = document.getElementById('genius-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('genius-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('genius-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.paused) { tooltipStatus.textContent = 'Paused'; } - else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } - else if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running) { tooltipStatus.textContent = 'Running'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.paused) { - tooltipCurrent.textContent = notAuthenticated ? 'Add Genius access token in Settings to enrich' : 'Click to resume'; - } else if (notAuthenticated) { - tooltipCurrent.textContent = 'Add Genius access token in Settings to enrich'; - } else if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - if (notAuthenticated) { - tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; - } else { - const artists = data.progress.artists || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - } -} - -async function toggleGeniusEnrichment() { - try { - const button = document.getElementById('genius-enrich-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/genius-enrichment/pause' : '/api/genius-enrichment/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Genius enrichment`); - } - - await updateGeniusEnrichmentStatus(); - console.log(`Genius enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling Genius enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('genius-enrich-button'); - if (button) { - button.addEventListener('click', toggleGeniusEnrichment); - updateGeniusEnrichmentStatus(); - setInterval(updateGeniusEnrichmentStatus, 2000); - } - }); -} else { - const button = document.getElementById('genius-enrich-button'); - if (button) { - button.addEventListener('click', toggleGeniusEnrichment); - updateGeniusEnrichmentStatus(); - setInterval(updateGeniusEnrichmentStatus, 2000); - } -} - -// =================================================================== -// TIDAL ENRICHMENT WORKER -// =================================================================== - -async function updateTidalEnrichmentStatus() { - if (socketConnected) return; - if (document.hidden) return; - try { - const response = await fetch('/api/tidal-enrichment/status'); - if (!response.ok) { console.warn('Tidal status endpoint unavailable'); return; } - const data = await response.json(); - updateTidalEnrichmentStatusFromData(data); - } catch (error) { - console.error('Error updating Tidal status:', error); - } -} - -function updateTidalEnrichmentStatusFromData(data) { - const button = document.getElementById('tidal-enrich-button'); - if (!button) return; - - const notAuthenticated = data.authenticated === false; - - button.classList.remove('active', 'paused', 'complete', 'no-auth'); - if (data.paused) { - button.classList.add('paused'); - } else if (notAuthenticated) { - button.classList.add('no-auth'); - } else if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } - - const tooltipStatus = document.getElementById('tidal-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('tidal-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('tidal-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.paused) { tooltipStatus.textContent = 'Paused'; } - else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } - else if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running) { tooltipStatus.textContent = 'Running'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.paused) { - tooltipCurrent.textContent = notAuthenticated ? 'Connect Tidal in Settings to enrich' : 'Click to resume'; - } else if (notAuthenticated) { - tooltipCurrent.textContent = 'Connect Tidal in Settings to enrich'; - } else if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - if (notAuthenticated) { - tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; - } else { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (!albumsComplete && !currentType)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - } -} - -async function toggleTidalEnrichment() { - try { - const button = document.getElementById('tidal-enrich-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/tidal-enrichment/pause' : '/api/tidal-enrichment/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Tidal enrichment`); - } - - await updateTidalEnrichmentStatus(); - console.log(`Tidal enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling Tidal enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('tidal-enrich-button'); - if (button) { - button.addEventListener('click', toggleTidalEnrichment); - updateTidalEnrichmentStatus(); - setInterval(updateTidalEnrichmentStatus, 2000); - } - }); -} else { - const button = document.getElementById('tidal-enrich-button'); - if (button) { - button.addEventListener('click', toggleTidalEnrichment); - updateTidalEnrichmentStatus(); - setInterval(updateTidalEnrichmentStatus, 2000); - } -} - -// =================================================================== -// QOBUZ ENRICHMENT WORKER -// =================================================================== - -async function updateQobuzEnrichmentStatus() { - if (socketConnected) return; - if (document.hidden) return; - try { - const response = await fetch('/api/qobuz-enrichment/status'); - if (!response.ok) { console.warn('Qobuz status endpoint unavailable'); return; } - const data = await response.json(); - updateQobuzEnrichmentStatusFromData(data); - } catch (error) { - console.error('Error updating Qobuz status:', error); - } -} - -function updateQobuzEnrichmentStatusFromData(data) { - const button = document.getElementById('qobuz-enrich-button'); - if (!button) return; - - const notAuthenticated = data.authenticated === false; - - button.classList.remove('active', 'paused', 'complete', 'no-auth'); - if (data.paused) { - button.classList.add('paused'); - } else if (notAuthenticated) { - button.classList.add('no-auth'); - } else if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } - - const tooltipStatus = document.getElementById('qobuz-enrich-tooltip-status'); - const tooltipCurrent = document.getElementById('qobuz-enrich-tooltip-current'); - const tooltipProgress = document.getElementById('qobuz-enrich-tooltip-progress'); - - if (tooltipStatus) { - if (data.paused) { tooltipStatus.textContent = 'Paused'; } - else if (notAuthenticated) { tooltipStatus.textContent = 'Not Authenticated'; } - else if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running) { tooltipStatus.textContent = 'Running'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.paused) { - tooltipCurrent.textContent = notAuthenticated ? 'Connect Qobuz in Settings to enrich' : 'Click to resume'; - } else if (notAuthenticated) { - tooltipCurrent.textContent = 'Connect Qobuz in Settings to enrich'; - } else if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Now: ${data.current_item.name}`; - } - } - - if (data.progress && tooltipProgress) { - if (notAuthenticated) { - tooltipProgress.textContent = `Pending: ${data.stats?.pending || 0} items`; - } else { - const artists = data.progress.artists || {}; - const albums = data.progress.albums || {}; - const tracks = data.progress.tracks || {}; - - const currentType = data.current_item?.type; - let progressText = ''; - - const artistsComplete = artists.matched >= artists.total; - const albumsComplete = albums.matched >= albums.total; - - if (currentType === 'artist' || (!artistsComplete && !currentType)) { - progressText = `Artists: ${artists.matched || 0} / ${artists.total || 0} (${artists.percent || 0}%)`; - } else if (currentType === 'album' || (!albumsComplete && !currentType)) { - progressText = `Albums: ${albums.matched || 0} / ${albums.total || 0} (${albums.percent || 0}%)`; - } else { - progressText = `Tracks: ${tracks.matched || 0} / ${tracks.total || 0} (${tracks.percent || 0}%)`; - } - - tooltipProgress.textContent = progressText; - } - } -} - -async function toggleQobuzEnrichment() { - try { - const button = document.getElementById('qobuz-enrich-button'); - if (!button) return; - - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/qobuz-enrichment/pause' : '/api/qobuz-enrichment/resume'; - - const response = await fetch(endpoint, { method: 'POST' }); - if (!response.ok) { - throw new Error(`Failed to ${isRunning ? 'pause' : 'resume'} Qobuz enrichment`); - } - - await updateQobuzEnrichmentStatus(); - console.log(`Qobuz enrichment ${isRunning ? 'paused' : 'resumed'}`); - - } catch (error) { - console.error('Error toggling Qobuz enrichment:', error); - showToast(`Error: ${error.message}`, 'error'); - } -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('qobuz-enrich-button'); - if (button) { - button.addEventListener('click', toggleQobuzEnrichment); - updateQobuzEnrichmentStatus(); - setInterval(updateQobuzEnrichmentStatus, 2000); - } - }); -} else { - const button = document.getElementById('qobuz-enrich-button'); - if (button) { - button.addEventListener('click', toggleQobuzEnrichment); - updateQobuzEnrichmentStatus(); - setInterval(updateQobuzEnrichmentStatus, 2000); - } -} - -// =================================================================== -// HYDRABASE P2P MIRROR WORKER -// =================================================================== - -async function updateHydrabaseStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/hydrabase-worker/status'); - if (!response.ok) return; - const data = await response.json(); - updateHydrabaseStatusFromData(data); - } catch (error) { - // Silently ignore — worker may not be available - } -} - -function updateHydrabaseStatusFromData(data) { - const button = document.getElementById('hydrabase-button'); - if (!button) return; - - button.classList.remove('active', 'paused'); - if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - const statusEl = document.getElementById('hydrabase-tooltip-status'); - if (statusEl) { - if (data.paused) { - statusEl.textContent = 'Paused'; - statusEl.style.color = '#ffc107'; - } else if (data.running) { - statusEl.textContent = 'Active'; - statusEl.style.color = '#ffffff'; - } else { - statusEl.textContent = 'Stopped'; - statusEl.style.color = '#ff5252'; - } - } -} - -async function toggleHydrabaseWorker() { - const button = document.getElementById('hydrabase-button'); - if (!button) return; - const isRunning = button.classList.contains('active'); - const endpoint = isRunning ? '/api/hydrabase-worker/pause' : '/api/hydrabase-worker/resume'; - try { - await fetch(endpoint, { method: 'POST' }); - await updateHydrabaseStatus(); - } catch (error) { - console.error('Error toggling Hydrabase worker:', error); - } -} - -// Initialize Hydrabase UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('hydrabase-button'); - if (button) { - button.addEventListener('click', toggleHydrabaseWorker); - updateHydrabaseStatus(); - setInterval(updateHydrabaseStatus, 2000); - } - }); -} else { - const button = document.getElementById('hydrabase-button'); - if (button) { - button.addEventListener('click', toggleHydrabaseWorker); - updateHydrabaseStatus(); - setInterval(updateHydrabaseStatus, 2000); - } -} - -// =================================================================== -// LIBRARY REPAIR WORKER -// =================================================================== - -async function updateRepairStatus() { - if (socketConnected) return; // WebSocket handles this - if (document.hidden) return; // Skip polling when tab is not visible - try { - const response = await fetch('/api/repair/status'); - if (!response.ok) { console.warn('Repair status endpoint unavailable'); return; } - const data = await response.json(); - updateRepairStatusFromData(data); - } catch (error) { - console.error('Error updating repair status:', error); - } -} - -function updateRepairStatusFromData(data) { - const button = document.getElementById('repair-button'); - if (!button) return; - - button.classList.remove('active', 'paused', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } else if (data.paused) { - button.classList.add('paused'); - } - - const tooltipStatus = document.getElementById('repair-tooltip-status'); - const tooltipCurrent = document.getElementById('repair-tooltip-current'); - const tooltipProgress = document.getElementById('repair-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) { tooltipStatus.textContent = 'Complete'; } - else if (data.running && !data.paused) { tooltipStatus.textContent = 'Running'; } - else if (data.paused) { tooltipStatus.textContent = data.yield_reason === 'downloads' ? 'Yielding for downloads' : 'Paused'; } - else { tooltipStatus.textContent = 'Idle'; } - } - - if (tooltipCurrent) { - if (data.idle) { - tooltipCurrent.textContent = 'All jobs complete — waiting for next schedule'; - } else if (data.current_job && data.current_job.display_name) { - const jobName = data.current_job.display_name; - const jobProgress = data.progress && data.progress.current_job; - if (jobProgress && jobProgress.total > 0) { - tooltipCurrent.textContent = `${jobName}: ${jobProgress.scanned} / ${jobProgress.total} (${jobProgress.percent}%)`; - } else { - tooltipCurrent.textContent = `Running: ${jobName}`; - } - } else if (data.current_item && data.current_item.name) { - tooltipCurrent.textContent = `Running: ${data.current_item.name}`; - } else { - tooltipCurrent.textContent = 'No active repairs'; - } - } - - if (tooltipProgress && data.progress) { - const tracks = data.progress.tracks || {}; - const parts = []; - if (tracks.total > 0) parts.push(`Checked: ${tracks.checked || 0} / ${tracks.total || 0}`); - if (tracks.repaired > 0) parts.push(`Repaired: ${tracks.repaired}`); - const pending = data.findings_pending || 0; - if (pending > 0) parts.push(`Findings: ${pending}`); - tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; - } - - // Update findings badge - const badge = document.getElementById('repair-findings-badge'); - const findingsPending = data.findings_pending || 0; - if (badge) { - badge.textContent = findingsPending; - badge.style.display = findingsPending > 0 ? '' : 'none'; - } - const tabBadge = document.getElementById('repair-findings-tab-badge'); - if (tabBadge) { - tabBadge.textContent = findingsPending; - tabBadge.style.display = findingsPending > 0 ? '' : 'none'; - } - - // Update master toggle in modal if open - const masterToggle = document.getElementById('repair-master-toggle'); - const masterLabel = document.getElementById('repair-master-label'); - if (masterToggle) masterToggle.checked = data.enabled || false; - if (masterLabel) masterLabel.textContent = data.enabled ? 'Enabled' : 'Disabled'; - - // Update button state - if (!data.enabled) { - button.classList.add('paused'); - button.classList.remove('active', 'complete'); - } -} - -// ── SoulID Worker Status ── - -function updateSoulIDStatusFromData(data) { - const button = document.getElementById('soulid-button'); - if (!button) return; - - button.classList.remove('active', 'complete'); - if (data.idle) { - button.classList.add('complete'); - } else if (data.running && !data.paused) { - button.classList.add('active'); - } - - const tooltipStatus = document.getElementById('soulid-tooltip-status'); - const tooltipCurrent = document.getElementById('soulid-tooltip-current'); - const tooltipProgress = document.getElementById('soulid-tooltip-progress'); - - if (tooltipStatus) { - if (data.idle) tooltipStatus.textContent = 'Complete'; - else if (data.running && !data.paused) tooltipStatus.textContent = 'Running'; - else if (data.paused) tooltipStatus.textContent = 'Paused'; - else tooltipStatus.textContent = 'Idle'; - } - - if (tooltipCurrent) { - if (data.current_item) { - tooltipCurrent.textContent = data.current_item; - } else if (data.idle) { - tooltipCurrent.textContent = 'All entities have soul IDs'; - } else { - tooltipCurrent.textContent = 'No items processing'; - } - } - - if (tooltipProgress && data.stats) { - const s = data.stats; - const parts = []; - if (s.artists_processed) parts.push(`Artists: ${s.artists_processed}`); - if (s.albums_processed) parts.push(`Albums: ${s.albums_processed}`); - if (s.tracks_processed) parts.push(`Tracks: ${s.tracks_processed}`); - if (s.pending > 0) parts.push(`Pending: ${s.pending}`); - tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; - } -} - -// ── Repair Modal State ── -let _repairCurrentTab = 'jobs'; -let _repairFindingsPage = 0; -let _repairSelectedFindings = new Set(); -let _repairFindingsTotal = 0; -const REPAIR_FINDINGS_PAGE_SIZE = 30; -let _repairJobsCache = {}; // Cache job data for help modal - -/** - * Open the Library Maintenance modal - */ -async function openRepairModal() { - navigateToPage('tools'); - // Scroll to maintenance section - setTimeout(() => { - const section = document.querySelector('.tools-maintenance-section'); - if (section) section.scrollIntoView({ behavior: 'smooth' }); - }, 100); - _repairCurrentTab = 'jobs'; - switchRepairTab('jobs'); - // Load master toggle state - updateRepairStatus(); - // Load any active job progress - try { - const resp = await fetch('/api/repair/progress'); - if (resp.ok) { - const data = await resp.json(); - if (Object.keys(data).length > 0) { - // Brief delay so job cards are rendered first - setTimeout(() => updateRepairJobProgressFromData(data), 300); - } - } - } catch (e) { /* ignore */ } -} - -function closeRepairModal() { - // No-op — repair content now lives on the tools page, no modal to close -} - -async function toggleRepairMaster() { - try { - const response = await fetch('/api/repair/toggle', { method: 'POST' }); - if (!response.ok) throw new Error('Failed to toggle'); - const data = await response.json(); - const label = document.getElementById('repair-master-label'); - const toggle = document.getElementById('repair-master-toggle'); - if (label) label.textContent = data.enabled ? 'Enabled' : 'Disabled'; - if (toggle) toggle.checked = data.enabled; - await updateRepairStatus(); - } catch (error) { - console.error('Error toggling repair master:', error); - showToast('Error toggling maintenance worker', 'error'); - } -} - -function switchRepairTab(tab) { - _repairCurrentTab = tab; - document.querySelectorAll('.repair-tab').forEach(t => { - t.classList.toggle('active', t.dataset.tab === tab); - }); - document.querySelectorAll('.repair-tab-content').forEach(c => { - c.style.display = 'none'; - }); - const content = document.getElementById(`repair-tab-${tab}`); - if (content) content.style.display = ''; - - if (tab === 'jobs') loadRepairJobs(); - else if (tab === 'findings') { loadRepairFindingsDashboard(); loadRepairFindings(); } - else if (tab === 'history') loadRepairHistory(); -} - -async function loadRepairJobs() { - const container = document.getElementById('repair-jobs-list'); - if (!container) return; - - try { - const response = await fetch('/api/repair/jobs'); - if (!response.ok) throw new Error('Failed to fetch jobs'); - const data = await response.json(); - const jobs = data.jobs || []; - - // Cache job data for help modal - _repairJobsCache = {}; - jobs.forEach(j => { _repairJobsCache[j.job_id] = j; }); - - if (jobs.length === 0) { - container.innerHTML = `
-
🔧
-
No Maintenance Jobs
-
Library maintenance jobs will appear here once available.
-
`; - return; - } - - // Populate findings job filter dropdown - const jobFilter = document.getElementById('repair-findings-job-filter'); - if (jobFilter && jobFilter.options.length <= 1) { - jobs.forEach(job => { - const opt = document.createElement('option'); - opt.value = job.job_id; - opt.textContent = job.display_name; - jobFilter.appendChild(opt); - }); - } - - container.innerHTML = jobs.map(job => { - const lastRunText = job.last_run ? formatCacheAge(job.last_run.finished_at) : 'Never'; - const nextRunText = job.next_run ? formatCacheAge(job.next_run) : (job.enabled ? 'Pending' : '-'); - const statusClass = job.is_running ? 'running' : (job.enabled ? 'idle' : 'disabled'); - const dotClass = job.is_running ? 'running' : (job.enabled ? 'enabled' : 'disabled'); - const cardClass = job.is_running ? 'running' : (!job.enabled ? 'disabled' : ''); - - // Build flow badges - const flowParts = []; - flowParts.push(`${job.is_running ? '▶ Running' : 'Scan'}`); - if (job.auto_fix) { - flowParts.push(''); - const isDryRun = job.settings && job.settings.dry_run === true; - if (isDryRun) { - flowParts.push('Dry Run'); - } else { - flowParts.push('Auto-fix'); - } - } - // Show pending findings count - const findingsCount = job.last_run ? (job.last_run.findings_created || 0) : 0; - if (findingsCount > 0) { - flowParts.push(''); - flowParts.push(`${findingsCount} finding${findingsCount !== 1 ? 's' : ''}`); - } - - // Build meta parts - const metaParts = []; - metaParts.push('Last: ' + lastRunText); - metaParts.push('Next: ' + nextRunText); - if (job.last_run) { - metaParts.push(`Scanned: ${(job.last_run.items_scanned || 0).toLocaleString()}`); - if (job.last_run.auto_fixed) metaParts.push(`Fixed: ${job.last_run.auto_fixed}`); - } - if (job.last_run && job.last_run.duration_seconds) { - metaParts.push(`${job.last_run.duration_seconds.toFixed(1)}s`); - } - - // Build settings HTML - let settingsHtml = ''; - if (job.settings && Object.keys(job.settings).length > 0) { - const settingsRows = Object.entries(job.settings).map(([key, val]) => { - const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - const inputType = typeof val === 'boolean' ? 'checkbox' : - typeof val === 'number' ? 'number' : 'text'; - const inputVal = inputType === 'checkbox' ? - (val ? ' checked' : '') : - ` value="${val}"`; - return `
- - -
`; - }).join(''); - - settingsHtml = ` - `; - } - - return `
-
-
-
-
${job.display_name}
-
${job.description || ''}
-
${flowParts.join('')}
-
${metaParts.join(' · ')}
-
-
- - - ${Object.keys(job.settings || {}).length > 0 ? - `` : ''} - -
-
- ${settingsHtml} -
`; - }).join(''); - - } catch (error) { - console.error('Error loading repair jobs:', error); - container.innerHTML = '
Error loading jobs
'; - } -} - -async function toggleRepairJob(jobId, enabled) { - try { - await fetch(`/api/repair/jobs/${jobId}/toggle`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); - // Update card visuals immediately - const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`); - if (card) { - card.classList.toggle('disabled', !enabled); - const dot = card.querySelector('.repair-job-status'); - if (dot) dot.className = 'repair-job-status ' + (enabled ? 'enabled' : 'disabled'); - } - } catch (error) { - console.error('Error toggling job:', error); - showToast('Error toggling job', 'error'); - } -} - -function expandRepairJobSettings(jobId) { - const el = document.getElementById(`repair-settings-${jobId}`); - if (el) el.style.display = el.style.display === 'none' ? '' : 'none'; -} - -function showRepairJobHelp(jobId) { - const job = _repairJobsCache[jobId]; - if (!job) return; - - // Remove existing overlay if present - let overlay = document.getElementById('repair-help-overlay'); - if (overlay) overlay.remove(); - - // Build settings summary - let settingsHtml = ''; - if (job.settings && Object.keys(job.settings).length > 0) { - const rows = Object.entries(job.settings).map(([key, val]) => { - const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); - const display = typeof val === 'boolean' ? (val ? 'Yes' : 'No') : val; - return `
${label}${display}
`; - }).join(''); - settingsHtml = `
-
Current Settings
- ${rows} -
`; - } - - // Build info badges - const badges = []; - if (job.auto_fix) { - const isDryRun = job.settings && job.settings.dry_run === true; - badges.push(isDryRun - ? 'Dry Run' - : 'Auto-fix'); - } else { - badges.push('Scan Only'); - } - badges.push(`Every ${job.interval_hours}h`); - if (job.enabled) { - badges.push('Enabled'); - } else { - badges.push('Disabled'); - } - - // Format help text paragraphs - const helpBody = (job.help_text || job.description || '').split('\n\n').map(p => { - if (p.startsWith('Settings:\n')) { - const lines = p.split('\n').slice(1); - return '
' + - lines.map(l => `
${l.replace(/^- /, '')}
`).join('') + - '
'; - } - return `

${p.replace(/\n/g, '
')}

`; - }).join(''); - - overlay = document.createElement('div'); - overlay.id = 'repair-help-overlay'; - overlay.className = 'repair-help-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - overlay.innerHTML = ` -
-
-

${job.display_name}

- -
-
${badges.join('')}
-
${helpBody}
- ${settingsHtml} -
- `; - document.body.appendChild(overlay); -} - -async function saveRepairJobSettings(jobId) { - try { - const inputs = document.querySelectorAll(`.repair-setting-input[data-job="${jobId}"]`); - let intervalHours = null; - const settings = {}; - - inputs.forEach(input => { - const key = input.dataset.key; - if (key === '_interval_hours') { - intervalHours = parseInt(input.value) || 24; - } else { - if (input.type === 'checkbox') settings[key] = input.checked; - else if (input.type === 'number') settings[key] = parseFloat(input.value); - else settings[key] = input.value; - } - }); - - await fetch(`/api/repair/jobs/${jobId}/settings`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ interval_hours: intervalHours, settings }) - }); - - showToast('Settings saved', 'success'); - } catch (error) { - console.error('Error saving job settings:', error); - showToast('Error saving settings', 'error'); - } -} - -async function runRepairJobNow(jobId) { - try { - await fetch(`/api/repair/jobs/${jobId}/run`, { method: 'POST' }); - showToast('Job started', 'success'); - setTimeout(() => loadRepairJobs(), 1000); - } catch (error) { - console.error('Error running job:', error); - showToast('Error starting job', 'error'); - } -} - -// ── Repair Job Live Progress ── -const _repairProgressLogCounts = {}; -const _repairProgressHideTimers = {}; - -function updateRepairJobProgressFromData(data) { - for (const [jobId, state] of Object.entries(data)) { - const card = document.querySelector(`.repair-job-card[data-job-id="${jobId}"]`); - if (!card) continue; - - // Update status dot - const statusDot = card.querySelector('.repair-job-status'); - if (statusDot) { - if (state.status === 'running') statusDot.className = 'repair-job-status running'; - else if (state.status === 'finished') statusDot.className = 'repair-job-status enabled'; - else if (state.status === 'error') statusDot.className = 'repair-job-status enabled'; - } - - // Update flow badge to show running state - const firstBadge = card.querySelector('.repair-flow-badge.scan'); - if (firstBadge) { - if (state.status === 'running') firstBadge.innerHTML = '▶ Running'; - else if (state.status === 'finished') firstBadge.innerHTML = '✓ Complete'; - else if (state.status === 'error') firstBadge.innerHTML = '✗ Error'; - } - - // Add/update card running class - card.classList.toggle('running', state.status === 'running'); - card.classList.remove('disabled'); - - // Create or find progress panel (bar-first layout like automation) - let panel = card.querySelector('.repair-job-progress'); - if (!panel) { - panel = document.createElement('div'); - panel.className = 'repair-job-progress'; - panel.innerHTML = ` -
-
-
-
-
- `; - card.appendChild(panel); - } - - // Show panel - panel.classList.add('visible'); - panel.classList.toggle('finished', state.status === 'finished'); - panel.classList.toggle('error', state.status === 'error'); - - if (state.status === 'running') { - panel.classList.remove('finished', 'error'); - if (_repairProgressHideTimers[jobId]) { - clearTimeout(_repairProgressHideTimers[jobId]); - delete _repairProgressHideTimers[jobId]; - } - // Reset log for re-run - if (_repairProgressLogCounts[jobId] > 0 && state.log && state.log.length < _repairProgressLogCounts[jobId]) { - const existingLog = panel.querySelector('.repair-progress-log'); - if (existingLog) existingLog.innerHTML = ''; - _repairProgressLogCounts[jobId] = 0; - } - } - - // Update progress bar - const bar = panel.querySelector('.repair-progress-bar'); - if (bar) bar.style.width = (state.progress || 0) + '%'; - - // Update phase - const phaseEl = panel.querySelector('.repair-progress-phase'); - if (phaseEl && state.phase) phaseEl.textContent = state.phase; - - // Update log - const logEl = panel.querySelector('.repair-progress-log'); - if (logEl && state.log) { - const prevCount = _repairProgressLogCounts[jobId] || 0; - if (state.log.length > prevCount) { - const newLines = state.log.slice(prevCount); - for (const line of newLines) { - const div = document.createElement('div'); - div.className = 'repair-log-line ' + (line.type || 'info'); - div.textContent = line.text; - logEl.appendChild(div); - } - logEl.scrollTop = logEl.scrollHeight; - } - _repairProgressLogCounts[jobId] = state.log.length; - } - - // Auto-hide panel after completion - if (state.status === 'finished' || state.status === 'error') { - if (!_repairProgressHideTimers[jobId]) { - _repairProgressHideTimers[jobId] = setTimeout(() => { - panel.classList.remove('visible'); - card.classList.remove('running'); - delete _repairProgressHideTimers[jobId]; - delete _repairProgressLogCounts[jobId]; - // Reload to get updated stats - loadRepairJobs(); - }, 30000); - } - } else { - // Clear any existing hide timer if job restarts - if (_repairProgressHideTimers[jobId]) { - clearTimeout(_repairProgressHideTimers[jobId]); - delete _repairProgressHideTimers[jobId]; - } - } - } -} - -async function loadRepairFindingsDashboard() { - const dashboard = document.getElementById('repair-findings-dashboard'); - if (!dashboard) return; - - try { - const response = await fetch('/api/repair/findings/counts'); - if (!response.ok) throw new Error('Failed to fetch counts'); - const data = await response.json(); - - const pending = data.pending || 0; - const resolved = data.resolved || 0; - const dismissed = data.dismissed || 0; - const autoFixed = data.auto_fixed || 0; - const byJob = data.by_job || {}; - - // Summary stats row - let html = '
'; - html += `
- ${pending.toLocaleString()} pending -
`; - html += `
- ${resolved.toLocaleString()} resolved -
`; - html += `
- ${dismissed.toLocaleString()} dismissed -
`; - if (autoFixed > 0) { - html += `
- ${autoFixed.toLocaleString()} auto-fixed -
`; - } - html += '
'; - - // Per-job chips (only if there are pending findings) - const jobIds = Object.keys(byJob).sort((a, b) => byJob[b].total - byJob[a].total); - if (jobIds.length > 0) { - html += '
'; - const jobFilter = document.getElementById('repair-findings-job-filter'); - const activeJob = jobFilter ? jobFilter.value : ''; - - for (const jid of jobIds) { - const job = byJob[jid]; - const isActive = activeJob === jid; - const severityDots = []; - if (job.warning > 0) severityDots.push(``); - if (job.info > 0) severityDots.push(``); - - html += `
- ${job.total.toLocaleString()} - ${_escFinding(job.display_name || jid.replace(/_/g, ' '))} - ${severityDots.length ? `${severityDots.join('')}` : ''} -
`; - } - html += '
'; - } - - dashboard.innerHTML = html; - - // Load cache health stats - _loadCacheHealthStats(dashboard); - } catch (error) { - console.error('Error loading findings dashboard:', error); - dashboard.innerHTML = ''; - } -} - -async function _loadCacheHealthStats(dashboard) { - try { - const response = await fetch('/api/repair/cache-health'); - if (!response.ok) return; - const stats = await response.json(); - if (!stats.total_entities && !stats.total_searches) return; - - const healthScore = stats.junk_entities === 0 && stats.stale_mb_nulls === 0 ? 'healthy' : stats.junk_entities > 50 ? 'poor' : 'fair'; - const healthLabel = healthScore === 'healthy' ? 'Healthy' : healthScore === 'fair' ? 'Needs Cleanup' : 'Needs Attention'; - - const section = document.createElement('div'); - section.className = 'repair-cache-health'; - section.innerHTML = ` -
- - Metadata Cache - ${stats.total_entities.toLocaleString()} entities · ${healthLabel} - View Details › -
- `; - dashboard.appendChild(section); - } catch (error) { - console.error('Error loading cache health:', error); - } -} - -async function openCacheHealthModal() { - if (document.getElementById('cache-health-modal-overlay')) return; - - const overlay = document.createElement('div'); - overlay.id = 'cache-health-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - overlay.innerHTML = ` -
-
-
-
📊
-
-

Cache Health

-

Metadata cache status across all sources

-
-
- -
-
-
-
-
Loading cache stats...
-
-
- -
- `; - document.body.appendChild(overlay); - - try { - const response = await fetch('/api/repair/cache-health'); - if (!response.ok) throw new Error('Failed to load'); - const s = await response.json(); - - const body = overlay.querySelector('.cache-health-body'); - const healthScore = s.junk_entities === 0 && s.stale_mb_nulls === 0 ? 'healthy' : s.junk_entities > 50 ? 'poor' : 'fair'; - const healthEmoji = healthScore === 'healthy' ? '✓' : healthScore === 'fair' ? '⚠' : '❌'; - const healthLabel = healthScore === 'healthy' ? 'Cache is healthy' : healthScore === 'fair' ? 'Minor issues detected' : 'Cleanup recommended'; - - body.innerHTML = ` -
-
${healthEmoji}
-
${healthLabel}
-
- -
-
-
${s.total_entities.toLocaleString()}
-
Total Entities
-
-
-
${s.total_searches.toLocaleString()}
-
Search Results
-
-
-
${s.junk_entities}
-
Junk Entries
-
-
0 ? 'onclick="openFailedMBLookupsModal()"' : ''}> -
${s.stale_mb_nulls}
-
Failed MB Lookups
- ${s.stale_mb_nulls > 0 ? '
Manage ›
' : ''} -
-
- -
-
By Source
-
- ${(() => { - const allSources = { ...(s.by_source || {}) }; - if (s.total_musicbrainz) allSources['musicbrainz'] = s.total_musicbrainz; - const maxCount = Math.max(...Object.values(allSources), 1); - return Object.entries(allSources).map(([src, count]) => { - const pct = Math.round(count / maxCount * 100); - const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : src === 'musicbrainz' ? '#BA478F' : '#666'; - return `
- ${src === 'musicbrainz' ? 'MusicBrainz' : src} -
- ${count.toLocaleString()} -
`; - }).join(''); - })()} -
-
- -
-
By Type
-
- ${Object.entries(s.by_type || {}).map(([type, count]) => `${type}s ${count.toLocaleString()}`).join('')} -
-
- -
-
Metrics
-
-
Average Age${s.avg_age_days} days
-
Total Cache Hits${s.total_access_hits.toLocaleString()}
-
Expiring in 24h${s.expiring_24h}
-
Expiring in 7 days${s.expiring_7d}
-
-
- `; - } catch (error) { - const body = overlay.querySelector('.cache-health-body'); - body.innerHTML = '
Failed to load cache stats
'; - } -} - -// ── Failed MB Lookups Management Modal ── -let _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; - -async function openFailedMBLookupsModal() { - if (document.getElementById('failed-mb-modal-overlay')) return; - - const overlay = document.createElement('div'); - overlay.id = 'failed-mb-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - overlay.innerHTML = ` -
-
-
-

Failed MusicBrainz Lookups

-

Tracks, albums, and artists that couldn't be matched automatically

-
- -
-
-
-
- - -
-
-
-
Loading...
-
- -
- `; - document.body.appendChild(overlay); - - // Search debounce - const searchInput = overlay.querySelector('#failed-mb-search'); - let searchTimer = null; - searchInput.addEventListener('input', () => { - clearTimeout(searchTimer); - searchTimer = setTimeout(() => { - _failedMBState.filter = searchInput.value; - _failedMBState.page = 1; - _loadFailedMBLookups(); - }, 300); - }); - - _failedMBState = { items: [], total: 0, page: 1, filter: '', typeFilter: '', typeCounts: {} }; - await _loadFailedMBLookups(); -} - -async function _loadFailedMBLookups() { - const body = document.getElementById('failed-mb-body'); - if (!body) return; - - // Only fetch type_counts on first load — cache them for tab switches - const needCounts = Object.keys(_failedMBState.typeCounts).length === 0; - const params = new URLSearchParams({ - page: _failedMBState.page, - limit: 50, - }); - if (needCounts) params.set('counts', 'true'); - if (_failedMBState.typeFilter) params.set('entity_type', _failedMBState.typeFilter); - if (_failedMBState.filter) params.set('search', _failedMBState.filter); - - try { - const resp = await fetch(`/api/metadata-cache/failed-mb-lookups?${params}`); - if (!resp.ok) throw new Error('Failed to load'); - const data = await resp.json(); - _failedMBState.items = data.items; - _failedMBState.total = data.total; - if (data.type_counts) _failedMBState.typeCounts = data.type_counts; - - // Render type filter tabs - const tabsEl = document.getElementById('failed-mb-tabs'); - if (tabsEl) { - const allCount = Object.values(_failedMBState.typeCounts).reduce((a, b) => a + b, 0); - let tabsHTML = ``; - const typeLabels = { artist: 'Artists', release: 'Albums', recording: 'Tracks' }; - for (const [type, count] of Object.entries(_failedMBState.typeCounts)) { - tabsHTML += ``; - } - tabsEl.innerHTML = tabsHTML; - } - - // Render items - if (data.items.length === 0) { - body.innerHTML = `
${_failedMBState.filter ? 'No matches for your search' : 'No failed lookups — cache is clean!'}
`; - } else { - const typeIcons = { artist: '🎤', release: '💿', recording: '🎵' }; - body.innerHTML = data.items.map(item => ` -
-
${typeIcons[item.entity_type] || '?'}
-
-
${escapeHtml(item.entity_name)}
- ${item.artist_name ? `
${escapeHtml(item.artist_name)}
` : ''} -
-
- ${item.entity_type} - ${item.last_updated ? new Date(item.last_updated).toLocaleDateString() : ''} -
-
- - -
-
- `).join(''); - } - - // Pagination footer - const footer = document.getElementById('failed-mb-footer'); - if (footer) { - const totalPages = Math.ceil(data.total / 50); - footer.innerHTML = totalPages > 1 ? ` -
- - Page ${_failedMBState.page} of ${totalPages} (${data.total} total) - -
- ` : `
${data.total} entries
`; - } - } catch (err) { - body.innerHTML = '
Failed to load data
'; - } -} - -function _failedMBSetType(type) { - _failedMBState.typeFilter = type; - _failedMBState.page = 1; - _loadFailedMBLookups(); -} - -function _failedMBPage(page) { - _failedMBState.page = page; - _loadFailedMBLookups(); -} - -async function _failedMBDelete(entryId) { - try { - const resp = await fetch(`/api/metadata-cache/mb-entry/${entryId}`, { method: 'DELETE' }); - if (resp.ok) { - const row = document.querySelector(`.failed-mb-item[data-id="${entryId}"]`); - if (row) { - row.style.opacity = '0'; - setTimeout(() => { - row.remove(); - _failedMBState.typeCounts = {}; // Force refresh counts - _loadFailedMBLookups(); - }, 200); - } - } - } catch (err) { - showToast('Failed to delete entry', 'error'); - } -} - -async function _failedMBClearAll() { - if (!confirm(`Clear all ${_failedMBState.total} failed lookups? They will be retried on next enrichment run.`)) return; - try { - const resp = await fetch('/api/metadata-cache/clear-musicbrainz?failed_only=true', { method: 'DELETE' }); - const data = await resp.json(); - if (data.success) { - showToast(`Cleared ${data.cleared} failed lookups`, 'success'); - _failedMBState.page = 1; - _failedMBState.typeCounts = {}; // Force refresh counts - _loadFailedMBLookups(); - } - } catch (err) { - showToast('Failed to clear lookups', 'error'); - } -} - -// ── MusicBrainz Search Sub-Modal ── -async function _failedMBSearch(entryId, entityType, entityName, artistName) { - // Remove existing search modal if any - const existing = document.getElementById('mb-search-modal-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'mb-search-modal-overlay'; - overlay.className = 'modal-overlay'; - overlay.style.zIndex = '10001'; - overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); }; - - const typeLabels = { artist: 'Artist', release: 'Album', recording: 'Track' }; - overlay.innerHTML = ` -
-
-
-

Search MusicBrainz

-

Find a match for: ${escapeHtml(entityName)}${artistName ? ` by ${escapeHtml(artistName)}` : ''}

-
- -
-
-
- - -
-
- - -
-
- - -
- -
-
-
Enter a search query and click Search
-
-
- `; - document.body.appendChild(overlay); - - // Toggle artist row visibility based on type - const typeSelect = overlay.querySelector('#mb-search-type'); - typeSelect.addEventListener('change', () => { - const artistRow = overlay.querySelector('#mb-search-artist-row'); - artistRow.style.display = typeSelect.value === 'artist' ? 'none' : ''; - }); - - // Enter to search - overlay.querySelectorAll('.mb-search-input').forEach(input => { - input.addEventListener('keydown', (e) => { if (e.key === 'Enter') _runMBSearch(entryId); }); - }); - - // Auto-search on open - _runMBSearch(entryId); -} - -async function _runMBSearch(entryId) { - const resultsEl = document.getElementById('mb-search-results'); - const typeEl = document.getElementById('mb-search-type'); - const queryEl = document.getElementById('mb-search-query'); - const artistEl = document.getElementById('mb-search-artist'); - const goBtn = document.getElementById('mb-search-go-btn'); - if (!resultsEl || !queryEl) return; - - const type = typeEl.value; - const query = queryEl.value.trim(); - const artist = artistEl ? artistEl.value.trim() : ''; - if (!query) return; - - goBtn.disabled = true; - goBtn.textContent = 'Searching...'; - resultsEl.innerHTML = '
Searching MusicBrainz...
'; - - try { - const params = new URLSearchParams({ type, q: query, limit: 10 }); - if (artist && type !== 'artist') params.set('artist', artist); - - const resp = await fetch(`/api/musicbrainz/search?${params}`); - if (!resp.ok) throw new Error('Search failed'); - const data = await resp.json(); - - if (!data.results || data.results.length === 0) { - resultsEl.innerHTML = '
No results found. Try adjusting your search.
'; - return; - } - - resultsEl.innerHTML = data.results.map((r, i) => { - const scoreColor = r.score >= 90 ? '#4ade80' : r.score >= 70 ? '#fbbf24' : '#f87171'; - let detail = ''; - if (type === 'release') detail = [r.artist, r.date, r.track_count ? `${r.track_count} tracks` : ''].filter(Boolean).join(' · '); - else if (type === 'recording') detail = [r.artist, r.album].filter(Boolean).join(' · '); - else detail = [r.type, r.country].filter(Boolean).join(' · '); - - return ` -
-
${r.score}%
-
-
${escapeHtml(r.name)}
- ${r.disambiguation ? `
${escapeHtml(r.disambiguation)}
` : ''} - ${detail ? `
${escapeHtml(detail)}
` : ''} -
-
${r.mbid.substring(0, 8)}...
-
- `; - }).join(''); - } catch (err) { - resultsEl.innerHTML = `
Search error: ${err.message}
`; - } finally { - goBtn.disabled = false; - goBtn.textContent = 'Search'; - } -} - -async function _selectMBMatch(entryId, mbid, mbName) { - try { - const resp = await fetch('/api/metadata-cache/mb-match', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ entry_id: entryId, mbid, mb_name: mbName }) - }); - const data = await resp.json(); - if (data.success) { - showToast(`Matched to: ${mbName}`, 'success'); - // Close search modal, refresh list with fresh counts - const searchOverlay = document.getElementById('mb-search-modal-overlay'); - if (searchOverlay) searchOverlay.remove(); - _failedMBState.typeCounts = {}; - _loadFailedMBLookups(); - } else { - showToast(data.error || 'Failed to save match', 'error'); - } - } catch (err) { - showToast('Failed to save match', 'error'); - } -} - -function filterFindingsByJob(jobId) { - const jobFilter = document.getElementById('repair-findings-job-filter'); - if (!jobFilter) return; - - // Toggle: click same chip again to clear filter - if (jobFilter.value === jobId) { - jobFilter.value = ''; - } else { - jobFilter.value = jobId; - } - _repairFindingsPage = 0; - loadRepairFindingsDashboard(); - loadRepairFindings(); -} - -async function loadRepairFindings() { - const container = document.getElementById('repair-findings-list'); - if (!container) return; - - const jobFilter = document.getElementById('repair-findings-job-filter'); - const severityFilter = document.getElementById('repair-findings-severity-filter'); - const statusFilter = document.getElementById('repair-findings-status-filter'); - - const params = new URLSearchParams(); - if (jobFilter && jobFilter.value) params.set('job_id', jobFilter.value); - if (severityFilter && severityFilter.value) params.set('severity', severityFilter.value); - if (statusFilter && statusFilter.value) params.set('status', statusFilter.value); - params.set('page', _repairFindingsPage); - params.set('limit', REPAIR_FINDINGS_PAGE_SIZE); - - try { - const response = await fetch(`/api/repair/findings?${params}`); - if (!response.ok) throw new Error('Failed to fetch findings'); - const data = await response.json(); - const items = data.items || []; - - _repairSelectedFindings.clear(); - _repairFindingsTotal = data.total || 0; - const bulkBar = document.getElementById('repair-findings-bulk'); - if (bulkBar) bulkBar.style.display = 'none'; - const selectAllCb = document.getElementById('repair-select-all-cb'); - if (selectAllCb) { selectAllCb.checked = false; selectAllCb.indeterminate = false; } - - if (items.length === 0) { - container.innerHTML = `
-
-
All Clear
-
No findings match your filters. Your library is looking good!
-
`; - document.getElementById('repair-findings-pagination').innerHTML = ''; - return; - } - - const severityIcons = { info: 'ℹ️', warning: '⚠️', critical: '🔴' }; - const typeLabels = { - dead_file: 'Dead File', orphan_file: 'Orphan', acoustid_mismatch: 'Wrong Song', - acoustid_no_match: 'No Match', fake_lossless: 'Fake Lossless', - duplicate_tracks: 'Duplicate', incomplete_album: 'Incomplete', - path_mismatch: 'Path Mismatch', metadata_gap: 'Missing Metadata', - missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number', - missing_lossy_copy: 'No Lossy Copy' - }; - - // Finding types that have an automated fix action - const fixableTypes = { - dead_file: 'Re-download', - orphan_file: 'Resolve', - track_number_mismatch: 'Fix', - missing_cover_art: 'Apply Art', - metadata_gap: 'Apply', - duplicate_tracks: 'Keep Best', - incomplete_album: 'Auto-Fill', - missing_lossy_copy: 'Convert', - acoustid_mismatch: 'Fix', - missing_discography_track: 'Add to Wishlist', - }; - - container.innerHTML = items.map(f => { - const icon = severityIcons[f.severity] || 'ℹ️'; - const age = formatCacheAge(f.created_at); - const actionLabels = { - removed_db_entry: 'Entry Removed', added_to_wishlist: 'Wishlisted', deleted_file: 'File Deleted', - already_gone: 'Already Gone', fixed_track_number: 'Track # Fixed', - applied_cover_art: 'Art Applied', applied_metadata: 'Metadata Applied', - removed_duplicates: 'Duplicates Removed', - }; - let statusBadge = ''; - if (f.status !== 'pending') { - const actionText = actionLabels[f.user_action] || f.status; - statusBadge = `${actionText}`; - } - const typeLabel = typeLabels[f.finding_type] || f.finding_type.replace(/_/g, ' '); - const d = f.details || {}; - const filePath = f.file_path || d.original_path || d.file_path || ''; - const fixLabel = fixableTypes[f.finding_type]; - - return `
-
-
- -
-
-
- ${icon} - ${_escFinding(f.title)} - ${typeLabel} - ${statusBadge} -
-
${_escFinding(f.description || '')}
- ${filePath ? `
${_escFinding(filePath)}
` : ''} -
- ${f.job_id.replace(/_/g, ' ')} - · - ${f.entity_type || 'file'} - ${f.entity_id ? `·ID: ${f.entity_id}` : ''} - · - ${age} -
-
-
- ${f.status === 'pending' ? ` - ${fixLabel ? `` : ''} - - ` : ''} - -
-
-
-
- ${_renderFindingDetail(f)} -
-
-
`; - }).join(''); - - // Pagination - renderRepairFindingsPagination(data.total, data.page); - - } catch (error) { - console.error('Error loading findings:', error); - container.innerHTML = '
Error loading findings
'; - } -} - -function _escFinding(s) { - if (!s) return ''; - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -function _renderScoreBar(value, label) { - const pct = Math.round((value || 0) * 100); - const cls = pct >= 80 ? 'good' : pct >= 50 ? 'warn' : 'bad'; - return `
- ${label} -
- ${pct}% -
`; -} - -function _formatFileSize(bytes) { - if (!bytes) return '-'; - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / 1048576).toFixed(1) + ' MB'; -} - -function _renderPlayButton(f) { - const d = f.details || {}; - const filePath = f.file_path || d.file_path || d.original_path; - if (!filePath) return ''; - const title = d.expected_title || d.title || d.file_title || d.matched_title || ''; - const artist = d.expected_artist || d.artist || d.artist_name || ''; - const album = d.album || d.album_title || ''; - const albumArt = d.album_thumb_url || ''; - return ``; -} - -function playFindingTrack(btn) { - const track = { - file_path: btn.dataset.path, - title: btn.dataset.title || 'Unknown Track', - id: btn.dataset.entityId || null - }; - const albumTitle = btn.dataset.album || ''; - const artistName = btn.dataset.artist || ''; - playLibraryTrack(track, albumTitle, artistName); -} - -function _renderFindingMedia(d) { - const albumUrl = d.album_thumb_url; - const artistUrl = d.artist_thumb_url; - if (!albumUrl && !artistUrl) return ''; - let html = '
'; - if (albumUrl) { - const albumLabel = d.album_title || 'Album'; - html += `
- Album art - ${_escFinding(albumLabel)} -
`; - } - if (artistUrl) { - const artistLabel = d.artist_name || d.artist || 'Artist'; - html += `
- Artist - ${_escFinding(artistLabel)} -
`; - } - html += '
'; - return html; -} - -function _renderFindingDetail(f) { - const d = f.details || {}; - const rows = []; - const media = _renderFindingMedia(d); - - switch (f.finding_type) { - case 'dead_file': - if (d.artist) rows.push(['Artist', d.artist]); - if (d.album) rows.push(['Album', d.album]); - if (d.title) rows.push(['Title', d.title]); - if (d.track_id) rows.push(['Track ID', d.track_id]); - if (d.original_path) rows.push(['Original Path', d.original_path, 'path']); - return media + _gridRows(rows) + _renderPlayButton(f); - - case 'orphan_file': - if (d.folder) rows.push(['Folder', d.folder, 'path']); - if (d.format) rows.push(['Format', d.format.toUpperCase()]); - if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]); - if (d.modified) rows.push(['Last Modified', d.modified]); - if (f.file_path) rows.push(['Full Path', f.file_path, 'path']); - return _gridRows(rows) + _renderPlayButton(f); - - case 'acoustid_mismatch': { - let html = media + '
'; - html += _renderScoreBar(d.fingerprint_score, 'Fingerprint'); - html += _renderScoreBar(d.title_similarity, 'Title Match'); - html += _renderScoreBar(d.artist_similarity, 'Artist Match'); - html += '
'; - rows.push(['Expected Title', d.expected_title || '-']); - rows.push(['Expected Artist', d.expected_artist || '-']); - rows.push(['AcoustID Title', d.acoustid_title || '-', 'highlight']); - rows.push(['AcoustID Artist', d.acoustid_artist || '-', 'highlight']); - if (f.file_path) rows.push(['File', f.file_path, 'path']); - return html + _gridRows(rows) + _renderPlayButton(f); - } - - case 'acoustid_no_match': - if (d.expected_title) rows.push(['Expected Title', d.expected_title]); - if (d.expected_artist) rows.push(['Expected Artist', d.expected_artist]); - if (f.file_path) rows.push(['File', f.file_path, 'path']); - return media + _gridRows(rows) + _renderPlayButton(f); - - case 'fake_lossless': { - const cutoff = d.detected_cutoff_khz || 0; - const expectedMin = d.expected_min_khz || 0; - const nyquist = d.nyquist_khz || (d.sample_rate ? d.sample_rate / 2000 : 22.05); - let flHtml = ''; - if (cutoff && expectedMin) { - const cutoffPct = Math.min(100, Math.round((cutoff / nyquist) * 100)); - const expectedPct = Math.min(100, Math.round((expectedMin / nyquist) * 100)); - flHtml += `
-
Spectral Analysis
-
-
-
-
-
- ${cutoff} kHz detected - ${expectedMin} kHz expected min -
-
`; - } - if (d.format) rows.push(['Format', d.format.toUpperCase()]); - if (d.sample_rate) rows.push(['Sample Rate', `${d.sample_rate} Hz`]); - if (d.bit_depth) rows.push(['Bit Depth', `${d.bit_depth}-bit`]); - if (d.bitrate) rows.push(['Bitrate', `${d.bitrate} kbps`]); - if (d.file_size) rows.push(['File Size', _formatFileSize(d.file_size)]); - if (f.file_path) rows.push(['File', f.file_path, 'path']); - return flHtml + _gridRows(rows) + _renderPlayButton(f); - } - - case 'duplicate_tracks': - if (!d.tracks || !d.tracks.length) return _gridRows([['Count', d.count || '?']]); - // Determine best copy (same logic as backend: highest bitrate, then duration, then track number) - const bestDup = d.tracks.reduce((best, t) => { - const bBr = best.bitrate || 0, tBr = t.bitrate || 0; - const bDur = best.duration || 0, tDur = t.duration || 0; - const bTn = best.track_number || 0, tTn = t.track_number || 0; - return (tBr > bBr || (tBr === bBr && tDur > bDur) || (tBr === bBr && tDur === bDur && tTn > bTn)) ? t : best; - }, d.tracks[0]); - const findingId = f.id; - return media + `
${d.tracks.map((t, i) => { - const tid = t.track_id || t.id; - const isBest = (t.id === bestDup.id); - return `
- - ${isBest ? 'KEEP' : 'REMOVE'} - ${_escFinding(t.title)} by ${_escFinding(t.artist)} - - Album: ${_escFinding(t.album || 'Unknown')}${t.bitrate ? ` · ${t.bitrate} kbps` : ''}${t.duration ? ` · ${Math.round(t.duration)}s` : ''}${t.track_number ? ` · Track #${t.track_number}` : ''} - ${t.file_path ? `${_escFinding(t.file_path)}` : ''} -
`; - }).join('')}
-
Click on a version to keep it, or use "Keep Best" for auto-selection
`; - - case 'incomplete_album': - if (d.artist) rows.push(['Artist', d.artist]); - if (d.album_title) rows.push(['Album', d.album_title]); - if (d.primary_source && d.primary_album_id) { - const primaryLabel = d.primary_source.charAt(0).toUpperCase() + d.primary_source.slice(1); - rows.push([`${primaryLabel} ID`, d.primary_album_id]); - if (d.spotify_album_id && d.primary_source !== 'spotify') { - rows.push(['Spotify ID', d.spotify_album_id]); - } - } else if (d.spotify_album_id) { - rows.push(['Spotify ID', d.spotify_album_id]); - } - let incHtml = media + _gridRows(rows); - const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0; - if (expected > 0) { - const pct = Math.round((actual / expected) * 100); - incHtml += `
-
${actual} of ${expected} tracks (${pct}%)
-
-
`; - } - if (d.missing_tracks && d.missing_tracks.length) { - incHtml += `
${d.missing_tracks.map(t => ` -
- #${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')} - ${t.source && t.source !== 'spotify' ? `Source: ${_escFinding(t.source)}${t.source_track_id ? ` · ID: ${_escFinding(t.source_track_id)}` : ''}` : ''} - ${t.duration_ms ? `Duration: ${Math.round(t.duration_ms / 1000)}s` : ''} -
`).join('')}
`; - } - return incHtml; - - case 'path_mismatch': - if (d.from) rows.push(['Current Path', d.from, 'path']); - if (d.to) rows.push(['Expected Path', d.to, 'success']); - return _gridRows(rows); - - case 'metadata_gap': - if (d.artist) rows.push(['Artist', d.artist]); - if (d.album) rows.push(['Album', d.album]); - if (d.title) rows.push(['Title', d.title]); - if (d.spotify_track_id) rows.push(['Spotify ID', d.spotify_track_id]); - if (d.resolved_source) rows.push(['Resolved Source', d.resolved_source]); - if (d.resolved_track_id) rows.push(['Resolved Track ID', d.resolved_track_id]); - if (d.found_fields && typeof d.found_fields === 'object') { - Object.entries(d.found_fields).forEach(([k, v]) => { - rows.push([`Found: ${k}`, String(v), 'success']); - }); - } - return media + _gridRows(rows); - - case 'missing_cover_art': - if (d.artist) rows.push(['Artist', d.artist]); - if (d.album_title) rows.push(['Album', d.album_title]); - if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]); - let artHtml = ''; - // Show artist image + found artwork side by side - if (d.artist_thumb_url || d.found_artwork_url) { - artHtml += '
'; - if (d.artist_thumb_url) { - artHtml += `
- Artist - ${_escFinding(d.artist || 'Artist')} -
`; - } - if (d.found_artwork_url) { - artHtml += `
- Found artwork - Found Artwork -
`; - } - artHtml += '
'; - } - artHtml += _gridRows(rows); - return artHtml; - - case 'track_number_mismatch': - if (d.album_title) rows.push(['Album', d.album_title]); - if (d.artist_name) rows.push(['Artist', d.artist_name]); - if (d.matched_title) rows.push(['Matched To', d.matched_title]); - if (d.file_title) rows.push(['File Title', d.file_title]); - if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]); - if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']); - if (f.file_path) rows.push(['File', f.file_path, 'path']); - let tnHtml = media; - if (d.match_score) { - tnHtml += '
'; - tnHtml += _renderScoreBar(d.match_score, 'Title Match'); - tnHtml += '
'; - } - tnHtml += _gridRows(rows); - if (d.changes && d.changes.length) { - tnHtml += `
${d.changes.map(c => ` -
${_escFinding(c)}
`).join('')}
`; - } - tnHtml += _renderPlayButton(f); - return tnHtml; - - default: - // Generic: render all detail keys - Object.entries(d).forEach(([k, v]) => { - if (typeof v !== 'object' && !k.endsWith('_thumb_url')) { - rows.push([k.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), String(v)]); - } - }); - if (f.file_path) rows.push(['File', f.file_path, 'path']); - return (media || '') + (rows.length ? _gridRows(rows) : 'No additional details available'); - } -} - -function _gridRows(rows) { - if (!rows.length) return ''; - return `
${rows.map(([k, v, cls]) => - `${_escFinding(k)}${_escFinding(v)}` - ).join('')}
`; -} - -function toggleFindingDetail(id) { - const panel = document.getElementById(`repair-detail-${id}`); - const btn = document.querySelector(`.repair-finding-expand-btn[data-finding="${id}"]`); - if (!panel) return; - const isOpen = panel.classList.toggle('open'); - if (btn) btn.classList.toggle('open', isOpen); -} - -function toggleFindingSelect(id, checked) { - if (checked) _repairSelectedFindings.add(id); - else _repairSelectedFindings.delete(id); - - _updateFindingsBulkBar(); -} - -function _updateFindingsBulkBar() { - const bulkBar = document.getElementById('repair-findings-bulk'); - const count = _repairSelectedFindings.size; - if (bulkBar) bulkBar.style.display = count > 0 ? '' : 'none'; - const countEl = document.getElementById('repair-bulk-count'); - if (countEl) countEl.textContent = count > 0 ? `${count} selected` : ''; - - // Show "Fix All (N)" when all on page are selected and there are more pages - const fixAllBtn = document.getElementById('repair-fix-all-btn'); - if (fixAllBtn && _repairFindingsTotal > 0) { - const allPageSelected = count > 0 && count >= document.querySelectorAll('.repair-finding-card').length; - fixAllBtn.style.display = (allPageSelected && _repairFindingsTotal > count) ? '' : 'none'; - fixAllBtn.textContent = `Fix All ${_repairFindingsTotal}`; - } - - // Sync "Select All" checkbox - const selectAllCb = document.getElementById('repair-select-all-cb'); - if (selectAllCb) { - const totalOnPage = document.querySelectorAll('.repair-finding-card').length; - selectAllCb.checked = totalOnPage > 0 && count >= totalOnPage; - selectAllCb.indeterminate = count > 0 && count < totalOnPage; - } -} - -function toggleSelectAllFindings(checked) { - const checkboxes = document.querySelectorAll('.repair-finding-select input[type="checkbox"]'); - checkboxes.forEach(cb => { - cb.checked = checked; - const card = cb.closest('.repair-finding-card'); - if (card) { - const id = parseInt(card.dataset.id); - if (checked) _repairSelectedFindings.add(id); - else _repairSelectedFindings.delete(id); - } - }); - _updateFindingsBulkBar(); -} - -async function fixAllMatchingFindings() { - const jobFilter = document.getElementById('repair-findings-job-filter'); - const severityFilter = document.getElementById('repair-findings-severity-filter'); - const jobId = jobFilter ? jobFilter.value : ''; - const severity = severityFilter ? severityFilter.value : ''; - - // If fixing orphan files or dead files, prompt for action FIRST - let fixAction = null; - if (jobId === 'dead_file_cleaner') { - fixAction = await _promptDeadFileAction(); - if (!fixAction) return; - } else if (jobId === 'orphan_file_detector' || _isMassOrphanFix(jobId, _repairFindingsTotal)) { - fixAction = await _promptOrphanAction(); - if (!fixAction) return; - // Confirm before proceeding - if (fixAction === 'delete' && _repairFindingsTotal > 50) { - if (!await showWitnessMeDialog(_repairFindingsTotal)) return; - } else if (fixAction === 'delete') { - if (!await showConfirmDialog({ - title: 'Delete Orphan Files', - message: `Permanently delete ${_repairFindingsTotal} orphan files from disk? This cannot be undone.`, - confirmText: 'Delete', - destructive: true - })) return; - } else if (fixAction === 'staging') { - if (!await showConfirmDialog({ - title: 'Move to Staging', - message: `Move ${_repairFindingsTotal} orphan files to the import folder? Files are NOT deleted — you can review and import them.`, - confirmText: 'Move All to Staging', - destructive: false - })) return; - } - } else { - const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; - if (!await showConfirmDialog({ - title: 'Fix All Findings', - message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`, - confirmText: 'Fix All', - destructive: true - })) return; - } - - showToast(`Fixing ${_repairFindingsTotal} findings...`, 'info'); - - try { - const body = {}; - if (jobId) body.job_id = jobId; - if (severity) body.severity = severity; - if (fixAction) body.fix_action = fixAction; - - const response = await fetch('/api/repair/findings/bulk-fix', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - const result = await response.json(); - if (result.success) { - let msg = `Fixed ${result.fixed}${result.failed ? `, ${result.failed} failed` : ''} of ${result.total}`; - if (result.errors && result.errors.length > 0) { - msg += `: ${result.errors[0].error}`; - } - showToast(msg, result.fixed > 0 ? 'success' : 'error'); - } else { - showToast(result.error || 'Bulk fix failed', 'error'); - } - } catch (error) { - console.error('Error in bulk fix:', error); - showToast('Error applying bulk fix', 'error'); - } - - _repairSelectedFindings.clear(); - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); -} - -function renderRepairFindingsPagination(total, currentPage) { - const container = document.getElementById('repair-findings-pagination'); - if (!container) return; - - const totalPages = Math.ceil(total / REPAIR_FINDINGS_PAGE_SIZE); - if (totalPages <= 1) { container.innerHTML = ''; return; } - - let html = ''; - if (currentPage > 0) { - html += ``; - } - - // Smart page range - let startPage = Math.max(0, currentPage - 3); - let endPage = Math.min(totalPages, startPage + 7); - if (endPage - startPage < 7) startPage = Math.max(0, endPage - 7); - - if (startPage > 0) { - html += ``; - if (startPage > 1) html += '...'; - } - for (let i = startPage; i < endPage; i++) { - html += ``; - } - if (endPage < totalPages) { - if (endPage < totalPages - 1) html += '...'; - html += ``; - } - - if (currentPage < totalPages - 1) { - html += ``; - } - html += `${total.toLocaleString()} total`; - container.innerHTML = html; -} - -async function selectDuplicateToKeep(findingId, keepTrackId) { - if (!await showConfirmDialog({ title: 'Keep This Version', message: 'Keep this version and remove the other duplicate(s)?', confirmText: 'Keep', destructive: true })) return; - try { - const response = await fetch(`/api/repair/findings/${findingId}/fix`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ fix_action: keepTrackId }), - }); - const result = await response.json(); - if (result.success) { - showToast(result.message || 'Duplicate resolved', 'success'); - } else { - showToast(result.error || 'Failed to resolve duplicate', 'error'); - } - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); - } catch (error) { - console.error('Error fixing duplicate:', error); - showToast('Error resolving duplicate', 'error'); - } -} - -async function fixRepairFinding(id, findingType) { - // Orphan files require user to choose an action - let fixAction = null; - if (findingType === 'orphan_file') { - fixAction = await _promptOrphanAction(); - if (!fixAction) return; // User cancelled - } - // Dead files: re-download or just remove from DB - if (findingType === 'dead_file') { - fixAction = await _promptDeadFileAction(); - if (!fixAction) return; - } - // AcoustID mismatch: retag, redownload, or delete - if (findingType === 'acoustid_mismatch') { - fixAction = await _promptAcoustidAction(); - if (!fixAction) return; - } - - const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); - const fixBtn = card ? card.querySelector('.repair-finding-btn.fix') : null; - let originalText = ''; - if (fixBtn) { - originalText = fixBtn.textContent; - fixBtn.disabled = true; - fixBtn.textContent = '...'; - } - try { - const body = fixAction ? { fix_action: fixAction } : {}; - const response = await fetch(`/api/repair/findings/${id}/fix`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const result = await response.json(); - if (result.success) { - showToast(result.message || 'Fixed successfully', 'success'); - } else { - showToast(result.error || 'Fix failed', 'error'); - } - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); - } catch (error) { - console.error('Error fixing finding:', error); - showToast('Error applying fix', 'error'); - if (fixBtn) { - fixBtn.disabled = false; - fixBtn.textContent = originalText; - } - } -} - -function _promptOrphanAction() { - return new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; - overlay.innerHTML = ` -
-
Orphan File Action
-
- Choose how to handle orphan files. Staging is safe and reversible. -
-
- - -
- -
- `; - document.body.appendChild(overlay); - - overlay.querySelector('#_orphan-staging').onclick = () => { overlay.remove(); resolve('staging'); }; - overlay.querySelector('#_orphan-delete').onclick = () => { overlay.remove(); resolve('delete'); }; - overlay.querySelector('#_orphan-cancel').onclick = () => { overlay.remove(); resolve(null); }; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - }); -} - -function _promptDeadFileAction() { - return new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; - overlay.innerHTML = ` -
-
Dead File Action
-
- This track's file no longer exists on disk. Choose how to handle it. -
-
- - -
- -
- `; - document.body.appendChild(overlay); - - overlay.querySelector('#_dead-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; - overlay.querySelector('#_dead-remove').onclick = () => { overlay.remove(); resolve('remove'); }; - overlay.querySelector('#_dead-cancel').onclick = () => { overlay.remove(); resolve(null); }; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - }); -} - -function _promptAcoustidAction() { - return new Promise(resolve => { - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;z-index:10000;'; - overlay.innerHTML = ` -
-
AcoustID Mismatch
-
- The audio fingerprint doesn't match the expected track. Choose how to fix it. -
-
- - - -
-
- Retag = update metadata to match actual audio • Re-download = add correct track to wishlist & delete wrong file • Delete = remove file and DB entry -
- -
- `; - document.body.appendChild(overlay); - - overlay.querySelector('#_acid-retag').onclick = () => { overlay.remove(); resolve('retag'); }; - overlay.querySelector('#_acid-redownload').onclick = () => { overlay.remove(); resolve('redownload'); }; - overlay.querySelector('#_acid-delete').onclick = () => { overlay.remove(); resolve('delete'); }; - overlay.querySelector('#_acid-cancel').onclick = () => { overlay.remove(); resolve(null); }; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - }); -} - -async function resolveRepairFinding(id) { - try { - await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' }); - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); - } catch (error) { - console.error('Error resolving finding:', error); - } -} - -async function dismissRepairFinding(id) { - try { - await fetch(`/api/repair/findings/${id}/dismiss`, { method: 'POST' }); - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); - } catch (error) { - console.error('Error dismissing finding:', error); - } -} - -async function bulkRepairAction(action) { - if (_repairSelectedFindings.size === 0) return; - try { - await fetch('/api/repair/findings/bulk', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ids: Array.from(_repairSelectedFindings), action }) - }); - showToast(`${_repairSelectedFindings.size} findings ${action === 'dismiss' ? 'dismissed' : 'resolved'}`, 'success'); - _repairSelectedFindings.clear(); - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); - } catch (error) { - console.error('Error bulk updating findings:', error); - showToast('Error updating findings', 'error'); - } -} - -async function bulkFixFindings() { - if (_repairSelectedFindings.size === 0) return; - const ids = Array.from(_repairSelectedFindings); - - // If any selected findings are orphan files, prompt for action FIRST - const selectedOrphanCards = ids.filter(id => { - const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); - return card && card.dataset.jobId === 'orphan_file_detector'; - }); - let orphanFixAction = null; - if (selectedOrphanCards.length > 0) { - orphanFixAction = await _promptOrphanAction(); - if (!orphanFixAction) return; - // Only show scary dialog for mass deletion, not staging - if (orphanFixAction === 'delete' && selectedOrphanCards.length > MASS_ORPHAN_THRESHOLD) { - const hasMassFlag = ids.some(id => { - const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); - return card && card.dataset.massOrphan === 'true'; - }); - if (hasMassFlag && !await showWitnessMeDialog(selectedOrphanCards.length)) return; - } - } - - // If any selected findings are dead files, prompt for action - const selectedDeadCards = ids.filter(id => { - const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); - return card && card.dataset.jobId === 'dead_file_cleaner'; - }); - let deadFixAction = null; - if (selectedDeadCards.length > 0) { - deadFixAction = await _promptDeadFileAction(); - if (!deadFixAction) return; - } - - // If any selected findings are AcoustID mismatches, prompt for action - const selectedAcoustidCards = ids.filter(id => { - const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); - return card && card.dataset.jobId === 'acoustid_scanner'; - }); - let acoustidFixAction = null; - if (selectedAcoustidCards.length > 0) { - acoustidFixAction = await _promptAcoustidAction(); - if (!acoustidFixAction) return; - } - - let fixed = 0, failed = 0, lastError = ''; - showToast(`Fixing ${ids.length} findings...`, 'info'); - - for (const id of ids) { - try { - // Determine if this finding needs a specific action - const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`); - const isOrphan = card && card.dataset.jobId === 'orphan_file_detector'; - const isDead = card && card.dataset.jobId === 'dead_file_cleaner'; - const isAcoustid = card && card.dataset.jobId === 'acoustid_scanner'; - let body = {}; - if (isOrphan && orphanFixAction) body = { fix_action: orphanFixAction }; - else if (isDead && deadFixAction) body = { fix_action: deadFixAction }; - else if (isAcoustid && acoustidFixAction) body = { fix_action: acoustidFixAction }; - - const response = await fetch(`/api/repair/findings/${id}/fix`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const result = await response.json(); - if (result.success) fixed++; - else { failed++; lastError = result.error || 'unknown error'; } - } catch { - failed++; - } - } - - _repairSelectedFindings.clear(); - let fixMsg = `Fixed ${fixed}${failed ? `, ${failed} failed` : ''}`; - if (failed && lastError) fixMsg += `: ${lastError}`; - showToast(fixMsg, fixed > 0 ? 'success' : 'error'); - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); -} - -async function clearRepairFindings() { - const jobFilter = document.getElementById('repair-findings-job-filter'); - const statusFilter = document.getElementById('repair-findings-status-filter'); - const jobId = jobFilter ? jobFilter.value : ''; - const status = statusFilter ? statusFilter.value : ''; - - const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs'; - const statusLabel = status ? ` (${status})` : ''; - if (!await showConfirmDialog({ - title: 'Clear Findings', - message: `Delete all findings for ${scopeLabel}${statusLabel}? This cannot be undone.`, - confirmText: 'Clear', - destructive: true - })) return; - - try { - const body = {}; - if (jobId) body.job_id = jobId; - if (status) body.status = status; - - const response = await fetch('/api/repair/findings/clear', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - const result = await response.json(); - if (result.success) { - showToast(`Cleared ${result.deleted} findings`, 'success'); - } else { - showToast(result.error || 'Failed to clear findings', 'error'); - } - _repairSelectedFindings.clear(); - loadRepairFindingsDashboard(); - loadRepairFindings(); - updateRepairStatus(); - } catch (error) { - console.error('Error clearing findings:', error); - showToast('Error clearing findings', 'error'); - } -} - -async function loadRepairHistory() { - const container = document.getElementById('repair-history-list'); - if (!container) return; - - try { - const response = await fetch('/api/repair/history?limit=50'); - if (!response.ok) throw new Error('Failed to fetch history'); - const data = await response.json(); - const runs = data.runs || []; - - if (runs.length === 0) { - container.innerHTML = `
-
🕑
-
No History Yet
-
Job run history will appear here after maintenance jobs complete their first scan.
-
`; - return; - } - - container.innerHTML = runs.map(run => { - const duration = run.duration_seconds ? `${run.duration_seconds.toFixed(1)}s` : '-'; - const age = formatCacheAge(run.started_at); - const statusClass = run.status === 'completed' ? 'success' : - run.status === 'failed' ? 'error' : 'running'; - - // Build stat pills - const stats = []; - stats.push(`${(run.items_scanned || 0).toLocaleString()} scanned`); - if (run.findings_created) stats.push(`${run.findings_created} findings`); - if (run.auto_fixed) stats.push(`${run.auto_fixed} fixed`); - if (run.errors) stats.push(`${run.errors} errors`); - - // Format timestamps - const startTime = run.started_at ? new Date(run.started_at).toLocaleString() : '-'; - const endTime = run.finished_at ? new Date(run.finished_at).toLocaleString() : 'In progress'; - - return `
-
-
- ${run.display_name || run.job_id} - ${run.status} - ${duration} -
-
${stats.join('')}
-
${age} · ${startTime} → ${endTime}
-
`; - }).join(''); - - } catch (error) { - console.error('Error loading repair history:', error); - container.innerHTML = '
Error loading history
'; - } -} - -// Initialize Repair Worker UI on page load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - const button = document.getElementById('repair-button'); - if (button) { - button.addEventListener('click', openRepairModal); - updateRepairStatus(); - setInterval(updateRepairStatus, 5000); - } - }); -} else { - const button = document.getElementById('repair-button'); - if (button) { - button.addEventListener('click', openRepairModal); - updateRepairStatus(); - setInterval(updateRepairStatus, 5000); - } -} - -// =================================================================== -// IMPORT PAGE (full page, replaces old modal) -// =================================================================== - -let importJobIdCounter = 0; - -const importPageState = { - stagingFiles: [], - selectedSingles: new Set(), - albumData: null, // response from /api/import/album/match - matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides - singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } } - initialized: false, - activeTab: 'album', - tapSelectedChip: null, // for mobile tap-to-assign fallback -}; - -// =============================== -// STATS PAGE -// =============================== - -let _statsRange = '7d'; -let _statsTimelineChart = null; -let _statsGenreChart = null; -let _statsDbStorageChart = null; -let _statsInitialized = false; - -function initializeStatsPage() { - if (_statsInitialized) { - loadStatsData(); - return; - } - _statsInitialized = true; - - // Time range buttons - const rangeContainer = document.getElementById('stats-time-range'); - if (rangeContainer) { - rangeContainer.addEventListener('click', (e) => { - const btn = e.target.closest('.stats-range-btn'); - if (!btn) return; - _statsRange = btn.dataset.range; - rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - loadStatsData(); - }); - } - - loadStatsData(); - _updateStatsLastSynced(); -} - -async function triggerStatsSync() { - const btn = document.getElementById('stats-sync-btn'); - if (btn) btn.classList.add('syncing'); - - try { - const resp = await fetch('/api/listening-stats/sync', { method: 'POST' }); - const data = await resp.json(); - if (data.success) { - showToast('Syncing listening data...', 'info'); - // Wait a few seconds for the sync to complete, then reload - setTimeout(async () => { - await loadStatsData(); - _updateStatsLastSynced(); - if (btn) btn.classList.remove('syncing'); - showToast('Listening stats updated', 'success'); - }, 5000); - } else { - showToast(data.error || 'Sync failed', 'error'); - if (btn) btn.classList.remove('syncing'); - } - } catch (e) { - showToast('Sync failed', 'error'); - if (btn) btn.classList.remove('syncing'); - } -} - -async function _updateStatsLastSynced() { - const el = document.getElementById('stats-last-synced'); - if (!el) return; - try { - const resp = await fetch('/api/listening-stats/status'); - const data = await resp.json(); - if (data.stats && data.stats.last_poll) { - el.textContent = `Last synced: ${data.stats.last_poll}`; - } else { - el.textContent = 'Not synced yet'; - } - } catch { - el.textContent = ''; - } -} - -async function loadStatsData() { - // Show loading state - document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3'); - - // Single cached endpoint — instant response - let data; - try { - const resp = await fetch(`/api/stats/cached?range=${_statsRange}`); - data = await resp.json(); - } catch { - data = {}; - } - - if (!data.success) { - // Cache not available — show empty state, user should hit Sync - data = { - overview: {}, top_artists: [], top_albums: [], top_tracks: [], - timeline: [], genres: [], recent: [], health: {} - }; - } - - const overview = data.overview || {}; - const emptyEl = document.getElementById('stats-empty'); - const hasData = (overview.total_plays || 0) > 0; - - if (emptyEl) { - emptyEl.classList.toggle('hidden', hasData); - } - // Hide main content sections when no data - const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width'); - mainSections.forEach(el => el.style.display = hasData ? '' : 'none'); - - // Overview cards - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toLocaleString(); - }; - const _fmtTime = (ms) => { - if (!ms) return '0h'; - const hours = Math.floor(ms / 3600000); - const mins = Math.floor((ms % 3600000) / 60000); - if (hours > 0) return `${hours}h ${mins}m`; - return `${mins}m`; - }; - - // Restore opacity - document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1'); - - _setText('stats-total-plays', _fmt(overview.total_plays)); - _setText('stats-listening-time', _fmtTime(overview.total_time_ms)); - _setText('stats-unique-artists', _fmt(overview.unique_artists)); - _setText('stats-unique-albums', _fmt(overview.unique_albums)); - _setText('stats-unique-tracks', _fmt(overview.unique_tracks)); - - // Top Artists — visual bubbles - _renderTopArtistsVisual(data.top_artists || []); - - // Top Artists — ranked list - _renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => ` -
- ${i + 1} - ${item.image_url ? `` : ''} -
-
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' ' : ''}
-
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
-
- ${_fmt(item.play_count)} plays -
- `); - - // Top Albums - _renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => ` -
- ${i + 1} - ${item.image_url ? `` : ''} -
-
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
-
- ${_fmt(item.play_count)} plays -
- `); - - // Top Tracks - _renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => ` -
- ${i + 1} - ${item.image_url ? `` : ''} -
-
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}
-
- - ${_fmt(item.play_count)} plays -
- `); - - // Timeline chart - _renderTimelineChart(data.timeline || []); - - // Genre chart - _renderGenreChart(data.genres || []); - - // Library health - _renderLibraryHealth(data.health || {}); - - // DB storage chart (separate fetch — not part of cached stats) - _loadDbStorageChart(); - - // Recent plays - _renderRecentPlays(data.recent || []); -} - -function _renderTopArtistsVisual(artists) { - const el = document.getElementById('stats-top-artists-visual'); - if (!el || !artists.length) { if (el) el.innerHTML = ''; return; } - - const top5 = artists.slice(0, 5); - const maxPlays = top5[0]?.play_count || 1; - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; - return n.toString(); - }; - - el.innerHTML = `
- ${top5.map((a, i) => { - const pct = Math.round((a.play_count / maxPlays) * 100); - const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 - return `
-
- ${!a.image_url ? `${(a.name || '?')[0]}` : ''} -
-
-
-
-
${_esc(a.name)}
-
${_fmt(a.play_count)}
-
`; - }).join('')} -
`; -} - -function _setText(id, text) { - const el = document.getElementById(id); - if (el) el.textContent = text; -} - -function _renderRankedList(containerId, items, template) { - const el = document.getElementById(containerId); - if (!el) return; - el.innerHTML = items.length - ? items.map((item, i) => template(item, i)).join('') - : '
No data yet
'; -} - -function _renderTimelineChart(data) { - const canvas = document.getElementById('stats-timeline-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsTimelineChart) _statsTimelineChart.destroy(); - - _statsTimelineChart = new Chart(canvas, { - type: 'bar', - data: { - labels: data.map(d => d.date), - datasets: [{ - label: 'Plays', - data: data.map(d => d.plays), - backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`, - borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`, - borderWidth: 1, - borderRadius: 4, - }] - }, - options: { - responsive: true, - maintainAspectRatio: false, - plugins: { legend: { display: false } }, - scales: { - x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } }, - y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true }, - } - } - }); -} - -function _renderGenreChart(data) { - const canvas = document.getElementById('stats-genre-chart'); - const legend = document.getElementById('stats-genre-legend'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsGenreChart) _statsGenreChart.destroy(); - - const colors = [ - '#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7', - '#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4', - '#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b', - ]; - - const top = data.slice(0, 10); - - _statsGenreChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(g => g.genre), - datasets: [{ - data: top.map(g => g.play_count), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 6, - }] - }, - options: { - responsive: true, - maintainAspectRatio: true, - cutout: '65%', - plugins: { legend: { display: false } }, - } - }); - - if (legend) { - legend.innerHTML = top.map((g, i) => ` -
- - ${g.genre} - ${g.percentage}% -
- `).join(''); - } -} - -function _renderLibraryHealth(data) { - if (!data || !data.total_tracks) return; - - const _fmt = (n) => { - if (!n) return '0'; - if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; - return n.toLocaleString(); - }; - - _setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`); - _setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h'); - _setText('stats-total-tracks-count', _fmt(data.total_tracks)); - - // Format bar - const bar = document.getElementById('stats-format-bar'); - if (bar && data.format_breakdown) { - const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1; - const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' }; - - bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => { - const pct = (count / total * 100).toFixed(1); - return `
${pct > 8 ? fmt : ''}
`; - }).join(''); - } - - // Enrichment coverage - const enrichEl = document.getElementById('stats-enrichment-coverage'); - if (enrichEl && data.enrichment_coverage) { - const ec = data.enrichment_coverage; - const services = [ - { name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' }, - { name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' }, - { name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' }, - { name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' }, - { name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' }, - { name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' }, - { name: 'Genius', pct: ec.genius || 0, color: '#ffff64' }, - { name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' }, - { name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' }, - ]; - enrichEl.innerHTML = services.map(s => ` -
- ${s.name} -
- ${s.pct}% -
- `).join(''); - } -} - -async function _loadDbStorageChart() { - try { - const resp = await fetch('/api/stats/db-storage'); - const data = await resp.json(); - if (!data.success || !data.tables || !data.tables.length) return; - _renderDbStorageChart(data.tables, data.total_file_size, data.method); - } catch (e) { - console.debug('DB storage chart load failed:', e); - } -} - -function _renderDbStorageChart(tables, totalFileSize, method) { - const canvas = document.getElementById('stats-db-storage-chart'); - if (!canvas || typeof Chart === 'undefined') return; - - if (_statsDbStorageChart) _statsDbStorageChart.destroy(); - - // Top 8 tables, group rest as "Other" - const top = tables.slice(0, 8); - const rest = tables.slice(8); - const restSize = rest.reduce((s, t) => s + t.size, 0); - if (restSize > 0) top.push({ name: 'Other', size: restSize }); - - const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555']; - - _statsDbStorageChart = new Chart(canvas, { - type: 'doughnut', - data: { - labels: top.map(t => t.name), - datasets: [{ - data: top.map(t => t.size), - backgroundColor: colors.slice(0, top.length), - borderWidth: 0, - hoverOffset: 4, - }], - }, - options: { - responsive: false, - cutout: '65%', - plugins: { - legend: { display: false }, - tooltip: { - callbacks: { - label: (ctx) => { - const val = ctx.parsed; - if (method === 'dbstat') { - if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`; - return ` ${(val / 1024).toFixed(0)} KB`; - } - return ` ${val.toLocaleString()} rows`; - } - } - } - }, - }, - }); - - // Center label — total file size - const totalEl = document.getElementById('stats-db-total'); - if (totalEl) { - let sizeStr; - if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB'; - else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB'; - else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB'; - totalEl.innerHTML = `
${sizeStr}
Total Size
`; - } - - // Legend - const legendEl = document.getElementById('stats-db-legend'); - if (legendEl) { - legendEl.innerHTML = top.map((t, i) => { - let sizeLabel; - if (method === 'dbstat') { - if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB'; - else sizeLabel = (t.size / 1024).toFixed(0) + ' KB'; - } else { - sizeLabel = t.size.toLocaleString() + ' rows'; - } - return `
- - ${t.name} - ${sizeLabel} -
`; - }).join(''); - } -} - -async function playStatsTrack(title, artist, album) { - try { - const resp = await fetch('/api/stats/resolve-track', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title, artist }), - }); - const data = await resp.json(); - if (!data.success || !data.track) { - showToast(data.error || 'Track not found in library', 'error'); - return; - } - const t = data.track; - playLibraryTrack({ - id: t.id, - title: t.title, - file_path: t.file_path, - bitrate: t.bitrate, - artist_id: t.artist_id, - album_id: t.album_id, - _stats_image: t.image_url || null, - }, t.album_title || album || '', t.artist_name || artist || ''); - } catch (e) { - showToast('Failed to play track', 'error'); - } -} - -function _renderRecentPlays(tracks) { - const el = document.getElementById('stats-recent-plays'); - if (!el) return; - - if (!tracks.length) { - el.innerHTML = '
No recent plays
'; - return; - } - - const _ago = (dateStr) => { - if (!dateStr) return ''; - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - return `${Math.floor(days / 30)}mo ago`; - }; - - el.innerHTML = tracks.map(t => ` -
- - ${_esc(t.title)} - ${_esc(t.artist || '')} - ${_ago(t.played_at)} -
- `).join(''); -} - -// --- Initialization --- - -function initializeImportPage() { - if (!importPageState.initialized) { - importPageState.initialized = true; - importPageRefreshStaging(); - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - } -} - -async function importPageRefreshStaging() { - // Clear finished jobs from the queue - importPageClearFinishedJobs(); - - try { - const resp = await fetch('/api/import/staging/files'); - const data = await resp.json(); - if (!data.success) { - document.getElementById('import-page-staging-path').textContent = `Import folder: error`; - return; - } - - importPageState.stagingFiles = data.files || []; - document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`; - - const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0); - const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB` - : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB` - : `${(totalSize / 1024).toFixed(0)} KB`; - document.getElementById('import-page-staging-stats').textContent = - `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`; - - // Refresh the current tab view after data is loaded - if (importPageState.activeTab === 'singles') { - importPageRenderSinglesList(); - } else if (importPageState.activeTab === 'album') { - importPageLoadAutoGroups(); - } - // Always refresh suggestions and groups in background - importPageLoadSuggestions(); - } catch (err) { - console.error('Failed to refresh staging:', err); - } -} - -function importPageSwitchTab(tab) { - importPageState.activeTab = tab; - document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto'); - document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto'); - - if (tab === 'singles' && importPageState.stagingFiles.length > 0) { - importPageRenderSinglesList(); - } - if (tab === 'auto') { - _autoImportLoadStatus(); - _autoImportLoadResults(); - _autoImportStartPolling(); - } else { - _autoImportStopPolling(); - } -} - -// ── Auto-Import Tab ── -let _autoImportPollInterval = null; -let _autoImportFilter = 'all'; - -function _autoImportStartPolling() { - _autoImportStopPolling(); - _autoImportPollInterval = setInterval(() => { - if (importPageState.activeTab === 'auto') { - _autoImportLoadStatus(); - _autoImportLoadResults(); - } - }, 5000); -} - -function _autoImportStopPolling() { - if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; } -} - -async function _autoImportToggle(enabled) { - // Optimistically update toggle state so it doesn't flicker - const toggle = document.getElementById('auto-import-enabled'); - if (toggle) toggle.checked = enabled; - const statusText = document.getElementById('auto-import-status-text'); - if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...'; - - try { - const res = await fetch('/api/auto-import/toggle', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); - const data = await res.json(); - if (data.success) { - showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); - _autoImportLoadStatus(); - } else { - // Revert on failure - if (toggle) toggle.checked = !enabled; - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - if (toggle) toggle.checked = !enabled; - } -} - -async function _autoImportLoadStatus() { - try { - const res = await fetch('/api/auto-import/status'); - const data = await res.json(); - if (!data.success) return; - - const toggle = document.getElementById('auto-import-enabled'); - const statusText = document.getElementById('auto-import-status-text'); - const settingsRow = document.getElementById('auto-import-settings-row'); - const scanNowBtn = document.getElementById('auto-import-scan-now'); - const progressEl = document.getElementById('auto-import-progress'); - const progressText = document.getElementById('auto-import-progress-text'); - - if (toggle) toggle.checked = data.running; - if (settingsRow) settingsRow.style.display = data.running ? '' : 'none'; - if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none'; - - // Live scan progress - if (progressEl) { - if (data.current_status === 'scanning') { - progressEl.style.display = ''; - if (progressText) { - const stats = data.stats || {}; - progressText.textContent = `Scanning: ${data.current_folder || '...'} (${stats.scanned || 0} processed)`; - } - } else { - progressEl.style.display = 'none'; - } - } - - if (statusText) { - if (data.paused) statusText.textContent = 'Paused'; - else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...'; - else if (data.running) { - // Show last scan time - let watchText = 'Watching'; - if (data.last_scan_time) { - try { - const lastScan = new Date(data.last_scan_time); - const diffS = Math.floor((Date.now() - lastScan) / 1000); - if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`; - else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`; - } catch (e) {} - } - statusText.textContent = watchText; - } else statusText.textContent = 'Disabled'; - statusText.className = 'auto-import-status ' + (data.running ? (data.current_status === 'scanning' ? 'scanning' : 'active') : 'disabled'); - } - } catch (e) {} -} - -async function _autoImportLoadResults() { - const container = document.getElementById('auto-import-results'); - if (!container) return; - try { - const res = await fetch('/api/auto-import/results?limit=100'); - const data = await res.json(); - if (!data.success || !data.results || data.results.length === 0) { - if (!container.querySelector('.auto-import-card')) { - container.innerHTML = `
-

No imports yet. Drop album folders or single tracks into your import folder.

-
`; - } - // Hide stats and filters - const statsEl = document.getElementById('auto-import-stats'); - const filtersEl = document.getElementById('auto-import-filters'); - if (statsEl) statsEl.style.display = 'none'; - if (filtersEl) filtersEl.style.display = 'none'; - return; - } - - // Compute stats - const allResults = data.results; - const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length; - const reviewCount = allResults.filter(r => r.status === 'pending_review').length; - const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length; - - // Update stats - const statsEl = document.getElementById('auto-import-stats'); - if (statsEl) { - statsEl.style.display = ''; - document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`; - document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`; - document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`; - } - - // Show filters - const filtersEl = document.getElementById('auto-import-filters'); - if (filtersEl) { - filtersEl.style.display = ''; - // Show batch action buttons when applicable - const approveAllBtn = document.getElementById('auto-import-approve-all'); - const clearBtn = document.getElementById('auto-import-clear-completed'); - if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none'; - if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none'; - } - - // Apply filter - let filtered = allResults; - if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review'); - else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved'); - else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification'); - - if (filtered.length === 0) { - const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter; - container.innerHTML = `

No ${filterName} items.

`; - return; - } - - container.innerHTML = filtered.map((r, idx) => { - const confPct = Math.round((r.confidence || 0) * 100); - const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; - const statusLabels = { - 'completed': 'Imported', 'pending_review': 'Needs Review', - 'needs_identification': 'Unidentified', 'failed': 'Failed', - 'scanning': 'Scanning...', 'matched': 'Matched', - 'rejected': 'Dismissed', 'approved': 'Approved', - }; - const statusIcons = { - 'completed': '\u2713', 'pending_review': '\u26A0', - 'needs_identification': '\u2717', 'failed': '\u2717', - 'scanning': '\u231B', 'matched': '\u2713', - 'rejected': '\u2715', 'approved': '\u2713', - }; - const statusLabel = statusLabels[r.status] || r.status; - const statusIcon = statusIcons[r.status] || ''; - const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' : - r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : 'neutral'; - - // Parse match data for track details - let matchCount = 0, totalTracks = 0, trackDetails = []; - if (r.match_data) { - try { - const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data; - matchCount = md.matched_count || 0; - totalTracks = md.total_tracks || 0; - if (md.matches) { - trackDetails = md.matches.map(m => ({ - name: m.track_name || m.track?.name || 'Unknown', - file: m.file ? m.file.split(/[/\\]/).pop() : '?', - confidence: Math.round((m.confidence || 0) * 100), - })); - } - } catch (e) {} - } - - const matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`; - const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' }; - const methodLabel = methodLabels[r.identification_method] || r.identification_method || ''; - - // Time ago - let timeAgo = ''; - if (r.created_at) { - try { - const d = new Date(r.created_at); - const diffM = Math.floor((Date.now() - d) / 60000); - if (diffM < 1) timeAgo = 'just now'; - else if (diffM < 60) timeAgo = `${diffM}m ago`; - else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`; - else timeAgo = `${Math.floor(diffM / 1440)}d ago`; - } catch (e) {} - } - - let actions = ''; - if (r.status === 'pending_review') { - actions = `
- - -
`; - } - - // Expanded track list (hidden by default) - let trackListHtml = ''; - if (trackDetails.length > 0) { - trackListHtml = `
-
- TrackMatched FileConf -
- ${trackDetails.map(t => { - const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low'; - return `
- ${escapeHtml(t.name)} - ${escapeHtml(t.file)} - ${t.confidence}% -
`; - }).join('')} -
`; - } - - return `
-
-
- ${r.image_url ? `` : `
\uD83D\uDCBF
`} -
-
-
${escapeHtml(r.album_name || r.folder_name)}
-
${escapeHtml(r.artist_name || 'Unknown Artist')}
-
- ${matchSummary} - ${methodLabel ? `${methodLabel}` : ''} - ${timeAgo ? `${timeAgo}` : ''} -
- ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''} -
-
-
${statusIcon} ${statusLabel}
-
-
-
-
${confPct}% confidence
- ${actions} -
-
-
${escapeHtml(r.folder_name)}
- ${trackListHtml} -
`; - }).join(''); - - } catch (e) {} -} - -async function _autoImportSaveSettings() { - const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100; - const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60); - try { - await fetch('/api/auto-import/settings', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval }) - }); - showToast('Settings saved', 'success'); - } catch (e) { showToast('Error', 'error'); } -} - -function _autoImportSetFilter(filter) { - _autoImportFilter = filter; - document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p => - p.classList.toggle('active', p.dataset.filter === filter)); - _autoImportLoadResults(); -} - -async function _autoImportScanNow() { - try { - const res = await fetch('/api/auto-import/scan-now', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast('Scan triggered', 'success'); - _autoImportLoadStatus(); - } else { - showToast(data.error || 'Failed to trigger scan', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportApproveAll() { - const confirmed = await showConfirmDialog({ - title: 'Approve All', - message: 'Approve and import all pending review items?', - confirmText: 'Approve All', - }); - if (!confirmed) return; - try { - const res = await fetch('/api/auto-import/approve-all', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Approved ${data.count || 0} items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportClearCompleted() { - try { - const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Cleared ${data.count || 0} imported items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -function _autoImportToggleDetail(idx) { - const trackList = document.getElementById(`auto-import-tracks-${idx}`); - if (trackList) { - trackList.classList.toggle('expanded'); - } -} -window._autoImportToggleDetail = _autoImportToggleDetail; -window._autoImportSetFilter = _autoImportSetFilter; -window._autoImportScanNow = _autoImportScanNow; -window._autoImportApproveAll = _autoImportApproveAll; -window._autoImportClearCompleted = _autoImportClearCompleted; - -async function _autoImportApprove(id) { - try { - const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -async function _autoImportReject(id) { - try { - const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -// --- Album Tab: Auto-Detected Groups (from file tags) --- - -async function importPageLoadAutoGroups() { - const grid = document.getElementById('import-page-suggestions-grid'); - if (!grid) return; - - try { - const resp = await fetch('/api/import/staging/groups'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.groups || data.groups.length === 0) return; - - // Build auto-groups section above suggestions - let groupsContainer = document.getElementById('import-page-auto-groups'); - if (!groupsContainer) { - groupsContainer = document.createElement('div'); - groupsContainer.id = 'import-page-auto-groups'; - groupsContainer.style.marginBottom = '16px'; - const suggestionsSection = document.getElementById('import-page-suggestions'); - if (suggestionsSection) { - suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection); - } else { - grid.parentNode.insertBefore(groupsContainer, grid); - } - } - - groupsContainer.innerHTML = ` -
- Auto-Detected Albums -
-
- ${data.groups.map((g, idx) => ` -
-
- ${g.file_count} -
-
-
${_esc(g.album)}
-
${_esc(g.artist)} · ${g.file_count} tracks
-
-
- `).join('')} -
- `; - - // Store groups for click handler - importPageState._autoGroups = data.groups; - } catch (err) { - console.warn('Failed to load auto-groups:', err); - } -} - -async function importPageMatchAutoGroup(groupIdx) { - const group = importPageState._autoGroups?.[groupIdx]; - if (!group) return; - - // Search for the album by name + artist - const query = `${group.artist} ${group.album}`; - const searchInput = document.getElementById('import-page-album-search-input'); - if (searchInput) searchInput.value = query; - - // Hide suggestions/groups, show search results - const suggestionsEl = document.getElementById('import-page-suggestions'); - const groupsEl = document.getElementById('import-page-auto-groups'); - if (suggestionsEl) suggestionsEl.style.display = 'none'; - if (groupsEl) groupsEl.style.display = 'none'; - - const grid = document.getElementById('import-page-album-results'); - if (grid) grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - - if (data.success && data.albums && data.albums.length > 0) { - // Store file_paths filter so match only includes this group's files - importPageState._autoGroupFilePaths = group.file_paths; - - // Render results — user picks the right album - grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join(''); - } else { - grid.innerHTML = '
No albums found — try searching manually
'; - } - } catch (err) { - console.error('Auto-group search failed:', err); - if (grid) grid.innerHTML = '
Search failed
'; - } -} - -// --- Album Tab: Suggestions (server-side cache, just fetch and render) --- - -async function importPageLoadSuggestions() { - const section = document.getElementById('import-page-suggestions'); - const grid = document.getElementById('import-page-suggestions-grid'); - if (!section || !grid) return; - - try { - const resp = await fetch('/api/import/staging/suggestions'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.suggestions || data.suggestions.length === 0) { - if (!data.ready) { - // Server is still building cache — show placeholder, retry shortly - section.style.display = ''; - grid.innerHTML = '
Loading suggestions...
'; - setTimeout(() => importPageLoadSuggestions(), 3000); - } else { - section.style.display = 'none'; - grid.innerHTML = ''; - } - return; - } - - section.style.display = ''; - grid.innerHTML = data.suggestions.map(a => _renderSuggestionCard(a)).join(''); - } catch (err) { - // Network error or server not ready — fail silently - console.warn('Failed to load import suggestions:', err); - } -} - -function _renderSuggestionCard(a) { - return `
- ${_escAttr(a.name)} -
${_esc(a.name)}
-
${_esc(a.artist)}
-
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
-
`; -} - -// --- Album Tab: Search --- - -async function importPageSearchAlbum() { - const query = document.getElementById('import-page-album-search-input').value.trim(); - if (!query) return; - - document.getElementById('import-page-suggestions').style.display = 'none'; - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = 'none'; - const grid = document.getElementById('import-page-album-results'); - grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - if (!data.success || !data.albums.length) { - grid.innerHTML = '
No albums found
'; - return; - } - grid.innerHTML = data.albums.map(a => ` -
- ${_escAttr(a.name)} -
${_esc(a.name)}
-
${_esc(a.artist)}
-
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
-
- `).join(''); - document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); - } catch (err) { - grid.innerHTML = `
Error: ${err.message}
`; - } -} - -// --- Album Tab: Select Album & Match --- - -async function importPageSelectAlbum(albumId) { - document.getElementById('import-page-album-search-section').classList.add('hidden'); - document.getElementById('import-page-album-match-section').classList.remove('hidden'); - - const matchList = document.getElementById('import-page-match-list'); - matchList.innerHTML = '
Matching files to tracklist...
'; - - try { - // Include file_paths filter if matching from an auto-group - const matchBody = { album_id: albumId }; - if (importPageState._autoGroupFilePaths) { - matchBody.file_paths = importPageState._autoGroupFilePaths; - importPageState._autoGroupFilePaths = null; // clear after use - } - const resp = await fetch('/api/import/album/match', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(matchBody) - }); - const data = await resp.json(); - if (!data.success) { - matchList.innerHTML = `
Error: ${data.error}
`; - return; - } - - importPageState.albumData = data; - importPageState.matchOverrides = {}; - - // Render hero - const album = data.album; - document.getElementById('import-page-album-hero').innerHTML = ` - ${_escAttr(album.name)} -
-
${_esc(album.name)}
-
${_esc(album.artist)}
-
${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0, 4) : ''}
-
- `; - - importPageRenderMatchList(); - } catch (err) { - matchList.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageRenderMatchList() { - const data = importPageState.albumData; - if (!data) return; - - const matchList = document.getElementById('import-page-match-list'); - const overrides = importPageState.matchOverrides; - - // Build effective matches: auto-match overridden by manual overrides - // Also track which staging files are used (auto or override) - const usedStagingFiles = new Set(); - - // First pass: collect overridden indices - Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx)); - - // Build rows - let matchedCount = 0; - const rows = data.matches.map((m, idx) => { - let file = null; - let confidence = m.confidence; - let isOverride = false; - - if (overrides.hasOwnProperty(idx)) { - const sfIdx = overrides[idx]; - if (sfIdx === -1) { - // Forcibly unmatched — no file - file = null; - } else { - // Manual override - file = importPageState.stagingFiles[sfIdx] || null; - confidence = 1.0; - isOverride = true; - usedStagingFiles.add(sfIdx); - } - } else if (m.staging_file) { - file = m.staging_file; - // Check if this file was reassigned to another track via override - const autoFileName = m.staging_file.filename; - const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => { - const sf = importPageState.stagingFiles[sfIdx]; - return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx; - }); - if (!reassigned) { - usedStagingFiles.add(-1); // placeholder — auto-matched file - } else { - file = null; // file was reassigned elsewhere - } - } - - if (file) matchedCount++; - const confPercent = Math.round(confidence * 100); - const confClass = confidence >= 0.7 ? '' : 'low'; - - return ` -
- ${m.spotify_track.track_number} - ${_esc(m.spotify_track.name)} - - ${file - ? `${_esc(file.filename)} - ${confPercent}%` - : `Drop a file here`} - - ${file ? `` : ''} -
- `; - }); - - matchList.innerHTML = rows.join(''); - - // Unmatched file pool - const unmatchedFiles = []; - importPageState.stagingFiles.forEach((f, i) => { - // Check if used by override - if (Object.values(overrides).includes(i)) return; - // Check if used by auto-match (not overridden away) - const autoUsed = data.matches.some((m, mIdx) => { - if (overrides.hasOwnProperty(mIdx)) return false; - return m.staging_file && m.staging_file.filename === f.filename; - }); - if (autoUsed) return; - unmatchedFiles.push({ file: f, index: i }); - }); - - const poolChips = document.getElementById('import-page-pool-chips'); - document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length; - - if (unmatchedFiles.length === 0) { - poolChips.innerHTML = 'All files matched'; - } else { - poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => ` - - ${_esc(file.filename)} - - `).join(''); - } - - // Stats & button - document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`; - const processBtn = document.getElementById('import-page-album-process-btn'); - processBtn.disabled = matchedCount === 0; - processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`; -} - -// --- Album Tab: Drag and Drop --- - -function importPageStartDrag(event, stagingFileIndex) { - event.dataTransfer.setData('text/plain', stagingFileIndex.toString()); - event.dataTransfer.effectAllowed = 'move'; -} - -function importPageHandleDragOver(event) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('drag-over'); - // Remove drag-over from others - document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => { - if (el !== event.currentTarget) el.classList.remove('drag-over'); - }); -} - -function importPageHandleDrop(event, trackIndex) { - event.preventDefault(); - event.currentTarget.classList.remove('drag-over'); - const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain')); - if (isNaN(stagingFileIndex)) return; - - // Remove this staging file from any other track it was assigned to - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// Mobile tap-to-assign fallback -function importPageTapSelectChip(stagingFileIndex) { - if (importPageState.tapSelectedChip === stagingFileIndex) { - importPageState.tapSelectedChip = null; - } else { - importPageState.tapSelectedChip = stagingFileIndex; - } - importPageRenderMatchList(); -} - -function importPageTapAssign(trackIndex) { - if (importPageState.tapSelectedChip === null) return; - const stagingFileIndex = importPageState.tapSelectedChip; - - // Remove from any other track - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -function importPageUnmatchTrack(trackIndex) { - delete importPageState.matchOverrides[trackIndex]; - // Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay. - // Actually, to truly unmatch: we need to suppress the auto-match too. - // We'll use a sentinel: override = -1 means "forcibly unmatched" - const m = importPageState.albumData?.matches[trackIndex]; - if (m && m.staging_file) { - importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match - } - importPageRenderMatchList(); -} - -function importPageAutoRematch() { - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// --- Album Tab: Process --- - -function importPageProcessAlbum() { - const data = importPageState.albumData; - if (!data) return; - - // Build effective matches with overrides applied - const overrides = importPageState.matchOverrides; - const effectiveMatches = []; - data.matches.forEach((m, idx) => { - if (overrides.hasOwnProperty(idx)) { - if (overrides[idx] === -1) return; // forcibly unmatched — skip - const sf = importPageState.stagingFiles[overrides[idx]]; - effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 }); - } else if (m.staging_file !== null) { - effectiveMatches.push(m); - } - }); - - if (effectiveMatches.length === 0) return; - - // Add to queue and reset search immediately so user can queue more - const album = data.album; - _importQueueAdd({ - type: 'album', - label: album.name, - sublabel: `${album.artist} · ${effectiveMatches.length} tracks`, - imageUrl: album.image_url, - items: effectiveMatches, - albumData: album, - }); - - importPageResetAlbumSearch(); -} - -function importPageResetAlbumSearch() { - importPageState.albumData = null; - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageState._autoGroupFilePaths = null; - - document.getElementById('import-page-album-search-section').classList.remove('hidden'); - document.getElementById('import-page-album-match-section').classList.add('hidden'); - - // Clear search - document.getElementById('import-page-album-results').innerHTML = ''; - document.getElementById('import-page-album-search-input').value = ''; - document.getElementById('import-page-album-clear-btn').classList.add('hidden'); - - // Re-show auto-groups - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = ''; - - // Refresh suggestions & staging - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - importPageRefreshStaging(); -} - -// --- Singles Tab --- - -function importPageRenderSinglesList() { - const list = document.getElementById('import-page-singles-list'); - const files = importPageState.stagingFiles; - - if (files.length === 0) { - list.innerHTML = '
No audio files found in import folder
'; - return; - } - - list.innerHTML = files.map((f, i) => { - const isSelected = importPageState.selectedSingles.has(i); - const manualMatch = importPageState.singlesManualMatches[i]; - const searchOpen = document.querySelector(`[data-singles-search="${i}"]`); - - let html = ` -
-
-
-
${_esc(f.filename)}
-
- ${f.title ? `${_esc(f.title)}` : ''} - ${f.artist ? `${_esc(f.artist)}` : ''} - ${f.extension ? `${f.extension}` : ''} -
- ${manualMatch ? ` -
- ✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)} - change -
- ` : ''} -
-
- -
-
- `; - return html; - }).join(''); - - importPageUpdateSinglesProcessButton(); -} - -function importPageToggleSingle(idx) { - if (importPageState.selectedSingles.has(idx)) { - importPageState.selectedSingles.delete(idx); - } else { - importPageState.selectedSingles.add(idx); - } - // Update checkbox UI without full re-render - const item = document.querySelector(`[data-single-idx="${idx}"]`); - if (item) { - const cb = item.querySelector('.import-page-single-checkbox'); - if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx)); - } - importPageUpdateSinglesProcessButton(); -} - -function importPageSelectAllSingles() { - const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length; - if (allSelected) { - importPageState.selectedSingles.clear(); - } else { - importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i)); - } - document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All'; - // Update all checkboxes - document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => { - cb.classList.toggle('checked', importPageState.selectedSingles.has(i)); - }); - importPageUpdateSinglesProcessButton(); -} - -function importPageUpdateSinglesProcessButton() { - const btn = document.getElementById('import-page-singles-process-btn'); - const count = importPageState.selectedSingles.size; - btn.textContent = `Process Selected (${count})`; - btn.disabled = count === 0; -} - -function importPageOpenSingleSearch(fileIdx) { - const item = document.querySelector(`[data-single-idx="${fileIdx}"]`); - if (!item) return; - - // Remove any existing search panel - const existing = item.querySelector('.import-page-single-search-panel'); - if (existing) { - existing.remove(); - return; - } - - // Close other open panels - document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove()); - - const f = importPageState.stagingFiles[fileIdx]; - const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, ''); - - const panel = document.createElement('div'); - panel.className = 'import-page-single-search-panel'; - panel.innerHTML = ` - -
- `; - item.appendChild(panel); - - // Auto-search - const input = panel.querySelector('input'); - input.focus(); - if (defaultQuery) { - importPageSearchSingleTrack(fileIdx, defaultQuery); - } -} - -async function importPageSearchSingleTrack(fileIdx, query) { - if (!query || !query.trim()) return; - - const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`); - if (!resultsDiv) return; - resultsDiv.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`); - const data = await resp.json(); - if (!data.success || !data.tracks.length) { - resultsDiv.innerHTML = '
No results found
'; - return; - } - // Store results in a temp cache so we can reference by index - window._importSingleSearchResults = window._importSingleSearchResults || {}; - window._importSingleSearchResults[fileIdx] = data.tracks; - - resultsDiv.innerHTML = data.tracks.map((t, tIdx) => { - const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return ` -
- ${t.image_url ? `` : ''} -
-
${_esc(t.name)} - ${_esc(t.artist)}
-
${_esc(t.album)}${dur ? ' · ' + dur : ''}
-
- -
- `; - }).join(''); - } catch (err) { - resultsDiv.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageSelectSingleMatch(fileIdx, trackIdx) { - const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx]; - if (!trackData) return; - importPageState.singlesManualMatches[fileIdx] = trackData; - - // Auto-select this file - importPageState.selectedSingles.add(fileIdx); - - // Close search panel and re-render this item - importPageRenderSinglesList(); -} - -// --- Singles Tab: Process --- - -function importPageProcessSingles() { - if (importPageState.selectedSingles.size === 0) return; - - const filesToProcess = Array.from(importPageState.selectedSingles).map(i => { - const f = importPageState.stagingFiles[i]; - const manualMatch = importPageState.singlesManualMatches[i]; - if (manualMatch) { - return { ...f, spotify_override: manualMatch }; - } - return f; - }); - - // Add to queue and reset immediately - _importQueueAdd({ - type: 'singles', - label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`, - sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''), - imageUrl: null, - items: filesToProcess, - }); - - importPageState.selectedSingles.clear(); - importPageState.singlesManualMatches = {}; - importPageUpdateSinglesProcessButton(); - importPageRefreshStaging(); -} - -// --- Processing Queue --- - -const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors } - -function _importQueueAdd(job) { - const id = ++importJobIdCounter; - const entry = { - id, - type: job.type, - label: job.label, - sublabel: job.sublabel, - imageUrl: job.imageUrl, - status: 'running', // running | done | error - processed: 0, - total: job.items.length, - errors: [], - }; - _importQueue.push(entry); - _importQueueRender(); - - // Fire and forget — runs in background - _importQueueRunJob(entry, job); -} - -async function _importQueueRunJob(entry, job) { - for (let i = 0; i < job.items.length; i++) { - const itemName = job.type === 'album' - ? (job.items[i].spotify_track?.name || `Track ${i + 1}`) - : (job.items[i].title || job.items[i].filename || `File ${i + 1}`); - - // Update status with current track info - entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`; - _importQueueRender(); - - try { - let resp; - if (job.type === 'album') { - resp = await fetch('/api/import/album/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - album: job.albumData, - matches: [job.items[i]] - }) - }); - } else { - resp = await fetch('/api/import/singles/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files: [job.items[i]] }) - }); - } - - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - if (data.success) entry.processed += (data.processed || 0); - if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors); - } catch (err) { - entry.errors.push(`${itemName}: ${err.message}`); - } - - _importQueueRender(); - } - - entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done'; - _importQueueRender(); - - // Refresh staging and suggestions since files moved - importPageRefreshStaging(); - importPageLoadSuggestions(); -} - -function _importQueueRender() { - const container = document.getElementById('import-page-queue'); - const list = document.getElementById('import-page-queue-list'); - const clearBtn = document.getElementById('import-page-queue-clear'); - if (!container || !list) return; - - if (_importQueue.length === 0) { - container.classList.add('hidden'); - return; - } - - container.classList.remove('hidden'); - - // Show clear button only if there are finished jobs - const hasFinished = _importQueue.some(j => j.status !== 'running'); - clearBtn.style.display = hasFinished ? '' : 'none'; - - list.innerHTML = _importQueue.map(j => { - const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0; - const fillClass = j.status === 'error' ? 'error' : ''; - let statusText, statusClass; - if (j.status === 'running') { - statusText = `${j.processed}/${j.total}`; - statusClass = ''; - } else if (j.status === 'done') { - statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done'; - statusClass = j.errors.length > 0 ? 'error' : 'done'; - } else { - statusText = 'Failed'; - statusClass = 'error'; - } - - return ` -
- ${j.imageUrl - ? `` - : `
`} -
-
${_esc(j.label)}
-
${_esc(j.sublabel)}
-
-
-
-
-
-
${statusText}
-
-
- `; - }).join(''); -} - -function importPageClearFinishedJobs() { - for (let i = _importQueue.length - 1; i >= 0; i--) { - if (_importQueue[i].status !== 'running') { - _importQueue.splice(i, 1); - } - } - _importQueueRender(); -} - -// ── Import File Tab ────────────────────────────────────────────────── - -let _importFileState = { - rawText: '', - fileName: '', - fileType: '', // 'csv' or 'text' - headers: [], // CSV column headers - rows: [], // raw parsed rows (arrays for csv, strings for text) - columnMap: {}, // { columnIndex: 'track_name' | 'artist_name' | 'album_name' | 'duration' | 'skip' } - parsedTracks: [] // final [{track_name, artist_name, album_name, duration_ms}] -}; - -function _initImportFileTab() { - const dropzone = document.getElementById('import-file-dropzone'); - const fileInput = document.getElementById('import-file-input'); - if (!dropzone || !fileInput) return; - - dropzone.addEventListener('click', () => fileInput.click()); - dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }); - dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); - dropzone.addEventListener('drop', (e) => { - e.preventDefault(); - dropzone.classList.remove('drag-over'); - const file = e.dataTransfer.files[0]; - if (file) _importFileRead(file); - }); - fileInput.addEventListener('change', () => { - if (fileInput.files[0]) _importFileRead(fileInput.files[0]); - fileInput.value = ''; - }); - - // Enable/disable import button based on playlist name - const nameInput = document.getElementById('import-file-playlist-name'); - if (nameInput) { - nameInput.addEventListener('input', () => { - const btn = document.getElementById('import-file-import-btn'); - if (btn) btn.disabled = !nameInput.value.trim(); - }); - } -} - -function _importFileRead(file) { - const ext = file.name.split('.').pop().toLowerCase(); - if (!['csv', 'tsv', 'txt'].includes(ext)) { - showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error'); - return; - } - - const reader = new FileReader(); - reader.onload = (e) => { - _importFileState.rawText = e.target.result; - _importFileState.fileName = file.name; - _importFileState.fileType = (ext === 'txt') ? 'text' : 'csv'; - _importFileParseAndPreview(); - }; - reader.readAsText(file); -} - -function _importFileDetectDelimiter(firstLine) { - const tab = (firstLine.match(/\t/g) || []).length; - const semi = (firstLine.match(/;/g) || []).length; - const comma = (firstLine.match(/,/g) || []).length; - if (tab >= comma && tab >= semi && tab > 0) return '\t'; - if (semi >= comma && semi > 0) return ';'; - return ','; -} - -function _importFileParseCsv(text, delimiter) { - const lines = text.split(/\r?\n/).filter(l => l.trim()); - if (lines.length < 2) return { headers: [], rows: [] }; - - // Parse CSV with basic quote handling - function parseLine(line) { - const result = []; - let current = ''; - let inQuotes = false; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (ch === '"') { - if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { - current += '"'; - i++; - } else { - inQuotes = !inQuotes; - } - } else if (ch === delimiter && !inQuotes) { - result.push(current.trim()); - current = ''; - } else { - current += ch; - } - } - result.push(current.trim()); - return result; - } - - const headers = parseLine(lines[0]); - const rows = []; - for (let i = 1; i < lines.length; i++) { - const row = parseLine(lines[i]); - if (row.some(cell => cell)) rows.push(row); - } - return { headers, rows }; -} - -function _importFileAutoMapColumns(headers) { - const map = {}; - const lowerHeaders = headers.map(h => h.toLowerCase().trim()); - - const trackPatterns = ['track_name', 'track name', 'track', 'title', 'song', 'song_name', 'song name', 'name']; - const artistPatterns = ['artist_name', 'artist name', 'artist', 'artists', 'performer']; - const albumPatterns = ['album_name', 'album name', 'album']; - const durationPatterns = ['duration', 'duration_ms', 'length', 'time']; - - function findMatch(patterns) { - for (const p of patterns) { - const idx = lowerHeaders.indexOf(p); - if (idx !== -1 && !(idx in map)) return idx; - } - return -1; - } - - const trackIdx = findMatch(trackPatterns); - if (trackIdx !== -1) map[trackIdx] = 'track_name'; - - const artistIdx = findMatch(artistPatterns); - if (artistIdx !== -1) map[artistIdx] = 'artist_name'; - - const albumIdx = findMatch(albumPatterns); - if (albumIdx !== -1) map[albumIdx] = 'album_name'; - - const durIdx = findMatch(durationPatterns); - if (durIdx !== -1) map[durIdx] = 'duration'; - - return map; -} - -function _importFileParseAndPreview() { - const state = _importFileState; - const text = state.rawText; - - if (state.fileType === 'text') { - // Plain text: one track per line - const lines = text.split(/\r?\n/).filter(l => l.trim()); - state.rows = lines; - state.headers = []; - state.columnMap = {}; - } else { - // CSV/TSV - const firstLine = text.split(/\r?\n/)[0] || ''; - const delimiter = _importFileDetectDelimiter(firstLine); - const { headers, rows } = _importFileParseCsv(text, delimiter); - state.headers = headers; - state.rows = rows; - state.columnMap = _importFileAutoMapColumns(headers); - } - - _importFileBuildTracks(); - _importFileRenderPreview(); -} - -function _importFileBuildTracks() { - const state = _importFileState; - state.parsedTracks = []; - - if (state.fileType === 'text') { - const orderEl = document.getElementById('import-file-text-order'); - const sepEl = document.getElementById('import-file-text-separator'); - const order = orderEl ? orderEl.value : 'artist-title'; - const sep = sepEl ? sepEl.value : ' - '; - - for (const line of state.rows) { - const parts = line.split(sep); - if (parts.length >= 2) { - const a = parts[0].trim(); - const b = parts.slice(1).join(sep).trim(); - state.parsedTracks.push({ - track_name: order === 'artist-title' ? b : a, - artist_name: order === 'artist-title' ? a : b, - album_name: '', - duration_ms: 0 - }); - } else { - // Can't split — treat whole line as track name - state.parsedTracks.push({ - track_name: line.trim(), - artist_name: '', - album_name: '', - duration_ms: 0 - }); - } - } - } else { - // CSV mapped - const map = state.columnMap; - const trackCol = Object.keys(map).find(k => map[k] === 'track_name'); - const artistCol = Object.keys(map).find(k => map[k] === 'artist_name'); - const albumCol = Object.keys(map).find(k => map[k] === 'album_name'); - const durCol = Object.keys(map).find(k => map[k] === 'duration'); - - for (const row of state.rows) { - const track = trackCol !== undefined ? (row[trackCol] || '') : ''; - const artist = artistCol !== undefined ? (row[artistCol] || '') : ''; - const album = albumCol !== undefined ? (row[albumCol] || '') : ''; - let dur = durCol !== undefined ? (row[durCol] || '') : ''; - - // Parse duration: could be ms, seconds, or mm:ss - let durationMs = 0; - if (dur) { - dur = dur.trim(); - if (dur.includes(':')) { - const parts = dur.split(':'); - durationMs = (parseInt(parts[0]) * 60 + parseInt(parts[1] || 0)) * 1000; - } else { - const num = parseFloat(dur); - durationMs = num > 10000 ? num : num * 1000; // assume ms if > 10000, else seconds - } - if (isNaN(durationMs)) durationMs = 0; - } - - state.parsedTracks.push({ - track_name: track, - artist_name: artist, - album_name: album, - duration_ms: durationMs - }); - } - } -} - -function _importFileRenderPreview() { - const state = _importFileState; - const validTracks = state.parsedTracks.filter(t => t.track_name || t.artist_name); - - // Show/hide sections - document.getElementById('import-file-upload-zone').style.display = 'none'; - document.getElementById('import-file-preview-section').style.display = ''; - - // File info - document.getElementById('import-file-name-label').textContent = state.fileName; - document.getElementById('import-file-track-count').textContent = `${validTracks.length} track${validTracks.length !== 1 ? 's' : ''} parsed`; - - // Show format controls based on file type - document.getElementById('import-file-text-format').style.display = state.fileType === 'text' ? '' : 'none'; - document.getElementById('import-file-column-mapping').style.display = state.fileType === 'csv' ? '' : 'none'; - - // Render column mapping for CSV - if (state.fileType === 'csv') { - _importFileRenderColumnMapping(); - } - - // Pre-fill playlist name from filename (strip extension) - const nameInput = document.getElementById('import-file-playlist-name'); - if (nameInput && !nameInput.value) { - nameInput.value = state.fileName.replace(/\.[^.]+$/, ''); - } - // Update button state - const btn = document.getElementById('import-file-import-btn'); - if (btn) btn.disabled = !nameInput.value.trim(); - - // Render preview table - const tbody = document.getElementById('import-file-preview-tbody'); - tbody.innerHTML = ''; - - state.parsedTracks.forEach((t, i) => { - const valid = !!(t.track_name || t.artist_name); - const tr = document.createElement('tr'); - if (!valid) tr.classList.add('invalid-row'); - tr.innerHTML = ` - ${i + 1} - ${_esc(t.track_name)} - ${_esc(t.artist_name)} - ${_esc(t.album_name)} - `; - tbody.appendChild(tr); - }); -} - -function _importFileRenderColumnMapping() { - const state = _importFileState; - const container = document.getElementById('import-file-mapping-selects'); - container.innerHTML = ''; - - const options = ['skip', 'track_name', 'artist_name', 'album_name', 'duration']; - const optLabels = { skip: 'Skip', track_name: 'Track', artist_name: 'Artist', album_name: 'Album', duration: 'Duration' }; - - state.headers.forEach((header, idx) => { - const mapped = state.columnMap[idx] || 'skip'; - const wrap = document.createElement('div'); - wrap.className = 'import-file-col-map'; - if (mapped === 'track_name') wrap.classList.add('mapped-track'); - else if (mapped === 'artist_name') wrap.classList.add('mapped-artist'); - else if (mapped === 'album_name') wrap.classList.add('mapped-album'); - - const label = document.createElement('span'); - label.className = 'import-file-col-label'; - label.textContent = header; - label.title = header; - - const sel = document.createElement('select'); - sel.className = 'import-file-select'; - options.forEach(o => { - const opt = document.createElement('option'); - opt.value = o; - opt.textContent = optLabels[o]; - if (o === mapped) opt.selected = true; - sel.appendChild(opt); - }); - sel.addEventListener('change', () => { - if (sel.value === 'skip') { - delete state.columnMap[idx]; - } else { - // Remove this mapping from any other column - for (const k of Object.keys(state.columnMap)) { - if (state.columnMap[k] === sel.value) delete state.columnMap[k]; - } - state.columnMap[idx] = sel.value; - } - _importFileBuildTracks(); - _importFileRenderPreview(); - }); - - wrap.appendChild(label); - wrap.appendChild(sel); - container.appendChild(wrap); - }); -} - -function importFileReparse() { - _importFileBuildTracks(); - _importFileRenderPreview(); -} - -function importFileClear() { - _importFileState = { - rawText: '', fileName: '', fileType: '', - headers: [], rows: [], columnMap: {}, parsedTracks: [] - }; - document.getElementById('import-file-upload-zone').style.display = ''; - document.getElementById('import-file-preview-section').style.display = 'none'; - document.getElementById('import-file-playlist-name').value = ''; - document.getElementById('import-file-preview-tbody').innerHTML = ''; -} - -function importFileSubmit() { - const nameInput = document.getElementById('import-file-playlist-name'); - const name = nameInput ? nameInput.value.trim() : ''; - if (!name) { - showToast('Please enter a playlist name.', 'error'); - nameInput && nameInput.focus(); - return; - } - - const tracks = _importFileState.parsedTracks.filter(t => t.track_name || t.artist_name); - if (!tracks.length) { - showToast('No valid tracks to import.', 'error'); - return; - } - - // Use a unique ID based on timestamp so multiple imports don't collide - const sourceId = `file_${Date.now()}`; - - mirrorPlaylist('file', sourceId, name, tracks, { - description: `Imported from ${_importFileState.fileName}`, - owner: 'local' - }); - - showToast(`Imported "${name}" with ${tracks.length} tracks`, 'success'); - importFileClear(); - - // Switch to mirrored tab so user sees the result - const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); - if (mirroredBtn) { - mirroredBtn.click(); - // Reload mirrored playlists to show the new one - setTimeout(() => loadMirroredPlaylists(), 500); - } -} - -// ── Mirrored Playlists ──────────────────────────────────────────────── - -let mirroredPlaylistsLoaded = false; - -/** - * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. - */ -function mirrorPlaylist(source, sourceId, name, tracks, metadata = {}) { - const normalizedTracks = tracks.map(t => ({ - track_name: t.track_name || t.name || '', - artist_name: t.artist_name || (Array.isArray(t.artists) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : t.artists || ''), - album_name: t.album_name || (typeof t.album === 'object' ? (t.album && t.album.name) : t.album) || '', - duration_ms: t.duration_ms || 0, - image_url: t.image_url || (t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null), - source_track_id: t.source_track_id || t.id || t.spotify_track_id || '', - extra_data: t.extra_data || null - })); - - fetch('/api/mirror-playlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - source, - source_playlist_id: String(sourceId), - name, - tracks: normalizedTracks, - description: metadata.description || '', - owner: metadata.owner || '', - image_url: metadata.image_url || '' - }) - }).then(r => r.json()).then(data => { - if (data.success) console.log(`Mirrored ${source} playlist: ${name} (${normalizedTracks.length} tracks)`); - }).catch(err => console.warn('Mirror save failed:', err)); -} - -/** - * Load and render all mirrored playlists into the Mirrored tab. - */ -async function loadMirroredPlaylists() { - const container = document.getElementById('mirrored-playlist-container'); - if (!container) return; - container.innerHTML = `
Loading mirrored playlists...
`; - - try { - const res = await fetch('/api/mirrored-playlists'); - const playlists = await res.json(); - if (playlists.error) throw new Error(playlists.error); - - if (!playlists.length) { - container.innerHTML = `
Playlists you parse from any service will appear here as persistent backups.
`; - return; - } - - container.innerHTML = ''; - playlists.forEach(p => renderMirroredCard(p, container)); - mirroredPlaylistsLoaded = true; - - // Hydrate discovery states from backend (survives page refresh) - await hydrateMirroredDiscoveryStates(); - } catch (err) { - container.innerHTML = `
Error loading mirrored playlists: ${err.message}
`; - } -} - -function renderMirroredCard(p, container) { - const ago = timeAgo(p.updated_at || p.mirrored_at); - const hash = `mirrored_${p.id}`; - const state = youtubePlaylistStates[hash]; - const phase = state ? state.phase : null; - - // Build phase indicator - let phaseHtml = ''; - if (phase === 'discovering') { - const pct = state.discoveryProgress || state.discovery_progress || 0; - phaseHtml = `Discovering ${pct}%`; - } else if (phase === 'discovered') { - const matches = state.spotifyMatches || state.spotify_matches || 0; - const total = state.spotify_total || p.track_count; - phaseHtml = `Discovered ${matches}/${total}`; - } else if (phase === 'syncing' || phase === 'sync_complete') { - phaseHtml = `${phase === 'syncing' ? 'Syncing...' : 'Synced'}`; - } else if (phase === 'downloading') { - phaseHtml = `Downloading...`; - } else if (phase === 'download_complete') { - phaseHtml = `Downloaded`; - } - - const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', file: '📄' }; - const srcIcon = sourceIcons[p.source] || '📋'; - - // Discovery ratio - const disc = p.discovered_count || 0; - const tot = p.total_count || p.track_count || 0; - let ratioHtml = ''; - if (disc > 0) { - const complete = disc >= tot; - const srcName = typeof currentMusicSourceName !== 'undefined' ? currentMusicSourceName : 'metadata'; - ratioHtml = `${disc}/${tot} discovered on ${srcName}`; - } - - const card = document.createElement('div'); - card.className = 'mirrored-playlist-card'; - card.id = `mirrored-card-${p.id}`; - card.innerHTML = ` -
${srcIcon}
-
-
${_esc(p.name)}
-
- ${_esc(p.source)} - ${p.track_count} tracks - Mirrored ${ago} - ${ratioHtml} - ${phaseHtml} -
-
- ${disc > 0 ? `` : ''} - - `; - card.addEventListener('click', () => { - const st = youtubePlaylistStates[hash]; - // Treat as non-fresh if phase is set, or if a poller/discovery modal exists - const hasActiveDiscovery = activeYouTubePollers[hash] || document.getElementById(`youtube-discovery-modal-${hash}`); - if (st && ((st.phase && st.phase !== 'fresh') || hasActiveDiscovery)) { - if (st.phase === 'downloading' || st.phase === 'download_complete') { - // Open download modal directly (follows Tidal/YouTube card click pattern) - const spotifyPlaylistId = st.convertedSpotifyPlaylistId; - if (spotifyPlaylistId && activeDownloadProcesses[spotifyPlaylistId]) { - // Modal already exists — just show it - const process = activeDownloadProcesses[spotifyPlaylistId]; - if (process.modalElement) { - if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); - } - process.modalElement.style.display = 'flex'; - } - } else if (spotifyPlaylistId) { - // Need to rehydrate the download modal - rehydrateMirroredDownloadModal(hash, st); - } else { - // No converted playlist ID yet, fall back to discovery modal - openYouTubeDiscoveryModal(hash); - } - } else { - openYouTubeDiscoveryModal(hash); - if (st.phase === 'discovering' && !activeYouTubePollers[hash]) { - startYouTubeDiscoveryPolling(hash); - } - } - } else { - openMirroredPlaylistModal(p.id); - } - }); - container.appendChild(card); -} - -function updateMirroredCardPhase(urlHash, phase) { - // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) - const state = youtubePlaylistStates[urlHash]; - if (state) state.phase = phase; - - // Extract the numeric ID from urlHash (e.g., 'mirrored_3' → '3') - const mirroredId = urlHash.replace('mirrored_', ''); - const card = document.getElementById(`mirrored-card-${mirroredId}`); - if (!card) return; - - const metaEl = card.querySelector('.card-meta'); - if (!metaEl) return; - - // Remove old phase indicator - const oldPhase = metaEl.querySelector('span[style]'); - if (oldPhase) oldPhase.remove(); - - // Add new phase indicator - let phaseHtml = ''; - switch (phase) { - case 'discovering': - phaseHtml = `Discovering...`; - break; - case 'discovered': - const matches = state?.spotifyMatches || state?.spotify_matches || 0; - const total = state?.spotify_total || 0; - phaseHtml = `Discovered ${matches}/${total}`; - break; - case 'syncing': - phaseHtml = `Syncing...`; - break; - case 'sync_complete': - phaseHtml = `Synced`; - break; - case 'downloading': - phaseHtml = `Downloading...`; - break; - case 'download_complete': - phaseHtml = `Downloaded`; - break; - } - if (phaseHtml) { - metaEl.insertAdjacentHTML('beforeend', phaseHtml); - } -} - -async function rehydrateMirroredDownloadModal(urlHash, state) { - try { - if (!state || !state.playlist) { - showToast('Cannot open download modal - invalid playlist data', 'error'); - return; - } - - console.log(`💧 [Rehydration] Rehydrating mirrored download modal for: ${state.playlist.name}`); - - // Get discovery results from backend if not already loaded - let discoveryRes = state.discoveryResults || state.discovery_results; - if (!discoveryRes || discoveryRes.length === 0) { - console.log(`🔍 Fetching discovery results from backend for mirrored playlist: ${urlHash}`); - const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); - if (stateResponse.ok) { - const fullState = await stateResponse.json(); - state.discovery_results = fullState.discovery_results; - state.discoveryResults = fullState.discovery_results; - state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; - state.download_process_id = fullState.download_process_id; - discoveryRes = fullState.discovery_results; - console.log(`✅ Loaded ${discoveryRes?.length || 0} discovery results from backend`); - } else { - showToast('Error loading playlist data', 'error'); - return; - } - } - - // Extract Spotify tracks from discovery results - const spotifyTracks = (discoveryRes || []) - .filter(r => r.spotify_data || (r.spotify_track && r.status_class === 'found')) - .map(r => { - if (r.spotify_data) return r.spotify_data; - const albumData = r.spotify_album || 'Unknown Album'; - return { - id: r.spotify_id || 'unknown', - name: r.spotify_track || 'Unknown Track', - artists: r.spotify_artist ? [r.spotify_artist] : ['Unknown Artist'], - album: typeof albumData === 'object' ? albumData : { name: albumData, album_type: 'album', images: [] }, - duration_ms: 0 - }; - }); - - if (spotifyTracks.length === 0) { - showToast('No Spotify matches found for download', 'error'); - return; - } - - const virtualPlaylistId = state.convertedSpotifyPlaylistId; - const playlistName = state.playlist.name; - - // Create the download modal - await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); - - // If we have a download process ID, set up the modal for the running/complete state - if (state.download_process_id) { - const process = activeDownloadProcesses[virtualPlaylistId]; - if (process) { - process.status = state.phase === 'download_complete' ? 'complete' : 'running'; - process.batchId = state.download_process_id; - - const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); - const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); - - if (state.phase === 'downloading') { - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'inline-block'; - - // Start polling for live updates - startModalDownloadPolling(virtualPlaylistId); - console.log(`🔄 Started polling for active mirrored download: ${state.download_process_id}`); - } else if (state.phase === 'download_complete') { - if (beginBtn) beginBtn.style.display = 'none'; - if (cancelBtn) cancelBtn.style.display = 'none'; - console.log(`✅ Showing completed mirrored download results: ${state.download_process_id}`); - - // Fetch final results to populate the modal - try { - const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`); - if (response.ok) { - const data = await response.json(); - if (data.phase === 'complete' && data.tasks) { - updateCompletedModalResults(virtualPlaylistId, data); - } - } - } catch (err) { - console.warn('Could not load completed download results:', err); - } - } - } - } - - console.log(`✅ Successfully rehydrated mirrored download modal for: ${state.playlist.name}`); - } catch (error) { - console.error('❌ Error rehydrating mirrored download modal:', error); - showToast('Error opening download modal', 'error'); - } -} - -async function hydrateMirroredDiscoveryStates() { - try { - const res = await fetch('/api/mirrored-playlists/discovery-states'); - const data = await res.json(); - if (data.error || !data.states || data.states.length === 0) return; - - console.log(`Hydrating ${data.states.length} mirrored discovery states`); - - for (const s of data.states) { - const hash = s.url_hash; - - youtubePlaylistStates[hash] = { - playlist: s.playlist, - phase: s.phase, - discovery_results: s.discovery_results || [], - discoveryResults: s.discovery_results || [], - discovery_progress: s.discovery_progress || 0, - discoveryProgress: s.discovery_progress || 0, - spotify_matches: s.spotify_matches || 0, - spotifyMatches: s.spotify_matches || 0, - spotify_total: s.spotify_total || 0, - status: s.status || '', - url: s.playlist?.url || '', - sync_playlist_id: null, - converted_spotify_playlist_id: s.converted_spotify_playlist_id, - convertedSpotifyPlaylistId: s.converted_spotify_playlist_id, - download_process_id: s.download_process_id, - created_at: Date.now() / 1000, - last_accessed: Date.now() / 1000, - discovery_future: null, - sync_progress: {}, - is_mirrored_playlist: true, - mirrored_source: s.playlist?.source || '' - }; - - // Update the card to reflect the current phase - const card = document.getElementById(`mirrored-card-${s.playlist_id}`); - if (card) { - const metaEl = card.querySelector('.card-meta'); - if (metaEl) { - // Remove old phase span and add new one - const oldPhase = metaEl.querySelector('span[style]'); - if (oldPhase) oldPhase.remove(); - - if (s.phase === 'discovering') { - metaEl.insertAdjacentHTML('beforeend', `Discovering ${s.discovery_progress || 0}%`); - } else if (s.phase === 'discovered') { - metaEl.insertAdjacentHTML('beforeend', `Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0}`); - } else if (s.phase === 'syncing' || s.phase === 'sync_complete') { - metaEl.insertAdjacentHTML('beforeend', `${s.phase === 'syncing' ? 'Syncing...' : 'Synced'}`); - } else if (s.phase === 'downloading') { - metaEl.insertAdjacentHTML('beforeend', `Downloading...`); - } else if (s.phase === 'download_complete') { - metaEl.insertAdjacentHTML('beforeend', `Downloaded`); - } - } - } - - // Resume polling if discovery is in progress - if (s.phase === 'discovering' && !activeYouTubePollers[hash]) { - startYouTubeDiscoveryPolling(hash); - } - } - } catch (err) { - console.warn('Failed to hydrate mirrored discovery states:', err); - } -} - -function timeAgo(dateStr) { - if (!dateStr) return ''; - // Handle ISO formats: "Z" suffix, "+00:00" offset, or bare (assume UTC) - let ts = dateStr; - if (!ts.includes('Z') && !ts.includes('+') && !ts.includes('-', 10)) ts += 'Z'; - const diff = Date.now() - new Date(ts).getTime(); - const secs = Math.floor(diff / 1000); - if (secs < 5) return 'just now'; - if (secs < 60) return `${secs}s ago`; - const mins = Math.floor(secs / 60); - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 30) return `${days}d ago`; - return `${Math.floor(days / 30)}mo ago`; -} - -/** - * Open modal showing all tracks in a mirrored playlist. - */ -async function openMirroredPlaylistModal(playlistId) { - showLoadingOverlay('Loading mirrored playlist...'); - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}`); - const data = await res.json(); - if (data.error) throw new Error(data.error); - - hideLoadingOverlay(); - - // Remove any existing modal - const old = document.getElementById('mirrored-track-modal'); - if (old) old.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'mirrored-track-modal'; - overlay.className = 'mirrored-modal-overlay'; - - const tracks = data.tracks || []; - const source = data.source || 'unknown'; - const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; - const sourceIcon = sourceIcons[source] || '📋'; - - const trackRows = tracks.map(t => { - const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return `
- ${t.position} - ${_esc(t.track_name)} - ${_esc(t.artist_name)} - ${_esc(t.album_name)} - ${dur} -
`; - }).join(''); - - overlay.innerHTML = ` -
-
-
-
${sourceIcon}
-
-

${_esc(data.name)}

-
- ${_esc(source)} - ${tracks.length} tracks - · - Mirrored ${timeAgo(data.updated_at || data.mirrored_at)} -
-
-
- × -
-
-
- #TrackArtistAlbumTime -
- ${trackRows} -
- -
- `; - - overlay.addEventListener('click', e => { if (e.target === overlay) closeMirroredModal(); }); - document.body.appendChild(overlay); - } catch (err) { - hideLoadingOverlay(); - showToast(`Error: ${err.message}`, 'error'); - } -} - -function closeMirroredModal() { - const m = document.getElementById('mirrored-track-modal'); - if (m) m.remove(); -} - -/** - * Delete a mirrored playlist after confirmation. - */ -async function clearMirroredDiscovery(playlistId, name) { - if (!await showConfirmDialog({ title: 'Clear Discovery Data', message: `Clear discovery data for "${name}"? You can re-discover afterwards to get updated cover art.` })) return; - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/clear-discovery`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success'); - // Signal cancellation to any running worker, then clear state - const hash = `mirrored_${playlistId}`; - if (youtubePlaylistStates[hash]) { - youtubePlaylistStates[hash].phase = 'cancelled'; - } - delete youtubePlaylistStates[hash]; - const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`); - if (staleModal) staleModal.remove(); - loadMirroredPlaylists(); - } else { - showToast(data.error || 'Failed to clear discovery', 'error'); - } - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -// ==================== Discovery Pool Modal ==================== - -let _discoveryPoolOverlay = null; -let _discoveryPoolData = null; -let _discoveryPoolView = 'categories'; // 'categories' | 'failed' | 'matched' -let _discoveryPoolPlaylistFilter = null; - -async function loadDiscoveryPoolStats() { - try { - const res = await fetch('/api/discovery-pool'); - const data = await res.json(); - const matchedEl = document.getElementById('discovery-pool-matched-count'); - const failedEl = document.getElementById('discovery-pool-failed-count'); - if (matchedEl) matchedEl.textContent = data.stats.matched || 0; - if (failedEl) failedEl.textContent = data.stats.failed || 0; - } catch (e) { } -} - -async function openDiscoveryPoolModal(playlistId = null) { - _discoveryPoolPlaylistFilter = playlistId; - _discoveryPoolView = 'categories'; - - // Fetch pool data - let url = '/api/discovery-pool'; - if (playlistId) url += `?playlist_id=${playlistId}`; - try { - const res = await fetch(url); - _discoveryPoolData = await res.json(); - } catch (err) { - showToast('Failed to load discovery pool', 'error'); - return; - } - - // Remove existing overlay if present - if (_discoveryPoolOverlay) _discoveryPoolOverlay.remove(); - - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - overlay.id = 'discovery-pool-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) closeDiscoveryPoolModal(); }; - - const playlistOptions = (_discoveryPoolData.playlists || []) - .map(p => ``) - .join(''); - - const failedCount = _discoveryPoolData.stats.failed || 0; - const matchedCount = _discoveryPoolData.stats.matched || 0; - - overlay.innerHTML = ` - - `; - - document.body.appendChild(overlay); - overlay.style.display = 'flex'; - _discoveryPoolOverlay = overlay; - - // Build matched mosaic if images available - _buildPoolMatchedMosaic(); -} - -function _buildPoolMatchedMosaic() { - const entries = _discoveryPoolData.matched || []; - const images = []; - for (const e of entries) { - const md = e.matched_data || {}; - if (md.image_url && images.indexOf(md.image_url) === -1) { - images.push(md.image_url); - if (images.length >= 20) break; - } - } - const bgEl = document.getElementById('pool-matched-bg'); - if (!bgEl || images.length < 4) return; // keep fallback gradient - - // Build mosaic rows similar to wishlist - bgEl.innerHTML = ''; - bgEl.className = 'wishlist-mosaic-background'; - const rows = 4; - const imgPerRow = Math.ceil(images.length / rows) * 2; // duplicate for seamless loop - for (let r = 0; r < rows; r++) { - const wrapper = document.createElement('div'); - wrapper.className = 'wishlist-mosaic-row-wrapper'; - const row = document.createElement('div'); - row.className = 'wishlist-mosaic-row' + (r % 2 === 1 ? ' scroll-right' : ''); - row.style.setProperty('--speed', (25 + r * 5) + 's'); - row.style.animationDelay = (r * 0.15) + 's'; - for (let i = 0; i < imgPerRow; i++) { - const img = images[(i + r * 3) % images.length]; - const tile = document.createElement('div'); - tile.className = 'wishlist-mosaic-tile'; - tile.innerHTML = `
`; - row.appendChild(tile); - } - wrapper.appendChild(row); - bgEl.appendChild(wrapper); - } -} - -function closeDiscoveryPoolModal() { - if (_discoveryPoolOverlay) { - _discoveryPoolOverlay.remove(); - _discoveryPoolOverlay = null; - } - _discoveryPoolData = null; - // Refresh dashboard stats - loadDiscoveryPoolStats(); -} - -function showPoolCategories() { - _discoveryPoolView = 'categories'; - const grid = document.getElementById('pool-category-grid'); - const list = document.getElementById('pool-list-view'); - if (grid) grid.style.display = ''; - if (list) list.style.display = 'none'; -} - -function showPoolList(category) { - _discoveryPoolView = category; - const grid = document.getElementById('pool-category-grid'); - const list = document.getElementById('pool-list-view'); - if (grid) grid.style.display = 'none'; - if (list) list.style.display = ''; - - const titleEl = document.getElementById('pool-list-title'); - if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks'; - - // Clear search filter when switching views - const searchEl = document.getElementById('pool-list-search'); - if (searchEl) searchEl.value = ''; - - renderPoolList(); -} - -async function filterDiscoveryPool(playlistId) { - _discoveryPoolPlaylistFilter = playlistId || null; - let url = '/api/discovery-pool'; - if (playlistId) url += `?playlist_id=${playlistId}`; - try { - const res = await fetch(url); - _discoveryPoolData = await res.json(); - // Update header counts - _updatePoolHeaderCounts(); - // Update category card counts - const failedCountEl = document.getElementById('pool-cat-failed-count'); - const matchedCountEl = document.getElementById('pool-cat-matched-count'); - if (failedCountEl) failedCountEl.textContent = _discoveryPoolData.stats.failed || 0; - if (matchedCountEl) matchedCountEl.textContent = _discoveryPoolData.stats.matched || 0; - // If viewing a list, refresh it - if (_discoveryPoolView === 'failed' || _discoveryPoolView === 'matched') { - renderPoolList(); - } - } catch (err) { - showToast('Failed to filter discovery pool', 'error'); - } -} - -function _updatePoolHeaderCounts() { - if (!_discoveryPoolData) return; - const failedCount = _discoveryPoolData.stats.failed || 0; - const matchedCount = _discoveryPoolData.stats.matched || 0; - const matchedEl = document.getElementById('pool-header-matched'); - const failedEl = document.getElementById('pool-header-failed'); - if (matchedEl) matchedEl.textContent = `${matchedCount} Matched`; - if (failedEl) { - failedEl.textContent = `${failedCount} Failed`; - failedEl.classList.toggle('pool-header-failed-highlight', failedCount > 0); - } -} - -function renderPoolList() { - const container = document.getElementById('pool-list-content'); - if (!container || !_discoveryPoolData) return; - - // Client-side search filter - const searchEl = document.getElementById('pool-list-search'); - const query = (searchEl ? searchEl.value : '').toLowerCase().trim(); - - if (_discoveryPoolView === 'failed') { - let tracks = _discoveryPoolData.failed || []; - if (query) { - tracks = tracks.filter(t => - (t.track_name || '').toLowerCase().includes(query) || - (t.artist_name || '').toLowerCase().includes(query) || - (t.playlist_name || '').toLowerCase().includes(query) - ); - } - if (tracks.length === 0) { - container.innerHTML = query - ? '
No failed tracks match your filter.
' - : '
No failed discoveries. All tracks matched successfully.
'; - return; - } - container.innerHTML = tracks.map(t => ` -
-
-
${_esc(t.track_name)}
-
- ${_esc(t.artist_name)} - ${_esc(t.playlist_name)} -
-
- -
- `).join(''); - } else { - let entries = _discoveryPoolData.matched || []; - if (query) { - entries = entries.filter(e => { - const md = e.matched_data || {}; - const matchedName = md.name || ''; - return (e.original_title || '').toLowerCase().includes(query) || - (e.original_artist || '').toLowerCase().includes(query) || - matchedName.toLowerCase().includes(query); - }); - } - if (entries.length === 0) { - container.innerHTML = query - ? '
No matched tracks match your filter.
' - : '
No cached discovery matches yet.
'; - return; - } - container.innerHTML = entries.map(e => { - const md = e.matched_data || {}; - const matchedArtists = (md.artists || []).map(a => typeof a === 'string' ? a : (a.name || '')).join(', '); - const conf = Math.round((e.confidence || 0) * 100); - const confClass = conf >= 80 ? 'high' : (conf >= 70 ? 'mid' : 'low'); - const album = md.album || {}; - const albumImages = (typeof album === 'object' && album.images) ? album.images : []; - const imgUrl = md.image_url || (albumImages.length > 0 ? albumImages[0].url || '' : ''); - return ` -
- ${imgUrl ? `` : '
'} -
-
${_esc(e.original_title)}
-
- ${_esc(e.original_artist)} - - ${_esc(md.name || '?')} - ${_esc(e.provider)} -
-
- ${conf}% - ${e.use_count}× - - -
- `; - }).join(''); - } -} - -function rematchPoolCacheEntry(cacheId, originalTitle, originalArtist) { - // Open the fix modal in "rematch" mode — saves to cache instead of mirrored tracks - openPoolRematchModal(cacheId, originalTitle, originalArtist); -} - -function openPoolRematchModal(cacheId, trackName, artistName) { - // Reuses the fix modal UI but saves via the rematch endpoint - let fixOverlay = document.getElementById('pool-fix-overlay'); - if (fixOverlay) fixOverlay.remove(); - - fixOverlay = document.createElement('div'); - fixOverlay.className = 'pool-fix-overlay'; - fixOverlay.id = 'pool-fix-overlay'; - fixOverlay.addEventListener('mousedown', (e) => { - if (e.target === fixOverlay) { - e.preventDefault(); - closePoolFixModal(); - } - }); - - fixOverlay.innerHTML = ` -
-
-

Rematch Track

- -
-
-
-
Current Match
-
- ${_esc(trackName)} - - ${_esc(artistName)} -
-
- -
-
-
Searching...
-
-
-
- -
- `; - - // Store rematch context - fixOverlay.dataset.mode = 'rematch'; - fixOverlay.dataset.cacheId = cacheId; - fixOverlay.dataset.originalTitle = trackName; - fixOverlay.dataset.originalArtist = artistName; - document.body.appendChild(fixOverlay); - - const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); - const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); - const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; - trackInput.addEventListener('keypress', enterHandler); - artistInput.addEventListener('keypress', enterHandler); - trackInput.focus(); - trackInput.select(); - - setTimeout(() => searchPoolFix(), 500); -} - -async function removePoolCacheEntry(entryId) { - if (!await showConfirmDialog({ title: 'Remove Cache Entry', message: 'Remove this cached match? The track will be re-discovered fresh next time.' })) return; - try { - const res = await fetch(`/api/discovery-pool/cache/${entryId}`, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - showToast('Cache entry removed', 'success'); - filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); - } else { - showToast(data.error || 'Failed to remove', 'error'); - } - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -// --- Pool Fix Sub-Modal --- - -function openPoolFixModal(trackId, trackName, artistName) { - // Create sub-modal overlay inside the pool modal - let fixOverlay = document.getElementById('pool-fix-overlay'); - if (fixOverlay) fixOverlay.remove(); - - fixOverlay = document.createElement('div'); - fixOverlay.className = 'pool-fix-overlay'; - fixOverlay.id = 'pool-fix-overlay'; - - // Only close on click to the overlay itself — use a dedicated close zone - // to prevent accidental dismissal when clicking near inputs - fixOverlay.addEventListener('mousedown', (e) => { - if (e.target === fixOverlay) { - e.preventDefault(); // Prevent stealing focus from inputs - closePoolFixModal(); - } - }); - - fixOverlay.innerHTML = ` -
-
-

Fix Track Match

- -
-
-
-
Original Track
-
- ${_esc(trackName)} - - ${_esc(artistName)} -
-
- -
-
-
Searching...
-
-
-
- -
- `; - - fixOverlay.dataset.trackId = trackId; - document.body.appendChild(fixOverlay); - - // Add enter key support - const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); - const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); - const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; - trackInput.addEventListener('keypress', enterHandler); - artistInput.addEventListener('keypress', enterHandler); - - // Focus the track input - trackInput.focus(); - trackInput.select(); - - // Auto-search after a delay - setTimeout(() => searchPoolFix(), 500); -} - -function closePoolFixModal() { - const fixOverlay = document.getElementById('pool-fix-overlay'); - if (fixOverlay) fixOverlay.remove(); -} - -async function searchPoolFix() { - const trackInput = document.getElementById('pool-fix-track-input'); - const artistInput = document.getElementById('pool-fix-artist-input'); - const resultsContainer = document.getElementById('pool-fix-results'); - if (!trackInput || !resultsContainer) return; - - const trackVal = trackInput.value.trim(); - const artistVal = artistInput.value.trim(); - if (!trackVal && !artistVal) { - resultsContainer.innerHTML = '
Enter a search term
'; - return; - } - - resultsContainer.innerHTML = '
Searching...
'; - - try { - const params = new URLSearchParams(); - if (trackVal) params.set('track', trackVal); - if (artistVal) params.set('artist', artistVal); - params.set('limit', '20'); - const res = await fetch(`/api/spotify/search_tracks?${params.toString()}`); - const data = await res.json(); - const tracks = data.tracks || []; - - if (tracks.length === 0) { - resultsContainer.innerHTML = '
No results found
'; - return; - } - - resultsContainer.innerHTML = tracks.map((track) => { - const artists = (track.artists || []).join(', '); - const duration = track.duration_ms ? formatDuration(track.duration_ms) : ''; - const albumText = track.album ? ` · ${_esc(track.album)}` : ''; - return ` -
-
-
${_esc(track.name || 'Unknown')}
-
${_esc(artists)}${albumText}
-
- ${duration ? `
${duration}
` : ''} -
- `; - }).join(''); - } catch (err) { - resultsContainer.innerHTML = `
Search failed: ${_esc(err.message)}
`; - } -} - -async function selectPoolFixTrack(track) { - const fixOverlay = document.getElementById('pool-fix-overlay'); - if (!fixOverlay) return; - - // Confirm selection - const artists = (track.artists || []).join(', '); - if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return; - - const isRematch = fixOverlay.dataset.mode === 'rematch'; - - try { - let res, data; - if (isRematch) { - // Rematch mode: save new match to discovery cache - res = await fetch('/api/discovery-pool/rematch', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - cache_id: parseInt(fixOverlay.dataset.cacheId), - original_title: fixOverlay.dataset.originalTitle, - original_artist: fixOverlay.dataset.originalArtist, - spotify_track: track, - }), - }); - data = await res.json(); - } else { - // Normal fix mode: save to mirrored track - const trackId = parseInt(fixOverlay.dataset.trackId); - res = await fetch('/api/discovery-pool/fix', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_id: trackId, - spotify_track: track, - }), - }); - data = await res.json(); - } - - if (data.success) { - showToast(`Matched: ${track.name}`, 'success'); - closePoolFixModal(); - // Refresh pool data - filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); - } else { - showToast(data.error || 'Failed to fix track', 'error'); - } - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -async function deleteMirroredPlaylist(playlistId, name) { - if (!await showConfirmDialog({ title: 'Delete Playlist', message: `Delete mirrored playlist "${name}"?`, confirmText: 'Delete', destructive: true })) return; - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}`, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) { - showToast(`Deleted mirror: ${name}`, 'success'); - loadMirroredPlaylists(); - } else { - showToast(data.error || 'Failed to delete', 'error'); - } - } catch (err) { - showToast(`Error: ${err.message}`, 'error'); - } -} - -/** - * Launch the existing discovery modal for a mirrored playlist by creating - * a temporary entry in youtubePlaylistStates and reusing openYouTubeDiscoveryModal. - */ -async function discoverMirroredPlaylist(playlistId) { - closeMirroredModal(); - const tempHash = `mirrored_${playlistId}`; - - // If state already exists (discovery in progress or completed), just reopen the modal - const existingState = youtubePlaylistStates[tempHash]; - const hasActiveDiscovery = activeYouTubePollers[tempHash] || document.getElementById(`youtube-discovery-modal-${tempHash}`); - if (existingState && (existingState.phase !== 'fresh' || hasActiveDiscovery)) { - openYouTubeDiscoveryModal(tempHash); - // Resume polling if discovery is in progress but poller stopped - if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) { - startYouTubeDiscoveryPolling(tempHash); - } - return; - } - - showLoadingOverlay('Preparing discovery...'); - try { - // Register the mirrored playlist on the backend so the YouTube discovery pipeline can find it - const prepRes = await fetch(`/api/mirrored-playlists/${playlistId}/prepare-discovery`, { method: 'POST' }); - const prepData = await prepRes.json(); - if (prepData.error) throw new Error(prepData.error); - - // Also fetch the full data for the frontend state - const res = await fetch(`/api/mirrored-playlists/${playlistId}`); - const data = await res.json(); - if (data.error) throw new Error(data.error); - hideLoadingOverlay(); - - // Build tracks in the format the discovery modal expects - const tracks = (data.tracks || []).map(t => ({ - id: t.source_track_id || `mirrored_${t.id}`, - name: t.track_name, - artists: [t.artist_name], - album: t.album_name || '', - duration_ms: t.duration_ms || 0 - })); - - // Check if backend returned cached results - if (prepData.from_cache) { - // Fetch the pre-populated status from the backend - const statusRes = await fetch(`/api/youtube/discovery/status/${tempHash}`); - const statusData = await statusRes.json(); - if (statusData.error) throw new Error(statusData.error); - - youtubePlaylistStates[tempHash] = { - playlist: { - name: data.name, - tracks: tracks, - track_count: tracks.length - }, - phase: statusData.phase || 'discovered', - discovery_results: statusData.results || [], - discoveryResults: statusData.results || [], - discovery_progress: statusData.progress || 100, - spotify_matches: statusData.spotify_matches || 0, - spotifyMatches: statusData.spotify_matches || 0, - spotify_total: tracks.length, - status: statusData.status || 'complete', - url: `mirrored://${data.source}/${data.source_playlist_id}`, - sync_playlist_id: null, - converted_spotify_playlist_id: null, - download_process_id: null, - created_at: Date.now() / 1000, - last_accessed: Date.now() / 1000, - discovery_future: null, - sync_progress: {}, - is_mirrored_playlist: true, - mirrored_source: data.source - }; - - const cached = prepData.cached_matches || 0; - const total = prepData.total_tracks || tracks.length; - showToast(`Loaded ${cached}/${total} cached discovery results`, 'success'); - } else { - // No cached data — fresh state - youtubePlaylistStates[tempHash] = { - playlist: { - name: data.name, - tracks: tracks, - track_count: tracks.length - }, - phase: 'fresh', - discovery_results: [], - discovery_progress: 0, - spotify_matches: 0, - spotify_total: tracks.length, - status: 'parsed', - url: `mirrored://${data.source}/${data.source_playlist_id}`, - sync_playlist_id: null, - converted_spotify_playlist_id: null, - download_process_id: null, - created_at: Date.now() / 1000, - last_accessed: Date.now() / 1000, - discovery_future: null, - sync_progress: {}, - is_mirrored_playlist: true, - mirrored_source: data.source - }; - } - - openYouTubeDiscoveryModal(tempHash); - } catch (err) { - hideLoadingOverlay(); - showToast(`Error: ${err.message}`, 'error'); - } -} - -// =============================== -// AUTOMATIONS — Visual Builder -// =============================== - -async function retryFailedMirroredDiscovery(urlHash) { - // Extract playlist ID from url_hash (format: "mirrored_") - const playlistId = urlHash.replace('mirrored_', ''); - try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/retry-failed-discovery`, { method: 'POST' }); - const data = await res.json(); - if (data.error) { - showToast(`Error: ${data.error}`, 'error'); - return; - } - if (data.retry_count === 0) { - showToast('All tracks already found!', 'success'); - return; - } - - // Update frontend state to discovering - const state = youtubePlaylistStates[urlHash]; - if (state) { - state.phase = 'discovering'; - state.status = 'discovering'; - state.discovery_progress = 0; - } - - // Update modal buttons to show discovering state - updateYouTubeModalButtons(urlHash, 'discovering'); - - // Start polling for progress - startYouTubeDiscoveryPolling(urlHash); - - showToast(`Retrying ${data.retry_count} failed tracks...`, 'info'); - } catch (err) { - showToast(`Error retrying discovery: ${err.message}`, 'error'); - } -} - -let _autoBlocks = null; // cached block definitions from /api/automations/blocks -let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false }; - -let _autoMirroredPlaylists = null; // cached mirrored playlist list -let _autoSpotifyAuthenticated = false; // whether Spotify is authed (for refresh filtering) - -const _autoIcons = { - schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705', - watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04', - playlist_changed: '\u270F\uFE0F', - process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F', - scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01', - discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D', - notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10', - signal_received: '\u26A1', fire_signal: '\u26A1', run_script: '\uD83D\uDCBB', - // Phase 3 - wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', - database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', - download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795', - watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64', - import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2', - quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F', library_scan_completed: '\uD83D\uDCE1', - start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F', - clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9', - update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA', - backup_database: '\uD83D\uDCBE', - refresh_beatport_cache: '\uD83C\uDFB5', - clean_search_history: '\uD83D\uDDD1\uFE0F', - clean_completed_downloads: '\u2705', - full_cleanup: '\uD83E\uDDF9', - playlist_pipeline: '\uD83D\uDE80', -}; - -// --- Inspiration Templates --- -// --- Automation Hub Data --- - -// ── Automation Hub: One-Click Pipeline Groups ── -const AUTO_HUB_GROUPS = [ - { - id: 'playlist-pipeline', icon: '🚀', name: 'Playlist Pipeline (All-in-One)', - desc: 'Single automation that runs the full playlist lifecycle: refresh → discover → sync → download missing. No signal wiring needed.', - category: 'Sync', badge: '1 automation', color: '#8b5cf6', - steps: [ - { label: 'Refresh', icon: '🔄', type: 'action' }, - { label: 'Discover', icon: '🔍', type: 'action' }, - { label: 'Sync', icon: '🔗', type: 'action' }, - { label: 'Download', icon: '📥', type: 'action' }, - ], - automations: [ - { name: 'Playlist Pipeline', trigger_type: 'schedule', trigger_config: { interval: 6, unit: 'hours' }, action_type: 'playlist_pipeline', action_config: { all: true }, then_actions: [], group_name: 'Playlist Pipeline' }, - ] - }, - { - id: 'new-music-pipeline', icon: '🚀', name: 'New Music Pipeline', - desc: 'Full hands-free new music workflow. Scans your watchlist for releases, downloads them, cleans up, and notifies you.', - category: 'Discovery', badge: '4 automations', color: '#f97316', - steps: [ - { label: 'Scan Artists', icon: '🔍', type: 'action' }, - { label: 'Download', icon: '📥', type: 'action' }, - { label: 'Cleanup', icon: '🧹', type: 'action' }, - { label: 'Notify', icon: '🔔', type: 'notify' }, - ], - automations: [ - { name: 'New Music — Scan Watchlist', trigger_type: 'schedule', trigger_config: { interval: 12, unit: 'hours' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_scanned' } }], group_name: 'New Music Pipeline' }, - { name: 'New Music — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_downloaded' } }], group_name: 'New Music Pipeline' }, - { name: 'New Music — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_downloaded' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_cleaned' } }], group_name: 'New Music Pipeline' }, - { name: 'New Music — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_cleaned' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'New Music Pipeline', needs_notify: true }, - ] - }, - { - id: 'nightly-ops', icon: '🌙', name: 'Nightly Operations', - desc: 'Staggered overnight maintenance: scan, download, cleanup, and backup while you sleep.', - category: 'Maintenance', badge: '4 automations', color: '#8b5cf6', - steps: [ - { label: '1AM Scan', icon: '🔍', type: 'action' }, - { label: '2AM Download', icon: '📥', type: 'action' }, - { label: '3AM Cleanup', icon: '🧹', type: 'action' }, - { label: '4AM Backup', icon: '💾', type: 'action' }, - ], - automations: [ - { name: 'Nightly — 1AM Scan', trigger_type: 'daily_time', trigger_config: { time: '01:00' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, - { name: 'Nightly — 2AM Download', trigger_type: 'daily_time', trigger_config: { time: '02:00' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, - { name: 'Nightly — 3AM Cleanup', trigger_type: 'daily_time', trigger_config: { time: '03:00' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, - { name: 'Nightly — 4AM Backup', trigger_type: 'daily_time', trigger_config: { time: '04:00' }, action_type: 'backup_database', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, - ] - }, - { - id: 'download-monitor', icon: '📊', name: 'Download Monitor', - desc: 'Stay informed about your downloads. Get notified on failures, quarantined files, and completed batches.', - category: 'Alerts', badge: '3 automations', color: '#ef4444', - steps: [ - { label: 'Failures', icon: '❌', type: 'notify' }, - { label: 'Quarantine', icon: '⚠️', type: 'notify' }, - { label: 'Complete', icon: '✅', type: 'notify' }, - ], - automations: [ - { name: 'Alert — Download Failed', trigger_type: 'download_failed', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, - { name: 'Alert — File Quarantined', trigger_type: 'download_quarantined', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, - { name: 'Alert — Batch Complete', trigger_type: 'batch_complete', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, - ] - }, - { - id: 'library-guardian', icon: '🛡️', name: 'Library Guardian', - desc: 'Protect your library quality. After scans, runs quality checks and notifies you of any issues found.', - category: 'Maintenance', badge: '2 automations', color: '#f59e0b', - steps: [ - { label: 'Quality Scan', icon: '✅', type: 'action' }, - { label: 'Notify', icon: '🔔', type: 'notify' }, - ], - automations: [ - { name: 'Guardian — Quality Check', trigger_type: 'library_scan_completed', trigger_config: {}, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'guardian_quality_done' } }], group_name: 'Library Guardian' }, - { name: 'Guardian — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'guardian_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Library Guardian', needs_notify: true }, - ] - }, - { - id: 'startup-recovery', icon: '⚡', name: 'Startup Recovery', - desc: 'Self-heal after a restart. Scans your library, processes pending wishlist items, and cleans up automatically.', - category: 'Maintenance', badge: '3 automations', color: '#14b8a6', - steps: [ - { label: 'Scan Library', icon: '📚', type: 'action' }, - { label: 'Process Wishlist', icon: '📥', type: 'action' }, - { label: 'Cleanup', icon: '🧹', type: 'action' }, - ], - automations: [ - { name: 'Startup — Scan Library', trigger_type: 'app_started', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_scanned' } }], group_name: 'Startup Recovery' }, - { name: 'Startup — Process Wishlist', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_processed' } }], group_name: 'Startup Recovery' }, - { name: 'Startup — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_processed' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Startup Recovery' }, - ] - }, - { - id: 'import-pipeline', icon: '📦', name: 'Import Pipeline', - desc: 'After importing files, automatically scans your library, runs a quality check, and notifies you when complete.', - category: 'Maintenance', badge: '3 automations', color: '#a855f7', - steps: [ - { label: 'Scan Library', icon: '📚', type: 'action' }, - { label: 'Quality Check', icon: '✅', type: 'action' }, - { label: 'Notify', icon: '🔔', type: 'notify' }, - ], - automations: [ - { name: 'Import — Scan Library', trigger_type: 'import_completed', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_scanned' } }], group_name: 'Import Pipeline' }, - { name: 'Import — Quality Check', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_scanned' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_quality_done' } }], group_name: 'Import Pipeline' }, - { name: 'Import — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Import Pipeline', needs_notify: true }, - ] - }, - { - id: 'weekly-deep-clean', icon: '✨', name: 'Weekly Deep Clean', - desc: 'Comprehensive weekly sweep: find duplicates, check quality, clean up, back up, and report results.', - category: 'Maintenance', badge: '5 automations', color: '#ec4899', - steps: [ - { label: 'Duplicates', icon: '📋', type: 'action' }, - { label: 'Quality', icon: '✅', type: 'action' }, - { label: 'Cleanup', icon: '🧹', type: 'action' }, - { label: 'Backup', icon: '💾', type: 'action' }, - { label: 'Notify', icon: '🔔', type: 'notify' }, - ], - automations: [ - { name: 'Deep Clean — Duplicates', trigger_type: 'weekly_time', trigger_config: { days: ['sunday'], time: '02:00' }, action_type: 'run_duplicate_cleaner', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_dedup_done' } }], group_name: 'Weekly Deep Clean' }, - { name: 'Deep Clean — Quality', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_dedup_done' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_quality_done' } }], group_name: 'Weekly Deep Clean' }, - { name: 'Deep Clean — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_quality_done' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_cleanup_done' } }], group_name: 'Weekly Deep Clean' }, - { name: 'Deep Clean — Backup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_cleanup_done' }, action_type: 'backup_database', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_backup_done' } }], group_name: 'Weekly Deep Clean' }, - { name: 'Deep Clean — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_backup_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Weekly Deep Clean', needs_notify: true }, - ] - }, - { - id: 'beatport-fresh', icon: '🎧', name: 'Beatport Fresh', - desc: 'Keep your Beatport charts and playlists up to date with a daily cache refresh.', - category: 'Discovery', badge: '1 automation', color: '#84cc16', - steps: [ - { label: 'Refresh Cache', icon: '🔄', type: 'action' }, - ], - automations: [ - { name: 'Beatport — Daily Refresh', trigger_type: 'daily_time', trigger_config: { time: '05:00' }, action_type: 'refresh_beatport_cache', action_config: {}, then_actions: [], group_name: 'Beatport Fresh' }, - ] - }, -]; - -const AUTO_HUB_RECIPES = [ - // Sync & Playlists - { - id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.', - category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] - }, - { - id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.', - category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [], - chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.' - }, - { - id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.', - category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] - }, - { - id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.', - category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] - }, - { - id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.', - category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: [] - }, - // New Music Discovery - { - id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.', - category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }], - chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'], - note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.' - }, - { - id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.', - category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [] - }, - { - id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.', - category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] - }, - { - id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.', - category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: [] - }, - { - id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.', - category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] - }, - // Library Maintenance - { - id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.', - category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: [] - }, - { - id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.', - category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: [] - }, - { - id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.', - category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: [] - }, - { - id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.', - category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }] - }, - { - id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.', - category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: [] - }, - // Notifications & Alerts - { - id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.', - category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] - }, - { - id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.', - category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }] - }, - { - id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.', - category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] - }, - // Power User Chains - { - id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.', - category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }], - chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'], - note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.' - }, - { - id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.', - category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [], - chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'], - note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.' - }, -]; - -const AUTO_HUB_GUIDES = [ - { - id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner', - steps: [ - 'Go to the Playlists page and find a Spotify playlist you want to track.', - 'Click Mirror Playlist to create a local copy.', - 'Go to Automations and click New Automation.', - 'Set WHEN to Schedule \u2192 Every 6 hours.', - 'Set DO to Refresh Mirrored Playlists.', - 'Save and enable \u2014 your playlist will now stay in sync automatically.' - ], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab'] - }, - { - id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner', - steps: [ - 'In Discord, go to your channel\'s settings \u2192 Integrations \u2192 Webhooks.', - 'Create a webhook and copy the URL.', - 'In SoulSync, go to Settings \u2192 Notifications and paste the Discord webhook URL.', - 'Go to Automations \u2192 New Automation.', - 'Set WHEN to Download Failed (or any event), DO to Notify Only, THEN to Discord.' - ], relatedRecipes: ['download-failure-alert', 'batch-complete-notify'] - }, - { - id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate', - steps: [ - 'Create Automation 1: Schedule (12h) \u2192 Scan Watchlist, THEN fire signal scan_done.', - 'Create Automation 2: Signal scan_done \u2192 Process Wishlist, THEN fire signal process_done.', - 'Create Automation 3: Signal process_done \u2192 Full Cleanup.', - 'Enable all three automations.', - 'Test by manually running Automation 1 \u2014 watch the chain execute.', - 'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.', - 'Adjust the schedule interval based on how often you want new music checked.' - ], relatedRecipes: ['complete-new-release', 'full-hands-free'] - }, - { - id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced', - steps: [ - 'Understand the concept: fire_signal is a THEN action that emits a named signal. signal_received is a WHEN trigger that listens for it.', - 'In your first automation, add a THEN action \u2192 Fire Signal and name it (e.g., step1_done).', - 'Create a second automation with WHEN \u2192 Signal Received \u2192 signal name step1_done.', - 'The second automation will fire automatically when the first one completes.', - 'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.', - 'Use descriptive signal names like watchlist_scanned or cleanup_finished.' - ], relatedRecipes: ['quality-assurance', 'complete-new-release'] - }, - { - id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate', - steps: [ - 'Create a Daily Time (04:00) \u2192 Backup Database automation.', - 'Create a Weekly Time (Saturday, 05:00) \u2192 Full Cleanup automation.', - 'Create a Daily Time (02:00) \u2192 Update Discovery Pool automation.', - 'Stagger times by at least 1 hour to avoid resource contention.', - 'Add Discord/Telegram notifications to any you want alerts for.' - ], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly'] - }, -]; - -const AUTO_HUB_TIPS = [ - { icon: '\u26A1', title: 'Signal Chaining 101', body: 'fire_signal (a THEN action) emits a named event. signal_received (a WHEN trigger) listens for it. This lets you chain automations: when one finishes, the next starts automatically.', tag: 'Signals' }, - { icon: '\u23F0', title: 'Stagger Your Schedules', body: 'If you have multiple timed automations, space them at least 1 hour apart. Running scan, process, and cleanup at the same time creates resource contention and can slow everything down.', tag: 'Performance' }, - { icon: '\uD83C\uDFAF', title: 'Use Conditions to Filter', body: 'Add conditions to event triggers to only fire on specific artists, formats, or quality levels. For example, trigger only when a downloaded track\'s artist matches "Radiohead".', tag: 'Filtering' }, - { icon: '\uD83D\uDCC1', title: 'Group Related Automations', body: 'Use the Group dropdown when creating automations to organize them. Groups like "Nightly", "Notifications", or "Pipeline" make it easy to find and manage related automations.', tag: 'Organization' }, - { icon: '\uD83D\uDD04', title: 'Avoid Chain Loops', body: 'SoulSync has built-in cycle detection, but it\'s good practice to design signal names carefully. If A fires signal X and B listens for X and fires Y, make sure nothing fires X again downstream.', tag: 'Safety' }, - { icon: '\uD83D\uDCDA', title: 'Stack THEN Actions', body: 'Each automation supports up to 3 THEN actions. Combine notification channels (Discord + Telegram) with a fire_signal to both notify yourself and trigger the next automation.', tag: 'Power' }, - { icon: '\u2699\uFE0F', title: 'System vs Custom', body: 'System automations handle core tasks like Spotify enrichment and are managed automatically. Create custom automations to extend their behavior \u2014 trigger on their completion events.', tag: 'Basics' }, - { icon: '\uD83E\uDDEA', title: 'Test with Notify Only', body: 'Set DO to Notify Only when testing a new trigger. You\'ll see when it fires without any side effects. Once you\'re confident in the timing, switch to the real action.', tag: 'Testing' }, -]; - -const AUTO_HUB_REFERENCE = { - triggers: [ - { - group: 'Time-Based', items: [ - { type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' }, - { type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' }, - { type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' }, - ] - }, - { - group: 'Download Events', items: [ - { type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' }, - { type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' }, - { type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' }, - { type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' }, - ] - }, - { - group: 'Watchlist & Wishlist', items: [ - { type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' }, - { type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' }, - { type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' }, - { type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' }, - { type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' }, - { type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' }, - ] - }, - { - group: 'Playlists', items: [ - { type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' }, - { type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' }, - { type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' }, - { type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' }, - ] - }, - { - group: 'Library & System', items: [ - { type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' }, - { type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' }, - { type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' }, - { type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' }, - { type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' }, - { type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' }, - ] - }, - { - group: 'Signals', items: [ - { type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' }, - ] - }, - ], - actions: [ - { - group: 'Downloads & Sync', items: [ - { type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' }, - { type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' }, - { type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' }, - { type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' }, - { type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' }, - { type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' }, - { type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' }, - ] - }, - { - group: 'Library Tools', items: [ - { type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' }, - { type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' }, - { type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' }, - { type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' }, - ] - }, - { - group: 'Cleanup', items: [ - { type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' }, - { type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' }, - { type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' }, - { type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' }, - { type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' }, - { type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' }, - ] - }, - { - group: 'Other', items: [ - { type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' }, - ] - }, - ], - thenActions: [ - { - group: 'Notifications', items: [ - { type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' }, - { type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' }, - { type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' }, - ] - }, - { - group: 'Chaining', items: [ - { type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' }, - ] - }, - ], -}; - -// --- Load & Render List --- - -// Drag-and-drop state -let _autoDragState = null; -let _autoDragEnterCount = 0; -let _autoDragExpandTimer = null; - -function _buildAutomationSection(id, label, automations, useGrid, options = {}) { - const groupName = options.groupName || null; - const isProtected = options.isProtected || false; // System, Hub sections - - const section = document.createElement('div'); - section.className = 'automations-section'; - if (isProtected) section.classList.add('section-protected'); - section.id = id; - if (groupName) section.dataset.groupName = groupName; - const collapsed = localStorage.getItem('auto_section_' + id) === '1'; - if (collapsed) section.classList.add('collapsed'); - - const header = document.createElement('div'); - header.className = 'automations-section-header'; - - // Group header actions (rename, bulk toggle, delete) — only for user groups - let actionsHtml = ''; - if (groupName && !isProtected) { - const enabledCount = automations.filter(a => a.enabled).length; - const allEnabled = enabledCount === automations.length; - actionsHtml = ` -
- - - -
- `; - } - - header.innerHTML = ` - - - ${automations.length} - ${actionsHtml} - - `; - header.onclick = (e) => { - if (e.target.closest('.section-actions')) return; - section.classList.toggle('collapsed'); - localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0'); - }; - - const body = document.createElement('div'); - body.className = 'automations-section-body'; - - // Drop zone setup (not for protected sections) - if (!isProtected) { - const dropGroupName = groupName; // null for "My Automations" - body.addEventListener('dragover', (e) => { - if (!_autoDragState) return; - e.preventDefault(); - e.dataTransfer.dropEffect = 'move'; - body.classList.add('drop-target'); - }); - body.addEventListener('dragenter', (e) => { - if (!_autoDragState) return; - _autoDragEnterCount++; - body.classList.add('drop-target'); - // Expand collapsed sections on drag-hover - if (section.classList.contains('collapsed')) { - _autoDragExpandTimer = setTimeout(() => { - section.classList.remove('collapsed'); - }, 500); - } - }); - body.addEventListener('dragleave', (e) => { - if (!_autoDragState) return; - _autoDragEnterCount--; - if (_autoDragEnterCount <= 0) { - _autoDragEnterCount = 0; - body.classList.remove('drop-target'); - if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } - } - }); - body.addEventListener('drop', async (e) => { - e.preventDefault(); - body.classList.remove('drop-target'); - _autoDragEnterCount = 0; - if (!_autoDragState) return; - const draggedId = _autoDragState.id; - const fromGroup = _autoDragState.groupName; - if (fromGroup === dropGroupName) return; // Same group, no-op - try { - const res = await fetch('/api/automations/' + draggedId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_name: dropGroupName }) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast(dropGroupName ? `Moved to "${dropGroupName}"` : 'Moved to My Automations', 'success'); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } - }); - } - - const container = document.createElement('div'); - container.className = useGrid ? 'automations-grid' : 'automations-user-list'; - automations.forEach(a => container.appendChild(renderAutomationCard(a))); - body.appendChild(container); - section.appendChild(header); - section.appendChild(body); - return section; -} - -/** - * Delete a group — ungroups all automations (moves to My Automations). - */ -async function _deleteGroup(groupName) { - // Collect automation IDs in this group - const ids = []; - document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { - if (card.dataset.id) ids.push(parseInt(card.dataset.id)); - }); - - if (ids.length === 0) { await loadAutomations(); return; } - - // Show choice dialog — ungroup or delete all - const choice = await _showDeleteGroupDialog(groupName, ids.length); - if (!choice) return; - - try { - if (choice === 'ungroup') { - const res = await fetch('/api/automations/group', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ automation_ids: ids, group_name: null }) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast(`Dissolved group "${groupName}" — ${data.updated} automations moved to My Automations`, 'success'); - } else if (choice === 'delete_all') { - // Delete each automation - let deleted = 0; - for (const id of ids) { - try { - const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); - const data = await res.json(); - if (data.success) deleted++; - } catch (e) {} - } - showToast(`Deleted group "${groupName}" and ${deleted} automation${deleted !== 1 ? 's' : ''}`, 'success'); - } - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -function _showDeleteGroupDialog(groupName, count) { - return new Promise((resolve) => { - const overlay = document.createElement('div'); - overlay.className = 'modal-overlay'; - overlay.style.display = 'flex'; - overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; - - overlay.innerHTML = ` -
-
🗑️
-

Delete Group "${groupName}"

-

This group contains ${count} automation${count !== 1 ? 's' : ''}. What would you like to do?

-
- - - -
-
- `; - - overlay.querySelector('#dg-ungroup').onclick = () => { overlay.remove(); resolve('ungroup'); }; - overlay.querySelector('#dg-delete').onclick = () => { overlay.remove(); resolve('delete_all'); }; - overlay.querySelector('#dg-cancel').onclick = () => { overlay.remove(); resolve(null); }; - - document.addEventListener('keydown', function esc(e) { - if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); } - }); - - document.body.appendChild(overlay); - }); -} - -/** - * Rename a group — inline edit on the section header label. - */ -function _startRenameGroup(groupName, btnEl) { - const section = btnEl.closest('.automations-section'); - const labelEl = section?.querySelector('.section-label'); - if (!labelEl) return; - - const input = document.createElement('input'); - input.className = 'section-rename-input'; - input.value = groupName; - input.onclick = (e) => e.stopPropagation(); - - const originalText = labelEl.textContent; - labelEl.textContent = ''; - labelEl.appendChild(input); - input.focus(); - input.select(); - - const finish = async (save) => { - const newName = input.value.trim(); - input.removeEventListener('blur', blurHandler); - if (!save || !newName || newName === groupName) { - labelEl.textContent = originalText; - return; - } - - const ids = []; - section.querySelectorAll('.automation-card').forEach(card => { - if (card.dataset.id) ids.push(parseInt(card.dataset.id)); - }); - - try { - const res = await fetch('/api/automations/group', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ automation_ids: ids, group_name: newName }) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast(`Renamed to "${newName}"`, 'success'); - await loadAutomations(); - } catch (err) { - showToast('Error: ' + err.message, 'error'); - labelEl.textContent = originalText; - } - }; - - input.addEventListener('keydown', (e) => { - e.stopPropagation(); - if (e.key === 'Enter') { e.preventDefault(); finish(true); } - if (e.key === 'Escape') { finish(false); } - }); - const blurHandler = () => finish(true); - input.addEventListener('blur', blurHandler); -} - -/** - * Bulk toggle all automations in a group. - */ -async function _bulkToggleGroup(groupName, currentlyAllEnabled) { - const ids = []; - document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { - if (card.dataset.id) ids.push(parseInt(card.dataset.id)); - }); - if (ids.length === 0) return; - - const targetEnabled = !currentlyAllEnabled; - try { - const res = await fetch('/api/automations/bulk-toggle', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ automation_ids: ids, enabled: targetEnabled }) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast(`${targetEnabled ? 'Enabled' : 'Disabled'} ${data.updated} automations`, 'success'); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -async function loadAutomations() { - const list = document.getElementById('automations-list'); - const empty = document.getElementById('automations-empty'); - const statsBar = document.getElementById('automations-stats'); - if (!list || !empty) return; - try { - const res = await fetch('/api/automations'); - const automations = await res.json(); - if (automations.error) throw new Error(automations.error); - if (!automations.length) { - list.innerHTML = ''; empty.style.display = ''; - if (statsBar) statsBar.innerHTML = ''; - return; - } - empty.style.display = 'none'; - list.innerHTML = ''; - - const systemAutos = automations.filter(a => a.is_system); - const userAutos = automations.filter(a => !a.is_system); - - if (systemAutos.length) { - list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true, { isProtected: true })); - } - - // Automation Hub section - list.appendChild(_buildAutomationHub()); - - // User automations — split by group - const groups = [...new Set(userAutos.filter(a => a.group_name).map(a => a.group_name))].sort(); - const ungrouped = userAutos.filter(a => !a.group_name); - groups.forEach(g => { - const groupAutos = userAutos.filter(a => a.group_name === g); - if (groupAutos.length) { - list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true, { groupName: g })); - } - }); - if (ungrouped.length) { - list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', ungrouped, true)); - } - - // Stats summary bar - if (statsBar) { - const total = automations.length; - const active = automations.filter(a => a.enabled).length; - const sys = systemAutos.length; - const custom = userAutos.length; - statsBar.innerHTML = ` - ${active} Active - ${sys} System - ${custom} Custom - `; - } - - // Filter bar — show when 6+ automations - _initAutoFilterBar(automations); - // Catch up on current automation progress - try { - const progRes = await fetch('/api/automations/progress'); - const progData = await progRes.json(); - if (!progData.error) updateAutomationProgressFromData(progData); - } catch (e) { } - } catch (err) { - list.innerHTML = ''; empty.style.display = ''; - if (statsBar) statsBar.innerHTML = ''; - } -} - -// --- Automation Hub --- - -function _buildAutomationHub() { - const section = document.createElement('div'); - section.className = 'automations-section'; - section.id = 'auto-section-hub'; - const collapsed = localStorage.getItem('auto_section_auto-section-hub') === '1'; - if (collapsed) section.classList.add('collapsed'); - const header = document.createElement('div'); - header.className = 'automations-section-header'; - header.innerHTML = ` - - - ${AUTO_HUB_GROUPS.length} pipelines · ${AUTO_HUB_RECIPES.length} recipes - - `; - header.onclick = () => { - section.classList.toggle('collapsed'); - localStorage.setItem('auto_section_auto-section-hub', section.classList.contains('collapsed') ? '1' : '0'); - }; - const body = document.createElement('div'); - body.className = 'automations-section-body'; - - const activeTab = localStorage.getItem('auto_hub_tab') || 'pipelines'; - const tabs = [ - { id: 'pipelines', label: 'Pipelines' }, - { id: 'recipes', label: 'Singles' }, - { id: 'guides', label: 'Quick Start' }, - { id: 'tips', label: 'Tips' }, - { id: 'reference', label: 'Reference' }, - ]; - - const tabBar = document.createElement('div'); - tabBar.className = 'auto-hub-tabs'; - tabs.forEach(t => { - const btn = document.createElement('button'); - btn.className = 'auto-hub-tab' + (t.id === activeTab ? ' active' : ''); - btn.textContent = t.label; - btn.dataset.tab = t.id; - btn.onclick = (e) => { e.stopPropagation(); _switchHubTab(t.id, body); }; - tabBar.appendChild(btn); - }); - body.appendChild(tabBar); - - // Build all tab contents - const pipelinesPane = _buildHubPipelines(); - pipelinesPane.id = 'auto-hub-pane-pipelines'; - pipelinesPane.className = 'auto-hub-tab-content' + (activeTab === 'pipelines' ? ' active' : ''); - body.appendChild(pipelinesPane); - - const recipesPane = _buildHubRecipes(); - recipesPane.id = 'auto-hub-pane-recipes'; - recipesPane.className = 'auto-hub-tab-content' + (activeTab === 'recipes' ? ' active' : ''); - body.appendChild(recipesPane); - - const guidesPane = _buildHubGuides(); - guidesPane.id = 'auto-hub-pane-guides'; - guidesPane.className = 'auto-hub-tab-content' + (activeTab === 'guides' ? ' active' : ''); - body.appendChild(guidesPane); - - const tipsPane = _buildHubTips(); - tipsPane.id = 'auto-hub-pane-tips'; - tipsPane.className = 'auto-hub-tab-content' + (activeTab === 'tips' ? ' active' : ''); - body.appendChild(tipsPane); - - const refPane = _buildHubReference(); - refPane.id = 'auto-hub-pane-reference'; - refPane.className = 'auto-hub-tab-content' + (activeTab === 'reference' ? ' active' : ''); - body.appendChild(refPane); - - section.appendChild(header); - section.appendChild(body); - return section; -} - -function _switchHubTab(tabId, bodyEl) { - const container = bodyEl || document.querySelector('#auto-section-hub .automations-section-body'); - if (!container) return; - container.querySelectorAll('.auto-hub-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabId)); - container.querySelectorAll('.auto-hub-tab-content').forEach(p => p.classList.toggle('active', p.id === 'auto-hub-pane-' + tabId)); - localStorage.setItem('auto_hub_tab', tabId); -} - -function _buildHubPipelines() { - const pane = document.createElement('div'); - - const intro = document.createElement('div'); - intro.className = 'auto-hub-pipeline-intro'; - intro.innerHTML = 'One-click deployment — each pipeline creates multiple linked automations that work together.'; - pane.appendChild(intro); - - const grid = document.createElement('div'); - grid.className = 'auto-hub-pipeline-grid'; - - AUTO_HUB_GROUPS.forEach(group => { - const card = document.createElement('div'); - card.className = 'auto-hub-pipeline-card'; - card.style.setProperty('--pipeline-color', group.color); - - // Pipeline flow visualization - const stepsHtml = group.steps.map((step, i) => { - const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action'; - return (i > 0 ? '' : '') + - `
- ${step.icon} - ${step.label} -
`; - }).join(''); - - card.innerHTML = ` -
- ${group.icon} -
-
${group.name}
- ${group.badge} -
-
-
${group.desc}
-
${stepsHtml}
- - `; - - card.addEventListener('click', (e) => { - if (e.target.closest('.pipeline-deploy-btn')) return; - showPipelineDetail(group.id); - }); - - grid.appendChild(card); - }); - - pane.appendChild(grid); - return pane; -} - -function showPipelineDetail(groupId) { - const group = AUTO_HUB_GROUPS.find(g => g.id === groupId); - if (!group) return; - - // Build automation detail list - const autoDetails = group.automations.map((auto, i) => { - const triggerLabel = _autoFormatTrigger(auto.trigger_type, auto.trigger_config); - const actionLabel = _autoFormatAction(auto.action_type); - const thenLabels = auto.then_actions.map(t => { - if (t.type === 'fire_signal') return `⚡ Signal: ${t.config.signal_name}`; - return _autoFormatNotify(t.type); - }); - if (auto.needs_notify) thenLabels.push('🔔 Your notification'); - - return ` -
-
${i + 1}
-
-
${auto.name}
-
- WHEN - ${_esc(triggerLabel)} - DO - ${_esc(actionLabel)} - ${thenLabels.length ? `THEN${thenLabels.map(t => _esc(t)).join(', ')}` : ''} -
-
-
`; - }).join(''); - - // Build flow diagram - const flowHtml = group.steps.map((step, i) => { - const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action'; - return (i > 0 ? '' : '') + - `
- ${step.icon} - ${step.label} -
`; - }).join(''); - - const overlay = document.createElement('div'); - overlay.className = 'pipeline-detail-overlay'; - overlay.innerHTML = ` -
- -
- ${group.icon} -
-
${group.name}
-
${group.desc}
-
-
-
${flowHtml}
-
How It Works
-
This pipeline deploys ${group.automations.length} automations${group.automations.some(a => a.then_actions.some(t => t.type === 'fire_signal')) ? ' linked by signals — each step triggers the next automatically' : ' running on independent schedules'}.
-
${autoDetails}
- -
- `; - - overlay.addEventListener('click', (e) => { - if (e.target === overlay) overlay.remove(); - }); - - document.body.appendChild(overlay); -} - -function _buildHubRecipes() { - const pane = document.createElement('div'); - const categories = ['All', 'Sync', 'Discovery', 'Maintenance', 'Alerts', 'Chains']; - const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced']; - - let activeCat = 'All', activeDiff = 'All'; - - // Category filters - const catFilters = document.createElement('div'); - catFilters.className = 'auto-hub-filters'; - categories.forEach(c => { - const pill = document.createElement('button'); - pill.className = 'auto-hub-filter-pill' + (c === 'All' ? ' active' : ''); - pill.textContent = c; - pill.dataset.filter = c; - pill.dataset.filterType = 'category'; - pill.onclick = () => { - activeCat = c; - catFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === c)); - filterRecipes(); - }; - catFilters.appendChild(pill); - }); - pane.appendChild(catFilters); - - // Difficulty filters - const diffFilters = document.createElement('div'); - diffFilters.className = 'auto-hub-filters'; - difficulties.forEach(d => { - const pill = document.createElement('button'); - pill.className = 'auto-hub-filter-pill' + (d === 'All' ? ' active' : ''); - pill.textContent = d; - pill.dataset.filter = d; - pill.dataset.filterType = 'difficulty'; - pill.onclick = () => { - activeDiff = d; - diffFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === d)); - filterRecipes(); - }; - diffFilters.appendChild(pill); - }); - pane.appendChild(diffFilters); - - const grid = document.createElement('div'); - grid.className = 'auto-hub-recipes-grid'; - - AUTO_HUB_RECIPES.forEach(r => { - const card = document.createElement('div'); - card.className = 'auto-hub-recipe-card'; - card.dataset.category = r.category; - card.dataset.difficulty = r.difficulty; - - const trigLabel = _autoFormatTrigger(r.when.type, r.when.config); - const actLabel = _autoFormatAction(r.do.type); - - let chainHTML = ''; - if (r.chain) { - chainHTML = '
' + r.chain.map((step, i) => { - let cls = 'flow-action'; - if (i === 0) cls = 'flow-trigger'; - else if (step.startsWith('\u26A1')) cls = 'flow-notify'; - return (i > 0 ? '' : '') + - `${_esc(step)}`; - }).join('') + '
'; - } else { - chainHTML = `
- ${_esc(trigLabel)} - - ${_esc(actLabel)} - ${r.then.length ? r.then.map(th => `${_esc(_autoFormatNotify(th.type))}`).join('') : ''} -
`; - } - - card.innerHTML = ` -
-
${r.icon}
-
${_esc(r.name)}
- ${_esc(r.difficulty)} -
-
${_esc(r.desc)}
- ${chainHTML} - ${r.note ? `
${_esc(r.note)}
` : ''} - - `; - card.onclick = () => useHubRecipe(r.id); - grid.appendChild(card); - }); - pane.appendChild(grid); - - function filterRecipes() { - grid.querySelectorAll('.auto-hub-recipe-card').forEach(card => { - const catMatch = activeCat === 'All' || card.dataset.category === activeCat; - const diffMatch = activeDiff === 'All' || card.dataset.difficulty === activeDiff.toLowerCase(); - card.style.display = (catMatch && diffMatch) ? '' : 'none'; - }); - } - - return pane; -} - -function _buildHubGuides() { - const pane = document.createElement('div'); - - const callout = document.createElement('div'); - callout.className = 'auto-hub-callout'; - callout.innerHTML = '\uD83D\uDCA1Click any guide to expand step-by-step instructions. Related recipes let you jump straight to a pre-filled template.'; - pane.appendChild(callout); - - AUTO_HUB_GUIDES.forEach(g => { - const card = document.createElement('div'); - card.className = 'auto-hub-guide-card'; - - const headerEl = document.createElement('div'); - headerEl.className = 'auto-hub-guide-header'; - headerEl.innerHTML = ` - ${g.icon} - ${_esc(g.title)} - ${_esc(g.difficulty)} - - `; - headerEl.onclick = () => card.classList.toggle('expanded'); - card.appendChild(headerEl); - - const bodyEl = document.createElement('div'); - bodyEl.className = 'auto-hub-guide-body'; - bodyEl.innerHTML = ` -
${_esc(g.subtitle)}
-
    ${g.steps.map(s => `
  1. ${s}
  2. `).join('')}
- ${g.relatedRecipes.length ? ` - - ` : ''} - `; - card.appendChild(bodyEl); - pane.appendChild(card); - }); - - return pane; -} - -function _buildHubTips() { - const pane = document.createElement('div'); - - const callout = document.createElement('div'); - callout.className = 'auto-hub-callout'; - callout.innerHTML = '\u2728Power-user tips to get the most out of your automations.'; - pane.appendChild(callout); - - const grid = document.createElement('div'); - grid.className = 'auto-hub-tips-grid'; - AUTO_HUB_TIPS.forEach(t => { - const card = document.createElement('div'); - card.className = 'auto-hub-tip-card'; - card.innerHTML = ` -
- ${t.icon} - ${_esc(t.title)} - ${_esc(t.tag)} -
-
${t.body}
- `; - grid.appendChild(card); - }); - pane.appendChild(grid); - return pane; -} - -function _buildHubReference() { - const pane = document.createElement('div'); - const sections = [ - { label: 'Triggers (WHEN)', data: AUTO_HUB_REFERENCE.triggers }, - { label: 'Actions (DO)', data: AUTO_HUB_REFERENCE.actions }, - { label: 'Then Actions (THEN)', data: AUTO_HUB_REFERENCE.thenActions }, - ]; - - sections.forEach(sec => { - const totalItems = sec.data.reduce((n, g) => n + g.items.length, 0); - const group = document.createElement('div'); - group.className = 'auto-hub-ref-group'; - - const header = document.createElement('div'); - header.className = 'auto-hub-ref-group-header'; - header.innerHTML = ` - ${_esc(sec.label)} - ${totalItems} - - `; - header.onclick = () => group.classList.toggle('expanded'); - group.appendChild(header); - - const body = document.createElement('div'); - body.className = 'auto-hub-ref-body'; - sec.data.forEach(sub => { - body.innerHTML += `
${_esc(sub.group)}
`; - let tableHTML = ''; - sub.items.forEach(item => { - tableHTML += ``; - }); - tableHTML += '
TypeDescription
${_esc(item.label)}${_esc(item.desc)}
'; - body.innerHTML += tableHTML; - }); - group.appendChild(body); - pane.appendChild(group); - }); - return pane; -} - -async function useHubRecipe(recipeId) { - const t = AUTO_HUB_RECIPES.find(r => r.id === recipeId); - if (!t) return; - await showAutomationBuilder(); - document.getElementById('builder-name').value = t.name; - _autoBuilder.when = { type: t.when.type, config: JSON.parse(JSON.stringify(t.when.config)) }; - _autoBuilder.do = { type: t.do.type, config: JSON.parse(JSON.stringify(t.do.config)) }; - _autoBuilder.then = t.then.map(th => ({ type: th.type, config: JSON.parse(JSON.stringify(th.config)) })); - _renderBuilderSidebar(); - _renderBuilderCanvas(); - if (t.note) { - showToast(t.note, 'info'); - } -} - -async function deployHubGroup(groupId) { - const group = AUTO_HUB_GROUPS.find(g => g.id === groupId); - if (!group) return; - - // Check if any automations need notifications — prompt for config - const needsNotify = group.automations.some(a => a.needs_notify); - let notifyConfig = null; - - if (needsNotify) { - notifyConfig = await _promptNotifyConfig(group.name); - if (notifyConfig === null) return; // User cancelled - if (notifyConfig === false) notifyConfig = null; // Skip notifications, still deploy - } - - // Deploy all automations in the group - let created = 0, failed = 0; - for (const auto of group.automations) { - try { - const payload = { - name: auto.name, - trigger_type: auto.trigger_type, - trigger_config: auto.trigger_config, - action_type: auto.action_type, - action_config: auto.action_config, - then_actions: [...auto.then_actions], - group_name: auto.group_name, - enabled: true, - }; - - // Inject notification config for automations that need it - if (auto.needs_notify && notifyConfig) { - payload.then_actions.push(notifyConfig); - } - - const response = await fetch('/api/automations', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - - if (response.ok) { - created++; - } else { - const err = await response.json(); - console.error(`Failed to create "${auto.name}":`, err); - failed++; - } - } catch (e) { - console.error(`Error creating "${auto.name}":`, e); - failed++; - } - } - - if (created > 0) { - showToast(`Deployed "${group.name}" — ${created} automation${created > 1 ? 's' : ''} created${failed ? `, ${failed} failed` : ''}`, 'success'); - loadAutomations(); - } else { - showToast(`Failed to deploy "${group.name}"`, 'error'); - } -} - -function _promptNotifyConfig(groupName) { - return new Promise(resolve => { - const overlay = document.createElement('div'); - 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 = ` -
-

Configure Notifications

-

${groupName} includes notification steps. Choose how to get notified.

-
- - -
-
-
- - -
-
- `; - - document.body.appendChild(overlay); - - const typeSelect = overlay.querySelector('#deploy-notify-type'); - const fieldsDiv = overlay.querySelector('#deploy-notify-fields'); - - function updateFields() { - const type = typeSelect.value; - if (type === 'discord_webhook') { - fieldsDiv.innerHTML = ''; - } else if (type === 'telegram') { - fieldsDiv.innerHTML = ''; - } else if (type === 'pushbullet') { - fieldsDiv.innerHTML = ''; - } else { - fieldsDiv.innerHTML = ''; - } - } - typeSelect.addEventListener('change', updateFields); - updateFields(); - - overlay.querySelector('#deploy-notify-cancel').addEventListener('click', () => { - document.body.removeChild(overlay); - resolve(null); - }); - - overlay.querySelector('#deploy-notify-confirm').addEventListener('click', () => { - const type = typeSelect.value; - let config = {}; - if (type === 'discord_webhook') { - config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; - } else if (type === 'telegram') { - config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() }; - } else if (type === 'pushbullet') { - config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; - } else { - document.body.removeChild(overlay); - resolve(false); // Skip notifications but still deploy - return; - } - document.body.removeChild(overlay); - resolve({ type, config }); - }); - - overlay.addEventListener('click', (e) => { - if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); } - }); - }); -} - -// --- Filter Bar --- -function _initAutoFilterBar(automations) { - const bar = document.getElementById('auto-filter-bar'); - if (!bar) return; - if (automations.length < 7) { bar.style.display = 'none'; return; } - bar.style.display = ''; - - // Populate trigger dropdown - const trigSel = document.getElementById('auto-filter-trigger'); - const actSel = document.getElementById('auto-filter-action'); - const trigTypes = [...new Set(automations.map(a => a.trigger_type))].sort(); - const actTypes = [...new Set(automations.map(a => a.action_type))].sort(); - const prevTrig = trigSel.value; - const prevAct = actSel.value; - trigSel.innerHTML = '' + trigTypes.map(t => - ``).join(''); - actSel.innerHTML = '' + actTypes.map(t => - ``).join(''); - trigSel.value = prevTrig; - actSel.value = prevAct; - - // Bind events (use a flag to avoid double-binding) - if (!bar.dataset.bound) { - bar.dataset.bound = '1'; - document.getElementById('auto-filter-search').addEventListener('input', _filterAutomations); - trigSel.addEventListener('change', _filterAutomations); - actSel.addEventListener('change', _filterAutomations); - } - _filterAutomations(); -} - -function _filterAutomations() { - const q = (document.getElementById('auto-filter-search').value || '').toLowerCase().trim(); - const trigFilter = document.getElementById('auto-filter-trigger').value; - const actFilter = document.getElementById('auto-filter-action').value; - const cards = document.querySelectorAll('#automations-list .automation-card'); - let visible = 0; - cards.forEach(card => { - const name = (card.querySelector('.automation-name')?.textContent || '').toLowerCase(); - const trig = card.querySelector('.flow-trigger')?.textContent || ''; - const act = card.querySelector('.flow-action')?.textContent || ''; - // Match search text against name, trigger label, action label - const matchQ = !q || name.includes(q) || trig.toLowerCase().includes(q) || act.toLowerCase().includes(q); - // Match trigger/action type filters using data attributes - const matchTrig = !trigFilter || card.dataset.triggerType === trigFilter; - const matchAct = !actFilter || card.dataset.actionType === actFilter; - const show = matchQ && matchTrig && matchAct; - card.style.display = show ? '' : 'none'; - if (show) visible++; - }); - const countEl = document.getElementById('auto-filter-count'); - if (countEl) { - countEl.textContent = (q || trigFilter || actFilter) ? `${visible} of ${cards.length}` : ''; - } -} - -// --- Group Dropdown --- -let _activeGroupDropdown = null; - -function _showGroupDropdown(event, autoId, currentGroup) { - // Close any existing dropdown - _closeGroupDropdown(); - - const btn = event.currentTarget; - const card = btn.closest('.automation-card'); - if (!card) return; - - // Collect all existing group names from visible cards - const allGroups = new Set(); - document.querySelectorAll('#automations-list .automation-card .automation-group-btn[data-group]').forEach(b => { - const g = b.dataset.group; - if (g) allGroups.add(g); - }); - - const dropdown = document.createElement('div'); - dropdown.className = 'auto-group-dropdown'; - - let html = ''; - if (currentGroup) { - html += `
Remove from group
`; - html += '
'; - } - allGroups.forEach(g => { - const isActive = g === currentGroup; - html += `
${_esc(g)}
`; - }); - if (allGroups.size) html += '
'; - html += ``; - - dropdown.innerHTML = html; - - // Position dropdown on document.body to avoid overflow:hidden clipping - const rect = btn.getBoundingClientRect(); - dropdown.style.position = 'fixed'; - dropdown.style.right = (window.innerWidth - rect.right) + 'px'; - dropdown.style.left = 'auto'; - document.body.appendChild(dropdown); - _activeGroupDropdown = dropdown; - - // Open upward if not enough room below - const dropdownHeight = dropdown.offsetHeight; - if (rect.bottom + 4 + dropdownHeight > window.innerHeight && rect.top - 4 - dropdownHeight > 0) { - dropdown.style.top = (rect.top - 4 - dropdownHeight) + 'px'; - } else { - dropdown.style.top = (rect.bottom + 4) + 'px'; - } - - // Focus the input - setTimeout(() => dropdown.querySelector('.auto-group-input')?.focus(), 50); - - // Close on outside click - const handler = (e) => { - if (!dropdown.contains(e.target) && e.target !== btn) { - _closeGroupDropdown(); - document.removeEventListener('click', handler, true); - } - }; - setTimeout(() => document.addEventListener('click', handler, true), 10); -} - -function _closeGroupDropdown() { - if (_activeGroupDropdown) { - _activeGroupDropdown.remove(); - _activeGroupDropdown = null; - } -} - -async function _assignGroup(autoId, groupName) { - _closeGroupDropdown(); - try { - const res = await fetch('/api/automations/' + autoId, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_name: groupName || null }) - }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast(groupName ? `Moved to "${groupName}"` : 'Removed from group', 'success'); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -function renderAutomationCard(a) { - const card = document.createElement('div'); - card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : ''); - card.dataset.id = a.id; - card.dataset.triggerType = a.trigger_type || ''; - card.dataset.actionType = a.action_type || ''; - - // Drag-and-drop (non-system only) - if (!a.is_system) { - card.draggable = true; - card.addEventListener('dragstart', (e) => { - _autoDragState = { id: a.id, groupName: a.group_name || null }; - e.dataTransfer.setData('text/plain', String(a.id)); - e.dataTransfer.effectAllowed = 'move'; - card.classList.add('dragging'); - // Dim protected sections during drag - document.querySelectorAll('.section-protected').forEach(s => s.classList.add('no-drop')); - }); - card.addEventListener('dragend', () => { - card.classList.remove('dragging'); - _autoDragState = null; - _autoDragEnterCount = 0; - document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); - document.querySelectorAll('.no-drop').forEach(el => el.classList.remove('no-drop')); - if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } - }); - } - const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F'; - const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F'; - const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config); - const al = aIcon + ' ' + _autoFormatAction(a.action_type); - const thenItems = a.then_actions || []; - const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0; - const metaParts = []; - if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run)); - const _timerTriggers = ['schedule', 'daily_time', 'weekly_time']; - if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run) + ''); - if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening'); - if (a.run_count) metaParts.push('Runs: ' + a.run_count + ''); - if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error)); - - const dupeBtn = a.is_system ? '' : - ``; - const groupBtn = a.is_system ? '' : - ``; - const deleteBtn = a.is_system ? '' : - ``; - - card.innerHTML = ` -
-
-
${_esc(a.name)}
-
- ${_esc(tl)} - - ${actionDelay ? `\u23F3 ${actionDelay}m` : ''} - ${_esc(al)} - ${thenItems.length ? thenItems.map(t => `${_esc(_autoFormatNotify(t.type))}`).join('') : ''} -
-
${metaParts.join(' · ')}
-
-
- - - - ${dupeBtn} - ${groupBtn} - ${deleteBtn} -
- `; - return card; -} - -function _autoFormatTrigger(type, config) { - if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours'); - if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00'); - if (type === 'weekly_time' && config) { - const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', '); - return (days || 'Every day') + ' at ' + (config.time || '00:00'); - } - if (type === 'signal_received' && config) { - const sig = config.signal_name || 'unknown'; - return 'Signal: ' + sig; - } - const labels = { - app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete', - watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced', - playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete', - wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done', - database_update_completed: 'Database Updated', download_failed: 'Download Failed', - download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added', - watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched', - import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored', - quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done', - library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' - }; - let label = labels[type] || type || 'Unknown'; - if (config && config.conditions && config.conditions.length) { - const first = config.conditions[0]; - label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' + - (config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')'; - } - return label; -} -function _autoFormatAction(type) { - const labels = { - process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist', - scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored', - sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist', - notify_only: 'Notify Only', - start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner', - clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist', - update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan', - backup_database: 'Backup Database', - refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History', - clean_completed_downloads: 'Clean Completed Downloads', - full_cleanup: 'Full Cleanup', - playlist_pipeline: 'Playlist Pipeline' - }; - return labels[type] || type || 'Unknown'; -} -function _autoFormatNotify(type) { - if (type === 'discord_webhook') return 'Discord'; - if (type === 'pushbullet') return 'Pushbullet'; - if (type === 'telegram') return 'Telegram'; - if (type === 'fire_signal') return '\u26A1 Signal'; - if (type === 'run_script') return '\uD83D\uDCBB Script'; - return type || ''; -} -function _autoParseUTC(ts) { - // If timestamp already has timezone info (+00:00 or Z), parse as-is; otherwise append Z to treat as UTC - if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime(); - return new Date(ts + 'Z').getTime(); -} -function _autoTimeAgo(ts) { - if (!ts) return 'Never'; - const d = (Date.now() - _autoParseUTC(ts)) / 1000; - if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d / 60) + 'm ago'; - if (d < 86400) return Math.floor(d / 3600) + 'h ago'; return Math.floor(d / 86400) + 'd ago'; -} -function _autoTimeUntil(ts) { - if (!ts) return ''; - const d = (_autoParseUTC(ts) - Date.now()) / 1000; - if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's'; - if (d < 3600) return 'in ' + Math.ceil(d / 60) + 'm'; if (d < 86400) return 'in ' + Math.round(d / 3600) + 'h'; - return 'in ' + Math.round(d / 86400) + 'd'; -} - -// --- Live countdown for "Next: in Xs" --- -setInterval(() => { - document.querySelectorAll('.auto-next-run[data-next]').forEach(el => { - el.textContent = 'Next: ' + _autoTimeUntil(el.dataset.next); - }); -}, 1000); - -// --- CRUD --- - -async function deleteAutomation(id, name) { - if (!await showConfirmDialog({ title: 'Delete Automation', message: `Delete automation "${name}"?`, confirmText: 'Delete', destructive: true })) return; - try { - const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast('Automation deleted', 'success'); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -async function duplicateAutomation(id) { - try { - const res = await fetch('/api/automations/' + id + '/duplicate', { method: 'POST' }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast('Automation duplicated', 'success'); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -async function toggleAutomation(id) { - try { - const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -// --- Automation Progress Tracking --- -const _autoProgressLogCounts = {}; -const _autoProgressHideTimers = {}; - -function updateAutomationProgressFromData(data) { - for (const [aidStr, state] of Object.entries(data)) { - const aid = parseInt(aidStr); - const card = document.querySelector(`.automation-card[data-id="${aid}"]`); - if (!card) continue; - - let panel = card.querySelector('.automation-output'); - if (!panel) { - panel = document.createElement('div'); - panel.className = 'automation-output'; - panel.innerHTML = ` -
-
-
- `; - card.appendChild(panel); - _autoProgressLogCounts[aid] = 0; - } - - // Update progress bar - const bar = panel.querySelector('.auto-progress-bar'); - bar.style.width = (state.progress || 0) + '%'; - - // Update phase text - const phaseEl = panel.querySelector('.auto-progress-phase'); - phaseEl.textContent = state.phase || ''; - - // Status indicator on card - const statusDot = card.querySelector('.automation-status'); - - if (state.status === 'running') { - if (statusDot) statusDot.className = 'automation-status running'; - card.classList.add('running'); - panel.classList.add('visible'); - panel.classList.remove('finished', 'error'); - if (_autoProgressHideTimers[aid]) { - clearTimeout(_autoProgressHideTimers[aid]); - delete _autoProgressHideTimers[aid]; - } - // Reset log for new run (handles re-run within hide window) - if (_autoProgressLogCounts[aid] > 0 && state.log && state.log.length < _autoProgressLogCounts[aid]) { - const existingLog = panel.querySelector('.auto-progress-log'); - if (existingLog) existingLog.innerHTML = ''; - _autoProgressLogCounts[aid] = 0; - } - } else if (state.status === 'finished' || state.status === 'error') { - if (statusDot) statusDot.className = 'automation-status ' + (card.querySelector('input[type=checkbox]')?.checked ? 'enabled' : 'disabled'); - card.classList.remove('running'); - bar.style.width = '100%'; - panel.classList.add('finished'); - if (state.status === 'error') panel.classList.add('error'); - if (!_autoProgressHideTimers[aid]) { - _autoProgressHideTimers[aid] = setTimeout(() => { - panel.classList.remove('visible'); - delete _autoProgressHideTimers[aid]; - _autoProgressLogCounts[aid] = 0; - }, 30000); - } - } - - // Update log lines - const logEl = panel.querySelector('.auto-progress-log'); - const rendered = _autoProgressLogCounts[aid] || 0; - const logLines = state.log || []; - if (logLines.length > rendered) { - // Normal append — log is still growing - for (let i = rendered; i < logLines.length; i++) { - const line = logLines[i]; - const div = document.createElement('div'); - div.className = 'auto-log-line ' + (line.type || 'info'); - div.textContent = line.text; - logEl.appendChild(div); - } - _autoProgressLogCounts[aid] = logLines.length; - logEl.scrollTop = logEl.scrollHeight; - } else if (logLines.length === rendered && logLines.length >= 50) { - // Log buffer is full and rotating — replace last few lines - const children = logEl.children; - if (children.length > 0) { - const lastServerLine = logLines[logLines.length - 1]; - const lastDomLine = children[children.length - 1]; - if (lastServerLine && lastDomLine.textContent !== lastServerLine.text) { - // Content changed — full re-render - logEl.innerHTML = ''; - for (const line of logLines) { - const div = document.createElement('div'); - div.className = 'auto-log-line ' + (line.type || 'info'); - div.textContent = line.text; - logEl.appendChild(div); - } - _autoProgressLogCounts[aid] = logLines.length; - logEl.scrollTop = logEl.scrollHeight; - } - } - } - } -} - -async function runAutomation(id) { - try { - const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' }); - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast('Automation triggered', 'success'); - setTimeout(() => loadAutomations(), 1500); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -const _RESULT_DISPLAY_MAP = { - 'start_database_update': [ - { key: 'artists', label: 'Artists' }, - { key: 'albums', label: 'Albums' }, - { key: 'tracks', label: 'Tracks' }, - { key: 'removed_artists', label: 'Removed Artists', hideZero: true }, - { key: 'removed_albums', label: 'Removed Albums', hideZero: true }, - { key: 'removed_tracks', label: 'Removed Tracks', hideZero: true }, - ], - 'deep_scan_library': [ - { key: 'artists', label: 'Artists' }, - { key: 'albums', label: 'Albums' }, - { key: 'tracks', label: 'Tracks' }, - { key: 'removed_artists', label: 'Removed Artists', hideZero: true }, - { key: 'removed_albums', label: 'Removed Albums', hideZero: true }, - { key: 'removed_tracks', label: 'Removed Tracks', hideZero: true }, - ], - 'scan_watchlist': [ - { key: 'artists_scanned', label: 'Artists Scanned' }, - { key: 'successful_scans', label: 'Successful' }, - { key: 'new_tracks_found', label: 'New Tracks' }, - { key: 'tracks_added_to_wishlist', label: 'Added to Wishlist' }, - ], - 'run_duplicate_cleaner': [ - { key: 'files_scanned', label: 'Files Scanned' }, - { key: 'duplicates_found', label: 'Duplicates Found' }, - { key: 'files_deleted', label: 'Files Deleted' }, - { key: 'space_freed_mb', label: 'Space Freed (MB)' }, - ], - 'start_quality_scan': [ - { key: 'tracks_scanned', label: 'Tracks Scanned' }, - { key: 'quality_met', label: 'Quality Met' }, - { key: 'low_quality', label: 'Low Quality' }, - { key: 'matched', label: 'Added to Wishlist' }, - ], - 'scan_library': [ - { key: 'scan_duration_seconds', label: 'Duration (s)' }, - ], - 'backup_database': [ - { key: 'size_mb', label: 'Backup Size (MB)' }, - ], - 'refresh_mirrored': [ - { key: 'refreshed', label: 'Playlists Refreshed' }, - { key: 'errors', label: 'Errors', hideZero: true }, - ], - 'clear_quarantine': [ - { key: 'removed', label: 'Items Removed' }, - ], - 'cleanup_wishlist': [ - { key: 'removed', label: 'Duplicates Removed' }, - ], - 'full_cleanup': [ - { key: 'quarantine_removed', label: 'Quarantine Removed' }, - { key: 'staging_removed', label: 'Import Dirs Removed' }, - { key: 'total_removed', label: 'Total Items Removed' }, - ], - 'playlist_pipeline': [ - { key: 'playlists_refreshed', label: 'Refreshed' }, - { key: 'tracks_discovered', label: 'Discovered' }, - { key: 'tracks_synced', label: 'Synced' }, - { key: 'sync_skipped', label: 'Skipped', hideZero: true }, - { key: 'wishlist_queued', label: 'Wishlist Queued' }, - { key: 'duration_seconds', label: 'Duration (s)' }, - ], -}; - -function _renderResultStats(resultJson, actionType) { - if (!resultJson || typeof resultJson !== 'object') return ''; - var fields = _RESULT_DISPLAY_MAP[actionType]; - var items = []; - if (fields) { - fields.forEach(function (f) { - var val = resultJson[f.key]; - if (val == null) return; - if (f.hideZero && (val === 0 || val === '0')) return; - items.push({ label: f.label, value: val }); - }); - } else { - // Generic fallback: show all non-status, non-underscore keys - Object.keys(resultJson).forEach(function (k) { - if (k === 'status' || k.startsWith('_')) return; - var label = k.replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); - items.push({ label: label, value: resultJson[k] }); - }); - } - if (items.length === 0) return ''; - var html = '
'; - items.forEach(function (it) { - html += '
' + _esc(it.label) + '
' + _esc(String(it.value)) + '
'; - }); - html += '
'; - return html; -} - -async function showAutomationHistory(automationId, automationName, actionType) { - let modal = document.getElementById('automation-history-modal'); - if (!modal) { - modal = document.createElement('div'); - modal.id = 'automation-history-modal'; - modal.className = 'modal-overlay'; - document.body.appendChild(modal); - } - modal.innerHTML = ''; - modal.style.display = 'flex'; - modal.onclick = function (e) { if (e.target === modal) modal.style.display = 'none'; }; - - try { - const res = await fetch('/api/automations/' + automationId + '/history?limit=50'); - const data = await res.json(); - if (data.error) throw new Error(data.error); - const body = modal.querySelector('.history-modal-body'); - if (!data.history || data.history.length === 0) { - body.innerHTML = '
No run history yet. History will be recorded on future runs.
'; - return; - } - let html = '
'; - data.history.forEach(function (entry) { - const statusClass = 'history-status-' + (entry.status || 'completed'); - const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1); - const timeAgo = _autoTimeAgo(entry.started_at); - const duration = entry.duration_seconds != null ? _formatDuration(entry.duration_seconds) : ''; - const summary = entry.summary ? _esc(entry.summary) : ''; - const hasLogs = entry.log_lines && entry.log_lines.length > 0; - const entryId = 'history-entry-' + entry.id; - - html += '
'; - html += '
'; - html += '' + statusLabel + ''; - html += '' + timeAgo + ''; - if (duration) html += '' + duration + ''; - if (hasLogs) html += ''; - html += '
'; - if (summary) html += '
' + summary + '
'; - if (entry.result_json && typeof entry.result_json === 'object') { - html += _renderResultStats(entry.result_json, actionType); - } - if (hasLogs) { - html += '
'; - entry.log_lines.forEach(function (log) { - html += '
' + _esc(log.text || '') + '
'; - }); - html += '
'; - } - html += '
'; - }); - html += '
'; - if (data.total > data.history.length) { - html += '
Showing ' + data.history.length + ' of ' + data.total + ' runs
'; - } - body.innerHTML = html; - } catch (err) { - const body = modal.querySelector('.history-modal-body'); - if (body) body.innerHTML = '
Error loading history: ' + _esc(err.message) + '
'; - } -} - -function _formatDuration(seconds) { - if (seconds < 1) return '<1s'; - if (seconds < 60) return Math.round(seconds) + 's'; - var m = Math.floor(seconds / 60); - var s = Math.round(seconds % 60); - if (m < 60) return m + 'm ' + s + 's'; - var h = Math.floor(m / 60); - m = m % 60; - return h + 'h ' + m + 'm'; -} - -async function saveAutomation() { - const name = document.getElementById('builder-name').value.trim(); - if (!name) { showToast('Name is required', 'error'); return; } - if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; } - if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; } - - // Read configs from DOM - const triggerConfig = _readPlacedConfig('when'); - const actionConfig = _readPlacedConfig('do'); - - // Read THEN actions (multi-slot) - const thenActions = _autoBuilder.then.map((item, i) => ({ - type: item.type, - config: _readPlacedConfig('then-' + i), - })); - - // Read optional delay from DO slot - const delayEl = document.getElementById('cfg-do-delay'); - const delayVal = delayEl ? parseInt(delayEl.value) : 0; - if (delayVal > 0) actionConfig.delay = delayVal; - - const groupInput = document.getElementById('builder-group-name'); - const groupName = groupInput ? groupInput.value.trim() : ''; - - const body = { - name, - trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig, - action_type: _autoBuilder.do.type, action_config: actionConfig, - then_actions: thenActions, - group_name: groupName || null, - }; - - try { - let res; - if (_autoBuilder.editId) { - res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - } else { - res = await fetch('/api/automations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - } - const data = await res.json(); - if (data.error) throw new Error(data.error); - showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success'); - hideAutomationBuilder(); - await loadAutomations(); - } catch (err) { showToast('Error: ' + err.message, 'error'); } -} - -// --- Builder View --- - -async function showAutomationBuilder(editId) { - // Load block definitions (always refresh) - try { - const res = await fetch('/api/automations/blocks'); - _autoBlocks = await res.json(); - } catch (e) { - if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; } - } - - _autoMirroredPlaylists = null; // invalidate so it re-fetches - _autoSpotifyAuthenticated = false; - _autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false }; - - // Populate group datalist from existing automations - try { - const allRes = await fetch('/api/automations'); - const allAutos = await allRes.json(); - const groupSet = new Set(); - if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); }); - const datalist = document.getElementById('builder-group-list'); - if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `' + - data.scripts.map(s => ``).join(''); - } - } catch (e) { console.warn('Failed to load scripts:', e); } - }, 100); - return `
- - -
-
- - seconds -
-
Place scripts in the scripts/ folder. Supported: .sh, .py, .bat, .ps1
`; - } - if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { - return '
No configuration needed
'; - } - if (blockType === 'refresh_mirrored') { - const allChecked = config.all ? ' checked' : ''; - return `
- - -
-
- -
`; - } - if (blockType === 'sync_playlist') { - return `
- - -
`; - } - if (blockType === 'discover_playlist') { - const allChecked = config.all ? ' checked' : ''; - return `
- - -
-
- -
`; - } - if (blockType === 'playlist_pipeline') { - const allChecked = config.all ? ' checked' : ''; - const skipWishlistChecked = config.skip_wishlist ? ' checked' : ''; - return `
- - -
-
- -
-
- -
-
Runs 4 phases: Refresh → Discover → Sync → Download Missing
`; - } - // Shared variable tags builder for notification types - function _notifyVarHtml(slotKey) { - let allVars = ['time', 'name', 'run_count', 'status']; - const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null; - if (triggerDef && triggerDef.variables) { - triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); }); - } - let html = '
'; - allVars.forEach(v => { html += `{${v}}`; }); - return html + '
'; - } - - if (blockType === 'discord_webhook') { - const url = _escAttr(config.webhook_url || ''); - return `
- - -
-
- - -
- ${_notifyVarHtml(slotKey)}`; - } - if (blockType === 'pushbullet') { - const token = _escAttr(config.access_token || ''); - return `
- - -
-
- - -
-
- - -
- ${_notifyVarHtml(slotKey)}`; - } - if (blockType === 'telegram') { - const botToken = _escAttr(config.bot_token || ''); - const chatId = _escAttr(config.chat_id || ''); - return `
- - -
-
- - -
-
- - -
- ${_notifyVarHtml(slotKey)}`; - } - if (blockType === 'webhook') { - const url = _escAttr(config.url || ''); - const hdrs = (config.headers || '').replace(/"/g, '"'); - return `
- - -
-
- - -
-
- - -
-
- Sends a JSON POST with all event variables. Custom message added as "message" field if set. -
- ${_notifyVarHtml(slotKey)}`; - } - return ''; -} - -// --- Condition Builder --- - -function _renderConditionBuilder(slotKey, blockDef, config) { - const conditions = config.conditions || []; - const match = config.match || 'all'; - const fields = blockDef.condition_fields || []; - - let html = '
'; - html += `
- - -
`; - - html += '
'; - if (conditions.length) { - conditions.forEach((cond, i) => { - html += _renderConditionRow(slotKey, i, fields, cond); - }); - } - html += '
'; - - html += ``; - html += '
'; - - if (!conditions.length) { - html += '
No conditions = triggers on every event
'; - } - - return html; -} - -function _renderConditionRow(slotKey, index, fields, cond) { - const field = cond ? cond.field : (fields[0] || ''); - const operator = cond ? cond.operator : 'equals'; - const value = cond ? _escAttr(cond.value) : ''; - - let fieldOpts = ''; - fields.forEach(f => { fieldOpts += ``; }); - - // For playlist-related triggers, use a mirrored playlist dropdown instead of free text - const triggerType = _autoBuilder.when ? _autoBuilder.when.type : ''; - const usePlaylistSelect = ((triggerType === 'playlist_changed' || triggerType === 'discovery_completed') && field === 'playlist_name'); - const valueHtml = usePlaylistSelect - ? `` - : ``; - - return `
- - - ${valueHtml} - -
`; -} - -function _autoAddCondition(slotKey) { - const data = _autoBuilder[slotKey]; - if (!data) return; - if (!data.config) data.config = {}; - if (!data.config.conditions) data.config.conditions = []; - - // Save existing conditions from DOM before re-render - _autoSaveConditionsFromDOM(slotKey); - - const blockDef = _findBlockDef(data.type); - const fields = blockDef ? (blockDef.condition_fields || []) : []; - data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' }); - _renderBuilderCanvas(); - // Re-populate mirrored playlist selects if needed - _autoLoadMirroredSelects(); -} - -function _autoRemoveCondition(slotKey, index) { - const data = _autoBuilder[slotKey]; - if (!data || !data.config || !data.config.conditions) return; - _autoSaveConditionsFromDOM(slotKey); - data.config.conditions.splice(index, 1); - _renderBuilderCanvas(); - _autoLoadMirroredSelects(); -} - -function _autoSaveConditionsFromDOM(slotKey) { - const data = _autoBuilder[slotKey]; - if (!data || !data.config) return; - const container = document.getElementById('condition-rows-' + slotKey); - if (!container) return; - const rows = container.querySelectorAll('.condition-row'); - const conditions = []; - rows.forEach(row => { - const field = row.querySelector('.cond-field')?.value || ''; - const operator = row.querySelector('.cond-operator')?.value || 'contains'; - const value = row.querySelector('.cond-value')?.value || ''; - conditions.push({ field, operator, value }); - }); - data.config.conditions = conditions; - // Also save match mode - const matchEl = document.getElementById('cfg-' + slotKey + '-match'); - if (matchEl) data.config.match = matchEl.value; -} - -// --- Mirrored Playlist Select --- - -function _autoTogglePlaylistSelect(slotKey) { - const allCb = document.getElementById('cfg-' + slotKey + '-all'); - const sel = document.getElementById('cfg-' + slotKey + '-playlist_id'); - if (sel) sel.disabled = allCb && allCb.checked; -} - -async function _autoLoadMirroredSelects() { - const selects = document.querySelectorAll('.mirrored-playlist-select'); - const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select'); - if (!selects.length && !nameSelects.length) return; - - if (!_autoMirroredPlaylists) { - try { - const res = await fetch('/api/mirrored-playlists/list'); - const data = await res.json(); - // New format returns { playlists, spotify_authenticated } - if (Array.isArray(data)) { - // Backward compat: old format was plain array - _autoMirroredPlaylists = data; - _autoSpotifyAuthenticated = false; - } else { - _autoMirroredPlaylists = data.playlists || []; - _autoSpotifyAuthenticated = data.spotify_authenticated || false; - } - } catch (e) { _autoMirroredPlaylists = []; _autoSpotifyAuthenticated = false; } - } - - selects.forEach(sel => { - const savedValue = sel.dataset.value || ''; - const isRefresh = sel.dataset.blockType === 'refresh_mirrored'; - sel.innerHTML = ''; - _autoMirroredPlaylists.forEach(p => { - // For refresh selects: hide file playlists, hide spotify (library) if not authed - if (isRefresh) { - if (p.source === 'file' || p.source === 'beatport') return; - if (p.source === 'spotify' && !_autoSpotifyAuthenticated) return; - } - sel.innerHTML += ``; - }); - }); - - nameSelects.forEach(sel => { - const savedValue = sel.dataset.value || ''; - sel.innerHTML = ''; - _autoMirroredPlaylists.forEach(p => { - sel.innerHTML += ``; - }); - }); -} - -function _readPlacedConfig(slotKey) { - let data; - if (slotKey.startsWith('then-')) { - const idx = parseInt(slotKey.split('-')[1]); - data = _autoBuilder.then[idx]; - } else { - data = _autoBuilder[slotKey]; - } - if (!data) return {}; - const type = data.type; - if (type === 'schedule') { - return { - interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6, - unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours', - }; - } - if (type === 'daily_time') { - return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' }; - } - if (type === 'weekly_time') { - const daysEl = document.getElementById('cfg-' + slotKey + '-days'); - const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : []; - return { - time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00', - days, - }; - } - // Event triggers with conditions - const blockDef = _findBlockDef(type); - if (blockDef && blockDef.has_conditions) { - _autoSaveConditionsFromDOM(slotKey); - return { - conditions: (data.config && data.config.conditions) || [], - match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all', - }; - } - if (type === 'process_wishlist') { - return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' }; - } - if (type === 'refresh_mirrored') { - const allCb = document.getElementById('cfg-' + slotKey + '-all'); - return { - playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', - all: allCb ? allCb.checked : false, - }; - } - if (type === 'sync_playlist') { - return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' }; - } - if (type === 'discover_playlist') { - const allCb = document.getElementById('cfg-' + slotKey + '-all'); - return { - playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', - all: allCb ? allCb.checked : false, - }; - } - if (type === 'playlist_pipeline') { - const allCb = document.getElementById('cfg-' + slotKey + '-all'); - const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist'); - return { - playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', - all: allCb ? allCb.checked : false, - skip_wishlist: skipWl ? skipWl.checked : false, - }; - } - if (type === 'signal_received' || type === 'fire_signal') { - return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' }; - } - if (type === 'run_script') { - return { - script_name: document.getElementById('cfg-' + slotKey + '-script_name')?.value || '', - timeout: parseInt(document.getElementById('cfg-' + slotKey + '-timeout')?.value || '60') || 60, - }; - } - if (type === 'discord_webhook') { - return { - webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '', - message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', - }; - } - if (type === 'pushbullet') { - return { - access_token: document.getElementById('cfg-' + slotKey + '-access_token')?.value?.trim() || '', - title: document.getElementById('cfg-' + slotKey + '-title')?.value || '', - message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', - }; - } - if (type === 'telegram') { - return { - bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', - chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', - message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', - }; - } - if (type === 'webhook') { - return { - url: document.getElementById('cfg-' + slotKey + '-url')?.value?.trim() || '', - headers: document.getElementById('cfg-' + slotKey + '-headers')?.value || '', - message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', - }; - } - return {}; -} - -function _findBlockDef(type) { - if (!_autoBlocks) return null; - for (const cat of ['triggers', 'actions', 'notifications']) { - const found = (_autoBlocks[cat] || []).find(b => b.type === type); - if (found) return found; - } - return null; -} - -// --- Drag & Drop --- - -function _autoDragStart(e, blockType, slotCategory) { - e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory })); - e.dataTransfer.effectAllowed = 'copy'; -} - -function _autoDragOver(e, slotKey) { - e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; - const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; - document.getElementById(targetId)?.classList.add('drag-over'); -} - -function _autoDragLeave(e, slotKey) { - const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; - document.getElementById(targetId)?.classList.remove('drag-over'); -} - -function _autoDrop(e, slotKey) { - e.preventDefault(); - const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; - document.getElementById(dropTargetId)?.classList.remove('drag-over'); - if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; - try { - const data = JSON.parse(e.dataTransfer.getData('text/plain')); - // Handle THEN slot (append to array) - if (slotKey === 'then') { - if (data.slot !== 'then') { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } - if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; } - _autoBuilder.then.push({ type: data.type, config: {} }); - } else { - if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } - _autoBuilder[slotKey] = { type: data.type, config: {} }; - } - _renderBuilderCanvas(); - } catch (err) { } -} - -// Click-to-add (alternative to drag) -function _autoClickBlock(blockType, slotCategory) { - if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return; - if (slotCategory === 'then') { - if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; } - _autoBuilder.then.push({ type: blockType, config: {} }); - } else { - _autoBuilder[slotCategory] = { type: blockType, config: {} }; - } - _renderBuilderCanvas(); -} - -function _autoRemoveBlock(slotKey) { - if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; - // Handle then-N slots - if (slotKey.startsWith('then-')) { - const idx = parseInt(slotKey.split('-')[1]); - if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) { - _autoBuilder.then.splice(idx, 1); - } - } else { - _autoBuilder[slotKey] = null; - } - _renderBuilderCanvas(); -} - -// Variable insertion -function _autoInsertVar(textareaId, variable) { - const el = document.getElementById(textareaId); - if (!el) return; - const start = el.selectionStart, end = el.selectionEnd; - el.value = el.value.substring(0, start) + variable + el.value.substring(end); - el.selectionStart = el.selectionEnd = start + variable.length; - el.focus(); -} - -// ===== ISSUES PAGE ===== - -const ISSUE_CATEGORIES = { - wrong_track: { label: 'Wrong Track', icon: '❌', description: 'This file plays a completely different song than expected', applies: ['track'] }, - wrong_metadata: { label: 'Wrong Metadata', icon: '✎', description: 'Title, artist, year, or other tags are incorrect', applies: ['track', 'album'] }, - wrong_cover: { label: 'Wrong Cover Art', icon: '📷', description: 'The album artwork is wrong or missing', applies: ['album'] }, - wrong_artist: { label: 'Wrong Artist', icon: '👤', description: 'This track is filed under the wrong artist', applies: ['track'] }, - duplicate_tracks: { label: 'Duplicate Tracks', icon: '🔁', description: 'The same track appears more than once in this album', applies: ['album'] }, - missing_tracks: { label: 'Missing Tracks', icon: '❓', description: 'Tracks that should be here are missing from this album', applies: ['album'] }, - audio_quality: { label: 'Audio Quality', icon: '🎵', description: 'Audio has quality issues — clipping, low bitrate, silence, etc.', applies: ['track'] }, - wrong_album: { label: 'Wrong Album', icon: '💿', description: 'This track belongs to a different album', applies: ['track'] }, - incomplete_album: { label: 'Incomplete Album', icon: '⚠', description: 'Album is partially downloaded — some tracks present, others not', applies: ['album'] }, - other: { label: 'Other', icon: '💬', description: 'Any other issue not listed above', applies: ['track', 'album'] }, -}; - -const ISSUE_STATUS_META = { - open: { label: 'Open', cls: 'issue-status-open' }, - in_progress: { label: 'In Progress', cls: 'issue-status-progress' }, - resolved: { label: 'Resolved', cls: 'issue-status-resolved' }, - dismissed: { label: 'Dismissed', cls: 'issue-status-dismissed' }, -}; - -let _issuesPageState = { loaded: false }; - -function _issueHeaders(extra) { - const h = { 'X-Profile-Id': String(currentProfile ? currentProfile.id : 1) }; - if (extra) Object.assign(h, extra); - return h; -} - -async function loadIssuesPage() { - const admin = isEnhancedAdmin(); - const subtitle = document.getElementById('issues-subtitle'); - if (subtitle) { - subtitle.textContent = admin ? 'Manage and resolve reported library problems' : 'Track and resolve library problems'; - } - await Promise.all([loadIssuesList(), loadIssuesCounts()]); -} - -async function loadIssuesCounts() { - try { - const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); - const data = await resp.json(); - if (!data.success) return; - const counts = data.counts; - const statsEl = document.getElementById('issues-stats'); - if (!statsEl) return; - const total = (counts.open || 0) + (counts.in_progress || 0) + (counts.resolved || 0) + (counts.dismissed || 0); - statsEl.innerHTML = ` -
-
${counts.open || 0}
-
Open
-
-
-
${counts.in_progress || 0}
-
In Progress
-
-
-
${counts.resolved || 0}
-
Resolved
-
-
-
${counts.dismissed || 0}
-
Dismissed
-
-
-
${total}
-
Total
-
- `; - // Update nav badge - const badge = document.getElementById('issues-nav-badge'); - if (badge) { - const openCount = counts.open || 0; - badge.textContent = openCount; - badge.classList.toggle('hidden', openCount === 0); - } - } catch (e) { - console.error('Failed to load issue counts:', e); - } -} - -async function loadIssuesList() { - const listEl = document.getElementById('issues-list'); - if (!listEl) return; - listEl.innerHTML = '
Loading issues...
'; - - const statusFilter = document.getElementById('issues-filter-status')?.value || ''; - const categoryFilter = document.getElementById('issues-filter-category')?.value || ''; - - let url = '/api/issues?'; - if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`; - if (categoryFilter) url += `category=${encodeURIComponent(categoryFilter)}&`; - - try { - const profileId = currentProfile ? currentProfile.id : 1; - const resp = await fetch(url, { headers: { 'X-Profile-Id': String(profileId) } }); - const data = await resp.json(); - if (!data.success || !data.issues || data.issues.length === 0) { - listEl.innerHTML = ` -
-
🔍
-
No issues found
-
${statusFilter || categoryFilter ? 'Try adjusting your filters' : 'No issues have been reported yet'}
-
- `; - return; - } - listEl.innerHTML = ''; - data.issues.forEach(issue => { - listEl.appendChild(renderIssueCard(issue)); - }); - } catch (e) { - console.error('Failed to load issues:', e); - listEl.innerHTML = '
Failed to load issues
'; - } -} - -function renderIssueCard(issue) { - const card = document.createElement('div'); - card.className = 'issue-card'; - card.dataset.issueId = issue.id; - card.onclick = () => showIssueDetailModal(issue.id); - - const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; - const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; - const admin = isEnhancedAdmin(); - - let snapshot = {}; - try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } - - const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); - const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; - const artistName = snapshot.artist_name || ''; - const albumName = snapshot.album_title || ''; - const thumbUrl = snapshot.thumb_url || snapshot.album_thumb || ''; - - const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; - const createdTime = issue.created_at ? new Date(issue.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : ''; - - // Priority indicator - const priorityCls = issue.priority === 'high' ? 'issue-priority-high' : (issue.priority === 'low' ? 'issue-priority-low' : 'issue-priority-normal'); - - let thumbHtml = ''; - if (thumbUrl) { - thumbHtml = ``; - } else { - thumbHtml = `
${catMeta.icon}
`; - } - - let metaLine = ''; - if (issue.entity_type === 'track') { - metaLine = [artistName, albumName].filter(Boolean).map(s => _esc(s)).join(' — '); - } else if (issue.entity_type === 'album') { - metaLine = artistName ? _esc(artistName) : ''; - } - - let profileBadge = ''; - if (admin && issue.reporter_name) { - profileBadge = `by ${_esc(issue.reporter_name)}`; - } - - let adminResponseIndicator = ''; - if (issue.admin_response) { - adminResponseIndicator = '💬'; - } - - card.innerHTML = ` -
- ${thumbHtml} -
-
-
- ${catMeta.icon} - ${_esc(issue.title)} - ${adminResponseIndicator} -
-
- ${_esc(entityLabel)} - ${_esc(entityName)} - ${metaLine ? `${metaLine}` : ''} -
- ${issue.description ? `
${_esc(issue.description)}
` : ''} - -
-
- ${_esc(statusMeta.label)} - -
- `; - return card; -} - -// --- Report Issue Modal --- - -let _reportIssueState = {}; - -function showReportIssueModal(entityType, entityId, entityName, artistName, albumTitle) { - _reportIssueState = { entityType, entityId, entityName, artistName, albumTitle: albumTitle || '' }; - const overlay = document.getElementById('report-issue-overlay'); - const titleEl = document.getElementById('report-issue-title'); - const body = document.getElementById('report-issue-body'); - if (!overlay || !body) return; - - const entityLabel = entityType === 'track' ? 'Track' : (entityType === 'album' ? 'Album' : 'Artist'); - titleEl.textContent = `Report Issue — ${entityLabel}`; - - body.innerHTML = ` -
-
${_esc(entityName)}
- ${artistName ? `
${_esc(artistName)}${albumTitle ? ' — ' + _esc(albumTitle) : ''}
` : ''} -
-
- -
- ${Object.entries(ISSUE_CATEGORIES) - .filter(([, cat]) => !cat.applies || cat.applies.includes(entityType)) - .map(([key, cat]) => ` -
-
${cat.icon}
-
${_esc(cat.label)}
-
${_esc(cat.description)}
-
- `).join('')} -
-
- - `; - - _reportIssueState.selectedCategory = null; - _reportIssueState.selectedPriority = 'normal'; - const submitBtn = document.getElementById('report-issue-submit-btn'); - if (submitBtn) submitBtn.disabled = true; - - overlay.classList.remove('hidden'); -} - -function selectIssueCategory(el, category) { - document.querySelectorAll('.report-issue-category-card').forEach(c => c.classList.remove('selected')); - el.classList.add('selected'); - _reportIssueState.selectedCategory = category; - - const detailsSection = document.getElementById('report-issue-details-section'); - if (detailsSection) detailsSection.style.display = ''; - - // Auto-generate title based on category - const titleInput = document.getElementById('report-issue-input-title'); - const catMeta = ISSUE_CATEGORIES[category]; - if (titleInput && !titleInput._userEdited) { - const entityName = _reportIssueState.entityName || ''; - titleInput.value = `${catMeta.label}: ${entityName}`; - } - - const submitBtn = document.getElementById('report-issue-submit-btn'); - if (submitBtn) submitBtn.disabled = false; -} - -function selectIssuePriority(el, priority) { - document.querySelectorAll('.report-issue-priority-btn').forEach(b => b.classList.remove('selected')); - el.classList.add('selected'); - _reportIssueState.selectedPriority = priority; -} - -function closeReportIssueModal() { - const overlay = document.getElementById('report-issue-overlay'); - if (overlay) overlay.classList.add('hidden'); - _reportIssueState = {}; -} - -async function submitIssue() { - if (_reportIssueState._submitting) return; - const category = _reportIssueState.selectedCategory; - if (!category) { - showToast('Please select an issue category', 'error'); - return; - } - - const titleInput = document.getElementById('report-issue-input-title'); - const descInput = document.getElementById('report-issue-input-desc'); - const title = (titleInput?.value || '').trim(); - const description = (descInput?.value || '').trim(); - - if (!title) { - showToast('Please provide a title for the issue', 'error'); - return; - } - - _reportIssueState._submitting = true; - const submitBtn = document.getElementById('report-issue-submit-btn'); - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.textContent = 'Submitting...'; - } - - try { - const resp = await fetch('/api/issues', { - method: 'POST', - headers: _issueHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - profile_id: currentProfile ? currentProfile.id : 1, - entity_type: _reportIssueState.entityType, - entity_id: String(_reportIssueState.entityId), - category: category, - title: title, - description: description, - priority: _reportIssueState.selectedPriority || 'normal', - }), - }); - const data = await resp.json(); - if (data.success) { - showToast('Issue reported successfully', 'success'); - closeReportIssueModal(); - // Refresh issues page if visible - const issuesPage = document.getElementById('issues-page'); - if (issuesPage && issuesPage.classList.contains('active')) { - loadIssuesPage(); - } - // Update badge - loadIssuesBadge(); - } else { - showToast(data.error || 'Failed to submit issue', 'error'); - } - } catch (e) { - console.error('Failed to submit issue:', e); - showToast('Failed to submit issue', 'error'); - } finally { - _reportIssueState._submitting = false; - if (submitBtn) { - submitBtn.disabled = false; - submitBtn.textContent = 'Submit Issue'; - } - } -} - -// --- Issue Detail Modal --- - -async function showIssueDetailModal(issueId) { - const overlay = document.getElementById('issue-detail-overlay'); - const body = document.getElementById('issue-detail-body'); - const footer = document.getElementById('issue-detail-footer'); - const titleEl = document.getElementById('issue-detail-title'); - if (!overlay || !body) return; - - body.innerHTML = '
Loading...
'; - footer.innerHTML = ''; - overlay.classList.remove('hidden'); - - try { - const resp = await fetch(`/api/issues/${issueId}`, { headers: _issueHeaders() }); - const data = await resp.json(); - if (!data.success || !data.issue) { - body.innerHTML = '
Issue not found
'; - return; - } - renderIssueDetail(data.issue, body, footer, titleEl); - } catch (e) { - console.error('Failed to load issue:', e); - body.innerHTML = '
Failed to load issue
'; - } -} - -function renderIssueDetail(issue, body, footer, titleEl) { - const admin = isEnhancedAdmin(); - const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; - const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; - - let snapshot = {}; - try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } - - const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); - const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; - const artistName = snapshot.artist_name || (issue.entity_type === 'artist' ? snapshot.name : '') || ''; - const albumTitle = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); - const artistId = issue.entity_type === 'artist' ? snapshot.id : snapshot.artist_id; - - // Resolve image URLs — album art and artist photo - let artistThumb = ''; - let albumThumb = ''; - if (issue.entity_type === 'album') { - albumThumb = snapshot.thumb_url || ''; - artistThumb = snapshot.artist_thumb || ''; - } else if (issue.entity_type === 'track') { - albumThumb = snapshot.album_thumb || ''; - artistThumb = snapshot.artist_thumb || ''; - } else { - // Artist issue - artistThumb = snapshot.thumb_url || ''; - } - - // Determine the album-level Spotify ID for download/wishlist actions - const spotifyAlbumId = snapshot.spotify_album_id || ''; - - console.log('Issue detail snapshot:', { entityType: issue.entity_type, albumThumb, artistThumb, spotifyAlbumId, snapshotKeys: Object.keys(snapshot) }); - - const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleString() : 'Unknown'; - const resolvedDate = issue.resolved_at ? new Date(issue.resolved_at).toLocaleString() : ''; - - titleEl.textContent = `Issue #${issue.id}`; - - // --- Build external links chips --- - function _extLinks(snap) { - const links = []; - if (snap.spotify_artist_id) links.push({ svc: 'Spotify', type: 'Artist', url: `https://open.spotify.com/artist/${snap.spotify_artist_id}`, cls: 'ext-spotify' }); - if (snap.spotify_album_id) links.push({ svc: 'Spotify', type: 'Album', url: `https://open.spotify.com/album/${snap.spotify_album_id}`, cls: 'ext-spotify' }); - if (snap.spotify_track_id) links.push({ svc: 'Spotify', type: 'Track', url: `https://open.spotify.com/track/${snap.spotify_track_id}`, cls: 'ext-spotify' }); - if (snap.artist_musicbrainz_id) links.push({ svc: 'MusicBrainz', type: 'Artist', url: `https://musicbrainz.org/artist/${snap.artist_musicbrainz_id}`, cls: 'ext-mb' }); - if (snap.musicbrainz_release_id) links.push({ svc: 'MusicBrainz', type: 'Release', url: `https://musicbrainz.org/release/${snap.musicbrainz_release_id}`, cls: 'ext-mb' }); - if (snap.musicbrainz_recording_id) links.push({ svc: 'MusicBrainz', type: 'Recording', url: `https://musicbrainz.org/recording/${snap.musicbrainz_recording_id}`, cls: 'ext-mb' }); - if (snap.artist_deezer_id) links.push({ svc: 'Deezer', type: 'Artist', url: `https://www.deezer.com/artist/${snap.artist_deezer_id}`, cls: 'ext-deezer' }); - if (snap.album_deezer_id) links.push({ svc: 'Deezer', type: 'Album', url: `https://www.deezer.com/album/${snap.album_deezer_id}`, cls: 'ext-deezer' }); - if (snap.track_deezer_id) links.push({ svc: 'Deezer', type: 'Track', url: `https://www.deezer.com/track/${snap.track_deezer_id}`, cls: 'ext-deezer' }); - if (snap.artist_tidal_id) links.push({ svc: 'Tidal', type: 'Artist', url: `https://listen.tidal.com/artist/${snap.artist_tidal_id}`, cls: 'ext-tidal' }); - if (snap.album_tidal_id) links.push({ svc: 'Tidal', type: 'Album', url: `https://listen.tidal.com/album/${snap.album_tidal_id}`, cls: 'ext-tidal' }); - if (snap.artist_qobuz_id) links.push({ svc: 'Qobuz', type: 'Artist', cls: 'ext-qobuz', id: snap.artist_qobuz_id }); - if (snap.album_qobuz_id) links.push({ svc: 'Qobuz', type: 'Album', cls: 'ext-qobuz', id: snap.album_qobuz_id }); - return links; - } - - const extLinks = _extLinks(snapshot); - let extLinksHtml = ''; - if (extLinks.length > 0) { - const chips = extLinks.map(l => { - if (l.url) { - return `${_esc(l.svc)} ${_esc(l.type)}`; - } - return `${_esc(l.svc)} ${_esc(l.type)}`; - }).join(''); - extLinksHtml = `
${chips}
`; - } - - // --- Build enhanced-library-style album/track widget --- - // Determine which album data to show (for album issues it's the entity, for track issues it's the parent) - const showAlbumWidget = (issue.entity_type === 'album' || issue.entity_type === 'track'); - const albumName = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); - const albumYear = snapshot.year || ''; - const albumLabel = snapshot.label || ''; - const albumType = snapshot.record_type || ''; - const albumTrackCount = issue.entity_type === 'album' ? (snapshot.track_count || '') : (snapshot.album_track_count || ''); - const albumGenres = snapshot.genres || []; - - // --- Build the hero section (artist photo + album art + info) --- - let heroHtml = ''; - if (showAlbumWidget) { - // Genre tags - let genreTagsHtml = ''; - if (Array.isArray(albumGenres) && albumGenres.length > 0) { - genreTagsHtml = `
${albumGenres.slice(0, 5).map(g => `${_esc(g)}`).join('')}
`; - } - - // Album meta line - const albumMetaParts = []; - if (albumYear) albumMetaParts.push(String(albumYear)); - if (albumType) albumMetaParts.push(albumType.charAt(0).toUpperCase() + albumType.slice(1)); - if (albumTrackCount) albumMetaParts.push(albumTrackCount + ' tracks'); - if (albumLabel) albumMetaParts.push(albumLabel); - - // For track issues, show the track title under the album - const trackNameLine = issue.entity_type === 'track' && entityName - ? `
♫ ${_esc(entityName)}
` : ''; - - heroHtml = ` -
-
- ${artistThumb ? `` : ''} - ${albumThumb ? `` : ''} -
${catMeta.icon}
-
-
- ${artistName ? `
${_esc(artistName)}
` : ''} -
${_esc(albumName)}
- ${trackNameLine} - ${albumMetaParts.length > 0 ? `
${_esc(albumMetaParts.join(' \u00B7 '))}
` : ''} - ${genreTagsHtml} - ${extLinksHtml} -
-
- `; - } else { - // Artist-level issue — simpler hero - heroHtml = ` -
-
- ${artistThumb ? `` : `
${catMeta.icon}
`} -
-
-
${_esc(entityName)}
- ${extLinksHtml} -
-
- `; - } - - // --- Issue info bar --- - let issueInfoHtml = ` -
-
- ${_esc(statusMeta.label)} - - ${catMeta.icon} ${_esc(catMeta.label)} -
-
- Reported ${_esc(createdDate)} - ${issue.reporter_name && admin ? `by ${_esc(issue.reporter_name)}` : ''} - ${resolvedDate ? `Resolved ${_esc(resolvedDate)}` : ''} -
-
- `; - - // --- Issue description --- - let descriptionHtml = ` -
-
Issue
-
${_esc(issue.title)}
- ${issue.description ? `
${_esc(issue.description)}
` : '
No additional details provided
'} -
- `; - - // --- Action buttons (Download Album / Add to Wishlist) for admin --- - let actionButtonsHtml = ''; - if (admin && (issue.entity_type === 'album' || issue.entity_type === 'track')) { - actionButtonsHtml = ` -
- - -
- `; - } - - // --- Metadata grid for track-level issues --- - let metaGridHtml = ''; - if (issue.entity_type === 'track') { - const metaItems = []; - if (snapshot.track_number) metaItems.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) }); - if (snapshot.duration) metaItems.push({ icon: '◷', label: 'Duration', value: typeof snapshot.duration === 'number' ? formatDurationMs(snapshot.duration) : String(snapshot.duration) }); - if (snapshot.format) metaItems.push({ icon: '💾', label: 'Format', value: snapshot.format }); - if (snapshot.bitrate) metaItems.push({ icon: '🎶', label: 'Bitrate', value: snapshot.bitrate + ' kbps' }); - if (snapshot.bpm) metaItems.push({ icon: '♫', label: 'BPM', value: String(snapshot.bpm) }); - if (snapshot.quality) metaItems.push({ icon: '★', label: 'Quality', value: snapshot.quality }); - if (metaItems.length > 0) { - metaGridHtml = ` -
-
Track Details
-
- ${metaItems.map(m => ` -
- ${m.icon} - ${_esc(m.label)} - ${_esc(m.value)} -
- `).join('')} -
-
- `; - } - } - - // --- File path display for tracks --- - let filePathHtml = ''; - if (snapshot.file_path) { - filePathHtml = ` -
-
File Path
-
${_esc(snapshot.file_path)}
-
- `; - } - - // --- Enhanced-library-style track listing --- - let trackListHtml = ''; - if (snapshot.tracks && Array.isArray(snapshot.tracks) && snapshot.tracks.length > 0) { - let lastDisc = null; - let rows = ''; - const hasMultiDisc = snapshot.tracks.some(tr => (tr.disc_number || 1) > 1); - snapshot.tracks.forEach(t => { - const disc = t.disc_number || 1; - if (hasMultiDisc && disc !== lastDisc) { - rows += `
Disc ${disc}
`; - lastDisc = disc; - } - const fmt = t.format || (t.file_path ? t.file_path.split('.').pop().toUpperCase() : ''); - const fmtLower = fmt.toLowerCase(); - const fmtClass = fmtLower === 'flac' ? 'flac' : (fmtLower === 'mp3' ? 'mp3' : 'other'); - const br = t.bitrate ? parseInt(t.bitrate) : 0; - const brClass = br >= 320 || fmtLower === 'flac' ? 'high' : (br >= 192 ? 'medium' : 'low'); - const durStr = t.duration && typeof t.duration === 'number' ? formatDurationMs(t.duration) : ''; - - rows += ` -
- ${_esc(String(t.track_number || '-'))} - ${_esc(t.title || 'Unknown')} - ${durStr ? `${durStr}` : ''} - - ${fmt ? `${_esc(fmt)}` : ''} - ${br ? `${br}k` : ''} - -
- `; - }); - trackListHtml = ` -
-
Track Listing ${snapshot.tracks.length} tracks
-
${rows}
-
- `; - } - - // --- Admin response section --- - let adminResponseHtml = ''; - if (admin) { - adminResponseHtml = ` -
-
Admin Response
- -
- `; - } else if (issue.admin_response) { - adminResponseHtml = ` -
-
Admin Response
-
${_esc(issue.admin_response)}
-
- `; - } - - body.innerHTML = ` - ${heroHtml} - ${issueInfoHtml} - ${actionButtonsHtml} - ${descriptionHtml} - ${metaGridHtml} - ${filePathHtml} - ${trackListHtml} - ${adminResponseHtml} - `; - - // --- Footer with status action buttons --- - const safeId = parseInt(issue.id, 10); - let footerHtml = ''; - - if (admin) { - if (issue.status === 'open' || issue.status === 'in_progress') { - if (issue.status === 'open') { - footerHtml += ``; - } - footerHtml += ``; - footerHtml += ``; - } else { - footerHtml += ``; - } - footerHtml += ``; - } else { - if (issue.status === 'open') { - footerHtml += ``; - } - } - - footer.innerHTML = footerHtml; - - // --- Attach action button handlers --- - const dlBtn = document.getElementById('issue-action-download'); - if (dlBtn) { - dlBtn.onclick = () => issueDownloadAlbum(spotifyAlbumId, artistName, albumName); - } - const wlBtn = document.getElementById('issue-action-wishlist'); - if (wlBtn) { - wlBtn.onclick = () => issueAddToWishlist(spotifyAlbumId, artistName, albumName); - } -} - -// --- Issue Action: Download Album --- -async function issueDownloadAlbum(spotifyAlbumId, artistName, albumName) { - const btn = document.getElementById('issue-action-download'); - if (!spotifyAlbumId && (!artistName || !albumName)) { - showToast('No album ID or artist/album info available for download', 'warning'); - return; - } - try { - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - - let response; - if (spotifyAlbumId) { - const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); - } else { - // No Spotify album ID — search for the album by name - const query = `${artistName} ${albumName}`; - const searchResp = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - if (!searchResp.ok) throw new Error('Album search failed'); - const searchData = await searchResp.json(); - const foundAlbum = searchData.spotify_albums?.[0]; - if (!foundAlbum || !foundAlbum.id) { - showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); - return; - } - const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); - } - - if (!response.ok) { - if (response.status === 401) throw new Error('Spotify not authenticated'); - throw new Error(`Failed to load album: ${response.status}`); - } - - const albumData = await response.json(); - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - showToast(`No tracks available for "${albumName}"`, 'warning'); - return; - } - - // Close the issue modal first - closeIssueDetailModal(); - - const resolvedAlbumId = albumData.id || spotifyAlbumId || Date.now(); - const virtualPlaylistId = `issue_download_${resolvedAlbumId}`; - - // Enrich tracks with album metadata - const enrichedTracks = albumData.tracks.map(track => ({ - ...track, - album: { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks - } - })); - - const playlistName = `[${artistName}] ${albumData.name}`; - const artistObject = { id: `issue_${artistName}`, name: artistName, image_url: '' }; - const fullAlbumObject = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - image_url: albumData.images?.[0]?.url || null, - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumData.artists || [{ name: artistName }] - }; - - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true - ); - - // Register download bubble so it appears on the dashboard - const albumType = fullAlbumObject.album_type || 'album'; - registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); - - } catch (error) { - console.error('Issue download error:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - if (btn) { btn.disabled = false; btn.innerHTML = ' Download Album'; } - } -} - -// --- Redownload Library Album (Enhanced View) --- -async function redownloadLibraryAlbum(album, artistName, btn) { - const albumName = album.title || ''; - const spotifyAlbumId = album.spotify_album_id || ''; - - if (!spotifyAlbumId && !albumName) { - showToast('No album ID or name available for redownload', 'warning'); - return; - } - - const origText = btn ? btn.innerHTML : ''; - try { - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - - let response; - if (spotifyAlbumId) { - const params = new URLSearchParams({ name: albumName, artist: artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${params}`); - } - - // Fallback: search by name if no ID or direct fetch failed - if (!response || !response.ok) { - const query = `${artistName || ''} ${albumName}`.trim(); - const searchResp = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - if (!searchResp.ok) throw new Error('Album search failed'); - const searchData = await searchResp.json(); - const found = searchData.spotify_albums?.[0] || searchData.itunes_albums?.[0]; - if (!found || !found.id) { - showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning'); - return; - } - const params = new URLSearchParams({ name: found.name || albumName, artist: found.artist || artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(found.id)}?${params}`); - } - - if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); - - const albumData = await response.json(); - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - showToast(`No tracks found for "${albumName}"`, 'warning'); - return; - } - - const resolvedId = albumData.id || spotifyAlbumId || album.id; - const virtualPlaylistId = `library_redownload_${resolvedId}`; - const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`; - - const enrichedTracks = albumData.tracks.map(track => ({ - ...track, - album: { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks - } - })); - - const enhancedArtist = artistDetailPageState.enhancedData?.artist; - const artistObject = { - id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`, - name: artistName || '', - image_url: enhancedArtist?.thumb_url || '' - }; - const fullAlbumObject = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - image_url: albumData.images?.[0]?.url || null, - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumData.artists || [{ name: artistName || '' }] - }; - - await openDownloadMissingModalForArtistAlbum( - virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true - ); - - // Register download bubble so it appears on the dashboard - const albumType = fullAlbumObject.album_type || 'album'; - registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); - - } catch (error) { - console.error('Redownload album error:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - if (btn) { btn.disabled = false; btn.innerHTML = origText; } - } -} - -// --- Issue Action: Add to Wishlist --- -async function issueAddToWishlist(spotifyAlbumId, artistName, albumName) { - const btn = document.getElementById('issue-action-wishlist'); - if (!spotifyAlbumId && (!artistName || !albumName)) { - showToast('No album ID or artist/album info available', 'warning'); - return; - } - try { - if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } - - let response; - if (spotifyAlbumId) { - const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); - } else { - // No Spotify album ID — search for the album by name - const query = `${artistName} ${albumName}`; - const searchResp = await fetch('/api/enhanced-search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query }) - }); - if (!searchResp.ok) throw new Error('Album search failed'); - const searchData = await searchResp.json(); - const foundAlbum = searchData.spotify_albums?.[0]; - if (!foundAlbum || !foundAlbum.id) { - showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); - return; - } - const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); - response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); - } - - if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); - - const albumData = await response.json(); - if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { - showToast(`No tracks available for "${albumName}"`, 'warning'); - return; - } - - // Close issue modal and open wishlist modal - closeIssueDetailModal(); - - const albumArtists = albumData.artists || [{ name: artistName }]; - const album = { - name: albumData.name, - id: albumData.id, - album_type: albumData.album_type || 'album', - images: albumData.images || [], - release_date: albumData.release_date, - total_tracks: albumData.total_tracks, - artists: albumArtists - }; - const artist = { id: null, name: artistName }; - - // Enrich tracks with album metadata — use album artist for wishlist grouping - // (Spotify returns per-track artists which can differ on compilations/soundtracks) - const tracks = albumData.tracks.map(t => ({ - ...t, - artists: albumArtists, - album: album - })); - - await openAddToWishlistModal(album, artist, tracks, albumData.album_type || 'album'); - - } catch (error) { - console.error('Issue wishlist error:', error); - showToast(`Error: ${error.message}`, 'error'); - } finally { - if (btn) { btn.disabled = false; btn.innerHTML = ' Add to Wishlist'; } - } -} - -async function updateIssueStatus(issueId, newStatus) { - const payload = { status: newStatus }; - - // Include admin response if present - const responseInput = document.getElementById('issue-detail-response-input'); - if (responseInput) { - payload.admin_response = responseInput.value.trim(); - } - - try { - const resp = await fetch(`/api/issues/${issueId}`, { - method: 'PUT', - headers: _issueHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify(payload), - }); - const data = await resp.json(); - if (data.success) { - showToast(`Issue ${newStatus === 'resolved' ? 'resolved' : newStatus === 'dismissed' ? 'dismissed' : newStatus === 'in_progress' ? 'marked in progress' : 'reopened'}`, 'success'); - closeIssueDetailModal(); - // Refresh if on issues page - const issuesPage = document.getElementById('issues-page'); - if (issuesPage && issuesPage.classList.contains('active')) { - loadIssuesPage(); - } - loadIssuesBadge(); - } else { - showToast(data.error || 'Failed to update issue', 'error'); - } - } catch (e) { - console.error('Failed to update issue:', e); - showToast('Failed to update issue', 'error'); - } -} - -async function deleteIssue(issueId) { - if (!confirm('Are you sure you want to delete this issue?')) return; - try { - const resp = await fetch(`/api/issues/${issueId}`, { method: 'DELETE', headers: _issueHeaders() }); - const data = await resp.json(); - if (data.success) { - showToast('Issue deleted', 'success'); - closeIssueDetailModal(); - const issuesPage = document.getElementById('issues-page'); - if (issuesPage && issuesPage.classList.contains('active')) { - loadIssuesPage(); - } - loadIssuesBadge(); - } else { - showToast(data.error || 'Failed to delete issue', 'error'); - } - } catch (e) { - console.error('Failed to delete issue:', e); - showToast('Failed to delete issue', 'error'); - } -} - -function closeIssueDetailModal() { - const overlay = document.getElementById('issue-detail-overlay'); - if (overlay) overlay.classList.add('hidden'); -} - -async function loadIssuesBadge() { - try { - const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); - const data = await resp.json(); - if (!data.success) return; - const badge = document.getElementById('issues-nav-badge'); - if (badge) { - const openCount = data.counts.open || 0; - badge.textContent = openCount; - badge.classList.toggle('hidden', openCount === 0); - } - } catch (e) { } -} - -// ===== END ISSUES PAGE ===== - -// --- Helpers --- - -function _esc(str) { - if (!str) return ''; - const d = document.createElement('div'); - d.textContent = str; - return d.innerHTML; -} - -function _escAttr(str) { - if (!str) return ''; - return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); -} - -// ===== ENHANCE QUALITY MODAL ===== - -let _enhanceQualityData = null; -let _enhanceArtistId = null; - -const ENHANCE_TIER_MAP = { - 'lossless': { num: 1, label: 'Lossless', cssClass: 'lossless' }, - 'high_lossy': { num: 2, label: 'High Lossy', cssClass: 'high-lossy' }, - 'standard_lossy': { num: 3, label: 'Standard Lossy', cssClass: 'standard-lossy' }, - 'low_lossy': { num: 4, label: 'Low Lossy', cssClass: 'low-lossy' }, - 'unknown': { num: 999, label: 'Unknown', cssClass: 'unknown' }, -}; - -async function checkArtistEnhanceEligibility(artistId) { - const btn = document.getElementById('library-artist-enhance-btn'); - if (!btn) return; - btn.classList.add('hidden'); - _enhanceArtistId = artistId; - - try { - const resp = await fetch(`/api/library/artist/${artistId}/quality-analysis`); - if (!resp.ok) return; - const data = await resp.json(); - if (!data.success || !data.tracks || data.tracks.length === 0) return; - - _enhanceQualityData = data; - - // Show button if any tracks are below the user's min acceptable tier - const minTier = data.min_acceptable_tier || 1; - const belowCount = data.tracks.filter(t => t.tier_num > minTier).length; - if (belowCount > 0) { - btn.classList.remove('hidden'); - btn.querySelector('.enhance-text').textContent = `Enhance Quality (${belowCount})`; - } - } catch (e) { - console.debug('Enhance eligibility check failed:', e); - } -} - -async function playArtistRadio() { - try { - const artistId = artistDetailPageState.currentArtistId; - const artistName = artistDetailPageState.currentArtistName || ''; - if (!artistId) { - showToast('No artist selected', 'error'); - return; - } - - // Get tracks from this artist's library - const resp = await fetch(`/api/library/artist/${artistId}/enhanced`); - if (!resp.ok) throw new Error('Failed to load artist data'); - const data = await resp.json(); - if (!data.success) throw new Error(data.error || 'Failed'); - - // Collect all tracks with file paths - const allTracks = []; - for (const album of (data.albums || [])) { - for (const track of (album.tracks || [])) { - if (track.file_path) { - allTracks.push({ track, album }); - } - } - } - - if (!allTracks.length) { - showToast('No playable tracks found for this artist', 'error'); - return; - } - - // Pick a random track - const random = allTracks[Math.floor(Math.random() * allTracks.length)]; - const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; - - // Clear existing queue and disable radio before starting fresh - npRadioMode = false; - clearQueue(); - if (audioPlayer && !audioPlayer.paused) { - audioPlayer.pause(); - } - - // Play the track first, then enable radio mode after a short delay - // so currentTrack is set and the radio queue fill triggers - playLibraryTrack({ - id: random.track.id, - title: random.track.title, - file_path: random.track.file_path, - bitrate: random.track.bitrate, - artist_id: artistId, - album_id: random.album.id, - }, random.album.title || '', artistName); - - // Enable radio mode after track starts loading - setTimeout(() => { - npRadioMode = true; - const radioBtn = document.querySelector('.np-radio-btn'); - if (radioBtn) radioBtn.classList.add('active'); - }, 1000); - - showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); - } catch (e) { - showToast(`Failed to start artist radio: ${e.message}`, 'error'); - } -} - -function openEnhanceQualityModal() { - if (!_enhanceQualityData) return; - const data = _enhanceQualityData; - - // Remove existing modal if any - const existing = document.getElementById('enhance-quality-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'enhance-quality-overlay'; - overlay.className = 'enhance-modal-overlay'; - overlay.onclick = (e) => { if (e.target === overlay) closeEnhanceQualityModal(); }; - - const minTier = data.min_acceptable_tier || 1; - const summary = data.quality_summary || {}; - - overlay.innerHTML = ` -
-
-

⚡ Enhance Quality — ${_esc(data.artist_name)}

- -
-
- ${_buildEnhanceSummaryChips(summary)} -
-
-
- - -
-
- - - 0 selected -
-
-
- - - - - - - - - - - - - -
#TitleAlbumFormatBitrate
-
- -
- `; - - document.body.appendChild(overlay); - renderEnhanceTrackRows(minTier); -} - -function _buildEnhanceSummaryChips(summary) { - const chips = [ - { key: 'lossless', label: 'FLAC', cssClass: 'lossless' }, - { key: 'high_lossy', label: 'OGG/Opus', cssClass: 'high-lossy' }, - { key: 'standard_lossy', label: 'M4A/AAC', cssClass: 'standard-lossy' }, - { key: 'low_lossy', label: 'MP3/WMA', cssClass: 'low-lossy' }, - ]; - return chips - .filter(c => (summary[c.key] || 0) > 0) - .map(c => ` -
- ${summary[c.key]} - ${c.label} -
- `).join(''); -} - -function renderEnhanceTrackRows(thresholdTier) { - const tbody = document.getElementById('enhance-track-tbody'); - if (!tbody || !_enhanceQualityData) return; - - const tracks = _enhanceQualityData.tracks; - // Sort: below-threshold first, then by album + track number - const sorted = [...tracks].sort((a, b) => { - const aBt = a.tier_num > thresholdTier ? 0 : 1; - const bBt = b.tier_num > thresholdTier ? 0 : 1; - if (aBt !== bBt) return aBt - bBt; - const albumCmp = (a.album_title || '').localeCompare(b.album_title || ''); - if (albumCmp !== 0) return albumCmp; - return (a.disc_number || 1) * 1000 + (a.track_number || 0) - ((b.disc_number || 1) * 1000 + (b.track_number || 0)); - }); - - tbody.innerHTML = sorted.map(track => { - const isBelow = track.tier_num > thresholdTier; - const tierInfo = ENHANCE_TIER_MAP[track.tier_name] || ENHANCE_TIER_MAP['unknown']; - const bitrateStr = track.bitrate ? `${track.bitrate} kbps` : '-'; - return ` - - - ${track.track_number || '-'} - ${_esc(track.title)} - ${_esc(track.album_title)} - ${_esc(track.format)} - ${bitrateStr} - - `; - }).join(''); - - updateEnhanceSelectedCount(); -} - -function updateEnhanceThreshold(tierNum) { - const rows = document.querySelectorAll('.enhance-track-row'); - rows.forEach(row => { - const trackTier = parseInt(row.dataset.tier); - const isBelow = trackTier > tierNum; - const cb = row.querySelector('.enhance-track-check'); - - row.classList.toggle('below-threshold', isBelow); - row.classList.toggle('above-threshold', !isBelow); - if (cb) cb.checked = isBelow; - }); - updateEnhanceSelectedCount(); -} - -function enhanceSelectAll(select) { - const thresholdTier = parseInt(document.getElementById('enhance-tier-dropdown')?.value || '1'); - const checks = document.querySelectorAll('.enhance-track-check'); - checks.forEach(cb => { - const row = cb.closest('.enhance-track-row'); - const trackTier = parseInt(row?.dataset.tier || '999'); - if (select) { - cb.checked = trackTier > thresholdTier; - } else { - cb.checked = false; - } - }); - updateEnhanceSelectedCount(); -} - -function updateEnhanceSelectedCount() { - const checks = document.querySelectorAll('.enhance-track-check:checked'); - const count = checks.length; - const countEl = document.getElementById('enhance-selected-count'); - const submitBtn = document.getElementById('enhance-submit-btn'); - - if (countEl) countEl.textContent = `${count} selected`; - if (submitBtn) { - submitBtn.textContent = `⚡ Enhance ${count} Track${count !== 1 ? 's' : ''}`; - submitBtn.disabled = count === 0; - } -} - -async function submitEnhanceQuality() { - const checks = document.querySelectorAll('.enhance-track-check:checked'); - const trackIds = []; - checks.forEach(cb => { - const row = cb.closest('.enhance-track-row'); - if (row?.dataset.trackId) trackIds.push(row.dataset.trackId); - }); - - if (trackIds.length === 0) return; - - const submitBtn = document.getElementById('enhance-submit-btn'); - const footerInfo = document.getElementById('enhance-footer-info'); - if (submitBtn) { - submitBtn.disabled = true; - submitBtn.innerHTML = 'Processing...'; - } - if (footerInfo) footerInfo.textContent = 'Matching tracks to Spotify and adding to wishlist...'; - - try { - const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ track_ids: trackIds }) - }); - - const result = await resp.json(); - - if (result.success) { - const msg = `${result.enhanced_count} track${result.enhanced_count !== 1 ? 's' : ''} queued for enhancement`; - if (footerInfo) footerInfo.textContent = msg; - - showToast(msg + (result.failed_count > 0 ? ` (${result.failed_count} failed)` : ''), 'success'); - - // Update button count - const enhBtn = document.getElementById('library-artist-enhance-btn'); - if (enhBtn && result.enhanced_count > 0) { - const remaining = trackIds.length - result.enhanced_count; - if (remaining <= 0) { - enhBtn.classList.add('hidden'); - } - } - - if (submitBtn) { - submitBtn.textContent = '✅ Done'; - submitBtn.disabled = true; - } - - // Auto-close after short delay - setTimeout(() => closeEnhanceQualityModal(), 1500); - } else { - throw new Error(result.error || 'Enhancement failed'); - } - } catch (e) { - console.error('Enhance quality error:', e); - showToast(`Enhancement failed: ${e.message}`, 'error'); - if (submitBtn) { - submitBtn.textContent = `⚡ Enhance ${trackIds.length} Tracks`; - submitBtn.disabled = false; - } - if (footerInfo) footerInfo.textContent = ''; - } -} - -function closeEnhanceQualityModal() { - const overlay = document.getElementById('enhance-quality-overlay'); - if (overlay) { - overlay.classList.add('hidden'); - setTimeout(() => overlay.remove(), 300); - } -} - -// Global exports -window.openEnhanceQualityModal = openEnhanceQualityModal; -window.closeEnhanceQualityModal = closeEnhanceQualityModal; -window.updateEnhanceThreshold = updateEnhanceThreshold; -window.enhanceSelectAll = enhanceSelectAll; -window.updateEnhanceSelectedCount = updateEnhanceSelectedCount; -window.submitEnhanceQuality = submitEnhanceQuality; - -// ===== END ENHANCE QUALITY MODAL ===== - -// ================================================================================== -// PLAYLIST EXPLORER — Visual Discovery Tree -// ================================================================================== - -const _explorer = { - initialized: false, - mode: 'albums', - artists: [], - selectedAlbums: new Set(), - expandedArtists: new Set(), - building: false, - playlistId: null, - meta: null, - _resizeTimer: null, -}; - -function initExplorer() { - if (_explorer.initialized) return; - _explorer.initialized = true; - _explorer._playlists = []; - _explorer._activeSource = null; - - _explorerLoadPlaylists(); - - // Listen for discovery completion to auto-refresh playlist cards - if (typeof socket !== 'undefined') { - socket.on('discovery:progress', (data) => { - if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) return; - // Match mirrored playlist discovery events - if (data.phase === 'discovered' || data.phase === 'sync_complete' || data.complete) { - // Discovery finished — refresh playlists after brief delay for DB commit - setTimeout(() => _explorerLoadPlaylists(), 1500); - } - // Live progress update on cards during discovery - if (data.id && data.id.startsWith('mirrored_')) { - const plId = parseInt(data.id.replace('mirrored_', '')); - const card = document.querySelector(`.explorer-picker-card[data-id="${plId}"]`); - if (card) { - const meta = card.querySelector('.explorer-picker-card-meta'); - if (meta && data.progress != null) { - meta.innerHTML = `Discovering... ${Math.round(data.progress)}%`; - } - } - } - }); - } -} - -function _explorerLoadPlaylists() { - fetch('/api/mirrored-playlists') - .then(r => r.json()) - .then(data => { - const playlists = Array.isArray(data) ? data : (data.playlists || []); - _explorer._playlists = playlists; - - if (playlists.length === 0) { - const scroll = document.getElementById('explorer-picker-scroll'); - if (scroll) scroll.innerHTML = '
No mirrored playlists found. Sync a playlist first.
'; - return; - } - - // Group by source - const groups = {}; - playlists.forEach(p => { - const src = (p.source || 'other').toLowerCase(); - if (!groups[src]) groups[src] = []; - groups[src].push(p); - }); - - // Render source tabs - const tabsEl = document.getElementById('explorer-picker-tabs'); - if (tabsEl) { - const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' }; - const sources = Object.keys(groups); - if (sources.length <= 1) { - tabsEl.style.display = 'none'; - } else { - tabsEl.innerHTML = sources.map((src, i) => { - const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1); - const count = groups[src].length; - const isActive = _explorer._activeSource === src || (!_explorer._activeSource && i === 0); - return ``; - }).join(''); - } - - // Show active or first source - const activeSource = _explorer._activeSource || sources[0]; - _explorer._activeSource = activeSource; - explorerRenderPickerCards(activeSource); - } - }) - .catch(() => { }); -} - -function explorerSwitchPickerTab(source) { - _explorer._activeSource = source; - document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source)); - explorerRenderPickerCards(source); -} - -function explorerRenderPickerCards(source) { - const scroll = document.getElementById('explorer-picker-scroll'); - if (!scroll) return; - - const filtered = _explorer._playlists.filter(p => (p.source || 'other').toLowerCase() === source); - scroll.innerHTML = filtered.map(p => { - const img = p.image_url || ''; - const total = p.total_count || p.track_count || 0; - const discovered = p.discovered_count || 0; - const pct = total > 0 ? Math.round((discovered / total) * 100) : 0; - const isReady = pct >= 50; - const isActive = _explorer.playlistId === p.id; - const isFullyDiscovered = pct === 100; - const wasExplored = !!(p.explored_at || p.explored); - const wishlisted = p.wishlisted_count || 0; - const inLibrary = p.in_library_count || 0; - - // Status badge: checkmark if explored/in-library, star if ready, % if needs discovery - let statusBadge = ''; - if (inLibrary > 0 && inLibrary >= total * 0.8) { - statusBadge = '
'; - } else if (wasExplored) { - statusBadge = '
'; - } else if (wishlisted > 0) { - statusBadge = '
'; - } else if (isFullyDiscovered) { - statusBadge = '
'; - } else if (!isReady) { - statusBadge = `
${pct}%
`; - } - - // Meta line with status indicators - let metaHTML; - const statusParts = []; - if (inLibrary > 0) statusParts.push(`${inLibrary} in library`); - if (wishlisted > 0) statusParts.push(`${wishlisted} wishlisted`); - - if (isFullyDiscovered) { - metaHTML = `${total} tracks · Fully discovered`; - } else if (isReady) { - metaHTML = `${total} tracks · ${pct}% discovered`; - } else { - metaHTML = `${total} tracks · ${pct}% discovered`; - } - if (statusParts.length > 0) { - metaHTML += `
${statusParts.join(' · ')}`; - } - - // Discover button for undiscovered playlists (replaces redirect to Sync) - const discoverBtn = !isReady ? `` : ''; - - return ` -
-
- ${img ? `` : '
'} -
-
-
-
${p.name || 'Untitled'}
- ${statusBadge} -
-
${metaHTML}
- ${discoverBtn ? `
${discoverBtn}
` : ''} -
-
- `; - }).join(''); -} - -function explorerSelectPlaylist(id, el) { - _explorer.playlistId = id; - document.querySelectorAll('.explorer-picker-card').forEach(c => c.classList.remove('active')); - if (el) el.classList.add('active'); - // Update hint text - const hint = document.getElementById('explorer-build-hint'); - const pl = _explorer._playlists.find(p => p.id === id); - if (hint && pl) hint.textContent = `Ready: ${pl.name}`; - else if (hint) hint.textContent = ''; -} - -function explorerRedirectToDiscover(playlistId) { - showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info'); - navigateToPage('sync'); - setTimeout(() => { - const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); - if (mirroredBtn) mirroredBtn.click(); - }, 200); -} - -async function explorerStartDiscovery(playlistId) { - const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); - const btn = card?.querySelector('.explorer-picker-discover-btn'); - if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; } - - try { - if (typeof discoverMirroredPlaylist === 'function') { - await discoverMirroredPlaylist(playlistId); - if (btn) { btn.disabled = false; btn.textContent = 'Open'; btn.title = 'Reopen discovery modal'; } - - // Poll for card updates while discovery is in progress - _explorerStartDiscoveryPoller(playlistId); - } else { - explorerRedirectToDiscover(playlistId); - } - } catch (err) { - showToast(`Discovery failed: ${err.message}`, 'error'); - if (btn) { btn.disabled = false; btn.textContent = 'Discover'; } - } -} - -function _explorerStartDiscoveryPoller(playlistId) { - // Poll every 5s to refresh playlist cards until this playlist is ready - if (_explorer._discoveryPoller) clearInterval(_explorer._discoveryPoller); - _explorer._discoveryPoller = setInterval(async () => { - // Stop polling if Explorer page isn't active - if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) { - clearInterval(_explorer._discoveryPoller); - _explorer._discoveryPoller = null; - return; - } - // Check if the mirrored playlist state shows discovery is done - const tempHash = `mirrored_${playlistId}`; - const state = youtubePlaylistStates[tempHash]; - const isDone = state && (state.phase === 'discovered' || state.phase === 'sync_complete'); - - // Refresh cards from API - await _explorerLoadPlaylists(); - - // Stop polling once discovery is complete - if (isDone) { - clearInterval(_explorer._discoveryPoller); - _explorer._discoveryPoller = null; - } - }, 5000); -} - -function explorerSetMode(mode) { - _explorer.mode = mode; - document.querySelectorAll('.explorer-mode-btn').forEach(btn => { - btn.classList.toggle('active', btn.dataset.mode === mode); - }); -} - -async function explorerBuildTree() { - const playlistId = _explorer.playlistId; - if (!playlistId) { - showToast('Select a playlist first', 'error'); - return; - } - if (_explorer.building) return; - - _explorer.building = true; - _explorer.artists = []; - _explorer.selectedAlbums.clear(); - _explorer.expandedArtists.clear(); - _explorer.playlistId = playlistId; - - const tree = document.getElementById('explorer-tree'); - const svg = document.getElementById('explorer-svg'); - const progress = document.getElementById('explorer-progress'); - const actionBar = document.getElementById('explorer-action-bar'); - const empty = document.getElementById('explorer-empty'); - const buildBtn = document.getElementById('explorer-build-btn'); - - if (empty) empty.style.display = 'none'; - if (actionBar) actionBar.style.display = 'none'; - if (progress) progress.style.display = 'flex'; - if (buildBtn) { buildBtn.disabled = true; buildBtn.textContent = 'Building...'; } - // Clear tree but preserve the SVG element (it lives inside the tree) - tree.innerHTML = ''; - _explorer._zoom = 1; - tree.style.transform = ''; - - try { - const response = await fetch('/api/playlist-explorer/build-tree', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ playlist_id: parseInt(playlistId), mode: _explorer.mode }) - }); - - if (!response.ok) { - const err = await response.json(); - throw new Error(err.error || 'Failed to build tree'); - } - - // Stream NDJSON - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - let artistCount = 0; - let totalArtists = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); - - for (const line of lines) { - if (!line.trim()) continue; - try { - const data = JSON.parse(line); - - if (data.type === 'meta') { - _explorer.meta = data; - totalArtists = data.total_artists; - _explorerRenderRoot(data); - } else if (data.type === 'artist') { - artistCount++; - _explorer.artists.push(data); - _explorerRenderArtistNode(data, artistCount); - - // Lines drawn after streaming completes (not during — flex reflow drifts positions) - - // Update progress - const pct = Math.round((artistCount / totalArtists) * 100); - const fill = document.getElementById('explorer-progress-fill'); - const text = document.getElementById('explorer-progress-text'); - if (fill) fill.style.width = pct + '%'; - if (text) text.textContent = `Discovering artists... ${artistCount} of ${totalArtists}`; - } else if (data.type === 'complete') { - // Done - } - } catch (e) { - console.warn('Explorer: failed to parse NDJSON line', e); - } - } - } - - // Tree built — show action bar, hide progress - if (actionBar) actionBar.style.display = 'flex'; - if (progress) progress.style.display = 'none'; - _explorerUpdateCount(); - - // Mark playlist as explored (server persists via explored_at; update local copy too) - const exploredPl = _explorer._playlists.find(p => p.id === playlistId); - if (exploredPl) { - exploredPl.explored_at = new Date().toISOString(); - // Update card badge without full re-render - const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`); - if (card) { - card.classList.add('explored'); - const oldBadge = card.querySelector('.explorer-picker-card-badge'); - const badgeHTML = '
'; - if (oldBadge) { - oldBadge.outerHTML = badgeHTML; - } else { - // Insert badge into the name row - const nameRow = card.querySelector('.explorer-picker-card-name-row'); - if (nameRow) { - nameRow.insertAdjacentHTML('beforeend', badgeHTML); - } - } - // Remove discover button if present (no longer needed) - const discoverBtn = card.querySelector('.explorer-picker-card-actions'); - if (discoverBtn) discoverBtn.remove(); - } - } - - // Draw all connections now that the tree is stable - setTimeout(() => _explorerRedrawAllConnections(true), 100); - - } catch (err) { - showToast('Explorer: ' + err.message, 'error'); - if (empty) { empty.style.display = 'flex'; } - if (progress) progress.style.display = 'none'; - } finally { - _explorer.building = false; - if (buildBtn) { buildBtn.disabled = false; buildBtn.textContent = 'Explore'; } - } -} - -function _explorerRenderRoot(meta) { - const tree = document.getElementById('explorer-tree'); - const rootHtml = ` -
-
-
- ${meta.playlist_image - ? `` - : '
' - } -
-
SOURCE
-
${meta.playlist_name}
-
${meta.total_tracks} tracks · ${meta.total_artists} artists
-
-
-
-
- `; - tree.insertAdjacentHTML('afterbegin', rootHtml); - _explorer._artistRowSizes = []; // Track row capacities: [2, 3, 4, ...] - _explorer._artistCount = 0; - _explorer._currentRowIndex = 0; -} - -function _explorerGetOrCreateRow() { - const container = document.getElementById('explorer-artist-tiers'); - if (!container) return null; - - // Determine row sizes: 2, 3, 4, 5... (tree shape) - const rowCapacity = _explorer._currentRowIndex + 2; - const existingRows = container.querySelectorAll('.explorer-tier-artists'); - let currentRow = existingRows[existingRows.length - 1]; - - if (!currentRow || currentRow.children.length >= (_explorer._currentRowIndex + 2)) { - // Need a new row - _explorer._currentRowIndex = existingRows.length; - const newRow = document.createElement('div'); - newRow.className = 'explorer-tier explorer-tier-artists'; - container.appendChild(newRow); - return newRow; - } - return currentRow; -} - -function _explorerRenderArtistNode(artist, index) { - const row = _explorerGetOrCreateRow(); - if (!row) return; - - _explorer._artistCount++; - const albumCount = artist.albums ? artist.albums.length : 0; - const safeKey = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); - const hasError = !!artist.error; - - const html = ` -
-
- ${artist.image_url - ? `` - : '' - } -
-
${artist.name || 'Unknown'}
-
${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}
-
- ${!hasError && albumCount > 0 ? '
' : ''} - ${hasError ? '
' : ''} -
-
-
- `; - row.insertAdjacentHTML('beforeend', html); -} - -function explorerToggleArtist(key) { - const children = document.getElementById(`explorer-children-${key}`); - const node = document.getElementById(`explorer-node-${key}`); - if (!children || !node) return; - - const isExpanded = _explorer.expandedArtists.has(key); - if (isExpanded) { - _explorer.expandedArtists.delete(key); - children.innerHTML = ''; - node.classList.remove('expanded'); - } else { - _explorer.expandedArtists.add(key); - node.classList.add('expanded'); - - const artist = _explorer.artists.find(a => (a.name || '').replace(/[^a-zA-Z0-9]/g, '_') === key); - if (artist && artist.albums) { - const albumsHtml = artist.albums.map((album, i) => { - const id = album.spotify_id || `${key}_${i}`; - const selected = _explorer.selectedAlbums.has(id); - const owned = album.owned; - const inPlaylist = album.in_playlist; - - const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; - return ` -
-
- ${album.image_url - ? `` - : '' - } -
-
${album.title || 'Unknown'}
-
${album.year || ''} · ${album.track_count || '?'} tracks
-
-
- -
- ${owned ? '
Owned
' : ''} - ${inPlaylist ? '
' : ''} -
-
-
- `; - }).join(''); - children.innerHTML = albumsHtml; - } - } - - // Redraw SVG after DOM settles - requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); -} - -async function explorerExpandAlbumTracks(spotifyAlbumId, nodeKey) { - if (!spotifyAlbumId) return; - const tracksContainer = document.getElementById(`explorer-tracks-${nodeKey}`); - if (!tracksContainer) return; - - // Toggle: if already has content, collapse - if (tracksContainer.innerHTML) { - tracksContainer.innerHTML = ''; - requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); - return; - } - - try { - const response = await fetch(`/api/playlist-explorer/album-tracks/${spotifyAlbumId}`); - const data = await response.json(); - if (!data.success || !data.tracks) return; - - const tracksHtml = data.tracks.map((t, i) => ` -
-
-
-
${t.track_number}. ${t.name}
-
${_formatDuration(t.duration_ms)}
-
-
-
- `).join(''); - tracksContainer.innerHTML = tracksHtml; - requestAnimationFrame(() => setTimeout(() => _explorerRedrawAllConnections(), 50)); - } catch (e) { - console.error('Failed to load album tracks:', e); - } -} - -function _formatDuration(ms) { - if (!ms) return ''; - const m = Math.floor(ms / 60000); - const s = Math.floor((ms % 60000) / 1000); - return `${m}:${s.toString().padStart(2, '0')}`; -} - -// Track double-click vs single-click on album nodes -let _explorerClickTimer = null; -let _explorerLastClickId = null; - -function explorerToggleAlbum(id) { - // Double-click detection: expand tracks - if (_explorerLastClickId === id && _explorerClickTimer) { - clearTimeout(_explorerClickTimer); - _explorerClickTimer = null; - _explorerLastClickId = null; - // Double-click — expand tracks - const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); - const spotifyId = id.includes('_') ? '' : id; // Only real IDs, not fallback keys - explorerExpandAlbumTracks(spotifyId, id); - return; - } - - _explorerLastClickId = id; - _explorerClickTimer = setTimeout(() => { - _explorerClickTimer = null; - _explorerLastClickId = null; - - // Single click — toggle selection - if (_explorer.selectedAlbums.has(id)) { - _explorer.selectedAlbums.delete(id); - } else { - _explorer.selectedAlbums.add(id); - } - - const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); - if (node) { - const isSelected = _explorer.selectedAlbums.has(id); - node.classList.toggle('selected', isSelected); - const check = node.querySelector('.explorer-node-select'); - if (check) check.classList.toggle('active', isSelected); - } - - _explorerUpdateCount(); - }, 250); - - _explorerUpdateCount(); -} - -function explorerSelectAll() { - _explorer.artists.forEach(a => { - (a.albums || []).forEach(album => { - if (album.spotify_id && !album.owned) _explorer.selectedAlbums.add(album.spotify_id); - }); - }); - _explorerRefreshAllCards(); - _explorerUpdateCount(); -} - -function explorerDeselectAll() { - _explorer.selectedAlbums.clear(); - _explorerRefreshAllCards(); - _explorerUpdateCount(); -} - -function _explorerRefreshAllCards() { - document.querySelectorAll('.explorer-node-album').forEach(node => { - const id = node.dataset.id; - const selected = _explorer.selectedAlbums.has(id); - node.classList.toggle('selected', selected); - const check = node.querySelector('.explorer-node-select'); - if (check) check.classList.toggle('active', selected); - }); -} - -function _explorerUpdateCount() { - const el = document.getElementById('explorer-selection-count'); - const count = _explorer.selectedAlbums.size; - if (el) el.textContent = `${count} album${count !== 1 ? 's' : ''} selected`; - _explorerRefreshArtistIndicators(); -} - -function _explorerRefreshArtistIndicators() { - // For each artist, check if any of their albums are selected — add visual indicator - _explorer.artists.forEach(artist => { - const key = (artist.name || '').replace(/[^a-zA-Z0-9]/g, '_'); - const node = document.getElementById(`explorer-node-${key}`); - if (!node) return; - const hasSelected = (artist.albums || []).some(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); - node.classList.toggle('has-selection', hasSelected); - }); -} - -function explorerAddToWishlist() { - if (_explorer.selectedAlbums.size === 0) { - showToast('No albums selected', 'error'); - return; - } - - // Group selected albums by artist with full metadata - const artistSections = []; - for (const artist of _explorer.artists) { - const artistId = artist.artist_id || artist.spotify_id; - if (!artist.albums) continue; - const selected = artist.albums.filter(a => a.spotify_id && _explorer.selectedAlbums.has(a.spotify_id)); - if (selected.length === 0) continue; - artistSections.push({ artistId, name: artist.name, image: artist.image_url, albums: selected }); - } - - if (artistSections.length === 0) { showToast('No valid albums selected', 'error'); return; } - - // Build confirmation modal (mirrors discog-modal pattern) - const overlay = document.createElement('div'); - overlay.className = 'discog-modal-overlay'; - overlay.id = 'explorer-wishlist-overlay'; - - const totalAlbums = artistSections.reduce((s, a) => s + a.albums.length, 0); - const totalTracks = artistSections.reduce((s, a) => s + a.albums.reduce((t, al) => t + (al.track_count || 0), 0), 0); - - let cardsHtml = ''; - artistSections.forEach(section => { - cardsHtml += `
${_esc(section.name)}
`; - section.albums.forEach((album, i) => { - const year = album.year || ''; - const typeLabel = album.album_type === 'single' ? 'Single' : album.album_type === 'ep' ? 'EP' : 'Album'; - cardsHtml += ` - - `; - }); - }); - - overlay.innerHTML = ` -
-
-
-
-

Add to Wishlist

-

${artistSections.length} artist${artistSections.length !== 1 ? 's' : ''} · ${totalAlbums} releases

-
- -
-
-
- - - -
-
- - -
-
-
${cardsHtml}
- - -
- `; - - document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add('visible')); - _explorerWishlistUpdateCount(); - - document.getElementById('explorer-wishlist-submit')?.addEventListener('click', () => _explorerWishlistSubmit(artistSections)); -} - -function _explorerWishlistToggleFilter(btn) { - btn.classList.toggle('active'); - const type = btn.dataset.type; - // Scoped to explorer wishlist modal only - document.querySelectorAll(`#explorer-wishlist-overlay .discog-card[data-type="${type}"]`).forEach(card => { - card.style.display = btn.classList.contains('active') ? '' : 'none'; - }); - _explorerWishlistUpdateCount(); -} - -function _explorerWishlistUpdateCount() { - const checked = document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked'); - let releases = 0, tracks = 0; - checked.forEach(cb => { - if (cb.closest('.discog-card').style.display !== 'none') { - releases++; - tracks += parseInt(cb.dataset.tracks) || 0; - } - }); - const info = document.getElementById('explorer-wishlist-info'); - const btn = document.getElementById('explorer-wishlist-submit-text'); - if (info) info.textContent = `${releases} release${releases !== 1 ? 's' : ''} · ${tracks} tracks`; - if (btn) btn.textContent = releases > 0 ? `Add ${releases} to Wishlist` : 'Select releases'; - const submitBtn = document.getElementById('explorer-wishlist-submit'); - if (submitBtn) submitBtn.disabled = releases === 0; -} - -async function _explorerWishlistSubmit(artistSections) { - const grid = document.getElementById('explorer-wishlist-grid'); - const progress = document.getElementById('explorer-wishlist-progress'); - const filterBar = document.querySelector('#explorer-wishlist-overlay .discog-filter-bar'); - const submitBtn = document.getElementById('explorer-wishlist-submit'); - - // Collect checked albums grouped by artist - const byArtist = {}; - document.querySelectorAll('#explorer-wishlist-overlay .discog-card-cb:checked').forEach(cb => { - if (cb.closest('.discog-card').style.display === 'none') return; - const card = cb.closest('.discog-card'); - const artistId = card.dataset.artistId; - const albumId = cb.dataset.albumId; - const title = card.querySelector('.discog-card-title')?.textContent || ''; - const img = card.querySelector('.discog-card-art img')?.src || ''; - if (!byArtist[artistId]) byArtist[artistId] = { albums: [], name: '' }; - byArtist[artistId].albums.push({ id: albumId, title, img, tracks: parseInt(cb.dataset.tracks) || 0 }); - }); - - // Fill in artist names - artistSections.forEach(s => { if (byArtist[s.artistId]) byArtist[s.artistId].name = s.name; }); - - // Switch to progress view - if (grid) grid.style.display = 'none'; - if (filterBar) filterBar.style.display = 'none'; - if (submitBtn) submitBtn.style.display = 'none'; - if (progress) { - progress.style.display = ''; - progress.innerHTML = ''; - for (const [artistId, data] of Object.entries(byArtist)) { - data.albums.forEach(album => { - const item = document.createElement('div'); - item.className = 'discog-progress-item active'; - item.id = `explorer-prog-${album.id}`; - item.innerHTML = ` -
${album.img ? `` : '♫'}
-
-
${_esc(album.title)}
-
Waiting...
-
-
- `; - progress.appendChild(item); - }); - } - } - - const info = document.getElementById('explorer-wishlist-info'); - if (info) info.textContent = 'Processing...'; - - let totalAdded = 0; - - for (const [artistId, data] of Object.entries(byArtist)) { - // Sort by track count descending (deluxe editions first) BEFORE extracting IDs - data.albums.sort((a, b) => b.tracks - a.tracks); - const albumIds = data.albums.map(a => a.id); - - try { - const response = await fetch(`/api/artist/${artistId}/download-discography`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ album_ids: albumIds, artist_name: data.name }) - }); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop(); - for (const line of lines) { - if (!line.trim()) continue; - try { - const result = JSON.parse(line); - if (result.status === 'complete') continue; // Summary line, skip - const item = document.getElementById(`explorer-prog-${result.album_id}`); - if (item) { - const statusEl = item.querySelector('.discog-prog-status'); - const iconEl = item.querySelector('.discog-prog-icon'); - if (result.status === 'done') { - const added = result.tracks_added || 0; - const skipped = result.tracks_skipped || 0; - totalAdded += added; - if (statusEl) statusEl.textContent = `Added ${added} track${added !== 1 ? 's' : ''}${skipped > 0 ? `, ${skipped} skipped` : ''}`; - if (iconEl) iconEl.innerHTML = ''; - item.classList.remove('active'); - item.classList.add('done'); - } else if (result.status === 'error') { - if (statusEl) statusEl.textContent = result.message || 'Error'; - if (iconEl) iconEl.innerHTML = ''; - item.classList.remove('active'); - item.classList.add('error'); - } - } - } catch (e) { } - } - } - } catch (e) { - console.error(`Explorer wishlist: failed for ${data.name}:`, e); - } - } - - if (info) info.textContent = `Done — ${totalAdded} tracks added to wishlist`; - // Change cancel button label to "Close" - const cancelBtn = document.querySelector('#explorer-wishlist-overlay .discog-cancel-btn'); - if (cancelBtn) cancelBtn.textContent = 'Close'; - showToast(`Added ${totalAdded} tracks to wishlist`, 'success'); - - // Mark albums as added on the tree - _explorer.selectedAlbums.forEach(id => { - const node = document.querySelector(`.explorer-node-album[data-id="${id}"]`); - if (node) { node.classList.add('added'); node.classList.remove('selected'); } - }); - _explorer.selectedAlbums.clear(); - _explorerUpdateCount(); - _explorerRefreshArtistIndicators(); -} - -function _explorerEnsureDefs() { - const svg = document.getElementById('explorer-svg'); - if (!svg || svg.querySelector('defs')) return; - // Read accent color from CSS custom property - const accentRgb = getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '100,200,255'; - const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); - defs.innerHTML = ` - - - - - - - - - `; - svg.appendChild(defs); -} - -function _explorerDrawConnectionToArtist(artistIndex) { - // Incremental: draw ONLY this artist's connection. Don't clear existing. - // Flex reflow within the current row may shift siblings, but the visual drift - // is minor and gets corrected by the final redraw after streaming completes. - const svg = document.getElementById('explorer-svg'); - const root = document.getElementById('explorer-root'); - if (!svg || !root) return; - - _explorerEnsureDefs(); - _explorerSizeSvg(); - - const artistNodes = document.querySelectorAll('.explorer-node-artist'); - const artistNode = artistNodes[artistIndex]; - if (!artistNode) return; - - const rc = _explorerGetPos(root); - const ac = _explorerGetPos(artistNode); - _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', true); -} - -function _explorerRedrawAllConnections(animate = false) { - const svg = document.getElementById('explorer-svg'); - const root = document.getElementById('explorer-root'); - if (!svg || !root) return; - - _explorerEnsureDefs(); - _explorerSizeSvg(); - - // Clear existing lines but keep defs - svg.querySelectorAll('path').forEach(p => p.remove()); - - const rc = _explorerGetPos(root); - - document.querySelectorAll('.explorer-node-artist').forEach(artistNode => { - const ac = _explorerGetPos(artistNode); - _explorerDrawCurve(svg, rc.cx, rc.bottom, ac.cx, ac.top, 'root', animate); - - if (artistNode.classList.contains('expanded')) { - const branch = artistNode.closest('.explorer-branch'); - if (!branch) return; - branch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-album').forEach(albumNode => { - const alc = _explorerGetPos(albumNode); - _explorerDrawCurve(svg, ac.cx, ac.bottom, alc.cx, alc.top, 'album', animate); - - const albumBranch = albumNode.closest('.explorer-branch'); - if (albumBranch) { - albumBranch.querySelectorAll(':scope > .explorer-children > .explorer-branch > .explorer-node-track').forEach(trackNode => { - const tc = _explorerGetPos(trackNode); - _explorerDrawCurve(svg, alc.cx, alc.bottom, tc.cx, tc.top, 'track', animate); - }); - } - }); - } - }); -} - -function _explorerSizeSvg() { - const svg = document.getElementById('explorer-svg'); - const tree = document.getElementById('explorer-tree'); - if (!svg || !tree) return; - // SVG is inside the tree. Use scrollWidth/scrollHeight which are unscaled. - // Add padding to ensure lines near edges aren't clipped. - const w = Math.max(tree.scrollWidth, tree.offsetWidth) + 40; - const h = Math.max(tree.scrollHeight, tree.offsetHeight) + 40; - svg.setAttribute('width', w); - svg.setAttribute('height', h); - svg.setAttribute('viewBox', `0 0 ${w} ${h}`); -} - -function _explorerGetPos(el) { - // SVG is inside the tree — positions are relative to tree, unscaled - const tree = document.getElementById('explorer-tree'); - if (!tree) return { cx: 0, top: 0, bottom: 0 }; - const tRect = tree.getBoundingClientRect(); - const r = el.getBoundingClientRect(); - const scale = _explorer._zoom || 1; - // getBoundingClientRect returns scaled coords; divide by scale to get unscaled tree-space coords - return { - cx: (r.left + r.width / 2 - tRect.left) / scale, - top: (r.top - tRect.top) / scale, - bottom: (r.bottom - tRect.top) / scale, - }; -} - -function _explorerDrawCurve(svg, x1, y1, x2, y2, type, animate) { - const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); - const midY = y1 + (y2 - y1) * 0.45; - path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`); - - if (type === 'root') { - path.setAttribute('stroke', 'url(#explorer-grad-root)'); - path.setAttribute('stroke-width', '1.5'); - } else if (type === 'album') { - path.setAttribute('stroke', 'url(#explorer-grad-album)'); - path.setAttribute('stroke-width', '1'); - } else { - path.setAttribute('stroke', 'rgba(255,255,255,0.05)'); - path.setAttribute('stroke-width', '0.8'); - } - path.setAttribute('fill', 'none'); - - svg.appendChild(path); - - if (animate) { - const len = path.getTotalLength(); - path.setAttribute('class', 'explorer-line explorer-line-animated'); - path.style.strokeDasharray = len; - path.style.strokeDashoffset = len; - } else { - path.setAttribute('class', 'explorer-line'); - } -} - -// ── Zoom & Pan ── -_explorer._zoom = 1; -_explorer._panX = 0; -_explorer._panY = 0; -_explorer._isPanning = false; -_explorer._panStartX = 0; -_explorer._panStartY = 0; -_explorer._panStartScrollX = 0; -_explorer._panStartScrollY = 0; - -function _explorerApplyTransform() { - const tree = document.getElementById('explorer-tree'); - if (tree) { - tree.style.transform = `scale(${_explorer._zoom})`; - tree.style.transformOrigin = 'top center'; - } - _explorerSizeSvg(); - requestAnimationFrame(() => _explorerRedrawAllConnections()); -} - -function explorerZoom(delta) { - _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom + delta)); - _explorerApplyTransform(); -} - -function explorerFitToView() { - const viewport = document.getElementById('explorer-viewport'); - const tree = document.getElementById('explorer-tree'); - if (!viewport || !tree) return; - - // Reset zoom to measure natural size - _explorer._zoom = 1; - tree.style.transform = 'scale(1)'; - - requestAnimationFrame(() => { - const treeW = tree.scrollWidth; - const treeH = tree.scrollHeight; - const vpW = viewport.clientWidth - 40; - const vpH = viewport.clientHeight - 40; - - if (treeW > 0 && treeH > 0) { - _explorer._zoom = Math.min(vpW / treeW, vpH / treeH, 1.5); - _explorer._zoom = Math.max(0.2, Math.min(3, _explorer._zoom)); - } - - _explorerApplyTransform(); - viewport.scrollTop = 0; - viewport.scrollLeft = Math.max(0, (tree.scrollWidth * _explorer._zoom - vpW) / 2); - }); -} - -// Scroll wheel zoom (no modifier needed inside viewport) -document.addEventListener('wheel', (e) => { - const viewport = document.getElementById('explorer-viewport'); - if (!viewport || !viewport.contains(e.target)) return; - // Check if we're on the explorer page - const page = document.getElementById('playlist-explorer-page'); - if (!page || !page.classList.contains('active')) return; - - e.preventDefault(); - const step = e.deltaY > 0 ? -0.08 : 0.08; - explorerZoom(step); -}, { passive: false }); - -// Middle-click / right-click drag to pan -document.addEventListener('mousedown', (e) => { - const viewport = document.getElementById('explorer-viewport'); - if (!viewport || !viewport.contains(e.target)) return; - // Middle click (button 1) or right click (button 2) - if (e.button !== 1 && e.button !== 2) return; - - e.preventDefault(); - _explorer._isPanning = true; - _explorer._panStartX = e.clientX; - _explorer._panStartY = e.clientY; - _explorer._panStartScrollX = viewport.scrollLeft; - _explorer._panStartScrollY = viewport.scrollTop; - viewport.style.cursor = 'grabbing'; -}); - -document.addEventListener('mousemove', (e) => { - if (!_explorer._isPanning) return; - const viewport = document.getElementById('explorer-viewport'); - if (!viewport) return; - const dx = e.clientX - _explorer._panStartX; - const dy = e.clientY - _explorer._panStartY; - viewport.scrollLeft = _explorer._panStartScrollX - dx; - viewport.scrollTop = _explorer._panStartScrollY - dy; -}); - -document.addEventListener('mouseup', (e) => { - if (!_explorer._isPanning) return; - _explorer._isPanning = false; - const viewport = document.getElementById('explorer-viewport'); - if (viewport) viewport.style.cursor = ''; -}); - -// Suppress context menu on right-click inside viewport (for panning) -document.addEventListener('contextmenu', (e) => { - const viewport = document.getElementById('explorer-viewport'); - if (viewport && viewport.contains(e.target)) { - e.preventDefault(); - } -}); - -// Debounced redraw on resize -window.addEventListener('resize', () => { - if (_explorer.artists.length === 0) return; - clearTimeout(_explorer._resizeTimer); - _explorer._resizeTimer = setTimeout(() => _explorerRedrawAllConnections(), 150); -}); - - -// ================================================================================== -// DASHBOARD — Recent Syncs Section -// ================================================================================== - -// ================================================================================== -// SERVER PLAYLIST MANAGER — Sync Page Server Tab -// ================================================================================== - -let _serverPlaylists = []; -let _serverEditorState = { playlistId: null, playlistName: '', tracks: [] }; - -async function loadServerPlaylists() { - const container = document.getElementById('server-playlist-container'); - const editor = document.getElementById('server-editor'); - const btn = document.getElementById('server-refresh-btn'); - - if (editor) editor.style.display = 'none'; - if (container) container.style.display = ''; - if (btn) { btn.disabled = true; btn.textContent = '🔄 Loading...'; } - - // Show skeleton loader - if (container) { - container.innerHTML = `
${Array.from({ length: 6 }, (_, i) => ` -
-
-
-
-
-
-
-
-
- -
`).join('')}
`; - } - - try { - // Fetch server playlists, mirrored playlists, and sync history names in parallel - const [serverRes, mirroredRes, historyNamesRes] = await Promise.all([ - fetch('/api/server/playlists'), - fetch('/api/mirrored-playlists'), - fetch('/api/sync/history/names'), - ]); - const data = await serverRes.json(); - let mirroredAll = []; - try { mirroredAll = await mirroredRes.json(); } catch (_) { } - if (!Array.isArray(mirroredAll)) mirroredAll = []; - let historyNames = []; - try { historyNames = await historyNamesRes.json(); } catch (_) { } - if (!Array.isArray(historyNames)) historyNames = []; - - if (!data.success || !data.playlists) { - if (container) container.innerHTML = `
${data.error || 'Could not load server playlists'}
`; - return; - } - - // Separate synced vs non-synced playlists - const mirroredNames = new Set(mirroredAll.map(p => p.name.trim().toLowerCase())); - const syncedNames = new Set(historyNames.map(n => n.trim().toLowerCase())); - const synced = []; - const unsynced = []; - for (const pl of data.playlists) { - const key = pl.name.trim().toLowerCase(); - if (mirroredNames.has(key) || syncedNames.has(key)) { - pl._synced = true; - synced.push(pl); - } else { - pl._synced = false; - unsynced.push(pl); - } - } - - _serverPlaylists = [...synced, ...unsynced]; - const title = document.getElementById('server-tab-title'); - const serverName = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : ''; - if (title) title.textContent = `Server Playlists (${serverName})`; - - if (synced.length === 0 && unsynced.length === 0) { - if (container) container.innerHTML = '
No playlists found on your media server.
'; - return; - } - - // Server type icon SVG - const serverIcons = { - plex: '', - jellyfin: '', - navidrome: '' - }; - const sIcon = serverIcons[data.server_type] || serverIcons.plex; - - function _renderPlCard(pl, i, isSynced) { - const hue = (i * 37 + 200) % 360; - const safeName = _esc(pl.name).replace(/'/g, "\\'"); - const cardClass = isSynced ? 'server-pl-card' : 'server-pl-card server-pl-unsynced'; - const action = isSynced ? 'Open Editor' : 'View Tracks'; - return ` -
-
-
-
-
- -
-
-
${sIcon}
-
-
-
${_esc(pl.name)}
-
- ${pl.track_count} tracks - ${isSynced ? 'Synced' : ''} -
-
- -
`; - } - - let html = ''; - - if (synced.length > 0) { - html += `
-
- 🔗 - Synced Playlists - ${synced.length} -
-
${synced.map((pl, i) => _renderPlCard(pl, i, true)).join('')}
-
`; - } - - if (unsynced.length > 0) { - html += `
-
- 🎵 - Other Server Playlists - ${unsynced.length} -
-
${unsynced.map((pl, i) => _renderPlCard(pl, i + synced.length, false)).join('')}
-
`; - } - - container.innerHTML = html; - - } catch (e) { - if (container) container.innerHTML = `
Error: ${e.message}
`; - } finally { - if (btn) { btn.disabled = false; btn.textContent = '🔄 Refresh'; } - } -} - -async function openServerPlaylistEditor(playlistId, playlistName) { - // Step 1: Look up mirrored playlists by name - let mirroredPlaylists = []; - try { - const res = await fetch('/api/mirrored-playlists'); - const all = await res.json(); - mirroredPlaylists = (Array.isArray(all) ? all : []).filter(p => - p.name.trim().toLowerCase() === playlistName.trim().toLowerCase() - ); - } catch (e) { - console.error('Failed to fetch mirrored playlists:', e); - } - - if (mirroredPlaylists.length === 1) { - // Single match — go straight to compare - _openServerCompareView(playlistId, playlistName, mirroredPlaylists[0]); - } else if (mirroredPlaylists.length === 0) { - // No match — server-only view - _openServerCompareView(playlistId, playlistName, null); - } else { - // Multiple — disambiguation - _showServerDisambig(playlistId, playlistName, mirroredPlaylists); - } -} - -// ── Disambiguation ── - -function _showServerDisambig(playlistId, playlistName, candidates) { - const overlay = document.getElementById('server-disambig-overlay'); - const list = document.getElementById('server-disambig-list'); - const subtitle = document.getElementById('server-disambig-subtitle'); - if (!overlay || !list) return; - - if (subtitle) subtitle.textContent = `"${playlistName}" was found on ${candidates.length} sources. Which one do you want to compare against?`; - - const sourceIcons = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' }; - - list.innerHTML = candidates.map((p, i) => { - const icon = sourceIcons[p.source] || '📋'; - const ago = timeAgo(p.mirrored_at || p.updated_at); - return ` -
-
${icon}
-
-
${_esc(p.name)}
-
- ${_esc(p.source)} - ${p.track_count || 0} tracks - ${p.owner ? `by ${_esc(p.owner)}` : ''} - Mirrored ${ago} -
-
-
- -
-
`; - }).join(''); - - overlay.classList.remove('hidden'); - requestAnimationFrame(() => overlay.classList.add('visible')); - - // Escape key + click backdrop to close - overlay.onclick = e => { if (e.target === overlay) closeServerDisambig(); }; - window._disambigEsc = e => { if (e.key === 'Escape') closeServerDisambig(); }; - document.addEventListener('keydown', window._disambigEsc); -} - -function closeServerDisambig() { - const overlay = document.getElementById('server-disambig-overlay'); - if (overlay) { - overlay.classList.remove('visible'); - setTimeout(() => overlay.classList.add('hidden'), 250); - } - if (window._disambigEsc) { document.removeEventListener('keydown', window._disambigEsc); window._disambigEsc = null; } -} - -async function selectDisambigPlaylist(playlistId, playlistName, mirroredId) { - closeServerDisambig(); - try { - const res = await fetch(`/api/mirrored-playlists/${mirroredId}`); - const mirrored = await res.json(); - _openServerCompareView(playlistId, playlistName, mirrored); - } catch (e) { - showToast('Failed to load mirrored playlist: ' + e.message, 'error'); - } -} - -// ── Compare View ── - -async function _openServerCompareView(playlistId, playlistName, mirroredPlaylist) { - const container = document.getElementById('server-playlist-container'); - const editor = document.getElementById('server-editor'); - if (!editor) return; - - if (container) container.style.display = 'none'; - editor.style.display = ''; - - const nameEl = document.getElementById('server-editor-name'); - const metaEl = document.getElementById('server-editor-meta'); - const banner = document.getElementById('server-no-source-banner'); - const sourceScroll = document.getElementById('server-col-source-scroll'); - const serverScroll = document.getElementById('server-col-server-scroll'); - - if (nameEl) nameEl.textContent = playlistName; - if (metaEl) metaEl.textContent = 'Loading comparison...'; - if (banner) banner.style.display = 'none'; - if (sourceScroll) sourceScroll.innerHTML = '
Loading...
'; - if (serverScroll) serverScroll.innerHTML = '
Loading...
'; - - // Store state - _serverEditorState = { - playlistId, - playlistName, - mirroredPlaylist, - tracks: [], - }; - - // Build API URL - let url = `/api/server/playlist/${playlistId}/tracks?name=${encodeURIComponent(playlistName)}`; - if (mirroredPlaylist && mirroredPlaylist.id) { - url += `&mirrored_playlist_id=${mirroredPlaylist.id}`; - } - - try { - const response = await fetch(url); - const data = await response.json(); - if (!data.success) { - if (metaEl) metaEl.textContent = data.error || 'Failed to load'; - return; - } - - _serverEditorState.tracks = data.tracks || []; - _serverEditorState.serverType = data.server_type; - - const tracks = _serverEditorState.tracks; - const serverLabel = data.server_type ? data.server_type.charAt(0).toUpperCase() + data.server_type.slice(1) : 'Server'; - - // Header metadata - if (metaEl) metaEl.textContent = `${serverLabel} · ${data.server_track_count || 0} server tracks · ${data.source_track_count || 0} source tracks`; - - // Show no-source banner if needed - if (!mirroredPlaylist && banner) { - banner.style.display = ''; - } - - // Stats, filter counts, footer - _updateCompareStats(tracks); - - // Column headers - const sourceLabel = mirroredPlaylist ? (mirroredPlaylist.source || 'source').charAt(0).toUpperCase() + (mirroredPlaylist.source || 'source').slice(1) : 'Source'; - const sourceIconMap = { spotify: '🟢', tidal: '🌊', youtube: '▶️', beatport: '🎛️', deezer: '🟣', file: '📄' }; - const serverIconMap = { plex: '🟠', jellyfin: '🟣', navidrome: '🔵' }; - - const srcIconEl = document.getElementById('server-col-source-icon'); - const srcLabelEl = document.getElementById('server-col-source-label'); - const srcCountEl = document.getElementById('server-col-source-count'); - const svrIconEl = document.getElementById('server-col-server-icon'); - const svrLabelEl = document.getElementById('server-col-server-label'); - const svrCountEl = document.getElementById('server-col-server-count'); - - if (srcIconEl) srcIconEl.textContent = mirroredPlaylist ? (sourceIconMap[mirroredPlaylist.source] || '📋') : '📋'; - if (srcLabelEl) srcLabelEl.textContent = sourceLabel; - if (srcCountEl) srcCountEl.textContent = `${data.source_track_count || 0} tracks`; - if (svrIconEl) svrIconEl.textContent = serverIconMap[data.server_type] || '💻'; - if (svrLabelEl) svrLabelEl.textContent = serverLabel; - if (svrCountEl) svrCountEl.textContent = `${data.server_track_count || 0} tracks`; - - // Render columns - _renderCompareColumns(tracks); - - // Scroll linking - _setupScrollLinking(); - - } catch (e) { - if (metaEl) metaEl.textContent = 'Error: ' + e.message; - } -} - -function _updateCompareStats(tracks) { - const matched = tracks.filter(t => t.match_status === 'matched').length; - const missing = tracks.filter(t => t.match_status === 'missing').length; - const extra = tracks.filter(t => t.match_status === 'extra').length; - - const statsEl = document.getElementById('server-editor-stats'); - if (statsEl) { - statsEl.innerHTML = ` -
${matched}
Matched
-
${missing}
Missing
- ${extra > 0 ? `
${extra}
Extra
` : ''} - `; - } - - const editor = document.getElementById('server-editor'); - if (editor) { - editor.querySelectorAll('.discog-filter').forEach(btn => { - const f = btn.dataset.filter; - if (f === 'all') btn.textContent = `All (${tracks.length})`; - else if (f === 'matched') btn.textContent = `Matched (${matched})`; - else if (f === 'missing') btn.textContent = `Missing (${missing})`; - else if (f === 'extra') btn.textContent = `Extra (${extra})`; - }); - } - - const footer = document.getElementById('server-editor-footer'); - if (footer) footer.textContent = `${matched}/${matched + missing} matched${extra > 0 ? ` · ${extra} extra on server` : ''}`; -} - -function _formatDurationMs(ms) { - if (!ms) return ''; - const s = Math.round(ms / 1000); - return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; -} - -function _renderCompareColumns(tracks) { - const sourceScroll = document.getElementById('server-col-source-scroll'); - const serverScroll = document.getElementById('server-col-server-scroll'); - if (!sourceScroll || !serverScroll) return; - - let sourceHTML = ''; - let serverHTML = ''; - - tracks.forEach((t, i) => { - const src = t.source_track; - const svr = t.server_track; - const status = t.match_status; - const pairId = `pair-${i}`; - - // ── Source (left) column ── - if (src) { - const dur = _formatDurationMs(src.duration_ms); - sourceHTML += ` -
-
${src.position != null ? src.position : i + 1}
-
- ${src.image_url ? `` : '
'} -
-
-
${_esc(src.name)}
-
${_esc(src.artist || '')}
-
-
${dur}
-
-
`; - } else { - // Extra track — no source - sourceHTML += ` -
-
- No source track -
-
`; - } - - // ── Server (right) column ── - if (svr) { - const dur = _formatDurationMs(svr.duration); - const conf = t.confidence != null ? t.confidence : null; - let confBadge = ''; - if (status === 'matched' && conf != null) { - const pct = Math.round(conf * 100); - const cls = pct >= 100 ? 'exact' : pct >= 90 ? 'high' : 'fuzzy'; - confBadge = `${pct}%`; - } - serverHTML += ` -
-
${i + 1}
-
- ${svr.thumb ? `` : '
'} -
-
-
${_esc(svr.title)}
-
${_esc(svr.artist || '')}
-
- ${confBadge} -
${dur}
-
- ${status === 'matched' ? `` : ''} - -
-
-
`; - } else { - // Missing on server — clickable empty slot - const hint = src ? `${src.artist || ''} — ${src.name}` : ''; - serverHTML += ` -
-
-
- -
- Find & add - ${_esc(hint)} -
-
`; - } - }); - - sourceScroll.innerHTML = sourceHTML; - serverScroll.innerHTML = serverHTML; -} - -function _setupScrollLinking() { - const sourceScroll = document.getElementById('server-col-source-scroll'); - const serverScroll = document.getElementById('server-col-server-scroll'); - if (!sourceScroll || !serverScroll) return; - - // Remove old listeners to prevent accumulation on refresh - if (window._serverScrollAC) window._serverScrollAC.abort(); - window._serverScrollAC = new AbortController(); - const signal = window._serverScrollAC.signal; - - let syncing = false; - - const syncScroll = (from, to) => { - if (syncing) return; - syncing = true; - const maxFrom = from.scrollHeight - from.clientHeight; - const maxTo = to.scrollHeight - to.clientHeight; - if (maxFrom > 0 && maxTo > 0) { - to.scrollTop = (from.scrollTop / maxFrom) * maxTo; - } - requestAnimationFrame(() => { syncing = false; }); - }; - - sourceScroll.addEventListener('scroll', () => syncScroll(sourceScroll, serverScroll), { signal }); - serverScroll.addEventListener('scroll', () => syncScroll(serverScroll, sourceScroll), { signal }); -} - -function _compareTrackClick(side, index) { - const otherSide = side === 'source' ? 'server' : 'source'; - const otherScroll = document.getElementById(`server-col-${otherSide}-scroll`); - const pairId = `pair-${index}`; - - // Clear previous highlights - document.querySelectorAll('.server-track-item.highlighted').forEach(el => el.classList.remove('highlighted')); - - // Highlight both paired items - document.querySelectorAll(`[data-pair-id="${pairId}"]`).forEach(el => el.classList.add('highlighted')); - - // Scroll the OTHER column to show the paired item - const target = otherScroll?.querySelector(`[data-pair-id="${pairId}"]`); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); - } -} - -function _serverEditorRefresh() { - _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); -} - -function serverEditorBack() { - const container = document.getElementById('server-playlist-container'); - const editor = document.getElementById('server-editor'); - if (editor) editor.style.display = 'none'; - if (container) container.style.display = ''; -} - -function _serverEditorFilter(btn, filter) { - btn.closest('.server-editor-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Filter both columns simultaneously - ['server-col-source-scroll', 'server-col-server-scroll'].forEach(colId => { - document.querySelectorAll(`#${colId} .server-track-item`).forEach(item => { - const status = item.dataset.status; - item.style.display = (filter === 'all' || status === filter) ? '' : 'none'; - }); - }); -} - -// ── Track Search / Replace ── - -async function serverSearchReplace(trackIndex, mode) { - const track = _serverEditorState.tracks[trackIndex]; - if (!track) return; - - const src = track.source_track || {}; - const svr = track.server_track || {}; - // Search by track name only first (more reliable than "artist trackname" blob) - const searchQuery = src.name ? src.name.trim() : (svr.title || '').trim(); - const contextArtist = src.artist || svr.artist || ''; - const contextName = src.name || svr.title || ''; - - const existing = document.getElementById('server-search-overlay'); - if (existing) existing.remove(); - - const overlay = document.createElement('div'); - overlay.id = 'server-search-overlay'; - overlay.className = 'server-search-overlay'; - overlay.innerHTML = ` -
-
-
-
${mode === 'replace' ? 'Swap Track' : 'Add Track to Server'}
- ${contextName ? `
- Source: - ${_esc(contextArtist)} - - ${_esc(contextName)} -
` : ''} -
- -
-
-
- -
- -
-
-
-
- -
Searching... -
-
-
- `; - // Click overlay background or press Escape to close - overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); - overlay._escHandler = e => { if (e.key === 'Escape') overlay.remove(); }; - document.addEventListener('keydown', overlay._escHandler); - // Clean up Escape listener when overlay is removed - const obs = new MutationObserver(() => { - if (!document.body.contains(overlay)) { document.removeEventListener('keydown', overlay._escHandler); obs.disconnect(); } - }); - obs.observe(document.body, { childList: true }); - - const popover = overlay.querySelector('.server-search-popover'); - popover.dataset.trackIndex = trackIndex; - popover.dataset.mode = mode; - - document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add('visible')); - document.getElementById('server-search-input')?.focus(); - document.getElementById('server-search-input')?.select(); - - _serverSearchExecute(); -} - -async function _serverSearchExecute() { - const input = document.getElementById('server-search-input'); - const results = document.getElementById('server-search-results'); - const resultsHeader = document.getElementById('server-search-results-header'); - const popover = document.getElementById('server-search-popover'); - if (!input || !results || !popover) return; - - const query = input.value.trim(); - if (!query) { - results.innerHTML = '
Type a search query
'; - if (resultsHeader) resultsHeader.textContent = ''; - return; - } - - results.innerHTML = '
Searching library...
'; - if (resultsHeader) resultsHeader.textContent = ''; - - try { - const response = await fetch(`/api/library/search-tracks?q=${encodeURIComponent(query)}&limit=20`); - const data = await response.json(); - - if (!data.success || !data.tracks || data.tracks.length === 0) { - results.innerHTML = `
- -
No results found
Try different keywords or a shorter query -
`; - return; - } - - const trackIndex = parseInt(popover.dataset.trackIndex); - const mode = popover.dataset.mode; - - if (resultsHeader) resultsHeader.textContent = `${data.tracks.length} result${data.tracks.length !== 1 ? 's' : ''}`; - - results.innerHTML = data.tracks.map((t, i) => { - const ext = (t.file_path || '').split('.').pop().toUpperCase(); - const format = ['FLAC', 'MP3', 'OPUS', 'OGG', 'M4A', 'AAC', 'WAV'].includes(ext) ? (ext === 'M4A' ? 'AAC' : ext) : ''; - const dur = _formatDurationMs(t.duration); - const bitrateStr = t.bitrate ? `${t.bitrate}k` : ''; - return ` -
-
- ${t.album_thumb_url ? `` : '
'} -
-
-
${_esc(t.title)}
-
${_esc(t.artist_name)}${t.album_title ? ` · ${_esc(t.album_title)}` : ''}
-
-
- ${format ? `${format}` : ''} - ${bitrateStr ? `${bitrateStr}` : ''} - ${dur ? `${dur}` : ''} -
- -
- `; - }).join(''); - - } catch (e) { - results.innerHTML = `
Error: ${e.message}
`; - } -} - -async function _serverSelectTrack(trackIndex, mode, newTrackId, el) { - const track = _serverEditorState.tracks[trackIndex]; - if (!track) return; - - const btn = el.querySelector('.server-search-select-btn'); - if (btn) { btn.disabled = true; btn.textContent = '...'; } - - try { - let response; - if (mode === 'replace') { - response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/replace-track`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - old_track_id: track.server_track?.id, - new_track_id: newTrackId, - playlist_name: _serverEditorState.playlistName, - }) - }); - } else { - // Calculate the server-side position for this track - // Count how many server tracks exist before this index - let serverPos = 0; - for (let k = 0; k < trackIndex; k++) { - if (_serverEditorState.tracks[k]?.server_track) serverPos++; - } - response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/add-track`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_id: newTrackId, - playlist_name: _serverEditorState.playlistName, - position: serverPos, - }) - }); - } - - const data = await response.json(); - if (data.success) { - showToast(data.message || 'Track updated', 'success'); - document.getElementById('server-search-overlay')?.remove(); - // Update playlist ID if server recreated it (Plex deletes+recreates) - if (data.new_playlist_id) _serverEditorState.playlistId = data.new_playlist_id; - - // Re-fetch from server so the compare view reflects the actual server state - // and the matching algorithm can correctly wire up the newly added/replaced track - _openServerCompareView(_serverEditorState.playlistId, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); - } else { - showToast(data.error || 'Failed to update track', 'error'); - if (btn) { btn.disabled = false; btn.textContent = 'Select'; } - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - if (btn) { btn.disabled = false; btn.textContent = 'Select'; } - } -} - -async function _serverRemoveTrack(trackIndex, serverTrackId) { - if (!serverTrackId) return; - - const track = _serverEditorState.tracks[trackIndex]; - const trackTitle = track?.server_track?.title || 'this track'; - - if (!await showConfirmDialog({ title: 'Remove Track', message: `Remove "${trackTitle}" from this playlist?`, confirmText: 'Remove', destructive: true })) return; - - try { - const response = await fetch(`/api/server/playlist/${_serverEditorState.playlistId}/remove-track`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - track_id: serverTrackId, - playlist_name: _serverEditorState.playlistName, - }) - }); - - const data = await response.json(); - if (data.success) { - showToast(data.message || 'Track removed', 'success'); - const pid = data.new_playlist_id || _serverEditorState.playlistId; - _serverEditorState.playlistId = pid; - _openServerCompareView(pid, _serverEditorState.playlistName, _serverEditorState.mirroredPlaylist); - } else { - showToast(data.error || 'Failed to remove track', 'error'); - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - } -} - - -// Auto-refresh sync cards every 30 seconds when on dashboard -setInterval(() => { - if (typeof currentPage !== 'undefined' && currentPage === 'dashboard') { - loadDashboardSyncHistory(); - } -}, 30000); - -async function loadDashboardSyncHistory() { - const container = document.getElementById('sync-history-cards'); - if (!container) return; - - try { - const response = await fetch('/api/sync/history?limit=10'); - if (!response.ok) return; - - const data = await response.json(); - // Filter to only show playlist syncs — not album downloads or wishlist processing - const entries = (data.entries || []).filter(e => e.sync_type === 'playlist' || !e.sync_type); - - if (entries.length === 0) { - container.innerHTML = '
No syncs yet
'; - return; - } - - container.innerHTML = entries.map((entry, cardIndex) => { - const found = entry.tracks_found || 0; - const total = entry.total_tracks || 0; - const downloaded = entry.tracks_downloaded || 0; - const failed = entry.tracks_failed || 0; - const pct = total > 0 ? Math.round((found / total) * 100) : 0; - - // Health color - let healthClass = 'health-good'; - if (pct < 50) healthClass = 'health-bad'; - else if (pct < 80) healthClass = 'health-warn'; - - // Source badge - const sourceLabels = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', wishlist: 'Wishlist' }; - const sourceLabel = sourceLabels[entry.source] || entry.source || 'Unknown'; - - // Time - const timeStr = entry.started_at ? _relativeTime(entry.started_at) : ''; - - // Name - const name = entry.artist_name - ? `${entry.artist_name} — ${entry.album_name || entry.playlist_name}` - : entry.playlist_name || 'Unknown'; - - return ` -
- -
- ${entry.thumb_url ? `` : '
'} -
-
-
${typeof _esc === 'function' ? _esc(name) : name}
-
- ${sourceLabel} - ${timeStr} -
-
-
-
${pct}%
-
-
-
-
${found}/${total} matched${downloaded > 0 ? ` · ${downloaded} ⬇` : ''}${failed > 0 ? ` · ${failed} ✗` : ''}
-
-
- `; - }).join(''); - - } catch (e) { - console.warn('Failed to load sync history for dashboard:', e); - } -} - -function _relativeTime(dateStr) { - try { - const d = new Date(dateStr); - const now = new Date(); - const diffMs = now - d; - const mins = Math.floor(diffMs / 60000); - if (mins < 1) return 'just now'; - if (mins < 60) return `${mins}m ago`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - if (days < 7) return `${days}d ago`; - return d.toLocaleDateString(); - } catch (e) { return ''; } -} - -async function openSyncDetailModal(entryId) { - try { - showLoadingOverlay('Loading sync details...'); - const response = await fetch(`/api/sync/history/${entryId}`); - const data = await response.json(); - hideLoadingOverlay(); - - if (!data.success || !data.entry) { - showToast('Could not load sync details', 'error'); - return; - } - - const entry = data.entry; - const trackResults = entry.track_results || []; - const name = entry.artist_name - ? `${entry.artist_name} — ${entry.album_name || entry.playlist_name}` - : entry.playlist_name || 'Unknown'; - - // Build modal - const overlay = document.createElement('div'); - overlay.className = 'discog-modal-overlay'; - overlay.id = 'sync-detail-overlay'; - - const found = entry.tracks_found || 0; - const total = entry.total_tracks || 0; - const downloaded = entry.tracks_downloaded || 0; - - let trackRowsHtml = ''; - if (trackResults.length > 0) { - trackRowsHtml = trackResults.map((t, i) => { - const statusIcon = t.status === 'found' ? '✅' : '❌'; - const statusClass = t.status === 'found' ? 'matched' : 'unmatched'; - const confPct = Math.round((t.confidence || 0) * 100); - const confClass = confPct >= 80 ? 'conf-high' : confPct >= 50 ? 'conf-mid' : 'conf-low'; - let dlIcon = ''; - if (t.download_status === 'completed') dlIcon = '✅'; - else if (t.download_status === 'failed') dlIcon = '❌'; - else if (t.download_status === 'not_found') dlIcon = '🔇'; - else if (t.download_status === 'cancelled') dlIcon = '🚫'; - - let dlDisplay = dlIcon; - if (!dlDisplay && t.download_status === 'wishlist') dlDisplay = '→ Wishlist'; - - return ` - - ${i + 1} - - ${t.image_url ? `` : '
'} - - ${_esc(t.name || '')} - ${_esc(t.artist || '')} - ${_esc(t.album || '')} - ${statusIcon} - ${confPct}% - ${dlDisplay} - - `; - }).join(''); - } else { - // Fallback to tracks_json if no track_results (old syncs before data caching) - const tracks = entry.tracks || []; - const esc = typeof _esc === 'function' ? _esc : s => s; - trackRowsHtml = ` - -
Per-track match data not available for this sync.
Re-sync this playlist to see detailed match results.
- - ` + tracks.map((t, i) => { - const artists = t.artists || []; - const artistName = artists.length > 0 ? (typeof artists[0] === 'string' ? artists[0] : artists[0]?.name || '') : ''; - const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); - return ` - - ${i + 1} - - ${esc(t.name || '')} - ${esc(artistName)} - ${esc(albumName)} - - - `; - }).join(''); - } - - // Count stats for filter bar - const matchedCount = trackResults.filter(t => t.status === 'found').length; - const unmatchedCount = trackResults.filter(t => t.status !== 'found').length; - const downloadedCount = trackResults.filter(t => t.download_status === 'completed').length; - - overlay.innerHTML = ` -
-
-
-
-

Sync Details

-

${_esc(name)}

-
- -
-
-
- - - - ${downloadedCount > 0 ? `` : ''} -
-
-
- - - - - - - - - - - - - - - ${trackRowsHtml} - -
#TrackArtistAlbumMatchConf.Status
-
- -
- `; - - document.body.appendChild(overlay); - requestAnimationFrame(() => overlay.classList.add('visible')); - - } catch (e) { - hideLoadingOverlay(); - showToast('Failed to load sync details', 'error'); - } -} - -async function deleteSyncHistoryCard(entryId, btnEl) { - try { - const card = btnEl.closest('.sync-history-card'); - if (card) { - card.style.opacity = '0'; - card.style.transform = 'scale(0.9)'; - } - const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' }); - if (resp.ok) { - setTimeout(() => { if (card) card.remove(); }, 200); - } - } catch (e) { - console.warn('Failed to delete sync entry:', e); - } -} - -function _syncDetailFilter(btn, filter) { - // Update active button - btn.closest('.discog-filters').querySelectorAll('.discog-filter').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - - // Filter rows - document.querySelectorAll('#sync-detail-tbody .sync-detail-row').forEach(row => { - if (filter === 'all') { - row.style.display = ''; - } else if (filter === 'matched') { - row.style.display = row.classList.contains('matched') ? '' : 'none'; - } else if (filter === 'unmatched') { - row.style.display = row.classList.contains('unmatched') ? '' : 'none'; - } else if (filter === 'downloaded') { - const dlCell = row.querySelector('.sync-detail-dl'); - row.style.display = dlCell && dlCell.textContent.trim() === '✅' ? '' : 'none'; - } - }); -} - - -// ============================================ -// ACTIVE DOWNLOADS PAGE — Centralized Live View -// ============================================ - -let _adlPoller = null; -let _adlFilter = 'all'; -let _adlData = []; -let _adlBatches = []; -let _adlBatchHistory = []; -let _adlExpandedBatches = new Set(); -let _adlBatchHistoryPoller = null; -let _adlFilterBatchId = null; // When set, main list shows only this batch -const _batchColorMap = {}; -const _batchCompletedAt = {}; // batch_id -> timestamp when first seen as complete -let _batchColorNext = 0; - -function _getBatchColor(batchId) { - if (!batchId) return -1; - if (_batchColorMap[batchId] === undefined) { - // Deterministic color from batch_id hash for consistency across reloads - let hash = 0; - for (let i = 0; i < batchId.length; i++) hash = ((hash << 5) - hash + batchId.charCodeAt(i)) | 0; - _batchColorMap[batchId] = Math.abs(hash) % 8; - } - return _batchColorMap[batchId]; -} - -function loadActiveDownloadsPage() { - _adlFetch(); - _adlFetchBatchHistory(); - // Poll downloads every 2 seconds, history every 60 seconds - if (_adlPoller) clearInterval(_adlPoller); - _adlPoller = setInterval(() => { - if (currentPage === 'active-downloads') _adlFetch(); - else { clearInterval(_adlPoller); _adlPoller = null; } - }, 2000); - if (_adlBatchHistoryPoller) clearInterval(_adlBatchHistoryPoller); - _adlBatchHistoryPoller = setInterval(() => { - if (currentPage === 'active-downloads') _adlFetchBatchHistory(); - else { clearInterval(_adlBatchHistoryPoller); _adlBatchHistoryPoller = null; } - }, 60000); -} - -function adlSetFilter(filter) { - _adlFilter = filter; - document.querySelectorAll('#adl-filter-pills .adl-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === filter)); - _adlRender(); -} - -async function _adlFetch() { - try { - const resp = await fetch('/api/downloads/all?limit=300'); - const data = await resp.json(); - if (data.success) { - _adlData = data.downloads || []; - _adlBatches = data.batches || []; - _adlRender(); - _adlRenderBatchPanel(); - // Don't call _adlUpdateBadge() here — it counts the truncated - // 300-item local array. The WebSocket status push already - // maintains the badge with the real server-side active count. - } - } catch (e) { - console.error('Downloads page fetch error:', e); - } -} - -function _adlUpdateBadge() { - const activeCount = _adlData.filter(d => ['downloading', 'searching', 'queued', 'pending', 'post_processing'].includes(d.status)).length; - _updateDlNavBadge(activeCount); -} - -function _updateDlNavBadge(count) { - const badge = document.getElementById('dl-nav-badge'); - if (badge) { - if (count > 0) { - badge.textContent = count; - badge.classList.remove('hidden'); - } else { - badge.classList.add('hidden'); - } - } -} - -function _adlRender() { - const list = document.getElementById('adl-list'); - const empty = document.getElementById('adl-empty'); - const countEl = document.getElementById('adl-count'); - if (!list) return; - - // Apply filter - const activeStatuses = ['downloading', 'searching', 'post_processing']; - const queuedStatuses = ['queued']; - const completedStatuses = ['completed', 'skipped', 'already_owned']; - const failedStatuses = ['failed', 'not_found', 'cancelled']; - - let filtered = _adlData; - - // Batch filter: if a batch card is selected, narrow to that batch first - if (_adlFilterBatchId) { - filtered = filtered.filter(d => d.batch_id === _adlFilterBatchId); - } - - if (_adlFilter === 'active') filtered = filtered.filter(d => activeStatuses.includes(d.status)); - else if (_adlFilter === 'queued') filtered = filtered.filter(d => queuedStatuses.includes(d.status)); - else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status)); - else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); - - const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length; - - if (countEl) { - const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length; - const queuedN = _adlData.filter(d => queuedStatuses.includes(d.status)).length; - const total = _adlData.length; - const parts = []; - if (activeN > 0) parts.push(`${activeN} active`); - if (queuedN > 0) parts.push(`${queuedN} queued`); - parts.push(`${total} total`); - countEl.textContent = parts.join(' / '); - } - - // Show/hide clear button - const clearBtn = document.getElementById('adl-clear-btn'); - if (clearBtn) clearBtn.style.display = completedN > 0 ? '' : 'none'; - - // Batch filter indicator banner - let existingBanner = document.getElementById('adl-batch-filter-banner'); - if (_adlFilterBatchId) { - const batchInfo = _adlBatches.find(b => b.batch_id === _adlFilterBatchId); - const batchName = batchInfo ? batchInfo.batch_name : 'Unknown batch'; - const colorIdx = _getBatchColor(_adlFilterBatchId); - const colorDot = colorIdx >= 0 ? `` : ''; - if (!existingBanner) { - existingBanner = document.createElement('div'); - existingBanner.id = 'adl-batch-filter-banner'; - existingBanner.className = 'adl-batch-filter-banner'; - list.parentNode.insertBefore(existingBanner, list); - } - existingBanner.innerHTML = `${colorDot}Showing: ${_adlEsc(batchName)} `; - existingBanner.style.display = ''; - } else if (existingBanner) { - existingBanner.style.display = 'none'; - } - - if (filtered.length === 0) { - if (empty) empty.style.display = ''; - // Clear any existing rows but keep the empty message - list.querySelectorAll('.adl-row').forEach(r => r.remove()); - return; - } - - if (empty) empty.style.display = 'none'; - - // Group by status category for section headers - const groups = { active: [], queued: [], completed: [], failed: [] }; - for (const dl of filtered) { - const cls = _adlStatusClass(dl.status); - if (cls === 'active') groups.active.push(dl); - else if (cls === 'queued') groups.queued.push(dl); - else if (cls === 'completed') groups.completed.push(dl); - else groups.failed.push(dl); - } - - let html = ''; - const sections = [ - { key: 'active', label: 'Active', items: groups.active }, - { key: 'queued', label: 'Queued', items: groups.queued }, - { key: 'completed', label: 'Completed', items: groups.completed }, - { key: 'failed', label: 'Failed', items: groups.failed }, - ]; - - for (const section of sections) { - if (section.items.length === 0) continue; - // Only show section headers in "all" filter mode - if (_adlFilter === 'all') { - html += `
${section.label} (${section.items.length})
`; - } - for (const dl of section.items) { - const statusClass = _adlStatusClass(dl.status); - const statusLabel = _adlStatusLabel(dl.status); - const title = _adlEsc(dl.title || 'Unknown Track'); - const artist = _adlEsc(dl.artist || ''); - const album = _adlEsc(dl.album || ''); - const batchName = _adlEsc(dl.batch_name || ''); - const error = dl.error ? _adlEsc(dl.error) : ''; - - const meta = [artist, album].filter(Boolean).join(' \u00B7 '); - const artHtml = dl.artwork - ? `` - : '
'; - - // Track position: "3 of 19" - const posText = dl.batch_total > 1 ? `${(dl.track_index || 0) + 1} of ${dl.batch_total}` : ''; - - const colorIdx = _getBatchColor(dl.batch_id); - const colorBar = colorIdx >= 0 - ? `
` - : ''; - - html += `
- ${colorBar} - ${artHtml} -
-
${title}
- ${meta ? `
${meta}
` : ''} - ${batchName ? `
${batchName}${posText ? ' · Track ' + posText : ''}
` : ''} - ${error ? `
${error}
` : ''} -
-
- - ${statusLabel} -
-
`; - } - } - - // Preserve empty element, inject rows - const emptyEl = document.getElementById('adl-empty'); - const emptyHtml = emptyEl ? emptyEl.outerHTML : ''; - list.innerHTML = emptyHtml + html; - const newEmpty = document.getElementById('adl-empty'); - if (newEmpty) newEmpty.style.display = filtered.length > 0 ? 'none' : ''; -} - -function _adlStatusClass(status) { - switch (status) { - case 'downloading': case 'searching': case 'post_processing': return 'active'; - case 'queued': case 'pending': return 'queued'; - case 'completed': case 'skipped': case 'already_owned': return 'completed'; - case 'failed': case 'not_found': return 'failed'; - case 'cancelled': return 'cancelled'; - default: return 'queued'; - } -} - -function _adlStatusLabel(status) { - switch (status) { - case 'downloading': return 'Downloading'; - case 'searching': return 'Searching'; - case 'post_processing': return 'Processing'; - case 'queued': case 'pending': return 'Queued'; - case 'completed': return 'Completed'; - case 'skipped': return 'Skipped'; - case 'already_owned': return 'Owned'; - case 'failed': return 'Failed'; - case 'not_found': return 'Not Found'; - case 'cancelled': return 'Cancelled'; - default: return status; - } -} - -function _adlEsc(str) { - if (!str) return ''; - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); -} - -async function adlClearCompleted() { - try { - const resp = await fetch('/api/downloads/clear-completed', { method: 'POST' }); - const data = await resp.json(); - if (data.success) { - if (typeof showToast === 'function') showToast(`Cleared ${data.cleared} downloads`, 'success'); - _adlFetch(); - } - } catch (e) { - console.error('Error clearing completed downloads:', e); - } -} - -// ---- Batch Context Panel ---- - -const _BATCH_FADE_SECONDS = 15; // Remove completed batches after this many seconds - -function _adlRenderBatchPanel() { - const container = document.getElementById('adl-batch-active'); - const headerTitle = document.querySelector('.adl-batch-panel-title'); - if (!container) return; - - const now = Date.now(); - - // Filter out batches that completed more than FADE seconds ago - const visibleBatches = _adlBatches.filter(batch => { - const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; - if (!isTerminal) { - delete _batchCompletedAt[batch.batch_id]; // Reset if it came back to life - return true; - } - if (!_batchCompletedAt[batch.batch_id]) { - _batchCompletedAt[batch.batch_id] = now; - } - const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; - return elapsed < _BATCH_FADE_SECONDS; - }); - - // Update header with count - if (headerTitle) { - const activeCount = visibleBatches.filter(b => b.phase !== 'complete' && b.phase !== 'cancelled' && b.phase !== 'error').length; - headerTitle.textContent = activeCount > 0 ? `Batches (${activeCount})` : 'Batches'; - } - - if (visibleBatches.length === 0) { - container.innerHTML = `
- -
No active batches
-
Start a download from Search, Sync, or Wishlist
-
`; - return; - } - - let html = ''; - for (const batch of visibleBatches) { - const colorIdx = _getBatchColor(batch.batch_id); - const colorStyle = colorIdx >= 0 ? `border-left-color: rgba(var(--batch-color-${colorIdx}), 0.6)` : ''; - const isExpanded = _adlExpandedBatches.has(batch.batch_id); - const isFiltered = _adlFilterBatchId === batch.batch_id; - const total = batch.total || 1; - const done = batch.completed + batch.failed; - const pct = Math.round((done / total) * 100); - const hasFailed = batch.failed > 0; - const isTerminal = batch.phase === 'complete' || batch.phase === 'cancelled' || batch.phase === 'error'; - const isActive = batch.phase === 'downloading' && batch.active > 0; - - // Fade progress for completing batches - let fadeStyle = ''; - if (isTerminal && _batchCompletedAt[batch.batch_id]) { - const elapsed = (now - _batchCompletedAt[batch.batch_id]) / 1000; - const fadeStart = _BATCH_FADE_SECONDS * 0.6; - if (elapsed > fadeStart) { - const fadeProgress = Math.min(1, (elapsed - fadeStart) / (_BATCH_FADE_SECONDS - fadeStart)); - fadeStyle = `opacity: ${1 - fadeProgress};`; - } - } - - const sourceBadge = batch.source_page - ? `${_adlEsc(batch.source_page)}` - : ''; - - // Phase label with icon - let phaseText = ''; - let phaseIcon = ''; - if (batch.phase === 'analysis') { - phaseText = 'Analyzing...'; - phaseIcon = ''; - } else if (batch.phase === 'downloading') { - phaseText = `${batch.completed}/${total} tracks`; - if (batch.active > 0) phaseIcon = ''; - } else if (batch.phase === 'complete') { - phaseText = `Done \u2014 ${batch.completed} tracks`; - phaseIcon = '\u2713'; - } else if (batch.phase === 'cancelled') { - phaseText = 'Cancelled'; - } else if (batch.phase === 'error') { - phaseText = 'Error'; - } else { - phaseText = batch.phase; - } - - // Get first track artwork for batch thumbnail, fallback to initial - const batchTracks = _adlData.filter(d => d.batch_id === batch.batch_id); - const artworkTrack = batchTracks.find(t => t.artwork); - let thumbHtml; - if (artworkTrack) { - thumbHtml = ``; - } else { - const initial = (batch.batch_name || 'D')[0].toUpperCase(); - const bgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.15)` : 'rgba(255,255,255,0.05)'; - const fgColor = colorIdx >= 0 ? `rgba(var(--batch-color-${colorIdx}), 0.7)` : 'rgba(255,255,255,0.4)'; - thumbHtml = `
${initial}
`; - } - - // Build expanded tracks list with per-track progress - let tracksHtml = ''; - if (isExpanded) { - if (batchTracks.length > 0) { - tracksHtml = batchTracks.map(t => { - const cls = _adlStatusClass(t.status); - const progress = t.progress || 0; - - // Status indicator with detail - let statusHtml = ''; - if (t.status === 'downloading' && progress > 0) { - statusHtml = `${Math.round(progress)}%`; - } else if (t.status === 'searching') { - statusHtml = ``; - } else if (t.status === 'post_processing') { - statusHtml = `proc`; - } else if (cls === 'completed') { - statusHtml = `\u2713`; - } else if (cls === 'failed') { - statusHtml = `\u2717`; - } else { - statusHtml = `\u00B7`; - } - - // Mini progress bar for downloading tracks - const miniBar = t.status === 'downloading' && progress > 0 - ? `
` - : ''; - - return `
- ${_adlEsc(t.title || 'Unknown')} - ${statusHtml} - ${miniBar} -
`; - }).join(''); - } else { - tracksHtml = '
No tracks loaded
'; - } - } - - const cardClasses = ['adl-batch-card']; - if (isExpanded) cardClasses.push('expanded'); - if (isActive) cardClasses.push('active-glow'); - if (isFiltered) cardClasses.push('filtered'); - - const playlistId = _adlEsc(batch.playlist_id || ''); - - html += `
-
- ${thumbHtml} -
- -
${phaseIcon}${phaseText}
-
- ${sourceBadge} -
- - ${!isTerminal ? `` : ''} -
-
-
-
-
-
${tracksHtml}
-
`; - } - - container.innerHTML = html; -} - -function _adlToggleBatch(batchId) { - if (_adlExpandedBatches.has(batchId)) { - _adlExpandedBatches.delete(batchId); - } else { - _adlExpandedBatches.add(batchId); - } - _adlRenderBatchPanel(); -} - -function _adlOpenBatchModal(batchId, playlistId, batchName) { - // For wishlist batches, navigate to wishlist and show modal - if (playlistId === 'wishlist') { - const clientProcess = activeDownloadProcesses['wishlist']; - if (clientProcess && clientProcess.modalElement && document.body.contains(clientProcess.modalElement)) { - clientProcess.modalElement.style.display = 'flex'; - if (typeof WishlistModalState !== 'undefined') WishlistModalState.setVisible(); - } else { - rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); - } - return; - } - - // For other batches, try to show existing modal or rehydrate - for (const [pid, process] of Object.entries(activeDownloadProcesses)) { - if (process.batchId === batchId && process.modalElement && document.body.contains(process.modalElement)) { - process.modalElement.style.display = 'flex'; - return; - } - } - // Rehydrate from server - rehydrateModal({ playlist_id: playlistId, playlist_name: batchName, batch_id: batchId }, true); -} - -function _adlFilterByBatch(batchId) { - if (_adlFilterBatchId === batchId) { - _adlFilterBatchId = null; // Toggle off - } else { - _adlFilterBatchId = batchId; - } - _adlRender(); - _adlRenderBatchPanel(); -} - -async function _adlCancelBatch(batchId) { - const batch = _adlBatches.find(b => b.batch_id === batchId); - const batchName = batch ? batch.batch_name : 'this batch'; - const confirmed = await showConfirmDialog({ - title: 'Cancel Batch', - message: `Cancel "${batchName}"? All active and queued downloads in this batch will be stopped.`, - confirmText: 'Cancel Batch', - destructive: true - }); - if (!confirmed) return; - try { - const resp = await fetch(`/api/playlists/${batchId}/cancel_batch`, { method: 'POST' }); - const data = await resp.json(); - if (data.success) { - showToast(`Cancelled ${data.cancelled_tasks} downloads`, 'info'); - _adlFetch(); - } else { - showToast(data.error || 'Failed to cancel batch', 'error'); - } - } catch (e) { - showToast('Failed to cancel batch', 'error'); - } -} - -// ---- Batch History ---- - -async function _adlFetchBatchHistory() { - try { - const resp = await fetch('/api/downloads/batch-history?days=7&limit=50'); - const data = await resp.json(); - if (data.success) { - _adlBatchHistory = data.history || []; - _adlRenderBatchHistory(); - } - } catch (e) { - console.debug('Batch history fetch error:', e); - } -} - -function _adlRenderBatchHistory() { - const section = document.getElementById('adl-batch-history-section'); - const list = document.getElementById('adl-batch-history-list'); - if (!section || !list) return; - - if (_adlBatchHistory.length === 0) { - section.style.display = 'none'; - return; - } - - section.style.display = ''; - - list.innerHTML = _adlBatchHistory.map(h => { - const name = _adlEsc(h.playlist_name || 'Unknown'); - const downloaded = h.tracks_downloaded || 0; - const failed = h.tracks_failed || 0; - const total = h.total_tracks || 0; - const statsParts = [`${downloaded}/${total}`]; - if (failed > 0) statsParts.push(`${failed} failed`); - - let dateText = ''; - if (h.completed_at) { - try { - const d = new Date(h.completed_at); - const now = new Date(); - const diffMs = now - d; - const diffH = Math.floor(diffMs / 3600000); - if (diffH < 1) dateText = 'just now'; - else if (diffH < 24) dateText = `${diffH}h ago`; - else dateText = `${Math.floor(diffH / 24)}d ago`; - } catch (e) { - dateText = ''; - } - } - - const sourceLabel = h.source_page ? `${_adlEsc(h.source_page)}` : ''; - - // Source type color dot - const sourceColors = { wishlist: '168, 85, 247', sync: '59, 130, 246', album: '16, 185, 129' }; - const dotColor = sourceColors[h.source_page] || '255, 255, 255'; - const histDot = ``; - - return `
- ${histDot} -
${name} ${sourceLabel}
-
${statsParts.join(' ')}
-
${dateText}
-
`; - }).join(''); -} - -function adlToggleBatchHistory() { - const section = document.getElementById('adl-batch-history-section'); - if (section) section.classList.toggle('expanded'); -} - -function adlToggleBatchPanel() { - const panel = document.getElementById('adl-batch-panel'); - if (panel) panel.classList.toggle('collapsed'); -} - -window.adlSetFilter = adlSetFilter; -window.adlClearCompleted = adlClearCompleted; -window._adlToggleBatch = _adlToggleBatch; -window._adlOpenBatchModal = _adlOpenBatchModal; -window._adlFilterByBatch = _adlFilterByBatch; -window._adlCancelBatch = _adlCancelBatch; -window.adlToggleBatchHistory = adlToggleBatchHistory; -window.adlToggleBatchPanel = adlToggleBatchPanel; diff --git a/webui/static/search.js b/webui/static/search.js new file mode 100644 index 00000000..585a5495 --- /dev/null +++ b/webui/static/search.js @@ -0,0 +1,1221 @@ +// SEARCH FUNCTIONALITY +// =============================== +// `enhancedSearchFetch`, `SOURCE_LABELS`, and `renderCompactSection` live in +// shared-helpers.js so the Search page and the global widget share the same +// implementations. + +function initializeSearch() { + // --- FIX: Corrected the element IDs to match the HTML --- + const searchInput = document.getElementById('downloads-search-input'); + const searchButton = document.getElementById('downloads-search-btn'); + + // Add this line to get the cancel button + const cancelButton = document.getElementById('downloads-cancel-btn'); + + if (searchButton && searchInput) { + searchButton.addEventListener('click', performDownloadsSearch); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') performDownloadsSearch(); + }); + } + + // Add this event listener for the cancel button + if (cancelButton) { + cancelButton.addEventListener('click', () => { + if (searchAbortController) { + searchAbortController.abort(); // This cancels the fetch request + console.log("Search cancelled by user."); + } + }); + } +} + +// =============================== +// SEARCH MODE TOGGLE +// =============================== + +let searchModeToggleInitialized = false; +// Set by the closure on first init; called by subsequent invocations to +// re-display the search dropdown from the controller's cached state. +// Solves the "results vanish on navigate-back" UX issue — a sidebar nav +// click is treated as outside-click and dismisses the dropdown, so when +// the user returns to /search we need to re-render whatever was cached. +let _searchPageRestoreOnEnter = null; +// Exposed so the global-search widget's Soulseek handoff can sync the +// controller's state.query to the widget's query before clicking the +// Soulseek icon — otherwise onSoulseekSelected fires with whatever the +// user last typed on /search and overwrites the basic input. +let _searchPageController = null; + +function initializeSearchModeToggle() { + // Subsequent invocations: just re-display cached results so they don't + // vanish on navigate-back. Skip the duplicate-listener setup. + if (searchModeToggleInitialized) { + if (_searchPageRestoreOnEnter) _searchPageRestoreOnEnter(); + return; + } + + const sourceRow = document.getElementById('enh-source-row'); + const basicSection = document.getElementById('basic-search-section'); + const enhancedSection = document.getElementById('enhanced-search-section'); + + if (!sourceRow || !basicSection || !enhancedSection) { + console.warn('Search source picker elements not found'); + return; + } + + searchModeToggleInitialized = true; + console.log('✅ Initializing search source picker (first time only)'); + + // State + fetch dispatch + icon-row rendering live in the shared + // `createSearchController` factory (shared-helpers.js) so this page and + // the global search widget share one implementation. This closure wires + // the controller up with Search-page-specific DOM + callbacks. + + const enhancedInput = document.getElementById('enhanced-search-input'); + const enhancedCancelBtn = document.getElementById('enhanced-cancel-btn'); + const enhancedDropdown = document.getElementById('enhanced-dropdown'); + const loadingState = document.getElementById('enhanced-loading'); + const emptyState = document.getElementById('enhanced-empty'); + const resultsContainer = document.getElementById('enhanced-results-container'); + + let debounceTimer = null; + + // ── Fallback banner ("Spotify unavailable — showing Deezer") ─────── + function _renderFallbackBanner(state) { + const banner = document.getElementById('enh-fallback-banner'); + if (!banner) return; + const src = state.activeSource; + const actual = state.fallbacks[src]; + if (actual && actual !== src) { + const clicked = (SOURCE_LABELS[src] || {}).text || src; + const served = (SOURCE_LABELS[actual] || {}).text || actual; + banner.textContent = `${clicked} unavailable — showing ${served}.`; + banner.classList.remove('hidden'); + } else { + banner.classList.add('hidden'); + } + } + + // Central re-render callback — called by the controller whenever state + // changes (cache hit, fetch settle, query reset). Drives the enhanced + // dropdown UI: loading state, empty state, results render, fallback + // banner. + function _renderFromState(state) { + const src = state.activeSource; + + // Soulseek has its own surface (basic-section) — the controller fires + // onSoulseekSelected for that, so there's nothing to render here. + if (src === 'soulseek') return; + + // Ensure the enhanced section is visible (may have been hidden if the + // user was previously on Soulseek). + basicSection.classList.remove('active'); + enhancedSection.classList.add('active'); + + _renderFallbackBanner(state); + + const cached = state.sources[src]; + const loading = state.loadingSources.has(src); + + // Mid-fetch with no cache yet → loading state. + if (loading && !cached) { + emptyState.classList.add('hidden'); + resultsContainer.classList.add('hidden'); + loadingState.classList.remove('hidden'); + const loadingText = document.getElementById('enhanced-loading-text'); + if (loadingText) { + const info = SOURCE_LABELS[src]; + loadingText.textContent = `Searching ${(info && info.text) || src} and your library...`; + } + showDropdown(); + return; + } + + // No cache + no query → nothing to show; hide the dropdown. + if (!cached) { + if (!state.query) { + hideDropdown(); + return; + } + // Fetch settled with no data — empty state. + loadingState.classList.add('hidden'); + resultsContainer.classList.add('hidden'); + emptyState.classList.remove('hidden'); + showDropdown(); + return; + } + + const total = src === 'youtube_videos' + ? ((cached.videos && cached.videos.length) || 0) + : ((cached.db_artists && cached.db_artists.length) || 0) + + ((cached.artists && cached.artists.length) || 0) + + ((cached.albums && cached.albums.length) || 0) + + ((cached.tracks && cached.tracks.length) || 0); + + loadingState.classList.add('hidden'); + + if (total === 0) { + resultsContainer.classList.add('hidden'); + emptyState.classList.remove('hidden'); + showDropdown(); + return; + } + + emptyState.classList.add('hidden'); + resultsContainer.classList.remove('hidden'); + showDropdown(); + + if (src === 'youtube_videos') { + ['enh-db-artists-section', 'enh-spotify-artists-section', 'enh-albums-section', 'enh-singles-section', 'enh-tracks-section'].forEach(id => { + const el = document.getElementById(id); + if (el) el.classList.add('hidden'); + }); + const artistsWrapper = document.querySelector('.enh-artists-wrapper'); + if (artistsWrapper) artistsWrapper.style.display = 'none'; + _renderVideoResults(cached.videos || []); + return; + } + + const videosSec = document.getElementById('enh-videos-section'); + if (videosSec) videosSec.classList.add('hidden'); + const artistsWrapper = document.querySelector('.enh-artists-wrapper'); + if (artistsWrapper) artistsWrapper.style.display = ''; + + renderDropdownResults({ + db_artists: cached.db_artists || [], + spotify_artists: cached.artists || [], + spotify_albums: cached.albums || [], + spotify_tracks: cached.tracks || [], + metadata_source: src, + }); + } + + const searchController = createSearchController({ + sourceRowElement: sourceRow, + iconClassPrefix: 'enh', + onStateChange: _renderFromState, + onSoulseekSelected: (query) => { + // Soulseek returns raw file results, rendered by the basic-search + // UI — swap sections and re-fire the basic search with the + // current query. + basicSection.classList.add('active'); + enhancedSection.classList.remove('active'); + hideDropdown(); + const basicInput = document.getElementById('downloads-search-input'); + if (basicInput) { + if (query) basicInput.value = query; + if (basicInput.value && typeof performDownloadsSearch === 'function') { + performDownloadsSearch(); + } + } + }, + }); + searchController.init(); + _searchPageController = searchController; + + // Expose a re-render hook so navigate-back to /search restores cached + // results instead of leaving the dropdown hidden. Deferred to the next + // tick so the render happens AFTER the nav-button click finishes + // bubbling to the document outside-click handler — otherwise that + // handler sees the just-shown dropdown and immediately dismisses it. + _searchPageRestoreOnEnter = () => { + if (!searchController.state.query) return; + setTimeout(() => _renderFromState(searchController.state), 0); + }; + + // Live search with debouncing + if (enhancedInput) { + enhancedInput.addEventListener('input', (e) => { + const query = e.target.value.trim(); + + // Show/hide cancel button + if (enhancedCancelBtn) { + enhancedCancelBtn.classList.toggle('hidden', query.length === 0); + } + + // Clear debounce timer + clearTimeout(debounceTimer); + + // Hide dropdown if query too short + if (query.length < 2) { + hideDropdown(); + return; + } + + // Debounce search + debounceTimer = setTimeout(() => { + searchController.submitQuery(query); + }, 300); + }); + + enhancedInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + const query = e.target.value.trim(); + if (query.length >= 2) { + clearTimeout(debounceTimer); + searchController.submitQuery(query); + } + } + }); + } + + if (enhancedCancelBtn) { + enhancedCancelBtn.addEventListener('click', () => { + enhancedInput.value = ''; + enhancedCancelBtn.classList.add('hidden'); + hideDropdown(); + }); + } + + // Close button inside dropdown (mobile) + const dropdownCloseBtn = document.getElementById('enhanced-dropdown-close'); + if (dropdownCloseBtn) { + dropdownCloseBtn.addEventListener('click', (e) => { + e.stopPropagation(); + hideDropdown(); + }); + } + + // Close dropdown when clicking outside + document.addEventListener('click', (e) => { + const dropdown = document.getElementById('enhanced-dropdown'); + if (dropdown && !dropdown.classList.contains('hidden')) { + const isClickInside = e.target.closest('.enhanced-search-input-wrapper'); + // Source icons live above the input, outside the dropdown — they + // control which cached source is shown, so don't dismiss when the + // user clicks them. + const isClickOnSourceRow = e.target.closest('#enh-source-row'); + // Modal sits above the dropdown; closing it shouldn't dismiss results. + const isClickInModal = e.target.closest('.download-missing-modal'); + if (!isClickInside && !isClickOnSourceRow && !isClickInModal) { + hideDropdown(); + } + } + }); + + function renderDropdownResults(data) { + const activeSource = searchController.state.activeSource; + + // Music Videos tab — don't render regular sections + if (activeSource === 'youtube_videos') return; + + // Determine source badge from active tab (not just primary) + const displaySource = activeSource || data.metadata_source || 'spotify'; + const sourceInfo = SOURCE_LABELS[displaySource] || SOURCE_LABELS.spotify; + const sourceBadge = { text: sourceInfo.text, class: sourceInfo.badgeClass }; + + // Render DB Artists + renderCompactSection( + 'enh-db-artists-section', + 'enh-db-artists-list', + 'enh-db-artists-count', + data.db_artists || [], + (artist) => ({ + image: artist.image_url, + placeholder: '📚', + name: artist.name, + meta: 'In Your Library', + badge: { text: 'Library', class: 'enh-badge-library' }, + onClick: () => { + console.log(`🎵 Opening library artist detail: ${artist.name} (ID: ${artist.id})`); + hideDropdown(); + navigateToArtistDetail(artist.id, artist.name); + } + }) + ); + + // Render Artists (source-aware badge) + renderCompactSection( + 'enh-spotify-artists-section', + 'enh-spotify-artists-list', + 'enh-spotify-artists-count', + data.spotify_artists || [], + (artist) => ({ + image: artist.image_url, + placeholder: '🎤', + name: artist.name, + meta: 'Artist', + badge: sourceBadge, + onClick: () => { + const sourceOverride = searchController.state.activeSource; + console.log(`🎵 Opening artist detail: ${artist.name} (ID: ${artist.id}, source: ${sourceOverride})`); + hideDropdown(); + navigateToArtistDetail(artist.id, artist.name, sourceOverride || null); + } + }) + ); + + // Split albums from singles/EPs (albums is the catch-all for unknown types) + const allAlbums = data.spotify_albums || []; + const singlesAndEPs = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep'); + const albums = allAlbums.filter(a => a.album_type !== 'single' && a.album_type !== 'ep'); + + // Render Albums + renderCompactSection( + 'enh-albums-section', + 'enh-albums-list', + 'enh-albums-count', + albums, + (album) => ({ + image: album.image_url, + placeholder: '💿', + name: album.name, + meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`, + onClick: () => handleEnhancedSearchAlbumClick(album) + }) + ); + + // Render Singles & EPs + renderCompactSection( + 'enh-singles-section', + 'enh-singles-list', + 'enh-singles-count', + singlesAndEPs, + (album) => ({ + image: album.image_url, + placeholder: '🎶', + name: album.name, + meta: `${album.artist} • ${album.release_date ? album.release_date.substring(0, 4) : 'N/A'}`, + onClick: () => handleEnhancedSearchAlbumClick(album) + }) + ); + + // Render Tracks + renderCompactSection( + 'enh-tracks-section', + 'enh-tracks-list', + 'enh-tracks-count', + data.spotify_tracks || [], + (track) => { + const duration = formatDuration(track.duration_ms); + return { + image: track.image_url, + placeholder: '🎵', + name: track.name, + meta: `${track.artist} • ${track.album}`, + duration: duration, + onClick: () => handleEnhancedSearchTrackClick(track), + onPlay: () => streamEnhancedSearchTrack(track) + }; + } + ); + + // Lazy load artist images that are missing + lazyLoadEnhancedSearchArtistImages(); + + // Async library ownership check — doesn't block rendering + _checkSearchResultsLibraryOwnership(data); + } + + async function _checkSearchResultsLibraryOwnership(data) { + try { + const allAlbums = data.spotify_albums || []; + const allTracks = data.spotify_tracks || []; + if (!allAlbums.length && !allTracks.length) return; + + const resp = await fetch('/api/enhanced-search/library-check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + albums: allAlbums.map(a => ({ name: a.name, artist: a.artist })), + tracks: allTracks.map(t => ({ name: t.name, artist: t.artist })), + }), + }); + const result = await resp.json(); + + // Tag album cards with staggered animation + const albumCards = document.querySelectorAll('#enh-albums-list .enh-compact-item, #enh-singles-list .enh-compact-item'); + const albumResults = result.albums || []; + let delay = 0; + albumCards.forEach((card, i) => { + if (albumResults[i]) { + setTimeout(() => { + const badge = document.createElement('div'); + badge.className = 'enh-item-lib-badge'; + badge.textContent = 'In Library'; + card.appendChild(badge); + }, delay); + delay += 30; + } + }); + + // Tag track rows + wire up library playback + const trackCards = document.querySelectorAll('#enh-tracks-list .enh-compact-item'); + const trackResults = result.tracks || []; + trackCards.forEach((card, i) => { + const tr = trackResults[i]; + if (tr && tr.in_library) { + setTimeout(() => { + const badge = document.createElement('div'); + badge.className = 'enh-item-lib-badge'; + badge.textContent = 'In Library'; + card.appendChild(badge); + + // Replace stream button to play from library instead of searching + if (tr.file_path) { + const playBtn = card.querySelector('.enh-item-play-btn'); + if (playBtn) { + const newBtn = playBtn.cloneNode(true); + newBtn.title = 'Play from library'; + newBtn.textContent = '▶'; + const trackInfo = tr; + newBtn.addEventListener('click', (e) => { + e.stopPropagation(); + playLibraryTrack( + { id: trackInfo.track_id, title: trackInfo.title, file_path: trackInfo.file_path, _stats_image: trackInfo.album_thumb_url || null }, + trackInfo.album_title || '', + trackInfo.artist_name || '' + ); + }); + playBtn.replaceWith(newBtn); + } + } + }, delay); + delay += 30; + } else if (tr && tr.in_wishlist) { + setTimeout(() => { + if (!card.querySelector('.enh-item-wishlist-badge')) { + const badge = document.createElement('div'); + badge.className = 'enh-item-wishlist-badge'; + badge.textContent = 'In Wishlist'; + card.appendChild(badge); + } + }, delay); + delay += 30; + } + }); + } catch (e) { + console.debug('Library check failed:', e); + } + } + + function _renderVideoResults(videos) { + let section = document.getElementById('enh-videos-section'); + if (!section) { + // Create the section dynamically if it doesn't exist + const container = document.getElementById('enhanced-results-container'); + if (!container) return; + section = document.createElement('div'); + section.id = 'enh-videos-section'; + section.className = 'enh-dropdown-section'; + section.innerHTML = ` +
+ 🎬 +

Music Videos

+ 0 +
+
+ `; + container.appendChild(section); + } + + section.classList.remove('hidden'); + const countEl = document.getElementById('enh-videos-count'); + const listEl = document.getElementById('enh-videos-list'); + if (countEl) countEl.textContent = videos.length; + + if (!videos.length) { + listEl.innerHTML = '
No music videos found
'; + return; + } + + listEl.innerHTML = videos.map(v => { + const duration = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; + const views = v.view_count ? _formatViewCount(v.view_count) : ''; + return ` +
+
+ +
+ + + + ${duration ? `${duration}` : ''} +
+
+
${v.title}
+
${v.channel}${views ? ` · ${views} views` : ''}
+
+
+ `; + }).join(''); + } + + function _formatViewCount(count) { + if (count >= 1000000000) return `${(count / 1000000000).toFixed(1)}B`; + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`; + if (count >= 1000) return `${(count / 1000).toFixed(1)}K`; + return String(count); + } + + // Lazy load artist images for enhanced search results + async function lazyLoadEnhancedSearchArtistImages() { + const artistLists = [ + document.getElementById('enh-db-artists-list'), + document.getElementById('enh-spotify-artists-list') + ]; + + for (const list of artistLists) { + if (!list) continue; + + const cardsNeedingImages = list.querySelectorAll('[data-needs-image="true"]'); + if (cardsNeedingImages.length === 0) continue; + + console.log(`🖼️ Lazy loading ${cardsNeedingImages.length} artist images in enhanced search`); + + for (const card of cardsNeedingImages) { + const artistId = card.dataset.artistId; + if (!artistId) continue; + + try { + const activeSource = searchController.state.activeSource; + // Pass the artist name so the backend can look up images + // for sources that don't store them (e.g. MusicBrainz — + // it only has MBIDs, not artist art, so the resolver + // falls back to iTunes/Deezer keyed by name). + const artistName = card.dataset.artistName || ''; + const params = new URLSearchParams(); + if (activeSource && activeSource !== 'spotify') params.set('source', activeSource); + if (artistName) params.set('name', artistName); + const qs = params.toString(); + const imgUrl = `/api/artist/${artistId}/image${qs ? '?' + qs : ''}`; + const response = await fetch(imgUrl); + const data = await response.json(); + + if (data.success && data.image_url) { + // Find the placeholder and replace with image + const placeholder = card.querySelector('.enh-item-image-placeholder'); + if (placeholder) { + const img = document.createElement('img'); + img.src = data.image_url; + img.className = 'enh-item-image artist-image'; + img.alt = card.querySelector('.enh-item-name')?.textContent || 'Artist'; + placeholder.replaceWith(img); + + // Apply dynamic glow + extractImageColors(data.image_url, (colors) => { + applyDynamicGlow(card, colors); + }); + } + card.dataset.needsImage = 'false'; + console.log(`✅ Loaded image for artist ${artistId}`); + } + } catch (error) { + console.warn(`⚠️ Failed to load image for artist ${artistId}:`, error); + } + } + } + } + + function formatDuration(durationMs) { + if (!durationMs) return ''; + const totalSeconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${minutes}:${seconds.toString().padStart(2, '0')}`; + } + + // renderCompactSection now lives in shared-helpers.js. + + async function handleEnhancedSearchAlbumClick(album) { + console.log(`💿 Enhanced search album clicked: ${album.name} by ${album.artist}`); + + showLoadingOverlay('Loading album...'); + + try { + // Fetch full album data with tracks — pass source for correct routing + const albumParams = new URLSearchParams({ name: album.name || '', artist: album.artist || '' }); + const activeSource = searchController.state.activeSource; + if (activeSource && activeSource !== 'spotify') { + albumParams.set('source', activeSource); + } + // Pass Hydrabase plugin origin so server routes to correct client + if (album.external_urls?.hydrabase_plugin) { + albumParams.set('plugin', album.external_urls.hydrabase_plugin); + } + const response = await fetch(`/api/spotify/album/${album.id}?${albumParams}`); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Spotify not authenticated. Please check your API settings.'); + } + throw new Error(`Failed to load album: ${response.status}`); + } + + const albumData = await response.json(); + + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + hideLoadingOverlay(); + showToast(`No tracks available for "${album.name}". This release may have been delisted or is not available in your region.`, 'warning'); + return; + } + + console.log(`✅ Loaded ${albumData.tracks.length} tracks for ${albumData.name}`); + + // Create virtual playlist ID for enhanced search albums + const virtualPlaylistId = `enhanced_search_album_${album.id}`; + + // Check if modal already exists and show it + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`📱 Reopening existing modal for ${album.name}`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + hideLoadingOverlay(); + return; + } + } + + // Enrich each track with full album object (needed for wishlist functionality) + const enrichedTracks = albumData.tracks.map(track => ({ + ...track, + album: { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks + } + })); + + console.log(`📦 Enriched ${enrichedTracks.length} tracks with album metadata`); + + // Format playlist name + const playlistName = `[${album.artist}] ${albumData.name}`; + + // Create artist object for the modal — extract ID from album data + const firstArtist = (albumData.artists || [])[0] || {}; + const artistObject = { + id: firstArtist.id || album.id?.split?.('_')?.[0] || '', + name: firstArtist.name || album.artist, + image_url: firstArtist.image_url || firstArtist.images?.[0]?.url || '', + source: activeSource || '', + }; + + // Prepare full album object for modal + const fullAlbumObject = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumData.artists || [{ name: album.artist }] + }; + + // Open download missing tracks modal + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + playlistName, + enrichedTracks, + fullAlbumObject, + artistObject, + false // Don't show loading overlay, we already have one + ); + + // Register this download in search bubbles + registerSearchDownload( + { + id: album.id, + name: albumData.name, + artist: album.artist, + image_url: albumData.images?.[0]?.url || null, + images: albumData.images || [] + }, + 'album', + virtualPlaylistId, + album.artist // artistName for grouping + ); + + hideLoadingOverlay(); + + } catch (error) { + hideLoadingOverlay(); + console.error('❌ Error handling enhanced search album click:', error); + showToast(`Error opening album: ${error.message}`, 'error'); + } + } + + async function streamEnhancedSearchTrack(track) { + console.log(`▶️ Stream enhanced search track: ${track.name} by ${track.artist}`); + + showLoadingOverlay(`Searching for ${track.name}...`); + + try { + // Send track metadata to backend for quick slskd search + const response = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_name: track.name, + artist_name: track.artist, + album_name: track.album, + duration_ms: track.duration_ms + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to search for track'); + } + + const data = await response.json(); + + if (!data.success || !data.result) { + throw new Error('No suitable track found'); + } + + const slskdResult = data.result; + + // Check if audio format is supported (YouTube/Tidal use encoded filenames, skip check) + const isStreamingSource = slskdResult.username === 'youtube' || slskdResult.username === 'tidal' || slskdResult.username === 'qobuz' || slskdResult.username === 'hifi'; + if (!isStreamingSource && slskdResult.filename && !isAudioFormatSupported(slskdResult.filename)) { + const format = getFileExtension(slskdResult.filename); + hideLoadingOverlay(); + showToast(`Sorry, ${format.toUpperCase()} format is not supported in your browser. Try downloading instead.`, 'error'); + return; + } + + console.log(`✅ Found track to stream:`, slskdResult); + console.log(`🎵 Track details - Username: ${slskdResult.username}, Filename: ${slskdResult.filename}`); + + hideLoadingOverlay(); + + // Use existing startStream function to play the track + console.log(`📡 Calling startStream() with result...`); + await startStream(slskdResult); + console.log(`✅ startStream() completed`); + + } catch (error) { + hideLoadingOverlay(); + console.error('❌ Error streaming enhanced search track:', error); + showToast(`Failed to stream track: ${error.message}`, 'error'); + } + } + + async function handleEnhancedSearchTrackClick(track) { + console.log(`🎵 Enhanced search track clicked: ${track.name} by ${track.artist}`); + + showLoadingOverlay('Loading track...'); + + try { + // Create virtual playlist ID for enhanced search tracks + const virtualPlaylistId = `enhanced_search_track_${track.id}`; + + // Check if modal already exists and show it + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`📱 Reopening existing modal for ${track.name}`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + hideLoadingOverlay(); + return; + } + } + + // Enrich track with album object (needed for wishlist functionality) + const enrichedTrack = { + id: track.id, + name: track.name, + artists: [track.artist], // Convert string to array for modal compatibility + album: { + name: track.album, + id: null, + album_type: 'single', + images: track.image_url ? [{ url: track.image_url }] : [], + release_date: track.release_date || null, + total_tracks: 1 + }, + duration_ms: track.duration_ms, + popularity: track.popularity || 0, + preview_url: track.preview_url || null, + external_urls: track.external_urls || null, + image_url: track.image_url + }; + + console.log(`📦 Enriched track with album metadata`); + + // Format playlist name + const playlistName = `${track.artist} - ${track.name}`; + + // Create minimal artist object for the modal + const artistObject = { + id: null, + name: track.artist + }; + + // Prepare album object for modal (single track) + const albumObject = { + name: track.album, + id: null, + album_type: 'single', + images: track.image_url ? [{ url: track.image_url }] : [], + release_date: track.release_date || null, + total_tracks: 1, + artists: [{ name: track.artist }] + }; + + // Open download missing tracks modal with single track + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + playlistName, + [enrichedTrack], // Array with single track + albumObject, + artistObject, + false + ); + + // Register this download in search bubbles + registerSearchDownload( + { + id: track.id, + name: track.name, + artist: track.artist, + image_url: track.image_url, + images: track.image_url ? [{ url: track.image_url }] : [] + }, + 'track', + virtualPlaylistId, + track.artist // artistName for grouping + ); + + hideLoadingOverlay(); + + } catch (error) { + hideLoadingOverlay(); + console.error('❌ Error handling enhanced search track click:', error); + showToast(`Error opening track: ${error.message}`, 'error'); + } + } + + async function searchSlskdFor(type, item) { + const mainResultsArea = document.getElementById('enhanced-main-results-area'); + if (!mainResultsArea) return; + + // Show loading in main results area + mainResultsArea.innerHTML = ` +
+
+

Searching for ${type === 'album' ? 'album' : 'track'}...

+
+ `; + + const query = `${item.artist} ${item.name}`; + + try { + const response = await fetch('/api/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + + const data = await response.json(); + + if (data.error) { + showToast(`Search error: ${data.error}`, 'error'); + return; + } + + // Filter results + const filtered = data.results.filter(r => r.result_type === type); + + // Render slskd results in main area + renderSlskdInMainArea(filtered, type, item); + + } catch (error) { + console.error('Slskd search error:', error); + showToast('Search failed', 'error'); + mainResultsArea.innerHTML = '

Search failed. Please try again.

'; + } + } + + function renderSlskdInMainArea(results, type, originalItem) { + const mainResultsArea = document.getElementById('enhanced-main-results-area'); + if (!mainResultsArea) return; + + if (!results || results.length === 0) { + mainResultsArea.innerHTML = '

No matches found for this ' + type + '.

'; + return; + } + + // Render results using same style as basic search + mainResultsArea.innerHTML = results.map(result => { + const title = type === 'album' + ? `${result.album_title} (${result.tracks ? result.tracks.length : 0} tracks)` + : result.title; + + return ` +
+
+

${escapeHtml(title)}

+ +
+
+ ${result.bitrate ? `${result.bitrate} kbps` : ''} + ${result.format ? `${result.format.toUpperCase()}` : ''} + ${result.size ? `${(result.size / 1024 / 1024).toFixed(1)} MB` : ''} + ${result.username ? `👤 ${escapeHtml(result.username)}` : ''} +
+
+ `; + }).join(''); + + // Attach download handlers + mainResultsArea.querySelectorAll('.download-result-btn').forEach(btn => { + btn.addEventListener('click', async function () { + const result = JSON.parse(this.dataset.result); + const type = this.dataset.type; + + this.disabled = true; + this.textContent = 'Downloading...'; + + try { + const downloadData = type === 'album' + ? { result_type: 'album', tracks: result.tracks || [] } + : { result_type: 'track', username: result.username, filename: result.filename, size: result.size }; + + const response = await fetch('/api/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(downloadData) + }); + + const data = await response.json(); + + if (data.error) { + showToast(`Download error: ${data.error}`, 'error'); + this.disabled = false; + this.innerHTML = '💾 Download'; + } else { + showToast('Download started!', 'success'); + this.innerHTML = '✅ Added'; + } + } catch (error) { + console.error('Download error:', error); + showToast('Download failed', 'error'); + this.disabled = false; + this.innerHTML = '💾 Download'; + } + }); + }); + } + + function showDropdown() { + const dropdown = document.getElementById('enhanced-dropdown'); + if (dropdown) dropdown.classList.remove('hidden'); + // Hide the page header + source picker to reclaim space + const header = document.querySelector('#search-page .downloads-header'); + const modeToggle = document.querySelector('.search-source-picker-container'); + const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); + if (header) header.classList.add('enh-results-active-hide'); + if (modeToggle) modeToggle.classList.add('enh-results-active-hide'); + if (slskdPlaceholder) slskdPlaceholder.classList.add('enh-results-active-hide'); + } + + function hideDropdown() { + const dropdown = document.getElementById('enhanced-dropdown'); + if (dropdown) dropdown.classList.add('hidden'); + // Restore hidden elements + const header = document.querySelector('#search-page .downloads-header'); + const modeToggle = document.querySelector('.search-source-picker-container'); + const slskdPlaceholder = document.querySelector('#enhanced-search-section .search-results-container'); + if (header) header.classList.remove('enh-results-active-hide'); + if (modeToggle) modeToggle.classList.remove('enh-results-active-hide'); + if (slskdPlaceholder) slskdPlaceholder.classList.remove('enh-results-active-hide'); + } +} + +async function performSearch() { + const query = document.getElementById('search-input').value.trim(); + if (!query) { + showToast('Please enter a search term', 'error'); + return; + } + + try { + showLoadingOverlay('Searching...'); + displaySearchResults([]); // Clear previous results + + const response = await fetch(API.search, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + + const data = await response.json(); + + if (data.error) { + showToast(`Search error: ${data.error}`, 'error'); + return; + } + + searchResults = data.results || []; + displaySearchResults(searchResults); + + if (searchResults.length === 0) { + showToast('No results found', 'error'); + } else { + showToast(`Found ${searchResults.length} results`, 'success'); + } + + } catch (error) { + console.error('Error performing search:', error); + showToast('Search failed', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +function displaySearchResults(results) { + const resultsContainer = document.getElementById('search-results'); + + if (!results.length) { + resultsContainer.innerHTML = '
No search results
'; + return; + } + + resultsContainer.innerHTML = results.map((result, index) => { + const isAlbum = result.type === 'album'; + const sizeText = isAlbum ? + `${result.track_count || 0} tracks, ${(result.size_mb || 0).toFixed(1)} MB` : + `${(result.file_size / 1024 / 1024).toFixed(1)} MB, ${result.bitrate || 0}kbps`; + + return ` +
+
+
+
${escapeHtml(result.title)}
+
${escapeHtml(result.artist)}
+ ${result.album ? `
${escapeHtml(result.album)}
` : ''} +
+
+ + +
+
+
+ ${sizeText} + by ${escapeHtml(result.username)} + ${result.quality ? `${escapeHtml(result.quality)}` : ''} +
+
+ `; + }).join(''); +} + +function selectResult(index) { + const result = searchResults[index]; + if (!result) return; + + console.log('Selected result:', result); + // Could show detailed view or additional actions here +} + + +async function startDownload(index) { + const result = searchResults[index]; + if (!result) return; + + try { + const response = await fetch('/api/downloads/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Download started', 'success'); + } else { + showToast(`Download failed: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error starting download:', error); + showToast('Failed to start download', 'error'); + } +} + +// =============================== +// PAGE DATA LOADING +// =============================== + +async function loadInitialData() { + try { + // Load artist bubble state first + await hydrateArtistBubblesFromSnapshot(); + + // Load search bubble state + await hydrateSearchBubblesFromSnapshot(); + + // Load discover download state + await hydrateDiscoverDownloadsFromSnapshot(); + + // Navigate to user's home page (or dashboard for admin) + const homePage = getProfileHomePage(); + const urlPage = _getPageFromPath(); + const targetPage = (urlPage && urlPage !== 'dashboard' && isPageAllowed(urlPage)) + ? urlPage + : homePage; + + history.replaceState({ page: targetPage }, '', (targetPage === 'dashboard' ? '/' : '/' + targetPage) + window.location.search + window.location.hash); + + if (targetPage !== 'dashboard') { + navigateToPage(targetPage, { skipPushState: true }); + } else { + await loadDashboardData(); + loadDashboardSyncHistory(); + } + } catch (error) { + console.error('Error loading initial data:', error); + } +} + +async function loadDashboardData() { + try { + const response = await fetch(API.activity); + const data = await response.json(); + + const activityFeed = document.getElementById('activity-feed'); + if (data.activities && data.activities.length) { + activityFeed.innerHTML = data.activities.map(activity => ` +
+ ${activity.time} + ${escapeHtml(activity.text)} +
+ `).join(''); + } + + // Initialize wishlist count when dashboard loads + await updateWishlistCount(); + + // Start periodic refresh of wishlist count (every 30 seconds, matching GUI behavior) + stopWishlistCountPolling(); // Ensure no duplicates + wishlistCountInterval = setInterval(updateWishlistCount, 30000); + + } catch (error) { + console.error('Error loading dashboard data:', error); + } +} + +// =========================================== + diff --git a/webui/static/settings.js b/webui/static/settings.js new file mode 100644 index 00000000..7d10dabf --- /dev/null +++ b/webui/static/settings.js @@ -0,0 +1,3658 @@ +// SUPPORT MODAL +// =============================== + +function showSupportModal() { + const overlay = document.getElementById('support-modal-overlay'); + if (overlay) overlay.classList.remove('hidden'); +} + +function closeSupportModal() { + const overlay = document.getElementById('support-modal-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +async function copyAddress(address, cryptoName) { + try { + // navigator.clipboard requires HTTPS — use fallback for HTTP (Docker) + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(address); + } else { + const textarea = document.createElement('textarea'); + textarea.value = address; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + showToast(`${cryptoName} address copied to clipboard`, 'success'); + } catch (error) { + console.error('Failed to copy address:', error); + // Show the address so user can copy manually + showToast(`${cryptoName}: ${address}`, 'info'); + } +} + +// =============================== +// SETTINGS FUNCTIONALITY +// =============================== + +let settingsAutoSaveTimer = null; + +function debouncedAutoSaveSettings() { + if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); + settingsAutoSaveTimer = setTimeout(() => saveSettings(true), 2000); +} + +function handleManualSaveClick() { + if (settingsAutoSaveTimer) clearTimeout(settingsAutoSaveTimer); + saveSettings(false); +} + +function initializeSettings() { + // This function is called when the settings page is loaded. + // It attaches event listeners to all interactive elements on the page. + + // Accent color listeners (live preview + custom picker toggle) + initAccentColorListeners(); + + // Main save button (manual save, non-quiet) + // Uses named function reference so addEventListener deduplicates across repeated calls + const saveButton = document.getElementById('save-settings'); + if (saveButton) { + saveButton.addEventListener('click', handleManualSaveClick); + } + + // Debounced auto-save on all settings inputs + // Uses named function reference (debouncedAutoSaveSettings) so addEventListener deduplicates + const settingsPage = document.getElementById('settings-page'); + if (settingsPage) { + settingsPage.querySelectorAll('input[type="text"], input[type="url"], input[type="password"], input[type="number"], input[type="range"]').forEach(input => { + input.addEventListener('input', debouncedAutoSaveSettings); + }); + settingsPage.querySelectorAll('input[type="checkbox"], select').forEach(input => { + input.addEventListener('change', debouncedAutoSaveSettings); + }); + } + + // Server toggle buttons + const plexToggle = document.getElementById('plex-toggle'); + if (plexToggle) { + plexToggle.addEventListener('click', () => toggleServer('plex')); + } + const jellyfinToggle = document.getElementById('jellyfin-toggle'); + if (jellyfinToggle) { + jellyfinToggle.addEventListener('click', () => toggleServer('jellyfin')); + } + + // Auto-detect buttons + const detectSlskdBtn = document.querySelector('#soulseek-url + .detect-button'); + if (detectSlskdBtn) { + detectSlskdBtn.addEventListener('click', autoDetectSlskd); + } + const detectPlexBtn = document.querySelector('#plex-container .detect-button'); + if (detectPlexBtn) { + detectPlexBtn.addEventListener('click', autoDetectPlex); + } + const detectJellyfinBtn = document.querySelector('#jellyfin-container .detect-button'); + if (detectJellyfinBtn) { + detectJellyfinBtn.addEventListener('click', autoDetectJellyfin); + } + + // Test connection buttons + // Test button event listeners removed - they use onclick attributes in HTML to avoid double firing +} + +function resetFileOrganizationTemplates() { + // Reset templates to defaults + const defaults = { + album: '$albumartist/$albumartist - $album/$track - $title', + single: '$artist/$artist - $title/$title', + playlist: '$playlist/$artist - $title', + video: '$artist/$title-video' + }; + + document.getElementById('template-album-path').value = defaults.album; + document.getElementById('template-single-path').value = defaults.single; + document.getElementById('template-playlist-path').value = defaults.playlist; + document.getElementById('template-video-path').value = defaults.video; + + debouncedAutoSaveSettings(); +} + +function validateFileOrganizationTemplates() { + const errors = []; + + // Valid variables for each template type + const validVars = { + album: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$disc', '$discnum', '$cdnum', '$year', '$quality'], + single: ['$artist', '$albumartist', '$artistletter', '$album', '$albumtype', '$title', '$track', '$year', '$quality'], + playlist: ['$artist', '$artistletter', '$playlist', '$title', '$year', '$quality'], + video: ['$artist', '$artistletter', '$title', '$year'] + }; + + // Get template values + const albumPath = document.getElementById('template-album-path').value.trim(); + const singlePath = document.getElementById('template-single-path').value.trim(); + const playlistPath = document.getElementById('template-playlist-path').value.trim(); + + // Validate album template + if (albumPath) { + if (albumPath.endsWith('/')) { + errors.push('Album template cannot end with /'); + } + if (albumPath.startsWith('/')) { + errors.push('Album template cannot start with /'); + } + if (!albumPath.includes('/')) { + errors.push('Album template must include at least one folder (use / separator)'); + } + if (albumPath.includes('//')) { + errors.push('Album template cannot have consecutive slashes //'); + } + // Check for likely typos of valid variables (case-insensitive to catch $Album, $ARTIST, etc.) + const albumVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; + const foundVars = albumPath.match(albumVarPattern) || []; + foundVars.forEach(v => { + // Normalize ${var} to $var for validation + const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; + const lowerVar = normalized.toLowerCase(); + // Check if lowercase version exists in valid vars + const isValid = validVars.album.some(validVar => validVar.toLowerCase() === lowerVar); + if (!isValid) { + errors.push(`Invalid variable "${normalized}" in album template. Valid: ${validVars.album.join(', ')}`); + } else if (normalized !== lowerVar && validVars.album.includes(lowerVar)) { + // Variable is valid but has wrong case + errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); + } + }); + } + + // Validate single template + if (singlePath) { + if (singlePath.endsWith('/')) { + errors.push('Single template cannot end with /'); + } + if (singlePath.startsWith('/')) { + errors.push('Single template cannot start with /'); + } + // Note: single template is allowed to have no slash (flat file: "$artist - $title") + if (singlePath.includes('//')) { + errors.push('Single template cannot have consecutive slashes //'); + } + const singleVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; + const foundVars = singlePath.match(singleVarPattern) || []; + foundVars.forEach(v => { + const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; + const lowerVar = normalized.toLowerCase(); + const isValid = validVars.single.some(validVar => validVar.toLowerCase() === lowerVar); + if (!isValid) { + errors.push(`Invalid variable "${normalized}" in single template. Valid: ${validVars.single.join(', ')}`); + } else if (normalized !== lowerVar && validVars.single.includes(lowerVar)) { + errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); + } + }); + } + + // Validate playlist template + if (playlistPath) { + if (playlistPath.endsWith('/')) { + errors.push('Playlist template cannot end with /'); + } + if (playlistPath.startsWith('/')) { + errors.push('Playlist template cannot start with /'); + } + if (!playlistPath.includes('/')) { + errors.push('Playlist template must include at least one folder (use / separator)'); + } + if (playlistPath.includes('//')) { + errors.push('Playlist template cannot have consecutive slashes //'); + } + const playlistVarPattern = /\$\{([a-zA-Z]+)\}|\$([a-zA-Z]+)/g; + const foundVars = playlistPath.match(playlistVarPattern) || []; + foundVars.forEach(v => { + const normalized = v.startsWith('${') ? '$' + v.slice(2, -1) : v; + const lowerVar = normalized.toLowerCase(); + const isValid = validVars.playlist.some(validVar => validVar.toLowerCase() === lowerVar); + if (!isValid) { + errors.push(`Invalid variable "${normalized}" in playlist template. Valid: ${validVars.playlist.join(', ')}`); + } else if (normalized !== lowerVar && validVars.playlist.includes(lowerVar)) { + errors.push(`Variable "${normalized}" should be lowercase: "${lowerVar}"`); + } + }); + } + + return errors; +} + +// Settings redesign — tab switching + service accordions +function switchSettingsTab(tab) { + // Update tab bar + document.querySelectorAll('.stg-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + // Show/hide settings groups and section headers by data-stg attribute + document.querySelectorAll('#settings-page [data-stg]').forEach(g => { + g.style.display = g.dataset.stg === tab ? '' : 'none'; + }); + // Re-apply collapsed state on section bodies (tab switch resets inline display) + document.querySelectorAll('#settings-page .settings-section-body.collapsed').forEach(b => { + b.style.display = 'none'; + }); + // Also hide/show the column wrappers if they're empty in this tab + document.querySelectorAll('#settings-page .settings-left-column, #settings-page .settings-right-column, #settings-page .settings-third-column').forEach(col => { + const hasVisible = Array.from(col.querySelectorAll('.settings-group[data-stg]')).some(g => g.style.display !== 'none'); + col.style.display = hasVisible ? '' : 'none'; + }); + // Re-apply conditional visibility (quality profile, source containers, etc.) + if (typeof updateDownloadSourceUI === 'function') { + try { updateDownloadSourceUI(); } catch (e) { } + } + // Load DB maintenance info when switching to Advanced tab + if (tab === 'advanced' && typeof loadDbMaintenanceInfo === 'function') { + try { loadDbMaintenanceInfo(); } catch (e) { } + } + // Initialize live log viewer when switching to Logs tab + if (tab === 'logs') { + _logViewerInit(); + } else { + _logViewerStop(); + } + // Refresh the green/yellow header gradient when arriving on Connections + if (tab === 'connections') { + try { applyServiceStatusGradients(); } catch (e) { } + } +} + +// ── Settings → Connections: per-service status gradient + verify wiring ── +// Gradient shows green when the user has filled in credentials, yellow when empty. +// It's based purely on config presence (cheap, no API calls). The verify layer — +// which runs on expand / Expand All — surfaces whether those credentials actually +// work, via an inline warning bar inside the expanded panel. + +let _stgServiceStatusState = {}; // service -> {configured: bool} +let _stgServiceVerifyInFlight = {}; // service -> true while a verify call is running + +async function applyServiceStatusGradients() { + try { + const resp = await fetch('/api/settings/config-status'); + if (!resp.ok) return; + const data = await resp.json(); + _stgServiceStatusState = data || {}; + document.querySelectorAll('#settings-page .stg-service[data-service]').forEach(card => { + const service = card.getAttribute('data-service'); + const header = card.querySelector('.stg-service-header'); + if (!service || !header) return; + const configured = !!(data[service] && data[service].configured); + header.classList.toggle('status-configured', configured); + header.classList.toggle('status-missing', !configured); + // Ensure the header has a spinner placeholder for the verify-checking state + if (!header.querySelector('.stg-service-verify-spinner')) { + const spinner = document.createElement('span'); + spinner.className = 'stg-service-verify-spinner'; + // Insert before the chevron on the right + const chevron = header.querySelector('.stg-service-chevron'); + if (chevron) header.insertBefore(spinner, chevron); + else header.appendChild(spinner); + } + }); + } catch (e) { + console.warn('[Settings Status] Failed to apply gradients:', e); + } +} + +function _stgSetCheckingState(service, isChecking) { + const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); + if (!card) return; + const header = card.querySelector('.stg-service-header'); + const body = card.querySelector('.stg-service-body'); + if (header) { + header.classList.toggle('status-checking', !!isChecking); + // Lazy-create the spinner element so it's there even if + // applyServiceStatusGradients() hasn't run yet. + if (!header.querySelector('.stg-service-verify-spinner')) { + const spinner = document.createElement('span'); + spinner.className = 'stg-service-verify-spinner'; + const chevron = header.querySelector('.stg-service-chevron'); + if (chevron) header.insertBefore(spinner, chevron); + else header.appendChild(spinner); + } + } + if (!body) return; + const existing = body.querySelector('.stg-service-verify-status'); + if (isChecking) { + if (!existing) { + const status = document.createElement('div'); + status.className = 'stg-service-verify-status'; + status.textContent = 'Testing connection…'; + body.insertBefore(status, body.firstChild); + } + } else if (existing) { + existing.remove(); + } +} + +function _stgShowVerifyWarning(service, message) { + const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); + if (!card) return; + const body = card.querySelector('.stg-service-body'); + if (!body) return; + const existing = body.querySelector('.stg-service-warning'); + if (existing) existing.remove(); + const warning = document.createElement('div'); + warning.className = 'stg-service-warning'; + warning.innerHTML = ` + + + `; + warning.querySelector('.stg-service-warning-text').textContent = + message || 'Connection test failed.'; + body.insertBefore(warning, body.firstChild); +} + +function _stgClearVerifyWarning(service) { + const card = document.querySelector(`#settings-page .stg-service[data-service="${service}"]`); + if (!card) return; + const existing = card.querySelector('.stg-service-warning'); + if (existing) existing.remove(); +} + +async function _stgRefreshAfterSave() { + // Called after a successful settings save. Cheap gradient refresh always, + // plus re-verify any cards the user currently has expanded (so they see + // immediate feedback on credentials they just edited). Collapsed cards + // keep their cached verify result until the user expands them. + try { + await applyServiceStatusGradients(); + const expandedServices = Array.from( + document.querySelectorAll('#settings-page .stg-service.expanded[data-service]') + ) + .map(card => card.getAttribute('data-service')) + .filter(Boolean); + if (expandedServices.length > 0) { + _stgVerifyServices(expandedServices, { force: true }); + } + } catch (e) { + console.warn('[Settings Status] Post-save refresh failed:', e); + } +} + +async function _stgVerifyServices(services, { force = false } = {}) { + if (!services || !services.length) return {}; + // Mark all as checking immediately so the user sees spinners/status lines + services.forEach(svc => { + _stgServiceVerifyInFlight[svc] = true; + _stgSetCheckingState(svc, true); + _stgClearVerifyWarning(svc); + }); + try { + const url = '/api/settings/verify' + (force ? '?force=true' : ''); + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ services }) + }); + const data = await resp.json(); + services.forEach(svc => { + _stgServiceVerifyInFlight[svc] = false; + _stgSetCheckingState(svc, false); + const result = data[svc]; + if (result && result.success === false) { + _stgShowVerifyWarning(svc, result.error || result.message || ''); + } + }); + return data; + } catch (e) { + console.warn('[Settings Verify] Network error:', e); + services.forEach(svc => { + _stgServiceVerifyInFlight[svc] = false; + _stgSetCheckingState(svc, false); + _stgShowVerifyWarning(svc, 'Unable to reach the verification endpoint.'); + }); + return {}; + } +} + +function toggleStgService(el) { + const service = el.closest('.stg-service'); + if (service) { + const wasExpanded = service.classList.contains('expanded'); + service.classList.toggle('expanded'); + // Fire verify when expanding a single card (not on collapse). The backend + // caches per service for 5 min, so rapid expand/collapse won't re-ping. + if (!wasExpanded) { + const serviceName = service.getAttribute('data-service'); + if (serviceName && !_stgServiceVerifyInFlight[serviceName]) { + _stgVerifyServices([serviceName]); + } + } + } +} +function toggleAllServiceAccordions(btn) { + const services = document.querySelectorAll('#settings-page .stg-service'); + const allExpanded = Array.from(services).every(s => s.classList.contains('expanded')); + const willExpand = !allExpanded; + services.forEach(s => s.classList.toggle('expanded', willExpand)); + btn.textContent = allExpanded ? 'Expand All' : 'Collapse All'; + + // On Expand All, fire a single batched verify for every service that has a + // data-service attribute. Backend caps concurrency at 3 to avoid rate limits. + // Skipped on Collapse All. + if (willExpand) { + const serviceNames = Array.from(services) + .map(s => s.getAttribute('data-service')) + .filter(Boolean) + .filter(name => !_stgServiceVerifyInFlight[name]); + if (serviceNames.length > 0) { + _stgVerifyServices(serviceNames); + } + } +} + +// ── Hybrid source priority list (drag-and-drop) ── +const HYBRID_SOURCES = [ + { id: 'soulseek', name: 'Soulseek', icon: 'https://raw.githubusercontent.com/slskd/slskd/master/docs/icon.png', emoji: '🎵' }, + { id: 'youtube', name: 'YouTube', icon: 'https://www.svgrepo.com/show/13671/youtube.svg', emoji: '▶️' }, + { id: 'tidal', name: 'Tidal', icon: 'https://www.svgrepo.com/show/519734/tidal.svg', emoji: '🌊' }, + { id: 'qobuz', name: 'Qobuz', icon: 'https://www.svgrepo.com/show/504778/qobuz.svg', emoji: '🎧' }, + { id: 'hifi', name: 'HiFi', icon: null, emoji: '🎶' }, + { id: 'deezer_dl', name: 'Deezer', icon: 'https://www.svgrepo.com/show/519734/deezer.svg', emoji: '🎧' }, + { id: 'lidarr', name: 'Lidarr', icon: null, emoji: '📦' }, +]; + +let _hybridSourceOrder = ['soulseek', 'youtube']; +let _hybridSourceEnabled = { soulseek: true, youtube: true, tidal: false, qobuz: false, hifi: false, deezer_dl: false, lidarr: false }; +let _hybridVisualOrder = null; // Full visual order including disabled sources + +function buildHybridSourceList() { + const container = document.getElementById('hybrid-source-list'); + if (!container) return; + + container.innerHTML = ''; + // Build visual order: use persisted visual order, or enabled first + disabled at bottom + if (!_hybridVisualOrder) { + _hybridVisualOrder = [..._hybridSourceOrder]; + for (const src of HYBRID_SOURCES) { + if (!_hybridVisualOrder.includes(src.id)) _hybridVisualOrder.push(src.id); + } + } + const allIds = _hybridVisualOrder; + + allIds.forEach((srcId, idx) => { + const src = HYBRID_SOURCES.find(s => s.id === srcId); + if (!src) return; + const enabled = _hybridSourceEnabled[srcId] !== false; + const isInOrder = _hybridSourceOrder.includes(srcId); + const priorityNum = isInOrder && enabled ? _hybridSourceOrder.indexOf(srcId) + 1 : ''; + + const item = document.createElement('div'); + item.className = `hybrid-source-item${enabled ? '' : ' disabled'}`; + item.draggable = true; + item.dataset.sourceId = srcId; + + item.innerHTML = ` + + + + + ${src.icon + ? `${src.name}` + : `${src.emoji}` + } + ${src.name} + ${priorityNum} + + `; + + container.appendChild(item); + }); + + // Sync hidden selects for backward compat + _syncHybridHiddenSelects(); +} + +function moveHybridSource(srcId, direction) { + if (!_hybridVisualOrder) return; + const idx = _hybridVisualOrder.indexOf(srcId); + if (idx < 0) return; + const newIdx = idx + direction; + if (newIdx < 0 || newIdx >= _hybridVisualOrder.length) return; + + // Swap in visual order + [_hybridVisualOrder[idx], _hybridVisualOrder[newIdx]] = [_hybridVisualOrder[newIdx], _hybridVisualOrder[idx]]; + + // Rebuild enabled order from visual order + _hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false); + buildHybridSourceList(); + updateDownloadSourceUI(); + debouncedAutoSaveSettings(); +} + +function toggleHybridSource(srcId, enabled) { + _hybridSourceEnabled[srcId] = enabled; + // Rebuild enabled order from visual order so priority matches position + if (_hybridVisualOrder) { + _hybridSourceOrder = _hybridVisualOrder.filter(id => _hybridSourceEnabled[id] !== false); + } + buildHybridSourceList(); + updateDownloadSourceUI(); + debouncedAutoSaveSettings(); +} + +function _syncHybridOrderFromDOM() { + const container = document.getElementById('hybrid-source-list'); + if (!container) return; + const items = container.querySelectorAll('.hybrid-source-item'); + const newOrder = []; + items.forEach(item => { + const id = item.dataset.sourceId; + if (_hybridSourceEnabled[id] !== false) { + newOrder.push(id); + } + }); + _hybridSourceOrder = newOrder; +} + +function _syncHybridHiddenSelects() { + // Keep hidden selects in sync for backward compat with saveSettings + const primary = document.getElementById('hybrid-primary-source'); + const secondary = document.getElementById('hybrid-secondary-source'); + if (primary && _hybridSourceOrder.length > 0) primary.value = _hybridSourceOrder[0]; + if (secondary && _hybridSourceOrder.length > 1) secondary.value = _hybridSourceOrder[1]; +} + +function getHybridOrder() { + return _hybridSourceOrder.filter(s => _hybridSourceEnabled[s] !== false); +} + +function loadHybridSourceOrder(settings) { + const order = settings.download_source?.hybrid_order; + const sourceStatus = settings._source_status || {}; + + if (order && Array.isArray(order) && order.length > 0) { + _hybridSourceOrder = order; + _hybridSourceEnabled = {}; + for (const src of HYBRID_SOURCES) { + _hybridSourceEnabled[src.id] = order.includes(src.id); + } + } else { + // Legacy: fall back to primary/secondary + const primary = settings.download_source?.hybrid_primary || 'soulseek'; + const secondary = settings.download_source?.hybrid_secondary || 'youtube'; + _hybridSourceOrder = [primary, secondary]; + _hybridSourceEnabled = {}; + for (const src of HYBRID_SOURCES) { + _hybridSourceEnabled[src.id] = src.id === primary || src.id === secondary; + } + } + + // Auto-disable sources that aren't configured on the server + let changed = false; + for (const src of HYBRID_SOURCES) { + if (_hybridSourceEnabled[src.id] && sourceStatus[src.id] === false) { + _hybridSourceEnabled[src.id] = false; + changed = true; + } + } + if (changed) { + _hybridSourceOrder = _hybridSourceOrder.filter(id => _hybridSourceEnabled[id] !== false); + } + + _hybridVisualOrder = null; // Reset so buildHybridSourceList rebuilds it + buildHybridSourceList(); +} + +function updateLossyBitrateOptions() { + const codec = document.getElementById('lossy-copy-codec')?.value || 'mp3'; + const bitrateSelect = document.getElementById('lossy-copy-bitrate'); + if (!bitrateSelect) return; + const opt320 = bitrateSelect.querySelector('option[value="320"]'); + if (codec === 'opus') { + // Opus max is 256kbps per channel — hide 320 option + if (opt320) opt320.disabled = true; + if (bitrateSelect.value === '320') bitrateSelect.value = '256'; + } else { + if (opt320) opt320.disabled = false; + } +} + +function updatePlexConfigurationButtons() { + const plexUrl = document.getElementById('plex-url'); + const plexToken = document.getElementById('plex-token'); + const hasPlexConfig = Boolean((plexUrl?.value || '').trim() || (plexToken?.value || '').trim()); + const plexViewConfigButton = document.getElementById('plex-view-config-button'); + const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); + const plexManualConfigButton = document.getElementById('plex-manual-config-button'); + const plexUrlActions = document.getElementById('plex-url-actions'); + const plexTokenActions = document.getElementById('plex-token-actions'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); + + if (plexViewConfigButton) plexViewConfigButton.style.display = hasPlexConfig ? '' : 'none'; + if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = hasPlexConfig ? 'none' : ''; + if (plexManualConfigButton) plexManualConfigButton.style.display = hasPlexConfig ? 'none' : ''; + if (plexUrlActions) plexUrlActions.style.display = hasPlexConfig ? 'none' : 'flex'; + if (plexTokenActions) plexTokenActions.style.display = hasPlexConfig ? 'none' : 'flex'; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; +} + +async function loadSettingsData() { + try { + const response = await fetch(API.settings); + const settings = await response.json(); + + // Populate Spotify settings + document.getElementById('spotify-client-id').value = settings.spotify?.client_id || ''; + document.getElementById('spotify-client-secret').value = settings.spotify?.client_secret || ''; + document.getElementById('spotify-redirect-uri').value = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; + document.getElementById('spotify-callback-display').textContent = settings.spotify?.redirect_uri || 'http://127.0.0.1:8888/callback'; + + // Populate Tidal settings + document.getElementById('tidal-client-id').value = settings.tidal?.client_id || ''; + document.getElementById('tidal-client-secret').value = settings.tidal?.client_secret || ''; + document.getElementById('tidal-redirect-uri').value = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; + document.getElementById('tidal-callback-display').textContent = settings.tidal?.redirect_uri || 'http://127.0.0.1:8889/tidal/callback'; + + // Populate Deezer OAuth settings + document.getElementById('deezer-app-id').value = settings.deezer?.app_id || ''; + document.getElementById('deezer-app-secret').value = settings.deezer?.app_secret || ''; + document.getElementById('deezer-redirect-uri').value = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback'; + document.getElementById('deezer-callback-display').textContent = settings.deezer?.redirect_uri || 'http://127.0.0.1:8008/deezer/callback'; + + // Add event listeners to update display URLs when input changes + document.getElementById('spotify-redirect-uri').addEventListener('input', function () { + document.getElementById('spotify-callback-display').textContent = this.value || 'http://127.0.0.1:8888/callback'; + }); + + document.getElementById('tidal-redirect-uri').addEventListener('input', function () { + document.getElementById('tidal-callback-display').textContent = this.value || 'http://127.0.0.1:8889/tidal/callback'; + }); + + document.getElementById('deezer-redirect-uri').addEventListener('input', function () { + document.getElementById('deezer-callback-display').textContent = this.value || 'http://127.0.0.1:8008/deezer/callback'; + }); + + // Populate Plex settings + const plexUrlInput = document.getElementById('plex-url'); + const plexTokenInput = document.getElementById('plex-token'); + if (plexUrlInput) plexUrlInput.value = settings.plex?.base_url || ''; + if (plexTokenInput) plexTokenInput.value = settings.plex?.token || ''; + if (plexUrlInput) plexUrlInput.addEventListener('input', updatePlexConfigurationButtons); + if (plexTokenInput) plexTokenInput.addEventListener('input', updatePlexConfigurationButtons); + updatePlexConfigurationButtons(); + + // Populate Jellyfin settings + document.getElementById('jellyfin-url').value = settings.jellyfin?.base_url || ''; + document.getElementById('jellyfin-api-key').value = settings.jellyfin?.api_key || ''; + document.getElementById('jellyfin-timeout').value = settings.jellyfin?.api_timeout || 120; + + // Populate Navidrome settings + document.getElementById('navidrome-url').value = settings.navidrome?.base_url || ''; + document.getElementById('navidrome-username').value = settings.navidrome?.username || ''; + document.getElementById('navidrome-password').value = settings.navidrome?.password || ''; + + // Set active server and toggle visibility + const activeServer = settings.active_media_server || 'plex'; + toggleServer(activeServer); + + // Load Plex music libraries if Plex is the active server + if (activeServer === 'plex') { + loadPlexMusicLibraries(); + } + + // Load Jellyfin users and music libraries if Jellyfin is the active server + if (activeServer === 'jellyfin') { + loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); + } + + // Load Navidrome music folders if Navidrome is the active server + if (activeServer === 'navidrome') { + loadNavidromeMusicFolders(); + } + + // Populate Soulseek settings + document.getElementById('soulseek-url').value = settings.soulseek?.slskd_url || ''; + document.getElementById('soulseek-api-key').value = settings.soulseek?.api_key || ''; + document.getElementById('soulseek-search-timeout').value = settings.soulseek?.search_timeout || 60; + document.getElementById('soulseek-search-timeout-buffer').value = settings.soulseek?.search_timeout_buffer || 15; + document.getElementById('soulseek-min-peer-speed').value = settings.soulseek?.min_peer_upload_speed || 0; + document.getElementById('soulseek-max-peer-queue').value = settings.soulseek?.max_peer_queue || 0; + document.getElementById('soulseek-download-timeout').value = Math.round((settings.soulseek?.download_timeout || 600) / 60); + document.getElementById('soulseek-auto-clear-searches').checked = settings.soulseek?.auto_clear_searches !== false; + + // Populate ListenBrainz settings + document.getElementById('listenbrainz-base-url').value = settings.listenbrainz?.base_url || ''; + document.getElementById('listenbrainz-token').value = settings.listenbrainz?.token || ''; + + // Populate AcoustID settings + document.getElementById('acoustid-api-key').value = settings.acoustid?.api_key || ''; + document.getElementById('acoustid-enabled').checked = settings.acoustid?.enabled || false; + + // Populate Last.fm settings + document.getElementById('lastfm-api-key').value = settings.lastfm?.api_key || ''; + document.getElementById('lastfm-api-secret').value = settings.lastfm?.api_secret || ''; + document.getElementById('lastfm-scrobble-enabled').checked = settings.lastfm?.scrobble_enabled === true; + const lfmStatus = document.getElementById('lastfm-scrobble-status'); + if (lfmStatus) { + lfmStatus.textContent = settings.lastfm?.session_key ? 'Authorized' : 'Not authorized'; + } + + // Populate ListenBrainz scrobble toggle + document.getElementById('listenbrainz-scrobble-enabled').checked = settings.listenbrainz?.scrobble_enabled === true; + + // Populate Genius settings + document.getElementById('genius-access-token').value = settings.genius?.access_token || ''; + + // Populate iTunes settings + document.getElementById('itunes-country').value = settings.itunes?.country || 'US'; + + // Populate Discogs settings + document.getElementById('discogs-token').value = settings.discogs?.token || ''; + + // Populate Metadata source setting + document.getElementById('metadata-fallback-source').value = settings.metadata?.fallback_source || 'itunes'; + + // Populate Hydrabase settings + const hbConfig = settings.hydrabase || {}; + document.getElementById('hydrabase-url').value = hbConfig.url || ''; + document.getElementById('hydrabase-api-key').value = hbConfig.api_key || ''; + document.getElementById('hydrabase-auto-connect').checked = hbConfig.auto_connect || false; + // Check live connection status + add Hydrabase to fallback dropdown if connected + fetch('/api/hydrabase/status').then(r => r.json()).then(s => { + const btn = document.getElementById('hydrabase-connect-btn'); + const statusEl = document.getElementById('hydrabase-settings-status'); + if (s.connected) { + if (btn) btn.textContent = 'Disconnect'; + if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } + // Add Hydrabase to fallback source dropdown + const fbSelect = document.getElementById('metadata-fallback-source'); + if (fbSelect && !fbSelect.querySelector('option[value="hydrabase"]')) { + const opt = document.createElement('option'); + opt.value = 'hydrabase'; + opt.textContent = 'Hydrabase (P2P)'; + fbSelect.appendChild(opt); + } + // Restore selection if it was hydrabase + if ((settings.metadata?.fallback_source) === 'hydrabase') { + fbSelect.value = 'hydrabase'; + } + } + }).catch(() => { }); + + // Populate Download settings (right column) + document.getElementById('download-path').value = settings.soulseek?.download_path || './downloads'; + document.getElementById('transfer-path').value = settings.soulseek?.transfer_path || './Transfer'; + document.getElementById('staging-path').value = settings.import?.staging_path || './Staging'; + document.getElementById('music-videos-path').value = settings.library?.music_videos_path || './MusicVideos'; + + // Populate Download Source settings + document.getElementById('download-source-mode').value = settings.download_source?.mode || 'soulseek'; + document.getElementById('stream-source').value = settings.download_source?.stream_source || 'youtube'; + document.getElementById('max-concurrent-downloads').value = settings.download_source?.max_concurrent || '3'; + loadHybridSourceOrder(settings); + document.getElementById('tidal-download-quality').value = settings.tidal_download?.quality || 'lossless'; + document.getElementById('tidal-allow-fallback').checked = settings.tidal_download?.allow_fallback !== false; + document.getElementById('qobuz-quality').value = settings.qobuz?.quality || 'lossless'; + document.getElementById('qobuz-allow-fallback').checked = settings.qobuz?.allow_fallback !== false; + document.getElementById('hifi-download-quality').value = settings.hifi_download?.quality || 'lossless'; + document.getElementById('hifi-allow-fallback').checked = settings.hifi_download?.allow_fallback !== false; + document.getElementById('deezer-download-quality').value = settings.deezer_download?.quality || 'flac'; + document.getElementById('deezer-allow-fallback').checked = settings.deezer_download?.allow_fallback !== false; + document.getElementById('deezer-download-arl').value = settings.deezer_download?.arl || ''; + document.getElementById('lidarr-url').value = settings.lidarr_download?.url || ''; + document.getElementById('lidarr-api-key').value = settings.lidarr_download?.api_key || ''; + // Sync ARL to connections tab field + bidirectional listeners + const _connArl = document.getElementById('deezer-connection-arl'); + const _dlArl = document.getElementById('deezer-download-arl'); + if (_connArl) _connArl.value = settings.deezer_download?.arl || ''; + if (_connArl && _dlArl) { + _connArl.addEventListener('input', () => { _dlArl.value = _connArl.value; }); + _dlArl.addEventListener('input', () => { _connArl.value = _dlArl.value; }); + } + + // Populate YouTube settings + document.getElementById('youtube-cookies-browser').value = settings.youtube?.cookies_browser || ''; + document.getElementById('youtube-download-delay').value = settings.youtube?.download_delay ?? 3; + + // Update UI based on download source mode + updateDownloadSourceUI(); + + // Populate Database settings + document.getElementById('max-workers').value = settings.database?.max_workers || '5'; + + // Populate Post-Processing settings + document.getElementById('metadata-enabled').checked = settings.metadata_enhancement?.enabled !== false; + document.getElementById('embed-album-art').checked = settings.metadata_enhancement?.embed_album_art !== false; + document.getElementById('cover-art-download').checked = settings.metadata_enhancement?.cover_art_download !== false; + document.getElementById('prefer-caa-art').checked = settings.metadata_enhancement?.prefer_caa_art === true; + document.getElementById('lrclib-enabled').checked = settings.metadata_enhancement?.lrclib_enabled !== false; + document.getElementById('replaygain-enabled').checked = settings.post_processing?.replaygain_enabled === true; + // Load service master toggles + document.getElementById('embed-spotify').checked = settings.spotify?.embed_tags !== false; + document.getElementById('embed-itunes').checked = settings.itunes?.embed_tags !== false; + document.getElementById('embed-musicbrainz').checked = settings.musicbrainz?.embed_tags !== false; + document.getElementById('embed-deezer').checked = settings.deezer?.embed_tags !== false; + document.getElementById('embed-audiodb').checked = settings.audiodb?.embed_tags !== false; + document.getElementById('embed-tidal').checked = settings.tidal?.embed_tags !== false; + document.getElementById('embed-qobuz').checked = settings.qobuz?.embed_tags !== false; + document.getElementById('embed-lastfm').checked = settings.lastfm?.embed_tags !== false; + document.getElementById('embed-genius').checked = settings.genius?.embed_tags !== false; + // Load per-tag toggles from data-config attributes + document.querySelectorAll('[data-config]').forEach(cb => { + const path = cb.dataset.config.split('.'); + let val = settings; + for (const key of path) { val = val?.[key]; } + cb.checked = val !== false; + }); + // Apply service disabled state to child tags + ['spotify', 'itunes', 'musicbrainz', 'deezer', 'audiodb', 'tidal', 'qobuz', 'lastfm', 'genius'].forEach(svc => { + const master = document.getElementById('embed-' + svc); + if (master) toggleServiceTags(master, svc); + }); + document.getElementById('post-processing-options').style.display = settings.metadata_enhancement?.enabled !== false ? 'block' : 'none'; + + // Populate File Organization settings + document.getElementById('file-organization-enabled').checked = settings.file_organization?.enabled !== false; + document.getElementById('template-album-path').value = settings.file_organization?.templates?.album_path || '$albumartist/$albumartist - $album/$track - $title'; + document.getElementById('template-single-path').value = settings.file_organization?.templates?.single_path || '$artist/$artist - $title/$title'; + document.getElementById('template-playlist-path').value = settings.file_organization?.templates?.playlist_path || '$playlist/$artist - $title'; + document.getElementById('template-video-path').value = settings.file_organization?.templates?.video_path || '$artist/$title-video'; + document.getElementById('disc-label').value = settings.file_organization?.disc_label || 'Disc'; + document.getElementById('collab-artist-mode').value = settings.file_organization?.collab_artist_mode || 'first'; + document.getElementById('artist-separator').value = settings.metadata_enhancement?.tags?.artist_separator || ', '; + document.getElementById('write-multi-artist').checked = settings.metadata_enhancement?.tags?.write_multi_artist || false; + document.getElementById('feat-in-title').checked = settings.metadata_enhancement?.tags?.feat_in_title || false; + document.getElementById('allow-duplicate-tracks').checked = settings.wishlist?.allow_duplicate_tracks !== false; + + // Populate Playlist Sync settings + document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false; + + // Populate Post-Download Conversion settings + document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true; + document.getElementById('lossy-copy-enabled').checked = settings.lossy_copy?.enabled === true; + document.getElementById('lossy-copy-codec').value = settings.lossy_copy?.codec || 'mp3'; + document.getElementById('lossy-copy-bitrate').value = settings.lossy_copy?.bitrate || '320'; + updateLossyBitrateOptions(); + document.getElementById('lossy-copy-delete-original').checked = settings.lossy_copy?.delete_original === true; + + // Populate Listening Stats settings + document.getElementById('listening-stats-enabled').checked = settings.listening_stats?.enabled === true; + document.getElementById('listening-stats-interval').value = settings.listening_stats?.poll_interval || 30; + document.getElementById('lossy-copy-options').style.display = + settings.lossy_copy?.enabled ? 'block' : 'none'; + + // Populate Music Library Paths + const _musicPaths = settings.library?.music_paths || []; + renderMusicPaths(_musicPaths); + + // Populate Content Filter settings + document.getElementById('allow-explicit').checked = settings.content_filter?.allow_explicit !== false; + + // Populate Genre Whitelist + const gwEnabled = settings.genre_whitelist?.enabled === true; + document.getElementById('genre-whitelist-enabled').checked = gwEnabled; + const gwContainer = document.getElementById('genre-whitelist-container'); + if (gwContainer) gwContainer.style.display = gwEnabled ? '' : 'none'; + if (gwEnabled) { + _genreWhitelistRender(settings.genre_whitelist?.genres || []); + } + + // Populate Import settings + document.getElementById('import-replace-lower-quality').checked = settings.import?.replace_lower_quality === true; + + // Populate M3U Export settings + document.getElementById('m3u-export-enabled').checked = settings.m3u_export?.enabled === true; + document.getElementById('m3u-entry-base-path').value = settings.m3u_export?.entry_base_path || ''; + + // Populate UI Appearance settings + const accentPreset = settings.ui_appearance?.accent_preset || '#1db954'; + const accentCustom = settings.ui_appearance?.accent_color || '#1db954'; + const presetSelect = document.getElementById('accent-preset'); + const customPicker = document.getElementById('accent-custom-color'); + const customGroup = document.getElementById('custom-color-group'); + if (presetSelect) { + // Check if the saved preset matches a dropdown option + const presetOptions = Array.from(presetSelect.options).map(o => o.value); + if (presetOptions.includes(accentPreset)) { + presetSelect.value = accentPreset; + } else { + presetSelect.value = 'custom'; + } + if (presetSelect.value === 'custom') { + if (customGroup) customGroup.style.display = ''; + if (customPicker) customPicker.value = accentCustom; + applyAccentColor(accentCustom); + } else { + if (customGroup) customGroup.style.display = 'none'; + applyAccentColor(accentPreset); + } + } + + // Sidebar visualizer type + const vizType = settings.ui_appearance?.sidebar_visualizer || 'bars'; + const vizSelect = document.getElementById('sidebar-visualizer-type'); + if (vizSelect) vizSelect.value = vizType; + sidebarVisualizerType = vizType; + + // Background particles toggle + const particlesEnabled = settings.ui_appearance?.particles_enabled !== false; // default true + const particlesCheckbox = document.getElementById('particles-enabled'); + if (particlesCheckbox) particlesCheckbox.checked = particlesEnabled; + applyParticlesSetting(particlesEnabled); + + // Worker orbs toggle + const workerOrbsEnabled = settings.ui_appearance?.worker_orbs_enabled !== false; // default true + const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); + if (workerOrbsCheckbox) workerOrbsCheckbox.checked = workerOrbsEnabled; + applyWorkerOrbsSetting(workerOrbsEnabled); + + // Reduce effects toggle + const reduceEffects = settings.ui_appearance?.reduce_effects === true; // default false + const reduceCheckbox = document.getElementById('reduce-effects-enabled'); + if (reduceCheckbox) reduceCheckbox.checked = reduceEffects; + applyReduceEffects(reduceEffects); + + // Populate Logging information + const logLevelSelect = document.getElementById('log-level-select'); + if (logLevelSelect) logLevelSelect.value = settings.logging?.level || 'INFO'; + document.getElementById('log-path-display').textContent = settings.logging?.path || 'logs/app.log'; + + // Load Discovery Lookback Period setting + try { + const lookbackResponse = await fetch('/api/discovery/lookback-period'); + const lookbackData = await lookbackResponse.json(); + if (lookbackData.period) { + document.getElementById('discovery-lookback-period').value = lookbackData.period; + } + } catch (error) { + console.error('Error loading discovery lookback period:', error); + } + + // Load Hemisphere setting + try { + const hemiResponse = await fetch('/api/discovery/hemisphere'); + const hemiData = await hemiResponse.json(); + if (hemiData.hemisphere) { + document.getElementById('discovery-hemisphere').value = hemiData.hemisphere; + } + } catch (error) { + console.error('Error loading hemisphere setting:', error); + } + + // Load current log level + try { + const logLevelResponse = await fetch('/api/settings/log-level'); + const logLevelData = await logLevelResponse.json(); + if (logLevelData.success && logLevelData.level) { + document.getElementById('log-level-select').value = logLevelData.level; + } + } catch (error) { + console.error('Error loading log level:', error); + } + + // Load security settings + try { + const requirePin = settings.security?.require_pin_on_launch || false; + document.getElementById('security-require-pin').checked = requirePin; + + // Check if admin has a PIN set + const profilesRes = await fetch('/api/profiles'); + const profilesData = await profilesRes.json(); + const adminProfile = (profilesData.profiles || []).find(p => p.is_admin); + const adminHasPin = adminProfile?.has_pin || false; + + // Show/hide PIN setup vs change sections + document.getElementById('security-pin-setup').style.display = adminHasPin ? 'none' : 'block'; + document.getElementById('security-change-pin-section').style.display = adminHasPin ? 'block' : 'none'; + + // If no PIN, disable the toggle + if (!adminHasPin) { + document.getElementById('security-require-pin').checked = false; + document.getElementById('security-require-pin').disabled = true; + } + } catch (error) { + console.error('Error loading security settings:', error); + } + + // Check dev mode status + try { + const devResponse = await fetch('/api/dev-mode'); + const devData = await devResponse.json(); + if (devData.enabled) { + document.getElementById('dev-mode-status').textContent = 'Active'; + document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))'; + document.getElementById('hydrabase-nav').style.display = ''; + document.getElementById('hydrabase-button-container').style.display = ''; + } + } catch (error) { + console.error('Error checking dev mode:', error); + } + + } catch (error) { + console.error('Error loading settings:', error); + showToast('Failed to load settings', 'error'); + } +} + +async function changeLogLevel() { + const selector = document.getElementById('log-level-select'); + const level = selector.value; + + try { + const response = await fetch('/api/settings/log-level', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ level: level }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`Log level changed to ${level}`, 'success'); + console.log(`Log level changed to: ${level}`); + } else { + showToast(`Failed to change log level: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error changing log level:', error); + showToast('Failed to change log level', 'error'); + } +} + +function updateMediaServerFields() { + const serverType = document.getElementById('media-server-type').value; + const urlInput = document.getElementById('media-server-url'); + const tokenInput = document.getElementById('media-server-token'); + + if (serverType === 'plex') { + urlInput.placeholder = 'http://localhost:32400'; + tokenInput.placeholder = 'Plex Token'; + } else { + urlInput.placeholder = 'http://localhost:8096'; + tokenInput.placeholder = 'Jellyfin API Key'; + } +} + +let _plexPinAuthRequestId = null; +let _plexPinAuthPollInterval = null; + +function showPlexConfiguration(disableFields = false, isManualConfig = false) { + stopPlexPinAuthPolling(); + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); + const plexUrl = document.getElementById('plex-url'); + const plexToken = document.getElementById('plex-token'); + const plexLibraryContainer = document.getElementById('plex-library-selector-container'); + + if (plexConfig) plexConfig.style.display = ''; + if (plexSetup) plexSetup.style.display = 'none'; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; + if (plexUrl) plexUrl.disabled = disableFields; + if (plexToken) plexToken.disabled = disableFields; + if (plexLibraryContainer && isManualConfig) { + plexLibraryContainer.style.display = 'none'; + } + setPlexConfigActionButton(isManualConfig); + updatePlexConfigurationButtons(); +} + +function showPlexSetup() { + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + const plexPinAuthFlow = document.getElementById('plex-pin-auth-flow'); + const plexLibraryContainer = document.getElementById('plex-library-selector-container'); + + if (plexConfig) plexConfig.style.display = 'none'; + if (plexSetup) plexSetup.style.display = ''; + if (plexPinAuthFlow) plexPinAuthFlow.style.display = 'none'; + if (plexLibraryContainer) plexLibraryContainer.style.display = 'none'; + setPlexConfigActionButton(false); +} + +function setPlexConfigActionButton(isManualConfig) { + const actionButton = document.getElementById('plex-config-action-button'); + if (!actionButton) return; + + if (isManualConfig) { + actionButton.textContent = 'Cancel'; + actionButton.onclick = showPlexSetup; + actionButton.title = 'Cancel manual Plex configuration'; + } else { + actionButton.textContent = 'Clear Configuration'; + actionButton.onclick = clearPlexConfiguration; + actionButton.title = 'Clear saved Plex configuration'; + } +} + +async function startPlexPinAuth() { + const setupButtons = document.getElementById('plex-setup-buttons'); + const authFlow = document.getElementById('plex-pin-auth-flow'); + const statusEl = document.getElementById('plex-pin-status'); + if (setupButtons) setupButtons.style.display = 'none'; + if (authFlow) authFlow.style.display = ''; + if (statusEl) statusEl.textContent = 'Starting Plex authorization...'; + + try { + showLoadingOverlay('Starting Plex authorization...'); + const response = await fetch('/api/plex/pin/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const result = await response.json(); + if (!result.success) { + throw new Error(result.error || 'Failed to start Plex PIN flow'); + } + + _plexPinAuthRequestId = result.request_id; + const pinCodeEl = document.getElementById('plex-pin-code'); + if (pinCodeEl) pinCodeEl.textContent = result.code || ''; + if (statusEl) { + statusEl.textContent = result.expires_in + ? `Enter this code at plex.tv/link. Code expires in ${result.expires_in} seconds.` + : 'Enter this code at plex.tv/link. Waiting for authorization...'; + } + + startPlexPinAuthPolling(); + } catch (error) { + console.error('Plex PIN auth start failed:', error); + showToast(error.message || 'Failed to start Plex authorization', 'error'); + cancelPlexPinAuth(); + } finally { + hideLoadingOverlay(); + } +} + +function startPlexPinAuthPolling() { + stopPlexPinAuthPolling(); + if (!_plexPinAuthRequestId) return; + _plexPinAuthPollInterval = setInterval(pollPlexPinAuthStatus, 5000); + pollPlexPinAuthStatus(); +} + +function stopPlexPinAuthPolling() { + if (_plexPinAuthPollInterval) { + clearInterval(_plexPinAuthPollInterval); + _plexPinAuthPollInterval = null; + } +} + +async function pollPlexPinAuthStatus() { + if (!_plexPinAuthRequestId) return; + try { + const response = await fetch(`/api/plex/pin/status?request_id=${encodeURIComponent(_plexPinAuthRequestId)}`); + const result = await response.json(); + const statusEl = document.getElementById('plex-pin-status'); + + if (!result.success && result.expired) { + if (statusEl) statusEl.textContent = 'PIN code expired. Generate a new code to continue.'; + stopPlexPinAuthPolling(); + return; + } + + if (result.success) { + stopPlexPinAuthPolling(); + if (statusEl) statusEl.textContent = 'Authorization complete! Saving Plex configuration...'; + document.getElementById('plex-url').value = result.found_url || ''; + document.getElementById('plex-token').value = result.token || ''; + if (typeof saveSettings === 'function') { + await saveSettings(true); + } + showToast('Plex successfully linked', 'success'); + showPlexConfiguration(true); + await testConnection('plex'); + return; + } + + if (result.status) { + if (statusEl) statusEl.textContent = result.status; + return; + } + + if (result.error) { + if (statusEl) statusEl.textContent = result.error; + return; + } + } catch (error) { + console.error('Error polling Plex PIN status:', error); + const statusEl = document.getElementById('plex-pin-status'); + if (statusEl) statusEl.textContent = 'Unable to contact Plex auth status. Retrying...'; + } +} + +function cancelPlexPinAuth() { + stopPlexPinAuthPolling(); + _plexPinAuthRequestId = null; + const setupButtons = document.getElementById('plex-setup-buttons'); + const authFlow = document.getElementById('plex-pin-auth-flow'); + if (setupButtons) setupButtons.style.display = ''; + if (authFlow) authFlow.style.display = 'none'; +} + +function restartPlexPinAuth() { + cancelPlexPinAuth(); + startPlexPinAuth(); +} + +async function clearPlexConfiguration() { + cancelPlexPinAuth(); + const plexUrl = document.getElementById('plex-url'); + const plexToken = document.getElementById('plex-token'); + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + const plexSetupButtons = document.getElementById('plex-setup-buttons'); + const plexViewConfigButton = document.getElementById('plex-view-config-button'); + const plexLinkToPlexButton = document.getElementById('plex-link-to-plex-button'); + const plexManualConfigButton = document.getElementById('plex-manual-config-button'); + + if (plexUrl) plexUrl.value = ''; + if (plexToken) plexToken.value = ''; + if (plexConfig) plexConfig.style.display = 'none'; + if (plexSetup) plexSetup.style.display = ''; + if (plexSetupButtons) plexSetupButtons.style.display = ''; + if (plexViewConfigButton) plexViewConfigButton.style.display = 'none'; + if (plexLinkToPlexButton) plexLinkToPlexButton.style.display = ''; + if (plexManualConfigButton) plexManualConfigButton.style.display = ''; + + const plexLibraryContainer = document.getElementById('plex-library-selector-container'); + const plexLibrarySelect = document.getElementById('plex-music-library'); + if (plexLibrarySelect) { + plexLibrarySelect.innerHTML = ''; + } + if (plexLibraryContainer) { + plexLibraryContainer.style.display = 'none'; + } + + updatePlexConfigurationButtons(); + + try { + await fetch('/api/plex/clear-library', { method: 'POST' }); + } catch (e) { + console.warn('Failed to clear Plex library preference:', e); + } + + if (typeof saveSettings === 'function') { + saveSettings(true); + } + if (typeof showToast === 'function') { + showToast('Plex configuration cleared', 'success'); + } +} + +function toggleServer(serverType) { + // Update toggle buttons + document.getElementById('plex-toggle').classList.remove('active'); + document.getElementById('jellyfin-toggle').classList.remove('active'); + document.getElementById('navidrome-toggle').classList.remove('active'); + document.getElementById('soulsync-toggle')?.classList.remove('active'); + document.getElementById(`${serverType}-toggle`)?.classList.add('active'); + + // Show/hide server containers + document.getElementById('plex-container').classList.toggle('hidden', serverType !== 'plex'); + document.getElementById('jellyfin-container').classList.toggle('hidden', serverType !== 'jellyfin'); + document.getElementById('navidrome-container').classList.toggle('hidden', serverType !== 'navidrome'); + document.getElementById('soulsync-container')?.classList.toggle('hidden', serverType !== 'soulsync'); + + // Show Plex setup when Plex is selected; otherwise hide both Plex panels + const plexConfig = document.getElementById('plex-configuration'); + const plexSetup = document.getElementById('plex-setup'); + if (plexConfig) plexConfig.style.display = serverType === 'plex' ? 'none' : ''; + if (plexSetup) plexSetup.style.display = serverType === 'plex' ? '' : 'none'; + + // Load Plex music libraries when switching to Plex + if (serverType === 'plex') { + loadPlexMusicLibraries(); + } + + // Load Jellyfin users and music libraries when switching to Jellyfin + if (serverType === 'jellyfin') { + loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); + } + + // Load Navidrome music folders when switching to Navidrome + if (serverType === 'navidrome') { + loadNavidromeMusicFolders(); + } + + // Auto-save after server toggle change + debouncedAutoSaveSettings(); +} + +function updateDownloadSourceUI() { + const mode = document.getElementById('download-source-mode').value; + const hybridContainer = document.getElementById('hybrid-settings-container'); + const soulseekContainer = document.getElementById('soulseek-settings-container'); + const tidalContainer = document.getElementById('tidal-download-settings-container'); + const qobuzContainer = document.getElementById('qobuz-settings-container'); + const youtubeContainer = document.getElementById('youtube-settings-container'); + const hifiContainer = document.getElementById('hifi-download-settings-container'); + const deezerDlContainer = document.getElementById('deezer-download-settings-container'); + const lidarrContainer = document.getElementById('lidarr-download-settings-container'); + + hybridContainer.style.display = mode === 'hybrid' ? 'block' : 'none'; + + // Determine which sources are active + let activeSources = new Set(); + if (mode === 'hybrid') { + const order = getHybridOrder(); + for (const src of order) activeSources.add(src); + // Fallback: if no sources enabled, at least show soulseek + if (activeSources.size === 0) activeSources.add('soulseek'); + } else { + activeSources.add(mode); + } + + soulseekContainer.style.display = activeSources.has('soulseek') ? 'block' : 'none'; + tidalContainer.style.display = activeSources.has('tidal') ? 'block' : 'none'; + qobuzContainer.style.display = activeSources.has('qobuz') ? 'block' : 'none'; + youtubeContainer.style.display = activeSources.has('youtube') ? 'block' : 'none'; + hifiContainer.style.display = activeSources.has('hifi') ? 'block' : 'none'; + if (deezerDlContainer) deezerDlContainer.style.display = activeSources.has('deezer_dl') ? 'block' : 'none'; + if (lidarrContainer) lidarrContainer.style.display = activeSources.has('lidarr') ? 'block' : 'none'; + + // Quality profile is Soulseek-only and downloads-tab-only + const qualityProfileSection = document.getElementById('quality-profile-section'); + if (qualityProfileSection) { + const activeTab = document.querySelector('.stg-tab.active'); + const onDownloadsTab = activeTab && activeTab.dataset.tab === 'downloads'; + qualityProfileSection.style.display = (activeSources.has('soulseek') && onDownloadsTab) ? '' : 'none'; + } + + if (activeSources.has('tidal')) { + checkTidalDownloadAuthStatus(); + } + if (activeSources.has('qobuz')) { + checkQobuzAuthStatus(); + } + if (activeSources.has('hifi')) { + testHiFiConnection(); + } +} + +function updateHybridSecondaryOptions() { + const primary = document.getElementById('hybrid-primary-source').value; + const secondary = document.getElementById('hybrid-secondary-source'); + const currentValue = secondary.value; + const allSources = [ + { value: 'soulseek', label: 'Soulseek' }, + { value: 'youtube', label: 'YouTube' }, + { value: 'tidal', label: 'Tidal' }, + { value: 'qobuz', label: 'Qobuz' }, + { value: 'hifi', label: 'HiFi' }, + ]; + + secondary.innerHTML = ''; + for (const source of allSources) { + if (source.value === primary) continue; + const opt = document.createElement('option'); + opt.value = source.value; + opt.textContent = source.label; + secondary.appendChild(opt); + } + + // Restore previous selection if still valid, otherwise pick first available + if (currentValue !== primary) { + secondary.value = currentValue; + } + + // Refresh source-specific settings visibility based on new primary/secondary + updateDownloadSourceUI(); +} + +// =============================== +// QUALITY PROFILE FUNCTIONS +// =============================== + +let currentQualityProfile = null; + +async function loadQualityProfile() { + try { + const response = await fetch('/api/quality-profile'); + const data = await response.json(); + + if (data.success) { + currentQualityProfile = data.profile; + populateQualityProfileUI(currentQualityProfile); + } + } catch (error) { + console.error('Error loading quality profile:', error); + } +} + +function populateQualityProfileUI(profile) { + // Update preset buttons + document.querySelectorAll('.preset-button').forEach(btn => { + btn.classList.remove('active'); + }); + const activePresetBtn = document.querySelector(`.preset-button[onclick*="${profile.preset}"]`); + if (activePresetBtn) { + activePresetBtn.classList.add('active'); + } + + // Populate each quality tier + const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; + qualities.forEach(quality => { + const config = profile.qualities[quality]; + if (config) { + // Set enabled checkbox + const enabledCheckbox = document.getElementById(`quality-${quality}-enabled`); + if (enabledCheckbox) { + enabledCheckbox.checked = config.enabled; + } + + // Set min/max sliders + const minSlider = document.getElementById(`${quality}-min`); + const maxSlider = document.getElementById(`${quality}-max`); + if (minSlider && maxSlider) { + minSlider.value = config.min_kbps; + maxSlider.value = config.max_kbps; + updateQualityRange(quality); + } + + // Set priority display + const prioritySpan = document.getElementById(`priority-${quality}`); + if (prioritySpan) { + prioritySpan.textContent = `Priority: ${config.priority}`; + } + + // Toggle sliders visibility + const sliders = document.getElementById(`sliders-${quality}`); + if (sliders) { + if (config.enabled) { + sliders.classList.remove('disabled'); + } else { + sliders.classList.add('disabled'); + } + } + + // FLAC-specific: restore bit depth selector and fallback toggle + if (quality === 'flac') { + const bitDepthValue = config.bit_depth || 'any'; + document.querySelectorAll('.bit-depth-btn').forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-value') === bitDepthValue); + }); + const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); + if (bitDepthSelector) { + if (config.enabled) { + bitDepthSelector.classList.remove('disabled'); + } else { + bitDepthSelector.classList.add('disabled'); + } + } + // Show/hide and restore fallback toggle + const fallbackToggle = document.getElementById('flac-fallback-toggle'); + if (fallbackToggle) { + fallbackToggle.style.display = bitDepthValue === 'any' ? 'none' : 'block'; + } + const fallbackCb = document.getElementById('flac-bit-depth-fallback'); + if (fallbackCb) { + fallbackCb.checked = config.bit_depth_fallback !== false; + } + } + } + }); + + // Set fallback checkbox + const fallbackCheckbox = document.getElementById('quality-fallback-enabled'); + if (fallbackCheckbox) { + fallbackCheckbox.checked = profile.fallback_enabled; + } +} + +function updateQualityRange(quality) { + const minSlider = document.getElementById(`${quality}-min`); + const maxSlider = document.getElementById(`${quality}-max`); + const minValue = document.getElementById(`${quality}-min-value`); + const maxValue = document.getElementById(`${quality}-max-value`); + + if (!minSlider || !maxSlider || !minValue || !maxValue) return; + + let min = parseInt(minSlider.value); + let max = parseInt(maxSlider.value); + + // Ensure min doesn't exceed max + if (min > max) { + min = max; + minSlider.value = min; + } + + // Ensure max doesn't go below min + if (max < min) { + max = min; + maxSlider.value = max; + } + + minValue.textContent = `${min} kbps`; + maxValue.textContent = `${max} kbps`; +} + +function toggleQuality(quality) { + const checkbox = document.getElementById(`quality-${quality}-enabled`); + const sliders = document.getElementById(`sliders-${quality}`); + + if (checkbox && sliders) { + if (checkbox.checked) { + sliders.classList.remove('disabled'); + } else { + sliders.classList.add('disabled'); + } + } + + // Also toggle FLAC bit depth selector + if (quality === 'flac') { + const bitDepthSelector = document.getElementById('flac-bit-depth-selector'); + if (bitDepthSelector && checkbox) { + if (checkbox.checked) { + bitDepthSelector.classList.remove('disabled'); + } else { + bitDepthSelector.classList.add('disabled'); + } + } + } + + // Mark preset as custom when manually changing + if (currentQualityProfile) { + currentQualityProfile.preset = 'custom'; + document.querySelectorAll('.preset-button').forEach(btn => { + btn.classList.remove('active'); + }); + } +} + +function setFlacBitDepth(value) { + document.querySelectorAll('.bit-depth-btn').forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-value') === value); + }); + + // Show/hide fallback toggle — only relevant when a specific bit depth is selected + const fallbackToggle = document.getElementById('flac-fallback-toggle'); + if (fallbackToggle) { + fallbackToggle.style.display = value === 'any' ? 'none' : 'block'; + } + + // Mark preset as custom when manually changing + if (currentQualityProfile) { + currentQualityProfile.preset = 'custom'; + document.querySelectorAll('.preset-button').forEach(btn => { + btn.classList.remove('active'); + }); + } + + debouncedAutoSaveSettings(); +} + +function setFlacBitDepthFallback(enabled) { + if (currentQualityProfile) { + currentQualityProfile.preset = 'custom'; + document.querySelectorAll('.preset-button').forEach(btn => { + btn.classList.remove('active'); + }); + } + debouncedAutoSaveSettings(); +} + +async function applyQualityPreset(presetName) { + try { + showLoadingOverlay(`Applying ${presetName} preset...`); + + const response = await fetch(`/api/quality-profile/preset/${presetName}`, { + method: 'POST' + }); + + const data = await response.json(); + + if (data.success) { + currentQualityProfile = data.profile; + populateQualityProfileUI(currentQualityProfile); + showToast(`Applied '${presetName}' preset`, 'success'); + } else { + showToast(`Failed to apply preset: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error applying quality preset:', error); + showToast('Failed to apply preset', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +function collectQualityProfileFromUI() { + const profile = { + version: 2, + preset: 'custom', // Will be overridden if a preset is active + qualities: {}, + fallback_enabled: document.getElementById('quality-fallback-enabled')?.checked ?? true + }; + + const qualities = ['flac', 'mp3_320', 'mp3_256', 'mp3_192']; + + qualities.forEach((quality, index) => { + const enabled = document.getElementById(`quality-${quality}-enabled`)?.checked || false; + const minSlider = document.getElementById(`${quality}-min`); + const maxSlider = document.getElementById(`${quality}-max`); + + // Preserve priority from the currently loaded profile instead of using array order + const existingPriority = currentQualityProfile?.qualities?.[quality]?.priority ?? (index + 1); + + profile.qualities[quality] = { + enabled: enabled, + min_kbps: parseInt(minSlider?.value || 0), + max_kbps: parseInt(maxSlider?.value || 99999), + priority: existingPriority + }; + + // Add FLAC-specific bit_depth and fallback settings + if (quality === 'flac') { + const activeBtn = document.querySelector('.bit-depth-btn.active'); + profile.qualities[quality].bit_depth = activeBtn ? activeBtn.getAttribute('data-value') : 'any'; + const fallbackCb = document.getElementById('flac-bit-depth-fallback'); + profile.qualities[quality].bit_depth_fallback = fallbackCb ? fallbackCb.checked : true; + } + }); + + // Check if current profile matches a preset + if (currentQualityProfile && currentQualityProfile.preset !== 'custom') { + profile.preset = currentQualityProfile.preset; + } + + return profile; +} + +async function saveQualityProfile() { + try { + const profile = collectQualityProfileFromUI(); + + const response = await fetch('/api/quality-profile', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(profile) + }); + + const data = await response.json(); + + if (data.success) { + currentQualityProfile = profile; + console.log('Quality profile saved successfully'); + return true; + } else { + console.error('Failed to save quality profile:', data.error); + return false; + } + } catch (error) { + console.error('Error saving quality profile:', error); + return false; + } +} + +// =============================== +// END QUALITY PROFILE FUNCTIONS +// =============================== + +async function toggleHydrabaseFromSettings() { + const statusEl = document.getElementById('hydrabase-settings-status'); + const btn = document.getElementById('hydrabase-connect-btn'); + const url = document.getElementById('hydrabase-url').value.trim(); + const apiKey = document.getElementById('hydrabase-api-key').value.trim(); + + if (!url || !apiKey) { + if (statusEl) statusEl.textContent = 'URL and API Key required'; + return; + } + + // Save settings first + await saveSettings(true); + + try { + // Check current status + const statusRes = await fetch('/api/hydrabase/status'); + const statusData = await statusRes.json(); + + if (statusData.connected) { + // Disconnect + await fetch('/api/hydrabase/disconnect', { method: 'POST' }); + if (btn) btn.textContent = 'Connect'; + if (statusEl) { statusEl.textContent = 'Disconnected'; statusEl.style.color = 'rgba(255,255,255,0.4)'; } + // Remove from fallback dropdown + reset to iTunes if was selected + const fbSel2 = document.getElementById('metadata-fallback-source'); + if (fbSel2) { + const hbOpt = fbSel2.querySelector('option[value="hydrabase"]'); + if (hbOpt) { + if (fbSel2.value === 'hydrabase') fbSel2.value = 'itunes'; + hbOpt.remove(); + } + } + showToast('Hydrabase disconnected', 'info'); + } else { + // Connect + const res = await fetch('/api/hydrabase/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, api_key: apiKey }) + }); + const data = await res.json(); + if (data.success) { + if (btn) btn.textContent = 'Disconnect'; + if (statusEl) { statusEl.textContent = 'Connected'; statusEl.style.color = '#4caf50'; } + // Add to fallback dropdown + const fbSel = document.getElementById('metadata-fallback-source'); + if (fbSel && !fbSel.querySelector('option[value="hydrabase"]')) { + const opt = document.createElement('option'); + opt.value = 'hydrabase'; + opt.textContent = 'Hydrabase (P2P)'; + fbSel.appendChild(opt); + } + showToast('Hydrabase connected', 'success'); + } else { + if (statusEl) statusEl.textContent = data.error || 'Connection failed'; + showToast('Hydrabase connection failed', 'error'); + } + } + } catch (e) { + if (statusEl) statusEl.textContent = 'Error'; + showToast('Hydrabase connection error', 'error'); + } +} + +// ── Music Library Paths ── +function renderMusicPaths(paths) { + const container = document.getElementById('music-paths-list'); + if (!container) return; + if (!paths || paths.length === 0) { + container.innerHTML = '
No paths configured. Click "Add Path" to add your music folder(s).
'; + return; + } + container.innerHTML = paths.map((p, i) => ` +
+ + +
+ `).join(''); + // Attach auto-save to dynamically rendered inputs + container.querySelectorAll('.music-path-input').forEach(input => { + input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); + }); +} + +function addMusicPathRow() { + const container = document.getElementById('music-paths-list'); + if (!container) return; + // Clear the "no paths" message if present + const placeholder = container.querySelector('div[style*="color: rgba"]'); + if (placeholder && !container.querySelector('.music-path-row')) placeholder.remove(); + const row = document.createElement('div'); + row.className = 'form-group music-path-row'; + row.style.marginBottom = '4px'; + row.innerHTML = ` + + + `; + container.appendChild(row); + const input = row.querySelector('input'); + input.focus(); + // Auto-save when the user finishes typing a path + input.addEventListener('change', () => { if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); }); +} + +function _removeMusicPathRow(btn) { + btn.closest('.music-path-row').remove(); + // Auto-save after removing a path + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); +} + +function collectMusicPaths() { + const inputs = document.querySelectorAll('.music-path-input'); + const paths = []; + inputs.forEach(input => { + const val = input.value.trim(); + if (val) paths.push(val); + }); + return paths; +} + +// ── Genre Whitelist ── +let _genreWhitelistCache = []; + +function _genreWhitelistRender(genres) { + _genreWhitelistCache = genres && genres.length ? genres : []; + const container = document.getElementById('genre-whitelist-chips'); + const countEl = document.getElementById('genre-whitelist-count'); + if (!container) return; + if (!_genreWhitelistCache.length) { + container.innerHTML = '
No genres configured. Click "Reset to Defaults" to load the default whitelist.
'; + if (countEl) countEl.textContent = ''; + return; + } + const searchVal = (document.getElementById('genre-whitelist-search')?.value || '').toLowerCase(); + const filtered = searchVal ? _genreWhitelistCache.filter(g => g.toLowerCase().includes(searchVal)) : _genreWhitelistCache; + container.innerHTML = filtered.map(g => + `${escapeHtml(g)}` + ).join(''); + if (countEl) countEl.textContent = `${_genreWhitelistCache.length} genres`; +} + +function _genreWhitelistRemove(genre) { + _genreWhitelistCache = _genreWhitelistCache.filter(g => g !== genre); + _genreWhitelistRender(_genreWhitelistCache); + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); +} + +function _genreWhitelistAdd(genre) { + genre = genre.trim(); + if (!genre) return; + if (_genreWhitelistCache.some(g => g.toLowerCase() === genre.toLowerCase())) return; + _genreWhitelistCache.push(genre); + _genreWhitelistCache.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + _genreWhitelistRender(_genreWhitelistCache); + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); +} + +async function _genreWhitelistReset() { + try { + const resp = await fetch('/api/genre-whitelist/defaults'); + const data = await resp.json(); + if (data.genres) { + _genreWhitelistCache = data.genres; + _genreWhitelistRender(_genreWhitelistCache); + if (typeof debouncedAutoSaveSettings === 'function') debouncedAutoSaveSettings(); + showToast(`Loaded ${data.genres.length} default genres`, 'success'); + } + } catch (e) { + showToast('Failed to load defaults', 'error'); + } +} + +// Toggle whitelist container visibility + init +document.addEventListener('change', (e) => { + if (e.target.id === 'genre-whitelist-enabled') { + const container = document.getElementById('genre-whitelist-container'); + if (container) container.style.display = e.target.checked ? '' : 'none'; + // Auto-populate with defaults on first enable if empty + if (e.target.checked && _genreWhitelistCache.length === 0) { + _genreWhitelistReset(); + } + } +}); + +// Search/add handler +document.addEventListener('keydown', (e) => { + if (e.target.id === 'genre-whitelist-search' && e.key === 'Enter') { + e.preventDefault(); + _genreWhitelistAdd(e.target.value); + e.target.value = ''; + } +}); +document.addEventListener('input', (e) => { + if (e.target.id === 'genre-whitelist-search') { + _genreWhitelistRender(_genreWhitelistCache); + } +}); + +function _collectGenreWhitelist() { + return _genreWhitelistCache; +} + +// ── Live Log Viewer ── +let _logViewerActive = false; +let _logViewerFilter = ''; +let _logViewerSource = 'app'; +let _logViewerSearch = ''; +const _LOG_MAX_LINES = 2000; + +function _logClassify(line) { + // Exact logger format first + if (line.includes(' - DEBUG - ')) return 'DEBUG'; + if (line.includes(' - INFO - ')) return 'INFO'; + if (line.includes(' - WARNING - ')) return 'WARNING'; + if (line.includes(' - ERROR - ') || line.includes(' - CRITICAL - ')) return 'ERROR'; + // Heuristic for print() output + const ll = line.toLowerCase(); + if (ll.includes('error') || ll.includes('traceback') || ll.includes('exception') || ll.includes('failed')) return 'ERROR'; + if (ll.includes('warning') || ll.includes('warn')) return 'WARNING'; + if (ll.includes('debug')) return 'DEBUG'; + return 'INFO'; +} + +function _logClassToCSS(level) { + return { DEBUG: 'log-debug', INFO: 'log-info', WARNING: 'log-warning', ERROR: 'log-error' }[level] || 'log-plain'; +} + +async function _logViewerInit() { + if (_logViewerActive) return; + _logViewerActive = true; + _logViewerSource = document.getElementById('log-viewer-source')?.value || 'app'; + + // Fetch initial tail + try { + const params = new URLSearchParams({ source: _logViewerSource, lines: 300 }); + if (_logViewerFilter) params.set('level', _logViewerFilter); + if (_logViewerSearch) params.set('search', _logViewerSearch); + const resp = await fetch(`/api/logs/tail?${params}`); + const data = await resp.json(); + if (data.lines) { + const container = document.getElementById('log-viewer-lines'); + if (container) { + container.innerHTML = ''; + _logViewerAppendLines(data.lines); + } + } + } catch (e) { + console.warn('Failed to load initial logs:', e); + } + + // Subscribe to live updates + if (typeof socket !== 'undefined' && socket && socket.connected) { + socket.emit('logs:subscribe', { source: _logViewerSource }); + socket.on('logs:live', _logViewerOnLive); + } +} + +function _logViewerStop() { + if (!_logViewerActive) return; + _logViewerActive = false; + if (typeof socket !== 'undefined' && socket) { + socket.off('logs:live', _logViewerOnLive); + socket.emit('logs:unsubscribe', {}); + } +} + +function _logViewerOnLive(data) { + if (!_logViewerActive || !data.lines) return; + if (data.source !== _logViewerSource) return; + let lines = data.lines; + // Apply level filter client-side for live lines + if (_logViewerFilter) { + lines = lines.filter(l => _logClassify(l) === _logViewerFilter); + } + // Apply search filter + if (_logViewerSearch) { + const s = _logViewerSearch.toLowerCase(); + lines = lines.filter(l => l.toLowerCase().includes(s)); + } + if (lines.length > 0) _logViewerAppendLines(lines); +} + +function _logViewerAppendLines(lines) { + const container = document.getElementById('log-viewer-lines'); + if (!container) return; + const autoScroll = document.getElementById('log-viewer-autoscroll')?.checked; + const terminal = document.getElementById('log-viewer-terminal'); + + const frag = document.createDocumentFragment(); + for (const line of lines) { + const div = document.createElement('div'); + div.className = 'log-line ' + _logClassToCSS(_logClassify(line)); + div.textContent = line; + frag.appendChild(div); + } + container.appendChild(frag); + + // Trim old lines + while (container.children.length > _LOG_MAX_LINES) { + container.removeChild(container.firstChild); + } + + // Update count + const countEl = document.getElementById('log-viewer-line-count'); + if (countEl) countEl.textContent = `${container.children.length} lines`; + + // Auto-scroll + if (autoScroll && terminal) { + terminal.scrollTop = terminal.scrollHeight; + } +} + +async function _logViewerChangeSource() { + _logViewerStop(); + _logViewerSource = document.getElementById('log-viewer-source')?.value || 'app'; + const container = document.getElementById('log-viewer-lines'); + if (container) container.innerHTML = '
Loading...
'; + await _logViewerInit(); +} + +function _logViewerFilterLevel(btn) { + document.querySelectorAll('.log-filter-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + _logViewerFilter = btn.dataset.level || ''; + _logViewerReload(); +} + +let _logSearchDebounce = null; +function _logViewerOnSearch(input) { + clearTimeout(_logSearchDebounce); + _logSearchDebounce = setTimeout(() => { + _logViewerSearch = (input.value || '').trim(); + _logViewerReload(); + }, 300); +} + +function _logViewerReload() { + _logViewerStop(); + const container = document.getElementById('log-viewer-lines'); + if (container) container.innerHTML = '
Loading...
'; + _logViewerInit(); +} + +function _logViewerCopy() { + const container = document.getElementById('log-viewer-lines'); + if (!container) return; + const text = Array.from(container.children).map(el => el.textContent).join('\n'); + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(() => showToast('Logs copied', 'success')); + } else { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.cssText = 'position:fixed;left:-9999px'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + showToast('Logs copied', 'success'); + } +} + +function _logViewerClear() { + const container = document.getElementById('log-viewer-lines'); + if (container) container.innerHTML = ''; + const countEl = document.getElementById('log-viewer-line-count'); + if (countEl) countEl.textContent = '0 lines'; +} + +// ── Database Maintenance ── +async function loadDbMaintenanceInfo() { + try { + const resp = await fetch('/api/database/maintenance/info'); + const data = await resp.json(); + if (!data.success) return; + const sizeEl = document.getElementById('db-size-display'); + const freeEl = document.getElementById('db-freepages-display'); + const vacEl = document.getElementById('db-autovacuum-display'); + if (sizeEl) sizeEl.textContent = data.total_size_display; + if (freeEl) freeEl.textContent = data.free_pages > 0 + ? `${data.free_pages.toLocaleString()} (${data.free_size_display} reclaimable)` + : 'None — database is fully compacted'; + if (vacEl) vacEl.textContent = data.auto_vacuum_label; + // Hide enable button if already incremental + const incBtn = document.getElementById('db-incvacuum-btn'); + if (incBtn && data.auto_vacuum === 2) { + incBtn.textContent = 'Incremental Vacuum Enabled'; + incBtn.disabled = true; + incBtn.style.opacity = '0.5'; + } + } catch (e) { console.error('Error loading DB maintenance info:', e); } +} + +async function runDatabaseVacuum() { + const btn = document.getElementById('db-vacuum-btn'); + const status = document.getElementById('db-vacuum-status'); + if (!confirm('This will compact the database by rewriting it. The database will be locked during this operation. For large databases this may take over a minute. Continue?')) return; + btn.disabled = true; + btn.textContent = 'Compacting...'; + if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Running VACUUM — this may take a while...'; } + try { + const resp = await fetch('/api/database/maintenance/vacuum', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + showToast(`Database compacted in ${data.elapsed_seconds}s — saved ${data.saved_display}`, 'success'); + if (status) { status.style.color = '#4caf50'; status.textContent = `Done in ${data.elapsed_seconds}s. Saved ${data.saved_display}.`; } + loadDbMaintenanceInfo(); + } else { + showToast('Vacuum failed: ' + (data.error || 'Unknown error'), 'error'); + if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); } + } + } catch (e) { + showToast('Vacuum failed: ' + e.message, 'error'); + if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; } + } finally { + btn.disabled = false; + btn.textContent = 'Compact Database (VACUUM)'; + } +} + +async function enableIncrementalVacuum() { + const btn = document.getElementById('db-incvacuum-btn'); + const status = document.getElementById('db-vacuum-status'); + if (!confirm('This will enable incremental vacuum mode. It requires a one-time full VACUUM to activate, which locks the database and may take over a minute on large databases. Continue?')) return; + btn.disabled = true; + btn.textContent = 'Enabling...'; + if (status) { status.style.display = 'block'; status.style.background = 'rgba(255,255,255,0.04)'; status.style.color = 'rgba(255,255,255,0.6)'; status.textContent = 'Enabling incremental vacuum — this may take a while...'; } + try { + const resp = await fetch('/api/database/maintenance/enable-incremental-vacuum', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + const msg = data.already_enabled ? 'Already enabled' : `Enabled in ${data.elapsed_seconds}s — saved ${data.saved_display}`; + showToast(msg, 'success'); + if (status) { status.style.color = '#4caf50'; status.textContent = msg; } + loadDbMaintenanceInfo(); + } else { + showToast('Failed: ' + (data.error || 'Unknown error'), 'error'); + if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + (data.error || 'Unknown error'); } + } + } catch (e) { + showToast('Failed: ' + e.message, 'error'); + if (status) { status.style.color = '#ef5350'; status.textContent = 'Failed: ' + e.message; } + } finally { + btn.disabled = false; + btn.textContent = 'Enable Incremental Vacuum'; + } +} + +async function activateDevMode() { + const password = document.getElementById('dev-mode-password').value; + try { + const response = await fetch('/api/dev-mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ password }) + }); + const data = await response.json(); + if (data.success) { + document.getElementById('dev-mode-status').textContent = 'Active'; + document.getElementById('dev-mode-status').style.color = 'rgb(var(--accent-light-rgb))'; + document.getElementById('hydrabase-nav').style.display = ''; + document.getElementById('hydrabase-button-container').style.display = ''; + document.getElementById('dev-mode-password').value = ''; + showToast('Dev mode activated', 'success'); + } else { + showToast('Invalid password', 'error'); + } + } catch (e) { + showToast('Failed to activate dev mode', 'error'); + } +} + +// ── Hydrabase Functions ── + +let _hydrabaseConnected = false; + +async function hydrabaseToggleConnection() { + if (_hydrabaseConnected) { + await hydrabaseDisconnect(); + } else { + await hydrabaseConnect(); + } +} + +async function hydrabaseConnect() { + const url = document.getElementById('hydra-ws-url').value.trim(); + const apiKey = document.getElementById('hydra-api-key').value.trim(); + if (!url || !apiKey) { + showToast('URL and API key required', 'error'); + return; + } + const statusEl = document.getElementById('hydra-connection-status'); + const btn = document.getElementById('hydra-connect-btn'); + statusEl.textContent = 'Connecting...'; + statusEl.style.color = '#f0ad4e'; + try { + const response = await fetch('/api/hydrabase/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, api_key: apiKey }) + }); + const data = await response.json(); + if (data.success) { + _hydrabaseConnected = true; + statusEl.textContent = 'Connected'; + statusEl.style.color = 'rgb(var(--accent-light-rgb))'; + btn.textContent = 'Disconnect'; + showToast('Connected to Hydrabase', 'success'); + } else { + statusEl.textContent = 'Failed'; + statusEl.style.color = '#f44336'; + showToast(data.error || 'Connection failed', 'error'); + } + } catch (e) { + statusEl.textContent = 'Error'; + statusEl.style.color = '#f44336'; + showToast('Connection error', 'error'); + } +} + +async function hydrabaseDisconnect() { + try { + await fetch('/api/hydrabase/disconnect', { method: 'POST' }); + } catch (e) { } + _hydrabaseConnected = false; + document.getElementById('hydra-connection-status').textContent = 'Disconnected'; + document.getElementById('hydra-connection-status').style.color = '#888'; + document.getElementById('hydra-connect-btn').textContent = 'Connect'; + // Dev mode is disabled on disconnect — hide Hydrabase nav and update settings status + document.getElementById('hydrabase-nav').style.display = 'none'; + document.getElementById('hydrabase-button-container').style.display = 'none'; + const devStatus = document.getElementById('dev-mode-status'); + if (devStatus) { + devStatus.textContent = 'Inactive'; + devStatus.style.color = '#888'; + } + showToast('Disconnected — dev mode disabled', 'success'); + navigateToPage('settings'); +} + +async function loadHydrabaseComparisons() { + const container = document.getElementById('hydra-comparisons-container'); + if (!container) return; + try { + const response = await fetch('/api/hydrabase/comparisons'); + const data = await response.json(); + if (!data.success || !data.comparisons?.length) { + container.innerHTML = '

No comparisons yet. Search with Hydrabase active to generate comparisons.

'; + return; + } + let html = ''; + for (const comp of data.comparisons) { + const time = new Date(comp.timestamp * 1000).toLocaleTimeString(); + html += `
+
+ "${comp.query}" + ${time} +
+
+
+
Hydrabase
+
${comp.hydrabase?.tracks || 0}T / ${comp.hydrabase?.artists || 0}A / ${comp.hydrabase?.albums || 0}Al
+
+
+
Spotify
+
${comp.spotify?.tracks || 0}T / ${comp.spotify?.artists || 0}A / ${comp.spotify?.albums || 0}Al
+
+
+
${comp.fallback_source === 'deezer' ? 'Deezer' : 'iTunes'}
+
${(comp.fallback || comp.itunes)?.tracks || 0}T / ${(comp.fallback || comp.itunes)?.artists || 0}A / ${(comp.fallback || comp.itunes)?.albums || 0}Al
+
+
+
`; + } + container.innerHTML = html; + } catch (e) { + container.innerHTML = '

Failed to load comparisons.

'; + } +} + +async function hydrabaseSendRaw(textareaId) { + const textarea = document.getElementById(textareaId); + const raw = textarea.value.trim(); + if (!raw) { + showToast('Payload is empty', 'error'); + return; + } + if (!_hydrabaseConnected) { + showToast('Not connected to Hydrabase', 'error'); + return; + } + let payload; + try { + payload = JSON.parse(raw); + } catch (e) { + showToast('Invalid JSON payload', 'error'); + return; + } + // Auto-inject a fresh nonce if not set or zero + if (!payload.nonce) { + payload.nonce = Date.now(); + } + const responseArea = document.getElementById('hydra-response'); + responseArea.textContent = 'Sending...'; + try { + const response = await fetch('/api/hydrabase/send', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ payload }) + }); + const data = await response.json(); + if (data.success) { + responseArea.textContent = JSON.stringify(data.data, null, 2); + } else { + responseArea.textContent = 'Error: ' + (data.error || 'Unknown error'); + if (data.error && data.error.includes('Not connected')) { + _hydrabaseConnected = false; + document.getElementById('hydra-connection-status').textContent = 'Disconnected'; + document.getElementById('hydra-connection-status').style.color = '#888'; + document.getElementById('hydra-connect-btn').textContent = 'Connect'; + } + } + } catch (e) { + responseArea.textContent = 'Error: ' + e.message; + } +} + +// ── Tag embedding accordion helpers ── +function toggleTagGroup(header) { + const body = header.nextElementSibling; + const arrow = header.querySelector('.tag-group-arrow'); + if (body.style.display === 'none') { + body.style.display = 'block'; + arrow.classList.add('open'); + } else { + body.style.display = 'none'; + arrow.classList.remove('open'); + } +} + +function toggleServiceTags(masterCheckbox, serviceName) { + const group = masterCheckbox.closest('.tag-service-group'); + if (!group) return; + const body = group.querySelector('.tag-service-body'); + if (!body) return; + const childCheckboxes = body.querySelectorAll('input[type="checkbox"]'); + childCheckboxes.forEach(cb => { + const label = cb.closest('.checkbox-label'); + if (masterCheckbox.checked) { + if (label) label.classList.remove('disabled-tag'); + cb.disabled = false; + } else { + if (label) label.classList.add('disabled-tag'); + cb.disabled = true; + } + }); +} + +function _collectServiceTags(serviceName) { + const tags = {}; + document.querySelectorAll(`[data-config^="${serviceName}.tags."]`).forEach(cb => { + const key = cb.dataset.config.split('.').pop(); + tags[key] = cb.checked; + }); + return tags; +} + +function _getTagConfig(path) { + const el = document.querySelector(`[data-config="${path}"]`); + return el ? el.checked : true; +} + +async function saveSettings(quiet = false) { + // Validate file organization templates before saving + const validationErrors = validateFileOrganizationTemplates(); + if (validationErrors.length > 0) { + if (!quiet) showToast('Template validation failed: ' + validationErrors.join(', '), 'error'); + return; + } + + // Determine active server from toggle buttons + let activeServer = 'plex'; + if (document.getElementById('jellyfin-toggle').classList.contains('active')) { + activeServer = 'jellyfin'; + } else if (document.getElementById('navidrome-toggle').classList.contains('active')) { + activeServer = 'navidrome'; + } else if (document.getElementById('soulsync-toggle')?.classList.contains('active')) { + activeServer = 'soulsync'; + } + + const settings = { + active_media_server: activeServer, + spotify: { + client_id: document.getElementById('spotify-client-id').value, + client_secret: document.getElementById('spotify-client-secret').value, + redirect_uri: document.getElementById('spotify-redirect-uri').value, + embed_tags: document.getElementById('embed-spotify').checked, + tags: _collectServiceTags('spotify') + }, + tidal: { + client_id: document.getElementById('tidal-client-id').value, + client_secret: document.getElementById('tidal-client-secret').value, + redirect_uri: document.getElementById('tidal-redirect-uri').value, + embed_tags: document.getElementById('embed-tidal').checked, + tags: _collectServiceTags('tidal') + }, + plex: { + base_url: document.getElementById('plex-url').value, + token: document.getElementById('plex-token').value + }, + jellyfin: { + base_url: document.getElementById('jellyfin-url').value, + api_key: document.getElementById('jellyfin-api-key').value, + api_timeout: parseInt(document.getElementById('jellyfin-timeout').value) || 30 + }, + navidrome: { + base_url: document.getElementById('navidrome-url').value, + username: document.getElementById('navidrome-username').value, + password: document.getElementById('navidrome-password').value + }, + soulseek: { + slskd_url: document.getElementById('soulseek-url').value, + api_key: document.getElementById('soulseek-api-key').value, + download_path: document.getElementById('download-path').value, + transfer_path: document.getElementById('transfer-path').value, + search_timeout: parseInt(document.getElementById('soulseek-search-timeout').value) || 60, + search_timeout_buffer: parseInt(document.getElementById('soulseek-search-timeout-buffer').value) || 15, + min_peer_upload_speed: parseInt(document.getElementById('soulseek-min-peer-speed').value) || 0, + max_peer_queue: parseInt(document.getElementById('soulseek-max-peer-queue').value) || 0, + download_timeout: (parseInt(document.getElementById('soulseek-download-timeout').value) || 10) * 60, + auto_clear_searches: document.getElementById('soulseek-auto-clear-searches').checked + }, + listenbrainz: { + base_url: document.getElementById('listenbrainz-base-url').value, + token: document.getElementById('listenbrainz-token').value, + scrobble_enabled: document.getElementById('listenbrainz-scrobble-enabled').checked, + }, + acoustid: { + api_key: document.getElementById('acoustid-api-key').value, + enabled: document.getElementById('acoustid-enabled').checked + }, + lastfm: { + api_key: document.getElementById('lastfm-api-key').value, + api_secret: document.getElementById('lastfm-api-secret').value, + scrobble_enabled: document.getElementById('lastfm-scrobble-enabled').checked, + embed_tags: document.getElementById('embed-lastfm').checked, + tags: _collectServiceTags('lastfm') + }, + genius: { + access_token: document.getElementById('genius-access-token').value, + embed_tags: document.getElementById('embed-genius').checked, + tags: _collectServiceTags('genius') + }, + itunes: { + country: document.getElementById('itunes-country').value || 'US', + embed_tags: document.getElementById('embed-itunes').checked, + tags: _collectServiceTags('itunes') + }, + discogs: { + token: document.getElementById('discogs-token').value, + }, + metadata: { + fallback_source: document.getElementById('metadata-fallback-source').value || 'itunes' + }, + hydrabase: { + url: document.getElementById('hydrabase-url').value, + api_key: document.getElementById('hydrabase-api-key').value, + auto_connect: document.getElementById('hydrabase-auto-connect').checked + }, + download_source: { + mode: document.getElementById('download-source-mode').value, + hybrid_primary: document.getElementById('hybrid-primary-source').value, + hybrid_secondary: document.getElementById('hybrid-secondary-source').value, + hybrid_order: getHybridOrder(), + stream_source: document.getElementById('stream-source').value, + max_concurrent: parseInt(document.getElementById('max-concurrent-downloads').value) || 3, + }, + tidal_download: { + quality: document.getElementById('tidal-download-quality').value || 'lossless', + allow_fallback: document.getElementById('tidal-allow-fallback').checked, + }, + hifi_download: { + quality: document.getElementById('hifi-download-quality').value || 'lossless', + allow_fallback: document.getElementById('hifi-allow-fallback').checked, + }, + deezer_download: { + quality: document.getElementById('deezer-download-quality').value || 'flac', + arl: document.getElementById('deezer-download-arl').value || '', + allow_fallback: document.getElementById('deezer-allow-fallback').checked, + }, + lidarr_download: { + url: document.getElementById('lidarr-url').value || '', + api_key: document.getElementById('lidarr-api-key').value || '', + }, + qobuz: { + quality: document.getElementById('qobuz-quality').value || 'lossless', + embed_tags: document.getElementById('embed-qobuz').checked, + tags: _collectServiceTags('qobuz'), + allow_fallback: document.getElementById('qobuz-allow-fallback').checked, + }, + database: { + max_workers: parseInt(document.getElementById('max-workers').value) + }, + metadata_enhancement: { + enabled: document.getElementById('metadata-enabled').checked, + embed_album_art: document.getElementById('embed-album-art').checked, + cover_art_download: document.getElementById('cover-art-download').checked, + prefer_caa_art: document.getElementById('prefer-caa-art').checked, + lrclib_enabled: document.getElementById('lrclib-enabled').checked, + tags: { + quality_tag: _getTagConfig('metadata_enhancement.tags.quality_tag'), + genre_merge: _getTagConfig('metadata_enhancement.tags.genre_merge'), + artist_separator: document.getElementById('artist-separator').value, + write_multi_artist: document.getElementById('write-multi-artist').checked, + feat_in_title: document.getElementById('feat-in-title').checked + } + }, + musicbrainz: { + embed_tags: document.getElementById('embed-musicbrainz').checked, + tags: _collectServiceTags('musicbrainz') + }, + deezer: { + app_id: document.getElementById('deezer-app-id').value, + app_secret: document.getElementById('deezer-app-secret').value, + redirect_uri: document.getElementById('deezer-redirect-uri').value, + embed_tags: document.getElementById('embed-deezer').checked, + tags: _collectServiceTags('deezer') + }, + audiodb: { + embed_tags: document.getElementById('embed-audiodb').checked, + tags: _collectServiceTags('audiodb') + }, + file_organization: { + enabled: document.getElementById('file-organization-enabled').checked, + disc_label: document.getElementById('disc-label').value, + collab_artist_mode: document.getElementById('collab-artist-mode').value, + templates: { + album_path: document.getElementById('template-album-path').value, + single_path: document.getElementById('template-single-path').value, + playlist_path: document.getElementById('template-playlist-path').value, + video_path: document.getElementById('template-video-path').value + } + }, + wishlist: { + allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked + }, + playlist_sync: { + create_backup: document.getElementById('create-backup').checked + }, + content_filter: { + allow_explicit: document.getElementById('allow-explicit').checked + }, + genre_whitelist: { + enabled: document.getElementById('genre-whitelist-enabled').checked, + genres: _collectGenreWhitelist(), + }, + post_processing: { + replaygain_enabled: document.getElementById('replaygain-enabled').checked, + }, + library: { + music_paths: collectMusicPaths(), + music_videos_path: document.getElementById('music-videos-path').value || './MusicVideos' + }, + import: { + replace_lower_quality: document.getElementById('import-replace-lower-quality').checked, + staging_path: document.getElementById('staging-path').value || './Staging' + }, + lossy_copy: { + enabled: document.getElementById('lossy-copy-enabled').checked, + codec: document.getElementById('lossy-copy-codec').value, + bitrate: document.getElementById('lossy-copy-bitrate').value, + delete_original: document.getElementById('lossy-copy-delete-original').checked, + downsample_hires: document.getElementById('downsample-hires').checked + }, + listening_stats: { + enabled: document.getElementById('listening-stats-enabled').checked, + poll_interval: parseInt(document.getElementById('listening-stats-interval').value) || 30, + }, + m3u_export: { + enabled: document.getElementById('m3u-export-enabled').checked, + entry_base_path: document.getElementById('m3u-entry-base-path').value || '' + }, + ui_appearance: { + accent_preset: document.getElementById('accent-preset')?.value || '#1db954', + accent_color: document.getElementById('accent-custom-color')?.value || '#1db954', + sidebar_visualizer: document.getElementById('sidebar-visualizer-type')?.value || 'bars', + particles_enabled: document.getElementById('particles-enabled')?.checked !== false, + worker_orbs_enabled: document.getElementById('worker-orbs-enabled')?.checked !== false, + reduce_effects: document.getElementById('reduce-effects-enabled')?.checked === true + }, + youtube: { + cookies_browser: document.getElementById('youtube-cookies-browser').value, + download_delay: parseInt(document.getElementById('youtube-download-delay').value) || 3, + }, + security: { + require_pin_on_launch: document.getElementById('security-require-pin')?.checked || false, + } + }; + + try { + if (!quiet) showLoadingOverlay('Saving settings...'); + + // Save main settings + const response = await fetch(API.settings, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings) + }); + + const result = await response.json(); + + // Save quality profile + const qualityProfileSaved = await saveQualityProfile(); + + // Save discovery lookback period + let lookbackSaved = true; + try { + const lookbackPeriod = document.getElementById('discovery-lookback-period').value; + const lookbackResponse = await fetch('/api/discovery/lookback-period', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ period: lookbackPeriod }) + }); + const lookbackResult = await lookbackResponse.json(); + lookbackSaved = lookbackResult.success === true; + } catch (error) { + console.error('Error saving discovery lookback period:', error); + lookbackSaved = false; + } + + // Save hemisphere setting + try { + const hemisphere = document.getElementById('discovery-hemisphere').value; + await fetch('/api/discovery/hemisphere', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hemisphere }) + }); + } catch (error) { + console.error('Error saving hemisphere setting:', error); + } + + if (result.success && qualityProfileSaved && lookbackSaved) { + showToast(quiet ? 'Settings auto-saved' : 'Settings saved successfully', 'success'); + _forceServiceStatusRefresh(); + _stgRefreshAfterSave(); + } else if (result.success && qualityProfileSaved && !lookbackSaved) { + showToast('Settings saved, but discovery lookback period failed to save', 'warning'); + _forceServiceStatusRefresh(); + _stgRefreshAfterSave(); + } else if (result.success && !qualityProfileSaved) { + showToast('Settings saved, but quality profile failed to save', 'warning'); + _forceServiceStatusRefresh(); + _stgRefreshAfterSave(); + } else { + showToast(`Failed to save settings: ${result.error}`, 'error', 'set-services'); + } + } catch (error) { + console.error('Error saving settings:', error); + showToast('Failed to save settings', 'error', 'set-services'); + } finally { + if (!quiet) hideLoadingOverlay(); + } +} + +async function authorizeLastfmScrobbling() { + try { + // Save settings first so API secret is stored + await saveSettings(); + const resp = await fetch('/api/lastfm/auth-url'); + const data = await resp.json(); + if (data.success && data.url) { + window.open(data.url, '_blank', 'width=600,height=500'); + showToast('Authorize SoulSync in the Last.fm window that opened', 'info'); + } else { + showToast(data.error || 'Could not generate auth URL', 'error'); + } + } catch (e) { + showToast('Failed to start Last.fm authorization', 'error'); + } +} + +async function testConnection(service) { + try { + showLoadingOverlay(`Testing ${service} connection...`); + + const response = await fetch(API.testConnection, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service }) + }); + + const result = await response.json(); + + if (result.success) { + // Use backend's message which contains dynamic source name + showToast(result.message || `${service} connection successful`, 'success'); + + // Load music libraries after successful connection + if (service === 'plex') { + loadPlexMusicLibraries(); + } else if (service === 'jellyfin') { + loadJellyfinUsers().then(() => loadJellyfinMusicLibraries()); + } else if (service === 'navidrome') { + loadNavidromeMusicFolders(); + } + } else { + showToast(`${service} connection failed: ${result.error}`, 'error', 'gs-connecting'); + } + } catch (error) { + console.error(`Error testing ${service} connection:`, error); + showToast(`Failed to test ${service} connection`, 'error', 'gs-connecting'); + } finally { + hideLoadingOverlay(); + } +} + +async function clearQuarantine() { + if (!await showConfirmDialog({ title: 'Clear Quarantine', message: 'Delete all files in the quarantine folder? This cannot be undone.', confirmText: 'Delete', destructive: true })) return; + try { + showLoadingOverlay('Clearing quarantine folder...'); + const response = await fetch('/api/quarantine/clear', { method: 'POST' }); + const result = await response.json(); + if (result.success) { + showToast(result.message || 'Quarantine cleared', 'success'); + } else { + showToast(`Failed to clear quarantine: ${result.error}`, 'error'); + } + } catch (error) { + console.error('Error clearing quarantine:', error); + showToast('Failed to clear quarantine', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +// ======================== API Key Management ======================== + +async function loadApiKeys() { + const container = document.getElementById('api-keys-list'); + if (!container) return; + + try { + const response = await fetch('/api/v1/api-keys-internal'); + if (response.ok) { + const data = await response.json(); + renderApiKeys(data.data?.keys || []); + } else { + container.innerHTML = '
No API keys configured.
'; + } + } catch (e) { + container.innerHTML = '
No API keys configured.
'; + } +} + +function renderApiKeys(keys) { + const container = document.getElementById('api-keys-list'); + if (!container) return; + + if (!keys || keys.length === 0) { + container.innerHTML = '
No API keys yet. Generate one below.
'; + return; + } + + container.innerHTML = keys.map(k => ` +
+
+
${k.label || 'Unnamed'}
+
+ ${k.key_prefix || 'sk_...'}... + · Created ${k.created_at ? new Date(k.created_at).toLocaleDateString() : 'unknown'} + ${k.last_used_at ? '· Last used ' + new Date(k.last_used_at).toLocaleDateString() : ''} +
+
+ +
+ `).join(''); +} + +async function generateApiKey() { + const labelInput = document.getElementById('api-key-label'); + const label = labelInput ? labelInput.value.trim() : ''; + + try { + const response = await fetch('/api/v1/api-keys-internal/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ label: label || 'Default' }) + }); + const data = await response.json(); + + if (data.success && data.data?.key) { + const keyDisplay = document.getElementById('api-key-generated'); + const keyValue = document.getElementById('api-key-value'); + if (keyDisplay && keyValue) { + keyValue.textContent = data.data.key; + keyDisplay.style.display = 'block'; + } + if (labelInput) labelInput.value = ''; + showToast('API key generated! Copy it now.', 'success'); + loadApiKeys(); + } else { + showToast(data.error?.message || 'Failed to generate API key', 'error'); + } + } catch (error) { + console.error('Error generating API key:', error); + showToast('Failed to generate API key', 'error'); + } +} + +function copyApiKey() { + const keyValue = document.getElementById('api-key-value'); + if (keyValue) { + navigator.clipboard.writeText(keyValue.textContent).then(() => { + showToast('API key copied to clipboard', 'success'); + }).catch(() => { + // Fallback for older browsers + const range = document.createRange(); + range.selectNode(keyValue); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + document.execCommand('copy'); + showToast('API key copied', 'success'); + }); + } +} + +async function revokeApiKey(keyId, label) { + if (!await showConfirmDialog({ title: 'Revoke API Key', message: `Revoke API key "${label}"? Any apps using this key will stop working.`, confirmText: 'Revoke', destructive: true })) return; + + try { + const response = await fetch(`/api/v1/api-keys-internal/revoke/${keyId}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + showToast('API key revoked', 'success'); + loadApiKeys(); + } else { + showToast(data.error?.message || 'Failed to revoke key', 'error'); + } + } catch (error) { + console.error('Error revoking API key:', error); + showToast('Failed to revoke key', 'error'); + } +} + +// Dashboard-specific test functions that create activity items +async function testDashboardConnection(service) { + try { + showLoadingOverlay(`Testing ${service} service...`); + + const response = await fetch(API.testDashboardConnection, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service }) + }); + + const result = await response.json(); + + if (result.success) { + // Use backend's message which contains dynamic source name + showToast(result.message || `${service} service verified`, 'success'); + // Refresh status indicators immediately so UI reflects the new state + fetchAndUpdateServiceStatus(); + } else { + showToast(`${service} service check failed: ${result.error}`, 'error'); + } + } catch (error) { + console.error(`Error testing ${service} service:`, error); + showToast(`Failed to test ${service} service`, 'error'); + } finally { + hideLoadingOverlay(); + } +} + +// Individual Auto-detect functions - same as GUI +async function autoDetectPlex() { + try { + showLoadingOverlay('Auto-detecting Plex server...'); + + const response = await fetch('/api/detect-media-server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_type: 'plex' }) + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('plex-url').value = result.found_url; + showToast(`Plex server detected: ${result.found_url}`, 'success'); + } else { + showToast(result.error, 'error'); + } + + } catch (error) { + console.error('Error auto-detecting Plex:', error); + showToast('Failed to auto-detect Plex server', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +async function autoDetectJellyfin() { + try { + showLoadingOverlay('Auto-detecting Jellyfin server...'); + + const response = await fetch('/api/detect-media-server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_type: 'jellyfin' }) + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('jellyfin-url').value = result.found_url; + showToast(`Jellyfin server detected: ${result.found_url}`, 'success'); + } else { + showToast(result.error, 'error'); + } + + } catch (error) { + console.error('Error auto-detecting Jellyfin:', error); + showToast('Failed to auto-detect Jellyfin server', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +async function autoDetectNavidrome() { + try { + showLoadingOverlay('Auto-detecting Navidrome server...'); + + const response = await fetch('/api/detect-media-server', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ server_type: 'navidrome' }) + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('navidrome-url').value = result.found_url; + showToast(`Navidrome server detected: ${result.found_url}`, 'success'); + } else { + showToast(result.error, 'error'); + } + + } catch (error) { + console.error('Error auto-detecting Navidrome:', error); + showToast('Failed to auto-detect Navidrome server', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +async function autoDetectSlskd() { + try { + showLoadingOverlay('Auto-detecting Soulseek (slskd) server...'); + + const response = await fetch('/api/detect-soulseek', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const result = await response.json(); + + if (result.success) { + document.getElementById('soulseek-url').value = result.found_url; + showToast(`Soulseek server detected: ${result.found_url}`, 'success'); + } else { + showToast(result.error, 'error'); + } + + } catch (error) { + console.error('Error auto-detecting Soulseek:', error); + showToast('Failed to auto-detect Soulseek server', 'error'); + } finally { + hideLoadingOverlay(); + } +} + + +function cancelDetection(service) { + const progressDiv = document.getElementById(`${service}-detection-progress`); + progressDiv.classList.add('hidden'); + showToast(`${service} detection cancelled`, 'error'); +} + +function updateStatusDisplays() { + // Update status displays based on current service status + // This would be called after status updates + const services = ['spotify', 'media-server', 'soulseek']; + services.forEach(service => { + const display = document.getElementById(`${service}-status-display`); + if (display) { + // Status will be updated by the regular status monitoring + } + }); +} + +async function authenticateSpotify() { + try { + showLoadingOverlay('Saving credentials and starting Spotify authentication...'); + // Save settings first to ensure client_id/client_secret are persisted + await saveSettings(); + showToast('Spotify authentication started', 'success'); + window.open('/auth/spotify', '_blank'); + } catch (error) { + console.error('Error authenticating Spotify:', error); + showToast('Failed to start Spotify authentication', 'error', 'gs-connecting'); + } finally { + hideLoadingOverlay(); + } +} + +async function disconnectSpotify() { + const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source'; + if (!await showConfirmDialog({ title: 'Disconnect Spotify', message: `Disconnect Spotify? The app will switch to ${fallbackName} for metadata.` })) { + return; + } + try { + showLoadingOverlay('Disconnecting Spotify...'); + const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); + const data = await response.json(); + if (data.success) { + showToast(`Spotify disconnected. Now using ${fallbackName}.`, 'success'); + // Immediately refresh status to update UI + await fetchAndUpdateServiceStatus(); + } else { + showToast(`Failed to disconnect: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error disconnecting Spotify:', error); + showToast('Failed to disconnect Spotify', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +async function clearSpotifyCacheAndFallback() { + const fallbackName = currentMusicSourceName !== 'Spotify' ? currentMusicSourceName : 'the configured fallback source'; + if (!await showConfirmDialog({ + title: 'Clear Spotify Cache', + message: `This will clear the Spotify token cache and switch metadata to ${fallbackName}. You can re-authenticate later.` + })) return; + try { + showLoadingOverlay('Clearing Spotify cache...'); + const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); + const data = await response.json(); + if (data.success) { + showToast(data.message || `Switched to ${fallbackName}`, 'success'); + await fetchAndUpdateServiceStatus(); + } else { + showToast(`Failed: ${data.error}`, 'error'); + } + } catch (error) { + showToast('Failed to clear Spotify cache', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +// ── Spotify Rate Limit Handling ─────────────────────────────────────────── +let _spotifyRateLimitShown = false; +let _spotifyInCooldown = false; +let _rateLimitModalOpen = false; +let _rateLimitCountdownInterval = null; +let _rateLimitExpiresAt = 0; + +function handleSpotifyRateLimit(rateLimitInfo) { + if (!rateLimitInfo || !rateLimitInfo.active) { + if (_spotifyRateLimitShown) { + _spotifyRateLimitShown = false; + closeRateLimitModal(); + showToast('Spotify access restored', 'success'); + // Refresh discover page if user is on it — data source switched back to Spotify + if (currentPage === 'discover') { + console.log('Spotify restored — refreshing discover page data'); + loadDiscoverPage(); + } + } + return; + } + // Update countdown if modal is open (status pushes every 10s keep it accurate) + if (_rateLimitModalOpen && rateLimitInfo.remaining_seconds) { + _rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000); + } + if (!_spotifyRateLimitShown) { + _spotifyRateLimitShown = true; + _spotifyInCooldown = false; + showRateLimitModal(rateLimitInfo); + // Refresh discover page if user is on it — data source switched to iTunes + if (currentPage === 'discover') { + console.log('Spotify rate limited — refreshing discover page with iTunes data'); + loadDiscoverPage(); + } + } +} + +function showRateLimitModal(rateLimitInfo) { + const overlay = document.getElementById('rate-limit-modal-overlay'); + if (!overlay) return; + + // Populate details + const banDuration = document.getElementById('rate-limit-ban-duration'); + const endpoint = document.getElementById('rate-limit-endpoint'); + const countdown = document.getElementById('rate-limit-countdown'); + + banDuration.textContent = formatRateLimitDuration(rateLimitInfo.retry_after || rateLimitInfo.remaining_seconds); + endpoint.textContent = rateLimitInfo.endpoint || 'unknown'; + countdown.textContent = formatRateLimitDuration(rateLimitInfo.remaining_seconds); + + // Set expiry for live countdown + _rateLimitExpiresAt = Date.now() + (rateLimitInfo.remaining_seconds * 1000); + + // Start live countdown timer + if (_rateLimitCountdownInterval) clearInterval(_rateLimitCountdownInterval); + _rateLimitCountdownInterval = setInterval(() => { + const remaining = Math.max(0, Math.round((_rateLimitExpiresAt - Date.now()) / 1000)); + countdown.textContent = formatRateLimitDuration(remaining); + if (remaining <= 0) { + clearInterval(_rateLimitCountdownInterval); + _rateLimitCountdownInterval = null; + } + }, 1000); + + overlay.classList.remove('hidden'); + _rateLimitModalOpen = true; +} + +function closeRateLimitModal() { + const overlay = document.getElementById('rate-limit-modal-overlay'); + if (overlay) overlay.classList.add('hidden'); + if (_rateLimitCountdownInterval) { + clearInterval(_rateLimitCountdownInterval); + _rateLimitCountdownInterval = null; + } + _rateLimitModalOpen = false; +} + +async function disconnectSpotifyFromRateLimit() { + closeRateLimitModal(); + try { + showLoadingOverlay('Disconnecting Spotify...'); + const response = await fetch('/api/spotify/disconnect', { method: 'POST' }); + const data = await response.json(); + if (data.success) { + _spotifyRateLimitShown = false; + showToast(`Spotify disconnected. Now using ${currentMusicSourceName}.`, 'success'); + await fetchAndUpdateServiceStatus(); + if (currentPage === 'discover') { + loadDiscoverPage(); + } + } else { + showToast(`Failed to disconnect: ${data.error}`, 'error'); + } + } catch (error) { + console.error('Error disconnecting Spotify:', error); + showToast('Failed to disconnect Spotify', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +function formatRateLimitDuration(seconds) { + if (!seconds || seconds <= 0) return '0s'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = seconds % 60; + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +async function authenticateTidal() { + try { + showLoadingOverlay('Saving credentials and starting Tidal authentication...'); + // Save settings first to ensure credentials are persisted + await saveSettings(); + showToast('Tidal authentication started', 'success'); + window.open('/auth/tidal', '_blank'); + } catch (error) { + console.error('Error authenticating Tidal:', error); + showToast('Failed to start Tidal authentication', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +async function authenticateDeezer() { + try { + showLoadingOverlay('Saving credentials and starting Deezer authentication...'); + await saveSettings(); + showToast('Deezer authentication started', 'success'); + window.open('/auth/deezer', '_blank'); + } catch (error) { + console.error('Error authenticating Deezer:', error); + showToast('Failed to start Deezer authentication', 'error'); + } finally { + hideLoadingOverlay(); + } +} + +// ===== Tidal Download Auth (Device Flow) ===== + +async function testHiFiConnection() { + const statusEl = document.getElementById('hifi-connection-status'); + const btn = document.getElementById('hifi-test-btn'); + if (!statusEl) return; + statusEl.textContent = 'Checking...'; + statusEl.style.color = '#aaa'; + try { + const resp = await fetch('/api/hifi/status'); + const data = await resp.json(); + if (data.available) { + statusEl.textContent = `Connected (v${data.version || '?'})`; + statusEl.style.color = '#4caf50'; + } else { + statusEl.textContent = 'No instances reachable'; + statusEl.style.color = '#ff9800'; + } + } catch (e) { + statusEl.textContent = 'Connection error'; + statusEl.style.color = '#f44336'; + } +} + +async function testLidarrConnection() { + const statusEl = document.getElementById('lidarr-connection-status'); + if (!statusEl) return; + statusEl.textContent = 'Checking...'; + statusEl.style.color = '#aaa'; + try { + // Save settings first so the backend has the URL/key + await saveSettings(); + const resp = await fetch('/api/test-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ service: 'lidarr' }) + }); + const data = await resp.json(); + if (data.success) { + statusEl.textContent = 'Connected'; + statusEl.style.color = '#4caf50'; + } else { + statusEl.textContent = data.error || 'Connection failed'; + statusEl.style.color = '#f44336'; + } + } catch (e) { + statusEl.textContent = 'Connection error'; + statusEl.style.color = '#f44336'; + } +} + +async function checkHiFiInstances() { + const panel = document.getElementById('hifi-instances-panel'); + const btn = document.getElementById('hifi-instances-check-btn'); + if (!panel) return; + panel.style.display = 'block'; + panel.innerHTML = '
Checking instances...
'; + if (btn) { btn.disabled = true; btn.textContent = 'Checking...'; } + try { + const resp = await fetch('/api/hifi/instances'); + const data = await resp.json(); + if (!data.instances || data.instances.length === 0) { + panel.innerHTML = '
No instances configured.
'; + return; + } + const _statusIcon = (inst) => { + if (inst.can_download) return '● Download'; + if (inst.can_search) return '● Search only'; + if (inst.status === 'online') return '● Online (limited)'; + if (inst.status === 'ssl_error') return '● SSL error'; + if (inst.status === 'timeout') return '● Timeout'; + if (inst.status === 'offline') return '● Offline'; + return `● ${escapeHtml(inst.status)}`; + }; + panel.innerHTML = data.instances.map(inst => { + const isActive = inst.url === data.active; + const ver = inst.version ? ` v${inst.version}` : ''; + const activeTag = isActive ? ' (ACTIVE)' : ''; + return `
+ ${escapeHtml(inst.url)}${ver}${activeTag} + ${_statusIcon(inst)} +
`; + }).join(''); + } catch (e) { + panel.innerHTML = `
Error checking instances: ${escapeHtml(e.message)}
`; + } finally { + if (btn) { btn.disabled = false; btn.textContent = 'Check All Instances'; } + } +} + +async function testDeezerDownloadConnection() { + const statusEl = document.getElementById('deezer-download-status'); + if (!statusEl) return; + statusEl.textContent = 'Checking...'; + statusEl.style.color = '#aaa'; + try { + // Save the ARL first so the backend can use it + const arl = document.getElementById('deezer-download-arl')?.value || ''; + if (!arl) { + statusEl.textContent = 'No ARL token provided'; + statusEl.style.color = '#ff9800'; + return; + } + const resp = await fetch('/api/deezer-download/test', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ arl }), + }); + const data = await resp.json(); + if (data.success) { + statusEl.textContent = `Connected as ${data.user || 'Unknown'} (${data.tier || 'Free'})`; + statusEl.style.color = '#4caf50'; + } else { + statusEl.textContent = data.error || 'Authentication failed'; + statusEl.style.color = '#f44336'; + } + } catch (e) { + statusEl.textContent = 'Connection error'; + statusEl.style.color = '#f44336'; + } +} + +async function checkTidalDownloadAuthStatus() { + const statusEl = document.getElementById('tidal-download-auth-status'); + const btn = document.getElementById('tidal-download-auth-btn'); + try { + const resp = await fetch('/api/tidal/download/auth/status'); + const data = await resp.json(); + if (data.authenticated) { + statusEl.textContent = 'Authenticated'; + statusEl.style.color = '#4caf50'; + btn.textContent = 'Re-link Tidal Account'; + } else { + statusEl.textContent = 'Not authenticated'; + statusEl.style.color = '#ff9800'; + btn.textContent = 'Link Tidal Account'; + } + } catch (e) { + statusEl.textContent = ''; + } +} + +let _tidalAuthPollTimer = null; + +async function startTidalDownloadAuth() { + const btn = document.getElementById('tidal-download-auth-btn'); + const statusEl = document.getElementById('tidal-download-auth-status'); + const codeEl = document.getElementById('tidal-download-auth-code'); + + btn.disabled = true; + btn.textContent = 'Starting...'; + statusEl.textContent = ''; + + try { + const resp = await fetch('/api/tidal/download/auth/start', { method: 'POST' }); + const data = await resp.json(); + + if (!resp.ok || !data.success) { + throw new Error(data.error || 'Failed to start auth'); + } + + // Show the link/code to the user + const uri = data.verification_uri || ''; + const code = data.user_code || ''; + codeEl.style.display = 'block'; + codeEl.innerHTML = `Go to ${uri} and enter code: ${code}`; + btn.textContent = 'Waiting for approval...'; + statusEl.textContent = 'Waiting...'; + statusEl.style.color = '#ff9800'; + + // Poll for completion + if (_tidalAuthPollTimer) clearInterval(_tidalAuthPollTimer); + _tidalAuthPollTimer = setInterval(async () => { + try { + const checkResp = await fetch('/api/tidal/download/auth/check'); + const checkData = await checkResp.json(); + + if (checkData.status === 'completed') { + clearInterval(_tidalAuthPollTimer); + _tidalAuthPollTimer = null; + codeEl.style.display = 'none'; + statusEl.textContent = 'Authenticated'; + statusEl.style.color = '#4caf50'; + btn.disabled = false; + btn.textContent = 'Re-link Tidal Account'; + showToast('Tidal download account linked successfully', 'success'); + } else if (checkData.status === 'error') { + clearInterval(_tidalAuthPollTimer); + _tidalAuthPollTimer = null; + codeEl.style.display = 'none'; + statusEl.textContent = 'Auth failed'; + statusEl.style.color = '#f44336'; + btn.disabled = false; + btn.textContent = 'Link Tidal Account'; + showToast('Tidal auth failed: ' + (checkData.message || 'Unknown error'), 'error'); + } + // status === 'pending' — keep polling + } catch (pollErr) { + console.error('Tidal auth poll error:', pollErr); + } + }, 3000); + + } catch (error) { + console.error('Tidal download auth error:', error); + showToast('Failed to start Tidal auth: ' + error.message, 'error'); + btn.disabled = false; + btn.textContent = 'Link Tidal Account'; + codeEl.style.display = 'none'; + } +} + +// =============================== +// QOBUZ AUTH FUNCTIONS +// =============================== + +async function checkQobuzAuthStatus() { + try { + const resp = await fetch('/api/qobuz/auth/status'); + const data = await resp.json(); + + // Update downloads tab section + const formEl = document.getElementById('qobuz-auth-form'); + const loggedInEl = document.getElementById('qobuz-auth-logged-in'); + const userInfoEl = document.getElementById('qobuz-auth-user-info'); + + // Update connections tab section + const connFormEl = document.getElementById('qobuz-connection-form'); + const connLoggedInEl = document.getElementById('qobuz-connection-logged-in'); + const connUserInfoEl = document.getElementById('qobuz-connection-user-info'); + + if (data.authenticated) { + const user = data.user || {}; + const label = `Connected: ${user.display_name || 'Qobuz User'} (${user.subscription || 'Active'})`; + + if (userInfoEl) { userInfoEl.textContent = label; } + if (loggedInEl) loggedInEl.style.display = 'flex'; + if (formEl) formEl.style.display = 'none'; + + if (connUserInfoEl) { connUserInfoEl.textContent = label; } + if (connLoggedInEl) connLoggedInEl.style.display = 'flex'; + if (connFormEl) connFormEl.style.display = 'none'; + } else { + if (loggedInEl) loggedInEl.style.display = 'none'; + if (formEl) formEl.style.display = 'block'; + + if (connLoggedInEl) connLoggedInEl.style.display = 'none'; + if (connFormEl) connFormEl.style.display = 'block'; + } + } catch (e) { + console.error('Qobuz auth status check failed:', e); + } +} + +async function loginQobuzFromConnections() { + const btn = document.getElementById('qobuz-connection-login-btn'); + const statusEl = document.getElementById('qobuz-connection-status'); + const email = document.getElementById('qobuz-connection-email').value.trim(); + const password = document.getElementById('qobuz-connection-password').value; + + if (!email || !password) { + showToast('Please enter your Qobuz email and password', 'warning'); + return; + } + + btn.disabled = true; + btn.textContent = 'Connecting...'; + statusEl.textContent = ''; + + try { + const resp = await fetch('/api/qobuz/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + const data = await resp.json(); + + if (data.success) { + showToast('Qobuz connected successfully!', 'success'); + document.getElementById('qobuz-connection-password').value = ''; + checkQobuzAuthStatus(); + } else { + statusEl.textContent = data.error || 'Login failed'; + statusEl.style.color = '#ff5555'; + showToast(data.error || 'Qobuz login failed', 'error'); + } + } catch (error) { + console.error('Qobuz login error:', error); + statusEl.textContent = 'Connection error'; + statusEl.style.color = '#ff5555'; + showToast('Failed to connect to Qobuz', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Connect Qobuz'; + } +} + +async function loginQobuzWithToken() { + const btn = document.getElementById('qobuz-token-login-btn'); + const statusEl = document.getElementById('qobuz-token-status'); + const token = document.getElementById('qobuz-connection-token').value.trim(); + + if (!token) { + showToast('Please paste your Qobuz auth token', 'warning'); + return; + } + + btn.disabled = true; + btn.textContent = 'Connecting...'; + if (statusEl) statusEl.textContent = ''; + + try { + const resp = await fetch('/api/qobuz/auth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const data = await resp.json(); + + if (data.success) { + showToast('Qobuz connected via token!', 'success'); + document.getElementById('qobuz-connection-token').value = ''; + checkQobuzAuthStatus(); + } else { + if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; } + showToast(data.error || 'Qobuz token login failed', 'error'); + } + } catch (error) { + console.error('Qobuz token login error:', error); + if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; } + showToast('Failed to connect to Qobuz', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Connect with Token'; + } +} + +async function loginQobuzWithTokenFromDownloads() { + const btn = document.getElementById('qobuz-download-token-btn'); + const statusEl = document.getElementById('qobuz-download-token-status'); + const token = document.getElementById('qobuz-download-token').value.trim(); + + if (!token) { + showToast('Please paste your Qobuz auth token', 'warning'); + return; + } + + btn.disabled = true; + btn.textContent = 'Connecting...'; + if (statusEl) statusEl.textContent = ''; + + try { + const resp = await fetch('/api/qobuz/auth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + const data = await resp.json(); + + if (data.success) { + showToast('Qobuz connected via token!', 'success'); + document.getElementById('qobuz-download-token').value = ''; + checkQobuzAuthStatus(); + } else { + if (statusEl) { statusEl.textContent = data.error || 'Token login failed'; statusEl.style.color = '#ff5555'; } + showToast(data.error || 'Qobuz token login failed', 'error'); + } + } catch (error) { + console.error('Qobuz token login error:', error); + if (statusEl) { statusEl.textContent = 'Connection error'; statusEl.style.color = '#ff5555'; } + showToast('Failed to connect to Qobuz', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Connect with Token'; + } +} + +async function loginQobuz() { + const btn = document.getElementById('qobuz-login-btn'); + const statusEl = document.getElementById('qobuz-auth-status'); + const email = document.getElementById('qobuz-email').value.trim(); + const password = document.getElementById('qobuz-password').value; + + if (!email || !password) { + showToast('Please enter your Qobuz email and password', 'warning'); + return; + } + + btn.disabled = true; + btn.textContent = 'Connecting...'; + statusEl.textContent = ''; + + try { + const resp = await fetch('/api/qobuz/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + const data = await resp.json(); + + if (data.success) { + showToast('Qobuz connected successfully!', 'success'); + // Clear password field + document.getElementById('qobuz-password').value = ''; + checkQobuzAuthStatus(); + } else { + statusEl.textContent = data.error || 'Login failed'; + statusEl.style.color = '#ff5555'; + showToast(data.error || 'Qobuz login failed', 'error'); + } + } catch (error) { + console.error('Qobuz login error:', error); + statusEl.textContent = 'Connection error'; + statusEl.style.color = '#ff5555'; + showToast('Failed to connect to Qobuz', 'error'); + } finally { + btn.disabled = false; + btn.textContent = 'Connect Qobuz'; + } +} + +async function logoutQobuz() { + try { + await fetch('/api/qobuz/auth/logout', { method: 'POST' }); + showToast('Qobuz disconnected', 'success'); + checkQobuzAuthStatus(); + } catch (e) { + console.error('Qobuz logout error:', e); + } +} + +const PATH_INPUT_IDS = { + download: 'download-path', + transfer: 'transfer-path', + staging: 'staging-path', + 'music-videos': 'music-videos-path', + 'm3u-entry-base': 'm3u-entry-base-path' +}; + +function togglePathLock(pathType, btn) { + const input = document.getElementById(PATH_INPUT_IDS[pathType]); + if (!input) return; + const isLocked = input.hasAttribute('readonly'); + if (isLocked) { + input.removeAttribute('readonly'); + input.focus(); + btn.textContent = 'Lock'; + btn.classList.remove('locked'); + } else { + input.setAttribute('readonly', ''); + btn.textContent = 'Unlock'; + btn.classList.add('locked'); + } +} + + +// =============================== + diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js new file mode 100644 index 00000000..348944ea --- /dev/null +++ b/webui/static/shared-helpers.js @@ -0,0 +1,3773 @@ +// SHARED HELPERS +// ============================================================================ +// General-purpose helpers extracted from artists.js. These functions are used +// across discover.js, api-monitor.js, library.js, enrichment.js, wishlist- +// tools.js and others — they have no conceptual home in the old Artists page +// file. Moved here so artists.js can be deleted once the inline Artists page +// is fully retired. +// +// Load order: this file must load AFTER core.js (uses artistsPageState, +// artistDownloadBubbles, searchDownloadBubbles, beatportDownloadBubbles +// globals declared there) and BEFORE any file that calls these functions. +// ============================================================================ + + +// ---------------------------------------------------------------------------- +// Enhanced search shared utilities (used by Search page + global widget) +// ---------------------------------------------------------------------------- + +// Pass source to restrict results to a single metadata provider; omit or pass +// null/'auto' to let the backend fan out across all configured sources. +async function enhancedSearchFetch(query, { source = null, signal = null } = {}) { + const body = { query }; + if (source && source !== 'auto') body.source = source; + const res = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal || undefined, + }); + if (!res.ok) throw new Error(`Enhanced search failed: ${res.status}`); + return res.json(); +} + +// Per-source labels + tab/badge CSS classes + icon glyph for the source +// picker row. The `logo` URL (when present) renders as an in the +// source-picker chip; `icon` stays as the emoji fallback for sources +// without a canonical logo. Logo URLs mirror the constants in core.js so +// both places stay in sync. +const SOURCE_LABELS = { + spotify: { + text: 'Spotify', icon: '🎵', + logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png', + tabClass: 'enh-tab-spotify', badgeClass: 'enh-badge-spotify', + }, + itunes: { + text: 'Apple Music', icon: '🍎', + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png', + tabClass: 'enh-tab-itunes', badgeClass: 'enh-badge-itunes', + }, + deezer: { + text: 'Deezer', icon: '🎶', + logo: 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610', + tabClass: 'enh-tab-deezer', badgeClass: 'enh-badge-deezer', + }, + discogs: { + text: 'Discogs', icon: '📀', + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/6b/Discogs_icon.svg/960px-Discogs_icon.svg.png', + tabClass: 'enh-tab-discogs', badgeClass: 'enh-badge-discogs', + }, + hydrabase: { + text: 'Hydrabase', icon: '💎', + logo: '/static/hydrabase.png', + tabClass: 'enh-tab-hydrabase', badgeClass: 'enh-badge-hydrabase', + }, + musicbrainz: { + text: 'MusicBrainz', icon: '🧠', + logo: 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png', + tabClass: 'enh-tab-musicbrainz', badgeClass: 'enh-badge-musicbrainz', + }, + youtube_videos: { + text: 'Music Videos', icon: '🎬', + tabClass: 'enh-tab-youtube', badgeClass: 'enh-badge-youtube', + }, + soulseek: { + // No canonical brand logo available — stick with a basic music glyph. + text: 'Soulseek', icon: '🎼', + tabClass: 'enh-tab-soulseek', badgeClass: 'enh-badge-soulseek', + }, +}; + +// Canonical display order for the source picker. Standard metadata sources +// first, then YouTube Music Videos, then Soulseek (basic-file source). +const SOURCE_ORDER = [ + 'spotify', 'itunes', 'deezer', 'discogs', 'hydrabase', 'musicbrainz', + 'youtube_videos', 'soulseek', +]; + +// Sources the config-status endpoint doesn't cover because they don't need +// user-supplied credentials — they always render as "configured" in the picker. +// Soulseek IS configurable (needs slskd URL), so it's intentionally not here: +// /api/settings/config-status reports its real state and the picker dims it +// when no slskd is set up, redirecting clicks to Settings → Downloads. +const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos']); + +// Fetch /api/settings/config-status and return a map { src -> bool } +// covering every source in SOURCE_ORDER. Sources not present in the backend +// registry (musicbrainz / youtube_videos / soulseek) are reported as +// configured so the picker doesn't dim always-available sources. +async function fetchSourceConfiguredMap() { + const map = {}; + try { + const resp = await fetch('/api/settings/config-status'); + if (resp.ok) { + const data = await resp.json(); + for (const src of SOURCE_ORDER) { + if (_ALWAYS_CONFIGURED_SOURCES.has(src)) { + map[src] = true; + } else { + map[src] = !!(data[src] && data[src].configured); + } + } + return map; + } + } catch (_) { /* fall through to conservative default */ } + // Network / endpoint failure — be permissive rather than dim everything. + for (const src of SOURCE_ORDER) map[src] = true; + return map; +} + +// Shared source-picker controller used by both the unified Search page +// and the global search widget. Owns all the query/active-source/per-query +// cache state, fetch dispatch (enhanced-search for standard sources, NDJSON +// for YouTube Music Videos), configured-source discovery, fallback tracking, +// and icon-row rendering. Each surface passes per-surface wiring — DOM +// elements, a CSS class prefix, and callbacks — and the controller takes +// care of the rest. +// +// Config: +// sourceRowElement — HTMLElement where the icon row is rendered +// iconClassPrefix — 'enh' or 'gsearch' (drives CSS class names) +// onStateChange(state) — called whenever the surface should re-render +// results (cache hit, fetch settle, query reset) +// onSoulseekSelected(q) — surface decides what happens when the user +// clicks the Soulseek icon (basic-section swap +// on the Search page, /search handoff on the +// global widget) +// onUnconfiguredClick(src)— override the default "open Settings" behaviour +// +// Returned methods: +// init() — async; reads /api/settings + /api/settings/ +// config-status, seeds default source, falls +// forward if primary is unconfigured, draws row +// submitQuery(query) — user typed a new query (clears cache on change) +// setActiveSource(src) — user clicked a different source icon +// renderSourceRow() — re-draws the icon row (call after state edits) +function createSearchController({ + sourceRowElement, + iconClassPrefix = 'enh', + onStateChange, + onSoulseekSelected, + onUnconfiguredClick, +} = {}) { + const iconClass = `${iconClassPrefix}-source-icon`; + const glyphClass = `${iconClassPrefix}-source-icon-glyph`; + const labelClass = `${iconClassPrefix}-source-icon-label`; + + // Per-query cache. `sources[src]` holds the result payload the last + // time `src` was fetched for the current query. `fallbacks[src]` + // records the source the backend actually served when it auto-fell- + // back (e.g. user clicked Spotify but got Deezer because Spotify is + // rate-limited). `loadingSources` drives per-icon spinners. The whole + // cache is cleared whenever the query string changes — we never + // serve stale results across queries. + const state = { + query: '', + activeSource: 'spotify', + sources: {}, + fallbacks: {}, + loadingSources: new Set(), + configuredSources: {}, + _initialized: false, + }; + // Optimistic default — replaced by the real config-status lookup on + // init. Prevents a flash of "all unconfigured" icons. + for (const src of SOURCE_ORDER) state.configuredSources[src] = true; + + let abortCtrl = null; + // Per-source request tokens. Each _fetchSource call increments the + // monotonic _requestSeq and stamps it into _sourceRequestIds[src]. + // Settle/error blocks bail before mutating shared state if their + // requestId no longer matches the latest id for THAT source — + // protecting against the fast-retype race (same-source supersession) + // without dropping cleanup for cross-source supersession. + // + // A single global token would mishandle cross-source: switching + // Spotify → Deezer aborts Spotify's fetch, but Spotify's catch needs + // to clear 'spotify' from loadingSources (Deezer's request hasn't + // touched it). Per-source tracking lets each source's catch own its + // own loadingSources entry. + let _requestSeq = 0; + const _sourceRequestIds = Object.create(null); + + function _notify() { if (onStateChange) onStateChange(state); } + + function renderSourceRow() { + if (!sourceRowElement) return; + sourceRowElement.innerHTML = SOURCE_ORDER.map(src => { + const info = SOURCE_LABELS[src]; + if (!info) return ''; + const active = src === state.activeSource; + const cached = !!state.sources[src]; + const loading = state.loadingSources.has(src); + const fallback = state.fallbacks[src]; + const configured = state.configuredSources[src] !== false; + + const classes = [ + iconClass, + active ? 'active' : '', + cached ? 'cached' : '', + loading ? 'loading' : '', + fallback ? 'fallback-warning' : '', + configured ? '' : 'unconfigured', + ].filter(Boolean).join(' '); + + let title; + if (!configured) { + title = `${info.text} — set up in Settings`; + } else if (fallback) { + title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`; + } else { + title = info.text; + } + + const glyph = loading + ? '⏳' + : (info.logo + ? `` + : info.icon); + + return ` + `; + }).join(''); + + sourceRowElement.querySelectorAll(`.${iconClass}`).forEach(btn => { + btn.addEventListener('click', (e) => { + // stopPropagation prevents surface-level outside-click handlers + // from dismissing the results while we re-render the icon row + // (which detaches the clicked button from the DOM). + e.stopPropagation(); + setActiveSource(btn.dataset.source); + }); + }); + } + + async function init() { + if (state._initialized) return; + state._initialized = true; + + // Resolve the user's configured primary source. + try { + const resp = await fetch('/api/settings'); + if (resp.ok) { + const settings = await resp.json(); + const cfg = settings.metadata && settings.metadata.fallback_source; + if (cfg && SOURCE_LABELS[cfg]) state.activeSource = cfg; + } + } catch (_) { /* best-effort */ } + if (!SOURCE_LABELS[state.activeSource]) state.activeSource = 'spotify'; + + // Figure out which sources actually have credentials saved. + try { + state.configuredSources = await fetchSourceConfiguredMap(); + } catch (_) { /* keep optimistic default */ } + + // If the configured primary is itself unconfigured (Spotify saved + // as primary but no client_id yet), fall forward to the first + // configured source so the default active icon is usable. + if (state.configuredSources[state.activeSource] === false) { + const firstConfigured = SOURCE_ORDER.find(s => state.configuredSources[s] !== false); + if (firstConfigured) state.activeSource = firstConfigured; + } + + renderSourceRow(); + _notify(); + } + + function setActiveSource(src) { + if (!SOURCE_LABELS[src]) return; + + // Unconfigured — jump to the relevant card in Settings rather than + // firing a search that can't succeed. Don't swap activeSource so the + // user's previous pick stays current when they come back. + if (state.configuredSources[src] === false) { + if (onUnconfiguredClick) onUnconfiguredClick(src); + else openSettingsForSource(src); + return; + } + + // Clicking the already-active source is a no-op for normal sources, + // but for Soulseek we still re-fire the callback so the surface can + // re-issue the handoff (e.g. user typed and wants a fresh search). + if (src === state.activeSource) { + if (src === 'soulseek' && onSoulseekSelected) onSoulseekSelected(state.query); + return; + } + + state.activeSource = src; + renderSourceRow(); + + // Soulseek — let the surface decide what to do (basic-section swap + // on Search page, /search handoff on global widget). We don't cache + // or auto-fetch soulseek results in the controller. + if (src === 'soulseek') { + if (onSoulseekSelected) onSoulseekSelected(state.query); + return; + } + + if (state.sources[src]) { + _notify(); + } else if (state.query) { + _fetchSource(src); + } else { + _notify(); + } + } + + async function _fetchSource(src) { + const query = state.query; + if (!query) return; + + const requestId = ++_requestSeq; + _sourceRequestIds[src] = requestId; + + state.loadingSources.add(src); + renderSourceRow(); + _notify(); + + if (abortCtrl) abortCtrl.abort(); + abortCtrl = new AbortController(); + + try { + if (src === 'youtube_videos') { + await _fetchYouTubeVideos(query, abortCtrl.signal, requestId); + } else { + const data = await enhancedSearchFetch(query, { + source: src, + signal: abortCtrl.signal, + }); + // Bail without writing if a newer request for THIS source + // has superseded us. Cross-source supersession (different + // src entirely) is handled by the loadingSources cleanup + // below — each source's catch owns its own entry. + if (_sourceRequestIds[src] !== requestId) return; + state.sources[src] = { + artists: data.spotify_artists || [], + albums: data.spotify_albums || [], + tracks: data.spotify_tracks || [], + videos: [], + db_artists: data.db_artists || [], + }; + const served = data.primary_source || data.metadata_source; + if (served && served !== src) state.fallbacks[src] = served; + } + + if (_sourceRequestIds[src] !== requestId) return; + state.loadingSources.delete(src); + renderSourceRow(); + _notify(); + } catch (err) { + // Only clear loadingSources if no newer request for THIS source + // is in flight. Cross-source supersession (e.g. user switched + // Spotify → Deezer) still falls through here so Spotify's + // spinner gets cleared on its own AbortError. + if (_sourceRequestIds[src] === requestId) { + state.loadingSources.delete(src); + renderSourceRow(); + _notify(); + } + if (err.name !== 'AbortError') { + console.debug(`Source fetch failed for ${src}:`, err); + } + } + } + + async function _fetchYouTubeVideos(query, signal, requestId) { + const res = await fetch('/api/enhanced-search/source/youtube_videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }), + signal, + }); + if (!res.ok) throw new Error(`YouTube search failed: ${res.status}`); + + // Bail before allocating cache entry if a newer YouTube request + // has superseded us. + if (_sourceRequestIds['youtube_videos'] !== requestId) return; + + state.sources['youtube_videos'] = { + artists: [], albums: [], tracks: [], videos: [], db_artists: [], + }; + const cache = state.sources['youtube_videos']; + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (_sourceRequestIds['youtube_videos'] !== requestId) return; + buffer += decoder.decode(value, { stream: true }); + let idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (!line) continue; + try { + const chunk = JSON.parse(line); + if (chunk.type === 'videos') { + cache.videos = chunk.data; + if (state.activeSource === 'youtube_videos') _notify(); + } + } catch (_) { /* best-effort NDJSON parse */ } + } + } + } + + function submitQuery(query) { + if (query !== state.query) { + state.query = query; + state.sources = {}; + state.fallbacks = {}; + state.loadingSources = new Set(); + // Invalidate every in-flight per-source token. Without this, a + // settle that arrives AFTER a query reset (e.g. user typed 'a', + // fetch started, then user cleared the input) would still + // pass the per-source token check and write stale data back + // into the just-cleared state.sources. Setting fresh tokens + // when each new _fetchSource fires re-stamps as needed. + for (const k in _sourceRequestIds) delete _sourceRequestIds[k]; + // Abort the active fetch — its results are useless now. + if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } + renderSourceRow(); + } + + // Soulseek — surface handles the full query handoff. + if (state.activeSource === 'soulseek') { + if (onSoulseekSelected) onSoulseekSelected(query); + return; + } + + // Cache hit — instant re-render, no fetch. + if (state.sources[state.activeSource]) { + _notify(); + return; + } + + _fetchSource(state.activeSource); + } + + return { + state, + init, + submitQuery, + setActiveSource, + renderSourceRow, + }; +} + + +// Navigate to Settings → relevant tab and scroll to the service card that +// matches the picker's source id. Called when a user clicks an unconfigured +// source icon. Soulseek is special-cased to land on the Downloads tab where +// its slskd URL field lives (gated behind the download-source-mode select); +// every other source has a card on Connections. +function openSettingsForSource(src) { + if (typeof navigateToPage !== 'function') return; + navigateToPage('settings'); + const targetTab = src === 'soulseek' ? 'downloads' : 'connections'; + setTimeout(() => { + try { + if (typeof switchSettingsTab === 'function') switchSettingsTab(targetTab); + } catch (_) { /* best-effort */ } + setTimeout(() => { + // Soulseek doesn't have a .stg-service card — scroll to the + // slskd URL input instead so the user lands on the right field. + const target = src === 'soulseek' + ? document.querySelector('#settings-page #soulseek-url') + : document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + if (src === 'soulseek') { + try { target.focus(); } catch (_) { /* best-effort */ } + } else { + target.classList.add('stg-service-flash'); + setTimeout(() => target.classList.remove('stg-service-flash'), 2200); + } + }, 120); + }, 60); +} + +// Render a single enhanced-search result section (artists / albums / tracks). +// Shared between the Search page and the global widget. The mapItem callback +// projects each backend item to the card config consumed here. +function renderCompactSection(sectionId, listId, countId, items, mapItem) { + const section = document.getElementById(sectionId); + const list = document.getElementById(listId); + const count = document.getElementById(countId); + + if (!list) return; + + list.innerHTML = ''; + + if (!items || items.length === 0) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + count.textContent = items.length; + + // Determine type based on section ID + const isArtist = sectionId.includes('artists'); + const isAlbum = sectionId.includes('albums') || sectionId.includes('singles'); + const isTrack = sectionId.includes('tracks'); + + // Add appropriate grid class to list + if (isArtist) { + list.classList.add('enh-artists-grid'); + } else if (isAlbum) { + list.classList.add('enh-albums-grid'); + } else if (isTrack) { + list.classList.add('enh-tracks-list'); + } + + items.forEach(item => { + const config = mapItem(item); + const elem = document.createElement('div'); + + // Add appropriate card class + if (isArtist) { + elem.className = 'enh-compact-item artist-card'; + // Add data attributes for lazy loading + if (item.id) { + elem.dataset.artistId = item.id; + elem.dataset.needsImage = config.image ? 'false' : 'true'; + // Stash the artist name so the lazy-loader can pass it to + // the backend. Needed for sources that don't store artist + // images directly (MusicBrainz) — backend resolves the + // image by looking up the name on a fallback source. + if (config.name) elem.dataset.artistName = config.name; + } + } else if (isAlbum) { + elem.className = 'enh-compact-item album-card'; + } else if (isTrack) { + elem.className = 'enh-compact-item track-item'; + } + + // Build image HTML with type-specific classes + let imageClass = 'enh-item-image'; + let placeholderClass = 'enh-item-image-placeholder'; + + if (isArtist) { + imageClass += ' artist-image'; + placeholderClass += ' artist-placeholder'; + } else if (isAlbum) { + imageClass += ' album-cover'; + placeholderClass += ' album-placeholder'; + } else if (isTrack) { + imageClass += ' track-cover'; + placeholderClass += ' track-placeholder'; + } + + // Fallback placeholder used when the image 404s (common for MB + // Cover Art Archive URLs — we construct them deterministically + // without probing first, so some will miss). Without onerror the + // browser shows its broken-image icon. + const placeholderHtml = `
${config.placeholder}
`; + const escapedFallback = placeholderHtml.replace(/"/g, '"'); + const imageHtml = config.image + ? `${escapeHtml(config.name)}` + : placeholderHtml; + + const badgeHtml = config.badge + ? `
${config.badge.text}
` + : ''; + + const durationHtml = config.duration && isTrack + ? `
+ ${escapeHtml(config.duration)} + +
` + : ''; + + elem.innerHTML = ` + ${imageHtml} +
+
${escapeHtml(config.name)}
+
${escapeHtml(config.meta)}
+
+ ${durationHtml} + ${badgeHtml} + `; + + elem.addEventListener('click', config.onClick); + + // Add play button handler for tracks + if (isTrack && config.onPlay) { + const playBtn = elem.querySelector('.enh-item-play-btn'); + if (playBtn) { + playBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Don't trigger main onClick + config.onPlay(); + }); + } + } + + list.appendChild(elem); + + // Extract colors from image for dynamic glow effect + if (config.image) { + extractImageColors(config.image, (colors) => { + applyDynamicGlow(elem, colors); + }); + } + }); +} + + +// ---------------------------------------------------------------------------- +// Discography completion checking (for artist-detail pages, library page) +// ---------------------------------------------------------------------------- + +async function checkDiscographyCompletion(artistId, discography) { + console.log(`🔍 Starting streaming completion check for artist: ${artistId}`); + + try { + // Create new abort controller for this completion check + artistCompletionController = new AbortController(); + + // Use fetch with streaming response + const response = await fetch(`/api/artist/${artistId}/completion-stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + discography: discography, + artist_name: artistsPageState.selectedArtist?.name || 'Unknown Artist', + source: discography?.source || artistsPageState.sourceOverride || null, + }), + signal: artistCompletionController.signal + }); + + if (!response.ok) { + throw new Error(`Failed to start completion check: ${response.status}`); + } + + // Handle streaming response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\n'); + + for (const line of lines) { + if (line.startsWith('data: ')) { + try { + const data = JSON.parse(line.slice(6)); + handleStreamingCompletionUpdate(data); + } catch (e) { + console.warn('Failed to parse streaming data:', line); + } + } + } + } + + // Clear the controller when done + artistCompletionController = null; + + } catch (error) { + // Don't show error if it was aborted (user navigated away) + if (error.name === 'AbortError') { + console.log('⏹️ Completion check aborted (user navigated to new artist)'); + return; + } + + console.error('❌ Failed to check completion status:', error); + showCompletionError(); + } finally { + // Always clear the controller + artistCompletionController = null; + } +} + +/** + * Handle individual streaming completion updates + */ +function handleStreamingCompletionUpdate(data) { + console.log('🔄 Streaming update received:', data.type, data.name || data.artist_name); + + switch (data.type) { + case 'start': + console.log(`🎤 Starting completion check for ${data.artist_name} (${data.total_items} items)`); + // Initialize cache for this artist if not exists + const artistId = artistsPageState.selectedArtist?.id; + if (artistId && !artistsPageState.cache.completionData[artistId]) { + artistsPageState.cache.completionData[artistId] = { + albums: [], + singles: [] + }; + } + break; + + case 'album_completion': + updateAlbumCompletionOverlay(data, 'albums'); + // Cache the completion data + cacheCompletionData(data, 'albums'); + console.log(`📀 Updated album: ${data.name} (${data.status})`); + break; + + case 'single_completion': + updateAlbumCompletionOverlay(data, 'singles'); + // Cache the completion data + cacheCompletionData(data, 'singles'); + console.log(`🎵 Updated single: ${data.name} (${data.status})`); + break; + + case 'error': + console.error('❌ Error processing item:', data.name, data.error); + // Could show error for specific item + break; + + case 'complete': + console.log(`✅ Completion check finished (${data.processed_count} items processed)`); + break; + + default: + console.log('Unknown streaming update type:', data.type); + } +} + +/** + * Cache completion data for future restoration + */ +function cacheCompletionData(completionData, type) { + const artistId = artistsPageState.selectedArtist?.id; + if (!artistId) return; + + // Ensure cache structure exists + if (!artistsPageState.cache.completionData[artistId]) { + artistsPageState.cache.completionData[artistId] = { + albums: [], + singles: [] + }; + } + + // Add to appropriate cache array + if (type === 'albums') { + artistsPageState.cache.completionData[artistId].albums.push(completionData); + } else if (type === 'singles') { + artistsPageState.cache.completionData[artistId].singles.push(completionData); + } +} + +/** + * Update completion overlay for a specific album/single + */ +function updateAlbumCompletionOverlay(completionData, containerType) { + const containerId = containerType === 'albums' ? 'album-cards-container' : 'singles-cards-container'; + const container = document.getElementById(containerId); + + if (!container) { + console.warn(`Container ${containerId} not found`); + return; + } + + // Find the album card by data-album-id + const albumCard = container.querySelector(`[data-album-id="${completionData.id}"]`); + + if (!albumCard) { + console.warn(`Album card not found for ID: ${completionData.id}`); + return; + } + + // Reclassify and move cards when track count reveals single/EP (Discogs lazy fetch) + const currentType = albumCard.dataset.albumType; + const expectedTracks = completionData.expected_tracks || 0; + if (expectedTracks > 0) { + albumCard.dataset.totalTracks = expectedTracks; + let newType = currentType; + if (currentType === 'album' && expectedTracks <= 3) newType = 'single'; + else if (currentType === 'album' && expectedTracks <= 6) newType = 'ep'; + + if (newType !== currentType) { + albumCard.dataset.albumType = newType; + const typeEl = albumCard.querySelector('.album-card-type'); + if (typeEl) typeEl.textContent = newType === 'single' ? 'Single' : 'EP'; + + // Move card from albums grid to singles grid + const singlesGrid = document.getElementById('singles-grid'); + const singlesSection = singlesGrid?.closest('.discography-section'); + if (singlesGrid) { + albumCard.remove(); + singlesGrid.appendChild(albumCard); + if (singlesSection) singlesSection.style.display = ''; + } + } + } + + const overlay = albumCard.querySelector('.completion-overlay'); + if (!overlay) { + console.warn(`Completion overlay not found for album: ${completionData.name}`); + return; + } + + // Remove existing status classes + overlay.classList.remove('checking', 'completed', 'nearly_complete', 'partial', 'missing', 'downloading', 'downloaded', 'error'); + + // Add new status class + overlay.classList.add(completionData.status); + + // Update overlay text and content + const statusText = getCompletionStatusText(completionData); + const progressText = completionData.expected_tracks > 0 + ? `${completionData.owned_tracks}/${completionData.expected_tracks}` + : ''; + + overlay.innerHTML = progressText + ? `${statusText}${progressText}` + : `${statusText}`; + + // Add tooltip with more details + overlay.title = `${completionData.name}\n${statusText} (${completionData.completion_percentage}%)\nTracks: ${completionData.owned_tracks}/${completionData.expected_tracks}\nConfidence: ${completionData.confidence}`; + + // Add brief flash animation to indicate update + overlay.style.animation = 'none'; + overlay.offsetHeight; // Trigger reflow + overlay.style.animation = 'completionOverlayFadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1)'; + + console.log(`📊 Updated overlay for "${completionData.name}": ${statusText} (${completionData.completion_percentage}%)`); +} + +/** + * Get human-readable status text for completion overlay + */ +function getCompletionStatusText(completionData) { + switch (completionData.status) { + case 'completed': + return 'Complete'; + case 'nearly_complete': + return 'Nearly Complete'; + case 'partial': + return 'Partial'; + case 'missing': + return 'Missing'; + case 'downloading': + return 'Downloading...'; + case 'downloaded': + return 'Downloaded'; + case 'error': + return 'Error'; + default: + return 'Unknown'; + } +} + +/** + * Set album to downloaded status after download finishes + */ +function setAlbumDownloadedStatus(albumId) { + console.log(`✅ [DOWNLOAD COMPLETE] Setting album ${albumId} to downloaded status`); + + const completionData = { + id: albumId, + status: 'downloaded', + owned_tracks: 0, + expected_tracks: 0, + name: 'Downloaded', + completion_percentage: 100 + }; + + // Find if it's in albums or singles container + let containerType = 'albums'; + let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); + if (!albumCard) { + containerType = 'singles'; + albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); + } + + if (albumCard) { + updateAlbumCompletionOverlay(completionData, containerType); + console.log(`✅ [DOWNLOAD COMPLETE] Album ${albumId} set to Downloaded status`); + } else { + console.warn(`❌ [DOWNLOAD COMPLETE] Album card not found for ID: "${albumId}"`); + } +} + +/** + * Set album to downloading status + */ +function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { + console.log(`🔍 [DOWNLOAD STATUS] Searching for album card with ID: "${albumId}"`); + + const completionData = { + id: albumId, + status: 'downloading', + owned_tracks: downloaded, + expected_tracks: total, + name: 'Downloading', + completion_percentage: Math.round((downloaded / total) * 100) || 0 + }; + + // Find if it's in albums or singles container + let containerType = 'albums'; + let albumCard = document.querySelector(`#album-cards-container [data-album-id="${albumId}"]`); + if (!albumCard) { + containerType = 'singles'; + albumCard = document.querySelector(`#singles-cards-container [data-album-id="${albumId}"]`); + } + + if (albumCard) { + console.log(`✅ [DOWNLOAD STATUS] Found album card in ${containerType} container, updating overlay`); + updateAlbumCompletionOverlay(completionData, containerType); + } else { + console.warn(`❌ [DOWNLOAD STATUS] Album card not found for ID: "${albumId}"`); + // Debug: List all available album cards + const allAlbums = document.querySelectorAll('#album-cards-container [data-album-id], #singles-cards-container [data-album-id]'); + console.log(`🔍 [DEBUG] Available album IDs:`, Array.from(allAlbums).map(card => card.dataset.albumId)); + } +} + + +// ---------------------------------------------------------------------------- +// Download bubble infrastructure, image colour/glow helpers, HTML escape, +// service-status polling, enrichment-card rendering. All originally defined +// in artists.js but used broadly across the app. +// ---------------------------------------------------------------------------- + +async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { + if (showLoadingOverlayParam) { + showLoadingOverlay('Loading album...'); + } + // Check if a process is already active for this virtual playlist + if (activeDownloadProcesses[virtualPlaylistId]) { + console.log(`Modal for ${virtualPlaylistId} already exists. Showing it.`); + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + if (showLoadingOverlayParam) { + hideLoadingOverlay(); + } + } + return; + } + + console.log(`📥 Opening Download Missing Tracks modal for artist album: ${virtualPlaylistId}`); + + // Create virtual playlist object for compatibility with existing modal logic + const virtualPlaylist = { + id: virtualPlaylistId, + name: playlistName, + track_count: spotifyTracks.length + }; + + // Store the tracks in the cache for the modal to use + playlistTrackCache[virtualPlaylistId] = spotifyTracks; + currentPlaylistTracks = spotifyTracks; + currentModalPlaylistId = virtualPlaylistId; + + let modal = document.createElement('div'); + modal.id = `download-missing-modal-${virtualPlaylistId}`; + modal.className = 'download-missing-modal'; + modal.style.display = 'none'; + document.body.appendChild(modal); + + // Register the new process in our global state tracker using the same structure as other modals + activeDownloadProcesses[virtualPlaylistId] = { + status: 'idle', + modalElement: modal, + poller: null, + batchId: null, + playlist: virtualPlaylist, + tracks: spotifyTracks, + // Additional metadata for artist albums + artist: artist, + album: album, + albumType: album.album_type, + source: artist?.source || album?.source || artistsPageState.artistDiscography?.source || null + }; + + // Generate hero section — 'artist_album' for releases, 'playlist' for charts/compilations + const heroContext = contextType === 'playlist' ? { + type: 'playlist', + playlist: { name: playlistName, owner: 'Beatport' }, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + } : { + type: 'artist_album', + artist: artist, + album: album, + trackCount: spotifyTracks.length, + playlistId: virtualPlaylistId + }; + + // Use the exact same modal HTML structure as the existing modals + modal.innerHTML = ` +
+
+ ${generateDownloadModalHeroSection(heroContext)} +
+ +
+
+
+
+ 🔍 Library Analysis + Ready to start +
+
+
+
+
+
+
+ ⏬ Downloads + Waiting for analysis +
+
+
+
+
+
+ +
+
+

📋 Track Analysis & Download Status

+ ${spotifyTracks.length} / ${spotifyTracks.length} tracks selected +
+
+ + + + + + + + + + + + + + + ${spotifyTracks.map((track, index) => ` + + + + + + + + + + + `).join('')} + +
+ + #Track NameArtist(s)DurationLibrary StatusDownload StatusActions
+ + ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
+
+
+
+ + + + +
+ `; + + applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); + modal.style.display = 'flex'; + hideLoadingOverlay(); + + console.log(`✅ Successfully opened download missing tracks modal for: ${playlistName}`); +} + +// =============================== +// ARTIST DOWNLOADS MANAGEMENT SYSTEM +// =============================== + +/** + * Register a new artist download for bubble management + */ +function registerArtistDownload(artist, album, virtualPlaylistId, albumType) { + console.log(`📝 Registering artist download: ${artist.name} - ${album.name}`); + + const artistId = artist.id; + + // Initialize artist bubble if it doesn't exist + if (!artistDownloadBubbles[artistId]) { + artistDownloadBubbles[artistId] = { + artist: artist, + downloads: [], + element: null, + hasCompletedDownloads: false + }; + } + + // Add this download to the artist's downloads + const downloadInfo = { + virtualPlaylistId: virtualPlaylistId, + album: album, + albumType: albumType, + status: 'in_progress', // 'in_progress', 'completed', 'view_results' + startTime: new Date() + }; + + artistDownloadBubbles[artistId].downloads.push(downloadInfo); + + // Show/update the artist downloads section + updateArtistDownloadsSection(); + + // Save snapshot of current state + saveArtistBubbleSnapshot(); + + // Monitor this download for completion + monitorArtistDownload(artistId, virtualPlaylistId); +} + +/** + * Debounced update for artist downloads section to prevent rapid updates + */ +function updateArtistDownloadsSection() { + if (downloadsUpdateTimeout) { + clearTimeout(downloadsUpdateTimeout); + } + downloadsUpdateTimeout = setTimeout(() => { + showArtistDownloadsSection(); + showLibraryDownloadsSection(); + showBeatportDownloadsSection(); + updateDashboardDownloads(); + }, 300); // 300ms debounce +} + +// --- Artist Bubble Snapshot System --- + +let snapshotSaveTimeout = null; // Debounce snapshot saves + +async function saveArtistBubbleSnapshot() { + /** + * Saves current artistDownloadBubbles state to backend for persistence. + * Debounced to prevent excessive backend calls. + */ + + // Clear any existing timeout + if (snapshotSaveTimeout) { + clearTimeout(snapshotSaveTimeout); + } + + // Debounce the actual save + snapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(artistDownloadBubbles).length; + + // Don't save empty state + if (bubbleCount === 0) { + console.log('📸 Skipping snapshot save - no artist bubbles to save'); + return; + } + + console.log(`📸 Saving artist bubble snapshot: ${bubbleCount} artists`); + + // Prepare snapshot data (clean up DOM references) + const cleanBubbles = {}; + for (const [artistId, bubbleData] of Object.entries(artistDownloadBubbles)) { + cleanBubbles[artistId] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(download => ({ + virtualPlaylistId: download.virtualPlaylistId, + album: download.album, + albumType: download.albumType, + status: download.status, + startTime: download.startTime instanceof Date ? download.startTime.toISOString() : download.startTime + })), + hasCompletedDownloads: bubbleData.hasCompletedDownloads + }; + } + + const response = await fetch('/api/artist_bubbles/snapshot', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + bubbles: cleanBubbles + }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`✅ Artist bubble snapshot saved: ${bubbleCount} artists`); + } else { + console.error('❌ Failed to save artist bubble snapshot:', data.error); + } + + } catch (error) { + console.error('❌ Error saving artist bubble snapshot:', error); + } + }, 1000); // 1 second debounce +} + +async function hydrateArtistBubblesFromSnapshot() { + /** + * Hydrates artist download bubbles from backend snapshot with live status. + * Called on page load to restore bubble state. + */ + try { + console.log('🔄 Loading artist bubble snapshot from backend...'); + + const response = await fetch('/api/artist_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load artist bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + const stats = data.stats || {}; + + console.log(`🔄 Loaded bubble snapshot: ${stats.total_artists || 0} artists, ${stats.active_downloads || 0} active, ${stats.completed_downloads || 0} completed`); + + if (Object.keys(bubbles).length === 0) { + console.log('ℹ️ No artist bubbles to hydrate'); + return; + } + + // Clear existing state + artistDownloadBubbles = {}; + + // Restore artistDownloadBubbles with hydrated data + for (const [artistId, bubbleData] of Object.entries(bubbles)) { + artistDownloadBubbles[artistId] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(download => ({ + virtualPlaylistId: download.virtualPlaylistId, + album: download.album, + albumType: download.albumType, + status: download.status, // Live status from backend + startTime: new Date(download.startTime) + })), + element: null, // Will be created when UI updates + hasCompletedDownloads: bubbleData.hasCompletedDownloads + }; + + console.log(`🔄 Hydrated artist: ${bubbleData.artist.name} (${bubbleData.downloads.length} downloads)`); + + // Start monitoring for any in-progress downloads + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + console.log(`📡 Starting monitoring for: ${download.album.name}`); + monitorArtistDownload(artistId, download.virtualPlaylistId); + } + } + } + + // Update UI to show hydrated bubbles + updateArtistDownloadsSection(); + + const totalArtists = Object.keys(artistDownloadBubbles).length; + console.log(`✅ Successfully hydrated ${totalArtists} artist download bubbles`); + + } catch (error) { + console.error('❌ Error hydrating artist bubbles from snapshot:', error); + } +} + +// --- Search Bubble Snapshot System --- + +async function saveSearchBubbleSnapshot() { + /** + * Saves current searchDownloadBubbles state to backend for persistence. + */ + try { + // Rate limit saves to avoid spamming backend + if (saveSearchBubbleSnapshot.lastSaveTime) { + const timeSinceLastSave = Date.now() - saveSearchBubbleSnapshot.lastSaveTime; + if (timeSinceLastSave < 2000) { + console.log('⏱️ Skipping search bubble snapshot save (rate limited)'); + return; + } + } + + const bubbleCount = Object.keys(searchDownloadBubbles).length; + + if (bubbleCount === 0) { + console.log('📸 Skipping snapshot save - no search bubbles to save'); + return; + } + + console.log(`📸 Saving search bubble snapshot: ${bubbleCount} artists`); + + // Convert search bubbles to plain objects for serialization + const bubblesToSave = {}; + for (const [artistName, bubbleData] of Object.entries(searchDownloadBubbles)) { + bubblesToSave[artistName] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + item: d.item, + type: d.type, + status: d.status, + startTime: d.startTime + })) + }; + } + + const response = await fetch('/api/search_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: bubblesToSave }) + }); + + const data = await response.json(); + + if (data.success) { + console.log(`✅ Search bubble snapshot saved: ${bubbleCount} artists`); + saveSearchBubbleSnapshot.lastSaveTime = Date.now(); + } else { + console.error('❌ Failed to save search bubble snapshot:', data.error); + } + + } catch (error) { + console.error('❌ Error saving search bubble snapshot:', error); + } +} + +async function hydrateSearchBubblesFromSnapshot() { + /** + * Hydrates search download bubbles from backend snapshot with live status. + */ + try { + console.log('🔄 Loading search bubble snapshot from backend...'); + + const response = await fetch('/api/search_bubbles/hydrate'); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load search bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + const stats = data.stats || {}; + + if (Object.keys(bubbles).length === 0) { + console.log('ℹ️ No search bubbles to hydrate'); + return; + } + + // Clear and restore search bubbles + searchDownloadBubbles = {}; + + for (const [artistName, bubbleData] of Object.entries(bubbles)) { + searchDownloadBubbles[artistName] = { + artist: bubbleData.artist, + downloads: bubbleData.downloads || [] + }; + + console.log(`🔄 Hydrated artist: ${artistName} (${bubbleData.downloads.length} downloads)`); + + // Setup monitoring for each download + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + monitorSearchDownload(artistName, download.virtualPlaylistId); + } + } + } + + const totalArtists = Object.keys(searchDownloadBubbles).length; + console.log(`✅ Successfully hydrated ${totalArtists} search download bubbles`); + + // Refresh display + showSearchDownloadBubbles(); + + } catch (error) { + console.error('❌ Error hydrating search bubbles from snapshot:', error); + } +} + +/** + * Register a new search download for bubble management (grouped by artist) + */ +function registerSearchDownload(item, type, virtualPlaylistId, artistName) { + console.log(`📝 [REGISTER] Registering search download: ${item.name} (${type}) by ${artistName}`); + + // Initialize artist bubble if it doesn't exist + if (!searchDownloadBubbles[artistName]) { + searchDownloadBubbles[artistName] = { + artist: { + name: artistName, + image_url: item.image_url || (item.images && item.images[0]?.url) || null + }, + downloads: [] + }; + } + + // Add this download to the artist's downloads + const downloadInfo = { + virtualPlaylistId: virtualPlaylistId, + item: item, + type: type, // 'album' or 'track' + status: 'in_progress', + startTime: new Date().toISOString() + }; + + searchDownloadBubbles[artistName].downloads.push(downloadInfo); + + console.log(`✅ [REGISTER] Registered search download for ${artistName} - ${item.name}`); + + // Save snapshot + saveSearchBubbleSnapshot(); + + // Setup monitoring + monitorSearchDownload(artistName, virtualPlaylistId); + + // Refresh display + updateSearchDownloadsSection(); +} + +/** + * Debounced update for search downloads section + */ +function updateSearchDownloadsSection() { + if (window.searchUpdateTimeout) { + clearTimeout(window.searchUpdateTimeout); + } + window.searchUpdateTimeout = setTimeout(() => { + showSearchDownloadBubbles(); + updateDashboardDownloads(); + }, 300); +} + +/** + * Monitor a search download for completion status changes + */ +function monitorSearchDownload(artistName, virtualPlaylistId) { + const checkCompletion = setInterval(() => { + const process = activeDownloadProcesses[virtualPlaylistId]; + + if (!process || !searchDownloadBubbles[artistName]) { + clearInterval(checkCompletion); + return; + } + + // Find the download in the artist's downloads + const download = searchDownloadBubbles[artistName].downloads.find( + d => d.virtualPlaylistId === virtualPlaylistId + ); + + if (!download) { + clearInterval(checkCompletion); + return; + } + + // Update status + const newStatus = process.status === 'complete' || process.status === 'view_results' + ? 'view_results' + : 'in_progress'; + + if (download.status !== newStatus) { + console.log(`🔄 [MONITOR] Status changed for ${download.item.name}: ${download.status} -> ${newStatus}`); + download.status = newStatus; + + // Save snapshot and refresh + saveSearchBubbleSnapshot(); + updateSearchDownloadsSection(); + } + }, 2000); +} + +/** + * Show or update the search downloads bubble section + */ +function showSearchDownloadBubbles() { + console.log(`🔄 [SHOW] showSearchDownloadBubbles() called`); + + const resultsArea = document.getElementById('enhanced-main-results-area'); + if (!resultsArea) { + console.log(`⏭️ [SHOW] Skipping - no enhanced-main-results-area found`); + return; + } + + // Count active artists (those with downloads) + const activeArtists = Object.keys(searchDownloadBubbles).filter(artistName => + searchDownloadBubbles[artistName].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + // Show placeholder + resultsArea.innerHTML = ` +
+

Search results will appear here when you select an album or track.

+
+ `; + return; + } + + // Create bubbles display + const bubblesHTML = activeArtists.map(artistName => + createSearchBubbleCard(searchDownloadBubbles[artistName]) + ).join(''); + + resultsArea.innerHTML = ` +
+
+

Active Downloads

+ ${activeArtists.length} +
+
+ ${bubblesHTML} +
+
+ `; + + console.log(`✅ [SHOW] Displayed ${activeArtists.length} search bubbles`); +} + +/** + * Create HTML for a search bubble card (grouped by artist) + */ +function createSearchBubbleCard(artistBubbleData) { + const { artist, downloads } = artistBubbleData; + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { + totalDownloads: downloads.length, + activeCount, + completedCount, + allCompleted + }); + + const imageUrl = artist.image_url || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${escapeHtml(imageUrl)}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ +
+ ` : ''} +
+ `; +} + +/** + * Open modal showing all downloads for an artist + */ +async function openSearchDownloadModal(artistName) { + const artistBubbleData = searchDownloadBubbles[artistName]; + if (!artistBubbleData || searchDownloadModalOpen) return; + + console.log(`🎵 [MODAL OPEN] Opening search download modal for: ${artistBubbleData.artist.name}`); + + searchDownloadModalOpen = true; + + const modal = document.createElement('div'); + modal.id = 'search-download-management-modal'; + modal.className = 'artist-download-management-modal'; + modal.innerHTML = ` +
+
+
+
+
+
+ ${artistBubbleData.artist.image_url + ? `${escapeHtml(artistBubbleData.artist.name)}` + : '
🎵
' + } +
+
+

${escapeHtml(artistBubbleData.artist.name)}

+

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

+
+
+ × +
+
+ +
+
+ ${artistBubbleData.downloads.map((download, index) => createSearchDownloadItem(download, index)).join('')} +
+
+
+
+ `; + + document.body.appendChild(modal); + modal.style.display = 'flex'; + + // Start monitoring for status changes + // Start monitoring for status changes + monitorSearchDownloadModal(artistName); + + // Lazy load artist image if missing (common for iTunes) + if (!artistBubbleData.artist.image_url) { + console.log(`🖼️ Lazy loading modal image for ${artistBubbleData.artist.name} (${artistBubbleData.artist.id})`); + fetch(`/api/artist/${artistBubbleData.artist.id}/image`) + .then(response => response.json()) + .then(data => { + if (data.success && data.image_url) { + // Update header background + const headerBg = modal.querySelector('.artist-download-modal-hero-bg'); + if (headerBg) { + headerBg.style.backgroundImage = `url('${data.image_url}')`; + } + + // Update avatar + const avatarContainer = modal.querySelector('.artist-download-modal-hero-avatar'); + if (avatarContainer) { + avatarContainer.innerHTML = `${artistBubbleData.artist.name}`; + } + + // Update artist object in memory + artistBubbleData.artist.image_url = data.image_url; + } + }) + .catch(err => console.error('❌ Failed to load modal image:', err)); + } +} + +/** + * Create HTML for a download item in the search modal + */ +function createSearchDownloadItem(download, index) { + const { item, type, status, virtualPlaylistId } = download; + const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; + const buttonClass = status === 'view_results' ? 'completed' : 'active'; + const typeLabel = type === 'album' ? 'Album' : type === 'single' ? 'Single' : 'Track'; + + return ` +
+
+ ${item.image_url + ? `${escapeHtml(item.name)}` + : `
+ ${type === 'album' ? '💿' : '🎵'} +
` + } +
+
+
${escapeHtml(item.name)}
+
${typeLabel}
+
+
+ +
+
+ `; +} + +/** + * Reopen an individual download modal from the artist modal + */ +async function reopenDownloadModal(virtualPlaylistId) { + const process = activeDownloadProcesses[virtualPlaylistId]; + + // If process exists, show the existing modal + if (process && process.modalElement) { + console.log(`✅ [REOPEN] Showing existing modal for ${virtualPlaylistId}`); + closeSearchDownloadModal(); + setTimeout(() => { + process.modalElement.style.display = 'flex'; + }, 100); + return; + } + + // Process doesn't exist (after page refresh) - recreate it + console.log(`🔄 [REOPEN] Modal not found, recreating for ${virtualPlaylistId}`); + + // Find the download in searchDownloadBubbles + let downloadData = null; + for (const artistName in searchDownloadBubbles) { + const bubble = searchDownloadBubbles[artistName]; + const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (download) { + downloadData = download; + break; + } + } + + if (!downloadData) { + console.warn(`⚠️ No download data found for ${virtualPlaylistId}`); + return; + } + + // Close search modal first + closeSearchDownloadModal(); + + // Recreate the modal based on type + const { item, type } = downloadData; + + if (type === 'album') { + // For albums, we need to fetch the tracks + console.log(`📥 [REOPEN] Recreating album modal for: ${item.name}`); + + // Fetch album tracks (pass name/artist for Hydrabase support) + showLoadingOverlay(`Loading ${item.name}...`); + + try { + const _sap2 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); + const response = await fetch(`/api/spotify/album/${item.id}?${_sap2}`); + if (!response.ok) { + throw new Error('Failed to fetch album tracks'); + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + throw new Error('No tracks found in album'); + } + + const spotifyTracks = albumData.tracks.map(track => ({ + id: track.id, + name: track.name, + artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], + album: { + name: item.name, + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: track.duration_ms || 0 + })); + + hideLoadingOverlay(); + + // Open the modal + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + item.name, + spotifyTracks, + item, + { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, + false // Don't show loading overlay again + ); + + // Sync with backend to check for active batch process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + try { + const processResponse = await fetch('/api/active-processes'); + if (processResponse.ok) { + const processData = await processResponse.json(); + const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); + + if (activeProcess) { + console.log(`📡 [REOPEN] Found active batch for album: ${activeProcess.batch_id}`); + process.status = 'running'; + process.batchId = activeProcess.batch_id; + + // Update UI to show running state + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + } + } + } catch (err) { + console.warn('Could not check for active processes:', err); + } + } + + } catch (error) { + hideLoadingOverlay(); + showToast(`Failed to load album: ${error.message}`, 'error'); + console.error('Error loading album:', error); + } + + } else { + // For tracks, create enriched track and open modal + console.log(`🎵 [REOPEN] Recreating track modal for: ${item.name}`); + + const enrichedTrack = { + id: item.id, + name: item.name, + artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], + album: item.album || { + name: item.album?.name || 'Unknown Album', + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: item.duration_ms || 0 + }; + + await openDownloadMissingModalForYouTube( + virtualPlaylistId, + `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, + [enrichedTrack] + ); + + // Sync with backend to check for active batch process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + try { + const processResponse = await fetch('/api/active-processes'); + if (processResponse.ok) { + const processData = await processResponse.json(); + const activeProcess = processData.active_processes?.find(p => p.playlist_id === virtualPlaylistId); + + if (activeProcess) { + console.log(`📡 [REOPEN] Found active batch for track: ${activeProcess.batch_id}`); + process.status = 'running'; + process.batchId = activeProcess.batch_id; + + // Update UI to show running state + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + } + } + } catch (err) { + console.warn('Could not check for active processes:', err); + } + } + } +} + +/** + * Monitor search download modal for status changes + */ +function monitorSearchDownloadModal(artistName) { + const updateModal = () => { + if (!searchDownloadModalOpen) return; + + const modal = document.getElementById('search-download-management-modal'); + const itemsContainer = document.getElementById('search-download-items'); + + if (!modal || !itemsContainer || !searchDownloadBubbles[artistName]) return; + + const downloads = searchDownloadBubbles[artistName].downloads; + + // If no downloads at all, close modal + if (downloads.length === 0) { + closeSearchDownloadModal(); + return; + } + + // Update modal content and sync status with active processes + let statusChanged = false; + itemsContainer.innerHTML = downloads.map((download, index) => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + + // Only update status if process exists (otherwise keep current status) + if (process) { + const newStatus = process.status === 'complete' || process.status === 'view_results' + ? 'view_results' + : 'in_progress'; + + if (download.status !== newStatus) { + console.log(`🔄 [MODAL MONITOR] Status changed: ${download.item.name} ${download.status} -> ${newStatus}`); + download.status = newStatus; + statusChanged = true; + } + } + + return createSearchDownloadItem(download, index); + }).join(''); + + // If status changed, refresh bubble display and save + if (statusChanged) { + updateSearchDownloadsSection(); + saveSearchBubbleSnapshot(); + } + + // Continue monitoring + setTimeout(updateModal, 2000); + }; + + setTimeout(updateModal, 1000); +} + +/** + * Close the search download modal + */ +function closeSearchDownloadModal() { + const modal = document.getElementById('search-download-management-modal'); + if (modal) { + modal.style.display = 'none'; + if (modal.parentElement) { + modal.parentElement.removeChild(modal); + } + } + searchDownloadModalOpen = false; +} + +/** + * Bulk complete all downloads for an artist (called when user clicks green checkmark) + */ +function bulkCompleteSearchDownloads(artistName) { + console.log(`🎯 Bulk completing downloads for artist: ${artistName}`); + + const artistBubbleData = searchDownloadBubbles[artistName]; + if (!artistBubbleData) { + console.warn(`❌ No artist bubble data found for ${artistName}`); + return; + } + + // Find all completed downloads + const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); + console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, + completedDownloads.map(d => d.item.name)); + + if (completedDownloads.length === 0) { + console.warn(`⚠️ No completed downloads found for bulk close`); + showToast('No completed downloads to close', 'info'); + return; + } + + // Close all completed modals + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + console.log(`🗑️ Closing modal for: ${download.item.name}`); + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + // No modal open — clean up the bubble entry directly + console.log(`🧹 Direct cleanup (no modal) for: ${download.item.name}`); + cleanupSearchDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); +} + +/** + * Cleanup search download when modal is closed + */ +function cleanupSearchDownload(virtualPlaylistId) { + console.log(`🔍 [CLEANUP] Looking for search download to cleanup: ${virtualPlaylistId}`); + + // Find which artist this download belongs to + for (const artistName in searchDownloadBubbles) { + const downloads = searchDownloadBubbles[artistName].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + console.log(`🧹 [CLEANUP] Found download in artist ${artistName}: ${downloads[downloadIndex].item.name}`); + + // Remove this download + downloads.splice(downloadIndex, 1); + console.log(`🗑️ [CLEANUP] Removed download from ${artistName}'s bubble`); + + // If no more downloads for this artist, remove the bubble + if (downloads.length === 0) { + delete searchDownloadBubbles[artistName]; + console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistName}`); + } + + // Save snapshot and refresh + saveSearchBubbleSnapshot(); + updateSearchDownloadsSection(); + + return; + } + } + + console.log(`⚠️ [CLEANUP] No matching search download found for: ${virtualPlaylistId}`); +} + +/** + * Show or update the artist downloads section in search state + */ +function showArtistDownloadsSection() { + console.log(`🔄 [SHOW] showArtistDownloadsSection() called - refreshing artist bubbles`); + console.log(`🔄 [SHOW] Current view: ${artistsPageState.currentView}, artistDownloadBubbles count: ${Object.keys(artistDownloadBubbles).length}`); + + // Only show in search state + if (artistsPageState.currentView !== 'search') { + console.log(`⏭️ [SHOW] Skipping - not in search state (current: ${artistsPageState.currentView})`); + return; + } + + const artistsSearchState = document.getElementById('artists-search-state'); + if (!artistsSearchState) { + console.log(`⏭️ [SHOW] Skipping - no artists-search-state element found`); + return; + } + + let downloadsSection = document.getElementById('artist-downloads-section'); + + // Create section if it doesn't exist + if (!downloadsSection) { + downloadsSection = document.createElement('div'); + downloadsSection.id = 'artist-downloads-section'; + downloadsSection.className = 'artist-downloads-section'; + + // Insert after the search container + const searchContainer = artistsSearchState.querySelector('.artists-search-container'); + if (searchContainer) { + searchContainer.insertAdjacentElement('afterend', downloadsSection); + } + } + + // Count active artists (those with downloads) + const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => + artistDownloadBubbles[artistId].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + // Show and populate the section + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Current Downloads

+

Active download processes

+
+
+ ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} +
+ `; + + // Add event listeners to bubble cards + activeArtists.forEach(artistId => { + const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); + if (bubbleCard) { + bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); + + // Add dynamic glow effect + const artist = artistDownloadBubbles[artistId].artist; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(bubbleCard, colors); + }); + } + } + }); +} + +/** + * Show download bubbles on the Library page (mirrors showArtistDownloadsSection) + */ +function showLibraryDownloadsSection() { + const libraryContent = document.querySelector('.library-content'); + if (!libraryContent) return; + + let downloadsSection = document.getElementById('library-downloads-section'); + + // Create section if it doesn't exist + if (!downloadsSection) { + downloadsSection = document.createElement('div'); + downloadsSection.id = 'library-downloads-section'; + downloadsSection.className = 'artist-downloads-section'; + + // Insert before the artist grid + const artistGrid = document.getElementById('library-artists-grid'); + if (artistGrid) { + libraryContent.insertBefore(downloadsSection, artistGrid); + } + } + + // Count active artists (reuses artistDownloadBubbles state) + const activeArtists = Object.keys(artistDownloadBubbles).filter(artistId => + artistDownloadBubbles[artistId].downloads.length > 0 + ); + + if (activeArtists.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Current Downloads

+

Active download processes

+
+
+ ${activeArtists.map(artistId => createArtistBubbleCard(artistDownloadBubbles[artistId])).join('')} +
+ `; + + // Add click handlers + glow effects + activeArtists.forEach(artistId => { + const bubbleCard = downloadsSection.querySelector(`[data-artist-id="${artistId}"]`); + if (bubbleCard) { + bubbleCard.addEventListener('click', () => openArtistDownloadModal(artistId)); + const artist = artistDownloadBubbles[artistId].artist; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(bubbleCard, colors); + }); + } + } + }); +} + +/** + * Create HTML for an artist bubble card + */ +function createArtistBubbleCard(artistBubbleData) { + const { artist, downloads } = artistBubbleData; + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + // Enhanced debug logging for bubble card creation and green checkmark detection + console.log(`🔵 [BUBBLE] Creating bubble for ${artist.name}:`, { + totalDownloads: downloads.length, + activeCount, + completedCount, + allCompleted, + downloadStatuses: downloads.map(d => `${d.album.name}: ${d.status}`) + }); + + // CRITICAL: Green checkmark detection logging + if (allCompleted) { + console.log(`🟢 [BUBBLE] GREEN CHECKMARK DETECTED for ${artist.name} - all ${downloads.length} downloads completed`); + console.log(`✅ [BUBBLE] This bubble will have 'all-completed' class and green checkmark`); + } else if (activeCount === 0 && completedCount === 0) { + console.log(`⭕ [BUBBLE] No active or completed downloads for ${artist.name} - this shouldn't happen`); + } else { + console.log(`⏳ [BUBBLE] Still waiting for completion: ${activeCount} active, ${completedCount} completed`); + } + + const imageUrl = artist.image_url || ''; + const backgroundStyle = imageUrl ? + `background-image: url('${imageUrl}');` : + `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(artist.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ +
+ ` : ''} +
+ `; +} + +/** + * Monitor an artist download for completion status changes + */ +function monitorArtistDownload(artistId, virtualPlaylistId) { + // Check if the download process exists and monitor its status + const checkStatus = () => { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (!process || !artistDownloadBubbles[artistId]) { + return; // Process or artist bubble no longer exists + } + + // Find this download in the artist's downloads + const download = artistDownloadBubbles[artistId].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (!download) return; + + // Update download status based on process status + if (process.status === 'complete' && download.status === 'in_progress') { + download.status = 'view_results'; + console.log(`✅ Download completed for ${artistDownloadBubbles[artistId].artist.name} - ${download.album.name}`); + console.log(`📊 Artist ${artistId} downloads status:`, artistDownloadBubbles[artistId].downloads.map(d => `${d.album.name}: ${d.status}`)); + + // Update the downloads section + updateArtistDownloadsSection(); + + // Save snapshot of updated state + saveArtistBubbleSnapshot(); + + // Check if all downloads for this artist are now completed + const artistDownloads = artistDownloadBubbles[artistId].downloads; + const allCompleted = artistDownloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`🟢 All downloads completed for ${artistDownloadBubbles[artistId].artist.name} - green checkmark should appear`); + console.log(`🎯 [STATUS DEBUG] Green checkmark trigger - forcing bubble refresh`); + // Force immediate bubble refresh to show green checkmark + setTimeout(updateArtistDownloadsSection, 100); + } + } + + // Continue monitoring if still active + if (process.status !== 'complete') { + setTimeout(checkStatus, 2000); // Check every 2 seconds + } + }; + + // Start monitoring after a brief delay + setTimeout(checkStatus, 1000); +} + +/** + * Open the artist download management modal + */ +function openArtistDownloadModal(artistId) { + const artistBubbleData = artistDownloadBubbles[artistId]; + if (!artistBubbleData || artistDownloadModalOpen) return; + + console.log(`🎵 [MODAL OPEN] Opening artist download modal for: ${artistBubbleData.artist.name}`); + console.log(`📊 [MODAL OPEN] Current download statuses:`, artistBubbleData.downloads.map(d => `${d.album.name}: ${d.status}`)); + artistDownloadModalOpen = true; + + const modal = document.createElement('div'); + modal.id = 'artist-download-management-modal'; + modal.className = 'artist-download-management-modal'; + modal.innerHTML = ` +
+
+
+
+
+
+ ${artistBubbleData.artist.image_url + ? `${escapeHtml(artistBubbleData.artist.name)}` + : '
' + } +
+
+

${escapeHtml(artistBubbleData.artist.name)}

+

${artistBubbleData.downloads.length} active download${artistBubbleData.downloads.length !== 1 ? 's' : ''}

+
+
+ × +
+
+ +
+
+ ${artistBubbleData.downloads.map((download, index) => createArtistDownloadItem(download, index)).join('')} +
+
+
+
+ `; + + document.body.appendChild(modal); + modal.style.display = 'flex'; + + // Monitor for real-time updates + startArtistDownloadModalMonitoring(artistId); +} + +/** + * Create HTML for an individual download item in the artist modal + */ +function createArtistDownloadItem(download, index) { + const { album, albumType, status, virtualPlaylistId } = download; + const buttonText = status === 'view_results' ? 'View Results' : 'View Progress'; + const buttonClass = status === 'view_results' ? 'completed' : 'active'; + + // Enhanced debugging for button text generation + console.log(`🎯 [BUTTON] Creating item for ${album.name}: status='${status}' → buttonText='${buttonText}'`); + + return ` +
+
+ ${album.image_url + ? `${escapeHtml(album.name)}` + : `
+ +
` + } +
+
+
${escapeHtml(album.name)}
+
${albumType === 'album' ? 'Album' : albumType === 'single' ? 'Single' : 'EP'}
+
+
+ +
+
+ `; +} + +/** + * Monitor artist download modal for real-time updates + */ +function startArtistDownloadModalMonitoring(artistId) { + if (!artistDownloadModalOpen) return; + + const updateModal = () => { + const modal = document.getElementById('artist-download-management-modal'); + const itemsContainer = document.getElementById(`artist-download-items-${artistId}`); + + if (!modal || !itemsContainer || !artistDownloadBubbles[artistId]) return; + + // Check for completed downloads that need to be removed + const activeDownloads = artistDownloadBubbles[artistId].downloads.filter(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + // Keep if process exists or if it's completed but not yet cleaned up + return process !== undefined; + }); + + // Update the downloads array + artistDownloadBubbles[artistId].downloads = activeDownloads; + + // If no downloads left, close modal + if (activeDownloads.length === 0) { + closeArtistDownloadModal(); + return; + } + + // Update modal content and synchronize with bubble state + let statusChanged = false; + itemsContainer.innerHTML = activeDownloads.map((download, index) => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process) { + const newStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; + if (download.status !== newStatus) { + console.log(`🔄 [ARTIST MODAL] Updating ${download.album.name} status from ${download.status} to ${newStatus}`); + download.status = newStatus; + statusChanged = true; + } + } + return createArtistDownloadItem(download, index); + }).join(''); + + // CRITICAL: If any status changed, immediately refresh artist bubble to show green checkmarks + if (statusChanged) { + console.log(`🎯 [SYNC] Status change detected in artist modal - refreshing bubble display`); + updateArtistDownloadsSection(); + + // Check if all downloads for this artist are now completed + const artistDownloads = artistDownloadBubbles[artistId].downloads; + const allCompleted = artistDownloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`🟢 [ARTIST MODAL] All downloads completed for artist ${artistId} - triggering green checkmark`); + // Force additional refresh after a brief delay to ensure UI updates + setTimeout(() => { + console.log(`✨ [ARTIST MODAL] Forcing final refresh for green checkmark`); + updateArtistDownloadsSection(); + }, 200); + } + } + + // Continue monitoring + setTimeout(updateModal, 2000); + }; + + setTimeout(updateModal, 1000); +} + +/** + * Open a specific artist download process modal + */ +function openArtistDownloadProcess(virtualPlaylistId) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process && process.modalElement) { + // Close artist management modal first + closeArtistDownloadModal(); + + // Show the download process modal + process.modalElement.style.display = 'flex'; + + if (process.status === 'complete') { + showToast('Review download results and click "Close" to finish.', 'info'); + } + } +} + +/** + * Close the artist download management modal + */ +function closeArtistDownloadModal() { + const modal = document.getElementById('artist-download-management-modal'); + if (modal) { + modal.remove(); + } + artistDownloadModalOpen = false; +} + +/** + * Bulk complete all downloads for an artist (when all are in 'view_results' state) + */ +function bulkCompleteArtistDownloads(artistId) { + console.log(`🎯 Bulk completing downloads for artist: ${artistId}`); + + const artistBubbleData = artistDownloadBubbles[artistId]; + if (!artistBubbleData) { + console.warn(`❌ No artist bubble data found for ${artistId}`); + return; + } + + // Find all downloads in 'view_results' state + const completedDownloads = artistBubbleData.downloads.filter(d => d.status === 'view_results'); + console.log(`📋 Found ${completedDownloads.length} completed downloads to close:`, + completedDownloads.map(d => d.album.name)); + + if (completedDownloads.length === 0) { + console.warn(`⚠️ No completed downloads found for bulk close`); + showToast('No completed downloads to close', 'info'); + return; + } + + // Programmatically close all completed modals + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + console.log(`🗑️ Closing modal for: ${download.album.name}`); + // Trigger the close function which handles cleanup + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + // No modal open — clean up the bubble entry directly + console.log(`🧹 Direct cleanup (no modal) for: ${download.album.name}`); + cleanupArtistDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${artistBubbleData.artist.name}`, 'success'); +} + +// ======================================== +// Beatport Download Bubbles +// ======================================== + +/** + * Register a new Beatport chart download for bubble management + */ +function registerBeatportDownload(chartName, chartImage, virtualPlaylistId) { + console.log(`📝 Registering Beatport download: ${chartName}`); + + // Use chart name as key (sanitised) + const chartKey = chartName.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + + if (!beatportDownloadBubbles[chartKey]) { + beatportDownloadBubbles[chartKey] = { + chart: { name: chartName, image: chartImage || '' }, + downloads: [] + }; + } + + beatportDownloadBubbles[chartKey].downloads.push({ + virtualPlaylistId: virtualPlaylistId, + status: 'in_progress', + startTime: new Date() + }); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + monitorBeatportDownload(chartKey, virtualPlaylistId); +} + +/** + * Debounced update for Beatport downloads section + */ +function updateBeatportDownloadsSection() { + if (beatportDownloadsUpdateTimeout) { + clearTimeout(beatportDownloadsUpdateTimeout); + } + beatportDownloadsUpdateTimeout = setTimeout(() => { + showBeatportDownloadsSection(); + updateDashboardDownloads(); + }, 300); +} + +/** + * Render Beatport download bubbles on the Beatport page + */ +function showBeatportDownloadsSection() { + const downloadsSection = document.getElementById('beatport-downloads-section'); + if (!downloadsSection) return; + + const activeCharts = Object.keys(beatportDownloadBubbles).filter(key => + beatportDownloadBubbles[key].downloads.length > 0 + ); + + if (activeCharts.length === 0) { + downloadsSection.style.display = 'none'; + return; + } + + downloadsSection.style.display = 'block'; + downloadsSection.innerHTML = ` +
+

Beatport Downloads

+

Active chart download processes

+
+
+ ${activeCharts.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} +
+ `; + + // Attach click handlers + glow + activeCharts.forEach(chartKey => { + const card = downloadsSection.querySelector(`[data-chart-key="${chartKey}"]`); + if (card) { + card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); + const chartImage = beatportDownloadBubbles[chartKey].chart.image; + if (chartImage) { + extractImageColors(chartImage, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); +} + +/** + * Create HTML for a Beatport bubble card (reuses artist bubble CSS) + */ +function createBeatportBubbleCard(bubbleData) { + const { chart, downloads } = bubbleData; + const chartKey = chart.name.replace(/[^a-zA-Z0-9]/g, '_').toLowerCase(); + const activeCount = downloads.filter(d => d.status === 'in_progress').length; + const completedCount = downloads.filter(d => d.status === 'view_results').length; + const allCompleted = activeCount === 0 && completedCount > 0; + + const backgroundStyle = chart.image + ? `background-image: url('${chart.image}');` + : `background: linear-gradient(135deg, rgba(0, 210, 120, 0.3) 0%, rgba(0, 170, 100, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(chart.name)}
+
+ ${activeCount > 0 ? `${activeCount} active` : ''} + ${completedCount > 0 ? `${completedCount} completed` : ''} +
+
+ ${allCompleted ? ` +
+ +
+ ` : ''} +
+ `; +} + +/** + * Monitor a Beatport download for completion + */ +function monitorBeatportDownload(chartKey, virtualPlaylistId) { + const checkStatus = () => { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (!process || !beatportDownloadBubbles[chartKey]) return; + + const download = beatportDownloadBubbles[chartKey].downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (!download) return; + + if (process.status === 'complete' && download.status === 'in_progress') { + download.status = 'view_results'; + console.log(`✅ Beatport download completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + + const allCompleted = beatportDownloadBubbles[chartKey].downloads.every(d => d.status === 'view_results'); + if (allCompleted) { + console.log(`🟢 All Beatport downloads completed for ${beatportDownloadBubbles[chartKey].chart.name}`); + setTimeout(updateBeatportDownloadsSection, 100); + } + } + + if (process.status !== 'complete') { + setTimeout(checkStatus, 2000); + } + }; + + setTimeout(checkStatus, 1000); +} + +/** + * Open the download modal for a Beatport chart bubble + */ +function openBeatportBubbleModal(chartKey) { + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + // Find the first download with an active modal + for (const download of bubbleData.downloads) { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + if (process.status === 'complete') { + showToast('Review download results and click "Close" to finish.', 'info'); + } + return; + } + } + + showToast('No active download modal found for this chart', 'info'); +} + +/** + * Bulk complete all downloads for a Beatport chart + */ +function bulkCompleteBeatportDownloads(chartKey) { + console.log(`🎯 Bulk completing Beatport downloads for chart: ${chartKey}`); + + const bubbleData = beatportDownloadBubbles[chartKey]; + if (!bubbleData) return; + + const completedDownloads = bubbleData.downloads.filter(d => d.status === 'view_results'); + if (completedDownloads.length === 0) { + showToast('No completed downloads to close', 'info'); + return; + } + + completedDownloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process && process.modalElement) { + closeDownloadMissingModal(download.virtualPlaylistId); + } else { + cleanupBeatportDownload(download.virtualPlaylistId); + } + }); + + showToast(`Completed ${completedDownloads.length} downloads for ${bubbleData.chart.name}`, 'success'); +} + +/** + * Clean up a Beatport download when its modal is closed + */ +function cleanupBeatportDownload(virtualPlaylistId) { + console.log(`🔍 [CLEANUP] Looking for Beatport download to cleanup: ${virtualPlaylistId}`); + + for (const chartKey in beatportDownloadBubbles) { + const downloads = beatportDownloadBubbles[chartKey].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + if (downloadIndex !== -1) { + downloads.splice(downloadIndex, 1); + console.log(`🧹 [CLEANUP] Removed Beatport download from ${chartKey}. Remaining: ${downloads.length}`); + + if (downloads.length === 0) { + delete beatportDownloadBubbles[chartKey]; + console.log(`🧹 [CLEANUP] No more downloads - removed Beatport bubble: ${chartKey}`); + } + + updateBeatportDownloadsSection(); + saveBeatportBubbleSnapshot(); + return; + } + } +} + +// --- Beatport Bubble Snapshot System --- + +let beatportSnapshotSaveTimeout = null; + +async function saveBeatportBubbleSnapshot() { + if (beatportSnapshotSaveTimeout) { + clearTimeout(beatportSnapshotSaveTimeout); + } + + beatportSnapshotSaveTimeout = setTimeout(async () => { + try { + const bubbleCount = Object.keys(beatportDownloadBubbles).length; + if (bubbleCount === 0) return; + + const cleanBubbles = {}; + for (const [chartKey, bubbleData] of Object.entries(beatportDownloadBubbles)) { + cleanBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: d.startTime instanceof Date ? d.startTime.toISOString() : d.startTime + })) + }; + } + + const response = await fetch('/api/beatport_bubbles/snapshot', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bubbles: cleanBubbles }) + }); + + const data = await response.json(); + if (data.success) { + console.log(`✅ Beatport bubble snapshot saved: ${bubbleCount} charts`); + } + } catch (error) { + console.error('❌ Error saving Beatport bubble snapshot:', error); + } + }, 1000); +} + +async function hydrateBeatportBubblesFromSnapshot() { + try { + console.log('🔄 Loading Beatport bubble snapshot from backend...'); + + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport_bubbles/hydrate', signal ? { signal } : undefined); + const data = await response.json(); + + if (!data.success) { + console.error('❌ Failed to load Beatport bubble snapshot:', data.error); + return; + } + + const bubbles = data.bubbles || {}; + if (Object.keys(bubbles).length === 0) { + console.log('ℹ️ No Beatport bubbles to hydrate'); + return; + } + + beatportDownloadBubbles = {}; + + for (const [chartKey, bubbleData] of Object.entries(bubbles)) { + beatportDownloadBubbles[chartKey] = { + chart: bubbleData.chart, + downloads: bubbleData.downloads.map(d => ({ + virtualPlaylistId: d.virtualPlaylistId, + status: d.status, + startTime: new Date(d.startTime) + })) + }; + + for (const download of bubbleData.downloads) { + if (download.status === 'in_progress') { + monitorBeatportDownload(chartKey, download.virtualPlaylistId); + } + } + } + + updateBeatportDownloadsSection(); + console.log(`✅ Hydrated ${Object.keys(beatportDownloadBubbles).length} Beatport download bubbles`); + } catch (error) { + if (error && error.name === 'AbortError') { + console.log('⏹ Beatport bubble hydration aborted'); + return; + } + console.error('❌ Error hydrating Beatport bubbles:', error); + } +} + +/** + * Clean up artist download when a modal is closed + */ +function cleanupArtistDownload(virtualPlaylistId) { + console.log(`🔍 [CLEANUP] Looking for download to cleanup: ${virtualPlaylistId}`); + console.log(`🔍 [CLEANUP] Current artist bubbles:`, Object.keys(artistDownloadBubbles)); + + // Find which artist this download belongs to + for (const artistId in artistDownloadBubbles) { + const downloads = artistDownloadBubbles[artistId].downloads; + const downloadIndex = downloads.findIndex(d => d.virtualPlaylistId === virtualPlaylistId); + + console.log(`🔍 [CLEANUP] Checking artist ${artistId}: ${downloads.length} downloads`); + downloads.forEach(d => console.log(` - ${d.album.name} (${d.virtualPlaylistId}): ${d.status}`)); + + if (downloadIndex !== -1) { + const downloadToRemove = downloads[downloadIndex]; + console.log(`🧹 [CLEANUP] Found download to cleanup: ${downloadToRemove.album.name} (status: ${downloadToRemove.status})`); + + // Remove this download from the artist's downloads + downloads.splice(downloadIndex, 1); + console.log(`✅ [CLEANUP] Removed download from artist ${artistId}. Remaining: ${downloads.length}`); + + // If no more downloads for this artist, remove the bubble + if (downloads.length === 0) { + delete artistDownloadBubbles[artistId]; + console.log(`🧹 [CLEANUP] No more downloads - removed artist bubble: ${artistId}`); + } else { + console.log(`📊 [CLEANUP] Artist ${artistId} still has ${downloads.length} downloads remaining`); + } + + // Update the downloads section + console.log(`🔄 [CLEANUP] Updating artist downloads section...`); + updateArtistDownloadsSection(); + + // Save snapshot of updated state + saveArtistBubbleSnapshot(); + break; + } + } + console.log(`✅ [CLEANUP] Cleanup process completed for ${virtualPlaylistId}`); +} + +/** + * Force refresh all artist download statuses (useful for debugging) + */ +function refreshAllArtistDownloadStatuses() { + console.log('🔄 Force refreshing all artist download statuses...'); + + for (const artistId in artistDownloadBubbles) { + const artistData = artistDownloadBubbles[artistId]; + let hasChanges = false; + + artistData.downloads.forEach(download => { + const process = activeDownloadProcesses[download.virtualPlaylistId]; + if (process) { + const expectedStatus = process.status === 'complete' ? 'view_results' : 'in_progress'; + if (download.status !== expectedStatus) { + console.log(`🔧 Fixing status for ${download.album.name}: ${download.status} → ${expectedStatus}`); + download.status = expectedStatus; + hasChanges = true; + } + } + }); + + if (hasChanges) { + console.log(`✅ Updated statuses for ${artistData.artist.name}`); + } + } + + // Force update the downloads section + showArtistDownloadsSection(); +} + +/** + * Extract dominant colors from an image for dynamic glow effects + */ +async function extractImageColors(imageUrl, callback) { + if (!imageUrl) { + callback(getAccentFallbackColors()); // Fallback to Spotify green + return; + } + + // Check cache first for performance + if (artistsPageState.cache.colors[imageUrl]) { + callback(artistsPageState.cache.colors[imageUrl]); + return; + } + + try { + // Create a canvas to analyze the image + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const img = new Image(); + + img.crossOrigin = 'anonymous'; + + img.onload = function () { + // Resize to small dimensions for faster processing + const size = 50; + canvas.width = size; + canvas.height = size; + + // Draw image to canvas + ctx.drawImage(img, 0, 0, size, size); + + try { + // Get image data + const imageData = ctx.getImageData(0, 0, size, size); + const data = imageData.data; + + // Extract colors (sample every few pixels for performance) + const colors = []; + for (let i = 0; i < data.length; i += 16) { // Sample every 4th pixel + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const alpha = data[i + 3]; + + // Skip transparent or very dark pixels + if (alpha > 128 && (r + g + b) > 150) { + colors.push({ r, g, b }); + } + } + + if (colors.length === 0) { + callback(getAccentFallbackColors()); // Fallback + return; + } + + // Find dominant colors using a simple clustering approach + const dominantColors = findDominantColors(colors, 2); + + // Convert to CSS hex colors + const hexColors = dominantColors.map(color => + `#${((1 << 24) + (color.r << 16) + (color.g << 8) + color.b).toString(16).slice(1)}` + ); + + // Cache the colors for future use + artistsPageState.cache.colors[imageUrl] = hexColors; + + callback(hexColors); + + } catch (e) { + console.warn('Color extraction failed, using fallback colors:', e); + callback(getAccentFallbackColors()); + } + }; + + img.onerror = function () { + callback(getAccentFallbackColors()); // Fallback on error + }; + + img.src = imageUrl; + + } catch (error) { + console.warn('Image color extraction error:', error); + callback(getAccentFallbackColors()); + } +} + +/** + * Simple color clustering to find dominant colors + */ +function findDominantColors(colors, numColors = 2) { + if (colors.length === 0) return [{ r: 29, g: 185, b: 84 }]; + + // Simple k-means clustering + let centroids = []; + + // Initialize centroids randomly + for (let i = 0; i < numColors; i++) { + centroids.push(colors[Math.floor(Math.random() * colors.length)]); + } + + // Run a few iterations of k-means + for (let iteration = 0; iteration < 5; iteration++) { + const clusters = Array(numColors).fill().map(() => []); + + // Assign each color to nearest centroid + colors.forEach(color => { + let minDistance = Infinity; + let nearestCluster = 0; + + centroids.forEach((centroid, i) => { + const distance = Math.sqrt( + Math.pow(color.r - centroid.r, 2) + + Math.pow(color.g - centroid.g, 2) + + Math.pow(color.b - centroid.b, 2) + ); + + if (distance < minDistance) { + minDistance = distance; + nearestCluster = i; + } + }); + + clusters[nearestCluster].push(color); + }); + + // Update centroids + centroids = clusters.map(cluster => { + if (cluster.length === 0) return centroids[0]; // Fallback + + const avgR = cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length; + const avgG = cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length; + const avgB = cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length; + + return { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) }; + }); + } + + // Ensure we have vibrant colors by boosting saturation + return centroids.map(color => { + const max = Math.max(color.r, color.g, color.b); + const min = Math.min(color.r, color.g, color.b); + const saturation = max === 0 ? 0 : (max - min) / max; + + // Boost low saturation colors + if (saturation < 0.4) { + const factor = 1.3; + return { + r: Math.min(255, Math.round(color.r * factor)), + g: Math.min(255, Math.round(color.g * factor)), + b: Math.min(255, Math.round(color.b * factor)) + }; + } + + return color; + }); +} + +/** + * Apply dynamic glow effect to a card element + */ +function applyDynamicGlow(cardElement, colors) { + if (!cardElement || colors.length < 2) return; + + const color1 = colors[0]; + const color2 = colors[1]; + + // Add a small delay to make the effect feel more natural + setTimeout(() => { + // Create CSS custom properties for the dynamic colors + cardElement.style.setProperty('--glow-color-1', color1); + cardElement.style.setProperty('--glow-color-2', color2); + cardElement.classList.add('has-dynamic-glow'); + + console.log(`🎨 Applied dynamic glow: ${color1}, ${color2}`); + }, Math.random() * 200 + 100); // Random delay between 100-300ms +} + +/** + * Utility function to escape HTML + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// --- Service Status and System Stats Functions --- + +async function _forceServiceStatusRefresh() { + // Force an immediate status refresh (bypasses WebSocket check) — used after settings save + try { + const response = await fetch('/status'); + if (!response.ok) return; + const data = await response.json(); + handleServiceStatusUpdate(data); + } catch (error) { + console.warn('Could not force service status refresh:', error); + } +} + +async function fetchAndUpdateServiceStatus() { + if (document.hidden) return; // Skip polling when tab is not visible + if (socketConnected) return; // WebSocket is pushing updates — skip HTTP poll + try { + const response = await fetch('/status'); + if (!response.ok) return; + + const data = await response.json(); + + // Cache for library status card + _lastServiceStatus = data; + + // Update service status indicators and text (dashboard) + updateServiceStatus('spotify', data.spotify); + updateServiceStatus('media-server', data.media_server); + updateServiceStatus('soulseek', data.soulseek); + + // Update sidebar service status indicators + updateSidebarServiceStatus('spotify', data.spotify); + updateSidebarServiceStatus('media-server', data.media_server); + updateSidebarServiceStatus('soulseek', data.soulseek); + + // Update downloads nav badge + if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads); + + // Hide sync buttons (not the page) for standalone mode + const isSoulsyncStandalone2 = data.media_server?.type === 'soulsync'; + _isSoulsyncStandalone = isSoulsyncStandalone2; + document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { + if (isSoulsyncStandalone2) { + btn.dataset.hiddenByStandalone = '1'; + btn.style.display = 'none'; + } else if (btn.dataset.hiddenByStandalone) { + delete btn.dataset.hiddenByStandalone; + btn.style.display = ''; + } + }); + + // Update enrichment service cards + if (data.enrichment) renderEnrichmentCards(data.enrichment); + + // Check for Spotify rate limit + if (data.spotify && data.spotify.rate_limited && data.spotify.rate_limit) { + handleSpotifyRateLimit(data.spotify.rate_limit); + } else if (_spotifyRateLimitShown) { + handleSpotifyRateLimit(null); + } + + } catch (error) { + console.warn('Could not fetch service status:', error); + } +} + +function updateServiceStatus(service, statusData) { + const indicator = document.getElementById(`${service}-status-indicator`); + const statusText = document.getElementById(`${service}-status-text`); + + if (indicator && statusText) { + if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) { + indicator.className = 'service-card-indicator rate-limited'; + const remaining = statusData.rate_limited + ? formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0) + : formatRateLimitDuration(statusData.post_ban_cooldown); + const phase = statusData.rate_limited ? 'paused' : 'recovering'; + const fallbackLabel = statusData.source === 'deezer' ? 'Deezer' : 'iTunes'; + statusText.textContent = `${fallbackLabel} (Spotify ${phase} \u2014 ${remaining})`; + statusText.className = 'service-card-status-text rate-limited'; + } else if (statusData.connected) { + indicator.className = 'service-card-indicator connected'; + statusText.textContent = `Connected (${statusData.response_time}ms)`; + statusText.className = 'service-card-status-text connected'; + } else { + indicator.className = 'service-card-indicator disconnected'; + statusText.textContent = 'Disconnected'; + statusText.className = 'service-card-status-text disconnected'; + } + } + + // Update music source title based on active source + if (service === 'spotify' && statusData.source) { + const musicSourceTitleElement = document.getElementById('music-source-title'); + if (musicSourceTitleElement) { + const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; + musicSourceTitleElement.textContent = sourceName; + currentMusicSourceName = sourceName; + } + + // Show/hide Spotify disconnect button based on connection state + const disconnectBtn = document.getElementById('spotify-disconnect-btn'); + if (disconnectBtn) { + disconnectBtn.style.display = statusData.source === 'spotify' ? '' : 'none'; + } + } + + // Update download source title on dashboard card + if (service === 'soulseek' && statusData.source) { + const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; + const displayName = sourceNames[statusData.source] || 'Soulseek'; + const titleEl = document.getElementById('download-source-title'); + if (titleEl) titleEl.textContent = displayName; + } +} + +function updateSidebarServiceStatus(service, statusData) { + const indicator = document.getElementById(`${service}-indicator`); + if (indicator) { + const dot = indicator.querySelector('.status-dot'); + const nameElement = indicator.querySelector('.status-name'); + + if (dot) { + if (service === 'spotify' && (statusData.rate_limited || statusData.post_ban_cooldown)) { + dot.className = 'status-dot rate-limited'; + dot.title = statusData.rate_limited + ? `Spotify paused \u2014 ${formatRateLimitDuration(statusData.rate_limit?.remaining_seconds || 0)} remaining` + : `Spotify recovering \u2014 ${formatRateLimitDuration(statusData.post_ban_cooldown)} cooldown`; + } else if (statusData.connected) { + dot.className = 'status-dot connected'; + dot.title = ''; + } else { + dot.className = 'status-dot disconnected'; + dot.title = ''; + } + } + + // Update media server name if it's the media server indicator + if (service === 'media-server' && statusData.type) { + const mediaServerNameElement = document.getElementById('media-server-name'); + if (mediaServerNameElement) { + const serverName = statusData.type.charAt(0).toUpperCase() + statusData.type.slice(1); + mediaServerNameElement.textContent = serverName; + } + } + + // Update music source name in sidebar based on active source + if (service === 'spotify' && statusData.source) { + const musicSourceNameElement = document.getElementById('music-source-name'); + if (musicSourceNameElement) { + const sourceName = statusData.source === 'spotify' ? 'Spotify' : statusData.source === 'deezer' ? 'Deezer' : statusData.source === 'discogs' ? 'Discogs' : 'iTunes'; + musicSourceNameElement.textContent = sourceName; + } + } + + // Update download source name based on configured mode + if (service === 'soulseek' && statusData.source) { + const sourceNames = { soulseek: 'Soulseek', youtube: 'YouTube', tidal: 'Tidal', qobuz: 'Qobuz', hifi: 'HiFi', deezer_dl: 'Deezer', hybrid: 'Hybrid' }; + const displayName = sourceNames[statusData.source] || 'Soulseek'; + const sidebarName = document.getElementById('download-source-name'); + if (sidebarName) sidebarName.textContent = displayName; + } + } +} + +function renderEnrichmentCards(enrichment) { + const grid = document.getElementById('enrichment-status-grid'); + if (!grid || !enrichment) return; + + // Service display order + const serviceOrder = [ + 'musicbrainz', 'spotify_enrichment', 'itunes_enrichment', 'deezer_enrichment', + 'tidal_enrichment', 'qobuz_enrichment', 'lastfm', 'genius', 'audiodb', + 'acoustid', 'listenbrainz' + ]; + + // Map service keys to their settings page selector for click-to-configure + const settingsSelectors = { + 'spotify_enrichment': '.spotify-title', + 'tidal_enrichment': '.tidal-title', + 'qobuz_enrichment': '.qobuz-title', + 'lastfm': '.lastfm-title', + 'genius': '.genius-title', + 'acoustid': '.acoustid-title', + 'listenbrainz': '.listenbrainz-title', + }; + + const chips = []; + for (const key of serviceOrder) { + const svc = enrichment[key]; + if (!svc) continue; + + // Determine status class and text + let statusClass, statusLabel; + if ('running' in svc) { + if (!svc.configured) { + statusClass = 'not-configured'; + statusLabel = 'Set up'; + } else if (svc.paused) { + statusClass = 'paused'; + statusLabel = svc.yield_reason === 'downloads' ? 'Yielding' : 'Paused'; + } else if (svc.running) { + statusClass = svc.idle ? 'idle' : 'running'; + statusLabel = svc.idle ? 'Idle' : 'Running'; + } else { + statusClass = 'stopped'; + statusLabel = 'Stopped'; + } + } else { + statusClass = svc.configured ? 'running' : 'not-configured'; + statusLabel = svc.configured ? 'Ready' : 'Set up'; + } + + const selector = settingsSelectors[key]; + const clickAttr = selector + ? `onclick="navigateToPage('settings'); setTimeout(() => { switchSettingsTab('connections'); setTimeout(() => { const el = document.querySelector('${selector}'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); }, 100); }, 50);"` + : ''; + + // Build activity display — human-readable, not cryptic numbers + let activityHtml = ''; + let metaHtml = ''; + const isSpotify = key === 'spotify_enrichment'; + + if ('running' in svc && svc.configured) { + const c1h = svc.calls_1h || 0; + const c24h = svc.calls_24h || 0; + + if (isSpotify && svc.daily_budget) { + // Spotify: show budget usage prominently + const b = svc.daily_budget; + const pct = Math.min(100, Math.round((b.used / b.limit) * 100)); + const barClass = b.exhausted ? 'exhausted' : pct > 80 ? 'high' : ''; + activityHtml = `${b.used.toLocaleString()} / ${b.limit.toLocaleString()}`; + metaHtml = `
+
+
`; + } else if (c24h > 0) { + // Other services: show 24h count + activityHtml = `${c24h.toLocaleString()} / 24h`; + } + } + + // Tooltip: full details including 1h breakdown + let tooltipLines = [svc.name + ' — ' + statusLabel]; + if ('running' in svc && svc.configured) { + const c1h = svc.calls_1h || 0; + const c24h = svc.calls_24h || 0; + if (c24h > 0 || c1h > 0) tooltipLines.push('Last hour: ' + c1h + ' · Last 24h: ' + c24h); + } + if (isSpotify && svc.daily_budget) { + const b = svc.daily_budget; + tooltipLines.push('Daily budget: ' + b.used + ' / ' + b.limit + (b.exhausted ? ' (exhausted)' : '')); + } + if (selector && statusClass === 'not-configured') { + tooltipLines = ['Click to configure in Settings']; + } + + const statusDisplay = statusClass === 'not-configured' && selector ? 'Configure →' : statusLabel; + + chips.push(` +
+ + ${svc.name} + ${activityHtml} + ${statusDisplay} + ${metaHtml} +
+ `); + } + + grid.innerHTML = chips.join(''); +} + +// =============================== + + +// ---------------------------------------------------------------------------- +// Similar Artists — fetch + render via MusicMap. Source-agnostic (artist name +// based), works for both library and metadata-source artists. Targets DOM IDs +// #similar-artists-loading, #similar-artists-error, #similar-artists-bubbles- +// container that live on the artist-detail page. +// ---------------------------------------------------------------------------- + +// Similar artists section lives on the standalone artist-detail page with the +// 'ad-' prefixed ids. The resolver shape was originally designed for both the +// inline Artists page and the standalone page; the inline page has since been +// retired, so only the standalone candidate remains. +function _resolveSimilarArtistsTargets() { + const sectionEl = document.getElementById('ad-similar-artists-section'); + if (!sectionEl) return null; + return { + section: sectionEl, + loadingEl: document.getElementById('ad-similar-artists-loading'), + errorEl: document.getElementById('ad-similar-artists-error'), + container: document.getElementById('ad-similar-artists-bubbles-container'), + }; +} + +async function loadSimilarArtists(artistName) { + if (!artistName) { + console.warn('⚠️ No artist name provided for similar artists'); + return; + } + + console.log(`🔍 Loading similar artists for: ${artistName}`); + + const targets = _resolveSimilarArtistsTargets(); + if (!targets) { + console.warn('⚠️ Similar artists section elements not found on any active page'); + return; + } + const { section, loadingEl, errorEl, container } = targets; + + // Show loading state + loadingEl.classList.remove('hidden'); + errorEl.classList.add('hidden'); + container.innerHTML = ''; + section.style.display = 'block'; + + try { + // Create new abort controller for this similar artists stream + similarArtistsController = new AbortController(); + + // Use streaming endpoint for real-time bubble creation + const url = `/api/artist/similar/${encodeURIComponent(artistName)}/stream`; + console.log(`📡 Streaming from: ${url}`); + + const response = await fetch(url, { + signal: similarArtistsController.signal + }); + + if (!response.ok) { + throw new Error(`Failed to fetch similar artists: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + let artistCount = 0; + + // Read the stream + while (true) { + const { done, value } = await reader.read(); + + if (done) { + console.log('✅ Stream complete'); + break; + } + + // Decode the chunk and add to buffer + buffer += decoder.decode(value, { stream: true }); + + // Process complete messages (separated by \n\n) + const messages = buffer.split('\n\n'); + buffer = messages.pop() || ''; // Keep incomplete message in buffer + + for (const message of messages) { + if (!message.trim() || !message.startsWith('data: ')) continue; + + try { + const jsonData = JSON.parse(message.substring(6)); // Remove 'data: ' prefix + + if (jsonData.error) { + throw new Error(jsonData.error); + } + + if (jsonData.artist) { + // Hide loading on first artist + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + } + + // Create and append bubble immediately + const bubble = createSimilarArtistBubble(jsonData.artist); + container.appendChild(bubble); + artistCount++; + + console.log(`✅ Added bubble for: ${jsonData.artist.name} (${artistCount})`); + } + + if (jsonData.complete) { + console.log(`🎉 Streaming complete: ${jsonData.total} artists`); + + if (artistCount === 0) { + loadingEl.classList.add('hidden'); + container.innerHTML = ` +
+
🎵
+
No similar artists found
+
+ `; + } else { + // Lazy load images for similar artists that don't have them + lazyLoadSimilarArtistImages(container); + } + } + } catch (parseError) { + console.error('❌ Error parsing stream message:', parseError); + } + } + } + + // Clear the controller when done + similarArtistsController = null; + + } catch (error) { + // Don't show error if it was aborted (user navigated away) + if (error.name === 'AbortError') { + console.log('⏹️ Similar artists stream aborted (user navigated to new artist)'); + loadingEl.classList.add('hidden'); + return; + } + + console.error('❌ Error loading similar artists:', error); + + // Hide loading, show error + loadingEl.classList.add('hidden'); + errorEl.classList.remove('hidden'); + + // Also show error message in container + container.innerHTML = ` +
+
⚠️
+
${error.message}
+
+ `; + } finally { + // Always clear the controller + similarArtistsController = null; + } +} + +/** + * Lazy load images for similar artist bubbles that don't have images + */ +async function lazyLoadSimilarArtistImages(container) { + if (!container) return; + + const bubblesNeedingImages = container.querySelectorAll('.similar-artist-bubble[data-needs-image="true"]'); + + if (bubblesNeedingImages.length === 0) { + console.log('✅ All similar artist bubbles have images'); + return; + } + + console.log(`🖼️ Lazy loading images for ${bubblesNeedingImages.length} similar artists`); + + // Load images in parallel batches + const batchSize = 5; + const bubbles = Array.from(bubblesNeedingImages); + + for (let i = 0; i < bubbles.length; i += batchSize) { + const batch = bubbles.slice(i, i + batchSize); + + await Promise.all(batch.map(async (bubble) => { + const artistId = bubble.getAttribute('data-artist-id'); + const artistSource = bubble.getAttribute('data-artist-source') || ''; + const artistPlugin = bubble.getAttribute('data-artist-plugin') || ''; + if (!artistId) return; + + try { + const params = new URLSearchParams(); + if (artistSource) params.set('source', artistSource); + if (artistPlugin) params.set('plugin', artistPlugin); + + const imageUrl = params.toString() + ? `/api/artist/${encodeURIComponent(artistId)}/image?${params.toString()}` + : `/api/artist/${encodeURIComponent(artistId)}/image`; + + const response = await fetch(imageUrl); + const data = await response.json(); + + if (data.success && data.image_url) { + const imageContainer = bubble.querySelector('.similar-artist-bubble-image'); + if (imageContainer) { + const artistName = bubble.querySelector('.similar-artist-bubble-name')?.textContent || 'Artist'; + imageContainer.innerHTML = `${artistName}`; + bubble.setAttribute('data-needs-image', 'false'); + console.log(`✅ Loaded image for similar artist ${artistId}`); + } + } + } catch (error) { + console.warn(`⚠️ Failed to load image for similar artist ${artistId}:`, error); + } + })); + } + + console.log('✅ Finished lazy loading similar artist images'); +} + +/** + * Display similar artist bubble cards progressively (one at a time with delay) + */ +function displaySimilarArtistsProgressively(artists) { + const targets = _resolveSimilarArtistsTargets(); + const container = targets && targets.container; + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Add each bubble with a delay to simulate progressive loading + artists.forEach((artist, index) => { + setTimeout(() => { + const bubble = createSimilarArtistBubble(artist); + container.appendChild(bubble); + }, index * 100); // 100ms delay between each bubble + }); + + console.log(`✅ Displaying ${artists.length} similar artist bubbles progressively`); +} + +/** + * Display similar artist bubble cards (all at once - legacy) + */ +function displaySimilarArtists(artists) { + const targets = _resolveSimilarArtistsTargets(); + const container = targets && targets.container; + + if (!container) { + console.warn('⚠️ Similar artists container not found'); + return; + } + + // Clear container + container.innerHTML = ''; + + // Create bubble cards with staggered animation + artists.forEach((artist, index) => { + const bubble = createSimilarArtistBubble(artist); + + // Add staggered animation delay (50ms per bubble) + bubble.style.animationDelay = `${index * 0.05}s`; + + container.appendChild(bubble); + }); + + console.log(`✅ Displayed ${artists.length} similar artist bubbles`); +} + +/** + * Create a similar artist bubble card element + */ +function createSimilarArtistBubble(artist) { + // Create bubble container + const bubble = document.createElement('div'); + bubble.className = 'similar-artist-bubble'; + bubble.setAttribute('data-artist-id', artist.id); + bubble.setAttribute('data-artist-source', artist.source || ''); + if (artist.plugin) { + bubble.setAttribute('data-artist-plugin', artist.plugin); + } + + // Track if image needs lazy loading + const hasImage = artist.image_url && artist.image_url.trim() !== ''; + bubble.setAttribute('data-needs-image', hasImage ? 'false' : 'true'); + + // Create image container + const imageContainer = document.createElement('div'); + imageContainer.className = 'similar-artist-bubble-image'; + + if (hasImage) { + const img = document.createElement('img'); + img.src = artist.image_url; + img.alt = artist.name; + + // Handle image load error + img.onerror = () => { + console.log(`Failed to load image for ${artist.name}`); + imageContainer.innerHTML = `
🎵
`; + bubble.setAttribute('data-needs-image', 'true'); + }; + + imageContainer.appendChild(img); + } else { + // No image - show fallback (will be lazy loaded) + imageContainer.innerHTML = `
🎵
`; + } + + // Create name element + const name = document.createElement('div'); + name.className = 'similar-artist-bubble-name'; + name.textContent = artist.name; + name.title = artist.name; // Tooltip for full name + + // Optional: Create genres element (hidden by default in CSS) + const genres = document.createElement('div'); + genres.className = 'similar-artist-bubble-genres'; + if (artist.genres && artist.genres.length > 0) { + genres.textContent = artist.genres.slice(0, 2).join(', '); + } + + // Assemble bubble + bubble.appendChild(imageContainer); + bubble.appendChild(name); + if (artist.genres && artist.genres.length > 0) { + bubble.appendChild(genres); + } + + // Click → navigate to the standalone artist-detail page. Works for both + // library and source artists thanks to the source-aware backend endpoint. + bubble.addEventListener('click', () => { + console.log(`🎵 Clicked similar artist: ${artist.name} (ID: ${artist.id})`); + navigateToArtistDetail(artist.id, artist.name, artist.source || null); + }); + + return bubble; +} + + +// ---------------------------------------------------------------------------- +// Lazy artist-card image loader (used by wishlist-tools.js + the legacy inline +// Artists page search results). Fetches /api/artist//image for each card +// flagged data-needs-image="true" in batches of 5. +// ---------------------------------------------------------------------------- + +async function lazyLoadArtistImages(container) { + if (!container) { + console.error('❌ lazyLoadArtistImages: container is null'); + return; + } + + const cardsNeedingImages = container.querySelectorAll('[data-needs-image="true"]'); + if (cardsNeedingImages.length === 0) return; + + const batchSize = 5; + const cards = Array.from(cardsNeedingImages); + + for (let i = 0; i < cards.length; i += batchSize) { + const batch = cards.slice(i, i + batchSize); + await Promise.all(batch.map(async (card) => { + const artistId = card.dataset.artistId; + if (!artistId) return; + try { + const response = await fetch(`/api/artist/${artistId}/image`); + const data = await response.json(); + if (data.success && data.image_url) { + if (card.classList.contains('suggestion-card')) { + card.style.backgroundImage = `url(${data.image_url})`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } else if (card.classList.contains('artist-card')) { + const bgElement = card.querySelector('.artist-card-background'); + if (bgElement) { + bgElement.style.cssText = `background-image: url('${data.image_url}'); background-size: cover; background-position: center;`; + } + } + card.dataset.needsImage = 'false'; + } + } catch (error) { + console.error(`❌ Failed to load image for artist ${artistId}:`, error); + } + })); + } +} + +// Legacy global alias — wishlist-tools.js falls back to window.lazyLoadArtistImages +window.lazyLoadArtistImages = lazyLoadArtistImages; + + +// ---------------------------------------------------------------------------- +// Album-card completion overlay error state (called from checkDiscographyCompletion +// when the API request fails) +// ---------------------------------------------------------------------------- + +function showCompletionError() { + const allOverlays = document.querySelectorAll('.completion-overlay.checking'); + allOverlays.forEach(overlay => { + overlay.classList.remove('checking'); + overlay.classList.add('error'); + overlay.innerHTML = 'Error'; + overlay.title = 'Failed to check completion status'; + }); +} diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js new file mode 100644 index 00000000..7b48c34c --- /dev/null +++ b/webui/static/stats-automations.js @@ -0,0 +1,7611 @@ +// IMPORT PAGE (full page, replaces old modal) +// =================================================================== + +let importJobIdCounter = 0; + +const importPageState = { + stagingFiles: [], + selectedSingles: new Set(), + albumData: null, // response from /api/import/album/match + matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides + singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } } + initialized: false, + activeTab: 'album', + tapSelectedChip: null, // for mobile tap-to-assign fallback +}; + +// =============================== +// STATS PAGE +// =============================== + +let _statsRange = '7d'; +let _statsTimelineChart = null; +let _statsGenreChart = null; +let _statsDbStorageChart = null; +let _statsInitialized = false; + +function initializeStatsPage() { + if (_statsInitialized) { + loadStatsData(); + return; + } + _statsInitialized = true; + + // Time range buttons + const rangeContainer = document.getElementById('stats-time-range'); + if (rangeContainer) { + rangeContainer.addEventListener('click', (e) => { + const btn = e.target.closest('.stats-range-btn'); + if (!btn) return; + _statsRange = btn.dataset.range; + rangeContainer.querySelectorAll('.stats-range-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + loadStatsData(); + }); + } + + loadStatsData(); + _updateStatsLastSynced(); +} + +async function triggerStatsSync() { + const btn = document.getElementById('stats-sync-btn'); + if (btn) btn.classList.add('syncing'); + + try { + const resp = await fetch('/api/listening-stats/sync', { method: 'POST' }); + const data = await resp.json(); + if (data.success) { + showToast('Syncing listening data...', 'info'); + // Wait a few seconds for the sync to complete, then reload + setTimeout(async () => { + await loadStatsData(); + _updateStatsLastSynced(); + if (btn) btn.classList.remove('syncing'); + showToast('Listening stats updated', 'success'); + }, 5000); + } else { + showToast(data.error || 'Sync failed', 'error'); + if (btn) btn.classList.remove('syncing'); + } + } catch (e) { + showToast('Sync failed', 'error'); + if (btn) btn.classList.remove('syncing'); + } +} + +async function _updateStatsLastSynced() { + const el = document.getElementById('stats-last-synced'); + if (!el) return; + try { + const resp = await fetch('/api/listening-stats/status'); + const data = await resp.json(); + if (data.stats && data.stats.last_poll) { + el.textContent = `Last synced: ${data.stats.last_poll}`; + } else { + el.textContent = 'Not synced yet'; + } + } catch { + el.textContent = ''; + } +} + +async function loadStatsData() { + // Show loading state + document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '0.3'); + + // Single cached endpoint — instant response + let data; + try { + const resp = await fetch(`/api/stats/cached?range=${_statsRange}`); + data = await resp.json(); + } catch { + data = {}; + } + + if (!data.success) { + // Cache not available — show empty state, user should hit Sync + data = { + overview: {}, top_artists: [], top_albums: [], top_tracks: [], + timeline: [], genres: [], recent: [], health: {} + }; + } + + const overview = data.overview || {}; + const emptyEl = document.getElementById('stats-empty'); + const hasData = (overview.total_plays || 0) > 0; + + if (emptyEl) { + emptyEl.classList.toggle('hidden', hasData); + } + // Hide main content sections when no data + const mainSections = document.querySelectorAll('.stats-overview, .stats-main-grid, .stats-full-width'); + mainSections.forEach(el => el.style.display = hasData ? '' : 'none'); + + // Overview cards + const _fmt = (n) => { + if (!n) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + return n.toLocaleString(); + }; + const _fmtTime = (ms) => { + if (!ms) return '0h'; + const hours = Math.floor(ms / 3600000); + const mins = Math.floor((ms % 3600000) / 60000); + if (hours > 0) return `${hours}h ${mins}m`; + return `${mins}m`; + }; + + // Restore opacity + document.querySelectorAll('.stats-card-value').forEach(el => el.style.opacity = '1'); + + _setText('stats-total-plays', _fmt(overview.total_plays)); + _setText('stats-listening-time', _fmtTime(overview.total_time_ms)); + _setText('stats-unique-artists', _fmt(overview.unique_artists)); + _setText('stats-unique-albums', _fmt(overview.unique_albums)); + _setText('stats-unique-tracks', _fmt(overview.unique_tracks)); + + // Top Artists — visual bubbles + _renderTopArtistsVisual(data.top_artists || []); + + // Top Artists — ranked list + _renderRankedList('stats-top-artists', data.top_artists || [], (item, i) => ` +
+ ${i + 1} + ${item.image_url ? `` : ''} +
+
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !String(item.soul_id).startsWith('soul_unnamed_') ? ' ' : ''}
+
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
+
+ ${_fmt(item.play_count)} plays +
+ `); + + // Top Albums + _renderRankedList('stats-top-albums', data.top_albums || [], (item, i) => ` +
+ ${i + 1} + ${item.image_url ? `` : ''} +
+
${_esc(item.name)}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
+
+ ${_fmt(item.play_count)} plays +
+ `); + + // Top Tracks + _renderRankedList('stats-top-tracks', data.top_tracks || [], (item, i) => ` +
+ ${i + 1} + ${item.image_url ? `` : ''} +
+
${_esc(item.name)}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}
+
+ + ${_fmt(item.play_count)} plays +
+ `); + + // Timeline chart + _renderTimelineChart(data.timeline || []); + + // Genre chart + _renderGenreChart(data.genres || []); + + // Library health + _renderLibraryHealth(data.health || {}); + + // DB storage chart (separate fetch — not part of cached stats) + _loadDbStorageChart(); + + // Recent plays + _renderRecentPlays(data.recent || []); +} + +function _renderTopArtistsVisual(artists) { + const el = document.getElementById('stats-top-artists-visual'); + if (!el || !artists.length) { if (el) el.innerHTML = ''; return; } + + const top5 = artists.slice(0, 5); + const maxPlays = top5[0]?.play_count || 1; + const _fmt = (n) => { + if (!n) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; + return n.toString(); + }; + + el.innerHTML = `
+ ${top5.map((a, i) => { + const pct = Math.round((a.play_count / maxPlays) * 100); + const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 + return `
+
+ ${!a.image_url ? `${(a.name || '?')[0]}` : ''} +
+
+
+
+
${_esc(a.name)}
+
${_fmt(a.play_count)}
+
`; + }).join('')} +
`; +} + +function _setText(id, text) { + const el = document.getElementById(id); + if (el) el.textContent = text; +} + +function _renderRankedList(containerId, items, template) { + const el = document.getElementById(containerId); + if (!el) return; + el.innerHTML = items.length + ? items.map((item, i) => template(item, i)).join('') + : '
No data yet
'; +} + +function _renderTimelineChart(data) { + const canvas = document.getElementById('stats-timeline-chart'); + if (!canvas || typeof Chart === 'undefined') return; + + if (_statsTimelineChart) _statsTimelineChart.destroy(); + + _statsTimelineChart = new Chart(canvas, { + type: 'bar', + data: { + labels: data.map(d => d.date), + datasets: [{ + label: 'Plays', + data: data.map(d => d.plays), + backgroundColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.5)`, + borderColor: `rgba(${getComputedStyle(document.documentElement).getPropertyValue('--accent-rgb').trim() || '29,185,84'}, 0.8)`, + borderWidth: 1, + borderRadius: 4, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { + x: { grid: { display: false }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 }, maxTicksLimit: 12 } }, + y: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: 'rgba(255,255,255,0.3)', font: { size: 10 } }, beginAtZero: true }, + } + } + }); +} + +function _renderGenreChart(data) { + const canvas = document.getElementById('stats-genre-chart'); + const legend = document.getElementById('stats-genre-legend'); + if (!canvas || typeof Chart === 'undefined') return; + + if (_statsGenreChart) _statsGenreChart.destroy(); + + const colors = [ + '#1db954', '#1ed760', '#4ade80', '#7c3aed', '#a855f7', + '#ec4899', '#f43f5e', '#f97316', '#eab308', '#06b6d4', + '#3b82f6', '#6366f1', '#14b8a6', '#84cc16', '#f59e0b', + ]; + + const top = data.slice(0, 10); + + _statsGenreChart = new Chart(canvas, { + type: 'doughnut', + data: { + labels: top.map(g => g.genre), + datasets: [{ + data: top.map(g => g.play_count), + backgroundColor: colors.slice(0, top.length), + borderWidth: 0, + hoverOffset: 6, + }] + }, + options: { + responsive: true, + maintainAspectRatio: true, + cutout: '65%', + plugins: { legend: { display: false } }, + } + }); + + if (legend) { + legend.innerHTML = top.map((g, i) => ` +
+ + ${g.genre} + ${g.percentage}% +
+ `).join(''); + } +} + +function _renderLibraryHealth(data) { + if (!data || !data.total_tracks) return; + + const _fmt = (n) => { + if (!n) return '0'; + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; + return n.toLocaleString(); + }; + + _setText('stats-unplayed', `${_fmt(data.unplayed_count)} (${data.unplayed_percentage || 0}%)`); + _setText('stats-total-duration', data.total_duration_ms ? `${Math.floor(data.total_duration_ms / 3600000)}h` : '0h'); + _setText('stats-total-tracks-count', _fmt(data.total_tracks)); + + // Format bar + const bar = document.getElementById('stats-format-bar'); + if (bar && data.format_breakdown) { + const total = Object.values(data.format_breakdown).reduce((s, v) => s + v, 0) || 1; + const fmtColors = { FLAC: '#3b82f6', MP3: '#f97316', Opus: '#a855f7', AAC: '#14b8a6', OGG: '#eab308', WAV: '#ec4899', Other: '#555' }; + + bar.innerHTML = Object.entries(data.format_breakdown).map(([fmt, count]) => { + const pct = (count / total * 100).toFixed(1); + return `
${pct > 8 ? fmt : ''}
`; + }).join(''); + } + + // Enrichment coverage + const enrichEl = document.getElementById('stats-enrichment-coverage'); + if (enrichEl && data.enrichment_coverage) { + const ec = data.enrichment_coverage; + const services = [ + { name: 'Spotify', pct: ec.spotify || 0, color: '#1db954' }, + { name: 'MusicBrainz', pct: ec.musicbrainz || 0, color: '#ba55d3' }, + { name: 'Deezer', pct: ec.deezer || 0, color: '#a238ff' }, + { name: 'Last.fm', pct: ec.lastfm || 0, color: '#d51007' }, + { name: 'iTunes', pct: ec.itunes || 0, color: '#fc3c44' }, + { name: 'AudioDB', pct: ec.audiodb || 0, color: '#1a9fff' }, + { name: 'Genius', pct: ec.genius || 0, color: '#ffff64' }, + { name: 'Tidal', pct: ec.tidal || 0, color: '#00ffff' }, + { name: 'Qobuz', pct: ec.qobuz || 0, color: '#4285f4' }, + ]; + enrichEl.innerHTML = services.map(s => ` +
+ ${s.name} +
+ ${s.pct}% +
+ `).join(''); + } +} + +async function _loadDbStorageChart() { + try { + const resp = await fetch('/api/stats/db-storage'); + const data = await resp.json(); + if (!data.success || !data.tables || !data.tables.length) return; + _renderDbStorageChart(data.tables, data.total_file_size, data.method); + } catch (e) { + console.debug('DB storage chart load failed:', e); + } +} + +function _renderDbStorageChart(tables, totalFileSize, method) { + const canvas = document.getElementById('stats-db-storage-chart'); + if (!canvas || typeof Chart === 'undefined') return; + + if (_statsDbStorageChart) _statsDbStorageChart.destroy(); + + // Top 8 tables, group rest as "Other" + const top = tables.slice(0, 8); + const rest = tables.slice(8); + const restSize = rest.reduce((s, t) => s + t.size, 0); + if (restSize > 0) top.push({ name: 'Other', size: restSize }); + + const colors = ['#3b82f6', '#f97316', '#a855f7', '#14b8a6', '#eab308', '#ec4899', '#6366f1', '#22c55e', '#555']; + + _statsDbStorageChart = new Chart(canvas, { + type: 'doughnut', + data: { + labels: top.map(t => t.name), + datasets: [{ + data: top.map(t => t.size), + backgroundColor: colors.slice(0, top.length), + borderWidth: 0, + hoverOffset: 4, + }], + }, + options: { + responsive: false, + cutout: '65%', + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => { + const val = ctx.parsed; + if (method === 'dbstat') { + if (val > 1048576) return ` ${(val / 1048576).toFixed(1)} MB`; + return ` ${(val / 1024).toFixed(0)} KB`; + } + return ` ${val.toLocaleString()} rows`; + } + } + } + }, + }, + }); + + // Center label — total file size + const totalEl = document.getElementById('stats-db-total'); + if (totalEl) { + let sizeStr; + if (totalFileSize > 1073741824) sizeStr = (totalFileSize / 1073741824).toFixed(2) + ' GB'; + else if (totalFileSize > 1048576) sizeStr = (totalFileSize / 1048576).toFixed(1) + ' MB'; + else sizeStr = (totalFileSize / 1024).toFixed(0) + ' KB'; + totalEl.innerHTML = `
${sizeStr}
Total Size
`; + } + + // Legend + const legendEl = document.getElementById('stats-db-legend'); + if (legendEl) { + legendEl.innerHTML = top.map((t, i) => { + let sizeLabel; + if (method === 'dbstat') { + if (t.size > 1048576) sizeLabel = (t.size / 1048576).toFixed(1) + ' MB'; + else sizeLabel = (t.size / 1024).toFixed(0) + ' KB'; + } else { + sizeLabel = t.size.toLocaleString() + ' rows'; + } + return `
+ + ${t.name} + ${sizeLabel} +
`; + }).join(''); + } +} + +async function playStatsTrack(title, artist, album) { + // 1. Try the library first — fastest and best quality if owned. + try { + const resp = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, artist }), + }); + const data = await resp.json(); + if (data.success && data.track) { + const t = data.track; + playLibraryTrack({ + id: t.id, + title: t.title, + file_path: t.file_path, + bitrate: t.bitrate, + artist_id: t.artist_id, + album_id: t.album_id, + _stats_image: t.image_url || null, + }, t.album_title || album || '', t.artist_name || artist || ''); + return; + } + } catch (e) { + console.debug('Library resolve failed, will try streaming fallback:', e); + } + + // 2. Library miss — fall back to streaming via the enhanced-search streamer + // (Soulseek → YouTube → other configured sources, same pipeline used by + // the search results' play button). + if (typeof showLoadingOverlay === 'function') { + showLoadingOverlay(`Searching for ${title}...`); + } + try { + const streamResp = await fetch('/api/enhanced-search/stream-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_name: title, + artist_name: artist, + album_name: album || '', + duration_ms: 0, + }), + }); + const streamData = await streamResp.json(); + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + + if (streamData.success && streamData.result) { + if (typeof startStream === 'function') { + await startStream(streamData.result); + } else { + showToast('Streaming not available', 'error'); + } + } else { + showToast(streamData.error || 'Track not found in library or any source', 'error'); + } + } catch (e) { + if (typeof hideLoadingOverlay === 'function') hideLoadingOverlay(); + showToast('Failed to play track', 'error'); + console.error('Stream fallback failed:', e); + } +} + +function _renderRecentPlays(tracks) { + const el = document.getElementById('stats-recent-plays'); + if (!el) return; + + if (!tracks.length) { + el.innerHTML = '
No recent plays
'; + return; + } + + const _ago = (dateStr) => { + if (!dateStr) return ''; + const diff = Date.now() - new Date(dateStr).getTime(); + const mins = Math.floor(diff / 60000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; + }; + + el.innerHTML = tracks.map(t => ` +
+ + ${_esc(t.title)} + ${_esc(t.artist || '')} + ${_ago(t.played_at)} +
+ `).join(''); +} + +// --- Initialization --- + +function initializeImportPage() { + if (!importPageState.initialized) { + importPageState.initialized = true; + importPageRefreshStaging(); + importPageLoadAutoGroups(); + importPageLoadSuggestions(); + } +} + +async function importPageRefreshStaging() { + // Clear finished jobs from the queue + importPageClearFinishedJobs(); + + try { + const resp = await fetch('/api/import/staging/files'); + const data = await resp.json(); + if (!data.success) { + document.getElementById('import-page-staging-path').textContent = `Import folder: error`; + return; + } + + importPageState.stagingFiles = data.files || []; + document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`; + + const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0); + const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB` + : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB` + : `${(totalSize / 1024).toFixed(0)} KB`; + document.getElementById('import-page-staging-stats').textContent = + `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`; + + // Refresh the current tab view after data is loaded + if (importPageState.activeTab === 'singles') { + importPageRenderSinglesList(); + } else if (importPageState.activeTab === 'album') { + importPageLoadAutoGroups(); + } + // Always refresh suggestions and groups in background + importPageLoadSuggestions(); + } catch (err) { + console.error('Failed to refresh staging:', err); + } +} + +function importPageSwitchTab(tab) { + importPageState.activeTab = tab; + document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album'); + document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles'); + document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto'); + document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album'); + document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles'); + document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto'); + + if (tab === 'singles' && importPageState.stagingFiles.length > 0) { + importPageRenderSinglesList(); + } + if (tab === 'auto') { + _autoImportLoadStatus(); + _autoImportLoadResults(); + _autoImportStartPolling(); + } else { + _autoImportStopPolling(); + } +} + +// ── Auto-Import Tab ── +let _autoImportPollInterval = null; +let _autoImportFilter = 'all'; + +function _autoImportStartPolling() { + _autoImportStopPolling(); + _autoImportPollInterval = setInterval(() => { + if (importPageState.activeTab === 'auto') { + _autoImportLoadStatus(); + _autoImportLoadResults(); + } + }, 5000); +} + +function _autoImportStopPolling() { + if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; } +} + +async function _autoImportToggle(enabled) { + // Optimistically update toggle state so it doesn't flicker + const toggle = document.getElementById('auto-import-enabled'); + if (toggle) toggle.checked = enabled; + const statusText = document.getElementById('auto-import-status-text'); + if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...'; + + try { + const res = await fetch('/api/auto-import/toggle', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }) + }); + const data = await res.json(); + if (data.success) { + showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); + _autoImportLoadStatus(); + } else { + // Revert on failure + if (toggle) toggle.checked = !enabled; + } + } catch (e) { + showToast('Error: ' + e.message, 'error'); + if (toggle) toggle.checked = !enabled; + } +} + +async function _autoImportLoadStatus() { + try { + const res = await fetch('/api/auto-import/status'); + const data = await res.json(); + if (!data.success) return; + + const toggle = document.getElementById('auto-import-enabled'); + const statusText = document.getElementById('auto-import-status-text'); + const settingsRow = document.getElementById('auto-import-settings-row'); + const scanNowBtn = document.getElementById('auto-import-scan-now'); + const progressEl = document.getElementById('auto-import-progress'); + const progressText = document.getElementById('auto-import-progress-text'); + + if (toggle) toggle.checked = data.running; + if (settingsRow) settingsRow.style.display = data.running ? '' : 'none'; + if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none'; + + // Live scan progress + if (progressEl) { + if (data.current_status === 'scanning') { + progressEl.style.display = ''; + if (progressText) { + const stats = data.stats || {}; + progressText.textContent = `Scanning: ${data.current_folder || '...'} (${stats.scanned || 0} processed)`; + } + } else { + progressEl.style.display = 'none'; + } + } + + if (statusText) { + if (data.paused) statusText.textContent = 'Paused'; + else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...'; + else if (data.running) { + // Show last scan time + let watchText = 'Watching'; + if (data.last_scan_time) { + try { + const lastScan = new Date(data.last_scan_time); + const diffS = Math.floor((Date.now() - lastScan) / 1000); + if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`; + else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`; + } catch (e) {} + } + statusText.textContent = watchText; + } else statusText.textContent = 'Disabled'; + statusText.className = 'auto-import-status ' + (data.running ? (data.current_status === 'scanning' ? 'scanning' : 'active') : 'disabled'); + } + } catch (e) {} +} + +async function _autoImportLoadResults() { + const container = document.getElementById('auto-import-results'); + if (!container) return; + try { + const res = await fetch('/api/auto-import/results?limit=100'); + const data = await res.json(); + if (!data.success || !data.results || data.results.length === 0) { + if (!container.querySelector('.auto-import-card')) { + container.innerHTML = `
+

No imports yet. Drop album folders or single tracks into your import folder.

+
`; + } + // Hide stats and filters + const statsEl = document.getElementById('auto-import-stats'); + const filtersEl = document.getElementById('auto-import-filters'); + if (statsEl) statsEl.style.display = 'none'; + if (filtersEl) filtersEl.style.display = 'none'; + return; + } + + // Compute stats + const allResults = data.results; + const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length; + const reviewCount = allResults.filter(r => r.status === 'pending_review').length; + const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length; + + // Update stats + const statsEl = document.getElementById('auto-import-stats'); + if (statsEl) { + statsEl.style.display = ''; + document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`; + document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`; + document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`; + } + + // Show filters + const filtersEl = document.getElementById('auto-import-filters'); + if (filtersEl) { + filtersEl.style.display = ''; + // Show batch action buttons when applicable + const approveAllBtn = document.getElementById('auto-import-approve-all'); + const clearBtn = document.getElementById('auto-import-clear-completed'); + if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none'; + if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none'; + } + + // Apply filter + let filtered = allResults; + if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review'); + else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved'); + else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification'); + + if (filtered.length === 0) { + const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter; + container.innerHTML = `

No ${filterName} items.

`; + return; + } + + container.innerHTML = filtered.map((r, idx) => { + const confPct = Math.round((r.confidence || 0) * 100); + const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; + const statusLabels = { + 'completed': 'Imported', 'pending_review': 'Needs Review', + 'needs_identification': 'Unidentified', 'failed': 'Failed', + 'scanning': 'Scanning...', 'matched': 'Matched', + 'rejected': 'Dismissed', 'approved': 'Approved', + }; + const statusIcons = { + 'completed': '\u2713', 'pending_review': '\u26A0', + 'needs_identification': '\u2717', 'failed': '\u2717', + 'scanning': '\u231B', 'matched': '\u2713', + 'rejected': '\u2715', 'approved': '\u2713', + }; + const statusLabel = statusLabels[r.status] || r.status; + const statusIcon = statusIcons[r.status] || ''; + const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' : + r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : 'neutral'; + + // Parse match data for track details + let matchCount = 0, totalTracks = 0, trackDetails = []; + if (r.match_data) { + try { + const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data; + matchCount = md.matched_count || 0; + totalTracks = md.total_tracks || 0; + if (md.matches) { + trackDetails = md.matches.map(m => ({ + name: m.track_name || m.track?.name || 'Unknown', + file: m.file ? m.file.split(/[/\\]/).pop() : '?', + confidence: Math.round((m.confidence || 0) * 100), + })); + } + } catch (e) {} + } + + const matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`; + const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' }; + const methodLabel = methodLabels[r.identification_method] || r.identification_method || ''; + + // Time ago + let timeAgo = ''; + if (r.created_at) { + try { + const d = new Date(r.created_at); + const diffM = Math.floor((Date.now() - d) / 60000); + if (diffM < 1) timeAgo = 'just now'; + else if (diffM < 60) timeAgo = `${diffM}m ago`; + else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`; + else timeAgo = `${Math.floor(diffM / 1440)}d ago`; + } catch (e) {} + } + + let actions = ''; + if (r.status === 'pending_review') { + actions = `
+ + +
`; + } + + // Expanded track list (hidden by default) + let trackListHtml = ''; + if (trackDetails.length > 0) { + trackListHtml = `
+
+ TrackMatched FileConf +
+ ${trackDetails.map(t => { + const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low'; + return `
+ ${escapeHtml(t.name)} + ${escapeHtml(t.file)} + ${t.confidence}% +
`; + }).join('')} +
`; + } + + return `
+
+
+ ${r.image_url ? `` : `
\uD83D\uDCBF
`} +
+
+
${escapeHtml(r.album_name || r.folder_name)}
+
${escapeHtml(r.artist_name || 'Unknown Artist')}
+
+ ${matchSummary} + ${methodLabel ? `${methodLabel}` : ''} + ${timeAgo ? `${timeAgo}` : ''} +
+ ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''} +
+
+
${statusIcon} ${statusLabel}
+
+
+
+
${confPct}% confidence
+ ${actions} +
+
+
${escapeHtml(r.folder_name)}
+ ${trackListHtml} +
`; + }).join(''); + + } catch (e) {} +} + +async function _autoImportSaveSettings() { + const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100; + const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60); + try { + await fetch('/api/auto-import/settings', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval }) + }); + showToast('Settings saved', 'success'); + } catch (e) { showToast('Error', 'error'); } +} + +function _autoImportSetFilter(filter) { + _autoImportFilter = filter; + document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p => + p.classList.toggle('active', p.dataset.filter === filter)); + _autoImportLoadResults(); +} + +async function _autoImportScanNow() { + try { + const res = await fetch('/api/auto-import/scan-now', { method: 'POST' }); + const data = await res.json(); + if (data.success) { + showToast('Scan triggered', 'success'); + _autoImportLoadStatus(); + } else { + showToast(data.error || 'Failed to trigger scan', 'error'); + } + } catch (e) { showToast('Error: ' + e.message, 'error'); } +} + +async function _autoImportApproveAll() { + const confirmed = await showConfirmDialog({ + title: 'Approve All', + message: 'Approve and import all pending review items?', + confirmText: 'Approve All', + }); + if (!confirmed) return; + try { + const res = await fetch('/api/auto-import/approve-all', { method: 'POST' }); + const data = await res.json(); + if (data.success) { + showToast(`Approved ${data.count || 0} items`, 'success'); + _autoImportLoadResults(); + } else { + showToast(data.error || 'Failed', 'error'); + } + } catch (e) { showToast('Error: ' + e.message, 'error'); } +} + +async function _autoImportClearCompleted() { + try { + const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' }); + const data = await res.json(); + if (data.success) { + showToast(`Cleared ${data.count || 0} imported items`, 'success'); + _autoImportLoadResults(); + } else { + showToast(data.error || 'Failed', 'error'); + } + } catch (e) { showToast('Error: ' + e.message, 'error'); } +} + +function _autoImportToggleDetail(idx) { + const trackList = document.getElementById(`auto-import-tracks-${idx}`); + if (trackList) { + trackList.classList.toggle('expanded'); + } +} +window._autoImportToggleDetail = _autoImportToggleDetail; +window._autoImportSetFilter = _autoImportSetFilter; +window._autoImportScanNow = _autoImportScanNow; +window._autoImportApproveAll = _autoImportApproveAll; +window._autoImportClearCompleted = _autoImportClearCompleted; + +async function _autoImportApprove(id) { + try { + const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); } + else showToast(data.error || 'Failed', 'error'); + } catch (e) { showToast('Error', 'error'); } +} + +async function _autoImportReject(id) { + try { + const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); } + else showToast(data.error || 'Failed', 'error'); + } catch (e) { showToast('Error', 'error'); } +} + +// --- Album Tab: Auto-Detected Groups (from file tags) --- + +async function importPageLoadAutoGroups() { + const grid = document.getElementById('import-page-suggestions-grid'); + if (!grid) return; + + try { + const resp = await fetch('/api/import/staging/groups'); + if (!resp.ok) return; + const data = await resp.json(); + + if (!data.success || !data.groups || data.groups.length === 0) return; + + // Build auto-groups section above suggestions + let groupsContainer = document.getElementById('import-page-auto-groups'); + if (!groupsContainer) { + groupsContainer = document.createElement('div'); + groupsContainer.id = 'import-page-auto-groups'; + groupsContainer.style.marginBottom = '16px'; + const suggestionsSection = document.getElementById('import-page-suggestions'); + if (suggestionsSection) { + suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection); + } else { + grid.parentNode.insertBefore(groupsContainer, grid); + } + } + + groupsContainer.innerHTML = ` +
+ Auto-Detected Albums +
+
+ ${data.groups.map((g, idx) => ` +
+
+ ${g.file_count} +
+
+
${_esc(g.album)}
+
${_esc(g.artist)} · ${g.file_count} tracks
+
+
+ `).join('')} +
+ `; + + // Store groups for click handler + importPageState._autoGroups = data.groups; + } catch (err) { + console.warn('Failed to load auto-groups:', err); + } +} + +async function importPageMatchAutoGroup(groupIdx) { + const group = importPageState._autoGroups?.[groupIdx]; + if (!group) return; + + // Search for the album by name + artist + const query = `${group.artist} ${group.album}`; + const searchInput = document.getElementById('import-page-album-search-input'); + if (searchInput) searchInput.value = query; + + // Hide suggestions/groups, show search results + const suggestionsEl = document.getElementById('import-page-suggestions'); + const groupsEl = document.getElementById('import-page-auto-groups'); + if (suggestionsEl) suggestionsEl.style.display = 'none'; + if (groupsEl) groupsEl.style.display = 'none'; + + const grid = document.getElementById('import-page-album-results'); + if (grid) grid.innerHTML = '
Searching...
'; + + try { + const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); + const data = await resp.json(); + + if (data.success && data.albums && data.albums.length > 0) { + // Store file_paths filter so match only includes this group's files + importPageState._autoGroupFilePaths = group.file_paths; + + // Render results — user picks the right album + grid.innerHTML = data.albums.map(a => _renderSuggestionCard(a)).join(''); + } else { + grid.innerHTML = '
No albums found — try searching manually
'; + } + } catch (err) { + console.error('Auto-group search failed:', err); + if (grid) grid.innerHTML = '
Search failed
'; + } +} + +// --- Album Tab: Suggestions (server-side cache, just fetch and render) --- + +async function importPageLoadSuggestions() { + const section = document.getElementById('import-page-suggestions'); + const grid = document.getElementById('import-page-suggestions-grid'); + if (!section || !grid) return; + + try { + const resp = await fetch('/api/import/staging/suggestions'); + if (!resp.ok) return; + const data = await resp.json(); + + if (!data.success || !data.suggestions || data.suggestions.length === 0) { + if (!data.ready) { + // Server is still building cache — show placeholder, retry shortly + section.style.display = ''; + grid.innerHTML = '
Loading suggestions...
'; + setTimeout(() => importPageLoadSuggestions(), 3000); + } else { + section.style.display = 'none'; + grid.innerHTML = ''; + } + return; + } + + section.style.display = ''; + grid.innerHTML = data.suggestions.map(a => _renderSuggestionCard(a)).join(''); + } catch (err) { + // Network error or server not ready — fail silently + console.warn('Failed to load import suggestions:', err); + } +} + +function _renderSuggestionCard(a) { + return `
+ ${_escAttr(a.name)} +
${_esc(a.name)}
+
${_esc(a.artist)}
+
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
+
`; +} + +// --- Album Tab: Search --- + +async function importPageSearchAlbum() { + const query = document.getElementById('import-page-album-search-input').value.trim(); + if (!query) return; + + document.getElementById('import-page-suggestions').style.display = 'none'; + const groupsEl = document.getElementById('import-page-auto-groups'); + if (groupsEl) groupsEl.style.display = 'none'; + const grid = document.getElementById('import-page-album-results'); + grid.innerHTML = '
Searching...
'; + + try { + const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); + const data = await resp.json(); + if (!data.success || !data.albums.length) { + grid.innerHTML = '
No albums found
'; + return; + } + grid.innerHTML = data.albums.map(a => ` +
+ ${_escAttr(a.name)} +
${_esc(a.name)}
+
${_esc(a.artist)}
+
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
+
+ `).join(''); + document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); + } catch (err) { + grid.innerHTML = `
Error: ${err.message}
`; + } +} + +// --- Album Tab: Select Album & Match --- + +async function importPageSelectAlbum(albumId) { + document.getElementById('import-page-album-search-section').classList.add('hidden'); + document.getElementById('import-page-album-match-section').classList.remove('hidden'); + + const matchList = document.getElementById('import-page-match-list'); + matchList.innerHTML = '
Matching files to tracklist...
'; + + try { + // Include file_paths filter if matching from an auto-group + const matchBody = { album_id: albumId }; + if (importPageState._autoGroupFilePaths) { + matchBody.file_paths = importPageState._autoGroupFilePaths; + importPageState._autoGroupFilePaths = null; // clear after use + } + const resp = await fetch('/api/import/album/match', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(matchBody) + }); + const data = await resp.json(); + if (!data.success) { + matchList.innerHTML = `
Error: ${data.error}
`; + return; + } + + importPageState.albumData = data; + importPageState.matchOverrides = {}; + + // Render hero + const album = data.album; + document.getElementById('import-page-album-hero').innerHTML = ` + ${_escAttr(album.name)} +
+
${_esc(album.name)}
+
${_esc(album.artist)}
+
${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0, 4) : ''}
+
+ `; + + importPageRenderMatchList(); + } catch (err) { + matchList.innerHTML = `
Error: ${err.message}
`; + } +} + +function importPageRenderMatchList() { + const data = importPageState.albumData; + if (!data) return; + + const matchList = document.getElementById('import-page-match-list'); + const overrides = importPageState.matchOverrides; + + // Build effective matches: auto-match overridden by manual overrides + // Also track which staging files are used (auto or override) + const usedStagingFiles = new Set(); + + // First pass: collect overridden indices + Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx)); + + // Build rows + let matchedCount = 0; + const rows = data.matches.map((m, idx) => { + let file = null; + let confidence = m.confidence; + let isOverride = false; + + if (overrides.hasOwnProperty(idx)) { + const sfIdx = overrides[idx]; + if (sfIdx === -1) { + // Forcibly unmatched — no file + file = null; + } else { + // Manual override + file = importPageState.stagingFiles[sfIdx] || null; + confidence = 1.0; + isOverride = true; + usedStagingFiles.add(sfIdx); + } + } else if (m.staging_file) { + file = m.staging_file; + // Check if this file was reassigned to another track via override + const autoFileName = m.staging_file.filename; + const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => { + const sf = importPageState.stagingFiles[sfIdx]; + return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx; + }); + if (!reassigned) { + usedStagingFiles.add(-1); // placeholder — auto-matched file + } else { + file = null; // file was reassigned elsewhere + } + } + + if (file) matchedCount++; + const confPercent = Math.round(confidence * 100); + const confClass = confidence >= 0.7 ? '' : 'low'; + + return ` +
+ ${m.spotify_track.track_number} + ${_esc(m.spotify_track.name)} + + ${file + ? `${_esc(file.filename)} + ${confPercent}%` + : `Drop a file here`} + + ${file ? `` : ''} +
+ `; + }); + + matchList.innerHTML = rows.join(''); + + // Unmatched file pool + const unmatchedFiles = []; + importPageState.stagingFiles.forEach((f, i) => { + // Check if used by override + if (Object.values(overrides).includes(i)) return; + // Check if used by auto-match (not overridden away) + const autoUsed = data.matches.some((m, mIdx) => { + if (overrides.hasOwnProperty(mIdx)) return false; + return m.staging_file && m.staging_file.filename === f.filename; + }); + if (autoUsed) return; + unmatchedFiles.push({ file: f, index: i }); + }); + + const poolChips = document.getElementById('import-page-pool-chips'); + document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length; + + if (unmatchedFiles.length === 0) { + poolChips.innerHTML = 'All files matched'; + } else { + poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => ` + + ${_esc(file.filename)} + + `).join(''); + } + + // Stats & button + document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`; + const processBtn = document.getElementById('import-page-album-process-btn'); + processBtn.disabled = matchedCount === 0; + processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`; +} + +// --- Album Tab: Drag and Drop --- + +function importPageStartDrag(event, stagingFileIndex) { + event.dataTransfer.setData('text/plain', stagingFileIndex.toString()); + event.dataTransfer.effectAllowed = 'move'; +} + +function importPageHandleDragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + event.currentTarget.classList.add('drag-over'); + // Remove drag-over from others + document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => { + if (el !== event.currentTarget) el.classList.remove('drag-over'); + }); +} + +function importPageHandleDrop(event, trackIndex) { + event.preventDefault(); + event.currentTarget.classList.remove('drag-over'); + const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain')); + if (isNaN(stagingFileIndex)) return; + + // Remove this staging file from any other track it was assigned to + Object.keys(importPageState.matchOverrides).forEach(k => { + if (importPageState.matchOverrides[k] === stagingFileIndex) { + delete importPageState.matchOverrides[k]; + } + }); + + importPageState.matchOverrides[trackIndex] = stagingFileIndex; + importPageState.tapSelectedChip = null; + importPageRenderMatchList(); +} + +// Mobile tap-to-assign fallback +function importPageTapSelectChip(stagingFileIndex) { + if (importPageState.tapSelectedChip === stagingFileIndex) { + importPageState.tapSelectedChip = null; + } else { + importPageState.tapSelectedChip = stagingFileIndex; + } + importPageRenderMatchList(); +} + +function importPageTapAssign(trackIndex) { + if (importPageState.tapSelectedChip === null) return; + const stagingFileIndex = importPageState.tapSelectedChip; + + // Remove from any other track + Object.keys(importPageState.matchOverrides).forEach(k => { + if (importPageState.matchOverrides[k] === stagingFileIndex) { + delete importPageState.matchOverrides[k]; + } + }); + + importPageState.matchOverrides[trackIndex] = stagingFileIndex; + importPageState.tapSelectedChip = null; + importPageRenderMatchList(); +} + +function importPageUnmatchTrack(trackIndex) { + delete importPageState.matchOverrides[trackIndex]; + // Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay. + // Actually, to truly unmatch: we need to suppress the auto-match too. + // We'll use a sentinel: override = -1 means "forcibly unmatched" + const m = importPageState.albumData?.matches[trackIndex]; + if (m && m.staging_file) { + importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match + } + importPageRenderMatchList(); +} + +function importPageAutoRematch() { + importPageState.matchOverrides = {}; + importPageState.tapSelectedChip = null; + importPageRenderMatchList(); +} + +// --- Album Tab: Process --- + +function importPageProcessAlbum() { + const data = importPageState.albumData; + if (!data) return; + + // Build effective matches with overrides applied + const overrides = importPageState.matchOverrides; + const effectiveMatches = []; + data.matches.forEach((m, idx) => { + if (overrides.hasOwnProperty(idx)) { + if (overrides[idx] === -1) return; // forcibly unmatched — skip + const sf = importPageState.stagingFiles[overrides[idx]]; + effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 }); + } else if (m.staging_file !== null) { + effectiveMatches.push(m); + } + }); + + if (effectiveMatches.length === 0) return; + + // Add to queue and reset search immediately so user can queue more + const album = data.album; + _importQueueAdd({ + type: 'album', + label: album.name, + sublabel: `${album.artist} · ${effectiveMatches.length} tracks`, + imageUrl: album.image_url, + items: effectiveMatches, + albumData: album, + }); + + importPageResetAlbumSearch(); +} + +function importPageResetAlbumSearch() { + importPageState.albumData = null; + importPageState.matchOverrides = {}; + importPageState.tapSelectedChip = null; + importPageState._autoGroupFilePaths = null; + + document.getElementById('import-page-album-search-section').classList.remove('hidden'); + document.getElementById('import-page-album-match-section').classList.add('hidden'); + + // Clear search + document.getElementById('import-page-album-results').innerHTML = ''; + document.getElementById('import-page-album-search-input').value = ''; + document.getElementById('import-page-album-clear-btn').classList.add('hidden'); + + // Re-show auto-groups + const groupsEl = document.getElementById('import-page-auto-groups'); + if (groupsEl) groupsEl.style.display = ''; + + // Refresh suggestions & staging + importPageLoadAutoGroups(); + importPageLoadSuggestions(); + importPageRefreshStaging(); +} + +// --- Singles Tab --- + +function importPageRenderSinglesList() { + const list = document.getElementById('import-page-singles-list'); + const files = importPageState.stagingFiles; + + if (files.length === 0) { + list.innerHTML = '
No audio files found in import folder
'; + return; + } + + list.innerHTML = files.map((f, i) => { + const isSelected = importPageState.selectedSingles.has(i); + const manualMatch = importPageState.singlesManualMatches[i]; + const searchOpen = document.querySelector(`[data-singles-search="${i}"]`); + + let html = ` +
+
+
+
${_esc(f.filename)}
+
+ ${f.title ? `${_esc(f.title)}` : ''} + ${f.artist ? `${_esc(f.artist)}` : ''} + ${f.extension ? `${f.extension}` : ''} +
+ ${manualMatch ? ` +
+ ✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)} + change +
+ ` : ''} +
+
+ +
+
+ `; + return html; + }).join(''); + + importPageUpdateSinglesProcessButton(); +} + +function importPageToggleSingle(idx) { + if (importPageState.selectedSingles.has(idx)) { + importPageState.selectedSingles.delete(idx); + } else { + importPageState.selectedSingles.add(idx); + } + // Update checkbox UI without full re-render + const item = document.querySelector(`[data-single-idx="${idx}"]`); + if (item) { + const cb = item.querySelector('.import-page-single-checkbox'); + if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx)); + } + importPageUpdateSinglesProcessButton(); +} + +function importPageSelectAllSingles() { + const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length; + if (allSelected) { + importPageState.selectedSingles.clear(); + } else { + importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i)); + } + document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All'; + // Update all checkboxes + document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => { + cb.classList.toggle('checked', importPageState.selectedSingles.has(i)); + }); + importPageUpdateSinglesProcessButton(); +} + +function importPageUpdateSinglesProcessButton() { + const btn = document.getElementById('import-page-singles-process-btn'); + const count = importPageState.selectedSingles.size; + btn.textContent = `Process Selected (${count})`; + btn.disabled = count === 0; +} + +function importPageOpenSingleSearch(fileIdx) { + const item = document.querySelector(`[data-single-idx="${fileIdx}"]`); + if (!item) return; + + // Remove any existing search panel + const existing = item.querySelector('.import-page-single-search-panel'); + if (existing) { + existing.remove(); + return; + } + + // Close other open panels + document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove()); + + const f = importPageState.stagingFiles[fileIdx]; + const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, ''); + + const panel = document.createElement('div'); + panel.className = 'import-page-single-search-panel'; + panel.innerHTML = ` + +
+ `; + item.appendChild(panel); + + // Auto-search + const input = panel.querySelector('input'); + input.focus(); + if (defaultQuery) { + importPageSearchSingleTrack(fileIdx, defaultQuery); + } +} + +async function importPageSearchSingleTrack(fileIdx, query) { + if (!query || !query.trim()) return; + + const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`); + if (!resultsDiv) return; + resultsDiv.innerHTML = '
Searching...
'; + + try { + const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`); + const data = await resp.json(); + if (!data.success || !data.tracks.length) { + resultsDiv.innerHTML = '
No results found
'; + return; + } + // Store results in a temp cache so we can reference by index + window._importSingleSearchResults = window._importSingleSearchResults || {}; + window._importSingleSearchResults[fileIdx] = data.tracks; + + resultsDiv.innerHTML = data.tracks.map((t, tIdx) => { + const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + return ` +
+ ${t.image_url ? `` : ''} +
+
${_esc(t.name)} - ${_esc(t.artist)}
+
${_esc(t.album)}${dur ? ' · ' + dur : ''}
+
+ +
+ `; + }).join(''); + } catch (err) { + resultsDiv.innerHTML = `
Error: ${err.message}
`; + } +} + +function importPageSelectSingleMatch(fileIdx, trackIdx) { + const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx]; + if (!trackData) return; + importPageState.singlesManualMatches[fileIdx] = trackData; + + // Auto-select this file + importPageState.selectedSingles.add(fileIdx); + + // Close search panel and re-render this item + importPageRenderSinglesList(); +} + +// --- Singles Tab: Process --- + +function importPageProcessSingles() { + if (importPageState.selectedSingles.size === 0) return; + + const filesToProcess = Array.from(importPageState.selectedSingles).map(i => { + const f = importPageState.stagingFiles[i]; + const manualMatch = importPageState.singlesManualMatches[i]; + if (manualMatch) { + return { ...f, spotify_override: manualMatch }; + } + return f; + }); + + // Add to queue and reset immediately + _importQueueAdd({ + type: 'singles', + label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`, + sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''), + imageUrl: null, + items: filesToProcess, + }); + + importPageState.selectedSingles.clear(); + importPageState.singlesManualMatches = {}; + importPageUpdateSinglesProcessButton(); + importPageRefreshStaging(); +} + +// --- Processing Queue --- + +const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors } + +function _importQueueAdd(job) { + const id = ++importJobIdCounter; + const entry = { + id, + type: job.type, + label: job.label, + sublabel: job.sublabel, + imageUrl: job.imageUrl, + status: 'running', // running | done | error + processed: 0, + total: job.items.length, + errors: [], + }; + _importQueue.push(entry); + _importQueueRender(); + + // Fire and forget — runs in background + _importQueueRunJob(entry, job); +} + +async function _importQueueRunJob(entry, job) { + for (let i = 0; i < job.items.length; i++) { + const itemName = job.type === 'album' + ? (job.items[i].spotify_track?.name || `Track ${i + 1}`) + : (job.items[i].title || job.items[i].filename || `File ${i + 1}`); + + // Update status with current track info + entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`; + _importQueueRender(); + + try { + let resp; + if (job.type === 'album') { + resp = await fetch('/api/import/album/process', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + album: job.albumData, + matches: [job.items[i]] + }) + }); + } else { + resp = await fetch('/api/import/singles/process', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ files: [job.items[i]] }) + }); + } + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const data = await resp.json(); + if (data.success) entry.processed += (data.processed || 0); + if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors); + } catch (err) { + entry.errors.push(`${itemName}: ${err.message}`); + } + + _importQueueRender(); + } + + entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done'; + _importQueueRender(); + + // Refresh staging and suggestions since files moved + importPageRefreshStaging(); + importPageLoadSuggestions(); +} + +function _importQueueRender() { + const container = document.getElementById('import-page-queue'); + const list = document.getElementById('import-page-queue-list'); + const clearBtn = document.getElementById('import-page-queue-clear'); + if (!container || !list) return; + + if (_importQueue.length === 0) { + container.classList.add('hidden'); + return; + } + + container.classList.remove('hidden'); + + // Show clear button only if there are finished jobs + const hasFinished = _importQueue.some(j => j.status !== 'running'); + clearBtn.style.display = hasFinished ? '' : 'none'; + + list.innerHTML = _importQueue.map(j => { + const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0; + const fillClass = j.status === 'error' ? 'error' : ''; + let statusText, statusClass; + if (j.status === 'running') { + statusText = `${j.processed}/${j.total}`; + statusClass = ''; + } else if (j.status === 'done') { + statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done'; + statusClass = j.errors.length > 0 ? 'error' : 'done'; + } else { + statusText = 'Failed'; + statusClass = 'error'; + } + + return ` +
+ ${j.imageUrl + ? `` + : `
`} +
+
${_esc(j.label)}
+
${_esc(j.sublabel)}
+
+
+
+
+
+
${statusText}
+
+
+ `; + }).join(''); +} + +function importPageClearFinishedJobs() { + for (let i = _importQueue.length - 1; i >= 0; i--) { + if (_importQueue[i].status !== 'running') { + _importQueue.splice(i, 1); + } + } + _importQueueRender(); +} + +// ── Import File Tab ────────────────────────────────────────────────── + +let _importFileState = { + rawText: '', + fileName: '', + fileType: '', // 'csv' or 'text' + headers: [], // CSV column headers + rows: [], // raw parsed rows (arrays for csv, strings for text) + columnMap: {}, // { columnIndex: 'track_name' | 'artist_name' | 'album_name' | 'duration' | 'skip' } + parsedTracks: [] // final [{track_name, artist_name, album_name, duration_ms}] +}; + +function _initImportFileTab() { + const dropzone = document.getElementById('import-file-dropzone'); + const fileInput = document.getElementById('import-file-input'); + if (!dropzone || !fileInput) return; + + dropzone.addEventListener('click', () => fileInput.click()); + dropzone.addEventListener('dragover', (e) => { e.preventDefault(); dropzone.classList.add('drag-over'); }); + dropzone.addEventListener('dragleave', () => dropzone.classList.remove('drag-over')); + dropzone.addEventListener('drop', (e) => { + e.preventDefault(); + dropzone.classList.remove('drag-over'); + const file = e.dataTransfer.files[0]; + if (file) _importFileRead(file); + }); + fileInput.addEventListener('change', () => { + if (fileInput.files[0]) _importFileRead(fileInput.files[0]); + fileInput.value = ''; + }); + + // Enable/disable import button based on playlist name + const nameInput = document.getElementById('import-file-playlist-name'); + if (nameInput) { + nameInput.addEventListener('input', () => { + const btn = document.getElementById('import-file-import-btn'); + if (btn) btn.disabled = !nameInput.value.trim(); + }); + } +} + +function _importFileRead(file) { + const ext = file.name.split('.').pop().toLowerCase(); + if (!['csv', 'tsv', 'txt'].includes(ext)) { + showToast('Unsupported file type. Use CSV, TSV, or TXT.', 'error'); + return; + } + + const reader = new FileReader(); + reader.onload = (e) => { + _importFileState.rawText = e.target.result; + _importFileState.fileName = file.name; + _importFileState.fileType = (ext === 'txt') ? 'text' : 'csv'; + _importFileParseAndPreview(); + }; + reader.readAsText(file); +} + +function _importFileDetectDelimiter(firstLine) { + const tab = (firstLine.match(/\t/g) || []).length; + const semi = (firstLine.match(/;/g) || []).length; + const comma = (firstLine.match(/,/g) || []).length; + if (tab >= comma && tab >= semi && tab > 0) return '\t'; + if (semi >= comma && semi > 0) return ';'; + return ','; +} + +function _importFileParseCsv(text, delimiter) { + const lines = text.split(/\r?\n/).filter(l => l.trim()); + if (lines.length < 2) return { headers: [], rows: [] }; + + // Parse CSV with basic quote handling + function parseLine(line) { + const result = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i++) { + const ch = line[i]; + if (ch === '"') { + if (inQuotes && i + 1 < line.length && line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + } else if (ch === delimiter && !inQuotes) { + result.push(current.trim()); + current = ''; + } else { + current += ch; + } + } + result.push(current.trim()); + return result; + } + + const headers = parseLine(lines[0]); + const rows = []; + for (let i = 1; i < lines.length; i++) { + const row = parseLine(lines[i]); + if (row.some(cell => cell)) rows.push(row); + } + return { headers, rows }; +} + +function _importFileAutoMapColumns(headers) { + const map = {}; + const lowerHeaders = headers.map(h => h.toLowerCase().trim()); + + const trackPatterns = ['track_name', 'track name', 'track', 'title', 'song', 'song_name', 'song name', 'name']; + const artistPatterns = ['artist_name', 'artist name', 'artist', 'artists', 'performer']; + const albumPatterns = ['album_name', 'album name', 'album']; + const durationPatterns = ['duration', 'duration_ms', 'length', 'time']; + + function findMatch(patterns) { + for (const p of patterns) { + const idx = lowerHeaders.indexOf(p); + if (idx !== -1 && !(idx in map)) return idx; + } + return -1; + } + + const trackIdx = findMatch(trackPatterns); + if (trackIdx !== -1) map[trackIdx] = 'track_name'; + + const artistIdx = findMatch(artistPatterns); + if (artistIdx !== -1) map[artistIdx] = 'artist_name'; + + const albumIdx = findMatch(albumPatterns); + if (albumIdx !== -1) map[albumIdx] = 'album_name'; + + const durIdx = findMatch(durationPatterns); + if (durIdx !== -1) map[durIdx] = 'duration'; + + return map; +} + +function _importFileParseAndPreview() { + const state = _importFileState; + const text = state.rawText; + + if (state.fileType === 'text') { + // Plain text: one track per line + const lines = text.split(/\r?\n/).filter(l => l.trim()); + state.rows = lines; + state.headers = []; + state.columnMap = {}; + } else { + // CSV/TSV + const firstLine = text.split(/\r?\n/)[0] || ''; + const delimiter = _importFileDetectDelimiter(firstLine); + const { headers, rows } = _importFileParseCsv(text, delimiter); + state.headers = headers; + state.rows = rows; + state.columnMap = _importFileAutoMapColumns(headers); + } + + _importFileBuildTracks(); + _importFileRenderPreview(); +} + +function _importFileBuildTracks() { + const state = _importFileState; + state.parsedTracks = []; + + if (state.fileType === 'text') { + const orderEl = document.getElementById('import-file-text-order'); + const sepEl = document.getElementById('import-file-text-separator'); + const order = orderEl ? orderEl.value : 'artist-title'; + const sep = sepEl ? sepEl.value : ' - '; + + for (const line of state.rows) { + const parts = line.split(sep); + if (parts.length >= 2) { + const a = parts[0].trim(); + const b = parts.slice(1).join(sep).trim(); + state.parsedTracks.push({ + track_name: order === 'artist-title' ? b : a, + artist_name: order === 'artist-title' ? a : b, + album_name: '', + duration_ms: 0 + }); + } else { + // Can't split — treat whole line as track name + state.parsedTracks.push({ + track_name: line.trim(), + artist_name: '', + album_name: '', + duration_ms: 0 + }); + } + } + } else { + // CSV mapped + const map = state.columnMap; + const trackCol = Object.keys(map).find(k => map[k] === 'track_name'); + const artistCol = Object.keys(map).find(k => map[k] === 'artist_name'); + const albumCol = Object.keys(map).find(k => map[k] === 'album_name'); + const durCol = Object.keys(map).find(k => map[k] === 'duration'); + + for (const row of state.rows) { + const track = trackCol !== undefined ? (row[trackCol] || '') : ''; + const artist = artistCol !== undefined ? (row[artistCol] || '') : ''; + const album = albumCol !== undefined ? (row[albumCol] || '') : ''; + let dur = durCol !== undefined ? (row[durCol] || '') : ''; + + // Parse duration: could be ms, seconds, or mm:ss + let durationMs = 0; + if (dur) { + dur = dur.trim(); + if (dur.includes(':')) { + const parts = dur.split(':'); + durationMs = (parseInt(parts[0]) * 60 + parseInt(parts[1] || 0)) * 1000; + } else { + const num = parseFloat(dur); + durationMs = num > 10000 ? num : num * 1000; // assume ms if > 10000, else seconds + } + if (isNaN(durationMs)) durationMs = 0; + } + + state.parsedTracks.push({ + track_name: track, + artist_name: artist, + album_name: album, + duration_ms: durationMs + }); + } + } +} + +function _importFileRenderPreview() { + const state = _importFileState; + const validTracks = state.parsedTracks.filter(t => t.track_name || t.artist_name); + + // Show/hide sections + document.getElementById('import-file-upload-zone').style.display = 'none'; + document.getElementById('import-file-preview-section').style.display = ''; + + // File info + document.getElementById('import-file-name-label').textContent = state.fileName; + document.getElementById('import-file-track-count').textContent = `${validTracks.length} track${validTracks.length !== 1 ? 's' : ''} parsed`; + + // Show format controls based on file type + document.getElementById('import-file-text-format').style.display = state.fileType === 'text' ? '' : 'none'; + document.getElementById('import-file-column-mapping').style.display = state.fileType === 'csv' ? '' : 'none'; + + // Render column mapping for CSV + if (state.fileType === 'csv') { + _importFileRenderColumnMapping(); + } + + // Pre-fill playlist name from filename (strip extension) + const nameInput = document.getElementById('import-file-playlist-name'); + if (nameInput && !nameInput.value) { + nameInput.value = state.fileName.replace(/\.[^.]+$/, ''); + } + // Update button state + const btn = document.getElementById('import-file-import-btn'); + if (btn) btn.disabled = !nameInput.value.trim(); + + // Render preview table + const tbody = document.getElementById('import-file-preview-tbody'); + tbody.innerHTML = ''; + + state.parsedTracks.forEach((t, i) => { + const valid = !!(t.track_name || t.artist_name); + const tr = document.createElement('tr'); + if (!valid) tr.classList.add('invalid-row'); + tr.innerHTML = ` + ${i + 1} + ${_esc(t.track_name)} + ${_esc(t.artist_name)} + ${_esc(t.album_name)} + `; + tbody.appendChild(tr); + }); +} + +function _importFileRenderColumnMapping() { + const state = _importFileState; + const container = document.getElementById('import-file-mapping-selects'); + container.innerHTML = ''; + + const options = ['skip', 'track_name', 'artist_name', 'album_name', 'duration']; + const optLabels = { skip: 'Skip', track_name: 'Track', artist_name: 'Artist', album_name: 'Album', duration: 'Duration' }; + + state.headers.forEach((header, idx) => { + const mapped = state.columnMap[idx] || 'skip'; + const wrap = document.createElement('div'); + wrap.className = 'import-file-col-map'; + if (mapped === 'track_name') wrap.classList.add('mapped-track'); + else if (mapped === 'artist_name') wrap.classList.add('mapped-artist'); + else if (mapped === 'album_name') wrap.classList.add('mapped-album'); + + const label = document.createElement('span'); + label.className = 'import-file-col-label'; + label.textContent = header; + label.title = header; + + const sel = document.createElement('select'); + sel.className = 'import-file-select'; + options.forEach(o => { + const opt = document.createElement('option'); + opt.value = o; + opt.textContent = optLabels[o]; + if (o === mapped) opt.selected = true; + sel.appendChild(opt); + }); + sel.addEventListener('change', () => { + if (sel.value === 'skip') { + delete state.columnMap[idx]; + } else { + // Remove this mapping from any other column + for (const k of Object.keys(state.columnMap)) { + if (state.columnMap[k] === sel.value) delete state.columnMap[k]; + } + state.columnMap[idx] = sel.value; + } + _importFileBuildTracks(); + _importFileRenderPreview(); + }); + + wrap.appendChild(label); + wrap.appendChild(sel); + container.appendChild(wrap); + }); +} + +function importFileReparse() { + _importFileBuildTracks(); + _importFileRenderPreview(); +} + +function importFileClear() { + _importFileState = { + rawText: '', fileName: '', fileType: '', + headers: [], rows: [], columnMap: {}, parsedTracks: [] + }; + document.getElementById('import-file-upload-zone').style.display = ''; + document.getElementById('import-file-preview-section').style.display = 'none'; + document.getElementById('import-file-playlist-name').value = ''; + document.getElementById('import-file-preview-tbody').innerHTML = ''; +} + +function importFileSubmit() { + const nameInput = document.getElementById('import-file-playlist-name'); + const name = nameInput ? nameInput.value.trim() : ''; + if (!name) { + showToast('Please enter a playlist name.', 'error'); + nameInput && nameInput.focus(); + return; + } + + const tracks = _importFileState.parsedTracks.filter(t => t.track_name || t.artist_name); + if (!tracks.length) { + showToast('No valid tracks to import.', 'error'); + return; + } + + // Use a unique ID based on timestamp so multiple imports don't collide + const sourceId = `file_${Date.now()}`; + + mirrorPlaylist('file', sourceId, name, tracks, { + description: `Imported from ${_importFileState.fileName}`, + owner: 'local' + }); + + showToast(`Imported "${name}" with ${tracks.length} tracks`, 'success'); + importFileClear(); + + // Switch to mirrored tab so user sees the result + const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); + if (mirroredBtn) { + mirroredBtn.click(); + // Reload mirrored playlists to show the new one + setTimeout(() => loadMirroredPlaylists(), 500); + } +} + +// ── Mirrored Playlists ──────────────────────────────────────────────── + +let mirroredPlaylistsLoaded = false; + +/** + * Fire-and-forget helper: send parsed playlist data to be mirrored on the backend. + */ +function mirrorPlaylist(source, sourceId, name, tracks, metadata = {}) { + const normalizedTracks = tracks.map(t => ({ + track_name: t.track_name || t.name || '', + artist_name: t.artist_name || (Array.isArray(t.artists) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : t.artists || ''), + album_name: t.album_name || (typeof t.album === 'object' ? (t.album && t.album.name) : t.album) || '', + duration_ms: t.duration_ms || 0, + image_url: t.image_url || (t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null), + source_track_id: t.source_track_id || t.id || t.spotify_track_id || '', + extra_data: t.extra_data || null + })); + + fetch('/api/mirror-playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source, + source_playlist_id: String(sourceId), + name, + tracks: normalizedTracks, + description: metadata.description || '', + owner: metadata.owner || '', + image_url: metadata.image_url || '' + }) + }).then(r => r.json()).then(data => { + if (data.success) console.log(`Mirrored ${source} playlist: ${name} (${normalizedTracks.length} tracks)`); + }).catch(err => console.warn('Mirror save failed:', err)); +} + +/** + * Load and render all mirrored playlists into the Mirrored tab. + */ +async function loadMirroredPlaylists() { + const container = document.getElementById('mirrored-playlist-container'); + if (!container) return; + container.innerHTML = `
Loading mirrored playlists...
`; + + try { + const res = await fetch('/api/mirrored-playlists'); + const playlists = await res.json(); + if (playlists.error) throw new Error(playlists.error); + + if (!playlists.length) { + container.innerHTML = `
Playlists you parse from any service will appear here as persistent backups.
`; + return; + } + + container.innerHTML = ''; + playlists.forEach(p => renderMirroredCard(p, container)); + mirroredPlaylistsLoaded = true; + + // Hydrate discovery states from backend (survives page refresh) + await hydrateMirroredDiscoveryStates(); + } catch (err) { + container.innerHTML = `
Error loading mirrored playlists: ${err.message}
`; + } +} + +function renderMirroredCard(p, container) { + const ago = timeAgo(p.updated_at || p.mirrored_at); + const hash = `mirrored_${p.id}`; + const state = youtubePlaylistStates[hash]; + const phase = state ? state.phase : null; + + // Build phase indicator + let phaseHtml = ''; + if (phase === 'discovering') { + const pct = state.discoveryProgress || state.discovery_progress || 0; + phaseHtml = `Discovering ${pct}%`; + } else if (phase === 'discovered') { + const matches = state.spotifyMatches || state.spotify_matches || 0; + const total = state.spotify_total || p.track_count; + phaseHtml = `Discovered ${matches}/${total}`; + } else if (phase === 'syncing' || phase === 'sync_complete') { + phaseHtml = `${phase === 'syncing' ? 'Syncing...' : 'Synced'}`; + } else if (phase === 'downloading') { + phaseHtml = `Downloading...`; + } else if (phase === 'download_complete') { + phaseHtml = `Downloaded`; + } + + const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛', file: '📄' }; + const srcIcon = sourceIcons[p.source] || '📋'; + + // Discovery ratio + const disc = p.discovered_count || 0; + const tot = p.total_count || p.track_count || 0; + let ratioHtml = ''; + if (disc > 0) { + const complete = disc >= tot; + const srcName = typeof currentMusicSourceName !== 'undefined' ? currentMusicSourceName : 'metadata'; + ratioHtml = `${disc}/${tot} discovered on ${srcName}`; + } + + const card = document.createElement('div'); + card.className = 'mirrored-playlist-card'; + card.id = `mirrored-card-${p.id}`; + card.innerHTML = ` +
${srcIcon}
+
+
${_esc(p.name)}
+
+ ${_esc(p.source)} + ${p.track_count} tracks + Mirrored ${ago} + ${ratioHtml} + ${phaseHtml} +
+
+ ${disc > 0 ? `` : ''} + + `; + card.addEventListener('click', () => { + const st = youtubePlaylistStates[hash]; + // Treat as non-fresh if phase is set, or if a poller/discovery modal exists + const hasActiveDiscovery = activeYouTubePollers[hash] || document.getElementById(`youtube-discovery-modal-${hash}`); + if (st && ((st.phase && st.phase !== 'fresh') || hasActiveDiscovery)) { + if (st.phase === 'downloading' || st.phase === 'download_complete') { + // Open download modal directly (follows Tidal/YouTube card click pattern) + const spotifyPlaylistId = st.convertedSpotifyPlaylistId; + if (spotifyPlaylistId && activeDownloadProcesses[spotifyPlaylistId]) { + // Modal already exists — just show it + const process = activeDownloadProcesses[spotifyPlaylistId]; + if (process.modalElement) { + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + } + } else if (spotifyPlaylistId) { + // Need to rehydrate the download modal + rehydrateMirroredDownloadModal(hash, st); + } else { + // No converted playlist ID yet, fall back to discovery modal + openYouTubeDiscoveryModal(hash); + } + } else { + openYouTubeDiscoveryModal(hash); + if (st.phase === 'discovering' && !activeYouTubePollers[hash]) { + startYouTubeDiscoveryPolling(hash); + } + } + } else { + openMirroredPlaylistModal(p.id); + } + }); + container.appendChild(card); +} + +function updateMirroredCardPhase(urlHash, phase) { + // Update the state phase (updateYouTubeCardPhase skips this for mirrored playlists due to no cardElement) + const state = youtubePlaylistStates[urlHash]; + if (state) state.phase = phase; + + // Extract the numeric ID from urlHash (e.g., 'mirrored_3' → '3') + const mirroredId = urlHash.replace('mirrored_', ''); + const card = document.getElementById(`mirrored-card-${mirroredId}`); + if (!card) return; + + const metaEl = card.querySelector('.card-meta'); + if (!metaEl) return; + + // Remove old phase indicator + const oldPhase = metaEl.querySelector('span[style]'); + if (oldPhase) oldPhase.remove(); + + // Add new phase indicator + let phaseHtml = ''; + switch (phase) { + case 'discovering': + phaseHtml = `Discovering...`; + break; + case 'discovered': + const matches = state?.spotifyMatches || state?.spotify_matches || 0; + const total = state?.spotify_total || 0; + phaseHtml = `Discovered ${matches}/${total}`; + break; + case 'syncing': + phaseHtml = `Syncing...`; + break; + case 'sync_complete': + phaseHtml = `Synced`; + break; + case 'downloading': + phaseHtml = `Downloading...`; + break; + case 'download_complete': + phaseHtml = `Downloaded`; + break; + } + if (phaseHtml) { + metaEl.insertAdjacentHTML('beforeend', phaseHtml); + } +} + +async function rehydrateMirroredDownloadModal(urlHash, state) { + try { + if (!state || !state.playlist) { + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + console.log(`💧 [Rehydration] Rehydrating mirrored download modal for: ${state.playlist.name}`); + + // Get discovery results from backend if not already loaded + let discoveryRes = state.discoveryResults || state.discovery_results; + if (!discoveryRes || discoveryRes.length === 0) { + console.log(`🔍 Fetching discovery results from backend for mirrored playlist: ${urlHash}`); + const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + state.discovery_results = fullState.discovery_results; + state.discoveryResults = fullState.discovery_results; + state.convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + state.download_process_id = fullState.download_process_id; + discoveryRes = fullState.discovery_results; + console.log(`✅ Loaded ${discoveryRes?.length || 0} discovery results from backend`); + } else { + showToast('Error loading playlist data', 'error'); + return; + } + } + + // Extract Spotify tracks from discovery results + const spotifyTracks = (discoveryRes || []) + .filter(r => r.spotify_data || (r.spotify_track && r.status_class === 'found')) + .map(r => { + if (r.spotify_data) return r.spotify_data; + const albumData = r.spotify_album || 'Unknown Album'; + return { + id: r.spotify_id || 'unknown', + name: r.spotify_track || 'Unknown Track', + artists: r.spotify_artist ? [r.spotify_artist] : ['Unknown Artist'], + album: typeof albumData === 'object' ? albumData : { name: albumData, album_type: 'album', images: [] }, + duration_ms: 0 + }; + }); + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + const virtualPlaylistId = state.convertedSpotifyPlaylistId; + const playlistName = state.playlist.name; + + // Create the download modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + // If we have a download process ID, set up the modal for the running/complete state + if (state.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = state.phase === 'download_complete' ? 'complete' : 'running'; + process.batchId = state.download_process_id; + + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + + if (state.phase === 'downloading') { + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + console.log(`🔄 Started polling for active mirrored download: ${state.download_process_id}`); + } else if (state.phase === 'download_complete') { + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'none'; + console.log(`✅ Showing completed mirrored download results: ${state.download_process_id}`); + + // Fetch final results to populate the modal + try { + const response = await fetch(`/api/playlists/${state.download_process_id}/download_status`); + if (response.ok) { + const data = await response.json(); + if (data.phase === 'complete' && data.tasks) { + updateCompletedModalResults(virtualPlaylistId, data); + } + } + } catch (err) { + console.warn('Could not load completed download results:', err); + } + } + } + } + + console.log(`✅ Successfully rehydrated mirrored download modal for: ${state.playlist.name}`); + } catch (error) { + console.error('❌ Error rehydrating mirrored download modal:', error); + showToast('Error opening download modal', 'error'); + } +} + +async function hydrateMirroredDiscoveryStates() { + try { + const res = await fetch('/api/mirrored-playlists/discovery-states'); + const data = await res.json(); + if (data.error || !data.states || data.states.length === 0) return; + + console.log(`Hydrating ${data.states.length} mirrored discovery states`); + + for (const s of data.states) { + const hash = s.url_hash; + + youtubePlaylistStates[hash] = { + playlist: s.playlist, + phase: s.phase, + discovery_results: s.discovery_results || [], + discoveryResults: s.discovery_results || [], + discovery_progress: s.discovery_progress || 0, + discoveryProgress: s.discovery_progress || 0, + spotify_matches: s.spotify_matches || 0, + spotifyMatches: s.spotify_matches || 0, + spotify_total: s.spotify_total || 0, + status: s.status || '', + url: s.playlist?.url || '', + sync_playlist_id: null, + converted_spotify_playlist_id: s.converted_spotify_playlist_id, + convertedSpotifyPlaylistId: s.converted_spotify_playlist_id, + download_process_id: s.download_process_id, + created_at: Date.now() / 1000, + last_accessed: Date.now() / 1000, + discovery_future: null, + sync_progress: {}, + is_mirrored_playlist: true, + mirrored_source: s.playlist?.source || '' + }; + + // Update the card to reflect the current phase + const card = document.getElementById(`mirrored-card-${s.playlist_id}`); + if (card) { + const metaEl = card.querySelector('.card-meta'); + if (metaEl) { + // Remove old phase span and add new one + const oldPhase = metaEl.querySelector('span[style]'); + if (oldPhase) oldPhase.remove(); + + if (s.phase === 'discovering') { + metaEl.insertAdjacentHTML('beforeend', `Discovering ${s.discovery_progress || 0}%`); + } else if (s.phase === 'discovered') { + metaEl.insertAdjacentHTML('beforeend', `Discovered ${s.spotify_matches || 0}/${s.spotify_total || 0}`); + } else if (s.phase === 'syncing' || s.phase === 'sync_complete') { + metaEl.insertAdjacentHTML('beforeend', `${s.phase === 'syncing' ? 'Syncing...' : 'Synced'}`); + } else if (s.phase === 'downloading') { + metaEl.insertAdjacentHTML('beforeend', `Downloading...`); + } else if (s.phase === 'download_complete') { + metaEl.insertAdjacentHTML('beforeend', `Downloaded`); + } + } + } + + // Resume polling if discovery is in progress + if (s.phase === 'discovering' && !activeYouTubePollers[hash]) { + startYouTubeDiscoveryPolling(hash); + } + } + } catch (err) { + console.warn('Failed to hydrate mirrored discovery states:', err); + } +} + +function timeAgo(dateStr) { + if (!dateStr) return ''; + // Handle ISO formats: "Z" suffix, "+00:00" offset, or bare (assume UTC) + let ts = dateStr; + if (!ts.includes('Z') && !ts.includes('+') && !ts.includes('-', 10)) ts += 'Z'; + const diff = Date.now() - new Date(ts).getTime(); + const secs = Math.floor(diff / 1000); + if (secs < 5) return 'just now'; + if (secs < 60) return `${secs}s ago`; + const mins = Math.floor(secs / 60); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + const days = Math.floor(hrs / 24); + if (days < 30) return `${days}d ago`; + return `${Math.floor(days / 30)}mo ago`; +} + +/** + * Open modal showing all tracks in a mirrored playlist. + */ +async function openMirroredPlaylistModal(playlistId) { + showLoadingOverlay('Loading mirrored playlist...'); + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}`); + const data = await res.json(); + if (data.error) throw new Error(data.error); + + hideLoadingOverlay(); + + // Remove any existing modal + const old = document.getElementById('mirrored-track-modal'); + if (old) old.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'mirrored-track-modal'; + overlay.className = 'mirrored-modal-overlay'; + + const tracks = data.tracks || []; + const source = data.source || 'unknown'; + const sourceIcons = { spotify: '🎵', tidal: '🌊', youtube: '▶', beatport: '🎛' }; + const sourceIcon = sourceIcons[source] || '📋'; + + const trackRows = tracks.map(t => { + const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; + return `
+ ${t.position} + ${_esc(t.track_name)} + ${_esc(t.artist_name)} + ${_esc(t.album_name)} + ${dur} +
`; + }).join(''); + + overlay.innerHTML = ` +
+
+
+
${sourceIcon}
+
+

${_esc(data.name)}

+
+ ${_esc(source)} + ${tracks.length} tracks + · + Mirrored ${timeAgo(data.updated_at || data.mirrored_at)} +
+
+
+ × +
+
+
+ #TrackArtistAlbumTime +
+ ${trackRows} +
+ +
+ `; + + overlay.addEventListener('click', e => { if (e.target === overlay) closeMirroredModal(); }); + document.body.appendChild(overlay); + } catch (err) { + hideLoadingOverlay(); + showToast(`Error: ${err.message}`, 'error'); + } +} + +function closeMirroredModal() { + const m = document.getElementById('mirrored-track-modal'); + if (m) m.remove(); +} + +/** + * Delete a mirrored playlist after confirmation. + */ +async function clearMirroredDiscovery(playlistId, name) { + if (!await showConfirmDialog({ title: 'Clear Discovery Data', message: `Clear discovery data for "${name}"? You can re-discover afterwards to get updated cover art.` })) return; + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/clear-discovery`, { method: 'POST' }); + const data = await res.json(); + if (data.success) { + showToast(`Cleared discovery for ${name} (${data.cleared} tracks)`, 'success'); + // Signal cancellation to any running worker, then clear state + const hash = `mirrored_${playlistId}`; + if (youtubePlaylistStates[hash]) { + youtubePlaylistStates[hash].phase = 'cancelled'; + } + delete youtubePlaylistStates[hash]; + const staleModal = document.getElementById(`youtube-discovery-modal-${hash}`); + if (staleModal) staleModal.remove(); + loadMirroredPlaylists(); + } else { + showToast(data.error || 'Failed to clear discovery', 'error'); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +// ==================== Discovery Pool Modal ==================== + +let _discoveryPoolOverlay = null; +let _discoveryPoolData = null; +let _discoveryPoolView = 'categories'; // 'categories' | 'failed' | 'matched' +let _discoveryPoolPlaylistFilter = null; + +async function loadDiscoveryPoolStats() { + try { + const res = await fetch('/api/discovery-pool'); + const data = await res.json(); + const matchedEl = document.getElementById('discovery-pool-matched-count'); + const failedEl = document.getElementById('discovery-pool-failed-count'); + if (matchedEl) matchedEl.textContent = data.stats.matched || 0; + if (failedEl) failedEl.textContent = data.stats.failed || 0; + } catch (e) { } +} + +async function openDiscoveryPoolModal(playlistId = null) { + _discoveryPoolPlaylistFilter = playlistId; + _discoveryPoolView = 'categories'; + + // Fetch pool data + let url = '/api/discovery-pool'; + if (playlistId) url += `?playlist_id=${playlistId}`; + try { + const res = await fetch(url); + _discoveryPoolData = await res.json(); + } catch (err) { + showToast('Failed to load discovery pool', 'error'); + return; + } + + // Remove existing overlay if present + if (_discoveryPoolOverlay) _discoveryPoolOverlay.remove(); + + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.id = 'discovery-pool-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeDiscoveryPoolModal(); }; + + const playlistOptions = (_discoveryPoolData.playlists || []) + .map(p => ``) + .join(''); + + const failedCount = _discoveryPoolData.stats.failed || 0; + const matchedCount = _discoveryPoolData.stats.matched || 0; + + overlay.innerHTML = ` + + `; + + document.body.appendChild(overlay); + overlay.style.display = 'flex'; + _discoveryPoolOverlay = overlay; + + // Build matched mosaic if images available + _buildPoolMatchedMosaic(); +} + +function _buildPoolMatchedMosaic() { + const entries = _discoveryPoolData.matched || []; + const images = []; + for (const e of entries) { + const md = e.matched_data || {}; + if (md.image_url && images.indexOf(md.image_url) === -1) { + images.push(md.image_url); + if (images.length >= 20) break; + } + } + const bgEl = document.getElementById('pool-matched-bg'); + if (!bgEl || images.length < 4) return; // keep fallback gradient + + // Build mosaic rows similar to wishlist + bgEl.innerHTML = ''; + bgEl.className = 'wishlist-mosaic-background'; + const rows = 4; + const imgPerRow = Math.ceil(images.length / rows) * 2; // duplicate for seamless loop + for (let r = 0; r < rows; r++) { + const wrapper = document.createElement('div'); + wrapper.className = 'wishlist-mosaic-row-wrapper'; + const row = document.createElement('div'); + row.className = 'wishlist-mosaic-row' + (r % 2 === 1 ? ' scroll-right' : ''); + row.style.setProperty('--speed', (25 + r * 5) + 's'); + row.style.animationDelay = (r * 0.15) + 's'; + for (let i = 0; i < imgPerRow; i++) { + const img = images[(i + r * 3) % images.length]; + const tile = document.createElement('div'); + tile.className = 'wishlist-mosaic-tile'; + tile.innerHTML = `
`; + row.appendChild(tile); + } + wrapper.appendChild(row); + bgEl.appendChild(wrapper); + } +} + +function closeDiscoveryPoolModal() { + if (_discoveryPoolOverlay) { + _discoveryPoolOverlay.remove(); + _discoveryPoolOverlay = null; + } + _discoveryPoolData = null; + // Refresh dashboard stats + loadDiscoveryPoolStats(); +} + +function showPoolCategories() { + _discoveryPoolView = 'categories'; + const grid = document.getElementById('pool-category-grid'); + const list = document.getElementById('pool-list-view'); + if (grid) grid.style.display = ''; + if (list) list.style.display = 'none'; +} + +function showPoolList(category) { + _discoveryPoolView = category; + const grid = document.getElementById('pool-category-grid'); + const list = document.getElementById('pool-list-view'); + if (grid) grid.style.display = 'none'; + if (list) list.style.display = ''; + + const titleEl = document.getElementById('pool-list-title'); + if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks'; + + // Clear search filter when switching views + const searchEl = document.getElementById('pool-list-search'); + if (searchEl) searchEl.value = ''; + + renderPoolList(); +} + +async function filterDiscoveryPool(playlistId) { + _discoveryPoolPlaylistFilter = playlistId || null; + let url = '/api/discovery-pool'; + if (playlistId) url += `?playlist_id=${playlistId}`; + try { + const res = await fetch(url); + _discoveryPoolData = await res.json(); + // Update header counts + _updatePoolHeaderCounts(); + // Update category card counts + const failedCountEl = document.getElementById('pool-cat-failed-count'); + const matchedCountEl = document.getElementById('pool-cat-matched-count'); + if (failedCountEl) failedCountEl.textContent = _discoveryPoolData.stats.failed || 0; + if (matchedCountEl) matchedCountEl.textContent = _discoveryPoolData.stats.matched || 0; + // If viewing a list, refresh it + if (_discoveryPoolView === 'failed' || _discoveryPoolView === 'matched') { + renderPoolList(); + } + } catch (err) { + showToast('Failed to filter discovery pool', 'error'); + } +} + +function _updatePoolHeaderCounts() { + if (!_discoveryPoolData) return; + const failedCount = _discoveryPoolData.stats.failed || 0; + const matchedCount = _discoveryPoolData.stats.matched || 0; + const matchedEl = document.getElementById('pool-header-matched'); + const failedEl = document.getElementById('pool-header-failed'); + if (matchedEl) matchedEl.textContent = `${matchedCount} Matched`; + if (failedEl) { + failedEl.textContent = `${failedCount} Failed`; + failedEl.classList.toggle('pool-header-failed-highlight', failedCount > 0); + } +} + +function renderPoolList() { + const container = document.getElementById('pool-list-content'); + if (!container || !_discoveryPoolData) return; + + // Client-side search filter + const searchEl = document.getElementById('pool-list-search'); + const query = (searchEl ? searchEl.value : '').toLowerCase().trim(); + + if (_discoveryPoolView === 'failed') { + let tracks = _discoveryPoolData.failed || []; + if (query) { + tracks = tracks.filter(t => + (t.track_name || '').toLowerCase().includes(query) || + (t.artist_name || '').toLowerCase().includes(query) || + (t.playlist_name || '').toLowerCase().includes(query) + ); + } + if (tracks.length === 0) { + container.innerHTML = query + ? '
No failed tracks match your filter.
' + : '
No failed discoveries. All tracks matched successfully.
'; + return; + } + container.innerHTML = tracks.map(t => ` +
+
+
${_esc(t.track_name)}
+
+ ${_esc(t.artist_name)} + ${_esc(t.playlist_name)} +
+
+ +
+ `).join(''); + } else { + let entries = _discoveryPoolData.matched || []; + if (query) { + entries = entries.filter(e => { + const md = e.matched_data || {}; + const matchedName = md.name || ''; + return (e.original_title || '').toLowerCase().includes(query) || + (e.original_artist || '').toLowerCase().includes(query) || + matchedName.toLowerCase().includes(query); + }); + } + if (entries.length === 0) { + container.innerHTML = query + ? '
No matched tracks match your filter.
' + : '
No cached discovery matches yet.
'; + return; + } + container.innerHTML = entries.map(e => { + const md = e.matched_data || {}; + const matchedArtists = (md.artists || []).map(a => typeof a === 'string' ? a : (a.name || '')).join(', '); + const conf = Math.round((e.confidence || 0) * 100); + const confClass = conf >= 80 ? 'high' : (conf >= 70 ? 'mid' : 'low'); + const album = md.album || {}; + const albumImages = (typeof album === 'object' && album.images) ? album.images : []; + const imgUrl = md.image_url || (albumImages.length > 0 ? albumImages[0].url || '' : ''); + return ` +
+ ${imgUrl ? `` : '
'} +
+
${_esc(e.original_title)}
+
+ ${_esc(e.original_artist)} + + ${_esc(md.name || '?')} + ${_esc(e.provider)} +
+
+ ${conf}% + ${e.use_count}× + + +
+ `; + }).join(''); + } +} + +function rematchPoolCacheEntry(cacheId, originalTitle, originalArtist) { + // Open the fix modal in "rematch" mode — saves to cache instead of mirrored tracks + openPoolRematchModal(cacheId, originalTitle, originalArtist); +} + +function openPoolRematchModal(cacheId, trackName, artistName) { + // Reuses the fix modal UI but saves via the rematch endpoint + let fixOverlay = document.getElementById('pool-fix-overlay'); + if (fixOverlay) fixOverlay.remove(); + + fixOverlay = document.createElement('div'); + fixOverlay.className = 'pool-fix-overlay'; + fixOverlay.id = 'pool-fix-overlay'; + fixOverlay.addEventListener('mousedown', (e) => { + if (e.target === fixOverlay) { + e.preventDefault(); + closePoolFixModal(); + } + }); + + fixOverlay.innerHTML = ` +
+
+

Rematch Track

+ +
+
+
+
Current Match
+
+ ${_esc(trackName)} + + ${_esc(artistName)} +
+
+ +
+
+
Searching...
+
+
+
+ +
+ `; + + // Store rematch context + fixOverlay.dataset.mode = 'rematch'; + fixOverlay.dataset.cacheId = cacheId; + fixOverlay.dataset.originalTitle = trackName; + fixOverlay.dataset.originalArtist = artistName; + document.body.appendChild(fixOverlay); + + const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); + const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); + const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; + trackInput.addEventListener('keypress', enterHandler); + artistInput.addEventListener('keypress', enterHandler); + trackInput.focus(); + trackInput.select(); + + setTimeout(() => searchPoolFix(), 500); +} + +async function removePoolCacheEntry(entryId) { + if (!await showConfirmDialog({ title: 'Remove Cache Entry', message: 'Remove this cached match? The track will be re-discovered fresh next time.' })) return; + try { + const res = await fetch(`/api/discovery-pool/cache/${entryId}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + showToast('Cache entry removed', 'success'); + filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); + } else { + showToast(data.error || 'Failed to remove', 'error'); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +// --- Pool Fix Sub-Modal --- + +function openPoolFixModal(trackId, trackName, artistName) { + // Create sub-modal overlay inside the pool modal + let fixOverlay = document.getElementById('pool-fix-overlay'); + if (fixOverlay) fixOverlay.remove(); + + fixOverlay = document.createElement('div'); + fixOverlay.className = 'pool-fix-overlay'; + fixOverlay.id = 'pool-fix-overlay'; + + // Only close on click to the overlay itself — use a dedicated close zone + // to prevent accidental dismissal when clicking near inputs + fixOverlay.addEventListener('mousedown', (e) => { + if (e.target === fixOverlay) { + e.preventDefault(); // Prevent stealing focus from inputs + closePoolFixModal(); + } + }); + + fixOverlay.innerHTML = ` +
+
+

Fix Track Match

+ +
+
+
+
Original Track
+
+ ${_esc(trackName)} + + ${_esc(artistName)} +
+
+ +
+
+
Searching...
+
+
+
+ +
+ `; + + fixOverlay.dataset.trackId = trackId; + document.body.appendChild(fixOverlay); + + // Add enter key support + const trackInput = fixOverlay.querySelector('#pool-fix-track-input'); + const artistInput = fixOverlay.querySelector('#pool-fix-artist-input'); + const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); }; + trackInput.addEventListener('keypress', enterHandler); + artistInput.addEventListener('keypress', enterHandler); + + // Focus the track input + trackInput.focus(); + trackInput.select(); + + // Auto-search after a delay + setTimeout(() => searchPoolFix(), 500); +} + +function closePoolFixModal() { + const fixOverlay = document.getElementById('pool-fix-overlay'); + if (fixOverlay) fixOverlay.remove(); +} + +async function searchPoolFix() { + const trackInput = document.getElementById('pool-fix-track-input'); + const artistInput = document.getElementById('pool-fix-artist-input'); + const resultsContainer = document.getElementById('pool-fix-results'); + if (!trackInput || !resultsContainer) return; + + const trackVal = trackInput.value.trim(); + const artistVal = artistInput.value.trim(); + if (!trackVal && !artistVal) { + resultsContainer.innerHTML = '
Enter a search term
'; + return; + } + + resultsContainer.innerHTML = '
Searching...
'; + + try { + const params = new URLSearchParams(); + if (trackVal) params.set('track', trackVal); + if (artistVal) params.set('artist', artistVal); + params.set('limit', '20'); + const res = await fetch(`/api/spotify/search_tracks?${params.toString()}`); + const data = await res.json(); + const tracks = data.tracks || []; + + if (tracks.length === 0) { + resultsContainer.innerHTML = '
No results found
'; + return; + } + + resultsContainer.innerHTML = tracks.map((track) => { + const artists = (track.artists || []).join(', '); + const duration = track.duration_ms ? formatDuration(track.duration_ms) : ''; + const albumText = track.album ? ` · ${_esc(track.album)}` : ''; + return ` +
+
+
${_esc(track.name || 'Unknown')}
+
${_esc(artists)}${albumText}
+
+ ${duration ? `
${duration}
` : ''} +
+ `; + }).join(''); + } catch (err) { + resultsContainer.innerHTML = `
Search failed: ${_esc(err.message)}
`; + } +} + +async function selectPoolFixTrack(track) { + const fixOverlay = document.getElementById('pool-fix-overlay'); + if (!fixOverlay) return; + + // Confirm selection + const artists = (track.artists || []).join(', '); + if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return; + + const isRematch = fixOverlay.dataset.mode === 'rematch'; + + try { + let res, data; + if (isRematch) { + // Rematch mode: save new match to discovery cache + res = await fetch('/api/discovery-pool/rematch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cache_id: parseInt(fixOverlay.dataset.cacheId), + original_title: fixOverlay.dataset.originalTitle, + original_artist: fixOverlay.dataset.originalArtist, + spotify_track: track, + }), + }); + data = await res.json(); + } else { + // Normal fix mode: save to mirrored track + const trackId = parseInt(fixOverlay.dataset.trackId); + res = await fetch('/api/discovery-pool/fix', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + track_id: trackId, + spotify_track: track, + }), + }); + data = await res.json(); + } + + if (data.success) { + showToast(`Matched: ${track.name}`, 'success'); + closePoolFixModal(); + // Refresh pool data + filterDiscoveryPool(_discoveryPoolPlaylistFilter || ''); + } else { + showToast(data.error || 'Failed to fix track', 'error'); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +async function deleteMirroredPlaylist(playlistId, name) { + if (!await showConfirmDialog({ title: 'Delete Playlist', message: `Delete mirrored playlist "${name}"?`, confirmText: 'Delete', destructive: true })) return; + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + showToast(`Deleted mirror: ${name}`, 'success'); + loadMirroredPlaylists(); + } else { + showToast(data.error || 'Failed to delete', 'error'); + } + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } +} + +/** + * Launch the existing discovery modal for a mirrored playlist by creating + * a temporary entry in youtubePlaylistStates and reusing openYouTubeDiscoveryModal. + */ +async function discoverMirroredPlaylist(playlistId) { + closeMirroredModal(); + const tempHash = `mirrored_${playlistId}`; + + // If state already exists (discovery in progress or completed), just reopen the modal + const existingState = youtubePlaylistStates[tempHash]; + const hasActiveDiscovery = activeYouTubePollers[tempHash] || document.getElementById(`youtube-discovery-modal-${tempHash}`); + if (existingState && (existingState.phase !== 'fresh' || hasActiveDiscovery)) { + openYouTubeDiscoveryModal(tempHash); + // Resume polling if discovery is in progress but poller stopped + if (existingState.phase === 'discovering' && !activeYouTubePollers[tempHash]) { + startYouTubeDiscoveryPolling(tempHash); + } + return; + } + + showLoadingOverlay('Preparing discovery...'); + try { + // Register the mirrored playlist on the backend so the YouTube discovery pipeline can find it + const prepRes = await fetch(`/api/mirrored-playlists/${playlistId}/prepare-discovery`, { method: 'POST' }); + const prepData = await prepRes.json(); + if (prepData.error) throw new Error(prepData.error); + + // Also fetch the full data for the frontend state + const res = await fetch(`/api/mirrored-playlists/${playlistId}`); + const data = await res.json(); + if (data.error) throw new Error(data.error); + hideLoadingOverlay(); + + // Build tracks in the format the discovery modal expects + const tracks = (data.tracks || []).map(t => ({ + id: t.source_track_id || `mirrored_${t.id}`, + name: t.track_name, + artists: [t.artist_name], + album: t.album_name || '', + duration_ms: t.duration_ms || 0 + })); + + // Check if backend returned cached results + if (prepData.from_cache) { + // Fetch the pre-populated status from the backend + const statusRes = await fetch(`/api/youtube/discovery/status/${tempHash}`); + const statusData = await statusRes.json(); + if (statusData.error) throw new Error(statusData.error); + + youtubePlaylistStates[tempHash] = { + playlist: { + name: data.name, + tracks: tracks, + track_count: tracks.length + }, + phase: statusData.phase || 'discovered', + discovery_results: statusData.results || [], + discoveryResults: statusData.results || [], + discovery_progress: statusData.progress || 100, + spotify_matches: statusData.spotify_matches || 0, + spotifyMatches: statusData.spotify_matches || 0, + spotify_total: tracks.length, + status: statusData.status || 'complete', + url: `mirrored://${data.source}/${data.source_playlist_id}`, + sync_playlist_id: null, + converted_spotify_playlist_id: null, + download_process_id: null, + created_at: Date.now() / 1000, + last_accessed: Date.now() / 1000, + discovery_future: null, + sync_progress: {}, + is_mirrored_playlist: true, + mirrored_source: data.source + }; + + const cached = prepData.cached_matches || 0; + const total = prepData.total_tracks || tracks.length; + showToast(`Loaded ${cached}/${total} cached discovery results`, 'success'); + } else { + // No cached data — fresh state + youtubePlaylistStates[tempHash] = { + playlist: { + name: data.name, + tracks: tracks, + track_count: tracks.length + }, + phase: 'fresh', + discovery_results: [], + discovery_progress: 0, + spotify_matches: 0, + spotify_total: tracks.length, + status: 'parsed', + url: `mirrored://${data.source}/${data.source_playlist_id}`, + sync_playlist_id: null, + converted_spotify_playlist_id: null, + download_process_id: null, + created_at: Date.now() / 1000, + last_accessed: Date.now() / 1000, + discovery_future: null, + sync_progress: {}, + is_mirrored_playlist: true, + mirrored_source: data.source + }; + } + + openYouTubeDiscoveryModal(tempHash); + } catch (err) { + hideLoadingOverlay(); + showToast(`Error: ${err.message}`, 'error'); + } +} + +// =============================== +// AUTOMATIONS — Visual Builder +// =============================== + +async function retryFailedMirroredDiscovery(urlHash) { + // Extract playlist ID from url_hash (format: "mirrored_") + const playlistId = urlHash.replace('mirrored_', ''); + try { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/retry-failed-discovery`, { method: 'POST' }); + const data = await res.json(); + if (data.error) { + showToast(`Error: ${data.error}`, 'error'); + return; + } + if (data.retry_count === 0) { + showToast('All tracks already found!', 'success'); + return; + } + + // Update frontend state to discovering + const state = youtubePlaylistStates[urlHash]; + if (state) { + state.phase = 'discovering'; + state.status = 'discovering'; + state.discovery_progress = 0; + } + + // Update modal buttons to show discovering state + updateYouTubeModalButtons(urlHash, 'discovering'); + + // Start polling for progress + startYouTubeDiscoveryPolling(urlHash); + + showToast(`Retrying ${data.retry_count} failed tracks...`, 'info'); + } catch (err) { + showToast(`Error retrying discovery: ${err.message}`, 'error'); + } +} + +let _autoBlocks = null; // cached block definitions from /api/automations/blocks +let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false }; + +let _autoMirroredPlaylists = null; // cached mirrored playlist list +let _autoSpotifyAuthenticated = false; // whether Spotify is authed (for refresh filtering) + +const _autoIcons = { + schedule: '\u23F1\uFE0F', daily_time: '\u{1F570}\uFE0F', weekly_time: '\uD83D\uDCC5', app_started: '\uD83D\uDE80', track_downloaded: '\u2B07\uFE0F', batch_complete: '\u2705', + watchlist_new_release: '\uD83D\uDD14', playlist_synced: '\uD83D\uDD04', + playlist_changed: '\u270F\uFE0F', + process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F', + scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01', + discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D', + notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10', + signal_received: '\u26A1', fire_signal: '\u26A1', run_script: '\uD83D\uDCBB', + // Phase 3 + wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', + database_update_completed: '\uD83D\uDDC4\uFE0F', download_failed: '\u274C', + download_quarantined: '\u26A0\uFE0F', wishlist_item_added: '\u2795', + watchlist_artist_added: '\uD83D\uDC64', watchlist_artist_removed: '\uD83D\uDC64', + import_completed: '\uD83D\uDCE5', mirrored_playlist_created: '\uD83D\uDCC2', + quality_scan_completed: '\uD83D\uDCCA', duplicate_scan_completed: '\uD83D\uDDC2\uFE0F', library_scan_completed: '\uD83D\uDCE1', + start_database_update: '\uD83D\uDDC4\uFE0F', run_duplicate_cleaner: '\uD83D\uDDC2\uFE0F', + clear_quarantine: '\uD83D\uDDD1\uFE0F', cleanup_wishlist: '\uD83E\uDDF9', + update_discovery_pool: '\uD83E\uDDED', start_quality_scan: '\uD83D\uDCCA', + backup_database: '\uD83D\uDCBE', + refresh_beatport_cache: '\uD83C\uDFB5', + clean_search_history: '\uD83D\uDDD1\uFE0F', + clean_completed_downloads: '\u2705', + full_cleanup: '\uD83E\uDDF9', + playlist_pipeline: '\uD83D\uDE80', +}; + +// --- Inspiration Templates --- +// --- Automation Hub Data --- + +// ── Automation Hub: One-Click Pipeline Groups ── +const AUTO_HUB_GROUPS = [ + { + id: 'playlist-pipeline', icon: '🚀', name: 'Playlist Pipeline (All-in-One)', + desc: 'Single automation that runs the full playlist lifecycle: refresh → discover → sync → download missing. No signal wiring needed.', + category: 'Sync', badge: '1 automation', color: '#8b5cf6', + steps: [ + { label: 'Refresh', icon: '🔄', type: 'action' }, + { label: 'Discover', icon: '🔍', type: 'action' }, + { label: 'Sync', icon: '🔗', type: 'action' }, + { label: 'Download', icon: '📥', type: 'action' }, + ], + automations: [ + { name: 'Playlist Pipeline', trigger_type: 'schedule', trigger_config: { interval: 6, unit: 'hours' }, action_type: 'playlist_pipeline', action_config: { all: true }, then_actions: [], group_name: 'Playlist Pipeline' }, + ] + }, + { + id: 'new-music-pipeline', icon: '🚀', name: 'New Music Pipeline', + desc: 'Full hands-free new music workflow. Scans your watchlist for releases, downloads them, cleans up, and notifies you.', + category: 'Discovery', badge: '4 automations', color: '#f97316', + steps: [ + { label: 'Scan Artists', icon: '🔍', type: 'action' }, + { label: 'Download', icon: '📥', type: 'action' }, + { label: 'Cleanup', icon: '🧹', type: 'action' }, + { label: 'Notify', icon: '🔔', type: 'notify' }, + ], + automations: [ + { name: 'New Music — Scan Watchlist', trigger_type: 'schedule', trigger_config: { interval: 12, unit: 'hours' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_scanned' } }], group_name: 'New Music Pipeline' }, + { name: 'New Music — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_downloaded' } }], group_name: 'New Music Pipeline' }, + { name: 'New Music — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_downloaded' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'nm_cleaned' } }], group_name: 'New Music Pipeline' }, + { name: 'New Music — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'nm_cleaned' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'New Music Pipeline', needs_notify: true }, + ] + }, + { + id: 'nightly-ops', icon: '🌙', name: 'Nightly Operations', + desc: 'Staggered overnight maintenance: scan, download, cleanup, and backup while you sleep.', + category: 'Maintenance', badge: '4 automations', color: '#8b5cf6', + steps: [ + { label: '1AM Scan', icon: '🔍', type: 'action' }, + { label: '2AM Download', icon: '📥', type: 'action' }, + { label: '3AM Cleanup', icon: '🧹', type: 'action' }, + { label: '4AM Backup', icon: '💾', type: 'action' }, + ], + automations: [ + { name: 'Nightly — 1AM Scan', trigger_type: 'daily_time', trigger_config: { time: '01:00' }, action_type: 'scan_watchlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, + { name: 'Nightly — 2AM Download', trigger_type: 'daily_time', trigger_config: { time: '02:00' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, + { name: 'Nightly — 3AM Cleanup', trigger_type: 'daily_time', trigger_config: { time: '03:00' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, + { name: 'Nightly — 4AM Backup', trigger_type: 'daily_time', trigger_config: { time: '04:00' }, action_type: 'backup_database', action_config: {}, then_actions: [], group_name: 'Nightly Operations' }, + ] + }, + { + id: 'download-monitor', icon: '📊', name: 'Download Monitor', + desc: 'Stay informed about your downloads. Get notified on failures, quarantined files, and completed batches.', + category: 'Alerts', badge: '3 automations', color: '#ef4444', + steps: [ + { label: 'Failures', icon: '❌', type: 'notify' }, + { label: 'Quarantine', icon: '⚠️', type: 'notify' }, + { label: 'Complete', icon: '✅', type: 'notify' }, + ], + automations: [ + { name: 'Alert — Download Failed', trigger_type: 'download_failed', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, + { name: 'Alert — File Quarantined', trigger_type: 'download_quarantined', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, + { name: 'Alert — Batch Complete', trigger_type: 'batch_complete', trigger_config: {}, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Download Monitor', needs_notify: true }, + ] + }, + { + id: 'library-guardian', icon: '🛡️', name: 'Library Guardian', + desc: 'Protect your library quality. After scans, runs quality checks and notifies you of any issues found.', + category: 'Maintenance', badge: '2 automations', color: '#f59e0b', + steps: [ + { label: 'Quality Scan', icon: '✅', type: 'action' }, + { label: 'Notify', icon: '🔔', type: 'notify' }, + ], + automations: [ + { name: 'Guardian — Quality Check', trigger_type: 'library_scan_completed', trigger_config: {}, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'guardian_quality_done' } }], group_name: 'Library Guardian' }, + { name: 'Guardian — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'guardian_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Library Guardian', needs_notify: true }, + ] + }, + { + id: 'startup-recovery', icon: '⚡', name: 'Startup Recovery', + desc: 'Self-heal after a restart. Scans your library, processes pending wishlist items, and cleans up automatically.', + category: 'Maintenance', badge: '3 automations', color: '#14b8a6', + steps: [ + { label: 'Scan Library', icon: '📚', type: 'action' }, + { label: 'Process Wishlist', icon: '📥', type: 'action' }, + { label: 'Cleanup', icon: '🧹', type: 'action' }, + ], + automations: [ + { name: 'Startup — Scan Library', trigger_type: 'app_started', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_scanned' } }], group_name: 'Startup Recovery' }, + { name: 'Startup — Process Wishlist', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_scanned' }, action_type: 'process_wishlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'startup_processed' } }], group_name: 'Startup Recovery' }, + { name: 'Startup — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'startup_processed' }, action_type: 'full_cleanup', action_config: {}, then_actions: [], group_name: 'Startup Recovery' }, + ] + }, + { + id: 'import-pipeline', icon: '📦', name: 'Import Pipeline', + desc: 'After importing files, automatically scans your library, runs a quality check, and notifies you when complete.', + category: 'Maintenance', badge: '3 automations', color: '#a855f7', + steps: [ + { label: 'Scan Library', icon: '📚', type: 'action' }, + { label: 'Quality Check', icon: '✅', type: 'action' }, + { label: 'Notify', icon: '🔔', type: 'notify' }, + ], + automations: [ + { name: 'Import — Scan Library', trigger_type: 'import_completed', trigger_config: {}, action_type: 'scan_library', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_scanned' } }], group_name: 'Import Pipeline' }, + { name: 'Import — Quality Check', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_scanned' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'import_quality_done' } }], group_name: 'Import Pipeline' }, + { name: 'Import — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'import_quality_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Import Pipeline', needs_notify: true }, + ] + }, + { + id: 'weekly-deep-clean', icon: '✨', name: 'Weekly Deep Clean', + desc: 'Comprehensive weekly sweep: find duplicates, check quality, clean up, back up, and report results.', + category: 'Maintenance', badge: '5 automations', color: '#ec4899', + steps: [ + { label: 'Duplicates', icon: '📋', type: 'action' }, + { label: 'Quality', icon: '✅', type: 'action' }, + { label: 'Cleanup', icon: '🧹', type: 'action' }, + { label: 'Backup', icon: '💾', type: 'action' }, + { label: 'Notify', icon: '🔔', type: 'notify' }, + ], + automations: [ + { name: 'Deep Clean — Duplicates', trigger_type: 'weekly_time', trigger_config: { days: ['sunday'], time: '02:00' }, action_type: 'run_duplicate_cleaner', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_dedup_done' } }], group_name: 'Weekly Deep Clean' }, + { name: 'Deep Clean — Quality', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_dedup_done' }, action_type: 'start_quality_scan', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_quality_done' } }], group_name: 'Weekly Deep Clean' }, + { name: 'Deep Clean — Cleanup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_quality_done' }, action_type: 'full_cleanup', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_cleanup_done' } }], group_name: 'Weekly Deep Clean' }, + { name: 'Deep Clean — Backup', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_cleanup_done' }, action_type: 'backup_database', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dc_backup_done' } }], group_name: 'Weekly Deep Clean' }, + { name: 'Deep Clean — Notify', trigger_type: 'signal_received', trigger_config: { signal_name: 'dc_backup_done' }, action_type: 'notify_only', action_config: {}, then_actions: [], group_name: 'Weekly Deep Clean', needs_notify: true }, + ] + }, + { + id: 'beatport-fresh', icon: '🎧', name: 'Beatport Fresh', + desc: 'Keep your Beatport charts and playlists up to date with a daily cache refresh.', + category: 'Discovery', badge: '1 automation', color: '#84cc16', + steps: [ + { label: 'Refresh Cache', icon: '🔄', type: 'action' }, + ], + automations: [ + { name: 'Beatport — Daily Refresh', trigger_type: 'daily_time', trigger_config: { time: '05:00' }, action_type: 'refresh_beatport_cache', action_config: {}, then_actions: [], group_name: 'Beatport Fresh' }, + ] + }, +]; + +const AUTO_HUB_RECIPES = [ + // Sync & Playlists + { + id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.', + category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] + }, + { + id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.', + category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [], + chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.' + }, + { + id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.', + category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] + }, + { + id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.', + category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] + }, + { + id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.', + category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: [] + }, + // New Music Discovery + { + id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.', + category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }], + chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'], + note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.' + }, + { + id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.', + category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [] + }, + { + id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.', + category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] + }, + { + id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.', + category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: [] + }, + { + id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.', + category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] + }, + // Library Maintenance + { + id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.', + category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: [] + }, + { + id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.', + category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: [] + }, + { + id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.', + category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: [] + }, + { + id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.', + category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }] + }, + { + id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.', + category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: [] + }, + // Notifications & Alerts + { + id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.', + category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] + }, + { + id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.', + category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }] + }, + { + id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.', + category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] + }, + // Power User Chains + { + id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.', + category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }], + chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'], + note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.' + }, + { + id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.', + category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [], + chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'], + note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.' + }, +]; + +const AUTO_HUB_GUIDES = [ + { + id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner', + steps: [ + 'Go to the Playlists page and find a Spotify playlist you want to track.', + 'Click Mirror Playlist to create a local copy.', + 'Go to Automations and click New Automation.', + 'Set WHEN to Schedule \u2192 Every 6 hours.', + 'Set DO to Refresh Mirrored Playlists.', + 'Save and enable \u2014 your playlist will now stay in sync automatically.' + ], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab'] + }, + { + id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner', + steps: [ + 'In Discord, go to your channel\'s settings \u2192 Integrations \u2192 Webhooks.', + 'Create a webhook and copy the URL.', + 'In SoulSync, go to Settings \u2192 Notifications and paste the Discord webhook URL.', + 'Go to Automations \u2192 New Automation.', + 'Set WHEN to Download Failed (or any event), DO to Notify Only, THEN to Discord.' + ], relatedRecipes: ['download-failure-alert', 'batch-complete-notify'] + }, + { + id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate', + steps: [ + 'Create Automation 1: Schedule (12h) \u2192 Scan Watchlist, THEN fire signal scan_done.', + 'Create Automation 2: Signal scan_done \u2192 Process Wishlist, THEN fire signal process_done.', + 'Create Automation 3: Signal process_done \u2192 Full Cleanup.', + 'Enable all three automations.', + 'Test by manually running Automation 1 \u2014 watch the chain execute.', + 'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.', + 'Adjust the schedule interval based on how often you want new music checked.' + ], relatedRecipes: ['complete-new-release', 'full-hands-free'] + }, + { + id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced', + steps: [ + 'Understand the concept: fire_signal is a THEN action that emits a named signal. signal_received is a WHEN trigger that listens for it.', + 'In your first automation, add a THEN action \u2192 Fire Signal and name it (e.g., step1_done).', + 'Create a second automation with WHEN \u2192 Signal Received \u2192 signal name step1_done.', + 'The second automation will fire automatically when the first one completes.', + 'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.', + 'Use descriptive signal names like watchlist_scanned or cleanup_finished.' + ], relatedRecipes: ['quality-assurance', 'complete-new-release'] + }, + { + id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate', + steps: [ + 'Create a Daily Time (04:00) \u2192 Backup Database automation.', + 'Create a Weekly Time (Saturday, 05:00) \u2192 Full Cleanup automation.', + 'Create a Daily Time (02:00) \u2192 Update Discovery Pool automation.', + 'Stagger times by at least 1 hour to avoid resource contention.', + 'Add Discord/Telegram notifications to any you want alerts for.' + ], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly'] + }, +]; + +const AUTO_HUB_TIPS = [ + { icon: '\u26A1', title: 'Signal Chaining 101', body: 'fire_signal (a THEN action) emits a named event. signal_received (a WHEN trigger) listens for it. This lets you chain automations: when one finishes, the next starts automatically.', tag: 'Signals' }, + { icon: '\u23F0', title: 'Stagger Your Schedules', body: 'If you have multiple timed automations, space them at least 1 hour apart. Running scan, process, and cleanup at the same time creates resource contention and can slow everything down.', tag: 'Performance' }, + { icon: '\uD83C\uDFAF', title: 'Use Conditions to Filter', body: 'Add conditions to event triggers to only fire on specific artists, formats, or quality levels. For example, trigger only when a downloaded track\'s artist matches "Radiohead".', tag: 'Filtering' }, + { icon: '\uD83D\uDCC1', title: 'Group Related Automations', body: 'Use the Group dropdown when creating automations to organize them. Groups like "Nightly", "Notifications", or "Pipeline" make it easy to find and manage related automations.', tag: 'Organization' }, + { icon: '\uD83D\uDD04', title: 'Avoid Chain Loops', body: 'SoulSync has built-in cycle detection, but it\'s good practice to design signal names carefully. If A fires signal X and B listens for X and fires Y, make sure nothing fires X again downstream.', tag: 'Safety' }, + { icon: '\uD83D\uDCDA', title: 'Stack THEN Actions', body: 'Each automation supports up to 3 THEN actions. Combine notification channels (Discord + Telegram) with a fire_signal to both notify yourself and trigger the next automation.', tag: 'Power' }, + { icon: '\u2699\uFE0F', title: 'System vs Custom', body: 'System automations handle core tasks like Spotify enrichment and are managed automatically. Create custom automations to extend their behavior \u2014 trigger on their completion events.', tag: 'Basics' }, + { icon: '\uD83E\uDDEA', title: 'Test with Notify Only', body: 'Set DO to Notify Only when testing a new trigger. You\'ll see when it fires without any side effects. Once you\'re confident in the timing, switch to the real action.', tag: 'Testing' }, +]; + +const AUTO_HUB_REFERENCE = { + triggers: [ + { + group: 'Time-Based', items: [ + { type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' }, + { type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' }, + { type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' }, + ] + }, + { + group: 'Download Events', items: [ + { type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' }, + { type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' }, + { type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' }, + { type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' }, + ] + }, + { + group: 'Watchlist & Wishlist', items: [ + { type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' }, + { type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' }, + { type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' }, + { type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' }, + { type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' }, + { type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' }, + ] + }, + { + group: 'Playlists', items: [ + { type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' }, + { type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' }, + { type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' }, + { type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' }, + ] + }, + { + group: 'Library & System', items: [ + { type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' }, + { type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' }, + { type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' }, + { type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' }, + { type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' }, + { type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' }, + ] + }, + { + group: 'Signals', items: [ + { type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' }, + ] + }, + ], + actions: [ + { + group: 'Downloads & Sync', items: [ + { type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' }, + { type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' }, + { type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' }, + { type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' }, + { type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' }, + { type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' }, + { type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' }, + ] + }, + { + group: 'Library Tools', items: [ + { type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' }, + { type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' }, + { type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' }, + { type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' }, + ] + }, + { + group: 'Cleanup', items: [ + { type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' }, + { type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' }, + { type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' }, + { type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' }, + { type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' }, + { type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' }, + ] + }, + { + group: 'Other', items: [ + { type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' }, + ] + }, + ], + thenActions: [ + { + group: 'Notifications', items: [ + { type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' }, + { type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' }, + { type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' }, + ] + }, + { + group: 'Chaining', items: [ + { type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' }, + ] + }, + ], +}; + +// --- Load & Render List --- + +// Drag-and-drop state +let _autoDragState = null; +let _autoDragEnterCount = 0; +let _autoDragExpandTimer = null; + +function _buildAutomationSection(id, label, automations, useGrid, options = {}) { + const groupName = options.groupName || null; + const isProtected = options.isProtected || false; // System, Hub sections + + const section = document.createElement('div'); + section.className = 'automations-section'; + if (isProtected) section.classList.add('section-protected'); + section.id = id; + if (groupName) section.dataset.groupName = groupName; + const collapsed = localStorage.getItem('auto_section_' + id) === '1'; + if (collapsed) section.classList.add('collapsed'); + + const header = document.createElement('div'); + header.className = 'automations-section-header'; + + // Group header actions (rename, bulk toggle, delete) — only for user groups + let actionsHtml = ''; + if (groupName && !isProtected) { + const enabledCount = automations.filter(a => a.enabled).length; + const allEnabled = enabledCount === automations.length; + actionsHtml = ` +
+ + + +
+ `; + } + + header.innerHTML = ` + + + ${automations.length} + ${actionsHtml} + + `; + header.onclick = (e) => { + if (e.target.closest('.section-actions')) return; + section.classList.toggle('collapsed'); + localStorage.setItem('auto_section_' + id, section.classList.contains('collapsed') ? '1' : '0'); + }; + + const body = document.createElement('div'); + body.className = 'automations-section-body'; + + // Drop zone setup (not for protected sections) + if (!isProtected) { + const dropGroupName = groupName; // null for "My Automations" + body.addEventListener('dragover', (e) => { + if (!_autoDragState) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + body.classList.add('drop-target'); + }); + body.addEventListener('dragenter', (e) => { + if (!_autoDragState) return; + _autoDragEnterCount++; + body.classList.add('drop-target'); + // Expand collapsed sections on drag-hover + if (section.classList.contains('collapsed')) { + _autoDragExpandTimer = setTimeout(() => { + section.classList.remove('collapsed'); + }, 500); + } + }); + body.addEventListener('dragleave', (e) => { + if (!_autoDragState) return; + _autoDragEnterCount--; + if (_autoDragEnterCount <= 0) { + _autoDragEnterCount = 0; + body.classList.remove('drop-target'); + if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } + } + }); + body.addEventListener('drop', async (e) => { + e.preventDefault(); + body.classList.remove('drop-target'); + _autoDragEnterCount = 0; + if (!_autoDragState) return; + const draggedId = _autoDragState.id; + const fromGroup = _autoDragState.groupName; + if (fromGroup === dropGroupName) return; // Same group, no-op + try { + const res = await fetch('/api/automations/' + draggedId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_name: dropGroupName }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(dropGroupName ? `Moved to "${dropGroupName}"` : 'Moved to My Automations', 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } + }); + } + + const container = document.createElement('div'); + container.className = useGrid ? 'automations-grid' : 'automations-user-list'; + automations.forEach(a => container.appendChild(renderAutomationCard(a))); + body.appendChild(container); + section.appendChild(header); + section.appendChild(body); + return section; +} + +/** + * Delete a group — ungroups all automations (moves to My Automations). + */ +async function _deleteGroup(groupName) { + // Collect automation IDs in this group + const ids = []; + document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { + if (card.dataset.id) ids.push(parseInt(card.dataset.id)); + }); + + if (ids.length === 0) { await loadAutomations(); return; } + + // Show choice dialog — ungroup or delete all + const choice = await _showDeleteGroupDialog(groupName, ids.length); + if (!choice) return; + + try { + if (choice === 'ungroup') { + const res = await fetch('/api/automations/group', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automation_ids: ids, group_name: null }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(`Dissolved group "${groupName}" — ${data.updated} automations moved to My Automations`, 'success'); + } else if (choice === 'delete_all') { + // Delete each automation + let deleted = 0; + for (const id of ids) { + try { + const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) deleted++; + } catch (e) {} + } + showToast(`Deleted group "${groupName}" and ${deleted} automation${deleted !== 1 ? 's' : ''}`, 'success'); + } + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +function _showDeleteGroupDialog(groupName, count) { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'modal-overlay'; + overlay.style.display = 'flex'; + overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } }; + + overlay.innerHTML = ` +
+
🗑️
+

Delete Group "${groupName}"

+

This group contains ${count} automation${count !== 1 ? 's' : ''}. What would you like to do?

+
+ + + +
+
+ `; + + overlay.querySelector('#dg-ungroup').onclick = () => { overlay.remove(); resolve('ungroup'); }; + overlay.querySelector('#dg-delete').onclick = () => { overlay.remove(); resolve('delete_all'); }; + overlay.querySelector('#dg-cancel').onclick = () => { overlay.remove(); resolve(null); }; + + document.addEventListener('keydown', function esc(e) { + if (e.key === 'Escape') { overlay.remove(); resolve(null); document.removeEventListener('keydown', esc); } + }); + + document.body.appendChild(overlay); + }); +} + +/** + * Rename a group — inline edit on the section header label. + */ +function _startRenameGroup(groupName, btnEl) { + const section = btnEl.closest('.automations-section'); + const labelEl = section?.querySelector('.section-label'); + if (!labelEl) return; + + const input = document.createElement('input'); + input.className = 'section-rename-input'; + input.value = groupName; + input.onclick = (e) => e.stopPropagation(); + + const originalText = labelEl.textContent; + labelEl.textContent = ''; + labelEl.appendChild(input); + input.focus(); + input.select(); + + const finish = async (save) => { + const newName = input.value.trim(); + input.removeEventListener('blur', blurHandler); + if (!save || !newName || newName === groupName) { + labelEl.textContent = originalText; + return; + } + + const ids = []; + section.querySelectorAll('.automation-card').forEach(card => { + if (card.dataset.id) ids.push(parseInt(card.dataset.id)); + }); + + try { + const res = await fetch('/api/automations/group', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automation_ids: ids, group_name: newName }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(`Renamed to "${newName}"`, 'success'); + await loadAutomations(); + } catch (err) { + showToast('Error: ' + err.message, 'error'); + labelEl.textContent = originalText; + } + }; + + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { e.preventDefault(); finish(true); } + if (e.key === 'Escape') { finish(false); } + }); + const blurHandler = () => finish(true); + input.addEventListener('blur', blurHandler); +} + +/** + * Bulk toggle all automations in a group. + */ +async function _bulkToggleGroup(groupName, currentlyAllEnabled) { + const ids = []; + document.querySelectorAll(`.automations-section[data-group-name="${groupName}"] .automation-card`).forEach(card => { + if (card.dataset.id) ids.push(parseInt(card.dataset.id)); + }); + if (ids.length === 0) return; + + const targetEnabled = !currentlyAllEnabled; + try { + const res = await fetch('/api/automations/bulk-toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ automation_ids: ids, enabled: targetEnabled }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(`${targetEnabled ? 'Enabled' : 'Disabled'} ${data.updated} automations`, 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +async function loadAutomations() { + const list = document.getElementById('automations-list'); + const empty = document.getElementById('automations-empty'); + const statsBar = document.getElementById('automations-stats'); + if (!list || !empty) return; + try { + const res = await fetch('/api/automations'); + const automations = await res.json(); + if (automations.error) throw new Error(automations.error); + if (!automations.length) { + list.innerHTML = ''; empty.style.display = ''; + if (statsBar) statsBar.innerHTML = ''; + return; + } + empty.style.display = 'none'; + list.innerHTML = ''; + + const systemAutos = automations.filter(a => a.is_system); + const userAutos = automations.filter(a => !a.is_system); + + if (systemAutos.length) { + list.appendChild(_buildAutomationSection('auto-section-system', 'System', systemAutos, true, { isProtected: true })); + } + + // Automation Hub section + list.appendChild(_buildAutomationHub()); + + // User automations — split by group + const groups = [...new Set(userAutos.filter(a => a.group_name).map(a => a.group_name))].sort(); + const ungrouped = userAutos.filter(a => !a.group_name); + groups.forEach(g => { + const groupAutos = userAutos.filter(a => a.group_name === g); + if (groupAutos.length) { + list.appendChild(_buildAutomationSection('auto-section-group-' + g.replace(/\W+/g, '_'), '\uD83D\uDCC1 ' + g, groupAutos, true, { groupName: g })); + } + }); + if (ungrouped.length) { + list.appendChild(_buildAutomationSection('auto-section-custom', 'My Automations', ungrouped, true)); + } + + // Stats summary bar + if (statsBar) { + const total = automations.length; + const active = automations.filter(a => a.enabled).length; + const sys = systemAutos.length; + const custom = userAutos.length; + statsBar.innerHTML = ` + ${active} Active + ${sys} System + ${custom} Custom + `; + } + + // Filter bar — show when 6+ automations + _initAutoFilterBar(automations); + // Catch up on current automation progress + try { + const progRes = await fetch('/api/automations/progress'); + const progData = await progRes.json(); + if (!progData.error) updateAutomationProgressFromData(progData); + } catch (e) { } + } catch (err) { + list.innerHTML = ''; empty.style.display = ''; + if (statsBar) statsBar.innerHTML = ''; + } +} + +// --- Automation Hub --- + +function _buildAutomationHub() { + const section = document.createElement('div'); + section.className = 'automations-section'; + section.id = 'auto-section-hub'; + const collapsed = localStorage.getItem('auto_section_auto-section-hub') === '1'; + if (collapsed) section.classList.add('collapsed'); + const header = document.createElement('div'); + header.className = 'automations-section-header'; + header.innerHTML = ` + + + ${AUTO_HUB_GROUPS.length} pipelines · ${AUTO_HUB_RECIPES.length} recipes + + `; + header.onclick = () => { + section.classList.toggle('collapsed'); + localStorage.setItem('auto_section_auto-section-hub', section.classList.contains('collapsed') ? '1' : '0'); + }; + const body = document.createElement('div'); + body.className = 'automations-section-body'; + + const activeTab = localStorage.getItem('auto_hub_tab') || 'pipelines'; + const tabs = [ + { id: 'pipelines', label: 'Pipelines' }, + { id: 'recipes', label: 'Singles' }, + { id: 'guides', label: 'Quick Start' }, + { id: 'tips', label: 'Tips' }, + { id: 'reference', label: 'Reference' }, + ]; + + const tabBar = document.createElement('div'); + tabBar.className = 'auto-hub-tabs'; + tabs.forEach(t => { + const btn = document.createElement('button'); + btn.className = 'auto-hub-tab' + (t.id === activeTab ? ' active' : ''); + btn.textContent = t.label; + btn.dataset.tab = t.id; + btn.onclick = (e) => { e.stopPropagation(); _switchHubTab(t.id, body); }; + tabBar.appendChild(btn); + }); + body.appendChild(tabBar); + + // Build all tab contents + const pipelinesPane = _buildHubPipelines(); + pipelinesPane.id = 'auto-hub-pane-pipelines'; + pipelinesPane.className = 'auto-hub-tab-content' + (activeTab === 'pipelines' ? ' active' : ''); + body.appendChild(pipelinesPane); + + const recipesPane = _buildHubRecipes(); + recipesPane.id = 'auto-hub-pane-recipes'; + recipesPane.className = 'auto-hub-tab-content' + (activeTab === 'recipes' ? ' active' : ''); + body.appendChild(recipesPane); + + const guidesPane = _buildHubGuides(); + guidesPane.id = 'auto-hub-pane-guides'; + guidesPane.className = 'auto-hub-tab-content' + (activeTab === 'guides' ? ' active' : ''); + body.appendChild(guidesPane); + + const tipsPane = _buildHubTips(); + tipsPane.id = 'auto-hub-pane-tips'; + tipsPane.className = 'auto-hub-tab-content' + (activeTab === 'tips' ? ' active' : ''); + body.appendChild(tipsPane); + + const refPane = _buildHubReference(); + refPane.id = 'auto-hub-pane-reference'; + refPane.className = 'auto-hub-tab-content' + (activeTab === 'reference' ? ' active' : ''); + body.appendChild(refPane); + + section.appendChild(header); + section.appendChild(body); + return section; +} + +function _switchHubTab(tabId, bodyEl) { + const container = bodyEl || document.querySelector('#auto-section-hub .automations-section-body'); + if (!container) return; + container.querySelectorAll('.auto-hub-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tabId)); + container.querySelectorAll('.auto-hub-tab-content').forEach(p => p.classList.toggle('active', p.id === 'auto-hub-pane-' + tabId)); + localStorage.setItem('auto_hub_tab', tabId); +} + +function _buildHubPipelines() { + const pane = document.createElement('div'); + + const intro = document.createElement('div'); + intro.className = 'auto-hub-pipeline-intro'; + intro.innerHTML = 'One-click deployment — each pipeline creates multiple linked automations that work together.'; + pane.appendChild(intro); + + const grid = document.createElement('div'); + grid.className = 'auto-hub-pipeline-grid'; + + AUTO_HUB_GROUPS.forEach(group => { + const card = document.createElement('div'); + card.className = 'auto-hub-pipeline-card'; + card.style.setProperty('--pipeline-color', group.color); + + // Pipeline flow visualization + const stepsHtml = group.steps.map((step, i) => { + const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action'; + return (i > 0 ? '' : '') + + `
+ ${step.icon} + ${step.label} +
`; + }).join(''); + + card.innerHTML = ` +
+ ${group.icon} +
+
${group.name}
+ ${group.badge} +
+
+
${group.desc}
+
${stepsHtml}
+ + `; + + card.addEventListener('click', (e) => { + if (e.target.closest('.pipeline-deploy-btn')) return; + showPipelineDetail(group.id); + }); + + grid.appendChild(card); + }); + + pane.appendChild(grid); + return pane; +} + +function showPipelineDetail(groupId) { + const group = AUTO_HUB_GROUPS.find(g => g.id === groupId); + if (!group) return; + + // Build automation detail list + const autoDetails = group.automations.map((auto, i) => { + const triggerLabel = _autoFormatTrigger(auto.trigger_type, auto.trigger_config); + const actionLabel = _autoFormatAction(auto.action_type); + const thenLabels = auto.then_actions.map(t => { + if (t.type === 'fire_signal') return `⚡ Signal: ${t.config.signal_name}`; + return _autoFormatNotify(t.type); + }); + if (auto.needs_notify) thenLabels.push('🔔 Your notification'); + + return ` +
+
${i + 1}
+
+
${auto.name}
+
+ WHEN + ${_esc(triggerLabel)} + DO + ${_esc(actionLabel)} + ${thenLabels.length ? `THEN${thenLabels.map(t => _esc(t)).join(', ')}` : ''} +
+
+
`; + }).join(''); + + // Build flow diagram + const flowHtml = group.steps.map((step, i) => { + const nodeClass = step.type === 'notify' ? 'pipeline-node-notify' : 'pipeline-node-action'; + return (i > 0 ? '' : '') + + `
+ ${step.icon} + ${step.label} +
`; + }).join(''); + + const overlay = document.createElement('div'); + overlay.className = 'pipeline-detail-overlay'; + overlay.innerHTML = ` +
+ +
+ ${group.icon} +
+
${group.name}
+
${group.desc}
+
+
+
${flowHtml}
+
How It Works
+
This pipeline deploys ${group.automations.length} automations${group.automations.some(a => a.then_actions.some(t => t.type === 'fire_signal')) ? ' linked by signals — each step triggers the next automatically' : ' running on independent schedules'}.
+
${autoDetails}
+ +
+ `; + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) overlay.remove(); + }); + + document.body.appendChild(overlay); +} + +function _buildHubRecipes() { + const pane = document.createElement('div'); + const categories = ['All', 'Sync', 'Discovery', 'Maintenance', 'Alerts', 'Chains']; + const difficulties = ['All', 'Beginner', 'Intermediate', 'Advanced']; + + let activeCat = 'All', activeDiff = 'All'; + + // Category filters + const catFilters = document.createElement('div'); + catFilters.className = 'auto-hub-filters'; + categories.forEach(c => { + const pill = document.createElement('button'); + pill.className = 'auto-hub-filter-pill' + (c === 'All' ? ' active' : ''); + pill.textContent = c; + pill.dataset.filter = c; + pill.dataset.filterType = 'category'; + pill.onclick = () => { + activeCat = c; + catFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === c)); + filterRecipes(); + }; + catFilters.appendChild(pill); + }); + pane.appendChild(catFilters); + + // Difficulty filters + const diffFilters = document.createElement('div'); + diffFilters.className = 'auto-hub-filters'; + difficulties.forEach(d => { + const pill = document.createElement('button'); + pill.className = 'auto-hub-filter-pill' + (d === 'All' ? ' active' : ''); + pill.textContent = d; + pill.dataset.filter = d; + pill.dataset.filterType = 'difficulty'; + pill.onclick = () => { + activeDiff = d; + diffFilters.querySelectorAll('.auto-hub-filter-pill').forEach(p => p.classList.toggle('active', p.dataset.filter === d)); + filterRecipes(); + }; + diffFilters.appendChild(pill); + }); + pane.appendChild(diffFilters); + + const grid = document.createElement('div'); + grid.className = 'auto-hub-recipes-grid'; + + AUTO_HUB_RECIPES.forEach(r => { + const card = document.createElement('div'); + card.className = 'auto-hub-recipe-card'; + card.dataset.category = r.category; + card.dataset.difficulty = r.difficulty; + + const trigLabel = _autoFormatTrigger(r.when.type, r.when.config); + const actLabel = _autoFormatAction(r.do.type); + + let chainHTML = ''; + if (r.chain) { + chainHTML = '
' + r.chain.map((step, i) => { + let cls = 'flow-action'; + if (i === 0) cls = 'flow-trigger'; + else if (step.startsWith('\u26A1')) cls = 'flow-notify'; + return (i > 0 ? '' : '') + + `${_esc(step)}`; + }).join('') + '
'; + } else { + chainHTML = `
+ ${_esc(trigLabel)} + + ${_esc(actLabel)} + ${r.then.length ? r.then.map(th => `${_esc(_autoFormatNotify(th.type))}`).join('') : ''} +
`; + } + + card.innerHTML = ` +
+
${r.icon}
+
${_esc(r.name)}
+ ${_esc(r.difficulty)} +
+
${_esc(r.desc)}
+ ${chainHTML} + ${r.note ? `
${_esc(r.note)}
` : ''} + + `; + card.onclick = () => useHubRecipe(r.id); + grid.appendChild(card); + }); + pane.appendChild(grid); + + function filterRecipes() { + grid.querySelectorAll('.auto-hub-recipe-card').forEach(card => { + const catMatch = activeCat === 'All' || card.dataset.category === activeCat; + const diffMatch = activeDiff === 'All' || card.dataset.difficulty === activeDiff.toLowerCase(); + card.style.display = (catMatch && diffMatch) ? '' : 'none'; + }); + } + + return pane; +} + +function _buildHubGuides() { + const pane = document.createElement('div'); + + const callout = document.createElement('div'); + callout.className = 'auto-hub-callout'; + callout.innerHTML = '\uD83D\uDCA1Click any guide to expand step-by-step instructions. Related recipes let you jump straight to a pre-filled template.'; + pane.appendChild(callout); + + AUTO_HUB_GUIDES.forEach(g => { + const card = document.createElement('div'); + card.className = 'auto-hub-guide-card'; + + const headerEl = document.createElement('div'); + headerEl.className = 'auto-hub-guide-header'; + headerEl.innerHTML = ` + ${g.icon} + ${_esc(g.title)} + ${_esc(g.difficulty)} + + `; + headerEl.onclick = () => card.classList.toggle('expanded'); + card.appendChild(headerEl); + + const bodyEl = document.createElement('div'); + bodyEl.className = 'auto-hub-guide-body'; + bodyEl.innerHTML = ` +
${_esc(g.subtitle)}
+
    ${g.steps.map(s => `
  1. ${s}
  2. `).join('')}
+ ${g.relatedRecipes.length ? ` + + ` : ''} + `; + card.appendChild(bodyEl); + pane.appendChild(card); + }); + + return pane; +} + +function _buildHubTips() { + const pane = document.createElement('div'); + + const callout = document.createElement('div'); + callout.className = 'auto-hub-callout'; + callout.innerHTML = '\u2728Power-user tips to get the most out of your automations.'; + pane.appendChild(callout); + + const grid = document.createElement('div'); + grid.className = 'auto-hub-tips-grid'; + AUTO_HUB_TIPS.forEach(t => { + const card = document.createElement('div'); + card.className = 'auto-hub-tip-card'; + card.innerHTML = ` +
+ ${t.icon} + ${_esc(t.title)} + ${_esc(t.tag)} +
+
${t.body}
+ `; + grid.appendChild(card); + }); + pane.appendChild(grid); + return pane; +} + +function _buildHubReference() { + const pane = document.createElement('div'); + const sections = [ + { label: 'Triggers (WHEN)', data: AUTO_HUB_REFERENCE.triggers }, + { label: 'Actions (DO)', data: AUTO_HUB_REFERENCE.actions }, + { label: 'Then Actions (THEN)', data: AUTO_HUB_REFERENCE.thenActions }, + ]; + + sections.forEach(sec => { + const totalItems = sec.data.reduce((n, g) => n + g.items.length, 0); + const group = document.createElement('div'); + group.className = 'auto-hub-ref-group'; + + const header = document.createElement('div'); + header.className = 'auto-hub-ref-group-header'; + header.innerHTML = ` + ${_esc(sec.label)} + ${totalItems} + + `; + header.onclick = () => group.classList.toggle('expanded'); + group.appendChild(header); + + const body = document.createElement('div'); + body.className = 'auto-hub-ref-body'; + sec.data.forEach(sub => { + body.innerHTML += `
${_esc(sub.group)}
`; + let tableHTML = ''; + sub.items.forEach(item => { + tableHTML += ``; + }); + tableHTML += '
TypeDescription
${_esc(item.label)}${_esc(item.desc)}
'; + body.innerHTML += tableHTML; + }); + group.appendChild(body); + pane.appendChild(group); + }); + return pane; +} + +async function useHubRecipe(recipeId) { + const t = AUTO_HUB_RECIPES.find(r => r.id === recipeId); + if (!t) return; + await showAutomationBuilder(); + document.getElementById('builder-name').value = t.name; + _autoBuilder.when = { type: t.when.type, config: JSON.parse(JSON.stringify(t.when.config)) }; + _autoBuilder.do = { type: t.do.type, config: JSON.parse(JSON.stringify(t.do.config)) }; + _autoBuilder.then = t.then.map(th => ({ type: th.type, config: JSON.parse(JSON.stringify(th.config)) })); + _renderBuilderSidebar(); + _renderBuilderCanvas(); + if (t.note) { + showToast(t.note, 'info'); + } +} + +async function deployHubGroup(groupId) { + const group = AUTO_HUB_GROUPS.find(g => g.id === groupId); + if (!group) return; + + // Check if any automations need notifications — prompt for config + const needsNotify = group.automations.some(a => a.needs_notify); + let notifyConfig = null; + + if (needsNotify) { + notifyConfig = await _promptNotifyConfig(group.name); + if (notifyConfig === null) return; // User cancelled + if (notifyConfig === false) notifyConfig = null; // Skip notifications, still deploy + } + + // Deploy all automations in the group + let created = 0, failed = 0; + for (const auto of group.automations) { + try { + const payload = { + name: auto.name, + trigger_type: auto.trigger_type, + trigger_config: auto.trigger_config, + action_type: auto.action_type, + action_config: auto.action_config, + then_actions: [...auto.then_actions], + group_name: auto.group_name, + enabled: true, + }; + + // Inject notification config for automations that need it + if (auto.needs_notify && notifyConfig) { + payload.then_actions.push(notifyConfig); + } + + const response = await fetch('/api/automations', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + + if (response.ok) { + created++; + } else { + const err = await response.json(); + console.error(`Failed to create "${auto.name}":`, err); + failed++; + } + } catch (e) { + console.error(`Error creating "${auto.name}":`, e); + failed++; + } + } + + if (created > 0) { + showToast(`Deployed "${group.name}" — ${created} automation${created > 1 ? 's' : ''} created${failed ? `, ${failed} failed` : ''}`, 'success'); + loadAutomations(); + } else { + showToast(`Failed to deploy "${group.name}"`, 'error'); + } +} + +function _promptNotifyConfig(groupName) { + return new Promise(resolve => { + const overlay = document.createElement('div'); + 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 = ` +
+

Configure Notifications

+

${groupName} includes notification steps. Choose how to get notified.

+
+ + +
+
+
+ + +
+
+ `; + + document.body.appendChild(overlay); + + const typeSelect = overlay.querySelector('#deploy-notify-type'); + const fieldsDiv = overlay.querySelector('#deploy-notify-fields'); + + function updateFields() { + const type = typeSelect.value; + if (type === 'discord_webhook') { + fieldsDiv.innerHTML = ''; + } else if (type === 'telegram') { + fieldsDiv.innerHTML = ''; + } else if (type === 'pushbullet') { + fieldsDiv.innerHTML = ''; + } else { + fieldsDiv.innerHTML = ''; + } + } + typeSelect.addEventListener('change', updateFields); + updateFields(); + + overlay.querySelector('#deploy-notify-cancel').addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(null); + }); + + overlay.querySelector('#deploy-notify-confirm').addEventListener('click', () => { + const type = typeSelect.value; + let config = {}; + if (type === 'discord_webhook') { + config = { webhook_url: (overlay.querySelector('#deploy-notify-url')?.value || '').trim() }; + } else if (type === 'telegram') { + config = { bot_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim(), chat_id: (overlay.querySelector('#deploy-notify-chat')?.value || '').trim() }; + } else if (type === 'pushbullet') { + config = { access_token: (overlay.querySelector('#deploy-notify-token')?.value || '').trim() }; + } else { + document.body.removeChild(overlay); + resolve(false); // Skip notifications but still deploy + return; + } + document.body.removeChild(overlay); + resolve({ type, config }); + }); + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) { document.body.removeChild(overlay); resolve(null); } + }); + }); +} + +// --- Filter Bar --- +function _initAutoFilterBar(automations) { + const bar = document.getElementById('auto-filter-bar'); + if (!bar) return; + if (automations.length < 7) { bar.style.display = 'none'; return; } + bar.style.display = ''; + + // Populate trigger dropdown + const trigSel = document.getElementById('auto-filter-trigger'); + const actSel = document.getElementById('auto-filter-action'); + const trigTypes = [...new Set(automations.map(a => a.trigger_type))].sort(); + const actTypes = [...new Set(automations.map(a => a.action_type))].sort(); + const prevTrig = trigSel.value; + const prevAct = actSel.value; + trigSel.innerHTML = '' + trigTypes.map(t => + ``).join(''); + actSel.innerHTML = '' + actTypes.map(t => + ``).join(''); + trigSel.value = prevTrig; + actSel.value = prevAct; + + // Bind events (use a flag to avoid double-binding) + if (!bar.dataset.bound) { + bar.dataset.bound = '1'; + document.getElementById('auto-filter-search').addEventListener('input', _filterAutomations); + trigSel.addEventListener('change', _filterAutomations); + actSel.addEventListener('change', _filterAutomations); + } + _filterAutomations(); +} + +function _filterAutomations() { + const q = (document.getElementById('auto-filter-search').value || '').toLowerCase().trim(); + const trigFilter = document.getElementById('auto-filter-trigger').value; + const actFilter = document.getElementById('auto-filter-action').value; + const cards = document.querySelectorAll('#automations-list .automation-card'); + let visible = 0; + cards.forEach(card => { + const name = (card.querySelector('.automation-name')?.textContent || '').toLowerCase(); + const trig = card.querySelector('.flow-trigger')?.textContent || ''; + const act = card.querySelector('.flow-action')?.textContent || ''; + // Match search text against name, trigger label, action label + const matchQ = !q || name.includes(q) || trig.toLowerCase().includes(q) || act.toLowerCase().includes(q); + // Match trigger/action type filters using data attributes + const matchTrig = !trigFilter || card.dataset.triggerType === trigFilter; + const matchAct = !actFilter || card.dataset.actionType === actFilter; + const show = matchQ && matchTrig && matchAct; + card.style.display = show ? '' : 'none'; + if (show) visible++; + }); + const countEl = document.getElementById('auto-filter-count'); + if (countEl) { + countEl.textContent = (q || trigFilter || actFilter) ? `${visible} of ${cards.length}` : ''; + } +} + +// --- Group Dropdown --- +let _activeGroupDropdown = null; + +function _showGroupDropdown(event, autoId, currentGroup) { + // Close any existing dropdown + _closeGroupDropdown(); + + const btn = event.currentTarget; + const card = btn.closest('.automation-card'); + if (!card) return; + + // Collect all existing group names from visible cards + const allGroups = new Set(); + document.querySelectorAll('#automations-list .automation-card .automation-group-btn[data-group]').forEach(b => { + const g = b.dataset.group; + if (g) allGroups.add(g); + }); + + const dropdown = document.createElement('div'); + dropdown.className = 'auto-group-dropdown'; + + let html = ''; + if (currentGroup) { + html += `
Remove from group
`; + html += '
'; + } + allGroups.forEach(g => { + const isActive = g === currentGroup; + html += `
${_esc(g)}
`; + }); + if (allGroups.size) html += '
'; + html += ``; + + dropdown.innerHTML = html; + + // Position dropdown on document.body to avoid overflow:hidden clipping + const rect = btn.getBoundingClientRect(); + dropdown.style.position = 'fixed'; + dropdown.style.right = (window.innerWidth - rect.right) + 'px'; + dropdown.style.left = 'auto'; + document.body.appendChild(dropdown); + _activeGroupDropdown = dropdown; + + // Open upward if not enough room below + const dropdownHeight = dropdown.offsetHeight; + if (rect.bottom + 4 + dropdownHeight > window.innerHeight && rect.top - 4 - dropdownHeight > 0) { + dropdown.style.top = (rect.top - 4 - dropdownHeight) + 'px'; + } else { + dropdown.style.top = (rect.bottom + 4) + 'px'; + } + + // Focus the input + setTimeout(() => dropdown.querySelector('.auto-group-input')?.focus(), 50); + + // Close on outside click + const handler = (e) => { + if (!dropdown.contains(e.target) && e.target !== btn) { + _closeGroupDropdown(); + document.removeEventListener('click', handler, true); + } + }; + setTimeout(() => document.addEventListener('click', handler, true), 10); +} + +function _closeGroupDropdown() { + if (_activeGroupDropdown) { + _activeGroupDropdown.remove(); + _activeGroupDropdown = null; + } +} + +async function _assignGroup(autoId, groupName) { + _closeGroupDropdown(); + try { + const res = await fetch('/api/automations/' + autoId, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_name: groupName || null }) + }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(groupName ? `Moved to "${groupName}"` : 'Removed from group', 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +function renderAutomationCard(a) { + const card = document.createElement('div'); + card.className = 'automation-card' + (a.enabled ? '' : ' disabled') + (a.is_system ? ' system' : ''); + card.dataset.id = a.id; + card.dataset.triggerType = a.trigger_type || ''; + card.dataset.actionType = a.action_type || ''; + + // Drag-and-drop (non-system only) + if (!a.is_system) { + card.draggable = true; + card.addEventListener('dragstart', (e) => { + _autoDragState = { id: a.id, groupName: a.group_name || null }; + e.dataTransfer.setData('text/plain', String(a.id)); + e.dataTransfer.effectAllowed = 'move'; + card.classList.add('dragging'); + // Dim protected sections during drag + document.querySelectorAll('.section-protected').forEach(s => s.classList.add('no-drop')); + }); + card.addEventListener('dragend', () => { + card.classList.remove('dragging'); + _autoDragState = null; + _autoDragEnterCount = 0; + document.querySelectorAll('.drop-target').forEach(el => el.classList.remove('drop-target')); + document.querySelectorAll('.no-drop').forEach(el => el.classList.remove('no-drop')); + if (_autoDragExpandTimer) { clearTimeout(_autoDragExpandTimer); _autoDragExpandTimer = null; } + }); + } + const tIcon = _autoIcons[a.trigger_type] || '\u2699\uFE0F'; + const aIcon = _autoIcons[a.action_type] || '\u2699\uFE0F'; + const tl = tIcon + ' ' + _autoFormatTrigger(a.trigger_type, a.trigger_config); + const al = aIcon + ' ' + _autoFormatAction(a.action_type); + const thenItems = a.then_actions || []; + const actionDelay = a.action_config && a.action_config.delay ? a.action_config.delay : 0; + const metaParts = []; + if (a.last_run) metaParts.push('Last: ' + _autoTimeAgo(a.last_run)); + const _timerTriggers = ['schedule', 'daily_time', 'weekly_time']; + if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run) + ''); + if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening'); + if (a.run_count) metaParts.push('Runs: ' + a.run_count + ''); + if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error)); + + const dupeBtn = a.is_system ? '' : + ``; + const groupBtn = a.is_system ? '' : + ``; + const deleteBtn = a.is_system ? '' : + ``; + + card.innerHTML = ` +
+
+
${_esc(a.name)}
+
+ ${_esc(tl)} + + ${actionDelay ? `\u23F3 ${actionDelay}m` : ''} + ${_esc(al)} + ${thenItems.length ? thenItems.map(t => `${_esc(_autoFormatNotify(t.type))}`).join('') : ''} +
+
${metaParts.join(' · ')}
+
+
+ + + + ${dupeBtn} + ${groupBtn} + ${deleteBtn} +
+ `; + return card; +} + +function _autoFormatTrigger(type, config) { + if (type === 'schedule' && config) return 'Every ' + (config.interval || 1) + ' ' + (config.unit || 'hours'); + if (type === 'daily_time' && config) return 'Daily at ' + (config.time || '00:00'); + if (type === 'weekly_time' && config) { + const days = (config.days || []).map(d => d.charAt(0).toUpperCase() + d.slice(1)).join(', '); + return (days || 'Every day') + ' at ' + (config.time || '00:00'); + } + if (type === 'signal_received' && config) { + const sig = config.signal_name || 'unknown'; + return 'Signal: ' + sig; + } + const labels = { + app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete', + watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced', + playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete', + wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done', + database_update_completed: 'Database Updated', download_failed: 'Download Failed', + download_quarantined: 'File Quarantined', wishlist_item_added: 'Wishlist Item Added', + watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched', + import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored', + quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done', + library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' + }; + let label = labels[type] || type || 'Unknown'; + if (config && config.conditions && config.conditions.length) { + const first = config.conditions[0]; + label += ' (' + first.field + ' ' + first.operator + ' "' + first.value + '"' + + (config.conditions.length > 1 ? ' +' + (config.conditions.length - 1) + ' more' : '') + ')'; + } + return label; +} +function _autoFormatAction(type) { + const labels = { + process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist', + scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored', + sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist', + notify_only: 'Notify Only', + start_database_update: 'Update Database', run_duplicate_cleaner: 'Run Duplicate Cleaner', + clear_quarantine: 'Clear Quarantine', cleanup_wishlist: 'Clean Up Wishlist', + update_discovery_pool: 'Update Discovery', start_quality_scan: 'Run Quality Scan', + backup_database: 'Backup Database', + refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History', + clean_completed_downloads: 'Clean Completed Downloads', + full_cleanup: 'Full Cleanup', + playlist_pipeline: 'Playlist Pipeline' + }; + return labels[type] || type || 'Unknown'; +} +function _autoFormatNotify(type) { + if (type === 'discord_webhook') return 'Discord'; + if (type === 'pushbullet') return 'Pushbullet'; + if (type === 'telegram') return 'Telegram'; + if (type === 'fire_signal') return '\u26A1 Signal'; + if (type === 'run_script') return '\uD83D\uDCBB Script'; + return type || ''; +} +function _autoParseUTC(ts) { + // If timestamp already has timezone info (+00:00 or Z), parse as-is; otherwise append Z to treat as UTC + if (/[Zz]$/.test(ts) || /[+-]\d{2}:\d{2}$/.test(ts)) return new Date(ts).getTime(); + return new Date(ts + 'Z').getTime(); +} +function _autoTimeAgo(ts) { + if (!ts) return 'Never'; + const d = (Date.now() - _autoParseUTC(ts)) / 1000; + if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d / 60) + 'm ago'; + if (d < 86400) return Math.floor(d / 3600) + 'h ago'; return Math.floor(d / 86400) + 'd ago'; +} +function _autoTimeUntil(ts) { + if (!ts) return ''; + const d = (_autoParseUTC(ts) - Date.now()) / 1000; + if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's'; + if (d < 3600) return 'in ' + Math.ceil(d / 60) + 'm'; if (d < 86400) return 'in ' + Math.round(d / 3600) + 'h'; + return 'in ' + Math.round(d / 86400) + 'd'; +} + +// --- Live countdown for "Next: in Xs" --- +setInterval(() => { + document.querySelectorAll('.auto-next-run[data-next]').forEach(el => { + el.textContent = 'Next: ' + _autoTimeUntil(el.dataset.next); + }); +}, 1000); + +// --- CRUD --- + +async function deleteAutomation(id, name) { + if (!await showConfirmDialog({ title: 'Delete Automation', message: `Delete automation "${name}"?`, confirmText: 'Delete', destructive: true })) return; + try { + const res = await fetch('/api/automations/' + id, { method: 'DELETE' }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast('Automation deleted', 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +async function duplicateAutomation(id) { + try { + const res = await fetch('/api/automations/' + id + '/duplicate', { method: 'POST' }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast('Automation duplicated', 'success'); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +async function toggleAutomation(id) { + try { + const res = await fetch('/api/automations/' + id + '/toggle', { method: 'POST' }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +// --- Automation Progress Tracking --- +const _autoProgressLogCounts = {}; +const _autoProgressHideTimers = {}; + +function updateAutomationProgressFromData(data) { + for (const [aidStr, state] of Object.entries(data)) { + const aid = parseInt(aidStr); + const card = document.querySelector(`.automation-card[data-id="${aid}"]`); + if (!card) continue; + + let panel = card.querySelector('.automation-output'); + if (!panel) { + panel = document.createElement('div'); + panel.className = 'automation-output'; + panel.innerHTML = ` +
+
+
+ `; + card.appendChild(panel); + _autoProgressLogCounts[aid] = 0; + } + + // Update progress bar + const bar = panel.querySelector('.auto-progress-bar'); + bar.style.width = (state.progress || 0) + '%'; + + // Update phase text + const phaseEl = panel.querySelector('.auto-progress-phase'); + phaseEl.textContent = state.phase || ''; + + // Status indicator on card + const statusDot = card.querySelector('.automation-status'); + + if (state.status === 'running') { + if (statusDot) statusDot.className = 'automation-status running'; + card.classList.add('running'); + panel.classList.add('visible'); + panel.classList.remove('finished', 'error'); + if (_autoProgressHideTimers[aid]) { + clearTimeout(_autoProgressHideTimers[aid]); + delete _autoProgressHideTimers[aid]; + } + // Reset log for new run (handles re-run within hide window) + if (_autoProgressLogCounts[aid] > 0 && state.log && state.log.length < _autoProgressLogCounts[aid]) { + const existingLog = panel.querySelector('.auto-progress-log'); + if (existingLog) existingLog.innerHTML = ''; + _autoProgressLogCounts[aid] = 0; + } + } else if (state.status === 'finished' || state.status === 'error') { + if (statusDot) statusDot.className = 'automation-status ' + (card.querySelector('input[type=checkbox]')?.checked ? 'enabled' : 'disabled'); + card.classList.remove('running'); + bar.style.width = '100%'; + panel.classList.add('finished'); + if (state.status === 'error') panel.classList.add('error'); + if (!_autoProgressHideTimers[aid]) { + _autoProgressHideTimers[aid] = setTimeout(() => { + panel.classList.remove('visible'); + delete _autoProgressHideTimers[aid]; + _autoProgressLogCounts[aid] = 0; + }, 30000); + } + } + + // Update log lines + const logEl = panel.querySelector('.auto-progress-log'); + const rendered = _autoProgressLogCounts[aid] || 0; + const logLines = state.log || []; + if (logLines.length > rendered) { + // Normal append — log is still growing + for (let i = rendered; i < logLines.length; i++) { + const line = logLines[i]; + const div = document.createElement('div'); + div.className = 'auto-log-line ' + (line.type || 'info'); + div.textContent = line.text; + logEl.appendChild(div); + } + _autoProgressLogCounts[aid] = logLines.length; + logEl.scrollTop = logEl.scrollHeight; + } else if (logLines.length === rendered && logLines.length >= 50) { + // Log buffer is full and rotating — replace last few lines + const children = logEl.children; + if (children.length > 0) { + const lastServerLine = logLines[logLines.length - 1]; + const lastDomLine = children[children.length - 1]; + if (lastServerLine && lastDomLine.textContent !== lastServerLine.text) { + // Content changed — full re-render + logEl.innerHTML = ''; + for (const line of logLines) { + const div = document.createElement('div'); + div.className = 'auto-log-line ' + (line.type || 'info'); + div.textContent = line.text; + logEl.appendChild(div); + } + _autoProgressLogCounts[aid] = logLines.length; + logEl.scrollTop = logEl.scrollHeight; + } + } + } + } +} + +async function runAutomation(id) { + try { + const res = await fetch('/api/automations/' + id + '/run', { method: 'POST' }); + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast('Automation triggered', 'success'); + setTimeout(() => loadAutomations(), 1500); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +const _RESULT_DISPLAY_MAP = { + 'start_database_update': [ + { key: 'artists', label: 'Artists' }, + { key: 'albums', label: 'Albums' }, + { key: 'tracks', label: 'Tracks' }, + { key: 'removed_artists', label: 'Removed Artists', hideZero: true }, + { key: 'removed_albums', label: 'Removed Albums', hideZero: true }, + { key: 'removed_tracks', label: 'Removed Tracks', hideZero: true }, + ], + 'deep_scan_library': [ + { key: 'artists', label: 'Artists' }, + { key: 'albums', label: 'Albums' }, + { key: 'tracks', label: 'Tracks' }, + { key: 'removed_artists', label: 'Removed Artists', hideZero: true }, + { key: 'removed_albums', label: 'Removed Albums', hideZero: true }, + { key: 'removed_tracks', label: 'Removed Tracks', hideZero: true }, + ], + 'scan_watchlist': [ + { key: 'artists_scanned', label: 'Artists Scanned' }, + { key: 'successful_scans', label: 'Successful' }, + { key: 'new_tracks_found', label: 'New Tracks' }, + { key: 'tracks_added_to_wishlist', label: 'Added to Wishlist' }, + ], + 'run_duplicate_cleaner': [ + { key: 'files_scanned', label: 'Files Scanned' }, + { key: 'duplicates_found', label: 'Duplicates Found' }, + { key: 'files_deleted', label: 'Files Deleted' }, + { key: 'space_freed_mb', label: 'Space Freed (MB)' }, + ], + 'start_quality_scan': [ + { key: 'tracks_scanned', label: 'Tracks Scanned' }, + { key: 'quality_met', label: 'Quality Met' }, + { key: 'low_quality', label: 'Low Quality' }, + { key: 'matched', label: 'Added to Wishlist' }, + ], + 'scan_library': [ + { key: 'scan_duration_seconds', label: 'Duration (s)' }, + ], + 'backup_database': [ + { key: 'size_mb', label: 'Backup Size (MB)' }, + ], + 'refresh_mirrored': [ + { key: 'refreshed', label: 'Playlists Refreshed' }, + { key: 'errors', label: 'Errors', hideZero: true }, + ], + 'clear_quarantine': [ + { key: 'removed', label: 'Items Removed' }, + ], + 'cleanup_wishlist': [ + { key: 'removed', label: 'Duplicates Removed' }, + ], + 'full_cleanup': [ + { key: 'quarantine_removed', label: 'Quarantine Removed' }, + { key: 'staging_removed', label: 'Import Dirs Removed' }, + { key: 'total_removed', label: 'Total Items Removed' }, + ], + 'playlist_pipeline': [ + { key: 'playlists_refreshed', label: 'Refreshed' }, + { key: 'tracks_discovered', label: 'Discovered' }, + { key: 'tracks_synced', label: 'Synced' }, + { key: 'sync_skipped', label: 'Skipped', hideZero: true }, + { key: 'wishlist_queued', label: 'Wishlist Queued' }, + { key: 'duration_seconds', label: 'Duration (s)' }, + ], +}; + +function _renderResultStats(resultJson, actionType) { + if (!resultJson || typeof resultJson !== 'object') return ''; + var fields = _RESULT_DISPLAY_MAP[actionType]; + var items = []; + if (fields) { + fields.forEach(function (f) { + var val = resultJson[f.key]; + if (val == null) return; + if (f.hideZero && (val === 0 || val === '0')) return; + items.push({ label: f.label, value: val }); + }); + } else { + // Generic fallback: show all non-status, non-underscore keys + Object.keys(resultJson).forEach(function (k) { + if (k === 'status' || k.startsWith('_')) return; + var label = k.replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); + items.push({ label: label, value: resultJson[k] }); + }); + } + if (items.length === 0) return ''; + var html = '
'; + items.forEach(function (it) { + html += '
' + _esc(it.label) + '
' + _esc(String(it.value)) + '
'; + }); + html += '
'; + return html; +} + +async function showAutomationHistory(automationId, automationName, actionType) { + let modal = document.getElementById('automation-history-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'automation-history-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + modal.innerHTML = ''; + modal.style.display = 'flex'; + modal.onclick = function (e) { if (e.target === modal) modal.style.display = 'none'; }; + + try { + const res = await fetch('/api/automations/' + automationId + '/history?limit=50'); + const data = await res.json(); + if (data.error) throw new Error(data.error); + const body = modal.querySelector('.history-modal-body'); + if (!data.history || data.history.length === 0) { + body.innerHTML = '
No run history yet. History will be recorded on future runs.
'; + return; + } + let html = '
'; + data.history.forEach(function (entry) { + const statusClass = 'history-status-' + (entry.status || 'completed'); + const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1); + const timeAgo = _autoTimeAgo(entry.started_at); + const duration = entry.duration_seconds != null ? _formatDuration(entry.duration_seconds) : ''; + const summary = entry.summary ? _esc(entry.summary) : ''; + const hasLogs = entry.log_lines && entry.log_lines.length > 0; + const entryId = 'history-entry-' + entry.id; + + html += '
'; + html += '
'; + html += '' + statusLabel + ''; + html += '' + timeAgo + ''; + if (duration) html += '' + duration + ''; + if (hasLogs) html += ''; + html += '
'; + if (summary) html += '
' + summary + '
'; + if (entry.result_json && typeof entry.result_json === 'object') { + html += _renderResultStats(entry.result_json, actionType); + } + if (hasLogs) { + html += '
'; + entry.log_lines.forEach(function (log) { + html += '
' + _esc(log.text || '') + '
'; + }); + html += '
'; + } + html += '
'; + }); + html += '
'; + if (data.total > data.history.length) { + html += '
Showing ' + data.history.length + ' of ' + data.total + ' runs
'; + } + body.innerHTML = html; + } catch (err) { + const body = modal.querySelector('.history-modal-body'); + if (body) body.innerHTML = '
Error loading history: ' + _esc(err.message) + '
'; + } +} + +function _formatDuration(seconds) { + if (seconds < 1) return '<1s'; + if (seconds < 60) return Math.round(seconds) + 's'; + var m = Math.floor(seconds / 60); + var s = Math.round(seconds % 60); + if (m < 60) return m + 'm ' + s + 's'; + var h = Math.floor(m / 60); + m = m % 60; + return h + 'h ' + m + 'm'; +} + +async function saveAutomation() { + const name = document.getElementById('builder-name').value.trim(); + if (!name) { showToast('Name is required', 'error'); return; } + if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; } + if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; } + + // Read configs from DOM + const triggerConfig = _readPlacedConfig('when'); + const actionConfig = _readPlacedConfig('do'); + + // Read THEN actions (multi-slot) + const thenActions = _autoBuilder.then.map((item, i) => ({ + type: item.type, + config: _readPlacedConfig('then-' + i), + })); + + // Read optional delay from DO slot + const delayEl = document.getElementById('cfg-do-delay'); + const delayVal = delayEl ? parseInt(delayEl.value) : 0; + if (delayVal > 0) actionConfig.delay = delayVal; + + const groupInput = document.getElementById('builder-group-name'); + const groupName = groupInput ? groupInput.value.trim() : ''; + + const body = { + name, + trigger_type: _autoBuilder.when.type, trigger_config: triggerConfig, + action_type: _autoBuilder.do.type, action_config: actionConfig, + then_actions: thenActions, + group_name: groupName || null, + }; + + try { + let res; + if (_autoBuilder.editId) { + res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + } else { + res = await fetch('/api/automations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + } + const data = await res.json(); + if (data.error) throw new Error(data.error); + showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success'); + hideAutomationBuilder(); + await loadAutomations(); + } catch (err) { showToast('Error: ' + err.message, 'error'); } +} + +// --- Builder View --- + +async function showAutomationBuilder(editId) { + // Load block definitions (always refresh) + try { + const res = await fetch('/api/automations/blocks'); + _autoBlocks = await res.json(); + } catch (e) { + if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; } + } + + _autoMirroredPlaylists = null; // invalidate so it re-fetches + _autoSpotifyAuthenticated = false; + _autoBuilder = { editId: editId || null, when: null, do: null, then: [], isSystem: false }; + + // Populate group datalist from existing automations + try { + const allRes = await fetch('/api/automations'); + const allAutos = await allRes.json(); + const groupSet = new Set(); + if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); }); + const datalist = document.getElementById('builder-group-list'); + if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `' + + data.scripts.map(s => ``).join(''); + } + } catch (e) { console.warn('Failed to load scripts:', e); } + }, 100); + return `
+ + +
+
+ + seconds +
+
Place scripts in the scripts/ folder. Supported: .sh, .py, .bat, .ps1
`; + } + if (blockType === 'scan_watchlist' || blockType === 'scan_library' || blockType === 'notify_only') { + return '
No configuration needed
'; + } + if (blockType === 'refresh_mirrored') { + const allChecked = config.all ? ' checked' : ''; + return `
+ + +
+
+ +
`; + } + if (blockType === 'sync_playlist') { + return `
+ + +
`; + } + if (blockType === 'discover_playlist') { + const allChecked = config.all ? ' checked' : ''; + return `
+ + +
+
+ +
`; + } + if (blockType === 'playlist_pipeline') { + const allChecked = config.all ? ' checked' : ''; + const skipWishlistChecked = config.skip_wishlist ? ' checked' : ''; + return `
+ + +
+
+ +
+
+ +
+
Runs 4 phases: Refresh → Discover → Sync → Download Missing
`; + } + // Shared variable tags builder for notification types + function _notifyVarHtml(slotKey) { + let allVars = ['time', 'name', 'run_count', 'status']; + const triggerDef = _autoBuilder.when ? _findBlockDef(_autoBuilder.when.type) : null; + if (triggerDef && triggerDef.variables) { + triggerDef.variables.forEach(v => { if (!allVars.includes(v)) allVars.push(v); }); + } + let html = '
'; + allVars.forEach(v => { html += `{${v}}`; }); + return html + '
'; + } + + if (blockType === 'discord_webhook') { + const url = _escAttr(config.webhook_url || ''); + return `
+ + +
+
+ + +
+ ${_notifyVarHtml(slotKey)}`; + } + if (blockType === 'pushbullet') { + const token = _escAttr(config.access_token || ''); + return `
+ + +
+
+ + +
+
+ + +
+ ${_notifyVarHtml(slotKey)}`; + } + if (blockType === 'telegram') { + const botToken = _escAttr(config.bot_token || ''); + const chatId = _escAttr(config.chat_id || ''); + return `
+ + +
+
+ + +
+
+ + +
+ ${_notifyVarHtml(slotKey)}`; + } + if (blockType === 'webhook') { + const url = _escAttr(config.url || ''); + const hdrs = (config.headers || '').replace(/"/g, '"'); + return `
+ + +
+
+ + +
+
+ + +
+
+ Sends a JSON POST with all event variables. Custom message added as "message" field if set. +
+ ${_notifyVarHtml(slotKey)}`; + } + return ''; +} + +// --- Condition Builder --- + +function _renderConditionBuilder(slotKey, blockDef, config) { + const conditions = config.conditions || []; + const match = config.match || 'all'; + const fields = blockDef.condition_fields || []; + + let html = '
'; + html += `
+ + +
`; + + html += '
'; + if (conditions.length) { + conditions.forEach((cond, i) => { + html += _renderConditionRow(slotKey, i, fields, cond); + }); + } + html += '
'; + + html += ``; + html += '
'; + + if (!conditions.length) { + html += '
No conditions = triggers on every event
'; + } + + return html; +} + +function _renderConditionRow(slotKey, index, fields, cond) { + const field = cond ? cond.field : (fields[0] || ''); + const operator = cond ? cond.operator : 'equals'; + const value = cond ? _escAttr(cond.value) : ''; + + let fieldOpts = ''; + fields.forEach(f => { fieldOpts += ``; }); + + // For playlist-related triggers, use a mirrored playlist dropdown instead of free text + const triggerType = _autoBuilder.when ? _autoBuilder.when.type : ''; + const usePlaylistSelect = ((triggerType === 'playlist_changed' || triggerType === 'discovery_completed') && field === 'playlist_name'); + const valueHtml = usePlaylistSelect + ? `` + : ``; + + return `
+ + + ${valueHtml} + +
`; +} + +function _autoAddCondition(slotKey) { + const data = _autoBuilder[slotKey]; + if (!data) return; + if (!data.config) data.config = {}; + if (!data.config.conditions) data.config.conditions = []; + + // Save existing conditions from DOM before re-render + _autoSaveConditionsFromDOM(slotKey); + + const blockDef = _findBlockDef(data.type); + const fields = blockDef ? (blockDef.condition_fields || []) : []; + data.config.conditions.push({ field: fields[0] || '', operator: 'contains', value: '' }); + _renderBuilderCanvas(); + // Re-populate mirrored playlist selects if needed + _autoLoadMirroredSelects(); +} + +function _autoRemoveCondition(slotKey, index) { + const data = _autoBuilder[slotKey]; + if (!data || !data.config || !data.config.conditions) return; + _autoSaveConditionsFromDOM(slotKey); + data.config.conditions.splice(index, 1); + _renderBuilderCanvas(); + _autoLoadMirroredSelects(); +} + +function _autoSaveConditionsFromDOM(slotKey) { + const data = _autoBuilder[slotKey]; + if (!data || !data.config) return; + const container = document.getElementById('condition-rows-' + slotKey); + if (!container) return; + const rows = container.querySelectorAll('.condition-row'); + const conditions = []; + rows.forEach(row => { + const field = row.querySelector('.cond-field')?.value || ''; + const operator = row.querySelector('.cond-operator')?.value || 'contains'; + const value = row.querySelector('.cond-value')?.value || ''; + conditions.push({ field, operator, value }); + }); + data.config.conditions = conditions; + // Also save match mode + const matchEl = document.getElementById('cfg-' + slotKey + '-match'); + if (matchEl) data.config.match = matchEl.value; +} + +// --- Mirrored Playlist Select --- + +function _autoTogglePlaylistSelect(slotKey) { + const allCb = document.getElementById('cfg-' + slotKey + '-all'); + const sel = document.getElementById('cfg-' + slotKey + '-playlist_id'); + if (sel) sel.disabled = allCb && allCb.checked; +} + +async function _autoLoadMirroredSelects() { + const selects = document.querySelectorAll('.mirrored-playlist-select'); + const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select'); + if (!selects.length && !nameSelects.length) return; + + if (!_autoMirroredPlaylists) { + try { + const res = await fetch('/api/mirrored-playlists/list'); + const data = await res.json(); + // New format returns { playlists, spotify_authenticated } + if (Array.isArray(data)) { + // Backward compat: old format was plain array + _autoMirroredPlaylists = data; + _autoSpotifyAuthenticated = false; + } else { + _autoMirroredPlaylists = data.playlists || []; + _autoSpotifyAuthenticated = data.spotify_authenticated || false; + } + } catch (e) { _autoMirroredPlaylists = []; _autoSpotifyAuthenticated = false; } + } + + selects.forEach(sel => { + const savedValue = sel.dataset.value || ''; + const isRefresh = sel.dataset.blockType === 'refresh_mirrored'; + sel.innerHTML = ''; + _autoMirroredPlaylists.forEach(p => { + // For refresh selects: hide file playlists, hide spotify (library) if not authed + if (isRefresh) { + if (p.source === 'file' || p.source === 'beatport') return; + if (p.source === 'spotify' && !_autoSpotifyAuthenticated) return; + } + sel.innerHTML += ``; + }); + }); + + nameSelects.forEach(sel => { + const savedValue = sel.dataset.value || ''; + sel.innerHTML = ''; + _autoMirroredPlaylists.forEach(p => { + sel.innerHTML += ``; + }); + }); +} + +function _readPlacedConfig(slotKey) { + let data; + if (slotKey.startsWith('then-')) { + const idx = parseInt(slotKey.split('-')[1]); + data = _autoBuilder.then[idx]; + } else { + data = _autoBuilder[slotKey]; + } + if (!data) return {}; + const type = data.type; + if (type === 'schedule') { + return { + interval: parseInt(document.getElementById('cfg-' + slotKey + '-interval')?.value) || 6, + unit: document.getElementById('cfg-' + slotKey + '-unit')?.value || 'hours', + }; + } + if (type === 'daily_time') { + return { time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00' }; + } + if (type === 'weekly_time') { + const daysEl = document.getElementById('cfg-' + slotKey + '-days'); + const days = daysEl ? Array.from(daysEl.querySelectorAll('.day-btn.active')).map(b => b.dataset.day) : []; + return { + time: document.getElementById('cfg-' + slotKey + '-time')?.value || '03:00', + days, + }; + } + // Event triggers with conditions + const blockDef = _findBlockDef(type); + if (blockDef && blockDef.has_conditions) { + _autoSaveConditionsFromDOM(slotKey); + return { + conditions: (data.config && data.config.conditions) || [], + match: document.getElementById('cfg-' + slotKey + '-match')?.value || 'all', + }; + } + if (type === 'process_wishlist') { + return { category: document.getElementById('cfg-' + slotKey + '-category')?.value || 'all' }; + } + if (type === 'refresh_mirrored') { + const allCb = document.getElementById('cfg-' + slotKey + '-all'); + return { + playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', + all: allCb ? allCb.checked : false, + }; + } + if (type === 'sync_playlist') { + return { playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '' }; + } + if (type === 'discover_playlist') { + const allCb = document.getElementById('cfg-' + slotKey + '-all'); + return { + playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', + all: allCb ? allCb.checked : false, + }; + } + if (type === 'playlist_pipeline') { + const allCb = document.getElementById('cfg-' + slotKey + '-all'); + const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist'); + return { + playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '', + all: allCb ? allCb.checked : false, + skip_wishlist: skipWl ? skipWl.checked : false, + }; + } + if (type === 'signal_received' || type === 'fire_signal') { + return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' }; + } + if (type === 'run_script') { + return { + script_name: document.getElementById('cfg-' + slotKey + '-script_name')?.value || '', + timeout: parseInt(document.getElementById('cfg-' + slotKey + '-timeout')?.value || '60') || 60, + }; + } + if (type === 'discord_webhook') { + return { + webhook_url: document.getElementById('cfg-' + slotKey + '-webhook_url')?.value?.trim() || '', + message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', + }; + } + if (type === 'pushbullet') { + return { + access_token: document.getElementById('cfg-' + slotKey + '-access_token')?.value?.trim() || '', + title: document.getElementById('cfg-' + slotKey + '-title')?.value || '', + message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', + }; + } + if (type === 'telegram') { + return { + bot_token: document.getElementById('cfg-' + slotKey + '-bot_token')?.value?.trim() || '', + chat_id: document.getElementById('cfg-' + slotKey + '-chat_id')?.value?.trim() || '', + message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', + }; + } + if (type === 'webhook') { + return { + url: document.getElementById('cfg-' + slotKey + '-url')?.value?.trim() || '', + headers: document.getElementById('cfg-' + slotKey + '-headers')?.value || '', + message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', + }; + } + return {}; +} + +function _findBlockDef(type) { + if (!_autoBlocks) return null; + for (const cat of ['triggers', 'actions', 'notifications']) { + const found = (_autoBlocks[cat] || []).find(b => b.type === type); + if (found) return found; + } + return null; +} + +// --- Drag & Drop --- + +function _autoDragStart(e, blockType, slotCategory) { + e.dataTransfer.setData('text/plain', JSON.stringify({ type: blockType, slot: slotCategory })); + e.dataTransfer.effectAllowed = 'copy'; +} + +function _autoDragOver(e, slotKey) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; + document.getElementById(targetId)?.classList.add('drag-over'); +} + +function _autoDragLeave(e, slotKey) { + const targetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; + document.getElementById(targetId)?.classList.remove('drag-over'); +} + +function _autoDrop(e, slotKey) { + e.preventDefault(); + const dropTargetId = slotKey === 'then' ? 'slot-then-add' : 'slot-' + slotKey; + document.getElementById(dropTargetId)?.classList.remove('drag-over'); + if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; + try { + const data = JSON.parse(e.dataTransfer.getData('text/plain')); + // Handle THEN slot (append to array) + if (slotKey === 'then') { + if (data.slot !== 'then') { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } + if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; } + _autoBuilder.then.push({ type: data.type, config: {} }); + } else { + if (data.slot !== slotKey) { showToast('Wrong slot — drop ' + data.slot + ' blocks here', 'error'); return; } + _autoBuilder[slotKey] = { type: data.type, config: {} }; + } + _renderBuilderCanvas(); + } catch (err) { } +} + +// Click-to-add (alternative to drag) +function _autoClickBlock(blockType, slotCategory) { + if (_autoBuilder.isSystem && (slotCategory === 'when' || slotCategory === 'do')) return; + if (slotCategory === 'then') { + if (_autoBuilder.then.length >= 3) { showToast('Maximum 3 then-actions', 'error'); return; } + _autoBuilder.then.push({ type: blockType, config: {} }); + } else { + _autoBuilder[slotCategory] = { type: blockType, config: {} }; + } + _renderBuilderCanvas(); +} + +function _autoRemoveBlock(slotKey) { + if (_autoBuilder.isSystem && (slotKey === 'when' || slotKey === 'do')) return; + // Handle then-N slots + if (slotKey.startsWith('then-')) { + const idx = parseInt(slotKey.split('-')[1]); + if (!isNaN(idx) && idx >= 0 && idx < _autoBuilder.then.length) { + _autoBuilder.then.splice(idx, 1); + } + } else { + _autoBuilder[slotKey] = null; + } + _renderBuilderCanvas(); +} + +// Variable insertion +function _autoInsertVar(textareaId, variable) { + const el = document.getElementById(textareaId); + if (!el) return; + const start = el.selectionStart, end = el.selectionEnd; + el.value = el.value.substring(0, start) + variable + el.value.substring(end); + el.selectionStart = el.selectionEnd = start + variable.length; + el.focus(); +} + +// ===== ISSUES PAGE ===== + +const ISSUE_CATEGORIES = { + wrong_track: { label: 'Wrong Track', icon: '❌', description: 'This file plays a completely different song than expected', applies: ['track'] }, + wrong_metadata: { label: 'Wrong Metadata', icon: '✎', description: 'Title, artist, year, or other tags are incorrect', applies: ['track', 'album'] }, + wrong_cover: { label: 'Wrong Cover Art', icon: '📷', description: 'The album artwork is wrong or missing', applies: ['album'] }, + wrong_artist: { label: 'Wrong Artist', icon: '👤', description: 'This track is filed under the wrong artist', applies: ['track'] }, + duplicate_tracks: { label: 'Duplicate Tracks', icon: '🔁', description: 'The same track appears more than once in this album', applies: ['album'] }, + missing_tracks: { label: 'Missing Tracks', icon: '❓', description: 'Tracks that should be here are missing from this album', applies: ['album'] }, + audio_quality: { label: 'Audio Quality', icon: '🎵', description: 'Audio has quality issues — clipping, low bitrate, silence, etc.', applies: ['track'] }, + wrong_album: { label: 'Wrong Album', icon: '💿', description: 'This track belongs to a different album', applies: ['track'] }, + incomplete_album: { label: 'Incomplete Album', icon: '⚠', description: 'Album is partially downloaded — some tracks present, others not', applies: ['album'] }, + other: { label: 'Other', icon: '💬', description: 'Any other issue not listed above', applies: ['track', 'album'] }, +}; + +const ISSUE_STATUS_META = { + open: { label: 'Open', cls: 'issue-status-open' }, + in_progress: { label: 'In Progress', cls: 'issue-status-progress' }, + resolved: { label: 'Resolved', cls: 'issue-status-resolved' }, + dismissed: { label: 'Dismissed', cls: 'issue-status-dismissed' }, +}; + +let _issuesPageState = { loaded: false }; + +function _issueHeaders(extra) { + const h = { 'X-Profile-Id': String(currentProfile ? currentProfile.id : 1) }; + if (extra) Object.assign(h, extra); + return h; +} + +async function loadIssuesPage() { + const admin = isEnhancedAdmin(); + const subtitle = document.getElementById('issues-subtitle'); + if (subtitle) { + subtitle.textContent = admin ? 'Manage and resolve reported library problems' : 'Track and resolve library problems'; + } + await Promise.all([loadIssuesList(), loadIssuesCounts()]); +} + +async function loadIssuesCounts() { + try { + const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); + const data = await resp.json(); + if (!data.success) return; + const counts = data.counts; + const statsEl = document.getElementById('issues-stats'); + if (!statsEl) return; + const total = (counts.open || 0) + (counts.in_progress || 0) + (counts.resolved || 0) + (counts.dismissed || 0); + statsEl.innerHTML = ` +
+
${counts.open || 0}
+
Open
+
+
+
${counts.in_progress || 0}
+
In Progress
+
+
+
${counts.resolved || 0}
+
Resolved
+
+
+
${counts.dismissed || 0}
+
Dismissed
+
+
+
${total}
+
Total
+
+ `; + // Update nav badge + const badge = document.getElementById('issues-nav-badge'); + if (badge) { + const openCount = counts.open || 0; + badge.textContent = openCount; + badge.classList.toggle('hidden', openCount === 0); + } + } catch (e) { + console.error('Failed to load issue counts:', e); + } +} + +async function loadIssuesList() { + const listEl = document.getElementById('issues-list'); + if (!listEl) return; + listEl.innerHTML = '
Loading issues...
'; + + const statusFilter = document.getElementById('issues-filter-status')?.value || ''; + const categoryFilter = document.getElementById('issues-filter-category')?.value || ''; + + let url = '/api/issues?'; + if (statusFilter) url += `status=${encodeURIComponent(statusFilter)}&`; + if (categoryFilter) url += `category=${encodeURIComponent(categoryFilter)}&`; + + try { + const profileId = currentProfile ? currentProfile.id : 1; + const resp = await fetch(url, { headers: { 'X-Profile-Id': String(profileId) } }); + const data = await resp.json(); + if (!data.success || !data.issues || data.issues.length === 0) { + listEl.innerHTML = ` +
+
🔍
+
No issues found
+
${statusFilter || categoryFilter ? 'Try adjusting your filters' : 'No issues have been reported yet'}
+
+ `; + return; + } + listEl.innerHTML = ''; + data.issues.forEach(issue => { + listEl.appendChild(renderIssueCard(issue)); + }); + } catch (e) { + console.error('Failed to load issues:', e); + listEl.innerHTML = '
Failed to load issues
'; + } +} + +function renderIssueCard(issue) { + const card = document.createElement('div'); + card.className = 'issue-card'; + card.dataset.issueId = issue.id; + card.onclick = () => showIssueDetailModal(issue.id); + + const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; + const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; + const admin = isEnhancedAdmin(); + + let snapshot = {}; + try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } + + const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); + const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; + const artistName = snapshot.artist_name || ''; + const albumName = snapshot.album_title || ''; + const thumbUrl = snapshot.thumb_url || snapshot.album_thumb || ''; + + const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''; + const createdTime = issue.created_at ? new Date(issue.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : ''; + + // Priority indicator + const priorityCls = issue.priority === 'high' ? 'issue-priority-high' : (issue.priority === 'low' ? 'issue-priority-low' : 'issue-priority-normal'); + + let thumbHtml = ''; + if (thumbUrl) { + thumbHtml = ``; + } else { + thumbHtml = `
${catMeta.icon}
`; + } + + let metaLine = ''; + if (issue.entity_type === 'track') { + metaLine = [artistName, albumName].filter(Boolean).map(s => _esc(s)).join(' — '); + } else if (issue.entity_type === 'album') { + metaLine = artistName ? _esc(artistName) : ''; + } + + let profileBadge = ''; + if (admin && issue.reporter_name) { + profileBadge = `by ${_esc(issue.reporter_name)}`; + } + + let adminResponseIndicator = ''; + if (issue.admin_response) { + adminResponseIndicator = '💬'; + } + + card.innerHTML = ` +
+ ${thumbHtml} +
+
+
+ ${catMeta.icon} + ${_esc(issue.title)} + ${adminResponseIndicator} +
+
+ ${_esc(entityLabel)} + ${_esc(entityName)} + ${metaLine ? `${metaLine}` : ''} +
+ ${issue.description ? `
${_esc(issue.description)}
` : ''} + +
+
+ ${_esc(statusMeta.label)} + +
+ `; + return card; +} + +// --- Report Issue Modal --- + +let _reportIssueState = {}; + +function showReportIssueModal(entityType, entityId, entityName, artistName, albumTitle) { + _reportIssueState = { entityType, entityId, entityName, artistName, albumTitle: albumTitle || '' }; + const overlay = document.getElementById('report-issue-overlay'); + const titleEl = document.getElementById('report-issue-title'); + const body = document.getElementById('report-issue-body'); + if (!overlay || !body) return; + + const entityLabel = entityType === 'track' ? 'Track' : (entityType === 'album' ? 'Album' : 'Artist'); + titleEl.textContent = `Report Issue — ${entityLabel}`; + + body.innerHTML = ` +
+
${_esc(entityName)}
+ ${artistName ? `
${_esc(artistName)}${albumTitle ? ' — ' + _esc(albumTitle) : ''}
` : ''} +
+
+ +
+ ${Object.entries(ISSUE_CATEGORIES) + .filter(([, cat]) => !cat.applies || cat.applies.includes(entityType)) + .map(([key, cat]) => ` +
+
${cat.icon}
+
${_esc(cat.label)}
+
${_esc(cat.description)}
+
+ `).join('')} +
+
+ + `; + + _reportIssueState.selectedCategory = null; + _reportIssueState.selectedPriority = 'normal'; + const submitBtn = document.getElementById('report-issue-submit-btn'); + if (submitBtn) submitBtn.disabled = true; + + overlay.classList.remove('hidden'); +} + +function selectIssueCategory(el, category) { + document.querySelectorAll('.report-issue-category-card').forEach(c => c.classList.remove('selected')); + el.classList.add('selected'); + _reportIssueState.selectedCategory = category; + + const detailsSection = document.getElementById('report-issue-details-section'); + if (detailsSection) detailsSection.style.display = ''; + + // Auto-generate title based on category + const titleInput = document.getElementById('report-issue-input-title'); + const catMeta = ISSUE_CATEGORIES[category]; + if (titleInput && !titleInput._userEdited) { + const entityName = _reportIssueState.entityName || ''; + titleInput.value = `${catMeta.label}: ${entityName}`; + } + + const submitBtn = document.getElementById('report-issue-submit-btn'); + if (submitBtn) submitBtn.disabled = false; +} + +function selectIssuePriority(el, priority) { + document.querySelectorAll('.report-issue-priority-btn').forEach(b => b.classList.remove('selected')); + el.classList.add('selected'); + _reportIssueState.selectedPriority = priority; +} + +function closeReportIssueModal() { + const overlay = document.getElementById('report-issue-overlay'); + if (overlay) overlay.classList.add('hidden'); + _reportIssueState = {}; +} + +async function submitIssue() { + if (_reportIssueState._submitting) return; + const category = _reportIssueState.selectedCategory; + if (!category) { + showToast('Please select an issue category', 'error'); + return; + } + + const titleInput = document.getElementById('report-issue-input-title'); + const descInput = document.getElementById('report-issue-input-desc'); + const title = (titleInput?.value || '').trim(); + const description = (descInput?.value || '').trim(); + + if (!title) { + showToast('Please provide a title for the issue', 'error'); + return; + } + + _reportIssueState._submitting = true; + const submitBtn = document.getElementById('report-issue-submit-btn'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.textContent = 'Submitting...'; + } + + try { + const resp = await fetch('/api/issues', { + method: 'POST', + headers: _issueHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + profile_id: currentProfile ? currentProfile.id : 1, + entity_type: _reportIssueState.entityType, + entity_id: String(_reportIssueState.entityId), + category: category, + title: title, + description: description, + priority: _reportIssueState.selectedPriority || 'normal', + }), + }); + const data = await resp.json(); + if (data.success) { + showToast('Issue reported successfully', 'success'); + closeReportIssueModal(); + // Refresh issues page if visible + const issuesPage = document.getElementById('issues-page'); + if (issuesPage && issuesPage.classList.contains('active')) { + loadIssuesPage(); + } + // Update badge + loadIssuesBadge(); + } else { + showToast(data.error || 'Failed to submit issue', 'error'); + } + } catch (e) { + console.error('Failed to submit issue:', e); + showToast('Failed to submit issue', 'error'); + } finally { + _reportIssueState._submitting = false; + if (submitBtn) { + submitBtn.disabled = false; + submitBtn.textContent = 'Submit Issue'; + } + } +} + +// --- Issue Detail Modal --- + +async function showIssueDetailModal(issueId) { + const overlay = document.getElementById('issue-detail-overlay'); + const body = document.getElementById('issue-detail-body'); + const footer = document.getElementById('issue-detail-footer'); + const titleEl = document.getElementById('issue-detail-title'); + if (!overlay || !body) return; + + body.innerHTML = '
Loading...
'; + footer.innerHTML = ''; + overlay.classList.remove('hidden'); + + try { + const resp = await fetch(`/api/issues/${issueId}`, { headers: _issueHeaders() }); + const data = await resp.json(); + if (!data.success || !data.issue) { + body.innerHTML = '
Issue not found
'; + return; + } + renderIssueDetail(data.issue, body, footer, titleEl); + } catch (e) { + console.error('Failed to load issue:', e); + body.innerHTML = '
Failed to load issue
'; + } +} + +function renderIssueDetail(issue, body, footer, titleEl) { + const admin = isEnhancedAdmin(); + const catMeta = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other; + const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; + + let snapshot = {}; + try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } + + const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); + const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; + const artistName = snapshot.artist_name || (issue.entity_type === 'artist' ? snapshot.name : '') || ''; + const albumTitle = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); + const artistId = issue.entity_type === 'artist' ? snapshot.id : snapshot.artist_id; + + // Resolve image URLs — album art and artist photo + let artistThumb = ''; + let albumThumb = ''; + if (issue.entity_type === 'album') { + albumThumb = snapshot.thumb_url || ''; + artistThumb = snapshot.artist_thumb || ''; + } else if (issue.entity_type === 'track') { + albumThumb = snapshot.album_thumb || ''; + artistThumb = snapshot.artist_thumb || ''; + } else { + // Artist issue + artistThumb = snapshot.thumb_url || ''; + } + + // Determine the album-level Spotify ID for download/wishlist actions + const spotifyAlbumId = snapshot.spotify_album_id || ''; + + console.log('Issue detail snapshot:', { entityType: issue.entity_type, albumThumb, artistThumb, spotifyAlbumId, snapshotKeys: Object.keys(snapshot) }); + + const createdDate = issue.created_at ? new Date(issue.created_at).toLocaleString() : 'Unknown'; + const resolvedDate = issue.resolved_at ? new Date(issue.resolved_at).toLocaleString() : ''; + + titleEl.textContent = `Issue #${issue.id}`; + + // --- Build external links chips --- + function _extLinks(snap) { + const links = []; + if (snap.spotify_artist_id) links.push({ svc: 'Spotify', type: 'Artist', url: `https://open.spotify.com/artist/${snap.spotify_artist_id}`, cls: 'ext-spotify' }); + if (snap.spotify_album_id) links.push({ svc: 'Spotify', type: 'Album', url: `https://open.spotify.com/album/${snap.spotify_album_id}`, cls: 'ext-spotify' }); + if (snap.spotify_track_id) links.push({ svc: 'Spotify', type: 'Track', url: `https://open.spotify.com/track/${snap.spotify_track_id}`, cls: 'ext-spotify' }); + if (snap.artist_musicbrainz_id) links.push({ svc: 'MusicBrainz', type: 'Artist', url: `https://musicbrainz.org/artist/${snap.artist_musicbrainz_id}`, cls: 'ext-mb' }); + if (snap.musicbrainz_release_id) links.push({ svc: 'MusicBrainz', type: 'Release', url: `https://musicbrainz.org/release/${snap.musicbrainz_release_id}`, cls: 'ext-mb' }); + if (snap.musicbrainz_recording_id) links.push({ svc: 'MusicBrainz', type: 'Recording', url: `https://musicbrainz.org/recording/${snap.musicbrainz_recording_id}`, cls: 'ext-mb' }); + if (snap.artist_deezer_id) links.push({ svc: 'Deezer', type: 'Artist', url: `https://www.deezer.com/artist/${snap.artist_deezer_id}`, cls: 'ext-deezer' }); + if (snap.album_deezer_id) links.push({ svc: 'Deezer', type: 'Album', url: `https://www.deezer.com/album/${snap.album_deezer_id}`, cls: 'ext-deezer' }); + if (snap.track_deezer_id) links.push({ svc: 'Deezer', type: 'Track', url: `https://www.deezer.com/track/${snap.track_deezer_id}`, cls: 'ext-deezer' }); + if (snap.artist_tidal_id) links.push({ svc: 'Tidal', type: 'Artist', url: `https://listen.tidal.com/artist/${snap.artist_tidal_id}`, cls: 'ext-tidal' }); + if (snap.album_tidal_id) links.push({ svc: 'Tidal', type: 'Album', url: `https://listen.tidal.com/album/${snap.album_tidal_id}`, cls: 'ext-tidal' }); + if (snap.artist_qobuz_id) links.push({ svc: 'Qobuz', type: 'Artist', cls: 'ext-qobuz', id: snap.artist_qobuz_id }); + if (snap.album_qobuz_id) links.push({ svc: 'Qobuz', type: 'Album', cls: 'ext-qobuz', id: snap.album_qobuz_id }); + return links; + } + + const extLinks = _extLinks(snapshot); + let extLinksHtml = ''; + if (extLinks.length > 0) { + const chips = extLinks.map(l => { + if (l.url) { + return `${_esc(l.svc)} ${_esc(l.type)}`; + } + return `${_esc(l.svc)} ${_esc(l.type)}`; + }).join(''); + extLinksHtml = `
${chips}
`; + } + + // --- Build enhanced-library-style album/track widget --- + // Determine which album data to show (for album issues it's the entity, for track issues it's the parent) + const showAlbumWidget = (issue.entity_type === 'album' || issue.entity_type === 'track'); + const albumName = issue.entity_type === 'album' ? (snapshot.title || '') : (snapshot.album_title || ''); + const albumYear = snapshot.year || ''; + const albumLabel = snapshot.label || ''; + const albumType = snapshot.record_type || ''; + const albumTrackCount = issue.entity_type === 'album' ? (snapshot.track_count || '') : (snapshot.album_track_count || ''); + const albumGenres = snapshot.genres || []; + + // --- Build the hero section (artist photo + album art + info) --- + let heroHtml = ''; + if (showAlbumWidget) { + // Genre tags + let genreTagsHtml = ''; + if (Array.isArray(albumGenres) && albumGenres.length > 0) { + genreTagsHtml = `
${albumGenres.slice(0, 5).map(g => `${_esc(g)}`).join('')}
`; + } + + // Album meta line + const albumMetaParts = []; + if (albumYear) albumMetaParts.push(String(albumYear)); + if (albumType) albumMetaParts.push(albumType.charAt(0).toUpperCase() + albumType.slice(1)); + if (albumTrackCount) albumMetaParts.push(albumTrackCount + ' tracks'); + if (albumLabel) albumMetaParts.push(albumLabel); + + // For track issues, show the track title under the album + const trackNameLine = issue.entity_type === 'track' && entityName + ? `
♫ ${_esc(entityName)}
` : ''; + + heroHtml = ` +
+
+ ${artistThumb ? `` : ''} + ${albumThumb ? `` : ''} +
${catMeta.icon}
+
+
+ ${artistName ? `
${_esc(artistName)}
` : ''} +
${_esc(albumName)}
+ ${trackNameLine} + ${albumMetaParts.length > 0 ? `
${_esc(albumMetaParts.join(' \u00B7 '))}
` : ''} + ${genreTagsHtml} + ${extLinksHtml} +
+
+ `; + } else { + // Artist-level issue — simpler hero + heroHtml = ` +
+
+ ${artistThumb ? `` : `
${catMeta.icon}
`} +
+
+
${_esc(entityName)}
+ ${extLinksHtml} +
+
+ `; + } + + // --- Issue info bar --- + let issueInfoHtml = ` +
+
+ ${_esc(statusMeta.label)} + + ${catMeta.icon} ${_esc(catMeta.label)} +
+
+ Reported ${_esc(createdDate)} + ${issue.reporter_name && admin ? `by ${_esc(issue.reporter_name)}` : ''} + ${resolvedDate ? `Resolved ${_esc(resolvedDate)}` : ''} +
+
+ `; + + // --- Issue description --- + let descriptionHtml = ` +
+
Issue
+
${_esc(issue.title)}
+ ${issue.description ? `
${_esc(issue.description)}
` : '
No additional details provided
'} +
+ `; + + // --- Action buttons (Download Album / Add to Wishlist) for admin --- + let actionButtonsHtml = ''; + if (admin && (issue.entity_type === 'album' || issue.entity_type === 'track')) { + actionButtonsHtml = ` +
+ + +
+ `; + } + + // --- Metadata grid for track-level issues --- + let metaGridHtml = ''; + if (issue.entity_type === 'track') { + const metaItems = []; + if (snapshot.track_number) metaItems.push({ icon: '#', label: 'Track', value: String(snapshot.track_number) }); + if (snapshot.duration) metaItems.push({ icon: '◷', label: 'Duration', value: typeof snapshot.duration === 'number' ? formatDurationMs(snapshot.duration) : String(snapshot.duration) }); + if (snapshot.format) metaItems.push({ icon: '💾', label: 'Format', value: snapshot.format }); + if (snapshot.bitrate) metaItems.push({ icon: '🎶', label: 'Bitrate', value: snapshot.bitrate + ' kbps' }); + if (snapshot.bpm) metaItems.push({ icon: '♫', label: 'BPM', value: String(snapshot.bpm) }); + if (snapshot.quality) metaItems.push({ icon: '★', label: 'Quality', value: snapshot.quality }); + if (metaItems.length > 0) { + metaGridHtml = ` +
+
Track Details
+
+ ${metaItems.map(m => ` +
+ ${m.icon} + ${_esc(m.label)} + ${_esc(m.value)} +
+ `).join('')} +
+
+ `; + } + } + + // --- File path display for tracks --- + let filePathHtml = ''; + if (snapshot.file_path) { + filePathHtml = ` +
+
File Path
+
${_esc(snapshot.file_path)}
+
+ `; + } + + // --- Enhanced-library-style track listing --- + let trackListHtml = ''; + if (snapshot.tracks && Array.isArray(snapshot.tracks) && snapshot.tracks.length > 0) { + let lastDisc = null; + let rows = ''; + const hasMultiDisc = snapshot.tracks.some(tr => (tr.disc_number || 1) > 1); + snapshot.tracks.forEach(t => { + const disc = t.disc_number || 1; + if (hasMultiDisc && disc !== lastDisc) { + rows += `
Disc ${disc}
`; + lastDisc = disc; + } + const fmt = t.format || (t.file_path ? t.file_path.split('.').pop().toUpperCase() : ''); + const fmtLower = fmt.toLowerCase(); + const fmtClass = fmtLower === 'flac' ? 'flac' : (fmtLower === 'mp3' ? 'mp3' : 'other'); + const br = t.bitrate ? parseInt(t.bitrate) : 0; + const brClass = br >= 320 || fmtLower === 'flac' ? 'high' : (br >= 192 ? 'medium' : 'low'); + const durStr = t.duration && typeof t.duration === 'number' ? formatDurationMs(t.duration) : ''; + + rows += ` +
+ ${_esc(String(t.track_number || '-'))} + ${_esc(t.title || 'Unknown')} + ${durStr ? `${durStr}` : ''} + + ${fmt ? `${_esc(fmt)}` : ''} + ${br ? `${br}k` : ''} + +
+ `; + }); + trackListHtml = ` +
+
Track Listing ${snapshot.tracks.length} tracks
+
${rows}
+
+ `; + } + + // --- Admin response section --- + let adminResponseHtml = ''; + if (admin) { + adminResponseHtml = ` +
+
Admin Response
+ +
+ `; + } else if (issue.admin_response) { + adminResponseHtml = ` +
+
Admin Response
+
${_esc(issue.admin_response)}
+
+ `; + } + + body.innerHTML = ` + ${heroHtml} + ${issueInfoHtml} + ${actionButtonsHtml} + ${descriptionHtml} + ${metaGridHtml} + ${filePathHtml} + ${trackListHtml} + ${adminResponseHtml} + `; + + // --- Footer with status action buttons --- + const safeId = parseInt(issue.id, 10); + let footerHtml = ''; + + if (admin) { + if (issue.status === 'open' || issue.status === 'in_progress') { + if (issue.status === 'open') { + footerHtml += ``; + } + footerHtml += ``; + footerHtml += ``; + } else { + footerHtml += ``; + } + footerHtml += ``; + } else { + if (issue.status === 'open') { + footerHtml += ``; + } + } + + footer.innerHTML = footerHtml; + + // --- Attach action button handlers --- + const dlBtn = document.getElementById('issue-action-download'); + if (dlBtn) { + dlBtn.onclick = () => issueDownloadAlbum(spotifyAlbumId, artistName, albumName); + } + const wlBtn = document.getElementById('issue-action-wishlist'); + if (wlBtn) { + wlBtn.onclick = () => issueAddToWishlist(spotifyAlbumId, artistName, albumName); + } +} + +// --- Issue Action: Download Album --- +async function issueDownloadAlbum(spotifyAlbumId, artistName, albumName) { + const btn = document.getElementById('issue-action-download'); + if (!spotifyAlbumId && (!artistName || !albumName)) { + showToast('No album ID or artist/album info available for download', 'warning'); + return; + } + try { + if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } + + let response; + if (spotifyAlbumId) { + const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); + } else { + // No Spotify album ID — search for the album by name + const query = `${artistName} ${albumName}`; + const searchResp = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!searchResp.ok) throw new Error('Album search failed'); + const searchData = await searchResp.json(); + const foundAlbum = searchData.spotify_albums?.[0]; + if (!foundAlbum || !foundAlbum.id) { + showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); + return; + } + const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); + } + + if (!response.ok) { + if (response.status === 401) throw new Error('Spotify not authenticated'); + throw new Error(`Failed to load album: ${response.status}`); + } + + const albumData = await response.json(); + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + showToast(`No tracks available for "${albumName}"`, 'warning'); + return; + } + + // Close the issue modal first + closeIssueDetailModal(); + + const resolvedAlbumId = albumData.id || spotifyAlbumId || Date.now(); + const virtualPlaylistId = `issue_download_${resolvedAlbumId}`; + + // Enrich tracks with album metadata + const enrichedTracks = albumData.tracks.map(track => ({ + ...track, + album: { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks + } + })); + + const playlistName = `[${artistName}] ${albumData.name}`; + const artistObject = { id: `issue_${artistName}`, name: artistName, image_url: '' }; + const fullAlbumObject = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + image_url: albumData.images?.[0]?.url || null, + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumData.artists || [{ name: artistName }] + }; + + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true + ); + + // Register download bubble so it appears on the dashboard + const albumType = fullAlbumObject.album_type || 'album'; + registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); + + } catch (error) { + console.error('Issue download error:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' Download Album'; } + } +} + +// --- Redownload Library Album (Enhanced View) --- +async function redownloadLibraryAlbum(album, artistName, btn) { + const albumName = album.title || ''; + const spotifyAlbumId = album.spotify_album_id || ''; + + if (!spotifyAlbumId && !albumName) { + showToast('No album ID or name available for redownload', 'warning'); + return; + } + + const origText = btn ? btn.innerHTML : ''; + try { + if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } + + let response; + if (spotifyAlbumId) { + const params = new URLSearchParams({ name: albumName, artist: artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${params}`); + } + + // Fallback: search by name if no ID or direct fetch failed + if (!response || !response.ok) { + const query = `${artistName || ''} ${albumName}`.trim(); + const searchResp = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!searchResp.ok) throw new Error('Album search failed'); + const searchData = await searchResp.json(); + const found = searchData.spotify_albums?.[0] || searchData.itunes_albums?.[0]; + if (!found || !found.id) { + showToast(`Could not find "${albumName}" by ${artistName || 'unknown'}`, 'warning'); + return; + } + const params = new URLSearchParams({ name: found.name || albumName, artist: found.artist || artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(found.id)}?${params}`); + } + + if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); + + const albumData = await response.json(); + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + showToast(`No tracks found for "${albumName}"`, 'warning'); + return; + } + + const resolvedId = albumData.id || spotifyAlbumId || album.id; + const virtualPlaylistId = `library_redownload_${resolvedId}`; + const playlistName = `[${artistName || 'Unknown'}] ${albumData.name}`; + + const enrichedTracks = albumData.tracks.map(track => ({ + ...track, + album: { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks + } + })); + + const enhancedArtist = artistDetailPageState.enhancedData?.artist; + const artistObject = { + id: artistDetailPageState.currentArtistId || `library_${artistName || album.id}`, + name: artistName || '', + image_url: enhancedArtist?.thumb_url || '' + }; + const fullAlbumObject = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + image_url: albumData.images?.[0]?.url || null, + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumData.artists || [{ name: artistName || '' }] + }; + + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, playlistName, enrichedTracks, fullAlbumObject, artistObject, true + ); + + // Register download bubble so it appears on the dashboard + const albumType = fullAlbumObject.album_type || 'album'; + registerArtistDownload(artistObject, fullAlbumObject, virtualPlaylistId, albumType); + + } catch (error) { + console.error('Redownload album error:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = origText; } + } +} + +// --- Issue Action: Add to Wishlist --- +async function issueAddToWishlist(spotifyAlbumId, artistName, albumName) { + const btn = document.getElementById('issue-action-wishlist'); + if (!spotifyAlbumId && (!artistName || !albumName)) { + showToast('No album ID or artist/album info available', 'warning'); + return; + } + try { + if (btn) { btn.disabled = true; btn.textContent = 'Loading...'; } + + let response; + if (spotifyAlbumId) { + const albumParams = new URLSearchParams({ name: albumName || '', artist: artistName || '' }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(spotifyAlbumId)}?${albumParams}`); + } else { + // No Spotify album ID — search for the album by name + const query = `${artistName} ${albumName}`; + const searchResp = await fetch('/api/enhanced-search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ query }) + }); + if (!searchResp.ok) throw new Error('Album search failed'); + const searchData = await searchResp.json(); + const foundAlbum = searchData.spotify_albums?.[0]; + if (!foundAlbum || !foundAlbum.id) { + showToast(`Could not find "${albumName}" by ${artistName}`, 'warning'); + return; + } + const albumParams = new URLSearchParams({ name: foundAlbum.name || albumName, artist: foundAlbum.artist || artistName }); + response = await fetch(`/api/spotify/album/${encodeURIComponent(foundAlbum.id)}?${albumParams}`); + } + + if (!response.ok) throw new Error(`Failed to load album: ${response.status}`); + + const albumData = await response.json(); + if (!albumData || !albumData.tracks || albumData.tracks.length === 0) { + showToast(`No tracks available for "${albumName}"`, 'warning'); + return; + } + + // Close issue modal and open wishlist modal + closeIssueDetailModal(); + + const albumArtists = albumData.artists || [{ name: artistName }]; + const album = { + name: albumData.name, + id: albumData.id, + album_type: albumData.album_type || 'album', + images: albumData.images || [], + release_date: albumData.release_date, + total_tracks: albumData.total_tracks, + artists: albumArtists + }; + const artist = { id: null, name: artistName }; + + // Enrich tracks with album metadata — use album artist for wishlist grouping + // (Spotify returns per-track artists which can differ on compilations/soundtracks) + const tracks = albumData.tracks.map(t => ({ + ...t, + artists: albumArtists, + album: album + })); + + await openAddToWishlistModal(album, artist, tracks, albumData.album_type || 'album'); + + } catch (error) { + console.error('Issue wishlist error:', error); + showToast(`Error: ${error.message}`, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' Add to Wishlist'; } + } +} + +async function updateIssueStatus(issueId, newStatus) { + const payload = { status: newStatus }; + + // Include admin response if present + const responseInput = document.getElementById('issue-detail-response-input'); + if (responseInput) { + payload.admin_response = responseInput.value.trim(); + } + + try { + const resp = await fetch(`/api/issues/${issueId}`, { + method: 'PUT', + headers: _issueHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + const data = await resp.json(); + if (data.success) { + showToast(`Issue ${newStatus === 'resolved' ? 'resolved' : newStatus === 'dismissed' ? 'dismissed' : newStatus === 'in_progress' ? 'marked in progress' : 'reopened'}`, 'success'); + closeIssueDetailModal(); + // Refresh if on issues page + const issuesPage = document.getElementById('issues-page'); + if (issuesPage && issuesPage.classList.contains('active')) { + loadIssuesPage(); + } + loadIssuesBadge(); + } else { + showToast(data.error || 'Failed to update issue', 'error'); + } + } catch (e) { + console.error('Failed to update issue:', e); + showToast('Failed to update issue', 'error'); + } +} + +async function deleteIssue(issueId) { + if (!confirm('Are you sure you want to delete this issue?')) return; + try { + const resp = await fetch(`/api/issues/${issueId}`, { method: 'DELETE', headers: _issueHeaders() }); + const data = await resp.json(); + if (data.success) { + showToast('Issue deleted', 'success'); + closeIssueDetailModal(); + const issuesPage = document.getElementById('issues-page'); + if (issuesPage && issuesPage.classList.contains('active')) { + loadIssuesPage(); + } + loadIssuesBadge(); + } else { + showToast(data.error || 'Failed to delete issue', 'error'); + } + } catch (e) { + console.error('Failed to delete issue:', e); + showToast('Failed to delete issue', 'error'); + } +} + +function closeIssueDetailModal() { + const overlay = document.getElementById('issue-detail-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +async function loadIssuesBadge() { + try { + const resp = await fetch('/api/issues/counts', { headers: _issueHeaders() }); + const data = await resp.json(); + if (!data.success) return; + const badge = document.getElementById('issues-nav-badge'); + if (badge) { + const openCount = data.counts.open || 0; + badge.textContent = openCount; + badge.classList.toggle('hidden', openCount === 0); + } + } catch (e) { } +} + +// ===== END ISSUES PAGE ===== + +// --- Helpers --- + +function _esc(str) { + if (!str) return ''; + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; +} + +function _escAttr(str) { + if (!str) return ''; + return String(str).replace(/&/g, '&').replace(/"/g, '"').replace(/'/g, ''').replace(//g, '>'); +} + +// ===== ENHANCE QUALITY MODAL ===== + +let _enhanceQualityData = null; +let _enhanceArtistId = null; + +const ENHANCE_TIER_MAP = { + 'lossless': { num: 1, label: 'Lossless', cssClass: 'lossless' }, + 'high_lossy': { num: 2, label: 'High Lossy', cssClass: 'high-lossy' }, + 'standard_lossy': { num: 3, label: 'Standard Lossy', cssClass: 'standard-lossy' }, + 'low_lossy': { num: 4, label: 'Low Lossy', cssClass: 'low-lossy' }, + 'unknown': { num: 999, label: 'Unknown', cssClass: 'unknown' }, +}; + +async function checkArtistEnhanceEligibility(artistId) { + const btn = document.getElementById('library-artist-enhance-btn'); + if (!btn) return; + btn.classList.add('hidden'); + _enhanceArtistId = artistId; + + try { + const resp = await fetch(`/api/library/artist/${artistId}/quality-analysis`); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) return; + + _enhanceQualityData = data; + + // Show button if any tracks are below the user's min acceptable tier + const minTier = data.min_acceptable_tier || 1; + const belowCount = data.tracks.filter(t => t.tier_num > minTier).length; + if (belowCount > 0) { + btn.classList.remove('hidden'); + btn.querySelector('.enhance-text').textContent = `Enhance Quality (${belowCount})`; + } + } catch (e) { + console.debug('Enhance eligibility check failed:', e); + } +} + +async function playArtistRadio() { + try { + const artistId = artistDetailPageState.currentArtistId; + const artistName = artistDetailPageState.currentArtistName || ''; + if (!artistId) { + showToast('No artist selected', 'error'); + return; + } + + // Get tracks from this artist's library + const resp = await fetch(`/api/library/artist/${artistId}/enhanced`); + if (!resp.ok) throw new Error('Failed to load artist data'); + const data = await resp.json(); + if (!data.success) throw new Error(data.error || 'Failed'); + + // Collect all tracks with file paths + const allTracks = []; + for (const album of (data.albums || [])) { + for (const track of (album.tracks || [])) { + if (track.file_path) { + allTracks.push({ track, album }); + } + } + } + + if (!allTracks.length) { + showToast('No playable tracks found for this artist', 'error'); + return; + } + + // Pick a random track + const random = allTracks[Math.floor(Math.random() * allTracks.length)]; + const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; + + // Clear existing queue and disable radio before starting fresh + npRadioMode = false; + clearQueue(); + if (audioPlayer && !audioPlayer.paused) { + audioPlayer.pause(); + } + + // Play the track first, then enable radio mode after a short delay + // so currentTrack is set and the radio queue fill triggers + playLibraryTrack({ + id: random.track.id, + title: random.track.title, + file_path: random.track.file_path, + bitrate: random.track.bitrate, + artist_id: artistId, + album_id: random.album.id, + }, random.album.title || '', artistName); + + // Enable radio mode after track starts loading + setTimeout(() => { + npRadioMode = true; + const radioBtn = document.querySelector('.np-radio-btn'); + if (radioBtn) radioBtn.classList.add('active'); + }, 1000); + + showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); + } catch (e) { + showToast(`Failed to start artist radio: ${e.message}`, 'error'); + } +} + +function openEnhanceQualityModal() { + if (!_enhanceQualityData) return; + const data = _enhanceQualityData; + + // Remove existing modal if any + const existing = document.getElementById('enhance-quality-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'enhance-quality-overlay'; + overlay.className = 'enhance-modal-overlay'; + overlay.onclick = (e) => { if (e.target === overlay) closeEnhanceQualityModal(); }; + + const minTier = data.min_acceptable_tier || 1; + const summary = data.quality_summary || {}; + + overlay.innerHTML = ` +
+
+

⚡ Enhance Quality — ${_esc(data.artist_name)}

+ +
+
+ ${_buildEnhanceSummaryChips(summary)} +
+
+
+ + +
+
+ + + 0 selected +
+
+
+ + + + + + + + + + + + + +
#TitleAlbumFormatBitrate
+
+ +
+ `; + + document.body.appendChild(overlay); + renderEnhanceTrackRows(minTier); +} + +function _buildEnhanceSummaryChips(summary) { + const chips = [ + { key: 'lossless', label: 'FLAC', cssClass: 'lossless' }, + { key: 'high_lossy', label: 'OGG/Opus', cssClass: 'high-lossy' }, + { key: 'standard_lossy', label: 'M4A/AAC', cssClass: 'standard-lossy' }, + { key: 'low_lossy', label: 'MP3/WMA', cssClass: 'low-lossy' }, + ]; + return chips + .filter(c => (summary[c.key] || 0) > 0) + .map(c => ` +
+ ${summary[c.key]} + ${c.label} +
+ `).join(''); +} + +function renderEnhanceTrackRows(thresholdTier) { + const tbody = document.getElementById('enhance-track-tbody'); + if (!tbody || !_enhanceQualityData) return; + + const tracks = _enhanceQualityData.tracks; + // Sort: below-threshold first, then by album + track number + const sorted = [...tracks].sort((a, b) => { + const aBt = a.tier_num > thresholdTier ? 0 : 1; + const bBt = b.tier_num > thresholdTier ? 0 : 1; + if (aBt !== bBt) return aBt - bBt; + const albumCmp = (a.album_title || '').localeCompare(b.album_title || ''); + if (albumCmp !== 0) return albumCmp; + return (a.disc_number || 1) * 1000 + (a.track_number || 0) - ((b.disc_number || 1) * 1000 + (b.track_number || 0)); + }); + + tbody.innerHTML = sorted.map(track => { + const isBelow = track.tier_num > thresholdTier; + const tierInfo = ENHANCE_TIER_MAP[track.tier_name] || ENHANCE_TIER_MAP['unknown']; + const bitrateStr = track.bitrate ? `${track.bitrate} kbps` : '-'; + return ` + + + ${track.track_number || '-'} + ${_esc(track.title)} + ${_esc(track.album_title)} + ${_esc(track.format)} + ${bitrateStr} + + `; + }).join(''); + + updateEnhanceSelectedCount(); +} + +function updateEnhanceThreshold(tierNum) { + const rows = document.querySelectorAll('.enhance-track-row'); + rows.forEach(row => { + const trackTier = parseInt(row.dataset.tier); + const isBelow = trackTier > tierNum; + const cb = row.querySelector('.enhance-track-check'); + + row.classList.toggle('below-threshold', isBelow); + row.classList.toggle('above-threshold', !isBelow); + if (cb) cb.checked = isBelow; + }); + updateEnhanceSelectedCount(); +} + +function enhanceSelectAll(select) { + const thresholdTier = parseInt(document.getElementById('enhance-tier-dropdown')?.value || '1'); + const checks = document.querySelectorAll('.enhance-track-check'); + checks.forEach(cb => { + const row = cb.closest('.enhance-track-row'); + const trackTier = parseInt(row?.dataset.tier || '999'); + if (select) { + cb.checked = trackTier > thresholdTier; + } else { + cb.checked = false; + } + }); + updateEnhanceSelectedCount(); +} + +function updateEnhanceSelectedCount() { + const checks = document.querySelectorAll('.enhance-track-check:checked'); + const count = checks.length; + const countEl = document.getElementById('enhance-selected-count'); + const submitBtn = document.getElementById('enhance-submit-btn'); + + if (countEl) countEl.textContent = `${count} selected`; + if (submitBtn) { + submitBtn.textContent = `⚡ Enhance ${count} Track${count !== 1 ? 's' : ''}`; + submitBtn.disabled = count === 0; + } +} + +async function submitEnhanceQuality() { + const checks = document.querySelectorAll('.enhance-track-check:checked'); + const trackIds = []; + checks.forEach(cb => { + const row = cb.closest('.enhance-track-row'); + if (row?.dataset.trackId) trackIds.push(row.dataset.trackId); + }); + + if (trackIds.length === 0) return; + + const submitBtn = document.getElementById('enhance-submit-btn'); + const footerInfo = document.getElementById('enhance-footer-info'); + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = 'Processing...'; + } + if (footerInfo) footerInfo.textContent = 'Matching tracks to Spotify and adding to wishlist...'; + + try { + const resp = await fetch(`/api/library/artist/${_enhanceArtistId}/enhance`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds }) + }); + + const result = await resp.json(); + + if (result.success) { + const msg = `${result.enhanced_count} track${result.enhanced_count !== 1 ? 's' : ''} queued for enhancement`; + if (footerInfo) footerInfo.textContent = msg; + + showToast(msg + (result.failed_count > 0 ? ` (${result.failed_count} failed)` : ''), 'success'); + + // Update button count + const enhBtn = document.getElementById('library-artist-enhance-btn'); + if (enhBtn && result.enhanced_count > 0) { + const remaining = trackIds.length - result.enhanced_count; + if (remaining <= 0) { + enhBtn.classList.add('hidden'); + } + } + + if (submitBtn) { + submitBtn.textContent = '✅ Done'; + submitBtn.disabled = true; + } + + // Auto-close after short delay + setTimeout(() => closeEnhanceQualityModal(), 1500); + } else { + throw new Error(result.error || 'Enhancement failed'); + } + } catch (e) { + console.error('Enhance quality error:', e); + showToast(`Enhancement failed: ${e.message}`, 'error'); + if (submitBtn) { + submitBtn.textContent = `⚡ Enhance ${trackIds.length} Tracks`; + submitBtn.disabled = false; + } + if (footerInfo) footerInfo.textContent = ''; + } +} + +function closeEnhanceQualityModal() { + const overlay = document.getElementById('enhance-quality-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + setTimeout(() => overlay.remove(), 300); + } +} + +// Global exports +window.openEnhanceQualityModal = openEnhanceQualityModal; +window.closeEnhanceQualityModal = closeEnhanceQualityModal; +window.updateEnhanceThreshold = updateEnhanceThreshold; +window.enhanceSelectAll = enhanceSelectAll; +window.updateEnhanceSelectedCount = updateEnhanceSelectedCount; +window.submitEnhanceQuality = submitEnhanceQuality; + +// ===== END ENHANCE QUALITY MODAL ===== + diff --git a/webui/static/style.css b/webui/static/style.css index 0865189e..054e9d94 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -255,11 +255,15 @@ body { .sidebar-header { min-height: 115px; - background: linear-gradient(180deg, + /* Opaque base layered under the accent gradient so nav items scrolling + past the sticky header don't bleed through the translucent stops. */ + background: + linear-gradient(180deg, rgba(var(--accent-rgb), 0.14) 0%, rgba(var(--accent-rgb), 0.08) 30%, rgba(var(--accent-rgb), 0.03) 70%, - rgba(18, 18, 18, 1) 100%); + rgba(18, 18, 18, 1) 100%), + rgb(18, 18, 18); border-bottom: 1px solid rgba(255, 255, 255, 0.08); border-top-right-radius: 20px; padding: 20px 24px; @@ -269,6 +273,10 @@ body { gap: 8px; position: sticky; top: 0; + /* Explicitly lift above the nav buttons. `.sidebar > *` sets z-index 1 on + every sidebar child, so without this the sticky header paints at the + same stacking level as the nav and loses to it on DOM order. */ + z-index: 2; overflow: hidden; flex-shrink: 0; @@ -4255,8 +4263,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { /* Main Layout: Replicates QSplitter */ .downloads-content { display: grid; - grid-template-columns: 1fr 370px; - /* Left panel is flexible, right is fixed */ + grid-template-columns: 1fr; gap: 24px; height: 100%; /* Fill parent .page content area */ @@ -5329,6 +5336,38 @@ body.helper-mode-active #dashboard-activity-feed:hover { GLOBAL SEARCH BAR — Spotlight-style search from anywhere ================================================================================== */ +/* Ambient glow under the global search bar — a radial gradient that emanates + from the bar's position and tapers out toward the window corners. Pointer + events disabled so it never intercepts clicks; hidden on /search where the + bar itself is hidden. */ +.gsearch-aura { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 260px; + pointer-events: none; + z-index: 99990; /* below the bar (99998) but above most page content */ + opacity: 0.55; + transition: opacity 0.4s ease, background 0.4s ease; + background: + radial-gradient(ellipse 620px 230px at 50% 100%, + rgba(var(--accent-rgb), 0.22) 0%, + rgba(var(--accent-rgb), 0.10) 32%, + rgba(var(--accent-rgb), 0.03) 62%, + transparent 85%); +} +.gsearch-aura.hidden { display: none; } +.gsearch-aura.active { + opacity: 1; + background: + radial-gradient(ellipse 820px 280px at 50% 100%, + rgba(var(--accent-rgb), 0.40) 0%, + rgba(var(--accent-rgb), 0.18) 28%, + rgba(var(--accent-rgb), 0.05) 58%, + transparent 85%); +} + .gsearch-bar { position: fixed; bottom: 24px; @@ -5427,6 +5466,22 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .gsearch-results.visible { display: flex; animation: gsearchSlideUp 0.2s ease; } +/* Stable results-panel structure built by downloads.js _doInit: the source + row and fallback banner keep their natural height; #gsearch-body grows + to fill what's left of the 60vh cap and scrolls when content overflows. + Without the flex:1 + overflow on #gsearch-body, content past the cap + was clipped with no scrollbar — the source-picker refactor introduced + the structure but not the accompanying CSS. */ +#gsearch-source-row { flex-shrink: 0; } +#gsearch-fallback-banner { flex-shrink: 0; } +#gsearch-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; +} +#gsearch-body::-webkit-scrollbar { width: 4px; } +#gsearch-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; } + @keyframes gsearchSlideUp { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } @@ -5472,6 +5527,195 @@ body.helper-mode-active #dashboard-activity-feed:hover { .gsearch-tab.active { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); border-color: rgba(var(--accent-rgb), 0.2); } .gsearch-tab:hover:not(.active) { background: rgba(255,255,255,0.06); } +/* Source icon row in the global widget. The popover itself is already a + glass panel, so this row is just a transparent flex strip — no double + border/background. justify-content: center keeps the chips grouped + instead of drifting to the left when they don't fill the popover width. */ +.gsearch-source-row { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: visible; + justify-content: center; + gap: 6px; + padding: 12px 14px 8px; + flex-shrink: 0; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} +.gsearch-source-row::-webkit-scrollbar { height: 4px; } +.gsearch-source-row::-webkit-scrollbar-track { background: transparent; } +.gsearch-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 2px; } + +.gsearch-source-icon { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + min-width: 72px; + padding: 8px 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + font-family: inherit; + font-size: 10px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, + color 0.18s ease, box-shadow 0.18s ease; +} +.gsearch-source-icon:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0.04) 100%); + color: #fff; + border-color: rgba(255, 255, 255, 0.18); + transform: translateY(-1px); + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.25); +} +.gsearch-source-icon:active { transform: translateY(0) scale(0.97); } + +.gsearch-source-icon-glyph { + font-size: 20px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.25)); +} +.gsearch-source-icon-glyph img { + width: 22px; + height: 22px; + object-fit: contain; + display: block; +} +.gsearch-source-icon-label { font-size: 10px; letter-spacing: 0.02em; font-weight: 600; } +.gsearch-source-icon.active .gsearch-source-icon-label { font-weight: 700; } + +.gsearch-source-icon.active { + transform: scale(1.04); + border-color: currentColor; +} +.gsearch-source-icon[data-source="spotify"].active { + color: #1db954; + background: linear-gradient(180deg, rgba(29, 185, 84, 0.28) 0%, rgba(29, 185, 84, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.35), 0 4px 16px rgba(29, 185, 84, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="itunes"].active { + color: #fc3c44; + background: linear-gradient(180deg, rgba(252, 60, 68, 0.28) 0%, rgba(252, 60, 68, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(252, 60, 68, 0.35), 0 4px 16px rgba(252, 60, 68, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="deezer"].active { + color: #a238ff; + background: linear-gradient(180deg, rgba(162, 56, 255, 0.28) 0%, rgba(162, 56, 255, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(162, 56, 255, 0.35), 0 4px 16px rgba(162, 56, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="discogs"].active { + color: #D4A574; + background: linear-gradient(180deg, rgba(212, 165, 116, 0.28) 0%, rgba(212, 165, 116, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(212, 165, 116, 0.35), 0 4px 16px rgba(212, 165, 116, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="hydrabase"].active { + color: #00b4d8; + background: linear-gradient(180deg, rgba(0, 180, 216, 0.28) 0%, rgba(0, 180, 216, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(0, 180, 216, 0.35), 0 4px 16px rgba(0, 180, 216, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="musicbrainz"].active { + color: #BA3358; + background: linear-gradient(180deg, rgba(186, 51, 88, 0.28) 0%, rgba(186, 51, 88, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(186, 51, 88, 0.35), 0 4px 16px rgba(186, 51, 88, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="youtube_videos"].active { + color: #ff4444; + background: linear-gradient(180deg, rgba(255, 0, 0, 0.28) 0%, rgba(255, 0, 0, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.35), 0 4px 16px rgba(255, 0, 0, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon[data-source="soulseek"].active { + color: #fff; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.06) 100%); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32), 0 4px 16px rgba(255, 255, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +.gsearch-source-icon.cached::after { + content: ''; + position: absolute; + top: 5px; + right: 6px; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 4px currentColor; + animation: gsearch-source-cache-pulse 2.4s ease-in-out infinite; +} +@keyframes gsearch-source-cache-pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.15); } +} +.gsearch-source-icon.loading .gsearch-source-icon-glyph { + animation: gsearch-source-loading-spin 1.2s linear infinite; + opacity: 0.7; +} +@keyframes gsearch-source-loading-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} +.gsearch-source-icon.fallback-warning { + border-color: rgba(250, 176, 5, 0.5); + box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25); +} + +/* Same unconfigured treatment as the Search page icons. */ +.gsearch-source-icon.unconfigured { + opacity: 0.42; + filter: grayscale(0.7); + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); +} +.gsearch-source-icon.unconfigured:hover { + opacity: 0.75; + filter: grayscale(0.35); + transform: none; + box-shadow: none; + border-color: rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon.unconfigured.active { + background: rgba(255, 255, 255, 0.02); + box-shadow: none; + transform: none; +} + +/* Flash highlight on the Settings service card after scrolling to it via + the picker. Two and a half seconds of gentle accent pulse so the user's + eye catches the card. */ +.stg-service.stg-service-flash { + animation: stg-service-flash-anim 2.2s ease-out; +} +@keyframes stg-service-flash-anim { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 35% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.25); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + +.gsearch-fallback-banner { + padding: 6px 14px; + margin: 0 12px 6px; + border-radius: 6px; + background: rgba(250, 176, 5, 0.12); + border: 1px solid rgba(250, 176, 5, 0.3); + color: #fab005; + font-size: 10.5px; + font-weight: 500; +} + /* Section headers */ .gsearch-section-header { font-size: 10px; @@ -7022,11 +7266,32 @@ body.helper-mode-active #dashboard-activity-feed:hover { font-weight: 600; padding: 12px 12px !important; cursor: pointer; - transition: all 0.2s ease; + transition: opacity 0.15s ease, background 0.2s ease, border-color 0.2s ease, transform 0.15s ease; align-self: flex-start; width: fit-content !important; height: fit-content !important; + /* Reveal on hover only — keeps the card clean until the user actually + wants to act. Space is reserved (opacity + pointer-events, not display) + so the layout doesn't jump when the button shows. */ + opacity: 0; + pointer-events: none; + transform: translateX(4px); +} +.download-item:hover .download-item__cancel-btn, +.download-item:focus-within .download-item__cancel-btn { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +/* Touch devices have no hover — keep cancel visible so it's reachable. */ +@media (hover: none) { + .download-item__cancel-btn { + opacity: 1; + pointer-events: auto; + transform: none; + } } .download-item__cancel-btn:hover { @@ -14808,16 +15073,17 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .youtube-discovery-modal .discovery-table th { - background: rgba(255, 255, 255, 0.1); + background: rgba(17, 17, 20, 0.96); color: #ffffff; font-weight: 600; padding: 12px 8px; text-align: left; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: 1px solid rgba(255, 255, 255, 0.18); font-size: 14px; position: sticky; top: 0; - z-index: 1; + z-index: 5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -22836,6 +23102,39 @@ body.helper-mode-active #dashboard-activity-feed:hover { max-width: 400px; } +/* Hand-off CTA shown in the library empty state when the user's search + returns no library matches — offers to run the same query against the + configured metadata source on the /search page. */ +.library-empty-search-cta { + display: inline-flex; + align-items: center; + gap: 10px; + margin-top: 8px; + padding: 12px 20px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.22), rgba(var(--accent-rgb), 0.08)); + border: 1px solid rgba(var(--accent-rgb), 0.35); + border-radius: 10px; + color: rgb(var(--accent-light-rgb, var(--accent-rgb))); + font-family: inherit; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; +} +.library-empty-search-cta:hover { + transform: translateY(-1px); + border-color: rgba(var(--accent-rgb), 0.55); + box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.22); +} +.library-empty-search-cta:active { transform: translateY(0); } +.library-empty-search-cta-icon { font-size: 16px; } +.library-empty-search-cta-arrow { + font-weight: 700; + transition: transform 0.15s ease; +} +.library-empty-search-cta:hover .library-empty-search-cta-arrow { transform: translateX(3px); } +#library-empty-search-cta-query { color: #fff; } + /* Pagination */ .library-pagination { display: flex; @@ -23290,8 +23589,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { .releases-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)); + gap: 20px; padding: 8px 0; } @@ -23515,8 +23814,8 @@ body.helper-mode-active #dashboard-activity-feed:hover { } .releases-grid { - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); + gap: 18px; } .release-card { @@ -32810,10 +33109,76 @@ div.artist-hero-badge { } /* ========================================= */ -/* SEARCH MODE TOGGLE SYSTEM */ +/* ========================================= */ +/* SEARCH SOURCE PICKER (replaces Enhanced/Basic toggle) */ /* ========================================= */ -/* Toggle Container */ +.search-source-picker-container { + display: flex; + justify-content: center; + align-items: center; + gap: 12px; + margin: 20px 0; +} + +.search-source-picker-label { + color: rgba(255, 255, 255, 0.7); + font-family: 'Segoe UI', sans-serif; + font-size: 13px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.search-source-picker-wrapper { + position: relative; + display: inline-flex; + align-items: center; +} + +.search-source-picker-select { + appearance: none; + -webkit-appearance: none; + background: rgba(30, 30, 30, 0.8); + border: 1px solid rgba(64, 64, 64, 0.4); + border-radius: 10px; + color: #ffffff; + padding: 10px 40px 10px 16px; + font-family: 'Segoe UI', sans-serif; + font-size: 14px; + font-weight: 500; + cursor: pointer; + min-width: 220px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.search-source-picker-select:hover { + border-color: rgba(138, 43, 226, 0.6); +} + +.search-source-picker-select:focus { + outline: none; + border-color: rgba(138, 43, 226, 0.9); + box-shadow: 0 2px 10px rgba(138, 43, 226, 0.35); +} + +.search-source-picker-select option { + background: #1e1e1e; + color: #ffffff; +} + +.search-source-picker-caret { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; +} + +/* Legacy toggle (kept for any other references, but unused by the Search page) */ .search-mode-toggle-container { display: flex; justify-content: center; @@ -32989,38 +33354,6 @@ div.artist-hero-badge { color: #fff; } -.enhanced-search-btn { - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 10px; - padding: 10px 20px; - color: rgba(255, 255, 255, 0.8); - font-family: 'Segoe UI', sans-serif; - font-size: 13px; - font-weight: 600; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - flex-shrink: 0; -} - -.enhanced-search-btn:hover { - background: rgba(255, 255, 255, 0.12); - color: #fff; - transform: none; - box-shadow: none; -} - -.enhanced-search-btn:active { - transform: scale(0.97); -} - -.btn-icon { - font-size: 14px; -} - /* Enhanced Search Status */ .enhanced-search-status { display: flex; @@ -33227,12 +33560,6 @@ div.artist-hero-badge { gap: 8px; } - .enhanced-search-btn { - width: 100%; - justify-content: center; - padding: 10px 16px; - } - #enhanced-search-input { font-size: 14px; } @@ -33253,11 +33580,6 @@ div.artist-hero-badge { margin-right: 6px; } - .enhanced-search-btn { - padding: 9px 14px; - font-size: 12px; - } - /* Better album/track results on mobile */ .album-result-item { margin-bottom: 8px; @@ -33418,6 +33740,212 @@ div.artist-hero-badge { .enh-source-tab.enh-tab-youtube.active { background: rgba(255, 0, 0, 0.2); color: #ff4444; } .enh-source-tab.enh-tab-musicbrainz.active { background: rgba(186, 51, 88, 0.2); color: #BA3358; } +/* ── Source picker icon row (replaces dropdown + post-search tabs) ── */ +.enh-source-row { + display: flex; + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: visible; + gap: 10px; + padding: 12px 14px; + margin: 10px 0 14px; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.2) transparent; + /* Frosted-glass panel that holds the icons together visually. */ + background: linear-gradient(180deg, rgba(255, 255, 255, 0.035) 0%, rgba(255, 255, 255, 0.015) 100%); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 14px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04), 0 2px 10px rgba(0, 0, 0, 0.15); + /* Don't compress when the parent flex container is tight. */ + flex-shrink: 0; +} +.enh-source-row::-webkit-scrollbar { height: 6px; } +.enh-source-row::-webkit-scrollbar-track { background: transparent; } +.enh-source-row::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.15); border-radius: 3px; } + +.enh-source-icon { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 90px; + padding: 12px 14px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.05) 0%, rgba(255, 255, 255, 0.02) 100%); + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease, + color 0.18s ease, box-shadow 0.18s ease; + font-family: inherit; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; + flex-shrink: 0; +} +.enh-source-icon:hover { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.09) 0%, rgba(255, 255, 255, 0.04) 100%); + color: #fff; + border-color: rgba(255, 255, 255, 0.18); + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.25); +} +.enh-source-icon:active { transform: translateY(0) scale(0.97); } + +.enh-source-icon-glyph { + font-size: 26px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25)); +} +.enh-source-icon-glyph img { + width: 30px; + height: 30px; + object-fit: contain; + display: block; +} +.enh-source-icon-label { + font-size: 11.5px; + letter-spacing: 0.02em; + font-weight: 600; +} +.enh-source-icon.active .enh-source-icon-label { font-weight: 700; } + +/* Active state — brand-coloured gradient + outer glow. Each source gets its + own palette. `transform: scale(1.03)` lifts the chip slightly above its + siblings. */ +.enh-source-icon.active { + transform: scale(1.03); + border-color: currentColor; +} +.enh-source-icon[data-source="spotify"].active { + color: #1db954; + background: linear-gradient(180deg, rgba(29, 185, 84, 0.28) 0%, rgba(29, 185, 84, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.35), 0 6px 22px rgba(29, 185, 84, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="itunes"].active { + color: #fc3c44; + background: linear-gradient(180deg, rgba(252, 60, 68, 0.28) 0%, rgba(252, 60, 68, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(252, 60, 68, 0.35), 0 6px 22px rgba(252, 60, 68, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="deezer"].active { + color: #a238ff; + background: linear-gradient(180deg, rgba(162, 56, 255, 0.28) 0%, rgba(162, 56, 255, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(162, 56, 255, 0.35), 0 6px 22px rgba(162, 56, 255, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="discogs"].active { + color: #D4A574; + background: linear-gradient(180deg, rgba(212, 165, 116, 0.28) 0%, rgba(212, 165, 116, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(212, 165, 116, 0.35), 0 6px 22px rgba(212, 165, 116, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="hydrabase"].active { + color: #00b4d8; + background: linear-gradient(180deg, rgba(0, 180, 216, 0.28) 0%, rgba(0, 180, 216, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(0, 180, 216, 0.35), 0 6px 22px rgba(0, 180, 216, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="musicbrainz"].active { + color: #BA3358; + background: linear-gradient(180deg, rgba(186, 51, 88, 0.28) 0%, rgba(186, 51, 88, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(186, 51, 88, 0.35), 0 6px 22px rgba(186, 51, 88, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="youtube_videos"].active { + color: #ff4444; + background: linear-gradient(180deg, rgba(255, 0, 0, 0.28) 0%, rgba(255, 0, 0, 0.08) 100%); + box-shadow: 0 0 0 1px rgba(255, 0, 0, 0.35), 0 6px 22px rgba(255, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.12); +} +.enh-source-icon[data-source="soulseek"].active { + color: #fff; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.22) 0%, rgba(255, 255, 255, 0.06) 100%); + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.32), 0 6px 22px rgba(255, 255, 255, 0.15), + inset 0 1px 0 rgba(255, 255, 255, 0.18); +} + +/* Cache dot — brand-coloured pulse on icons that have results cached for + the current query. */ +.enh-source-icon.cached::after { + content: ''; + position: absolute; + top: 7px; + right: 9px; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 6px currentColor; + animation: enh-source-cache-pulse 2.4s ease-in-out infinite; +} +@keyframes enh-source-cache-pulse { + 0%, 100% { opacity: 0.6; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.15); } +} + +.enh-source-icon.loading .enh-source-icon-glyph { + animation: enh-source-loading-spin 1.2s linear infinite; + opacity: 0.7; +} +@keyframes enh-source-loading-spin { + from { transform: rotate(0); } + to { transform: rotate(360deg); } +} + +.enh-source-icon.fallback-warning { + border-color: rgba(250, 176, 5, 0.5); + box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25); +} + +/* Unconfigured — no credentials saved for this source. The chip still + clicks (redirects to Settings → Connections), but looks muted so the + user's eye is drawn to the sources that actually work. */ +.enh-source-icon.unconfigured { + opacity: 0.42; + filter: grayscale(0.7); + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); +} +.enh-source-icon.unconfigured:hover { + opacity: 0.75; + filter: grayscale(0.35); + transform: none; + box-shadow: none; + border-color: rgba(255, 255, 255, 0.12); +} +/* Kill brand glow / active gradient if an unconfigured source is somehow + marked active (defensive — setActiveSource bails before this normally). */ +.enh-source-icon.unconfigured.active { + background: rgba(255, 255, 255, 0.02); + box-shadow: none; + transform: none; +} + +/* Rate-limit fallback banner above the enhanced results. */ +.enh-fallback-banner { + padding: 8px 12px; + margin-bottom: 10px; + border-radius: 8px; + background: rgba(250, 176, 5, 0.12); + border: 1px solid rgba(250, 176, 5, 0.3); + color: #fab005; + font-size: 12px; + font-weight: 500; +} +.enh-fallback-banner.hidden { display: none; } + /* Music Video Grid */ .enh-video-grid { display: grid; @@ -43302,6 +43830,9 @@ a.enhanced-id-badge:visited { .enhanced-artist-meta-actions { display: flex; gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; } .enhanced-meta-save-btn, .enhanced-meta-cancel-btn { @@ -44140,6 +44671,272 @@ textarea.enhanced-meta-field-input { cursor: not-allowed; } +/* ═══════════════════════════════════════════════════════════════════════════ + REORGANIZE STATUS PANEL — sits at the start of .enhanced-artist-meta-actions + ═══════════════════════════════════════════════════════════════════════════ */ + +.reorganize-status-panel { + flex: 1 1 100%; + order: -1; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 8px 12px; + font-size: 12px; + color: rgba(255, 255, 255, 0.85); + transition: background 0.2s ease, border-color 0.2s ease; + min-width: 0; +} +.reorganize-status-panel.hidden { + display: none; +} + +.reorg-panel-compact { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + cursor: pointer; + user-select: none; + min-width: 0; +} +.reorg-panel-compact-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1 1 auto; +} +.reorg-panel-compact-right { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.reorg-panel-active-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.reorg-panel-active-text strong { + color: #ffffff; + font-weight: 600; +} +.reorg-panel-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid rgba(var(--accent-rgb), 0.25); + border-top-color: rgb(var(--accent-rgb)); + border-radius: 50%; + flex-shrink: 0; + animation: reorgPanelSpin 0.9s linear infinite; +} +@keyframes reorgPanelSpin { + to { transform: rotate(360deg); } +} +.reorg-panel-recent-icon { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} +.reorg-panel-recent-icon.recent-ok { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.4); } +.reorg-panel-recent-icon.recent-warn { background: #facc15; box-shadow: 0 0 6px rgba(250, 204, 21, 0.4); } + +.reorg-panel-queue-badge { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + border: 1px solid rgba(var(--accent-rgb), 0.3); + padding: 2px 8px; + border-radius: 10px; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.4px; +} +.reorg-panel-chevron { + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + transition: color 0.2s ease; +} +.reorg-panel-compact:hover .reorg-panel-chevron { + color: rgba(255, 255, 255, 0.85); +} + +.reorg-panel-expanded { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 10px; +} + +.reorg-panel-active-card { + background: rgba(var(--accent-rgb), 0.06); + border: 1px solid rgba(var(--accent-rgb), 0.18); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} +.reorg-panel-active-title { + font-weight: 600; + color: #ffffff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reorg-panel-cross-artist { + margin-left: 6px; + color: rgba(255, 255, 255, 0.55); + font-weight: 400; + font-size: 11px; + font-style: italic; +} +.reorg-panel-progress-track { + width: 100%; + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; +} +.reorg-panel-progress-fill { + height: 100%; + background: linear-gradient(90deg, + rgb(var(--accent-rgb)) 0%, + rgb(var(--accent-light-rgb)) 100%); + border-radius: 2px; + transition: width 0.4s ease; +} +.reorg-panel-active-meta { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + color: rgba(255, 255, 255, 0.65); + font-size: 11px; +} +.reorg-panel-current-track { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1 1 auto; + color: rgba(255, 255, 255, 0.5); + font-style: italic; +} +.reorg-panel-counters { + display: inline-flex; + gap: 8px; + flex-shrink: 0; +} +.reorg-panel-counters .ok { color: #4ade80; } +.reorg-panel-counters .warn { color: #facc15; } +.reorg-panel-counters .fail { color: #f87171; } + +.reorg-panel-section-header { + display: flex; + align-items: center; + justify-content: space-between; + color: rgba(255, 255, 255, 0.5); + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.6px; +} +.reorg-panel-clear-btn { + background: transparent; + border: 1px solid rgba(248, 113, 113, 0.3); + color: #f87171; + padding: 3px 10px; + border-radius: 5px; + font-size: 10px; + font-weight: 600; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.4px; + transition: all 0.15s ease; +} +.reorg-panel-clear-btn:hover { + background: rgba(248, 113, 113, 0.12); + border-color: rgba(248, 113, 113, 0.5); +} + +.reorg-panel-list { + display: flex; + flex-direction: column; + gap: 4px; +} +.reorg-panel-row { + display: flex; + align-items: center; + gap: 10px; + background: rgba(255, 255, 255, 0.025); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 6px; + padding: 6px 10px; + min-width: 0; +} +.reorg-panel-row-pos { + color: rgba(255, 255, 255, 0.35); + font-size: 10px; + font-weight: 700; + flex-shrink: 0; + width: 22px; +} +.reorg-panel-row-body { + flex: 1 1 auto; + min-width: 0; +} +.reorg-panel-row-title { + color: rgba(255, 255, 255, 0.85); + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reorg-panel-row-sub { + color: rgba(255, 255, 255, 0.45); + font-size: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.reorg-panel-cancel-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.4); + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + line-height: 1; + flex-shrink: 0; + transition: all 0.15s ease; +} +.reorg-panel-cancel-btn:hover { + background: rgba(248, 113, 113, 0.15); + color: #f87171; +} +.reorg-panel-row.recent-row.success { border-color: rgba(74, 222, 128, 0.18); } +.reorg-panel-row.recent-row.warning { border-color: rgba(250, 204, 21, 0.18); } +.reorg-panel-row.recent-row.cancelled { border-color: rgba(255, 255, 255, 0.08); opacity: 0.7; } +.reorg-panel-row-icon { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.reorg-panel-row-icon.success { background: #4ade80; } +.reorg-panel-row-icon.warning { background: #facc15; } +.reorg-panel-row-icon.cancelled { background: rgba(255, 255, 255, 0.3); } + /* ═══════════════════════════════════════════════════════════════════════════ DOWNLOAD DISCOGRAPHY — Button + Modal ═══════════════════════════════════════════════════════════════════════════ */ @@ -45632,6 +46429,32 @@ textarea.enhanced-meta-field-input { color: rgba(100, 149, 237, 0.9); border-color: rgba(100, 149, 237, 0.35); } +.enhanced-reorganize-album-btn.reorg-state-queued { + background: rgba(var(--accent-rgb), 0.10); + border-color: rgba(var(--accent-rgb), 0.35); + color: rgb(var(--accent-light-rgb)); +} +.enhanced-reorganize-album-btn.reorg-state-queued::before { + content: '⏳ '; + margin-right: 2px; +} +.enhanced-reorganize-album-btn.reorg-state-running { + background: rgba(var(--accent-rgb), 0.18); + border-color: rgba(var(--accent-rgb), 0.55); + color: #ffffff; +} +.enhanced-reorganize-album-btn.reorg-state-running::before { + content: ''; + display: inline-block; + width: 9px; + height: 9px; + margin-right: 5px; + border: 2px solid rgba(255,255,255,0.35); + border-top-color: #ffffff; + border-radius: 50%; + animation: reorgPanelSpin 0.9s linear infinite; + vertical-align: -1px; +} .enhanced-redownload-album-btn { padding: 6px 14px; @@ -45702,6 +46525,19 @@ textarea.enhanced-meta-field-input { .reorganize-template-input:focus { border-color: rgba(100, 149, 237, 0.5); } +/* ` + + # + Track + Artist + Duration + Library Match + Download Status + Actions + + + + ${spotifyTracks.map((track, index) => ` + + + + + ${index + 1} + ${escapeHtml(track.name)} + ${escapeHtml(formatArtists(track.artists))} + ${formatDuration(track.duration_ms)} + 🔍 Pending + - + - + + `).join('')} + + +
+
+ + + + + `; + + applyProgressiveTrackRendering(virtualPlaylistId, spotifyTracks.length); + modal.style.display = 'flex'; + hideLoadingOverlay(); +} + + +// =================================================================== +// DEEZER ARL PLAYLIST MANAGEMENT (Spotify-identical pattern) +// =================================================================== + +async function loadDeezerArlPlaylists() { + const container = document.getElementById('deezer-arl-playlist-container'); + const refreshBtn = document.getElementById('deezer-arl-refresh-btn'); + + container.innerHTML = `
🔄 Loading playlists...
`; + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + + try { + const response = await fetch('/api/deezer/arl-playlists'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Deezer playlists'); + } + deezerArlPlaylists = await response.json(); + renderDeezerArlPlaylists(); + deezerArlPlaylistsLoaded = true; + + // Check for active syncs or downloads and rehydrate UI + await checkForActiveProcesses(); + for (const p of deezerArlPlaylists) { + const arlId = `deezer_arl_${p.id}`; + try { + const syncResp = await fetch(`/api/sync/status/${arlId}`); + if (syncResp.ok) { + const syncState = await syncResp.json(); + if (syncState.status === 'syncing') { + // Re-attach sync polling and update card UI + if (!spotifyPlaylists.find(sp => sp.id === arlId)) { + spotifyPlaylists.push({ id: arlId, name: p.name, track_count: p.track_count || 0, image_url: p.image_url || '', owner: p.owner || '' }); + } + updateCardToSyncing(arlId, syncState.progress?.progress || 0, syncState.progress); + startSyncPolling(arlId); + console.log(`🔄 Rehydrated active sync for Deezer ARL playlist: ${p.name}`); + } + } + } catch (e) { /* No active sync — normal */ } + } + + } catch (error) { + container.innerHTML = `
❌ Error: ${error.message}
`; + showToast(`Error loading Deezer playlists: ${error.message}`, 'error'); + } finally { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } +} + +function renderDeezerArlPlaylists() { + const container = document.getElementById('deezer-arl-playlist-container'); + if (deezerArlPlaylists.length === 0) { + container.innerHTML = `
No Deezer playlists found.
`; + return; + } + + container.innerHTML = deezerArlPlaylists.map(p => { + const arlId = `deezer_arl_${p.id}`; + let statusClass = 'status-never-synced'; + if (p.sync_status && p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; + + return ` +
+
+
+
${escapeHtml(p.name)}
+
+ ${p.track_count} tracks • + ${p.sync_status || 'Never Synced'} +
+
+
+
+ + +
+
+
+ `; + }).join(''); +} + +function handleDeezerArlViewProgressClick(event, playlistId) { + event.stopPropagation(); + const arlPlaylistId = `deezer_arl_${playlistId}`; + const process = activeDownloadProcesses[arlPlaylistId]; + if (process && process.modalElement) { + process.modalElement.style.display = 'flex'; + } +} + +async function openDeezerArlPlaylistDetailsModal(event, playlistId) { + event.stopPropagation(); + + const playlist = deezerArlPlaylists.find(p => String(p.id) === String(playlistId)); + if (!playlist) return; + + const arlPlaylistId = `deezer_arl_${playlistId}`; + showLoadingOverlay(`Loading playlist: ${playlist.name}...`); + + try { + if (playlistTrackCache[arlPlaylistId]) { + const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] }; + showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId); + } else { + const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + + playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks; + + // Auto-mirror + mirrorPlaylist('deezer', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ + track_name: t.name, + artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', + album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', + duration_ms: t.duration_ms || 0, + source_track_id: t.id || '' + })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); + + showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId); + } + } catch (error) { + showToast(`Error: ${error.message}`, 'error'); + } finally { + hideLoadingOverlay(); + } +} + +function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) { + let modal = document.getElementById('deezer-arl-playlist-details-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'deezer-arl-playlist-details-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + const playlistId = playlist.id; + const activeProcess = activeDownloadProcesses[playlistId]; + const hasCompletedProcess = activeProcess && activeProcess.status === 'complete'; + const isSyncing = !!activeSyncPollers[playlistId]; + + modal.innerHTML = ` + + `; + + // Store playlist in spotifyPlaylists-compatible format for openDownloadMissingModal + if (!spotifyPlaylists.find(p => p.id === playlistId)) { + spotifyPlaylists.push({ + id: playlistId, + name: playlist.name, + track_count: playlist.tracks ? playlist.tracks.length : 0, + image_url: playlist.image_url || '', + owner: playlist.owner || '', + }); + } + + modal.style.display = 'flex'; +} + +function closeDeezerArlPlaylistDetailsModal() { + const modal = document.getElementById('deezer-arl-playlist-details-modal'); + if (modal) modal.style.display = 'none'; +} + +function updateDeezerArlPlaylistCardUI(playlistId) { + const arlPlaylistId = `deezer_arl_${playlistId}`; + const process = activeDownloadProcesses[arlPlaylistId]; + const progressBtn = document.getElementById(`progress-btn-${arlPlaylistId}`); + const actionBtn = document.getElementById(`action-btn-${arlPlaylistId}`); + const card = document.querySelector(`.playlist-card[data-playlist-id="${arlPlaylistId}"]`); + + if (!progressBtn || !actionBtn) return; + + if (process && process.status === 'running') { + progressBtn.classList.remove('hidden'); + progressBtn.textContent = 'View Progress'; + progressBtn.style.backgroundColor = ''; + actionBtn.textContent = '📥 Downloading...'; + actionBtn.disabled = true; + if (card) card.classList.remove('download-complete'); + } else if (process && process.status === 'complete') { + progressBtn.classList.remove('hidden'); + progressBtn.textContent = '📋 View Results'; + progressBtn.style.backgroundColor = '#28a745'; + progressBtn.style.color = 'white'; + actionBtn.textContent = '✅ Ready for Review'; + actionBtn.disabled = false; + if (card) card.classList.add('download-complete'); + } else { + progressBtn.classList.add('hidden'); + progressBtn.style.backgroundColor = ''; + progressBtn.style.color = ''; + actionBtn.textContent = 'Sync / Download'; + actionBtn.disabled = false; + if (card) card.classList.remove('download-complete'); + } +} + + +// =================================================================== +// DEEZER PLAYLIST MANAGEMENT (URL-input like YouTube, reuses YouTube modal) +// =================================================================== + +async function loadDeezerPlaylist() { + const urlInput = document.getElementById('deezer-url-input'); + if (!urlInput) return; + + const rawUrl = urlInput.value.trim(); + if (!rawUrl) { + showToast('Please paste a Deezer playlist URL', 'error'); + return; + } + + // Extract playlist ID from URL + // Supports: deezer.com/playlist/{id}, deezer.com/{locale}/playlist/{id}, or raw numeric ID + let playlistId = null; + const urlMatch = rawUrl.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i); + if (urlMatch) { + playlistId = urlMatch[1]; + } else if (/^\d+$/.test(rawUrl)) { + playlistId = rawUrl; + } + + if (!playlistId) { + showToast('Invalid Deezer playlist URL. Expected format: deezer.com/playlist/{id}', 'error'); + return; + } + + // Check if already loaded + if (deezerPlaylists.find(p => String(p.id) === String(playlistId))) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + const parseBtn = document.getElementById('deezer-parse-btn'); + if (parseBtn) { + parseBtn.disabled = true; + parseBtn.textContent = 'Loading...'; + } + + try { + const response = await fetch(`/api/deezer/playlist/${playlistId}`); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Deezer playlist'); + } + + const playlist = await response.json(); + deezerPlaylists.push(playlist); + + // Auto-mirror Deezer playlist + if (playlist.tracks && playlist.tracks.length > 0) { + mirrorPlaylist('deezer', playlist.id, playlist.name, playlist.tracks.map(t => ({ + track_name: t.name || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artists || ''), + album_name: typeof t.album === 'string' ? t.album : '', duration_ms: t.duration_ms || 0, + source_track_id: t.id || '' + })), { owner: playlist.owner, image_url: playlist.image_url, description: rawUrl }); + } + + // Save to URL history + saveUrlHistory('deezer', rawUrl, playlist.name); + + renderDeezerPlaylists(); + await loadDeezerPlaylistStatesFromBackend(); + + urlInput.value = ''; + showToast(`Deezer playlist loaded: ${playlist.name} (${playlist.track_count || playlist.tracks.length} tracks)`, 'success'); + console.log(`🎵 Loaded Deezer playlist: ${playlist.name}`); + + } catch (error) { + showToast(`Error loading Deezer playlist: ${error.message}`, 'error'); + } finally { + if (parseBtn) { + parseBtn.disabled = false; + parseBtn.textContent = 'Load Playlist'; + } + } +} + +function renderDeezerPlaylists() { + const container = document.getElementById('deezer-playlist-container'); + if (deezerPlaylists.length === 0) { + container.innerHTML = `
Paste a Deezer playlist URL above to get started.
`; + return; + } + + container.innerHTML = deezerPlaylists.map(p => { + if (!deezerPlaylistStates[p.id]) { + deezerPlaylistStates[p.id] = { + phase: 'fresh', + playlist: p + }; + } + return createDeezerCard(p); + }).join(''); + + // Add click handlers to cards + deezerPlaylists.forEach(p => { + const card = document.getElementById(`deezer-card-${p.id}`); + if (card) { + card.addEventListener('click', () => handleDeezerCardClick(p.id)); + } + }); +} + +function createDeezerCard(playlist) { + const state = deezerPlaylistStates[playlist.id]; + const phase = state.phase; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` +
+
🎵
+
+
${escapeHtml(playlist.name)}
+
+ ${playlist.track_count || playlist.tracks.length} tracks + ${phaseText} +
+
+
+ +
+ +
+ `; +} + +async function handleDeezerCardClick(playlistId) { + const state = deezerPlaylistStates[playlistId]; + if (!state) { + console.error(`No state found for Deezer playlist: ${playlistId}`); + showToast('Playlist state not found - try refreshing the page', 'error'); + return; + } + + if (!state.playlist) { + console.error(`No playlist data found for Deezer playlist: ${playlistId}`); + showToast('Playlist data missing - try refreshing the page', 'error'); + return; + } + + if (!state.phase) { + state.phase = 'fresh'; + } + + console.log(`🎵 [Card Click] Deezer card clicked: ${playlistId}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + console.log(`🎵 Using pre-loaded Deezer playlist data for: ${state.playlist.name}`); + openDeezerDiscoveryModal(playlistId, state.playlist); + + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + console.log(`🎵 [Card Click] Opening Deezer discovery modal for ${state.phase} phase`); + + if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { + try { + const stateResponse = await fetch(`/api/deezer/state/${playlistId}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results) { + state.discovery_results = fullState.discovery_results; + state.spotify_matches = fullState.spotify_matches || state.spotify_matches; + state.discovery_progress = fullState.discovery_progress || state.discovery_progress; + deezerPlaylistStates[playlistId] = { ...deezerPlaylistStates[playlistId], ...state }; + console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); + } + } + } catch (error) { + console.error(`Failed to fetch discovery results from backend: ${error}`); + } + } + + openDeezerDiscoveryModal(playlistId, state.playlist); + } else if (state.phase === 'downloading' || state.phase === 'download_complete') { + if (state.convertedSpotifyPlaylistId) { + if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { + const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } else { + await rehydrateDeezerDownloadModal(playlistId, state); + } + } else { + await rehydrateDeezerDownloadModal(playlistId, state); + } + } else { + if (state.discovery_results && state.discovery_results.length > 0) { + openDeezerDiscoveryModal(playlistId, state.playlist); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateDeezerDownloadModal(playlistId, state) { + try { + if (!state || !state.playlist) { + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + const spotifyTracks = state.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + const virtualPlaylistId = state.convertedSpotifyPlaylistId || `deezer_${playlistId}`; + await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); + + if (state.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = state.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(virtualPlaylistId); + } + } + } else { + showToast('No Spotify tracks found for download', 'error'); + } + } catch (error) { + console.error(`Error rehydrating Deezer download modal: ${error}`); + } +} + +async function openDeezerDiscoveryModal(playlistId, playlistData) { + console.log(`🎵 Opening Deezer discovery modal (reusing YouTube modal): ${playlistData.name}`); + + const fakeUrlHash = `deezer_${playlistId}`; + + const deezerCardState = deezerPlaylistStates[playlistId]; + const isAlreadyDiscovered = deezerCardState && (deezerCardState.phase === 'discovered' || deezerCardState.phase === 'syncing' || deezerCardState.phase === 'sync_complete'); + const isCurrentlyDiscovering = deezerCardState && deezerCardState.phase === 'discovering'; + + let transformedResults = []; + let actualMatches = 0; + if (isAlreadyDiscovered && deezerCardState.discovery_results) { + transformedResults = deezerCardState.discovery_results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + if (isFound) actualMatches++; + + return { + index: index, + yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown', + yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) + ? result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-' + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }); + console.log(`🎵 Deezer modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); + } + + const modalPhase = deezerCardState ? deezerCardState.phase : 'fresh'; + youtubePlaylistStates[fakeUrlHash] = { + phase: modalPhase, + playlist: { + name: playlistData.name, + tracks: playlistData.tracks + }, + is_deezer_playlist: true, + deezer_playlist_id: playlistId, + discovery_progress: isAlreadyDiscovered ? 100 : 0, + spotify_matches: isAlreadyDiscovered ? actualMatches : 0, + spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, + spotify_total: playlistData.tracks.length, + discovery_results: transformedResults, + discoveryResults: transformedResults, + discoveryProgress: isAlreadyDiscovered ? 100 : 0 + }; + + if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { + try { + console.log(`🔍 Starting Deezer discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('Error starting Deezer discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('Deezer discovery started, beginning polling...'); + + deezerPlaylistStates[playlistId].phase = 'discovering'; + updateDeezerCardPhase(playlistId, 'discovering'); + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + + startDeezerDiscoveryPolling(fakeUrlHash, playlistId); + + } catch (error) { + console.error('Error starting Deezer discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else if (isCurrentlyDiscovering) { + console.log(`🔄 Resuming Deezer discovery polling for: ${playlistData.name}`); + startDeezerDiscoveryPolling(fakeUrlHash, playlistId); + } else if (deezerCardState && deezerCardState.phase === 'syncing') { + console.log(`🔄 Resuming Deezer sync polling for: ${playlistData.name}`); + startDeezerSyncPolling(fakeUrlHash); + } else { + console.log('Using existing results - no need to re-discover'); + } + + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startDeezerDiscoveryPolling(fakeUrlHash, playlistId) { + console.log(`🔄 Starting Deezer discovery polling for: ${playlistId}`); + + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + // WebSocket subscription + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [playlistId] }); + _discoveryProgressCallbacks[playlistId] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; + return; + } + const transformed = { + progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, + complete: data.complete, + results: (data.results || []).map((r, i) => { + const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; + const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); + return { + index: i, yt_track: r.deezer_track ? r.deezer_track.name : 'Unknown', + yt_artist: r.deezer_track ? (r.deezer_track.artists ? r.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), + status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists + ? (Array.isArray(r.spotify_data.artists) + ? (r.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : r.spotify_data.artists) + : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), + spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, + wing_it_fallback: isWingIt + }; + }) + }; + const st = youtubePlaylistStates[fakeUrlHash]; + if (st) { + st.discovery_progress = data.progress; st.discoveryProgress = data.progress; + st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; + st.discovery_results = data.results; st.discoveryResults = transformed.results; + st.phase = data.phase; + updateYouTubeDiscoveryModal(fakeUrlHash, transformed); + } + if (deezerPlaylistStates[playlistId]) { + deezerPlaylistStates[playlistId].phase = data.phase; + deezerPlaylistStates[playlistId].discovery_results = data.results; + deezerPlaylistStates[playlistId].spotify_matches = data.spotify_matches; + deezerPlaylistStates[playlistId].discovery_progress = data.progress; + updateDeezerCardPhase(playlistId, data.phase); + } + updateDeezerCardProgress(playlistId, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [playlistId] }); delete _discoveryProgressCallbacks[playlistId]; + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/deezer/discovery/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Deezer discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + return; + } + + const transformedStatus = { + progress: status.progress, + spotify_matches: status.spotify_matches, + spotify_total: status.spotify_total, + complete: status.complete, + results: status.results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + + return { + index: index, + yt_track: result.deezer_track ? result.deezer_track.name : 'Unknown', + yt_artist: result.deezer_track ? (result.deezer_track.artists ? result.deezer_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists + ? (Array.isArray(result.spotify_data.artists) + ? (result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }) + }; + + const state = youtubePlaylistStates[fakeUrlHash]; + if (state) { + state.discovery_progress = status.progress; + state.discoveryProgress = status.progress; + state.spotify_matches = status.spotify_matches; + state.spotifyMatches = status.spotify_matches; + state.discovery_results = status.results; + state.discoveryResults = transformedStatus.results; + state.phase = status.phase; + + updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); + + if (deezerPlaylistStates[playlistId]) { + deezerPlaylistStates[playlistId].phase = status.phase; + deezerPlaylistStates[playlistId].discovery_results = status.results; + deezerPlaylistStates[playlistId].spotify_matches = status.spotify_matches; + deezerPlaylistStates[playlistId].discovery_progress = status.progress; + updateDeezerCardPhase(playlistId, status.phase); + } + + updateDeezerCardProgress(playlistId, status); + + console.log(`🔄 Deezer discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + if (status.complete) { + console.log(`Deezer discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('Error polling Deezer discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); + + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +async function loadDeezerPlaylistStatesFromBackend() { + try { + console.log('🎵 Loading Deezer playlist states from backend...'); + + const response = await fetch('/api/deezer/playlists/states'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Deezer playlist states'); + } + + const data = await response.json(); + const states = data.states || []; + + console.log(`🎵 Found ${states.length} stored Deezer playlist states in backend`); + + if (states.length === 0) return; + + for (const stateInfo of states) { + await applyDeezerPlaylistState(stateInfo); + } + + // Rehydrate download modals for Deezer playlists in downloading/download_complete phases + for (const stateInfo of states) { + if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && + stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { + + const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; + + if (!activeDownloadProcesses[convertedPlaylistId]) { + console.log(`Rehydrating download modal for Deezer playlist: ${stateInfo.playlist_id}`); + try { + const playlistData = deezerPlaylists.find(p => String(p.id) === String(stateInfo.playlist_id)); + if (!playlistData) continue; + + const spotifyTracks = deezerPlaylistStates[stateInfo.playlist_id]?.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForTidal( + convertedPlaylistId, + playlistData.name, + spotifyTracks + ); + + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = stateInfo.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(convertedPlaylistId); + } + } + } catch (error) { + console.error(`Error rehydrating Deezer download modal for ${stateInfo.playlist_id}:`, error); + } + } + } + } + + console.log('Deezer playlist states loaded and applied'); + + } catch (error) { + console.error('Error loading Deezer playlist states:', error); + } +} + +async function applyDeezerPlaylistState(stateInfo) { + const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; + + try { + console.log(`🎵 Applying saved state for Deezer playlist: ${playlist_id}, Phase: ${phase}`); + + const playlistData = deezerPlaylists.find(p => String(p.id) === String(playlist_id)); + if (!playlistData) { + console.warn(`Playlist data not found for state ${playlist_id} - skipping`); + return; + } + + if (!deezerPlaylistStates[playlist_id]) { + deezerPlaylistStates[playlist_id] = { + playlist: playlistData, + phase: 'fresh' + }; + } + + deezerPlaylistStates[playlist_id].phase = phase; + deezerPlaylistStates[playlist_id].discovery_progress = discovery_progress; + deezerPlaylistStates[playlist_id].spotify_matches = spotify_matches; + deezerPlaylistStates[playlist_id].discovery_results = discovery_results; + deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; + deezerPlaylistStates[playlist_id].download_process_id = download_process_id; + deezerPlaylistStates[playlist_id].playlist = playlistData; + + if (phase !== 'fresh' && phase !== 'discovering') { + try { + const stateResponse = await fetch(`/api/deezer/state/${playlist_id}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results && deezerPlaylistStates[playlist_id]) { + deezerPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; + deezerPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; + deezerPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; + deezerPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + deezerPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; + } + } + } catch (error) { + console.warn(`Error fetching full discovery results for Deezer playlist ${playlistData.name}:`, error.message); + } + } + + updateDeezerCardPhase(playlist_id, phase); + + if (phase === 'discovered' && deezerPlaylistStates[playlist_id]) { + const progressInfo = { + spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, + spotify_matches: deezerPlaylistStates[playlist_id].spotify_matches || 0 + }; + updateDeezerCardProgress(playlist_id, progressInfo); + } + + if (phase === 'discovering') { + const fakeUrlHash = `deezer_${playlist_id}`; + startDeezerDiscoveryPolling(fakeUrlHash, playlist_id); + } else if (phase === 'syncing') { + const fakeUrlHash = `deezer_${playlist_id}`; + startDeezerSyncPolling(fakeUrlHash); + } + + } catch (error) { + console.error(`Error applying Deezer playlist state for ${playlist_id}:`, error); + } +} + +function updateDeezerCardPhase(playlistId, phase) { + const state = deezerPlaylistStates[playlistId]; + if (!state) return; + + state.phase = phase; + + const card = document.getElementById(`deezer-card-${playlistId}`); + if (card) { + const newCardHtml = createDeezerCard(state.playlist); + card.outerHTML = newCardHtml; + + const newCard = document.getElementById(`deezer-card-${playlistId}`); + if (newCard) { + newCard.addEventListener('click', () => handleDeezerCardClick(playlistId)); + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateDeezerCardSyncProgress(playlistId, state.lastSyncProgress); + }, 0); + } + } +} + +function updateDeezerCardProgress(playlistId, progress) { + const state = deezerPlaylistStates[playlistId]; + if (!state) return; + + const card = document.getElementById(`deezer-card-${playlistId}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + progressElement.classList.remove('hidden'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + + if (total > 0) { + progressElement.innerHTML = ` +
+ ✓ ${matches} + / + ♪ ${total} +
+ `; + } +} + +// =============================== +// DEEZER SYNC FUNCTIONALITY +// =============================== + +async function startDeezerPlaylistSync(urlHash) { + try { + console.log('🎵 Starting Deezer playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_deezer_playlist) { + console.error('Invalid Deezer playlist state for sync'); + return; + } + + const playlistId = state.deezer_playlist_id; + const response = await fetch(`/api/deezer/sync/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + updateDeezerCardPhase(playlistId, 'syncing'); + updateDeezerModalButtons(urlHash, 'syncing'); + + startDeezerSyncPolling(urlHash, syncPlaylistId); + + showToast('Deezer playlist sync started!', 'success'); + + } catch (error) { + console.error('Error starting Deezer sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startDeezerSyncPolling(urlHash, syncPlaylistId) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.deezer_playlist_id; + + syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); + + // WebSocket subscription + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateDeezerCardSyncProgress(playlistId, progress); + updateDeezerModalSyncProgress(urlHash, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateDeezerCardPhase(playlistId, 'sync_complete'); + updateDeezerModalButtons(urlHash, 'sync_complete'); + showToast('Deezer playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateDeezerCardPhase(playlistId, 'discovered'); + updateDeezerModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollFunction = async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/deezer/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Deezer sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateDeezerCardSyncProgress(playlistId, status.progress); + updateDeezerModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateDeezerCardPhase(playlistId, 'sync_complete'); + updateDeezerModalButtons(urlHash, 'sync_complete'); + showToast('Deezer playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (deezerPlaylistStates[playlistId]) deezerPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateDeezerCardPhase(playlistId, 'discovered'); + updateDeezerModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('Error polling Deezer sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + if (!socketConnected) pollFunction(); + + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelDeezerSync(urlHash) { + try { + console.log('Cancelling Deezer sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_deezer_playlist) { + console.error('Invalid Deezer playlist state'); + return; + } + + const playlistId = state.deezer_playlist_id; + const response = await fetch(`/api/deezer/sync/cancel/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + const syncId = state && state.syncPlaylistId; + if (syncId && _syncProgressCallbacks[syncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); + delete _syncProgressCallbacks[syncId]; + } + + updateDeezerCardPhase(playlistId, 'discovered'); + updateDeezerModalButtons(urlHash, 'discovered'); + + showToast('Deezer sync cancelled', 'info'); + + } catch (error) { + console.error('Error cancelling Deezer sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateDeezerCardSyncProgress(playlistId, progress) { + const state = deezerPlaylistStates[playlistId]; + if (!state || !state.playlist || !progress) return; + + state.lastSyncProgress = progress; + + const card = document.getElementById(`deezer-card-${playlistId}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + if (statusCounterHTML) { + progressElement.innerHTML = statusCounterHTML; + } +} + +function updateDeezerModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`deezer-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`deezer-total-${urlHash}`); + const matchedEl = document.getElementById(`deezer-matched-${urlHash}`); + const failedEl = document.getElementById(`deezer-failed-${urlHash}`); + const percentageEl = document.getElementById(`deezer-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } +} + +function updateDeezerModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +async function startDeezerDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for Deezer playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_deezer_playlist) { + console.error('Invalid Deezer playlist state for download'); + return; + } + + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (!discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + const spotifyTracks = []; + for (const result of discoveryResults) { + if (result.spotify_data) { + spotifyTracks.push(result.spotify_data); + } else if (result.spotify_track && result.status_class === 'found') { + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + spotifyTracks.push({ + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }); + } + } + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + const virtualPlaylistId = `deezer_${state.deezer_playlist_id}`; + const playlistName = state.playlist.name; + + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + } + + await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error starting download missing tracks:', error); + showToast(`Error starting downloads: ${error.message}`, 'error'); + } +} + + +// =============================== +// SYNC PAGE FUNCTIONALITY (REDESIGNED) +// =============================== + +function initializeSyncPage() { + // Logic for tab switching + const tabButtons = document.querySelectorAll('.sync-tab-button'); + const syncSidebar = document.querySelector('.sync-sidebar'); + const syncContentArea = document.querySelector('.sync-content-area'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + const tabId = button.dataset.tab; + const previousActiveTab = document.querySelector('.sync-tab-button.active'); + const previousTabId = previousActiveTab ? previousActiveTab.dataset.tab : null; + + // Update button active state + tabButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Update content active state + document.querySelectorAll('.sync-tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(`${tabId}-tab-content`).classList.add('active'); + + // Show/hide sidebar based on active tab (skip on mobile where sidebar is always hidden) + if (syncSidebar && syncContentArea) { + const isMobile = window.innerWidth <= 1300; + // Sidebar always hidden by default — shown only when sync is active + syncSidebar.style.display = 'none'; + syncContentArea.style.gridTemplateColumns = '1fr'; + } + + // Auto-load Deezer ARL playlists on first tab activation + if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) { + // Check ARL status first + fetch('/api/deezer/arl-status').then(r => r.json()).then(data => { + const container = document.getElementById('deezer-arl-playlist-container'); + if (data.authenticated) { + loadDeezerArlPlaylists(); + } else if (container) { + container.innerHTML = `
Deezer ARL not configured. Add your ARL token in Settings > Downloads to see your playlists here.
`; + } + }).catch(() => { }); + } + + // Auto-load mirrored playlists on first tab activation + if (tabId === 'mirrored' && !mirroredPlaylistsLoaded) { + loadMirroredPlaylists(); + } + + // Auto-load server playlists on first tab activation + if (tabId === 'server' && !window._serverPlaylistsLoaded) { + window._serverPlaylistsLoaded = true; + loadServerPlaylists(); + } + + if (previousTabId === 'beatport' && tabId !== 'beatport') { + cleanupBeatportContent(); + } + + // Lazily load Beatport content the first time the Beatport tab is opened + if (tabId === 'beatport') { + ensureBeatportContentLoaded(); + } + }); + }); + + // If the Beatport tab is already active when Sync initializes, load it now. + const activeBeatportTab = document.querySelector('.sync-tab-button.active[data-tab="beatport"]'); + if (activeBeatportTab) { + ensureBeatportContentLoaded(); + } + + // Logic for the Spotify refresh button + const refreshBtn = document.getElementById('spotify-refresh-btn'); + if (refreshBtn) { + // Remove any old listeners to be safe, then add the new one + refreshBtn.removeEventListener('click', loadSpotifyPlaylists); + refreshBtn.addEventListener('click', loadSpotifyPlaylists); + } + + // Logic for the Tidal refresh button + const tidalRefreshBtn = document.getElementById('tidal-refresh-btn'); + if (tidalRefreshBtn) { + tidalRefreshBtn.removeEventListener('click', loadTidalPlaylists); + tidalRefreshBtn.addEventListener('click', loadTidalPlaylists); + } + + // Logic for the Deezer ARL refresh button + const deezerArlRefreshBtn = document.getElementById('deezer-arl-refresh-btn'); + if (deezerArlRefreshBtn) { + deezerArlRefreshBtn.removeEventListener('click', loadDeezerArlPlaylists); + deezerArlRefreshBtn.addEventListener('click', loadDeezerArlPlaylists); + } + + // Logic for the Deezer Link parse button + const deezerParseBtn = document.getElementById('deezer-parse-btn'); + if (deezerParseBtn) { + deezerParseBtn.addEventListener('click', loadDeezerPlaylist); + } + // Also allow Enter key in the Deezer input + const deezerUrlInput = document.getElementById('deezer-url-input'); + if (deezerUrlInput) { + deezerUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') loadDeezerPlaylist(); + }); + } + + // Logic for the Mirrored refresh button + const mirroredRefreshBtn = document.getElementById('mirrored-refresh-btn'); + if (mirroredRefreshBtn) { + mirroredRefreshBtn.addEventListener('click', loadMirroredPlaylists); + } + + // Initialize import file tab + _initImportFileTab(); + + // Logic for the Beatport clear button + const beatportClearBtn = document.getElementById('beatport-clear-btn'); + if (beatportClearBtn) { + beatportClearBtn.addEventListener('click', clearBeatportPlaylists); + // Set initial clear button state + updateBeatportClearButtonState(); + } + + // Logic for Beatport nested tabs + const beatportTabButtons = document.querySelectorAll('.beatport-tab-button'); + beatportTabButtons.forEach(button => { + button.addEventListener('click', () => { + const tabId = button.dataset.beatportTab; + + // Update button active state + beatportTabButtons.forEach(btn => btn.classList.remove('active')); + button.classList.add('active'); + + // Update content active state + document.querySelectorAll('.beatport-tab-content').forEach(content => { + content.classList.remove('active'); + }); + document.getElementById(`beatport-${tabId}-content`).classList.add('active'); + + // Initialize rebuild content lazily when the rebuild tab is selected + if (tabId === 'rebuild') { + ensureBeatportContentLoaded(); + } + }); + }); + + // Logic for Homepage Genre Explorer card + const genreExplorerCard = document.querySelector('[data-action="show-genres"]'); + if (genreExplorerCard) { + genreExplorerCard.addEventListener('click', () => { + console.log('🎵 Genre Explorer card clicked'); + showBeatportSubView('genres'); + loadBeatportGenres(); + }); + } + + // Setup homepage chart handlers (following genre page pattern to prevent duplicates) + setupHomepageChartTypeHandlers(); + + // Load homepage chart collections automatically (disabled since Browse Charts tab is hidden) + // loadDJChartsInline(); + // loadFeaturedChartsInline(); + + // Logic for Beatport breadcrumb back buttons + const beatportBackButtons = document.querySelectorAll('.breadcrumb-back'); + beatportBackButtons.forEach(button => { + button.addEventListener('click', () => { + // Handle different back button types + if (button.id === 'genre-detail-back') { + showBeatportGenresView(); + } else if (button.id === 'genre-charts-list-back') { + showBeatportGenreDetailViewFromBack(); + } else { + showBeatportMainView(); + } + }); + }); + + // Logic for Beatport chart items + const beatportChartItems = document.querySelectorAll('.beatport-chart-item'); + beatportChartItems.forEach(item => { + item.addEventListener('click', () => { + const chartType = item.dataset.chartType; + const chartId = item.dataset.chartId; + const chartName = item.dataset.chartName; + const chartEndpoint = item.dataset.chartEndpoint; + handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint); + }); + }); + + // Logic for Beatport genre items + const beatportGenreItems = document.querySelectorAll('.beatport-genre-item'); + beatportGenreItems.forEach(item => { + item.addEventListener('click', () => { + const genreSlug = item.dataset.genreSlug; + const genreId = item.dataset.genreId; + handleBeatportGenreClick(genreSlug, genreId); + }); + }); + + // Logic for Rebuild page Top 10 containers - Beatport Top 10 + const beatportTop10Container = document.getElementById('beatport-top10-list'); + if (beatportTop10Container) { + beatportTop10Container.addEventListener('click', () => { + console.log('🎵 Beatport Top 10 container clicked on rebuild page'); + handleRebuildBeatportTop10Click(); + }); + } + + // Logic for Rebuild page Top 10 containers - Hype Top 10 + const beatportHype10Container = document.getElementById('beatport-hype10-list'); + if (beatportHype10Container) { + beatportHype10Container.addEventListener('click', () => { + console.log('🔥 Hype Top 10 container clicked on rebuild page'); + handleRebuildHypeTop10Click(); + }); + } + + // Logic for Rebuild page Hero Slider - individual slide click handlers will be set up in populateBeatportSlider + // Container-level click handler removed to allow individual slide clicks like top 10 releases + + // Logic for the Start Sync button + const startSyncBtn = document.getElementById('start-sync-btn'); + if (startSyncBtn) { + startSyncBtn.addEventListener('click', startSequentialSync); + } + + // Logic for the YouTube parse button + const youtubeParseBtn = document.getElementById('youtube-parse-btn'); + if (youtubeParseBtn) { + youtubeParseBtn.addEventListener('click', parseYouTubePlaylist); + } + + // Logic for YouTube URL input (Enter key support) + const youtubeUrlInput = document.getElementById('youtube-url-input'); + if (youtubeUrlInput) { + youtubeUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseYouTubePlaylist(); + } + }); + } + + // Logic for Spotify Public parse button + const spotifyPublicParseBtn = document.getElementById('spotify-public-parse-btn'); + if (spotifyPublicParseBtn) { + spotifyPublicParseBtn.addEventListener('click', parseSpotifyPublicUrl); + } + + // Logic for Spotify Public URL input (Enter key support) + const spotifyPublicUrlInput = document.getElementById('spotify-public-url-input'); + if (spotifyPublicUrlInput) { + spotifyPublicUrlInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + parseSpotifyPublicUrl(); + } + }); + } + + // Logic for Beatport Top 100 button + const beatportTop100Btn = document.getElementById('beatport-top100-btn'); + if (beatportTop100Btn) { + beatportTop100Btn.addEventListener('click', handleBeatportTop100Click); + } + + // Logic for Hype Top 100 button + const hypeTop100Btn = document.getElementById('hype-top100-btn'); + if (hypeTop100Btn) { + hypeTop100Btn.addEventListener('click', handleHypeTop100Click); + } + + // Initialize live log viewer + initializeLiveLogViewer(); +} + + +// --- Event Handlers --- + +// --- Find and REPLACE the existing handleDbUpdateButtonClick function --- + +async function handleDbUpdateButtonClick() { + const button = document.getElementById('db-update-button'); + const currentAction = button.textContent; + + if (currentAction === 'Update Database') { + const refreshSelect = document.getElementById('db-refresh-type'); + const isFullRefresh = refreshSelect.value === 'full'; + + if (isFullRefresh) { + // Replicates the QMessageBox confirmation from the GUI + const confirmed = await showConfirmDialog({ title: 'Full Refresh', message: 'This will clear and rebuild the database for the active server. It can take a long time.\n\nAre you sure you want to proceed?', confirmText: 'Proceed' }); + if (!confirmed) return; + } + + try { + button.disabled = true; + button.textContent = 'Starting...'; + const response = await fetch('/api/database/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ full_refresh: isFullRefresh }) + }); + + if (response.ok) { + showToast('Database update started!', 'success'); + // Start polling immediately to get live status + checkAndUpdateDbProgress(); + } else { + const errorData = await response.json(); + showToast(`Error: ${errorData.error}`, 'error'); + button.disabled = false; + button.textContent = 'Update Database'; + } + } catch (error) { + showToast('Failed to start update process.', 'error'); + button.disabled = false; + button.textContent = 'Update Database'; + } + + } else { // "Stop Update" + try { + const response = await fetch('/api/database/update/stop', { method: 'POST' }); + if (response.ok) { + showToast('Stop request sent.', 'info'); + } else { + showToast('Failed to send stop request.', 'error'); + } + } catch (error) { + showToast('Error sending stop request.', 'error'); + } + } +} + +async function handleWishlistButtonClick() { + try { + const playlistId = 'wishlist'; + + console.log('🎵 [Wishlist Button] User clicked wishlist button - checking server state first'); + + // STEP 1: Always check server state first to detect any active wishlist processes + const response = await fetch('/api/active-processes'); + if (!response.ok) { + throw new Error(`Failed to fetch active processes: ${response.status}`); + } + + const data = await response.json(); + const processes = data.active_processes || []; + const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); + + // STEP 2: Handle active server process - show current state immediately + if (serverWishlistProcess) { + console.log('🎯 [Wishlist Button] Server has active wishlist process:', { + batch_id: serverWishlistProcess.batch_id, + phase: serverWishlistProcess.phase, + auto_initiated: serverWishlistProcess.auto_initiated, + should_show: serverWishlistProcess.should_show_modal + }); + + // Clear any user-closed state since user explicitly requested to see modal + WishlistModalState.clearUserClosed(); + + // Check if we need to create/sync the frontend modal + const clientWishlistProcess = activeDownloadProcesses[playlistId]; + const needsRehydration = !clientWishlistProcess || + clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || + !clientWishlistProcess.modalElement || + !document.body.contains(clientWishlistProcess.modalElement); + + if (needsRehydration) { + console.log('🔄 [Wishlist Button] Frontend modal needs sync/creation'); + await rehydrateModal(serverWishlistProcess, true); // user-requested = true + } else { + console.log('✅ [Wishlist Button] Frontend modal already synced, showing existing modal'); + clientWishlistProcess.modalElement.style.display = 'flex'; + WishlistModalState.setVisible(); + } + return; + } + + // STEP 3: No active server process - check wishlist count and create fresh modal + console.log('📭 [Wishlist Button] No active server process, checking wishlist content'); + + const countResponse = await fetch('/api/wishlist/count'); + if (!countResponse.ok) { + throw new Error(`Failed to fetch wishlist count: ${countResponse.status}`); + } + + const countData = await countResponse.json(); + if (countData.count === 0) { + showToast('Wishlist is empty. No tracks to download.', 'info'); + return; + } + + // STEP 4: Open wishlist overview modal (NEW - category selection) + console.log(`🆕 [Wishlist Button] Opening wishlist overview for ${countData.count} tracks`); + await openWishlistOverviewModal(); + + } catch (error) { + console.error('❌ [Wishlist Button] Error handling wishlist button click:', error); + showToast(`Error opening wishlist: ${error.message}`, 'error'); + } +} + +async function cleanupWishlist(playlistId) { + try { + // Show information dialog + const confirmed = await showConfirmDialog({ + title: 'Cleanup Wishlist', + message: 'This will check all wishlist tracks against your music library and automatically remove any tracks that already exist in your database.\n\nThis is a safe operation that only removes tracks you already have. Continue with cleanup?' + }); + + if (!confirmed) { + return; + } + + // Disable the cleanup button during the operation + const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); + if (cleanupBtn) { + cleanupBtn.disabled = true; + cleanupBtn.textContent = '🧹 Cleaning...'; + } + + const response = await fetch('/api/wishlist/cleanup', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (result.success) { + const removedCount = result.removed_count || 0; + const processedCount = result.processed_count || 0; + + if (removedCount > 0) { + showToast(`Wishlist cleanup completed: ${removedCount} tracks removed (${processedCount} checked)`, 'success'); + + // Refresh the modal content to show updated state + setTimeout(() => { + openDownloadMissingWishlistModal(); + }, 500); + + // Update the wishlist count in the main dashboard + await updateWishlistCount(); + } else { + showToast(`Wishlist cleanup completed: No tracks to remove (${processedCount} checked)`, 'info'); + } + } else { + showToast(`Error cleaning wishlist: ${result.error}`, 'error'); + } + + } catch (error) { + console.error('Error cleaning wishlist:', error); + showToast(`Error cleaning wishlist: ${error.message}`, 'error'); + } finally { + // Re-enable the cleanup button + const cleanupBtn = document.getElementById(`cleanup-wishlist-btn-${playlistId}`); + if (cleanupBtn) { + cleanupBtn.disabled = false; + cleanupBtn.textContent = '🧹 Cleanup Wishlist'; + } + } +} + +async function clearWishlist(playlistId) { + try { + // Show confirmation dialog + const confirmed = await showConfirmDialog({ + title: 'Clear Wishlist', + message: 'Are you sure you want to clear the entire wishlist?\n\nThis will permanently remove all failed tracks from the wishlist. This action cannot be undone.', + confirmText: 'Clear All', + destructive: true + }); + + if (!confirmed) { + return; + } + + // Disable the clear button during the operation + const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`); + if (clearBtn) { + clearBtn.disabled = true; + clearBtn.textContent = 'Clearing...'; + } + + // Call the clear API endpoint + const response = await fetch('/api/wishlist/clear', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const result = await response.json(); + + if (result.success) { + showToast('Wishlist cleared successfully', 'success'); + + // Close the modal since there are no more tracks + closeDownloadMissingModal(playlistId); + + // Update the wishlist count in the main dashboard + await updateWishlistCount(); + + } else { + showToast(`Failed to clear wishlist: ${result.error || 'Unknown error'}`, 'error'); + } + + } catch (error) { + console.error('Error clearing wishlist:', error); + showToast(`Error clearing wishlist: ${error.message}`, 'error'); + } finally { + // Re-enable the clear button + const clearBtn = document.getElementById(`clear-wishlist-btn-${playlistId}`); + if (clearBtn) { + clearBtn.disabled = false; + clearBtn.textContent = '🗑️ Clear Wishlist'; + } + } +} + + +// =============================== +// BEATPORT CHARTS FUNCTIONALITY +// =============================== + +function updateBeatportClearButtonState() { + const clearBtn = document.getElementById('beatport-clear-btn'); + if (!clearBtn) return; + + // Check if any Beatport cards are in active states + const activeCharts = Object.values(beatportChartStates).filter(state => + state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading' + ); + + const hasActiveCharts = activeCharts.length > 0; + const hasAnyCharts = Object.keys(beatportChartStates).length > 0; + + if (!hasAnyCharts) { + // No charts at all + clearBtn.disabled = true; + clearBtn.textContent = '🗑️ Clear'; + clearBtn.style.opacity = '0.5'; + clearBtn.style.cursor = 'not-allowed'; + clearBtn.title = 'No Beatport charts to clear'; + } else if (hasActiveCharts) { + // Has charts but some are active + clearBtn.disabled = true; + clearBtn.textContent = '🚫 Clear Blocked'; + clearBtn.style.opacity = '0.6'; + clearBtn.style.cursor = 'not-allowed'; + const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', '); + clearBtn.title = `Cannot clear: ${activeCharts.length} chart(s) are currently active: ${activeNames}`; + } else { + // Has charts and none are active + clearBtn.disabled = false; + clearBtn.textContent = '🗑️ Clear'; + clearBtn.style.opacity = '1'; + clearBtn.style.cursor = 'pointer'; + clearBtn.title = 'Clear all Beatport charts'; + } +} + +async function clearBeatportPlaylists() { + const container = document.getElementById('beatport-playlist-container'); + const clearBtn = document.getElementById('beatport-clear-btn'); + + if (Object.keys(beatportChartStates).length === 0) { + showToast('No Beatport playlists to clear', 'info'); + return; + } + + // Check if any Beatport cards are in active states (discovering, syncing, or downloading) + const activeCharts = Object.values(beatportChartStates).filter(state => + state.phase === 'discovering' || state.phase === 'syncing' || state.phase === 'downloading' + ); + + if (activeCharts.length > 0) { + const activeNames = activeCharts.map(state => state.chart?.name || 'Unknown').join(', '); + showToast(`Cannot clear: ${activeCharts.length} chart(s) are currently discovering, syncing, or downloading: ${activeNames}`, 'warning'); + return; + } + + // Show loading state + clearBtn.disabled = true; + clearBtn.textContent = '🗑️ Clearing...'; + + try { + // Clear all Beatport chart states + Object.keys(beatportChartStates).forEach(chartHash => { + // Close any open modals for this chart + const modal = document.getElementById(`youtube-discovery-modal-${chartHash}`); + if (modal) { + modal.remove(); + } + + // Remove from YouTube states (since Beatport reuses that infrastructure) + if (youtubePlaylistStates[chartHash]) { + // Clean up any active download processes for this Beatport chart + const ytState = youtubePlaylistStates[chartHash]; + if (ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) { + const downloadProcess = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]; + if (downloadProcess) { + console.log(`🗑️ Cleaning up download process for Beatport chart: ${chartHash}`); + if (downloadProcess.modalElement) { + downloadProcess.modalElement.remove(); + } + delete activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]; + } + } + + delete youtubePlaylistStates[chartHash]; + } + }); + + // Clear Beatport states + const chartHashesToClear = Object.keys(beatportChartStates); + beatportChartStates = {}; + + // Clear backend state for all charts + for (const chartHash of chartHashesToClear) { + try { + await fetch(`/api/beatport/charts/delete/${chartHash}`, { + method: 'DELETE' + }); + console.log(`🗑️ Deleted backend state for Beatport chart: ${chartHash}`); + } catch (error) { + console.warn(`⚠️ Error deleting backend state for chart ${chartHash}:`, error); + } + } + + // Reset container to placeholder + container.innerHTML = ` +
Your created Beatport playlists will appear here.
+ `; + + console.log(`🗑️ Cleared ${chartHashesToClear.length} Beatport charts from frontend and backend`); + showToast('Cleared all Beatport playlists', 'success'); + + // Update clear button state after clearing all charts + updateBeatportClearButtonState(); + + } catch (error) { + console.error('Error clearing Beatport playlists:', error); + showToast(`Error clearing playlists: ${error.message}`, 'error'); + } finally { + clearBtn.disabled = false; + clearBtn.textContent = '🗑️ Clear'; + } +} + +function handleBeatportCategoryClick(category) { + console.log(`🎵 Beatport category clicked: ${category}`); + + // Only handle genres category now - homepage has direct chart buttons + switch (category) { + case 'genres': + showBeatportSubView('genres'); + loadBeatportGenres(); // Load genres dynamically + break; + default: + showToast(`Unknown category: ${category}`, 'error'); + } +} + +async function loadBeatportGenres() { + console.log('🔍 Loading Beatport genres dynamically...'); + + const genreGrid = document.querySelector('#beatport-genres-view .beatport-genre-grid'); + if (!genreGrid) { + console.error('❌ Could not find genre grid element'); + return; + } + + // Show loading state + genreGrid.innerHTML = ` +
+
+

🔍 Discovering current Beatport genres...

+
+ `; + + try { + // First, fetch genres quickly without images + console.log('🚀 Fetching genres without images for fast loading...'); + const fastResponse = await fetch('/api/beatport/genres'); + if (!fastResponse.ok) { + throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`); + } + + const fastData = await fastResponse.json(); + const genres = fastData.genres || []; + + if (genres.length === 0) { + genreGrid.innerHTML = ` +
+

⚠️ No genres available

+ +
+ `; + return; + } + + // Generate genre cards dynamically (without images first) + const genreCardsHTML = genres.map(genre => ` +
+
🎵
+

${genre.name}

+ Top 100 +
+ `).join(''); + + genreGrid.innerHTML = genreCardsHTML; + + // Add click handlers to dynamically created genre items + const genreItems = genreGrid.querySelectorAll('.beatport-genre-item'); + genreItems.forEach(item => { + item.addEventListener('click', () => { + const genreSlug = item.dataset.genreSlug; + const genreId = item.dataset.genreId; + const genreName = item.dataset.genreName; + handleBeatportGenreClick(genreSlug, genreId, genreName); + }); + }); + + console.log(`✅ Loaded ${genres.length} Beatport genres dynamically (fast mode)`); + showToast(`Loaded ${genres.length} current Beatport genres`, 'success'); + + // Now fetch images progressively in the background if there are many genres + if (genres.length > 10) { + console.log('🖼️ Loading genre images progressively...'); + loadGenreImagesProgressively(genres); + } + + } catch (error) { + console.error('❌ Error loading Beatport genres:', error); + genreGrid.innerHTML = ` +
+

❌ Failed to load genres: ${error.message}

+ +
+ `; + showToast(`Error loading Beatport genres: ${error.message}`, 'error'); + } +} + +async function loadGenreImagesProgressively(genres) { + // Load genre images with 2 concurrent workers for faster loading + + const imageQueue = [...genres]; // Create a copy for processing + let imagesLoaded = 0; + const maxWorkers = 2; + + console.log(`🖼️ Starting progressive image loading with ${maxWorkers} workers for ${imageQueue.length} genres`); + + // Function to process a single image + async function processImage(genre) { + try { + // Fetch individual genre image from backend + const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`); + + if (response.ok) { + const data = await response.json(); + + if (data.success && data.image_url) { + // Find the genre item in the DOM + const genreItem = document.querySelector( + `[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]` + ); + + if (genreItem) { + const iconElement = genreItem.querySelector('.genre-icon'); + if (iconElement) { + // Create new image element with smooth transition + const imageDiv = document.createElement('div'); + imageDiv.className = 'genre-image'; + imageDiv.style.backgroundImage = `url('${data.image_url}')`; + imageDiv.style.opacity = '0'; + imageDiv.style.transition = 'opacity 0.3s ease'; + + // Replace icon with image + iconElement.replaceWith(imageDiv); + + // Trigger fade-in animation + setTimeout(() => { + imageDiv.style.opacity = '1'; + }, 50); + + imagesLoaded++; + console.log(`🖼️ [${imagesLoaded}/${imageQueue.length}] Loaded image for ${genre.name}`); + } + } + } + } + } catch (error) { + console.warn(`⚠️ Failed to load image for ${genre.name}:`, error); + } + } + + // Worker function that processes images from the queue + async function imageWorker(workerId) { + while (imageQueue.length > 0) { + const genre = imageQueue.shift(); // Take next image from queue + if (genre) { + await processImage(genre); + + // Small delay between requests to be respectful (500ms per worker = ~2 images per second total) + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + console.log(`✅ Worker ${workerId} finished`); + } + + // Start the workers + const workers = []; + for (let i = 0; i < maxWorkers; i++) { + workers.push(imageWorker(i + 1)); + } + + // Wait for all workers to complete + await Promise.all(workers); + + console.log(`✅ Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`); +} + +function setupHomepageChartTypeHandlers() { + console.log('🔧 Setting up homepage chart type handlers...'); + + // Select all homepage chart type cards (following genre page pattern) + const chartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]'); + + chartTypeCards.forEach(card => { + // Remove existing listeners by cloning (following genre page pattern) + card.replaceWith(card.cloneNode(true)); + }); + + // Re-select after cloning to ensure clean event listeners (following genre page pattern) + const newChartTypeCards = document.querySelectorAll('.homepage-main-charts-section .genre-chart-type-card[data-chart-type], .homepage-releases-section .genre-chart-type-card[data-chart-type], .homepage-hype-section .genre-chart-type-card[data-chart-type]'); + + newChartTypeCards.forEach(card => { + card.addEventListener('click', () => { + const chartType = card.dataset.chartType; + const chartEndpoint = card.dataset.chartEndpoint; + const chartName = card.querySelector('.chart-type-info h3').textContent; + console.log(`🔥 Homepage chart clicked: ${chartName} (${chartType})`); + handleHomepageChartTypeClick(chartType, chartEndpoint, chartName); + }); + }); + + console.log(`✅ Setup ${newChartTypeCards.length} homepage chart handlers`); +} + +async function handleHomepageChartTypeClick(chartType, chartEndpoint, chartName) { + console.log(`🔥 Homepage chart type clicked: ${chartType} (${chartName})`); + + // Map chart types to API endpoints and create descriptive names (following genre page pattern) + const chartTypeMap = { + 'top-10': { + endpoint: `/api/beatport/top-100`, // Use top-100 endpoint and limit to 10 + name: `Beatport Top 10`, + limit: 10 + }, + 'top-100': { + endpoint: `/api/beatport/top-100`, + name: `Beatport Top 100`, + limit: 100 + }, + 'releases-top-10': { + endpoint: `/api/beatport/homepage/top-10-releases`, // Working route + name: `Top 10 Releases`, + limit: 10 + }, + 'releases-top-100': { + endpoint: `/api/beatport/top-100-releases`, + name: `Top 100 Releases`, + limit: 100 + }, + 'latest-releases': { + endpoint: `/api/beatport/homepage/new-releases`, // Use new-releases as fallback for now + name: `Latest Releases`, + limit: 50 + }, + 'hype-top-10': { + endpoint: `/api/beatport/hype-top-100`, // Use hype-100 endpoint and limit to 10 + name: `Hype Top 10`, + limit: 10 + }, + 'hype-top-100': { + endpoint: `/api/beatport/hype-top-100`, + name: `Hype Top 100`, + limit: 100 + }, + 'hype-picks': { + endpoint: `/api/beatport/homepage/hype-picks`, // Working route + name: `Hype Picks`, + limit: 50 + } + }; + + const chartConfig = chartTypeMap[chartType]; + if (!chartConfig) { + console.error(`❌ Unknown homepage chart type: ${chartType}`); + showToast(`Unknown chart type: ${chartType}`, 'error'); + return; + } + + try { + showToast(`Loading ${chartConfig.name}...`, 'info'); + showLoadingOverlay(`Loading ${chartConfig.name}...`); + + const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`); + if (!response.ok) { + throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartConfig.name}`); + } + + console.log(`✅ Fetched ${data.tracks.length} tracks from ${chartConfig.name}`); + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(data.tracks, chartConfig.name, null); + + } catch (error) { + console.error(`❌ Error loading ${chartConfig.name}:`, error); + hideLoadingOverlay(); + showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error'); + } +} + + + +async function openBeatportDiscoveryModal(chartHash, chartData) { + console.log(`🎵 Opening Beatport discovery modal (reusing YouTube modal): ${chartData.name}`); + + // Create YouTube-style state entry for this Beatport chart + const beatportState = { + phase: 'fresh', + playlist: { + name: chartData.name, + tracks: chartData.tracks, + description: `${chartData.track_count} tracks from ${chartData.name}`, + source: 'beatport' + }, + is_beatport_playlist: true, + beatport_chart_type: chartData.chart_type, + beatport_chart_hash: chartHash // Link to Beatport card state + }; + + // Store in YouTube playlist states (reusing the infrastructure) + youtubePlaylistStates[chartHash] = beatportState; + + // Start discovery automatically (like Tidal does) + try { + console.log(`🔍 Starting Beatport discovery for: ${chartData.name}`); + + // Update card phase to discovering immediately + updateBeatportCardPhase(chartHash, 'discovering'); + + // Call the discovery start endpoint with chart data + const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + chart_data: chartData + }) + }); + + const result = await response.json(); + if (result.success) { + // Update state to discovering + youtubePlaylistStates[chartHash].phase = 'discovering'; + + // Start polling for progress + startBeatportDiscoveryPolling(chartHash); + + console.log(`✅ Started Beatport discovery for: ${chartData.name}`); + } else { + console.error('❌ Error starting Beatport discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + // Revert card phase on error + updateBeatportCardPhase(chartHash, 'fresh'); + } + } catch (error) { + console.error('❌ Error starting Beatport discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + // Revert card phase on error + updateBeatportCardPhase(chartHash, 'fresh'); + } + + // Open the existing YouTube discovery modal infrastructure + openYouTubeDiscoveryModal(chartHash); + + console.log(`✅ Beatport discovery modal opened for ${chartData.name} with ${chartData.tracks.length} tracks`); +} + +function startBeatportDiscoveryPolling(urlHash) { + console.log(`🔄 Starting Beatport discovery polling for: ${urlHash}`); + + // Stop any existing polling (reuse YouTube polling infrastructure) + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + if (youtubePlaylistStates[urlHash]) { + const transformed = { + progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0, + results: (data.results || []).map((r, i) => ({ + index: r.index !== undefined ? r.index : i, + yt_track: r.beatport_track ? r.beatport_track.title : 'Unknown', + yt_artist: r.beatport_track ? r.beatport_track.artist : 'Unknown', + status: (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found') ? '✅ Found' : (r.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: r.status_class || ((r.status === 'found' || r.status === '✅ Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists ? r.spotify_data.artists.map(a => a.name || a).join(', ') : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), + spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match + })) + }; + const st = youtubePlaylistStates[urlHash]; + st.discovery_progress = data.progress; st.discoveryProgress = data.progress; + st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; + st.discovery_results = data.results; st.discoveryResults = transformed.results; + st.phase = data.phase || 'discovering'; + const chartHash = st.beatport_chart_hash || urlHash; + updateBeatportCardPhase(chartHash, data.phase || 'discovering'); + updateBeatportCardProgress(chartHash, { spotify_total: data.spotify_total || 0, spotify_matches: data.spotify_matches || 0, failed: (data.spotify_total || 0) - (data.spotify_matches || 0) }); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = data.phase || 'discovering'; + updateYouTubeDiscoveryModal(urlHash, transformed); + } + if (data.phase === 'discovered' || data.phase === 'error') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + } + }; + } + + const pollInterval = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + const response = await fetch(`/api/beatport/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling Beatport discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + // Update state and modal (reuse YouTube infrastructure like Tidal) + if (youtubePlaylistStates[urlHash]) { + // Transform Beatport results to YouTube modal format (like Tidal does) + const transformedStatus = { + progress: status.progress || 0, + spotify_matches: status.spotify_matches || 0, + spotify_total: status.spotify_total || 0, + results: (status.results || []).map((result, index) => ({ + index: result.index !== undefined ? result.index : index, + yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', + yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', + status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + result.spotify_data.artists.map(a => a.name || a).join(', ') : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, // Pass through + spotify_id: result.spotify_id, // Pass through + manual_match: result.manual_match // Pass through + })) + }; + + // Update state with both backend and frontend formats (like Tidal) + const state = youtubePlaylistStates[urlHash]; + state.discovery_progress = status.progress; // Backend format + state.discoveryProgress = status.progress; // Frontend format - for modal progress display + state.spotify_matches = status.spotify_matches; // Backend format + state.spotifyMatches = status.spotify_matches; // Frontend format - for button logic + state.discovery_results = status.results; // Backend format + state.discoveryResults = transformedStatus.results; // Frontend format - for button logic + state.phase = status.phase || 'discovering'; + + // Update Beatport card phase and progress + const chartHash = state.beatport_chart_hash || urlHash; + updateBeatportCardPhase(chartHash, status.phase || 'discovering'); + updateBeatportCardProgress(chartHash, { + spotify_total: status.spotify_total || 0, + spotify_matches: status.spotify_matches || 0, + failed: (status.spotify_total || 0) - (status.spotify_matches || 0) + }); + + // Sync with backend Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = status.phase || 'discovering'; + } + + // Update modal display with transformed data + updateYouTubeDiscoveryModal(urlHash, transformedStatus); + } + + // Stop polling when discovery is complete + if (status.phase === 'discovered' || status.phase === 'error') { + console.log(`✅ Beatport discovery polling complete for: ${urlHash}`); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + + } catch (error) { + console.error('❌ Error polling Beatport discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + }, 2000); // Poll every 2 seconds like Tidal + + // Store the interval so we can clean it up later + activeYouTubePollers[urlHash] = pollInterval; +} + +function showBeatportSubView(viewType) { + // Hide main category view + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.remove('active'); + } + + // Hide all sub-views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + // Show the requested sub-view + const targetView = document.getElementById(`beatport-${viewType}-view`); + if (targetView) { + targetView.classList.add('active'); + console.log(`🎵 Showing Beatport ${viewType} view`); + } else { + console.error(`🎵 Could not find view: beatport-${viewType}-view`); + } +} + +function showBeatportMainView() { + // Hide all sub-views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + // Show main category view + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.add('active'); + console.log('🎵 Showing Beatport main view'); + } +} + +// =============================== +// REBUILD PAGE TOP 10 FUNCTIONALITY +// =============================== + +// Global variable to store rebuild page track data for reuse +let rebuildPageTrackData = { + beatport_top10: null, + hype_top10: null + // hero_slider removed - now uses individual slide click handlers +}; + +async function handleRebuildBeatportTop10Click() { + console.log('🎵 Handling Beatport Top 10 click on rebuild page'); + + // Use the existing chart creation pattern from Browse Charts EXACTLY + await handleRebuildChartClick('beatport_top10', 'Beatport Top 10', 'rebuild_beatport_top10'); +} + +async function handleRebuildHypeTop10Click() { + console.log('🔥 Handling Hype Top 10 click on rebuild page'); + + // Use the existing chart creation pattern from Browse Charts EXACTLY + await handleRebuildChartClick('hype_top10', 'Hype Top 10', 'rebuild_hype_top10'); +} + +// Hero slider now uses individual slide click handlers instead of container-level clicking +// The old handleRebuildHeroSliderClick function has been removed in favor of individual release discovery + +async function handleRebuildChartClick(trackDataKey, chartName, chartType) { + if (_beatportModalOpening) return; + _beatportModalOpening = true; + setTimeout(() => { _beatportModalOpening = false; }, 2000); + + try { + // Get basic track data from DOM + const trackData = await getRebuildPageTrackData(trackDataKey); + if (!trackData || trackData.length === 0) { + throw new Error(`No track data found for ${chartName}`); + } + + console.log(`✅ Got ${trackData.length} tracks from ${chartName}, enriching one-by-one...`); + showLoadingOverlay(`Fetching track metadata... (0/${trackData.length})`); + + const enrichedTracks = await _enrichTracksWithProgress(trackData, chartName); + + console.log(`✅ Enriched ${enrichedTracks.length} tracks`); + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); + + } catch (error) { + hideLoadingOverlay(); + console.error(`❌ Error handling ${chartName} click:`, error); + showToast(`Error loading ${chartName}: ${error.message}`, 'error'); + } +} + +async function getRebuildPageTrackData(trackDataKey) { + // First check if we have cached data from when the rebuild page was loaded + if (rebuildPageTrackData[trackDataKey]) { + console.log(`📦 Using cached ${trackDataKey} data`); + return rebuildPageTrackData[trackDataKey]; + } + + // If no cached data, extract from DOM (fallback) + console.log(`🔍 Extracting ${trackDataKey} data from rebuild page DOM`); + + let containerSelector, cardSelector; + if (trackDataKey === 'beatport_top10') { + containerSelector = '#beatport-top10-list'; + cardSelector = '.beatport-top10-card[data-url]'; + } else if (trackDataKey === 'hype_top10') { + containerSelector = '#beatport-hype10-list'; + cardSelector = '.beatport-hype10-card[data-url]'; + } else { + throw new Error(`Unknown track data key: ${trackDataKey}`); + } + + const container = document.querySelector(containerSelector); + if (!container) { + throw new Error(`Container ${containerSelector} not found`); + } + + const trackCards = container.querySelectorAll(cardSelector); + if (trackCards.length === 0) { + throw new Error(`No track cards found in ${containerSelector}`); + } + + // Extract track data from DOM cards + const tracks = Array.from(trackCards).map(card => { + const title = card.querySelector('.beatport-top10-card-title, .beatport-hype10-card-title')?.textContent?.trim() || 'Unknown Title'; + const artist = card.querySelector('.beatport-top10-card-artist, .beatport-hype10-card-artist')?.textContent?.trim() || 'Unknown Artist'; + const label = card.querySelector('.beatport-top10-card-label, .beatport-hype10-card-label')?.textContent?.trim() || 'Unknown Label'; + const url = card.getAttribute('data-url') || ''; + const rank = card.querySelector('.beatport-top10-card-rank, .beatport-hype10-card-rank')?.textContent?.trim() || ''; + + return { + title: title, + artist: artist, + label: label, + url: url, + rank: rank + }; + }); + + console.log(`📋 Extracted ${tracks.length} tracks from ${containerSelector}`); + + // Cache for future use + rebuildPageTrackData[trackDataKey] = tracks; + + return tracks; +} + +// getHeroSliderTrackData function removed - hero slider now uses individual slide click handlers +// Each slide will create its own discovery modal using handleBeatportReleaseCardClick + +// Hook into the loadBeatportTop10Lists function to cache track data +const originalLoadBeatportTop10Lists = window.loadBeatportTop10Lists; +if (originalLoadBeatportTop10Lists) { + window.loadBeatportTop10Lists = async function () { + const result = await originalLoadBeatportTop10Lists.apply(this, arguments); + + // If the load was successful, we can potentially cache the track data + // But for now, we'll rely on DOM extraction as it's more reliable + + return result; + }; +} + +// =============================== +// BEATPORT CHART FUNCTIONALITY +// =============================== + +function createBeatportCard(chartData) { + const state = beatportChartStates[chartData.hash]; + const phase = state ? state.phase : 'fresh'; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` +
+
🎧
+
+
${escapeHtml(chartData.name)}
+
+ ${chartData.track_count} tracks + ${phaseText} +
+
+
+ +
+ +
+ `; +} + +function addBeatportCardToContainer(chartData) { + const container = document.getElementById('beatport-playlist-container'); + + // Remove placeholder if it exists + const placeholder = container.querySelector('.playlist-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Check if card already exists + const existingCard = document.getElementById(`beatport-card-${chartData.hash}`); + if (existingCard) { + console.log(`Card already exists for ${chartData.name}, updating instead`); + return; + } + + // Create and add the card + const cardHtml = createBeatportCard(chartData); + container.insertAdjacentHTML('beforeend', cardHtml); + + // Initialize state + beatportChartStates[chartData.hash] = { + phase: 'fresh', + chart: chartData, + cardElement: document.getElementById(`beatport-card-${chartData.hash}`) + }; + + // Add click handler + const card = document.getElementById(`beatport-card-${chartData.hash}`); + if (card) { + card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash)); + } + + console.log(`🃏 Created Beatport card: ${chartData.name}`); + + // Auto-mirror this Beatport chart + if (chartData.tracks && chartData.tracks.length > 0) { + mirrorPlaylist('beatport', chartData.hash, chartData.name, chartData.tracks.map(t => ({ + track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''), + album_name: t.album || '', duration_ms: t.duration_ms || 0, + source_track_id: t.id || '', image_url: t.image_url || null + }))); + } + + // Update clear button state after creating card + updateBeatportClearButtonState(); +} + +async function handleBeatportCardClick(chartHash) { + const state = beatportChartStates[chartHash]; + if (!state) { + console.error(`❌ [Card Click] No state found for Beatport chart: ${chartHash}`); + showToast('Chart state not found - try refreshing the page', 'error'); + return; + } + + if (!state.chart) { + console.error(`❌ [Card Click] No chart data found for Beatport chart: ${chartHash}`); + showToast('Chart data missing - try refreshing the page', 'error'); + return; + } + + console.log(`🎧 [Card Click] Beatport card clicked: ${chartHash}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + // Open discovery modal and start discovery + openBeatportDiscoveryModal(chartHash, state.chart); + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + // Reopen existing modal with preserved discovery results + console.log(`🎧 [Card Click] Opening Beatport discovery modal for ${state.phase} phase`); + + // Check if we have the required state data + const ytState = youtubePlaylistStates[chartHash]; + if (!ytState || !ytState.playlist) { + console.log(`🔍 [Card Click] Missing playlist data for ${state.phase} phase, fetching from backend...`); + + try { + // Fetch the full state from backend + const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + + // Restore the missing playlist data + if (fullState.chart_data) { + if (!youtubePlaylistStates[chartHash]) { + youtubePlaylistStates[chartHash] = {}; + } + youtubePlaylistStates[chartHash].playlist = fullState.chart_data; + youtubePlaylistStates[chartHash].is_beatport_playlist = true; + youtubePlaylistStates[chartHash].beatport_chart_hash = chartHash; + + // Also restore discovery results if available + if (fullState.discovery_results) { + youtubePlaylistStates[chartHash].discovery_results = fullState.discovery_results; + console.log(`🔄 [Hydration] Restored ${fullState.discovery_results.length} discovery results`); + console.log(`🔄 [Hydration] First result:`, fullState.discovery_results[0]); + } + + // Restore discovery progress state + if (fullState.discovery_progress !== undefined) { + youtubePlaylistStates[chartHash].discovery_progress = fullState.discovery_progress; + } + if (fullState.spotify_matches !== undefined) { + youtubePlaylistStates[chartHash].spotify_matches = fullState.spotify_matches; + console.log(`🔄 [Hydration] Restored spotify_matches: ${fullState.spotify_matches}`); + } + if (fullState.spotify_total !== undefined) { + youtubePlaylistStates[chartHash].spotify_total = fullState.spotify_total; + } + + console.log(`✅ [Card Click] Restored playlist data for ${state.phase} phase`); + } + } else { + console.error(`❌ [Card Click] Failed to fetch state for chart: ${chartHash}`); + showToast('Error loading chart data', 'error'); + return; + } + } catch (error) { + console.error(`❌ [Card Click] Error fetching chart state:`, error); + showToast('Error loading chart data', 'error'); + return; + } + } + + openYouTubeDiscoveryModal(chartHash); + + // If still in discovering phase, start polling for live updates + if (state.phase === 'discovering') { + console.log(`🔄 [Card Click] Starting discovery polling for ${state.phase} phase`); + + // Let the polling handle all modal updates to avoid data structure mismatches + console.log(`📊 [Card Click] Starting polling - it will update modal with current progress`); + + startBeatportDiscoveryPolling(chartHash); + } + } else if (state.phase === 'downloading' || state.phase === 'download_complete') { + // Open download modal if we have the converted playlist ID (following YouTube/Tidal pattern) + const ytState = youtubePlaylistStates[chartHash]; + if (ytState && ytState.is_beatport_playlist && ytState.convertedSpotifyPlaylistId) { + console.log(`📥 [Card Click] Opening download modal for Beatport chart: ${ytState.playlist.name} (phase: ${state.phase})`); + + // Check if modal already exists, if not create it (like Tidal implementation) + if (activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]) { + const process = activeDownloadProcesses[ytState.convertedSpotifyPlaylistId]; + if (process.modalElement) { + console.log(`📱 [Card Click] Showing existing download modal for ${state.phase} phase`); + process.modalElement.style.display = 'flex'; + } else { + console.warn(`⚠️ [Card Click] Download process exists but modal element missing - rehydrating`); + await rehydrateBeatportDownloadModal(chartHash, ytState); + } + } else { + // Need to create the download modal - fetch the discovery results if needed + console.log(`🔧 [Card Click] Rehydrating Beatport download modal for ${state.phase} phase`); + await rehydrateBeatportDownloadModal(chartHash, ytState); + } + } else { + console.error('❌ [Card Click] No converted Spotify playlist ID found for Beatport download modal'); + console.log('📊 [Card Click] Available state data:', Object.keys(ytState || {})); + + // Fallback: try to open discovery modal if we have discovery results + if (ytState && ytState.discovery_results && ytState.discovery_results.length > 0) { + console.log(`🔄 [Card Click] Fallback: Opening discovery modal with ${ytState.discovery_results.length} results`); + openYouTubeDiscoveryModal(chartHash); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateBeatportDownloadModal(chartHash, ytState) { + try { + console.log(`💧 [Rehydration] Attempting fallback rehydration for Beatport chart: ${chartHash}`); + + // This function is only called as a fallback when the modal wasn't created during backend loading + // In most cases, the modal should already exist from loadBeatportChartsFromBackend() + + if (!ytState || !ytState.playlist || !ytState.convertedSpotifyPlaylistId) { + console.error(`❌ [Rehydration] Invalid state data for Beatport chart: ${chartHash}`); + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + // Get discovery results from backend if not already loaded + if (!ytState.discovery_results) { + console.log(`🔍 Fetching discovery results from backend for Beatport chart: ${chartHash}`); + const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + ytState.discovery_results = fullState.discovery_results; + ytState.download_process_id = fullState.download_process_id; + console.log(`✅ Loaded ${fullState.discovery_results?.length || 0} discovery results from backend`); + } else { + console.error('❌ Failed to fetch Beatport discovery results from backend'); + showToast('Error loading playlist data', 'error'); + return; + } + } + + // Extract Spotify tracks from discovery results + const spotifyTracks = ytState.discovery_results + .filter(result => result.spotify_data) + .map(result => { + const track = result.spotify_data; + // Ensure artists is an array of strings + if (track.artists && Array.isArray(track.artists)) { + track.artists = track.artists.map(artist => + typeof artist === 'string' ? artist : (artist.name || artist) + ); + } else if (track.artists && typeof track.artists === 'string') { + track.artists = [track.artists]; + } else { + track.artists = ['Unknown Artist']; + } + return { + id: track.id, + name: track.name, + artists: track.artists, + album: track.album || 'Unknown Album', + duration_ms: track.duration_ms || 0, + external_urls: track.external_urls || {} + }; + }); + + if (spotifyTracks.length === 0) { + console.error('❌ No Spotify tracks found for download modal'); + showToast('No Spotify matches found for download', 'error'); + return; + } + + const virtualPlaylistId = ytState.convertedSpotifyPlaylistId; + const playlistName = ytState.playlist.name; + + // Create the download modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + // Set up the modal for the running state if we have a download process ID + if (ytState.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = ytState.download_process_id; + + // Update UI to reflect running state + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for this process + startModalDownloadPolling(virtualPlaylistId); + + console.log(`✅ [Rehydration] Fallback modal rehydrated for running download process`); + } + } + + } catch (error) { + console.error(`❌ [Rehydration] Error in fallback rehydration for Beatport chart:`, error); + showToast('Error opening download modal', 'error'); + hideLoadingOverlay(); + } +} + +function updateBeatportCardPhase(chartHash, phase) { + const state = beatportChartStates[chartHash]; + if (!state) return; + + state.phase = phase; + + // Re-render the card with new phase + const card = document.getElementById(`beatport-card-${chartHash}`); + if (card) { + const newCardHtml = createBeatportCard(state.chart); + card.outerHTML = newCardHtml; + + // Re-attach click handler + const newCard = document.getElementById(`beatport-card-${chartHash}`); + if (newCard) { + newCard.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); + state.cardElement = newCard; + } + } + + // Update clear button state after phase change + updateBeatportClearButtonState(); +} + +function updateBeatportCardProgress(chartHash, progress) { + const state = beatportChartStates[chartHash]; + if (!state) return; + + const card = document.getElementById(`beatport-card-${chartHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + const { spotify_total, spotify_matches, failed } = progress; + const percentage = spotify_total > 0 ? Math.round((spotify_matches / spotify_total) * 100) : 0; + + progressElement.textContent = `♪ ${spotify_total} / ✓ ${spotify_matches} / ✗ ${failed} / ${percentage}%`; + progressElement.classList.remove('hidden'); + + console.log('🎧 Updated Beatport card progress:', chartHash, `${spotify_matches}/${spotify_total} (${percentage}%)`); +} + +function switchToBeatportPlaylistsTab() { + // Switch from "Browse Charts" to "My Playlists" tab + const browseTab = document.querySelector('.beatport-tab-button[data-beatport-tab="browse"]'); + const playlistsTab = document.querySelector('.beatport-tab-button[data-beatport-tab="playlists"]'); + const browseContent = document.getElementById('beatport-browse-content'); + const playlistsContent = document.getElementById('beatport-playlists-content'); + + if (browseTab && playlistsTab && browseContent && playlistsContent) { + // Update tab buttons + browseTab.classList.remove('active'); + playlistsTab.classList.add('active'); + + // Update tab content + browseContent.classList.remove('active'); + playlistsContent.classList.add('active'); + + console.log('🔄 Switched to Beatport "My Playlists" tab'); + } +} + +// =============================== +// BEATPORT SYNC FUNCTIONALITY +// =============================== + +async function startBeatportPlaylistSync(urlHash) { + try { + console.log('🎧 Starting Beatport playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_beatport_playlist) { + console.error('❌ Invalid Beatport playlist state for sync'); + showToast('Invalid Beatport playlist state', 'error'); + return; + } + + // Call Beatport sync endpoint + const response = await fetch(`/api/beatport/sync/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + // Capture sync_playlist_id for WebSocket subscription (Beatport returns sync_id) + const syncPlaylistId = result.sync_id || result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + // Update state to syncing + state.phase = 'syncing'; + updateBeatportCardPhase(state.beatport_chart_hash || urlHash, 'syncing'); + + // Update modal buttons and start polling + updateBeatportModalButtons(urlHash, 'syncing'); + startBeatportSyncPolling(urlHash, syncPlaylistId); + + showToast('Starting Beatport playlist sync...', 'success'); + + } catch (error) { + console.error('❌ Error starting Beatport sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startBeatportSyncPolling(urlHash, syncPlaylistId) { + // Stop any existing polling (reuse activeYouTubePollers for Beatport) + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + // Resolve syncPlaylistId from argument or stored state + const bpState = youtubePlaylistStates[urlHash]; + syncPlaylistId = syncPlaylistId || (bpState && bpState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateBeatportModalSyncProgress(urlHash, progress); + + if (data.status === 'finished' || data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + + const state = youtubePlaylistStates[urlHash]; + if (state) { + const chartHash = state.beatport_chart_hash || urlHash; + if (data.status === 'finished') { + state.phase = 'sync_complete'; + updateBeatportCardPhase(chartHash, 'sync_complete'); + updateBeatportModalButtons(urlHash, 'sync_complete'); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete'; + } else { + state.phase = 'discovered'; + updateBeatportCardPhase(chartHash, 'discovered'); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered'; + } + } + } + }; + } + + // Define the polling function (HTTP fallback) + const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates + try { + const response = await fetch(`/api/beatport/sync/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling Beatport sync:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateBeatportModalSyncProgress(urlHash, status.progress); + + if (status.complete || status.status === 'error') { + const state = youtubePlaylistStates[urlHash]; + if (state) { + const chartHash = state.beatport_chart_hash || urlHash; + if (status.complete) { + state.phase = 'sync_complete'; + state.convertedSpotifyPlaylistId = status.converted_spotify_playlist_id; + updateBeatportCardPhase(chartHash, 'sync_complete'); + updateBeatportModalButtons(urlHash, 'sync_complete'); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'sync_complete'; + } else { + state.phase = 'discovered'; + updateBeatportCardPhase(chartHash, 'discovered'); + if (beatportChartStates[chartHash]) beatportChartStates[chartHash].phase = 'discovered'; + } + } + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + } catch (error) { + console.error('❌ Error polling Beatport sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); + + // Then continue polling at regular intervals + const pollInterval = setInterval(pollFunction, 2000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelBeatportSync(urlHash) { + try { + console.log('❌ Cancelling Beatport sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_beatport_playlist) { + console.error('❌ Invalid Beatport playlist state'); + return; + } + + const response = await fetch(`/api/beatport/sync/cancel/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + // Stop polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Phase 6: Clean up WS subscription + const bpSyncId = state && state.syncPlaylistId; + if (bpSyncId && _syncProgressCallbacks[bpSyncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [bpSyncId] }); + delete _syncProgressCallbacks[bpSyncId]; + } + + // Revert to discovered phase + const chartHash = state.beatport_chart_hash || urlHash; + state.phase = 'discovered'; + updateBeatportCardPhase(chartHash, 'discovered'); + updateBeatportModalButtons(urlHash, 'discovered'); + + // Sync with backend Beatport chart state + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'discovered'; + } + + showToast('Beatport sync cancelled', 'info'); + + } catch (error) { + console.error('❌ Error cancelling Beatport sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateBeatportModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`beatport-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + console.log(`📊 Updating Beatport modal sync progress for ${urlHash}:`, progress); + + // Update individual counters with Beatport-specific IDs + const totalEl = document.getElementById(`beatport-total-${urlHash}`); + const matchedEl = document.getElementById(`beatport-matched-${urlHash}`); + const failedEl = document.getElementById(`beatport-failed-${urlHash}`); + const percentageEl = document.getElementById(`beatport-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const percentage = total > 0 ? Math.round((matched / total) * 100) : 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = percentage; +} + +function updateBeatportModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +async function startBeatportDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for Beatport chart:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + // Support both camelCase and snake_case + const discoveryResults = state?.discoveryResults || state?.discovery_results; + + if (!state || !discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + if (!state.is_beatport_playlist) { + console.error('❌ State is not a Beatport playlist'); + showToast('Invalid Beatport chart state', 'error'); + return; + } + + // Convert Beatport discovery results to Spotify tracks format (like Tidal does) + console.log(`🔍 Total discovery results: ${discoveryResults.length}`); + console.log(`🔍 First result (full object):`, JSON.stringify(discoveryResults[0], null, 2)); + console.log(`🔍 Second result (full object):`, JSON.stringify(discoveryResults[1], null, 2)); + console.log(`🔍 Results with spotify_data:`, discoveryResults.filter(r => r.spotify_data).length); + console.log(`🔍 Results with spotify_id:`, discoveryResults.filter(r => r.spotify_id).length); + + const spotifyTracks = discoveryResults + .filter(result => { + // Accept if has spotify_data OR if has spotify_track (from automatic discovery) + return result.spotify_data || (result.spotify_track && result.status_class === 'found'); + }) + .map(result => { + // Use spotify_data if available, otherwise build from individual fields + let track; + if (result.spotify_data) { + track = result.spotify_data; + } else { + // Build from individual fields (automatic discovery format) + // Convert album to proper object format for wishlist compatibility + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + track = { + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }; + } + + // Ensure artists is an array of strings + if (track.artists && Array.isArray(track.artists)) { + track.artists = track.artists.map(artist => + typeof artist === 'string' ? artist : (artist.name || artist) + ); + } else if (track.artists && typeof track.artists === 'string') { + track.artists = [track.artists]; + } else { + track.artists = ['Unknown Artist']; + } + + // Ensure album is an object (in case it was converted back to string somehow) + const albumForReturn = typeof track.album === 'object' && track.album !== null + ? track.album + : { + name: typeof track.album === 'string' ? track.album : 'Unknown Album', + album_type: 'album', + images: [] + }; + + return { + id: track.id, + name: track.name, + artists: track.artists, + album: albumForReturn, + duration_ms: track.duration_ms || 0, + external_urls: track.external_urls || {} + }; + }); + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + console.log(`🎧 Found ${spotifyTracks.length} Spotify tracks for Beatport download`); + + // Create a virtual playlist for the download system + const virtualPlaylistId = `beatport_${urlHash}`; + const playlistName = state.playlist.name; + + // Store reference for card navigation (but don't change phase yet) + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Store converted playlist ID in backend but keep current phase + const chartHash = state.beatport_chart_hash || urlHash; + if (beatportChartStates[chartHash]) { + try { + await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: state.phase, // Keep current phase (should be 'discovered') + converted_spotify_playlist_id: virtualPlaylistId + }) + }); + console.log('✅ Updated backend with Beatport converted playlist ID (phase unchanged)'); + } catch (error) { + console.warn('⚠️ Error updating backend Beatport state:', error); + } + } + + // Close the discovery modal if it's open + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + console.log('🔄 Closed Beatport discovery modal to show download modal'); + } + + // DON'T update card phase here - let the download modal handle phase changes when "Begin Analysis" is clicked + + // Open download missing tracks modal using the same system as YouTube/Tidal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + console.log(`✅ Opened download modal for Beatport chart: ${state.playlist.name}`); + + } catch (error) { + console.error('❌ Error starting Beatport download missing tracks:', error); + showToast(`Error starting downloads: ${error.message}`, 'error'); + } +} + +async function handleBeatportChartClick(chartType, chartId, chartName, chartEndpoint) { + console.log(`🎵 Beatport chart clicked: ${chartType} - ${chartId} - ${chartName}`); + + try { + showToast(`Loading ${chartName}...`, 'info'); + showLoadingOverlay(`Loading ${chartName}...`); + + const response = await fetch(`${chartEndpoint}?limit=100`); + if (!response.ok) { + throw new Error(`Failed to fetch ${chartName}: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartName}`); + } + + console.log(`✅ Fetched ${data.tracks.length} tracks from ${chartName}`); + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(data.tracks, chartName, null); + + } catch (error) { + console.error(`❌ Error handling Beatport chart click:`, error); + hideLoadingOverlay(); + showToast(`Error loading ${chartName || chartId}: ${error.message}`, 'error'); + } +} + +function handleBeatportGenreClick(genreSlug, genreId, genreName) { + console.log(`🎵 Beatport genre clicked: ${genreName} (${genreSlug}/${genreId}) - SHOWING GENRE DETAIL VIEW`); + console.log(`📝 Debug: Parameters received - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`); + + // Navigate to genre detail view with proper parameters + showBeatportGenreDetailView(genreSlug, genreId, genreName); +} + +function showBeatportGenreDetailView(genreSlug, genreId, genreName) { + console.log(`🎯 Showing genre detail view for: ${genreName}`); + console.log(`📝 Debug: Function called with - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`); + + // Hide all other beatport views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.remove('active'); + } + + // Show genre detail view + const genreDetailView = document.getElementById('beatport-genre-detail-view'); + if (genreDetailView) { + genreDetailView.classList.add('active'); + console.log(`📝 Debug: Genre detail view element found and activated`); + + // Update view content + const titleElement = document.getElementById('genre-detail-title'); + const breadcrumbElement = document.getElementById('genre-detail-breadcrumb'); + + console.log(`📝 Debug: Title element found: ${!!titleElement}, Breadcrumb element found: ${!!breadcrumbElement}`); + + if (titleElement) { + titleElement.textContent = genreName; + console.log(`📝 Debug: Updated title to: ${genreName}`); + } + if (breadcrumbElement) { + breadcrumbElement.textContent = `Browse Charts > Genre Explorer > ${genreName} Charts`; + console.log(`📝 Debug: Updated breadcrumb`); + } + + // Update chart type titles with genre name + const chartTitles = [ + 'genre-top-10-title', + 'genre-top-100-title', + 'genre-releases-top-10-title', + 'genre-releases-top-100-title', + 'genre-staff-picks-title', + 'genre-latest-releases-title', + 'genre-new-charts-title' + ]; + + chartTitles.forEach(titleId => { + const element = document.getElementById(titleId); + if (element) { + console.log(`📝 Debug: Found chart title element: ${titleId}`); + } else { + console.log(`📝 Debug: Missing chart title element: ${titleId}`); + } + }); + + document.getElementById('genre-top-10-title').textContent = `Top 10 ${genreName}`; + document.getElementById('genre-top-100-title').textContent = `Top 100 ${genreName}`; + document.getElementById('genre-releases-top-10-title').textContent = `Top 10 ${genreName} Releases`; + document.getElementById('genre-releases-top-100-title').textContent = `Top 100 ${genreName} Releases`; + document.getElementById('genre-staff-picks-title').textContent = `${genreName} Staff Picks`; + document.getElementById('genre-latest-releases-title').textContent = `Latest ${genreName} Releases`; + + // Update Hype section titles + document.getElementById('genre-hype-top-10-title').textContent = `${genreName} Hype Top 10`; + document.getElementById('genre-hype-top-100-title').textContent = `${genreName} Hype Top 100`; + document.getElementById('genre-hype-picks-title').textContent = `${genreName} Hype Picks`; + + // Load new charts directly (no expansion needed) + console.log(`🔄 Auto-loading new charts for ${genreName}...`); + loadNewChartsInline(genreSlug, genreId, genreName); + + // Store current genre data for chart type handlers + genreDetailView.dataset.genreSlug = genreSlug; + genreDetailView.dataset.genreId = genreId; + genreDetailView.dataset.genreName = genreName; + + // Add click handlers to chart type cards + setupGenreChartTypeHandlers(); + + console.log(`✅ Genre detail view shown for ${genreName}`); + } else { + console.error('❌ Genre detail view element not found'); + } +} + +function setupGenreChartTypeHandlers() { + const chartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card'); + + chartTypeCards.forEach(card => { + // Remove existing listeners + card.replaceWith(card.cloneNode(true)); + }); + + // Re-select after cloning + const newChartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card'); + + newChartTypeCards.forEach(card => { + card.addEventListener('click', () => { + const chartType = card.dataset.chartType; + const genreDetailView = document.getElementById('beatport-genre-detail-view'); + const genreSlug = genreDetailView.dataset.genreSlug; + const genreId = genreDetailView.dataset.genreId; + const genreName = genreDetailView.dataset.genreName; + + // All chart types now go directly to discovery modal + handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType); + }); + }); +} + +function showBeatportGenresView() { + // Hide genre detail view and show genres view + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + const genresView = document.getElementById('beatport-genres-view'); + if (genresView) { + genresView.classList.add('active'); + } +} + +async function toggleNewChartsExpansion(genreSlug, genreId, genreName) { + console.log(`📈 Toggling new charts expansion for: ${genreName}`); + + const expandedContent = document.getElementById('new-charts-expanded'); + const expandIndicator = document.getElementById('expand-indicator'); + const chartsCount = document.getElementById('new-charts-count'); + + if (!expandedContent || !expandIndicator) { + console.error('❌ New charts expansion elements not found'); + return; + } + + // Check if already expanded + const isExpanded = expandedContent.style.display !== 'none'; + + if (isExpanded) { + // Collapse + expandedContent.style.display = 'none'; + expandIndicator.classList.remove('expanded'); + console.log('📉 Collapsed new charts section'); + } else { + // Expand and load charts + expandedContent.style.display = 'block'; + expandIndicator.classList.add('expanded'); + + // Load charts if not already loaded + await loadNewChartsInline(genreSlug, genreId, genreName); + console.log('📈 Expanded new charts section'); + } +} + +async function loadNewChartsInline(genreSlug, genreId, genreName) { + const chartsGrid = document.getElementById('new-charts-grid'); + const loadingInline = document.getElementById('charts-loading-inline'); + + if (!chartsGrid || !loadingInline) { + console.error('❌ Inline charts elements not found'); + return; + } + + // Show loading state + loadingInline.style.display = 'block'; + chartsGrid.style.display = 'none'; + chartsGrid.innerHTML = ''; + + try { + console.log(`🔍 Loading inline charts for ${genreName}...`); + + // Fetch charts from the new-charts endpoint + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=20`); + if (!response.ok) { + throw new Error(`Failed to fetch charts: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + // Show empty state + chartsGrid.innerHTML = ` +
+

No Charts Available

+

No curated charts found for ${genreName} at the moment.

+
+ `; + } else { + // Populate charts grid + const chartsHTML = data.tracks.map((chart, index) => { + const chartName = chart.title || 'Untitled Chart'; + const artistName = chart.artist || 'Various Artists'; + const chartUrl = chart.url || ''; + + return ` +
+
+
📈
+
+
${chartName}
+

by ${artistName}

+
+
+
+ Curated ${genreName} chart collection +
+ +
+ `; + }).join(''); + + chartsGrid.innerHTML = chartsHTML; + + // Add click handlers to chart items + setupNewChartItemHandlers(genreSlug, genreId, genreName); + } + + // Hide loading and show grid + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + console.log(`✅ Loaded ${data.tracks?.length || 0} inline charts for ${genreName}`); + showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success'); + + } catch (error) { + console.error(`❌ Error loading inline charts for ${genreName}:`, error); + + // Show error state + chartsGrid.innerHTML = ` +
+

Error Loading Charts

+

Unable to load chart collections for ${genreName}.

+
+ `; + + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + showToast(`Error loading charts: ${error.message}`, 'error'); + } +} + +async function loadDJChartsInline() { + const chartsGrid = document.getElementById('dj-charts-grid'); + const loadingInline = document.getElementById('dj-charts-loading-inline'); + + if (!chartsGrid || !loadingInline) { + console.error('❌ DJ charts elements not found'); + return; + } + + // Show loading state + loadingInline.style.display = 'block'; + chartsGrid.style.display = 'none'; + chartsGrid.innerHTML = ''; + + try { + console.log('🔍 Loading DJ charts...'); + + // Fetch charts from the dj-charts-improved endpoint + const response = await fetch('/api/beatport/dj-charts-improved?limit=20'); + if (!response.ok) { + throw new Error(`Failed to fetch DJ charts: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.charts || data.charts.length === 0) { + // Show empty state + chartsGrid.innerHTML = ` +
+

No DJ Charts Available

+

No DJ curated charts found at the moment.

+
+ `; + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + return; + } + + // Create chart items using New Charts structure + const chartsHTML = data.charts.map(chart => { + const chartName = chart.name || chart.title || 'Untitled Chart'; + const artistName = chart.artist || chart.curator || 'Various Artists'; + const chartUrl = chart.url || chart.chart_url || ''; + + return ` +
+
+
🎧
+
+
${chartName}
+

by ${artistName}

+
+
+
+ DJ curated chart collection +
+ +
+ `; + }).join(''); + + chartsGrid.innerHTML = chartsHTML; + + // Hide loading, show content + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + // Setup click handlers for chart items + setupDJChartItemHandlers(); + + console.log(`✅ Loaded ${data.charts.length} DJ charts`); + + } catch (error) { + console.error('❌ Error loading DJ charts:', error); + + // Show error state + chartsGrid.innerHTML = ` +
+

Error Loading DJ Charts

+

Unable to load DJ chart collections.

+
+ `; + + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + showToast(`Error loading DJ charts: ${error.message}`, 'error'); + } +} + +async function loadFeaturedChartsInline() { + const chartsGrid = document.getElementById('featured-charts-grid'); + const loadingInline = document.getElementById('featured-charts-loading-inline'); + + if (!chartsGrid || !loadingInline) { + console.error('❌ Featured charts elements not found'); + return; + } + + // Show loading state + loadingInline.style.display = 'block'; + chartsGrid.style.display = 'none'; + chartsGrid.innerHTML = ''; + + try { + console.log('🔍 Loading Featured charts...'); + + // Fetch charts from the homepage/featured-charts endpoint + const response = await fetch('/api/beatport/homepage/featured-charts?limit=20'); + if (!response.ok) { + throw new Error(`Failed to fetch Featured charts: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + // Show empty state + chartsGrid.innerHTML = ` +
+

No Featured Charts Available

+

No featured curated charts found at the moment.

+
+ `; + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + return; + } + + // Create chart items using New Charts structure + const chartsHTML = data.tracks.map(chart => { + const chartName = chart.name || chart.title || 'Untitled Chart'; + const artistName = chart.artist || chart.curator || 'Various Artists'; + const chartUrl = chart.url || chart.chart_url || ''; + + return ` +
+
+
+
+
${chartName}
+

by ${artistName}

+
+
+
+ Editor curated chart collection +
+ +
+ `; + }).join(''); + + chartsGrid.innerHTML = chartsHTML; + + // Hide loading, show content + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + // Setup click handlers for chart items + setupFeaturedChartItemHandlers(); + + console.log(`✅ Loaded ${data.tracks.length} Featured charts`); + + } catch (error) { + console.error('❌ Error loading Featured charts:', error); + + // Show error state + chartsGrid.innerHTML = ` +
+

Error Loading Featured Charts

+

Unable to load featured chart collections.

+
+ `; + + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + showToast(`Error loading Featured charts: ${error.message}`, 'error'); + } +} + +function setupDJChartItemHandlers() { + const chartItems = document.querySelectorAll('#dj-charts-grid .new-chart-item'); + + chartItems.forEach(item => { + item.addEventListener('click', async () => { + const chartName = item.dataset.chartName; + const chartUrl = item.dataset.chartUrl; + + console.log(`🎧 DJ Chart clicked: ${chartName}`); + + try { + showToast(`Loading ${chartName}...`, 'info'); + showLoadingOverlay(`Scraping ${chartName}...`); + + const response = await fetch('/api/beatport/chart/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) + }); + + if (!response.ok) { + throw new Error(`Failed to extract chart tracks: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in chart'); + } + + console.log(`✅ Extracted ${data.tracks.length} raw tracks from DJ chart, enriching...`); + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); + + } catch (error) { + console.error('❌ Error extracting DJ chart tracks:', error); + hideLoadingOverlay(); + showToast(`Error loading chart: ${error.message}`, 'error'); + } + }); + }); +} + +function setupFeaturedChartItemHandlers() { + const chartItems = document.querySelectorAll('#featured-charts-grid .new-chart-item'); + + chartItems.forEach(item => { + item.addEventListener('click', async () => { + const chartName = item.dataset.chartName; + const chartUrl = item.dataset.chartUrl; + + console.log(`⭐ Featured Chart clicked: ${chartName}`); + + try { + showToast(`Loading ${chartName}...`, 'info'); + showLoadingOverlay(`Scraping ${chartName}...`); + + const response = await fetch('/api/beatport/chart/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) + }); + + if (!response.ok) { + throw new Error(`Failed to extract chart tracks: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in chart'); + } + + console.log(`✅ Extracted ${data.tracks.length} raw tracks from Featured chart, enriching...`); + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, chartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, chartName, null); + + } catch (error) { + console.error('❌ Error extracting Featured chart tracks:', error); + hideLoadingOverlay(); + showToast(`Error loading chart: ${error.message}`, 'error'); + } + }); + }); +} + +function setupNewChartItemHandlers(genreSlug, genreId, genreName) { + const chartItems = document.querySelectorAll('#new-charts-grid .new-chart-item'); + + chartItems.forEach(item => { + item.addEventListener('click', async () => { + const chartName = item.dataset.chartName; + const chartArtist = item.dataset.chartArtist; + const chartUrl = item.dataset.chartUrl; + + console.log(`🎵 Chart clicked: ${chartName} by ${chartArtist}`); + + const fullChartName = `${chartName} (${genreName})`; + + try { + showToast(`Loading ${chartName}...`, 'info'); + showLoadingOverlay(`Scraping ${chartName}...`); + + const response = await fetch('/api/beatport/chart/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) + }); + + if (!response.ok) { + throw new Error(`Failed to fetch chart content: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in chart'); + } + + console.log(`✅ Extracted ${data.tracks.length} raw tracks from ${fullChartName}, enriching...`); + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, fullChartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, fullChartName, null); + + } catch (error) { + console.error(`❌ Error loading chart: ${error.message}`); + hideLoadingOverlay(); + showToast(`Error loading chart: ${error.message}`, 'error'); + } + }); + }); +} + +function showBeatportGenreDetailViewFromBack() { + // Show genre detail view (used by charts list back button) + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + const genreDetailView = document.getElementById('beatport-genre-detail-view'); + if (genreDetailView) { + genreDetailView.classList.add('active'); + } +} + +async function showBeatportGenreChartsListView(genreSlug, genreId, genreName) { + console.log(`📈 Showing charts list for: ${genreName}`); + + // Hide all other beatport views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.remove('active'); + } + + // Show charts list view + const chartsListView = document.getElementById('beatport-genre-charts-list-view'); + if (chartsListView) { + chartsListView.classList.add('active'); + + // Update view content + document.getElementById('genre-charts-list-title').textContent = `New ${genreName} Charts`; + document.getElementById('genre-charts-list-breadcrumb').textContent = `Browse Charts > Genre Explorer > ${genreName} Charts > New Charts`; + + // Store current genre data for individual chart handlers + chartsListView.dataset.genreSlug = genreSlug; + chartsListView.dataset.genreId = genreId; + chartsListView.dataset.genreName = genreName; + + // Load charts for this genre + await loadGenreChartsList(genreSlug, genreId, genreName); + + console.log(`✅ Charts list view shown for ${genreName}`); + } else { + console.error('❌ Charts list view element not found'); + } +} + +async function loadGenreChartsList(genreSlug, genreId, genreName) { + const chartsGrid = document.getElementById('genre-charts-grid'); + const loadingPlaceholder = document.getElementById('charts-loading-placeholder'); + + if (!chartsGrid || !loadingPlaceholder) { + console.error('❌ Charts grid or loading placeholder not found'); + return; + } + + // Show loading state + loadingPlaceholder.style.display = 'block'; + chartsGrid.style.display = 'none'; + chartsGrid.innerHTML = ''; + + try { + console.log(`🔍 Loading charts for ${genreName}...`); + + // Fetch charts from the new-charts endpoint + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=50`); + if (!response.ok) { + throw new Error(`Failed to fetch charts: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + // Show empty state + chartsGrid.innerHTML = ` +
+

No Charts Available

+

No curated charts found for ${genreName} at the moment.
Check back later for new DJ and artist chart collections.

+
+ `; + } else { + // Populate charts grid + const chartsHTML = data.tracks.map((chart, index) => { + const chartName = chart.title || 'Untitled Chart'; + const artistName = chart.artist || 'Various Artists'; + const chartUrl = chart.url || ''; + + // Extract chart ID from URL for click handling + const chartId = chartUrl.split('/').pop() || `chart_${index}`; + + return ` +
+
+
📈
+
+

${chartName}

+

by ${artistName}

+
+
+
+ Curated chart collection featuring ${genreName} tracks +
+ +
+ `; + }).join(''); + + chartsGrid.innerHTML = chartsHTML; + + // Add click handlers to chart items + setupGenreChartItemHandlers(genreSlug, genreId, genreName); + } + + // Hide loading and show grid + loadingPlaceholder.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + console.log(`✅ Loaded ${data.tracks?.length || 0} charts for ${genreName}`); + showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success'); + + } catch (error) { + console.error(`❌ Error loading charts for ${genreName}:`, error); + + // Show error state + chartsGrid.innerHTML = ` +
+

Error Loading Charts

+

Unable to load chart collections for ${genreName}.
Please try again later.

+
+ `; + + loadingPlaceholder.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + showToast(`Error loading charts: ${error.message}`, 'error'); + } +} + +function setupGenreChartItemHandlers(genreSlug, genreId, genreName) { + const chartItems = document.querySelectorAll('#genre-charts-grid .genre-chart-item'); + + chartItems.forEach(item => { + item.addEventListener('click', async () => { + const chartName = item.dataset.chartName; + const chartArtist = item.dataset.chartArtist; + const chartUrl = item.dataset.chartUrl; + + console.log(`🎵 Chart clicked: ${chartName} by ${chartArtist}`); + + const fullChartName = `${chartName} (${genreName})`; + + try { + showToast(`Loading ${chartName}...`, 'info'); + showLoadingOverlay(`Scraping ${chartName}...`); + + const response = await fetch('/api/beatport/chart/extract', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chart_url: chartUrl, chart_name: chartName, limit: 100, enrich: false }) + }); + + if (!response.ok) { + throw new Error(`Failed to fetch chart content: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error('No tracks found in chart'); + } + + console.log(`✅ Extracted ${data.tracks.length} raw tracks from ${fullChartName}, enriching...`); + const enrichedTracks = await _enrichTracksWithProgress(data.tracks, fullChartName); + + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(enrichedTracks, fullChartName, null); + + } catch (error) { + console.error(`❌ Error loading chart: ${error.message}`); + hideLoadingOverlay(); + showToast(`Error loading chart: ${error.message}`, 'error'); + } + }); + }); +} + +async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType) { + console.log(`🎯 Genre chart type clicked: ${chartType} for ${genreName} (${genreSlug}/${genreId})`); + + // Map chart types to API endpoints and create descriptive names + const chartTypeMap = { + 'top-10': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/top-10`, + name: `Top 10 ${genreName}`, + limit: 10 + }, + 'top-100': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/tracks`, + name: `Top 100 ${genreName}`, + limit: 100 + }, + 'releases-top-10': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-10`, + name: `Top 10 ${genreName} Releases`, + limit: 10 + }, + 'releases-top-100': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-100`, + name: `Top 100 ${genreName} Releases`, + limit: 100 + }, + 'staff-picks': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/staff-picks`, + name: `${genreName} Staff Picks`, + limit: 50 + }, + 'latest-releases': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/latest-releases`, + name: `Latest ${genreName} Releases`, + limit: 50 + }, + 'hype-top-10': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-10`, + name: `${genreName} Hype Top 10`, + limit: 10 + }, + 'hype-top-100': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-top-100`, + name: `${genreName} Hype Top 100`, + limit: 100 + }, + 'hype-picks': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/hype-picks`, + name: `${genreName} Hype Picks`, + limit: 50 + }, + 'new-charts': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/new-charts`, + name: `New ${genreName} Charts`, + limit: 100 + } + }; + + const chartConfig = chartTypeMap[chartType]; + if (!chartConfig) { + console.error(`❌ Unknown chart type: ${chartType}`); + showToast(`Unknown chart type: ${chartType}`, 'error'); + return; + } + + try { + showToast(`Loading ${chartConfig.name}...`, 'info'); + showLoadingOverlay(`Loading ${chartConfig.name}...`); + + const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`); + if (!response.ok) { + throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartConfig.name}`); + } + + console.log(`✅ Fetched ${data.tracks.length} tracks from ${chartConfig.name}`); + hideLoadingOverlay(); + openBeatportChartAsDownloadModal(data.tracks, chartConfig.name, null); + + } catch (error) { + console.error(`❌ Error loading ${chartConfig.name}:`, error); + hideLoadingOverlay(); + showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error'); + } +} + +// =============================== +// SPOTIFY PUBLIC LINK FUNCTIONALITY +// =============================== + +let spotifyPublicPlaylists = []; // Array of loaded Spotify public playlist objects +let spotifyPublicPlaylistStates = {}; // Key: url_hash, Value: state dict + +async function parseSpotifyPublicUrl() { + const urlInput = document.getElementById('spotify-public-url-input'); + const url = urlInput.value.trim(); + + if (!url) { + showToast('Please enter a Spotify URL', 'error'); + return; + } + + // Basic URL validation + if (!url.includes('open.spotify.com/playlist') && !url.includes('open.spotify.com/album') && + !url.startsWith('spotify:playlist:') && !url.startsWith('spotify:album:')) { + showToast('Please enter a valid Spotify playlist or album URL', 'error'); + return; + } + + // Check if already loaded + if (_isUrlAlreadyLoaded('spotify-public', url)) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + const parseBtn = document.getElementById('spotify-public-parse-btn'); + if (parseBtn) { + parseBtn.disabled = true; + parseBtn.textContent = 'Loading...'; + } + + try { + console.log('🎵 Parsing public Spotify URL:', url); + + const response = await fetch('/api/spotify/parse-public', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url }) + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error: ${result.error}`, 'error'); + return; + } + + // Check if already loaded + if (spotifyPublicPlaylists.find(p => String(p.url_hash) === String(result.url_hash))) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + console.log(`✅ Spotify ${result.type} parsed: ${result.name} (${result.track_count} tracks)`); + + spotifyPublicPlaylists.push(result); + + // Auto-mirror + if (result.tracks && result.tracks.length > 0) { + mirrorPlaylist('spotify_public', result.url_hash, result.name, result.tracks.map(t => ({ + track_name: t.name || '', + artist_name: Array.isArray(t.artists) ? t.artists.map(a => a.name).join(', ') : '', + album_name: t.album?.name || '', + duration_ms: t.duration_ms || 0, + source_track_id: t.id || '' + })), { owner: result.subtitle || '', image_url: '', description: result.url || '' }); + } + + // Save to URL history + saveUrlHistory('spotify-public', url, result.name); + + renderSpotifyPublicPlaylists(); + await loadSpotifyPublicPlaylistStatesFromBackend(); + + urlInput.value = ''; + showToast(`Loaded: ${result.name} (${result.track_count} tracks)`, 'success'); + console.log(`🎵 Loaded Spotify playlist: ${result.name}`); + + } catch (error) { + console.error('❌ Error parsing Spotify URL:', error); + showToast(`Error parsing Spotify URL: ${error.message}`, 'error'); + } finally { + if (parseBtn) { + parseBtn.disabled = false; + parseBtn.textContent = 'Load'; + } + } +} + +function renderSpotifyPublicPlaylists() { + const container = document.getElementById('spotify-public-playlist-container'); + if (spotifyPublicPlaylists.length === 0) { + container.innerHTML = `
Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
`; + return; + } + + container.innerHTML = spotifyPublicPlaylists.map(p => { + if (!spotifyPublicPlaylistStates[p.url_hash]) { + spotifyPublicPlaylistStates[p.url_hash] = { + phase: 'fresh', + playlist: p + }; + } + return createSpotifyPublicCard(p); + }).join(''); + + // Add click handlers to cards + spotifyPublicPlaylists.forEach(p => { + const card = document.getElementById(`spotify-public-card-${p.url_hash}`); + if (card) { + card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash)); + } + }); +} + +function createSpotifyPublicCard(playlist) { + const state = spotifyPublicPlaylistStates[playlist.url_hash]; + const phase = state ? state.phase : 'fresh'; + const isAlbum = playlist.type === 'album'; + + let buttonText = getActionButtonText(phase); + let phaseText = getPhaseText(phase); + let phaseColor = getPhaseColor(phase); + + return ` +
+
${isAlbum ? '💿' : '🎵'}
+
+
${escapeHtml(playlist.name)}
+
+ ${isAlbum ? 'Album' : 'Playlist'} + ${playlist.track_count || playlist.tracks.length} tracks + ${phaseText} +
+
+
+ +
+ +
+ `; +} + +async function handleSpotifyPublicCardClick(urlHash) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) { + console.error(`No state found for Spotify public playlist: ${urlHash}`); + showToast('Playlist state not found - try refreshing the page', 'error'); + return; + } + + if (!state.playlist) { + console.error(`No playlist data found for Spotify public playlist: ${urlHash}`); + showToast('Playlist data missing - try refreshing the page', 'error'); + return; + } + + if (!state.phase) { + state.phase = 'fresh'; + } + + console.log(`🎵 [Card Click] Spotify public card clicked: ${urlHash}, Phase: ${state.phase}`); + + if (state.phase === 'fresh') { + console.log(`🎵 Using pre-loaded Spotify public playlist data for: ${state.playlist.name}`); + openSpotifyPublicDiscoveryModal(urlHash, state.playlist); + + } else if (state.phase === 'discovering' || state.phase === 'discovered' || state.phase === 'syncing' || state.phase === 'sync_complete') { + console.log(`🎵 [Card Click] Opening Spotify public discovery modal for ${state.phase} phase`); + + if (state.phase === 'discovered' && (!state.discovery_results || state.discovery_results.length === 0)) { + try { + const stateResponse = await fetch(`/api/spotify-public/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results) { + state.discovery_results = fullState.discovery_results; + state.spotify_matches = fullState.spotify_matches || state.spotify_matches; + state.discovery_progress = fullState.discovery_progress || state.discovery_progress; + spotifyPublicPlaylistStates[urlHash] = { ...spotifyPublicPlaylistStates[urlHash], ...state }; + console.log(`Restored ${fullState.discovery_results.length} discovery results from backend`); + } + } + } catch (error) { + console.error(`Failed to fetch discovery results from backend: ${error}`); + } + } + + openSpotifyPublicDiscoveryModal(urlHash, state.playlist); + } else if (state.phase === 'downloading' || state.phase === 'download_complete') { + if (state.convertedSpotifyPlaylistId) { + if (activeDownloadProcesses[state.convertedSpotifyPlaylistId]) { + const process = activeDownloadProcesses[state.convertedSpotifyPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } else { + await rehydrateSpotifyPublicDownloadModal(urlHash, state); + } + } else { + await rehydrateSpotifyPublicDownloadModal(urlHash, state); + } + } else { + if (state.discovery_results && state.discovery_results.length > 0) { + openSpotifyPublicDiscoveryModal(urlHash, state.playlist); + } else { + showToast('Unable to open download modal - missing playlist data', 'error'); + } + } + } +} + +async function rehydrateSpotifyPublicDownloadModal(urlHash, state) { + try { + if (!state || !state.playlist) { + showToast('Cannot open download modal - invalid playlist data', 'error'); + return; + } + + const spotifyTracks = state.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + const virtualPlaylistId = state.convertedSpotifyPlaylistId || `spotify_public_${urlHash}`; + await openDownloadMissingModalForTidal(virtualPlaylistId, state.playlist.name, spotifyTracks); + + if (state.download_process_id) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = state.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(virtualPlaylistId); + } + } + } else { + showToast('No Spotify tracks found for download', 'error'); + } + } catch (error) { + console.error(`Error rehydrating Spotify public download modal: ${error}`); + } +} + +async function openSpotifyPublicDiscoveryModal(urlHash, playlistData) { + console.log(`🎵 Opening Spotify public discovery modal (reusing YouTube modal): ${playlistData.name}`); + + const fakeUrlHash = `spotifypublic_${urlHash}`; + + const cardState = spotifyPublicPlaylistStates[urlHash]; + const isAlreadyDiscovered = cardState && (cardState.phase === 'discovered' || cardState.phase === 'syncing' || cardState.phase === 'sync_complete'); + const isCurrentlyDiscovering = cardState && cardState.phase === 'discovering'; + + let transformedResults = []; + let actualMatches = 0; + if (isAlreadyDiscovered && cardState.discovery_results) { + transformedResults = cardState.discovery_results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + if (isFound) actualMatches++; + + return { + index: index, + yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown', + yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) + ? result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-' + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }); + console.log(`🎵 Spotify public modal: Calculated ${actualMatches} matches from ${transformedResults.length} results`); + } + + // Normalize artist objects to strings for the discovery modal table + const normalizedTracks = playlistData.tracks.map(t => ({ + ...t, + artists: Array.isArray(t.artists) + ? t.artists.map(a => typeof a === 'object' ? a.name : a) + : t.artists + })); + + const modalPhase = cardState ? cardState.phase : 'fresh'; + youtubePlaylistStates[fakeUrlHash] = { + phase: modalPhase, + playlist: { + name: playlistData.name, + tracks: normalizedTracks + }, + is_spotify_public_playlist: true, + spotify_public_playlist_id: urlHash, + discovery_progress: isAlreadyDiscovered ? 100 : 0, + spotify_matches: isAlreadyDiscovered ? actualMatches : 0, + spotifyMatches: isAlreadyDiscovered ? actualMatches : 0, + spotify_total: playlistData.tracks.length, + discovery_results: transformedResults, + discoveryResults: transformedResults, + discoveryProgress: isAlreadyDiscovered ? 100 : 0 + }; + + if (!isAlreadyDiscovered && !isCurrentlyDiscovering) { + try { + console.log(`🔍 Starting Spotify public discovery for: ${playlistData.name}`); + + const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + console.error('Error starting Spotify public discovery:', result.error); + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + console.log('Spotify public discovery started, beginning polling...'); + + spotifyPublicPlaylistStates[urlHash].phase = 'discovering'; + updateSpotifyPublicCardPhase(urlHash, 'discovering'); + youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + + startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); + + } catch (error) { + console.error('Error starting Spotify public discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } + } else if (isCurrentlyDiscovering) { + console.log(`🔄 Resuming Spotify public discovery polling for: ${playlistData.name}`); + startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); + } else if (cardState && cardState.phase === 'syncing') { + console.log(`🔄 Resuming Spotify public sync polling for: ${playlistData.name}`); + startSpotifyPublicSyncPolling(fakeUrlHash); + } else { + console.log('Using existing results - no need to re-discover'); + } + + openYouTubeDiscoveryModal(fakeUrlHash); +} + +function startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash) { + console.log(`🔄 Starting Spotify public discovery polling for: ${urlHash}`); + + if (activeYouTubePollers[fakeUrlHash]) { + clearInterval(activeYouTubePollers[fakeUrlHash]); + } + + // WebSocket subscription + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + const transformed = { + progress: data.progress, spotify_matches: data.spotify_matches, spotify_total: data.spotify_total, + complete: data.complete, + results: (data.results || []).map((r, i) => { + const isWingIt = r.wing_it_fallback || r.status_class === 'wing-it'; + const isFound = !isWingIt && (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found' || r.spotify_data || r.spotify_track); + return { + index: i, yt_track: r.spotify_public_track ? r.spotify_public_track.name : 'Unknown', + yt_artist: r.spotify_public_track ? (r.spotify_public_track.artists ? r.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isWingIt ? '🎯 Wing It' : (isFound ? '✅ Found' : '❌ Not Found'), + status_class: isWingIt ? 'wing-it' : (isFound ? 'found' : 'not-found'), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data && r.spotify_data.artists + ? (Array.isArray(r.spotify_data.artists) + ? (r.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : r.spotify_data.artists) + : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) : (r.spotify_album || '-'), + spotify_data: r.spotify_data, spotify_id: r.spotify_id, manual_match: r.manual_match, + wing_it_fallback: isWingIt + }; + }) + }; + const st = youtubePlaylistStates[fakeUrlHash]; + if (st) { + st.discovery_progress = data.progress; st.discoveryProgress = data.progress; + st.spotify_matches = data.spotify_matches; st.spotifyMatches = data.spotify_matches; + st.discovery_results = data.results; st.discoveryResults = transformed.results; + st.phase = data.phase; + updateYouTubeDiscoveryModal(fakeUrlHash, transformed); + } + if (spotifyPublicPlaylistStates[urlHash]) { + spotifyPublicPlaylistStates[urlHash].phase = data.phase; + spotifyPublicPlaylistStates[urlHash].discovery_results = data.results; + spotifyPublicPlaylistStates[urlHash].spotify_matches = data.spotify_matches; + spotifyPublicPlaylistStates[urlHash].discovery_progress = data.progress; + updateSpotifyPublicCardPhase(urlHash, data.phase); + } + updateSpotifyPublicCardProgress(urlHash, data); + if (data.complete) { + if (activeYouTubePollers[fakeUrlHash]) { clearInterval(activeYouTubePollers[fakeUrlHash]); delete activeYouTubePollers[fakeUrlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/spotify-public/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Spotify public discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + return; + } + + const transformedStatus = { + progress: status.progress, + spotify_matches: status.spotify_matches, + spotify_total: status.spotify_total, + complete: status.complete, + results: status.results.map((result, index) => { + const isFound = result.status === 'found' || + result.status === '✅ Found' || + result.status_class === 'found' || + result.spotify_data || + result.spotify_track; + + return { + index: index, + yt_track: result.spotify_public_track ? result.spotify_public_track.name : 'Unknown', + yt_artist: result.spotify_public_track ? (result.spotify_public_track.artists ? result.spotify_public_track.artists.join(', ') : 'Unknown') : 'Unknown', + status: isFound ? '✅ Found' : '❌ Not Found', + status_class: isFound ? 'found' : 'not-found', + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists + ? (Array.isArray(result.spotify_data.artists) + ? (result.spotify_data.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-') + : result.spotify_data.artists) + : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + spotify_id: result.spotify_id, + manual_match: result.manual_match + }; + }) + }; + + const state = youtubePlaylistStates[fakeUrlHash]; + if (state) { + state.discovery_progress = status.progress; + state.discoveryProgress = status.progress; + state.spotify_matches = status.spotify_matches; + state.spotifyMatches = status.spotify_matches; + state.discovery_results = status.results; + state.discoveryResults = transformedStatus.results; + state.phase = status.phase; + + updateYouTubeDiscoveryModal(fakeUrlHash, transformedStatus); + + if (spotifyPublicPlaylistStates[urlHash]) { + spotifyPublicPlaylistStates[urlHash].phase = status.phase; + spotifyPublicPlaylistStates[urlHash].discovery_results = status.results; + spotifyPublicPlaylistStates[urlHash].spotify_matches = status.spotify_matches; + spotifyPublicPlaylistStates[urlHash].discovery_progress = status.progress; + updateSpotifyPublicCardPhase(urlHash, status.phase); + } + + updateSpotifyPublicCardProgress(urlHash, status); + + console.log(`🔄 Spotify public discovery progress: ${status.progress}% (${status.spotify_matches}/${status.spotify_total} found)`); + } + + if (status.complete) { + console.log(`Spotify public discovery complete: ${status.spotify_matches}/${status.spotify_total} tracks found`); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + + } catch (error) { + console.error('Error polling Spotify public discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[fakeUrlHash]; + } + }, 1000); + + activeYouTubePollers[fakeUrlHash] = pollInterval; +} + +async function loadSpotifyPublicPlaylistStatesFromBackend() { + try { + console.log('🎵 Loading Spotify public playlist states from backend...'); + + const response = await fetch('/api/spotify-public/playlists/states'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Spotify public playlist states'); + } + + const data = await response.json(); + const states = data.states || []; + + console.log(`🎵 Found ${states.length} stored Spotify public playlist states in backend`); + + if (states.length === 0) return; + + for (const stateInfo of states) { + await applySpotifyPublicPlaylistState(stateInfo); + } + + // Rehydrate download modals for playlists in downloading/download_complete phases + for (const stateInfo of states) { + if ((stateInfo.phase === 'downloading' || stateInfo.phase === 'download_complete') && + stateInfo.converted_spotify_playlist_id && stateInfo.download_process_id) { + + const convertedPlaylistId = stateInfo.converted_spotify_playlist_id; + + if (!activeDownloadProcesses[convertedPlaylistId]) { + console.log(`Rehydrating download modal for Spotify public playlist: ${stateInfo.playlist_id}`); + try { + const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(stateInfo.playlist_id)); + if (!playlistData) continue; + + const spotifyTracks = spotifyPublicPlaylistStates[stateInfo.playlist_id]?.discovery_results + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForTidal( + convertedPlaylistId, + playlistData.name, + spotifyTracks + ); + + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = stateInfo.download_process_id; + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + startModalDownloadPolling(convertedPlaylistId); + } + } + } catch (error) { + console.error(`Error rehydrating Spotify public download modal for ${stateInfo.playlist_id}:`, error); + } + } + } + } + + console.log('Spotify public playlist states loaded and applied'); + + } catch (error) { + console.error('Error loading Spotify public playlist states:', error); + } +} + +async function applySpotifyPublicPlaylistState(stateInfo) { + const { playlist_id, phase, discovery_progress, spotify_matches, discovery_results, converted_spotify_playlist_id, download_process_id } = stateInfo; + + try { + console.log(`🎵 Applying saved state for Spotify public playlist: ${playlist_id}, Phase: ${phase}`); + + const playlistData = spotifyPublicPlaylists.find(p => String(p.url_hash) === String(playlist_id)); + if (!playlistData) { + console.warn(`Playlist data not found for state ${playlist_id} - skipping`); + return; + } + + if (!spotifyPublicPlaylistStates[playlist_id]) { + spotifyPublicPlaylistStates[playlist_id] = { + playlist: playlistData, + phase: 'fresh' + }; + } + + spotifyPublicPlaylistStates[playlist_id].phase = phase; + spotifyPublicPlaylistStates[playlist_id].discovery_progress = discovery_progress; + spotifyPublicPlaylistStates[playlist_id].spotify_matches = spotify_matches; + spotifyPublicPlaylistStates[playlist_id].discovery_results = discovery_results; + spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = converted_spotify_playlist_id; + spotifyPublicPlaylistStates[playlist_id].download_process_id = download_process_id; + spotifyPublicPlaylistStates[playlist_id].playlist = playlistData; + + if (phase !== 'fresh' && phase !== 'discovering') { + try { + const stateResponse = await fetch(`/api/spotify-public/state/${playlist_id}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results && spotifyPublicPlaylistStates[playlist_id]) { + spotifyPublicPlaylistStates[playlist_id].discovery_results = fullState.discovery_results; + spotifyPublicPlaylistStates[playlist_id].discovery_progress = fullState.discovery_progress; + spotifyPublicPlaylistStates[playlist_id].spotify_matches = fullState.spotify_matches; + spotifyPublicPlaylistStates[playlist_id].convertedSpotifyPlaylistId = fullState.converted_spotify_playlist_id; + spotifyPublicPlaylistStates[playlist_id].download_process_id = fullState.download_process_id; + } + } + } catch (error) { + console.warn(`Error fetching full discovery results for Spotify public playlist ${playlistData.name}:`, error.message); + } + } + + updateSpotifyPublicCardPhase(playlist_id, phase); + + if (phase === 'discovered' && spotifyPublicPlaylistStates[playlist_id]) { + const progressInfo = { + spotify_total: playlistData.track_count || playlistData.tracks?.length || 0, + spotify_matches: spotifyPublicPlaylistStates[playlist_id].spotify_matches || 0 + }; + updateSpotifyPublicCardProgress(playlist_id, progressInfo); + } + + if (phase === 'discovering') { + const fakeUrlHash = `spotifypublic_${playlist_id}`; + startSpotifyPublicDiscoveryPolling(fakeUrlHash, playlist_id); + } else if (phase === 'syncing') { + const fakeUrlHash = `spotifypublic_${playlist_id}`; + startSpotifyPublicSyncPolling(fakeUrlHash); + } + + } catch (error) { + console.error(`Error applying Spotify public playlist state for ${playlist_id}:`, error); + } +} + +function updateSpotifyPublicCardPhase(urlHash, phase) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) return; + + state.phase = phase; + + const card = document.getElementById(`spotify-public-card-${urlHash}`); + if (card) { + const newCardHtml = createSpotifyPublicCard(state.playlist); + card.outerHTML = newCardHtml; + + const newCard = document.getElementById(`spotify-public-card-${urlHash}`); + if (newCard) { + newCard.addEventListener('click', () => handleSpotifyPublicCardClick(urlHash)); + } + + if ((phase === 'syncing' || phase === 'sync_complete') && state.lastSyncProgress) { + setTimeout(() => { + updateSpotifyPublicCardSyncProgress(urlHash, state.lastSyncProgress); + }, 0); + } + } +} + +function updateSpotifyPublicCardProgress(urlHash, progress) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) return; + + const card = document.getElementById(`spotify-public-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + if (!progressElement) return; + + progressElement.classList.remove('hidden'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + + if (total > 0) { + progressElement.innerHTML = ` +
+ ✓ ${matches} + / + ♪ ${total} +
+ `; + } +} + +// =============================== +// SPOTIFY PUBLIC SYNC FUNCTIONALITY +// =============================== + +async function startSpotifyPublicPlaylistSync(urlHash) { + try { + console.log('🎵 Starting Spotify public playlist sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_spotify_public_playlist) { + console.error('Invalid Spotify public playlist state for sync'); + return; + } + + const playlistId = state.spotify_public_playlist_id; + const response = await fetch(`/api/spotify-public/sync/start/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + updateSpotifyPublicCardPhase(playlistId, 'syncing'); + updateSpotifyPublicModalButtons(urlHash, 'syncing'); + + startSpotifyPublicSyncPolling(urlHash, syncPlaylistId); + + showToast('Spotify public playlist sync started!', 'success'); + + } catch (error) { + console.error('Error starting Spotify public sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startSpotifyPublicSyncPolling(urlHash, syncPlaylistId) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + const state = youtubePlaylistStates[urlHash]; + const playlistId = state.spotify_public_playlist_id; + + syncPlaylistId = syncPlaylistId || (state && state.syncPlaylistId); + + // WebSocket subscription + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateSpotifyPublicCardSyncProgress(playlistId, progress); + updateSpotifyPublicModalSyncProgress(urlHash, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateSpotifyPublicCardPhase(playlistId, 'sync_complete'); + updateSpotifyPublicModalButtons(urlHash, 'sync_complete'); + showToast('Spotify public playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateSpotifyPublicCardPhase(playlistId, 'discovered'); + updateSpotifyPublicModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollFunction = async () => { + if (socketConnected) return; + try { + const response = await fetch(`/api/spotify-public/sync/status/${playlistId}`); + const status = await response.json(); + + if (status.error) { + console.error('Error polling Spotify public sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateSpotifyPublicCardSyncProgress(playlistId, status.progress); + updateSpotifyPublicModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'sync_complete'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'sync_complete'; + updateSpotifyPublicCardPhase(playlistId, 'sync_complete'); + updateSpotifyPublicModalButtons(urlHash, 'sync_complete'); + showToast('Spotify public playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + if (spotifyPublicPlaylistStates[playlistId]) spotifyPublicPlaylistStates[playlistId].phase = 'discovered'; + if (youtubePlaylistStates[urlHash]) youtubePlaylistStates[urlHash].phase = 'discovered'; + updateSpotifyPublicCardPhase(playlistId, 'discovered'); + updateSpotifyPublicModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('Error polling Spotify public sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + if (!socketConnected) pollFunction(); + + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelSpotifyPublicSync(urlHash) { + try { + console.log('Cancelling Spotify public sync:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_spotify_public_playlist) { + console.error('Invalid Spotify public playlist state'); + return; + } + + const playlistId = state.spotify_public_playlist_id; + const response = await fetch(`/api/spotify-public/sync/cancel/${playlistId}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + const syncId = state && state.syncPlaylistId; + if (syncId && _syncProgressCallbacks[syncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncId] }); + delete _syncProgressCallbacks[syncId]; + } + + updateSpotifyPublicCardPhase(playlistId, 'discovered'); + updateSpotifyPublicModalButtons(urlHash, 'discovered'); + + showToast('Spotify public sync cancelled', 'info'); + + } catch (error) { + console.error('Error cancelling Spotify public sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateSpotifyPublicCardSyncProgress(urlHash, progress) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state || !state.playlist || !progress) return; + + state.lastSyncProgress = progress; + + const card = document.getElementById(`spotify-public-card-${urlHash}`); + if (!card) return; + + const progressElement = card.querySelector('.playlist-card-progress'); + + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + if (statusCounterHTML) { + progressElement.innerHTML = statusCounterHTML; + } +} + +function updateSpotifyPublicModalSyncProgress(urlHash, progress) { + const statusDisplay = document.getElementById(`spotify-public-sync-status-${urlHash}`); + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`spotify-public-total-${urlHash}`); + const matchedEl = document.getElementById(`spotify-public-matched-${urlHash}`); + const failedEl = document.getElementById(`spotify-public-failed-${urlHash}`); + const percentageEl = document.getElementById(`spotify-public-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } +} + +function updateSpotifyPublicModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +async function startSpotifyPublicDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks for Spotify public playlist:', urlHash); + + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.is_spotify_public_playlist) { + console.error('Invalid Spotify public playlist state for download'); + return; + } + + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (!discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + const spotifyTracks = []; + for (const result of discoveryResults) { + if (result.spotify_data) { + spotifyTracks.push(result.spotify_data); + } else if (result.spotify_track && result.status_class === 'found') { + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + spotifyTracks.push({ + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }); + } + } + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + const realUrlHash = state.spotify_public_playlist_id; + const virtualPlaylistId = `spotify_public_${realUrlHash}`; + const playlistName = state.playlist.name; + + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Sync convertedSpotifyPlaylistId to spotifyPublicPlaylistStates for card click routing + if (realUrlHash && spotifyPublicPlaylistStates[realUrlHash]) { + spotifyPublicPlaylistStates[realUrlHash].convertedSpotifyPlaylistId = virtualPlaylistId; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + } + + await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); + + } catch (error) { + console.error('Error starting Spotify public download missing:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +// =============================== +// URL HISTORY (Saved playlist URLs) +// =============================== + +const URL_HISTORY_MAX = 10; +const URL_HISTORY_SOURCES = { + youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() }, + deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() }, + 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() } +}; + +function getUrlHistory(source) { + try { + const cfg = URL_HISTORY_SOURCES[source]; + if (!cfg) return []; + const raw = localStorage.getItem(cfg.key); + return raw ? JSON.parse(raw) : []; + } catch { return []; } +} + +function saveUrlHistory(source, url, name) { + const cfg = URL_HISTORY_SOURCES[source]; + if (!cfg || !url) return; + let history = getUrlHistory(source); + // Remove duplicate (same URL) + history = history.filter(h => h.url !== url); + // Add to front + history.unshift({ url, name: name || url, ts: Date.now() }); + // Cap + if (history.length > URL_HISTORY_MAX) history = history.slice(0, URL_HISTORY_MAX); + localStorage.setItem(cfg.key, JSON.stringify(history)); + renderUrlHistory(source); +} + +function removeUrlHistoryEntry(source, url) { + const cfg = URL_HISTORY_SOURCES[source]; + if (!cfg) return; + let history = getUrlHistory(source); + history = history.filter(h => h.url !== url); + localStorage.setItem(cfg.key, JSON.stringify(history)); + renderUrlHistory(source); +} + +function renderUrlHistory(source) { + const cfg = URL_HISTORY_SOURCES[source]; + if (!cfg) return; + const container = document.getElementById(cfg.containerId); + if (!container) return; + const history = getUrlHistory(source); + if (history.length === 0) { + container.style.display = 'none'; + container.innerHTML = ''; + return; + } + container.style.display = 'flex'; + container.innerHTML = `Recent` + + history.map(h => { + const rawName = h.name.length > 30 ? h.name.substring(0, 28) + '...' : h.name; + const safeName = escapeHtml(rawName); + const safeTitle = escapeHtml(h.name); + const safeUrl = h.url.replace(/"/g, '"'); + return `
+ ${cfg.icon} + ${safeName} + +
`; + }).join(''); + + // Pill click → fill input and load (skip if already loaded) + container.querySelectorAll('.url-history-pill').forEach(pill => { + pill.addEventListener('click', (e) => { + // Don't trigger if clicking the X button + if (e.target.classList.contains('url-history-pill-remove')) return; + const pillUrl = pill.dataset.url; + if (_isUrlAlreadyLoaded(source, pillUrl)) { + showToast('This playlist is already loaded', 'info'); + return; + } + const input = document.getElementById(cfg.inputId); + if (input) input.value = pillUrl; + cfg.loadFn(); + }); + }); + + // X button click → remove entry + container.querySelectorAll('.url-history-pill-remove').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + removeUrlHistoryEntry(btn.dataset.source, btn.dataset.url); + }); + }); +} + +function _isUrlAlreadyLoaded(source, url) { + if (source === 'youtube') { + // Check for existing YouTube card with this URL + const container = document.getElementById('youtube-playlist-container'); + if (container) { + const cards = container.querySelectorAll('.youtube-playlist-card[data-url]'); + for (const card of cards) { + if (card.dataset.url === url) return true; + } + } + return false; + } else if (source === 'deezer') { + // Extract playlist ID from URL and check deezerPlaylists array + const match = url.match(/deezer\.com\/(?:[a-z]{2}\/)?playlist\/(\d+)/i); + const id = match ? match[1] : (/^\d+$/.test(url) ? url : null); + if (id && deezerPlaylists.find(p => String(p.id) === String(id))) return true; + return false; + } else if (source === 'spotify-public') { + // Extract Spotify ID from URL and compare against loaded playlists + const spMatch = url.match(/open\.spotify\.com\/(playlist|album)\/([a-zA-Z0-9]+)/); + const spId = spMatch ? spMatch[2] : null; + if (spId && spotifyPublicPlaylists.some(p => p.id === spId)) return true; + // Fallback: direct URL comparison + return spotifyPublicPlaylists.some(p => p.url === url); + } + return false; +} + +function initUrlHistories() { + for (const source of Object.keys(URL_HISTORY_SOURCES)) { + renderUrlHistory(source); + } +} + +// =============================== +// YOUTUBE PLAYLIST FUNCTIONALITY +// =============================== + +async function parseYouTubePlaylist() { + const urlInput = document.getElementById('youtube-url-input'); + const url = urlInput.value.trim(); + + if (!url) { + showToast('Please enter a YouTube playlist URL', 'error'); + return; + } + + // Validate URL format + if (!url.includes('youtube.com/playlist') && !url.includes('music.youtube.com/playlist')) { + showToast('Please enter a valid YouTube playlist URL', 'error'); + return; + } + + // Check if already loaded + if (_isUrlAlreadyLoaded('youtube', url)) { + showToast('This playlist is already loaded', 'info'); + urlInput.value = ''; + return; + } + + try { + console.log('🎬 Parsing YouTube playlist:', url); + + // Create card immediately in 'fresh' phase + createYouTubeCard(url, 'fresh'); + + // Parse playlist via API + const response = await fetch('/api/youtube/parse', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: url }) + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error parsing YouTube playlist: ${result.error}`, 'error'); + removeYouTubeCard(url); + return; + } + + console.log('✅ YouTube playlist parsed:', result.name, `(${result.tracks.length} tracks)`); + + // Save to URL history + saveUrlHistory('youtube', url, result.name); + + // Update card with parsed data and stay in 'fresh' phase + updateYouTubeCardData(result.url_hash, result); + updateYouTubeCardPhase(result.url_hash, 'fresh'); + + // Auto-mirror this YouTube playlist + mirrorPlaylist('youtube', result.url_hash, result.name, result.tracks.map(t => ({ + track_name: t.name || t.title || '', artist_name: Array.isArray(t.artists) ? t.artists[0] : (t.artist || ''), + album_name: '', duration_ms: t.duration_ms || 0, source_track_id: t.id || '' + })), { description: url }); + + // Clear input + urlInput.value = ''; + + // Show success message + showToast(`YouTube playlist parsed: ${result.name} (${result.tracks.length} tracks)`, 'success'); + + } catch (error) { + console.error('❌ Error parsing YouTube playlist:', error); + showToast(`Error parsing YouTube playlist: ${error.message}`, 'error'); + removeYouTubeCard(url); + } +} + +function createYouTubeCard(url, phase = 'fresh') { + const container = document.getElementById('youtube-playlist-container'); + const placeholder = container.querySelector('.playlist-placeholder'); + + // Remove placeholder if it exists + if (placeholder) { + placeholder.style.display = 'none'; + } + + // Create temporary URL hash for initial card + const tempHash = btoa(url).substring(0, 8); + + const cardHtml = ` +
+
+
+
Parsing YouTube playlist...
+
+ -- tracks + Loading... +
+
+ + +
+ `; + + container.insertAdjacentHTML('beforeend', cardHtml); + + // Store temporary state + youtubePlaylistStates[tempHash] = { + phase: phase, + url: url, + cardElement: document.getElementById(`youtube-card-${tempHash}`), + tempHash: tempHash + }; + + console.log('🃏 Created YouTube card for URL:', url); +} + +function updateYouTubeCardData(urlHash, playlistData) { + // Find the card by URL or temp hash + let state = youtubePlaylistStates[urlHash]; + if (!state) { + // Look for temporary card by URL + const tempState = Object.values(youtubePlaylistStates).find(s => s.url === playlistData.url); + if (tempState) { + // Update the state with real hash + delete youtubePlaylistStates[tempState.tempHash]; + youtubePlaylistStates[urlHash] = tempState; + state = tempState; + + // Update card ID + if (state.cardElement) { + state.cardElement.id = `youtube-card-${urlHash}`; + } + } + } + + if (!state || !state.cardElement) { + console.error('❌ Could not find YouTube card for hash:', urlHash); + return; + } + + const card = state.cardElement; + + // Update card content + const nameElement = card.querySelector('.playlist-card-name'); + const trackCountElement = card.querySelector('.playlist-card-track-count'); + + nameElement.textContent = playlistData.name; + trackCountElement.textContent = `${playlistData.tracks.length} tracks`; + + // Store playlist data + state.playlist = playlistData; + state.urlHash = urlHash; + + // Add click handler for card and action button + const handleCardClick = () => handleYouTubeCardClick(urlHash); + const actionBtn = card.querySelector('.playlist-card-action-btn'); + + card.addEventListener('click', handleCardClick); + actionBtn.addEventListener('click', (e) => { + e.stopPropagation(); // Prevent card click + handleCardClick(); + }); + + console.log('🃏 Updated YouTube card data:', playlistData.name); +} + +function updateYouTubeCardPhase(urlHash, phase) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.cardElement) return; + + const card = state.cardElement; + const phaseTextElement = card.querySelector('.playlist-card-phase-text'); + const actionBtn = card.querySelector('.playlist-card-action-btn'); + const progressElement = card.querySelector('.playlist-card-progress'); + + state.phase = phase; + + switch (phase) { + case 'fresh': + phaseTextElement.textContent = 'Ready to discover'; + phaseTextElement.style.color = '#999'; + actionBtn.textContent = 'Start Discovery'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + + case 'discovering': + phaseTextElement.textContent = 'Discovering...'; + phaseTextElement.style.color = '#ffa500'; // Orange + actionBtn.textContent = 'View Progress'; + actionBtn.disabled = false; + progressElement.classList.remove('hidden'); + break; + + case 'discovered': + phaseTextElement.textContent = 'Discovery Complete'; + phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green + actionBtn.textContent = 'View Details'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + + case 'syncing': + phaseTextElement.textContent = 'Syncing...'; + phaseTextElement.style.color = '#ffa500'; // Orange + actionBtn.textContent = 'View Progress'; + actionBtn.disabled = false; + progressElement.classList.remove('hidden'); + break; + + case 'sync_complete': + phaseTextElement.textContent = 'Sync Complete'; + phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green + actionBtn.textContent = 'View Details'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + + case 'downloading': + phaseTextElement.textContent = 'Downloading...'; + phaseTextElement.style.color = '#ffa500'; // Orange + actionBtn.textContent = 'View Downloads'; + actionBtn.disabled = false; + progressElement.classList.remove('hidden'); + break; + + case 'download_complete': + phaseTextElement.textContent = 'Download Complete'; + phaseTextElement.style.color = 'rgb(var(--accent-rgb))'; // Green + actionBtn.textContent = 'View Results'; + actionBtn.disabled = false; + progressElement.classList.add('hidden'); + break; + } + + console.log('🃏 Updated YouTube card phase:', urlHash, phase); +} + +function handleYouTubeCardClick(urlHash) { + const state = youtubePlaylistStates[urlHash]; + if (!state) return; + + switch (state.phase) { + case 'fresh': + // First click: Start discovery and open modal + console.log('🎬 Starting YouTube discovery for first time:', urlHash); + updateYouTubeCardPhase(urlHash, 'discovering'); + startYouTubeDiscovery(urlHash); + openYouTubeDiscoveryModal(urlHash); + break; + + case 'discovering': + case 'discovered': + case 'syncing': + case 'sync_complete': + // Open discovery modal with current state + console.log('🎬 Opening YouTube discovery modal:', urlHash); + openYouTubeDiscoveryModal(urlHash); + break; + + case 'downloading': + case 'download_complete': + // Open download missing tracks modal + console.log('🎬 Opening download modal for YouTube playlist:', urlHash); + // Need to get playlist ID from converted Spotify data + const spotifyPlaylistId = state.convertedSpotifyPlaylistId; + if (spotifyPlaylistId) { + // Check if we have discovery results, if not load them first + if (!state.discoveryResults || state.discoveryResults.length === 0) { + console.log('🔍 Loading discovery results for download modal...'); + fetch(`/api/youtube/state/${urlHash}`) + .then(response => response.json()) + .then(fullState => { + if (fullState.discovery_results) { + state.discoveryResults = fullState.discovery_results; + console.log(`✅ Loaded ${state.discoveryResults.length} discovery results`); + + // Now open the modal with the loaded data + const playlistName = state.playlist.name; + const spotifyTracks = state.discoveryResults + .filter(result => result.spotify_data) + .map(result => result.spotify_data); + openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks); + } else { + console.error('❌ No discovery results found for downloads'); + showToast('Unable to open download modal - no discovery data', 'error'); + } + }) + .catch(error => { + console.error('❌ Error loading discovery results:', error); + showToast('Error loading playlist data', 'error'); + }); + } else { + // Use the YouTube-specific function to maintain proper state linking + const playlistName = state.playlist.name; + const spotifyTracks = state.discoveryResults + .filter(result => result.spotify_data) + .map(result => result.spotify_data); + openDownloadMissingModalForYouTube(spotifyPlaylistId, playlistName, spotifyTracks); + } + } else { + console.error('❌ No converted Spotify playlist ID found for downloads'); + showToast('Unable to open download modal - missing playlist data', 'error'); + } + break; + } +} + +function updateYouTubeCardProgress(urlHash, progress) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.cardElement) return; + + const card = state.cardElement; + const progressElement = card.querySelector('.playlist-card-progress'); + + const total = progress.spotify_total || 0; + const matches = progress.spotify_matches || 0; + const failed = total - matches; + const percentage = total > 0 ? Math.round((matches / total) * 100) : 0; + + progressElement.textContent = `♪ ${total} / ✓ ${matches} / ✗ ${failed} / ${percentage}%`; + + console.log('🃏 Updated YouTube card progress:', urlHash, `${matches}/${total} (${percentage}%)`); +} + +function removeYouTubeCard(url) { + const state = Object.values(youtubePlaylistStates).find(s => s.url === url); + if (state && state.cardElement) { + state.cardElement.remove(); + + // Remove from state + if (state.urlHash) { + delete youtubePlaylistStates[state.urlHash]; + } else if (state.tempHash) { + delete youtubePlaylistStates[state.tempHash]; + } + } + + // Show placeholder if no cards left + const container = document.getElementById('youtube-playlist-container'); + const cards = container.querySelectorAll('.youtube-playlist-card'); + const placeholder = container.querySelector('.playlist-placeholder'); + + if (cards.length === 0 && placeholder) { + placeholder.style.display = 'block'; + } +} + +async function startYouTubeDiscovery(urlHash) { + try { + console.log('🔍 Starting YouTube Spotify discovery for:', urlHash); + + const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting discovery: ${result.error}`, 'error'); + return; + } + + // Update frontend phase to match backend + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + if (state) { + state.phase = 'discovering'; + } + + // Update modal buttons to show "Discovering..." instead of "Start Discovery" + updateYouTubeModalButtons(urlHash, 'discovering'); + + // Start polling for progress + startYouTubeDiscoveryPolling(urlHash); + + // Open discovery modal + openYouTubeDiscoveryModal(urlHash); + + } catch (error) { + console.error('❌ Error starting YouTube discovery:', error); + showToast(`Error starting discovery: ${error.message}`, 'error'); + } +} + +function startYouTubeDiscoveryPolling(urlHash) { + // Stop any existing polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [urlHash] }); + _discoveryProgressCallbacks[urlHash] = (data) => { + if (data.error) { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + return; + } + updateYouTubeCardProgress(urlHash, data); + const st = youtubePlaylistStates[urlHash]; + if (st) { st.discoveryResults = data.results || []; st.discovery_results = data.results || []; st.discoveryProgress = data.progress || 0; st.spotifyMatches = data.spotify_matches || 0; st.spotify_matches = data.spotify_matches || 0; } + updateYouTubeDiscoveryModal(urlHash, data); + if (data.complete) { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('discovery:unsubscribe', { ids: [urlHash] }); delete _discoveryProgressCallbacks[urlHash]; + // Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement) + if (st) st.phase = 'discovered'; + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + showToast('Discovery complete!', 'success'); + } + }; + } + + const pollInterval = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + const response = await fetch(`/api/youtube/discovery/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling YouTube discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + // Update card progress + updateYouTubeCardProgress(urlHash, status); + + // Store discovery results and progress in state + const state = youtubePlaylistStates[urlHash]; + if (state) { + state.discoveryResults = status.results || []; + state.discovery_results = status.results || []; + state.discoveryProgress = status.progress || 0; + state.spotifyMatches = status.spotify_matches || 0; + state.spotify_matches = status.spotify_matches || 0; + } + + // Update modal if open + updateYouTubeDiscoveryModal(urlHash, status); + + // Check if complete + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + + // Update phase in state directly (updateYouTubeCardPhase may skip if no cardElement) + if (state) state.phase = 'discovered'; + // Update card phase to discovered + updateYouTubeCardPhase(urlHash, 'discovered'); + + // Update modal buttons to show sync and download buttons + updateYouTubeModalButtons(urlHash, 'discovered'); + + console.log('✅ Discovery complete:', urlHash); + showToast('Discovery complete!', 'success'); + } + + } catch (error) { + console.error('❌ Error polling YouTube discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + } + }, 1000); + + activeYouTubePollers[urlHash] = pollInterval; +} + +function stopYouTubeDiscoveryPolling(urlHash) { + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + console.log('⏹ Stopped YouTube discovery polling for:', urlHash); + } +} + +function openYouTubeDiscoveryModal(urlHash) { + // Check ListenBrainz state first, then fallback to YouTube state + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + if (!state || !state.playlist) { + console.error('❌ No playlist data found for identifier:', urlHash); + return; + } + + console.log('🎵 Opening discovery modal for:', state.playlist.name); + + // Check if modal already exists + let modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + + if (modal) { + // Modal exists, just show it + modal.classList.remove('hidden'); + console.log('🔄 Showing existing modal with preserved state'); + console.log('🔄 Current discovery results count:', state.discoveryResults?.length || state.discovery_results?.length || 0); + + // Resume polling if discovery or sync is in progress + if (state.phase === 'discovering' && !activeYouTubePollers[urlHash]) { + console.log('🔄 Resuming discovery polling...'); + startYouTubeDiscoveryPolling(urlHash); + } else if (state.phase === 'syncing' && !activeYouTubePollers[urlHash]) { + console.log('🔄 Resuming sync polling...'); + if (state.is_tidal_playlist) { + startTidalSyncPolling(urlHash); + } else if (state.is_deezer_playlist) { + startDeezerSyncPolling(urlHash); + } else if (state.is_spotify_public_playlist) { + startSpotifyPublicSyncPolling(urlHash); + } else if (state.is_beatport_playlist) { + startBeatportSyncPolling(urlHash); + } else if (state.is_listenbrainz_playlist) { + startListenBrainzSyncPolling(urlHash); + } else { + startYouTubeSyncPolling(urlHash); + } + } + } else { + // Create new modal (support YouTube, Tidal, Deezer, Beatport, ListenBrainz, Spotify Public, and Mirrored) + const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_playlist; + const isSpotifyPublic = state.is_spotify_public_playlist; + const isBeatport = state.is_beatport_playlist; + const isListenBrainz = state.is_listenbrainz_playlist; + const isMirrored = state.is_mirrored_playlist; + const isLastfmRadio = typeof urlHash === 'string' && urlHash.startsWith('lastfm_radio_'); + const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' : + isSpotifyPublic ? '🎵 Spotify Playlist Discovery' : + isDeezer ? '🎵 Deezer Playlist Discovery' : + isTidal ? '🎵 Tidal Playlist Discovery' : + isBeatport ? '🎵 Beatport Chart Discovery' : + isLastfmRadio ? '📻 Last.fm Radio Discovery' : + isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' : + '🎵 YouTube Playlist Discovery'; + const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') : + isSpotifyPublic ? 'Spotify' : + isDeezer ? 'Deezer' : + isTidal ? 'Tidal' : + isBeatport ? 'Beatport' : + isLastfmRadio ? 'Last.fm' : + isListenBrainz ? 'LB' : + 'YT'; + + const modalHtml = ` + + `; + + // Add modal to DOM + document.body.insertAdjacentHTML('beforeend', modalHtml); + modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + + // Store modal reference + state.modalElement = modal; + + // Set initial progress if we have discovery results + if (state.discoveryResults && state.discoveryResults.length > 0) { + // Compute progress from results if discoveryProgress is missing/zero + let progress = state.discoveryProgress || 0; + const matches = state.spotifyMatches || 0; + if (progress === 0 && state.discoveryResults.length > 0 && state.playlist.tracks.length > 0) { + progress = Math.min(100, Math.round((state.discoveryResults.length / state.playlist.tracks.length) * 100)); + } + const progressData = { + progress: progress, + spotify_matches: matches || state.discoveryResults.filter(r => r.status_class === 'found').length, + spotify_total: state.playlist.tracks.length, + results: state.discoveryResults + }; + updateYouTubeDiscoveryModal(urlHash, progressData); + } + + // Start polling immediately if modal is opened in syncing phase + if (state.phase === 'syncing') { + console.log('🔄 Modal opened in syncing phase - starting immediate polling...'); + if (state.is_tidal_playlist) { + startTidalSyncPolling(urlHash); + } else if (state.is_deezer_playlist) { + startDeezerSyncPolling(urlHash); + } else if (state.is_spotify_public_playlist) { + startSpotifyPublicSyncPolling(urlHash); + } else if (state.is_beatport_playlist) { + startBeatportSyncPolling(urlHash); + } else { + startYouTubeSyncPolling(urlHash); + } + } + + console.log('✨ Created new modal with current state'); + } +} + +function getModalActionButtons(urlHash, phase, state = null) { + // Get state if not provided + if (!state) { + state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + } + + const isTidal = state && state.is_tidal_playlist; + const isDeezer = state && state.is_deezer_playlist; + const isSpotifyPublic = state && state.is_spotify_public_playlist; + const isBeatport = state && state.is_beatport_playlist; + const isListenBrainz = state && state.is_listenbrainz_playlist; + + // Validate data availability for buttons (support both naming conventions) + const hasDiscoveryResults = state && ((state.discoveryResults && state.discoveryResults.length > 0) || (state.discovery_results && state.discovery_results.length > 0)); + const hasSpotifyMatches = state && ((state.spotifyMatches > 0) || (state.spotify_matches > 0)); + const hasConvertedPlaylistId = state && state.convertedSpotifyPlaylistId; + + switch (phase) { + case 'fresh': + case 'discovering': + // Show start discovery button for fresh playlists + if (phase === 'fresh') { + const wingItBtn = ` `; + + if (isListenBrainz) { + return `${wingItBtn}`; + } else { + return `${wingItBtn}`; + } + } else { + // Discovering phase - show progress + return ``; + } + + case 'discovered': + case 'downloading': + case 'download_complete': + // Only show buttons if we actually have discovery data + if (!hasDiscoveryResults) { + return ``; + } + + let buttons = ''; + + // Only show sync button if there are Spotify matches (and not standalone mode) + if (hasSpotifyMatches && !_isSoulsyncStandalone) { + if (isListenBrainz) { + buttons += ``; + } else if (isTidal) { + buttons += ``; + } else if (isDeezer) { + buttons += ``; + } else if (isSpotifyPublic) { + buttons += ``; + } else if (isBeatport) { + buttons += ``; + } else { + buttons += ``; + } + } + + // Only show download button if we have matches or a converted playlist ID + if (hasSpotifyMatches || hasConvertedPlaylistId) { + if (isListenBrainz) { + buttons += ``; + } else if (isTidal) { + buttons += ``; + } else if (isDeezer) { + buttons += ``; + } else if (isSpotifyPublic) { + buttons += ``; + } else if (isBeatport) { + buttons += ``; + } else { + buttons += ``; + } + } + + // Retry Failed button for mirrored playlists + if (state && state.is_mirrored_playlist) { + const results = state.discovery_results || state.discoveryResults || []; + const failedCount = results.filter(r => r.status_class !== 'found').length; + if (failedCount > 0) { + buttons += ``; + } + } + + // Rediscover button — reset and re-run discovery (only for sources with reset endpoints) + if (isBeatport) { + buttons += ``; + } else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) { + buttons += ``; + } + + // Wing It button — available in discovered phase + buttons += ` `; + + if (!buttons || buttons.trim().startsWith(' +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } else if (isTidal) { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } else if (isDeezer) { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } else if (isSpotifyPublic) { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } else if (isBeatport) { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } else { + return ` + +
+ 0 + / + 0 + / + 0 + (0%) +
+ `; + } + + case 'sync_complete': + let syncCompleteButtons = ''; + + // Only show sync button if there are Spotify matches (and not standalone mode) + if (hasSpotifyMatches && !_isSoulsyncStandalone) { + if (isListenBrainz) { + syncCompleteButtons += ``; + } else if (isTidal) { + syncCompleteButtons += ``; + } else if (isSpotifyPublic) { + syncCompleteButtons += ``; + } else if (isBeatport) { + syncCompleteButtons += ``; + } else { + syncCompleteButtons += ``; + } + } + + // Only show download button if we have matches or a converted playlist ID + if (hasSpotifyMatches || hasConvertedPlaylistId) { + if (isListenBrainz) { + syncCompleteButtons += ``; + } else if (isTidal) { + syncCompleteButtons += ``; + } else if (isSpotifyPublic) { + syncCompleteButtons += ``; + } else if (isBeatport) { + syncCompleteButtons += ``; + } else { + syncCompleteButtons += ``; + } + } + + // Rediscover button (only for sources with reset endpoints) + if (isBeatport) { + syncCompleteButtons += ``; + } else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) { + syncCompleteButtons += ``; + } + + // Wing It button + syncCompleteButtons += ` `; + + return syncCompleteButtons; + + case 'download_complete': + // Same options as sync_complete — allow re-sync, download missing, and reset + let dlCompleteButtons = ''; + + if (hasSpotifyMatches) { + if (isListenBrainz) { + dlCompleteButtons += ``; + } else if (isTidal) { + dlCompleteButtons += ``; + } else if (isDeezer) { + dlCompleteButtons += ``; + } else if (isSpotifyPublic) { + dlCompleteButtons += ``; + } else if (isBeatport) { + dlCompleteButtons += ``; + } else { + dlCompleteButtons += ``; + } + } + + if (hasSpotifyMatches || hasConvertedPlaylistId) { + if (isListenBrainz) { + dlCompleteButtons += ``; + } else if (isTidal) { + dlCompleteButtons += ``; + } else if (isDeezer) { + dlCompleteButtons += ``; + } else if (isSpotifyPublic) { + dlCompleteButtons += ``; + } else if (isBeatport) { + dlCompleteButtons += ``; + } else { + dlCompleteButtons += ``; + } + } + + // Rediscover button (only for sources with reset endpoints) + if (isBeatport) { + dlCompleteButtons += ``; + } else if (!isListenBrainz && !isTidal && !isDeezer && !isSpotifyPublic) { + dlCompleteButtons += ``; + } + + return dlCompleteButtons; + + default: + return ''; + } +} + +function getModalDescription(phase, isTidal = false, isBeatport = false, isListenBrainz = false, isMirrored = false, isDeezer = false, isSpotifyPublic = false, isLastfmRadio = false) { + const source = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'Spotify' : (isDeezer ? 'Deezer' : (isLastfmRadio ? 'Last.fm Radio' : (isListenBrainz ? 'ListenBrainz' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))))); + switch (phase) { + case 'fresh': + return `Ready to discover clean ${currentMusicSourceName} metadata for ${source} tracks...`; + case 'discovering': + return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`; + case 'discovered': + case 'downloading': + case 'download_complete': + return 'Discovery complete! View the results below.'; + default: + return `Discovering clean ${currentMusicSourceName} metadata for ${source} tracks...`; + } +} + +function getInitialProgressText(phase, isTidal = false, isBeatport = false, isListenBrainz = false) { + switch (phase) { + case 'fresh': + return 'Click Start Discovery to begin...'; + case 'discovering': + return 'Starting discovery...'; + case 'discovered': + case 'downloading': + case 'download_complete': + return 'Discovery completed!'; + default: + return 'Starting discovery...'; + } +} + +function generateTableRowsFromState(state, urlHash) { + const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_playlist; + const isSpotifyPublic = state.is_spotify_public_playlist; + const isBeatport = state.is_beatport_playlist; + const isListenBrainz = state.is_listenbrainz_playlist; + const isMirrored = state.is_mirrored_playlist; + const platform = isMirrored ? 'mirrored' : (isSpotifyPublic ? 'spotify_public' : (isDeezer ? 'deezer' : (isListenBrainz ? 'listenbrainz' : (isTidal ? 'tidal' : (isBeatport ? 'beatport' : 'youtube'))))); + + // Support both camelCase and snake_case + const discoveryResults = state.discoveryResults || state.discovery_results; + + if (discoveryResults && discoveryResults.length > 0) { + // Generate rows from existing discovery results + return discoveryResults.map((result, index) => { + // Handle different field names based on platform + const trackName = result.lb_track || result.yt_track || result.track_name || '-'; + const artistName = result.lb_artist || result.yt_artist || result.artist_name || '-'; + + return ` + + ${trackName} + ${artistName} + ${result.status} + ${result.spotify_track || '-'} + ${result.spotify_artist || '-'} + ${result.spotify_album || '-'} + ${generateDiscoveryActionButton(result, urlHash, platform)} + + `; + }).join(''); + } else { + // Generate initial rows from playlist tracks + return generateInitialTableRows(state.playlist.tracks, isTidal, urlHash, isBeatport, isListenBrainz); + } +} + +function generateInitialTableRows(tracks, isTidal = false, urlHash = '', isBeatport = false, isListenBrainz = false) { + return tracks.map((track, index) => { + // Handle different track formats based on platform + let trackName, artistName; + + if (isListenBrainz) { + // ListenBrainz tracks have track_name and artist_name + trackName = track.track_name || 'Unknown Track'; + artistName = track.artist_name || 'Unknown Artist'; + } else { + // YouTube/Tidal/Beatport tracks have name and artists + trackName = track.name || 'Unknown Track'; + artistName = track.artists ? (Array.isArray(track.artists) ? track.artists.join(', ') : track.artists) : 'Unknown Artist'; + } + + return ` + + ${trackName} + ${artistName} + 🔍 Pending... + - + - + - + - + + `; + }).join(''); +} + +function formatDuration(durationMs) { + if (!durationMs) return '0:00'; + const minutes = Math.floor(durationMs / 60000); + const seconds = Math.floor((durationMs % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +/** + * Generate action button for discovery table row + */ +function generateDiscoveryActionButton(result, identifier, platform) { + // Show fix button for not_found, error, or any non-found status + const isNotFound = result.status === 'not_found' || + result.status_class === 'not-found' || + result.status === '❌ Not Found' || + result.status === 'Not Found'; + + const isError = result.status === 'error' || + result.status_class === 'error' || + result.status === '❌ Error'; + + const isWingIt = result.wing_it_fallback || + result.status_class === 'wing-it'; + + const isFound = result.status === 'found' || + result.status_class === 'found' || + result.status === '✅ Found'; + + if (isNotFound || isError) { + return ``; + } + + // For wing-it fallbacks, show fix button so user can find a real match + if (isWingIt) { + return ``; + } + + // For found matches, show re-match and unmatch buttons + if (isFound) { + return ``; + } + + return '-'; +} + +function updateYouTubeDiscoveryModal(urlHash, status) { + const progressBar = document.getElementById(`youtube-discovery-progress-${urlHash}`); + const progressText = document.getElementById(`youtube-discovery-progress-text-${urlHash}`); + const tableBody = document.getElementById(`youtube-discovery-table-${urlHash}`); + + if (!progressBar || !progressText || !tableBody) { + console.warn(`⚠️ Missing modal elements for ${urlHash}:`, { + progressBar: !!progressBar, + progressText: !!progressText, + tableBody: !!tableBody + }); + return; + } + + // Update progress bar + progressBar.style.width = `${status.progress}%`; + progressText.textContent = `${status.spotify_matches} / ${status.spotify_total} tracks matched (${status.progress}%)`; + + + // Update table rows + status.results.forEach(result => { + const row = document.getElementById(`discovery-row-${urlHash}-${result.index}`); + if (!row) return; + + const statusCell = row.querySelector('.discovery-status'); + const spotifyTrackCell = row.querySelector('.spotify-track'); + const spotifyArtistCell = row.querySelector('.spotify-artist'); + const spotifyAlbumCell = row.querySelector('.spotify-album'); + const actionsCell = row.querySelector('.discovery-actions'); + + statusCell.textContent = result.status; + statusCell.className = `discovery-status ${result.status_class}`; + + spotifyTrackCell.textContent = result.spotify_track || '-'; + spotifyArtistCell.textContent = result.spotify_artist || '-'; + spotifyAlbumCell.textContent = result.spotify_album || '-'; + + // Update actions cell with appropriate button + if (actionsCell) { + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + const platform = state?.is_mirrored_playlist ? 'mirrored' : + (state?.is_spotify_public_playlist ? 'spotify_public' : + (state?.is_deezer_playlist ? 'deezer' : + (state?.is_listenbrainz_playlist ? 'listenbrainz' : + (state?.is_tidal_playlist ? 'tidal' : + (state?.is_beatport_playlist ? 'beatport' : 'youtube'))))); + actionsCell.innerHTML = generateDiscoveryActionButton(result, urlHash, platform); + } + }); + + // Update action buttons and description when discovery is complete. + // status.complete is explicitly set by LB/WS polling callers; only act when transitioning + // from 'discovering' to avoid interfering with download/sync phases of other playlist types. + if (status.complete) { + const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + if (state && state.phase === 'discovering') { + state.phase = 'discovered'; + const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); + if (actionButtonsContainer) { + actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); + console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`); + } + const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`); + if (descEl) descEl.textContent = 'Discovery complete! View the results below.'; + } else if (state && state.phase === 'discovered') { + // Already discovered — ensure buttons are correct (e.g. after rehydration) + const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); + if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) { + actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); + } + } + } +} + +function refreshYouTubeDiscoveryModalTable(urlHash) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.modalElement) { + console.warn(`⚠️ Cannot refresh modal table: no state or modal for ${urlHash}`); + return; + } + + console.log(`🔄 Refreshing modal table with ${state.discoveryResults?.length || 0} discovery results`); + + // Update the table body with new discovery results + const tableBody = state.modalElement.querySelector(`#youtube-discovery-table-${urlHash}`); + if (tableBody) { + tableBody.innerHTML = generateTableRowsFromState(state, urlHash); + console.log(`✅ Modal table refreshed with discovery data`); + } else { + console.warn(`⚠️ Could not find table body for modal ${urlHash}`); + } + + // Update the progress bar and footer buttons too + if (state.discoveryResults && state.discoveryResults.length > 0) { + const progressData = { + progress: state.discoveryProgress || 100, + spotify_matches: state.spotifyMatches || 0, + spotify_total: state.playlist.tracks.length, + results: state.discoveryResults + }; + updateYouTubeDiscoveryModal(urlHash, progressData); + } +} + +function closeYouTubeDiscoveryModal(urlHash) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) { + // Hide modal instead of removing it to preserve state + modal.classList.add('hidden'); + console.log('🚪 Hidden YouTube discovery modal (preserving state):', urlHash); + } + + // Handle phase reset for completed discovery (Tidal/Beatport pattern) + const state = youtubePlaylistStates[urlHash]; + if (state) { + const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_playlist; + const isSpotifyPublic = state.is_spotify_public_playlist; + const isBeatport = state.is_beatport_playlist; + + // Reset to 'discovered' phase if modal is closed after completion (like Tidal does) + if (state.phase === 'sync_complete' || state.phase === 'download_complete') { + console.log(`🧹 [Modal Close] Resetting ${isSpotifyPublic ? 'Spotify Public' : (isDeezer ? 'Deezer' : (isBeatport ? 'Beatport' : (isTidal ? 'Tidal' : 'YouTube')))} state after completion`); + + if (isSpotifyPublic) { + // Spotify Public: Extract url_hash and reset state + const spUrlHash = state.spotify_public_playlist_id || null; + if (spUrlHash && spotifyPublicPlaylistStates[spUrlHash]) { + const preservedData = { + playlist: spotifyPublicPlaylistStates[spUrlHash].playlist, + discovery_results: spotifyPublicPlaylistStates[spUrlHash].discovery_results, + spotify_matches: spotifyPublicPlaylistStates[spUrlHash].spotify_matches, + discovery_progress: spotifyPublicPlaylistStates[spUrlHash].discovery_progress, + convertedSpotifyPlaylistId: spotifyPublicPlaylistStates[spUrlHash].convertedSpotifyPlaylistId + }; + + delete spotifyPublicPlaylistStates[spUrlHash].download_process_id; + delete spotifyPublicPlaylistStates[spUrlHash].phase; + + Object.assign(spotifyPublicPlaylistStates[spUrlHash], preservedData); + spotifyPublicPlaylistStates[spUrlHash].phase = 'discovered'; + + updateSpotifyPublicCardPhase(spUrlHash, 'discovered'); + + try { + fetch(`/api/spotify-public/update_phase/${spUrlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('Error updating backend Spotify Public phase:', error); + } + } + } else if (isDeezer) { + // Deezer: Extract playlist ID and reset Deezer state + const deezerPlaylistId = state.deezer_playlist_id || null; + if (deezerPlaylistId && deezerPlaylistStates[deezerPlaylistId]) { + const preservedData = { + playlist: deezerPlaylistStates[deezerPlaylistId].playlist, + discovery_results: deezerPlaylistStates[deezerPlaylistId].discovery_results, + spotify_matches: deezerPlaylistStates[deezerPlaylistId].spotify_matches, + discovery_progress: deezerPlaylistStates[deezerPlaylistId].discovery_progress, + convertedSpotifyPlaylistId: deezerPlaylistStates[deezerPlaylistId].convertedSpotifyPlaylistId + }; + + delete deezerPlaylistStates[deezerPlaylistId].download_process_id; + delete deezerPlaylistStates[deezerPlaylistId].phase; + + Object.assign(deezerPlaylistStates[deezerPlaylistId], preservedData); + deezerPlaylistStates[deezerPlaylistId].phase = 'discovered'; + + updateDeezerCardPhase(deezerPlaylistId, 'discovered'); + + try { + fetch(`/api/deezer/update_phase/${deezerPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('Error updating backend Deezer phase:', error); + } + } + } else if (isTidal) { + // Tidal: Extract playlist ID and reset Tidal state + const tidalPlaylistId = state.tidal_playlist_id || null; + if (tidalPlaylistId && tidalPlaylistStates[tidalPlaylistId]) { + // Preserve discovery data but reset phase + const preservedData = { + playlist: tidalPlaylistStates[tidalPlaylistId].playlist, + discovery_results: tidalPlaylistStates[tidalPlaylistId].discovery_results, + spotify_matches: tidalPlaylistStates[tidalPlaylistId].spotify_matches, + discovery_progress: tidalPlaylistStates[tidalPlaylistId].discovery_progress, + convertedSpotifyPlaylistId: tidalPlaylistStates[tidalPlaylistId].convertedSpotifyPlaylistId + }; + + // Clear download state + delete tidalPlaylistStates[tidalPlaylistId].download_process_id; + delete tidalPlaylistStates[tidalPlaylistId].phase; + + // Restore preserved data and set to discovered phase + Object.assign(tidalPlaylistStates[tidalPlaylistId], preservedData); + tidalPlaylistStates[tidalPlaylistId].phase = 'discovered'; + + updateTidalCardPhase(tidalPlaylistId, 'discovered'); + + // Update backend state + try { + fetch(`/api/tidal/update_phase/${tidalPlaylistId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('⚠️ Error updating backend Tidal phase:', error); + } + } + } else if (isBeatport) { + // Beatport: Reset chart state + const chartHash = state.beatport_chart_hash || urlHash; + if (beatportChartStates[chartHash]) { + beatportChartStates[chartHash].phase = 'discovered'; + updateBeatportCardPhase(chartHash, 'discovered'); + + // Update backend state + try { + fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('⚠️ Error updating backend Beatport phase:', error); + } + } + } else { + // YouTube: Reset to discovered phase + updateYouTubeCardPhase(urlHash, 'discovered'); + + // Update backend state + try { + fetch(`/api/youtube/update_phase/${urlHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + } catch (error) { + console.warn('⚠️ Error updating backend YouTube phase:', error); + } + } + + // Reset frontend state to discovered + state.phase = 'discovered'; + console.log(`✅ [Modal Close] Reset to discovered phase: ${urlHash}`); + } + } + + // Keep modal reference and all state intact + // Discovery polling continues in background if active +} + +// =============================== +// YOUTUBE SYNC FUNCTIONALITY +// =============================== + +async function startYouTubePlaylistSync(urlHash) { + try { + console.log('🔄 Starting YouTube playlist sync:', urlHash); + + const response = await fetch(`/api/youtube/sync/start/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error starting sync: ${result.error}`, 'error'); + return; + } + + // Capture sync_playlist_id for WebSocket subscription + const syncPlaylistId = result.sync_playlist_id; + const ytState = youtubePlaylistStates[urlHash]; + if (ytState) ytState.syncPlaylistId = syncPlaylistId; + + // Update card and modal to syncing phase + updateYouTubeCardPhase(urlHash, 'syncing'); + + // Update modal buttons if modal is open + updateYouTubeModalButtons(urlHash, 'syncing'); + + // Start sync polling + startYouTubeSyncPolling(urlHash, syncPlaylistId); + + showToast('YouTube playlist sync started!', 'success'); + + } catch (error) { + console.error('❌ Error starting YouTube sync:', error); + showToast(`Error starting sync: ${error.message}`, 'error'); + } +} + +function startYouTubeSyncPolling(urlHash, syncPlaylistId) { + // Stop any existing polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + } + + // Resolve syncPlaylistId from argument or stored state + const ytState = youtubePlaylistStates[urlHash]; + syncPlaylistId = syncPlaylistId || (ytState && ytState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateYouTubeCardSyncProgress(urlHash, progress); + updateYouTubeModalSyncProgress(urlHash, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + updateYouTubeCardPhase(urlHash, 'sync_complete'); + updateYouTubeModalButtons(urlHash, 'sync_complete'); + showToast('YouTube playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[urlHash]) { clearInterval(activeYouTubePollers[urlHash]); delete activeYouTubePollers[urlHash]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + // Define the polling function (HTTP fallback) + const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates + try { + const response = await fetch(`/api/youtube/sync/status/${urlHash}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling YouTube sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + return; + } + + updateYouTubeCardSyncProgress(urlHash, status.progress); + updateYouTubeModalSyncProgress(urlHash, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + updateYouTubeCardPhase(urlHash, 'sync_complete'); + updateYouTubeModalButtons(urlHash, 'sync_complete'); + showToast('YouTube playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[urlHash]; + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('❌ Error polling YouTube sync:', error); + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + } + }; + + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); + + // Then continue polling at regular intervals + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[urlHash] = pollInterval; +} + +async function cancelYouTubeSync(urlHash) { + try { + console.log('❌ Cancelling YouTube sync:', urlHash); + + const response = await fetch(`/api/youtube/sync/cancel/${urlHash}`, { + method: 'POST' + }); + + const result = await response.json(); + + if (result.error) { + showToast(`Error cancelling sync: ${result.error}`, 'error'); + return; + } + + // Stop polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Phase 6: Clean up WS subscription + const ytCancelState = youtubePlaylistStates[urlHash]; + const ytSyncId = ytCancelState && ytCancelState.syncPlaylistId; + if (ytSyncId && _syncProgressCallbacks[ytSyncId]) { + if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [ytSyncId] }); + delete _syncProgressCallbacks[ytSyncId]; + } + + // Revert to discovered phase + updateYouTubeCardPhase(urlHash, 'discovered'); + updateYouTubeModalButtons(urlHash, 'discovered'); + + showToast('YouTube sync cancelled', 'info'); + + } catch (error) { + console.error('❌ Error cancelling YouTube sync:', error); + showToast(`Error cancelling sync: ${error.message}`, 'error'); + } +} + +function updateYouTubeCardSyncProgress(urlHash, progress) { + const state = youtubePlaylistStates[urlHash]; + if (!state || !state.cardElement || !progress) return; + + const card = state.cardElement; + const progressElement = card.querySelector('.playlist-card-progress'); + + // Build clean status counter HTML exactly like Spotify cards + let statusCounterHTML = ''; + if (progress && progress.total_tracks > 0) { + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const processed = matched + failed; + const percentage = total > 0 ? Math.round((processed / total) * 100) : 0; + + statusCounterHTML = ` +
+ ♪ ${total} + / + ✓ ${matched} + / + ✗ ${failed} + (${percentage}%) +
+ `; + } + + // Only update if we have valid sync progress, otherwise preserve existing discovery results + if (statusCounterHTML) { + progressElement.innerHTML = statusCounterHTML; + } + + console.log(`🔄 Updated YouTube sync progress: ♪ ${progress?.total_tracks || 0} / ✓ ${progress?.matched_tracks || 0} / ✗ ${progress?.failed_tracks || 0}`); +} + +function updateYouTubeModalSyncProgress(urlHash, progress) { + // Try all source-specific element ID prefixes + const prefixes = ['youtube', 'listenbrainz', 'tidal', 'deezer', 'spotify-public', 'beatport']; + let statusDisplay = null; + let prefix = 'youtube'; + for (const p of prefixes) { + statusDisplay = document.getElementById(`${p}-sync-status-${urlHash}`); + if (statusDisplay) { prefix = p; break; } + } + if (!statusDisplay || !progress) return; + + const totalEl = document.getElementById(`${prefix}-total-${urlHash}`); + const matchedEl = document.getElementById(`${prefix}-matched-${urlHash}`); + const failedEl = document.getElementById(`${prefix}-failed-${urlHash}`); + const percentageEl = document.getElementById(`${prefix}-percentage-${urlHash}`); + + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + + // Calculate percentage like Spotify sync + if (total > 0) { + const processed = matched + failed; + const percentage = Math.round((processed / total) * 100); + if (percentageEl) percentageEl.textContent = percentage; + } + + console.log(`📊 YouTube modal updated: ♪ ${total} / ✓ ${matched} / ✗ ${failed} (${Math.round((matched + failed) / total * 100)}%)`); +} + +function updateYouTubeModalButtons(urlHash, phase) { + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (!modal) return; + + const footerLeft = modal.querySelector('.modal-footer-left'); + if (footerLeft) { + footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + } +} + +// =============================== +// YOUTUBE DOWNLOAD MISSING TRACKS +// =============================== + +async function startYouTubeDownloadMissing(urlHash) { + try { + console.log('🔍 Starting download missing tracks:', urlHash); + + // Check both YouTube and ListenBrainz states (like Beatport does) + const state = youtubePlaylistStates[urlHash] || listenbrainzPlaylistStates[urlHash]; + // Support both camelCase and snake_case + const discoveryResults = state?.discoveryResults || state?.discovery_results; + + if (!state || !discoveryResults) { + showToast('No discovery results available for download', 'error'); + return; + } + + // Determine source type (prefix removed - no longer needed) + const isListenBrainz = state.is_listenbrainz_playlist; + const isBeatport = state.is_beatport_playlist; + const isTidal = state.is_tidal_playlist; + const isDeezer = state.is_deezer_playlist; + + // Convert discovery results to a format compatible with the download modal + const spotifyTracks = discoveryResults + .filter(result => result.spotify_data || (result.spotify_track && result.status_class === 'found')) + .map(result => { + if (result.spotify_data) { + return result.spotify_data; + } else { + // Build from individual fields (automatic discovery format) + // Convert album to proper object format for wishlist compatibility + const albumData = result.spotify_album || 'Unknown Album'; + const albumObject = typeof albumData === 'object' && albumData !== null + ? albumData + : { + name: typeof albumData === 'string' ? albumData : 'Unknown Album', + album_type: 'album', + images: [] + }; + + return { + id: result.spotify_id || 'unknown', + name: result.spotify_track || 'Unknown Track', + artists: result.spotify_artist ? [result.spotify_artist] : ['Unknown Artist'], + album: albumObject, + duration_ms: 0 + }; + } + }); + + if (spotifyTracks.length === 0) { + showToast('No Spotify matches found for download', 'error'); + return; + } + + // Create a virtual playlist for the download system + const virtualPlaylistId = isListenBrainz ? `listenbrainz_${urlHash}` : (isDeezer ? `deezer_${urlHash}` : (isBeatport ? `beatport_${urlHash}` : (isTidal ? `tidal_${urlHash}` : `youtube_${urlHash}`))); + const playlistName = state.playlist.name; + + // Store reference for card navigation + state.convertedSpotifyPlaylistId = virtualPlaylistId; + + // Close the discovery modal if it's open + const discoveryModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (discoveryModal) { + discoveryModal.classList.add('hidden'); + console.log('🔄 Closed YouTube discovery modal to show download modal'); + } + + // Open download missing tracks modal for YouTube playlist + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + // Phase will change to 'downloading' when user clicks "Begin Analysis" button + + } catch (error) { + console.error('❌ Error starting download missing tracks:', error); + showToast(`Error starting downloads: ${error.message}`, 'error'); + } +} + +async function resetYouTubePlaylist(urlHash) { + const state = youtubePlaylistStates[urlHash]; + if (!state) return; + + try { + console.log(`🔄 Resetting YouTube playlist to fresh state: ${state.playlist.name}`); + + // Call backend reset endpoint + const response = await fetch(`/api/youtube/reset/${urlHash}`, { + method: 'POST' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to reset playlist'); + } + + // Stop any active polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Update client state to match backend reset + state.phase = 'fresh'; + state.discoveryResults = []; + state.discoveryProgress = 0; + state.spotifyMatches = 0; + state.syncPlaylistId = null; + state.syncProgress = {}; + state.convertedSpotifyPlaylistId = null; + + // Update card to reflect fresh state + updateYouTubeCardPhase(urlHash, 'fresh'); + updateYouTubeCardProgress(urlHash, { + discovery_progress: 0, + spotify_matches: 0, + spotify_total: state.playlist.tracks.length + }); + + // Close modal + closeYouTubeDiscoveryModal(urlHash); + + showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); + console.log(`✅ Successfully reset YouTube playlist: ${state.playlist.name}`); + + } catch (error) { + console.error(`❌ Error resetting YouTube playlist:`, error); + showToast(`Error resetting playlist: ${error.message}`, 'error'); + } +} + +async function resetBeatportChart(urlHash) { + const state = youtubePlaylistStates[urlHash]; + const chartState = beatportChartStates[urlHash]; + + if (!state || !state.is_beatport_playlist || !chartState) { + console.error('❌ Invalid Beatport chart state for reset'); + return; + } + + try { + console.log(`🔄 Resetting Beatport chart to fresh state: ${state.playlist.name}`); + + // Call backend reset endpoint for Beatport + const chartHash = state.beatport_chart_hash || urlHash; + const response = await fetch(`/api/beatport/charts/update-phase/${chartHash}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + phase: 'fresh', + reset: true + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to reset Beatport chart'); + } + + // Stop any active polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Update client state to match backend reset + state.phase = 'fresh'; + state.discoveryResults = []; + state.discoveryProgress = 0; + state.spotifyMatches = 0; + state.discovery_results = []; + state.discovery_progress = 0; + state.spotify_matches = 0; + state.syncPlaylistId = null; + state.syncProgress = {}; + state.convertedSpotifyPlaylistId = null; + + // Update Beatport chart state + chartState.phase = 'fresh'; + + // Update card to reflect fresh state + updateBeatportCardPhase(chartHash, 'fresh'); + updateBeatportCardProgress(chartHash, { + spotify_total: state.playlist.tracks.length, + spotify_matches: 0, + failed: 0 + }); + + // Close modal + closeYouTubeDiscoveryModal(urlHash); + + showToast(`Reset "${state.playlist.name}" to fresh state`, 'success'); + console.log(`✅ Successfully reset Beatport chart: ${state.playlist.name}`); + + } catch (error) { + console.error(`❌ Error resetting Beatport chart:`, error); + showToast(`Error resetting chart: ${error.message}`, 'error'); + } +} + +// ============================================================================ +// LISTENBRAINZ PLAYLIST DISCOVERY & SYNC +// ============================================================================ + +function startListenBrainzDiscoveryPolling(playlistMbid) { + console.log(`🔄 Starting ListenBrainz discovery polling for: ${playlistMbid}`); + + // Stop any existing polling (reuse YouTube polling infrastructure) + if (activeYouTubePollers[playlistMbid]) { + clearInterval(activeYouTubePollers[playlistMbid]); + } + + // Phase 5: Subscribe via WebSocket + if (socketConnected) { + socket.emit('discovery:subscribe', { ids: [playlistMbid] }); + _discoveryProgressCallbacks[playlistMbid] = (data) => { + if (data.error) { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid]; + return; + } + if (listenbrainzPlaylistStates[playlistMbid]) { + const transformed = { + progress: data.progress || 0, spotify_matches: data.spotify_matches || 0, spotify_total: data.spotify_total || 0, + results: (data.results || []).map((r, i) => ({ + index: r.index !== undefined ? r.index : i, + yt_track: r.lb_track || r.track_name || 'Unknown', + yt_artist: r.lb_artist || r.artist_name || 'Unknown', + status: (r.status === 'found' || r.status === '✅ Found' || r.status_class === 'found') ? '✅ Found' : (r.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: r.status_class || ((r.status === 'found' || r.status === '✅ Found') ? 'found' : (r.status === 'error' ? 'error' : 'not-found')), + spotify_track: r.spotify_data ? r.spotify_data.name : (r.spotify_track || '-'), + spotify_artist: r.spotify_data ? (r.spotify_data.artists && r.spotify_data.artists[0] ? (typeof r.spotify_data.artists[0] === 'object' ? r.spotify_data.artists[0].name : r.spotify_data.artists[0]) : '-') : (r.spotify_artist || '-'), + spotify_album: r.spotify_data ? (typeof r.spotify_data.album === 'object' ? r.spotify_data.album.name : r.spotify_data.album) || '-' : (r.spotify_album || '-'), + spotify_data: r.spotify_data, duration: r.duration || '0:00' + })), + complete: data.complete || data.phase === 'discovered' + }; + const st = listenbrainzPlaylistStates[playlistMbid]; + st.discovery_results = data.results || []; st.discoveryResults = transformed.results; + st.discovery_progress = data.progress || 0; st.discoveryProgress = data.progress || 0; + st.spotify_matches = data.spotify_matches || 0; st.spotifyMatches = data.spotify_matches || 0; + st.spotify_total = data.spotify_total || 0; st.spotifyTotal = data.spotify_total || 0; + updateYouTubeDiscoveryModal(playlistMbid, transformed); + } + if (data.complete || data.phase === 'discovered') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('discovery:unsubscribe', { ids: [playlistMbid] }); delete _discoveryProgressCallbacks[playlistMbid]; + if (listenbrainzPlaylistStates[playlistMbid]) listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; + updateYouTubeModalButtons(playlistMbid, 'discovered'); + const _descElWs = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`); + if (_descElWs) _descElWs.textContent = 'Discovery complete! View the results below.'; + const playlistIdEl = `discover-lb-playlist-${playlistMbid}`; + const syncBtn = document.getElementById(`${playlistIdEl}-sync-btn`); + if (syncBtn) syncBtn.style.display = 'inline-block'; + showToast('ListenBrainz discovery complete!', 'success'); + } + }; + } + + const pollInterval = setInterval(async () => { + // Always poll — no dedicated WebSocket events for discovery progress + try { + const response = await fetch(`/api/listenbrainz/discovery/status/${playlistMbid}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling ListenBrainz discovery status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + return; + } + + // Update state and modal (reuse YouTube infrastructure like Beatport/Tidal) + if (listenbrainzPlaylistStates[playlistMbid]) { + // Transform ListenBrainz results to YouTube modal format (like Beatport does) + const transformedStatus = { + progress: status.progress || 0, + spotify_matches: status.spotify_matches || 0, + spotify_total: status.spotify_total || 0, + results: (status.results || []).map((result, index) => ({ + index: result.index !== undefined ? result.index : index, + yt_track: result.lb_track || result.track_name || 'Unknown', + yt_artist: result.lb_artist || result.artist_name || 'Unknown', + status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data ? (result.spotify_data.artists && result.spotify_data.artists[0] ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : '-') : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) || '-' : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + duration: result.duration || '0:00' + })), + complete: status.complete || status.phase === 'discovered' + }; + + // Store both raw and transformed results (support both naming conventions) + listenbrainzPlaylistStates[playlistMbid].discovery_results = status.results || []; + listenbrainzPlaylistStates[playlistMbid].discoveryResults = transformedStatus.results; + listenbrainzPlaylistStates[playlistMbid].discovery_progress = status.progress || 0; + listenbrainzPlaylistStates[playlistMbid].discoveryProgress = status.progress || 0; + listenbrainzPlaylistStates[playlistMbid].spotify_matches = status.spotify_matches || 0; + listenbrainzPlaylistStates[playlistMbid].spotifyMatches = status.spotify_matches || 0; // camelCase for modal + listenbrainzPlaylistStates[playlistMbid].spotify_total = status.spotify_total || 0; + listenbrainzPlaylistStates[playlistMbid].spotifyTotal = status.spotify_total || 0; // camelCase for modal + + // Update modal if open + updateYouTubeDiscoveryModal(playlistMbid, transformedStatus); + } + + // Check if complete + if (status.complete || status.phase === 'discovered') { + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + + // Update phase in backend for persistence (like Beatport does) + try { + await fetch(`/api/listenbrainz/update-phase/${playlistMbid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phase: 'discovered' }) + }); + console.log('✅ Updated ListenBrainz backend phase to discovered'); + } catch (error) { + console.warn('⚠️ Failed to update backend phase:', error); + } + + // Update phase in frontend state + if (listenbrainzPlaylistStates[playlistMbid]) { + listenbrainzPlaylistStates[playlistMbid].phase = 'discovered'; + } + + // Update modal buttons to show sync and download buttons + updateYouTubeModalButtons(playlistMbid, 'discovered'); + + // Update modal description to "Discovery complete!" + const descEl = document.querySelector(`#youtube-discovery-modal-${playlistMbid} .modal-description`); + if (descEl) descEl.textContent = 'Discovery complete! View the results below.'; + + // Show sync button in playlist listing (hidden by default until discovered) + const playlistId = `discover-lb-playlist-${playlistMbid}`; + const syncBtn = document.getElementById(`${playlistId}-sync-btn`); + if (syncBtn) { + syncBtn.style.display = 'inline-block'; + console.log('✅ Showing sync button after discovery completion'); + } + + console.log('✅ ListenBrainz discovery complete:', playlistMbid); + showToast('ListenBrainz discovery complete!', 'success'); + } + + } catch (error) { + console.error('❌ Error polling ListenBrainz discovery:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + } + }, 1000); + + activeYouTubePollers[playlistMbid] = pollInterval; +} + +function startListenBrainzSyncPolling(playlistMbid, syncPlaylistId) { + // Stop any existing polling + if (activeYouTubePollers[playlistMbid]) { + clearInterval(activeYouTubePollers[playlistMbid]); + } + + // Resolve syncPlaylistId from argument or stored state + const lbState = listenbrainzPlaylistStates[playlistMbid]; + syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + updateYouTubeModalSyncProgress(playlistMbid, progress); + + if (data.status === 'finished') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + updateYouTubeModalButtons(playlistMbid, 'sync_complete'); + showToast('ListenBrainz playlist sync complete!', 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + updateYouTubeModalButtons(playlistMbid, 'discovered'); + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + // Define the polling function (HTTP fallback) + const pollFunction = async () => { + if (socketConnected) return; // Phase 6: WS handles updates + try { + const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling ListenBrainz sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + return; + } + + updateYouTubeModalSyncProgress(playlistMbid, status.progress); + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + updateYouTubeModalButtons(playlistMbid, 'sync_complete'); + showToast('ListenBrainz playlist sync complete!', 'success'); + } else if (status.sync_status === 'error') { + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + updateYouTubeModalButtons(playlistMbid, 'discovered'); + showToast(`Sync failed: ${status.error || 'Unknown error'}`, 'error'); + } + } catch (error) { + console.error('❌ Error polling ListenBrainz sync:', error); + if (activeYouTubePollers[playlistMbid]) { + clearInterval(activeYouTubePollers[playlistMbid]); + delete activeYouTubePollers[playlistMbid]; + } + } + }; + + // Run immediately to get current status (skip if WS active) + if (!socketConnected) pollFunction(); + + // Then continue polling at regular intervals + const pollInterval = setInterval(pollFunction, 1000); + activeYouTubePollers[playlistMbid] = pollInterval; +} + +async function startListenBrainzDiscovery(playlistMbid) { + const state = listenbrainzPlaylistStates[playlistMbid]; + if (!state) { + console.error('❌ No ListenBrainz playlist state found'); + return; + } + + try { + console.log('🔍 Starting ListenBrainz discovery for:', state.playlist.name); + + // Update local phase to discovering + state.phase = 'discovering'; + state.status = 'discovering'; + + // Call backend to start discovery worker + const response = await fetch(`/api/listenbrainz/discovery/start/${playlistMbid}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist: state.playlist + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start discovery'); + } + + console.log('✅ ListenBrainz discovery started on backend'); + + // Start polling for progress + startListenBrainzDiscoveryPolling(playlistMbid); + + // Update modal to show discovering state + updateYouTubeDiscoveryModal(playlistMbid, { + phase: 'discovering', + progress: 0, + results: [] + }); + + showToast('Starting ListenBrainz discovery...', 'info'); + + } catch (error) { + console.error('❌ Error starting ListenBrainz discovery:', error); + showToast(`Error: ${error.message}`, 'error'); + + // Revert phase on error + state.phase = 'fresh'; + state.status = 'pending'; + } +} + +async function startListenBrainzPlaylistSync(playlistMbid) { + const state = listenbrainzPlaylistStates[playlistMbid]; + if (!state) { + console.error('❌ No ListenBrainz playlist state found'); + return; + } + + try { + console.log('🔄 Starting ListenBrainz sync for:', state.playlist.name); + + // Check if being called from playlist listing (has UI elements) or modal + const listingPlaylistId = `discover-lb-playlist-${playlistMbid}`; + const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); + const isFromListing = statusDisplay !== null; + + if (isFromListing) { + console.log('🔄 Sync initiated from playlist listing'); + // Show status display in listing + statusDisplay.style.display = 'block'; + const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); + if (syncButton) { + syncButton.disabled = true; + syncButton.style.opacity = '0.5'; + } + } + + // Call backend to start sync + const response = await fetch(`/api/listenbrainz/sync/start/${playlistMbid}`, { + method: 'POST' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to start sync'); + } + + // Capture sync_playlist_id for WebSocket subscription + const result = await response.json(); + const syncPlaylistId = result.sync_playlist_id; + if (state) state.syncPlaylistId = syncPlaylistId; + + // Update phase to syncing + state.phase = 'syncing'; + + // Start polling for sync progress + if (isFromListing) { + startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId); + } else { + startListenBrainzSyncPolling(playlistMbid, syncPlaylistId); + updateYouTubeModalButtons(playlistMbid, 'syncing'); + } + + showToast('Starting ListenBrainz sync...', 'info'); + + } catch (error) { + console.error('❌ Error starting ListenBrainz sync:', error); + showToast(`Error: ${error.message}`, 'error'); + } +} + +function startListenBrainzListingSyncPolling(playlistMbid, listingPlaylistId, syncPlaylistId) { + console.log(`🔄 Starting listing sync polling for: ${playlistMbid} (UI: ${listingPlaylistId})`); + + // Stop any existing polling + if (activeYouTubePollers[playlistMbid]) { + clearInterval(activeYouTubePollers[playlistMbid]); + } + + // Resolve syncPlaylistId from argument or stored state + const lbState = listenbrainzPlaylistStates[playlistMbid]; + syncPlaylistId = syncPlaylistId || (lbState && lbState.syncPlaylistId); + + // Phase 6: Subscribe via WebSocket + if (socketConnected && syncPlaylistId) { + socket.emit('sync:subscribe', { playlist_ids: [syncPlaylistId] }); + _syncProgressCallbacks[syncPlaylistId] = (data) => { + const progress = data.progress || {}; + const total = progress.total_tracks || 0; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const percentage = total > 0 ? Math.round((matched / total) * 100) : 0; + + const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`); + const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`); + const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`); + const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`); + + if (totalEl) totalEl.textContent = total; + if (matchedEl) matchedEl.textContent = matched; + if (failedEl) failedEl.textContent = failed; + if (percentageEl) percentageEl.textContent = percentage; + + if (data.status === 'finished') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + + const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); + const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); + if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; } + + if (listenbrainzPlaylistStates[playlistMbid]) { + listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete'; + } + + showToast(`Sync complete: ${matched}/${total} tracks matched`, 'success'); + } else if (data.status === 'error' || data.status === 'cancelled') { + if (activeYouTubePollers[playlistMbid]) { clearInterval(activeYouTubePollers[playlistMbid]); delete activeYouTubePollers[playlistMbid]; } + socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] }); + delete _syncProgressCallbacks[syncPlaylistId]; + showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); + } + }; + } + + const pollInterval = setInterval(async () => { + if (socketConnected) return; // Phase 6: WS handles updates + try { + const response = await fetch(`/api/listenbrainz/sync/status/${playlistMbid}`); + const status = await response.json(); + + if (status.error) { + console.error('❌ Error polling ListenBrainz sync status:', status.error); + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + return; + } + + const totalEl = document.getElementById(`${listingPlaylistId}-sync-total`); + const matchedEl = document.getElementById(`${listingPlaylistId}-sync-matched`); + const failedEl = document.getElementById(`${listingPlaylistId}-sync-failed`); + const percentageEl = document.getElementById(`${listingPlaylistId}-sync-percentage`); + + if (totalEl) totalEl.textContent = status.progress?.total_tracks || 0; + if (matchedEl) matchedEl.textContent = status.progress?.matched_tracks || 0; + if (failedEl) failedEl.textContent = status.progress?.failed_tracks || 0; + + const percentage = status.progress?.total_tracks > 0 + ? Math.round(((status.progress?.matched_tracks || 0) / status.progress.total_tracks) * 100) + : 0; + if (percentageEl) percentageEl.textContent = percentage; + + if (status.complete) { + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + + const statusDisplay = document.getElementById(`${listingPlaylistId}-sync-status`); + const syncButton = document.getElementById(`${listingPlaylistId}-sync-btn`); + if (statusDisplay) setTimeout(() => { statusDisplay.style.display = 'none'; }, 3000); + if (syncButton) { syncButton.disabled = false; syncButton.style.opacity = '1'; } + + if (listenbrainzPlaylistStates[playlistMbid]) { + listenbrainzPlaylistStates[playlistMbid].phase = 'sync_complete'; + } + + showToast(`Sync complete: ${status.progress?.matched_tracks || 0}/${status.progress?.total_tracks || 0} tracks matched`, 'success'); + } + } catch (error) { + console.error('❌ Error polling ListenBrainz listing sync:', error); + clearInterval(pollInterval); + delete activeYouTubePollers[playlistMbid]; + } + }, 1000); + + activeYouTubePollers[playlistMbid] = pollInterval; +} + +// ============================================================================ + diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js new file mode 100644 index 00000000..95e592c2 --- /dev/null +++ b/webui/static/sync-spotify.js @@ -0,0 +1,2554 @@ +// == SYNC PAGE SPOTIFY FUNCTIONALITY == +// =========================================== + +async function loadSyncData() { + // This is called when the sync page is navigated to. + // Load server playlists first (default active tab) + if (!window._serverPlaylistsLoaded) { + window._serverPlaylistsLoaded = true; + loadServerPlaylists(); // Don't await — load in background + } + + if (!spotifyPlaylistsLoaded) { + await loadSpotifyPlaylists(); + } + + // Load YouTube playlists from backend (always refresh to get latest state) + await loadYouTubePlaylistsFromBackend(); + + // Render saved URL histories for YouTube, Deezer, Spotify Link tabs + initUrlHistories(); +} + +async function ensureBeatportContentLoaded() { + if (beatportContentState.loaded) { + showBeatportDownloadsSection(); + return true; + } + + if (beatportContentState.loadingPromise) { + return beatportContentState.loadingPromise; + } + + beatportContentState.abortController = new AbortController(); + beatportContentState.loadingPromise = (async () => { + try { + console.log('🎧 Lazy-loading Beatport content...'); + + await hydrateBeatportBubblesFromSnapshot(); + throwIfBeatportLoadAborted(); + await loadBeatportChartsFromBackend(); + throwIfBeatportLoadAborted(); + + initializeBeatportRebuildSlider(); + initializeBeatportReleasesSlider(); + initializeBeatportHypePicksSlider(); + initializeBeatportChartsSlider(); + initializeBeatportDJSlider(); + throwIfBeatportLoadAborted(); + await Promise.all([ + loadBeatportTop10Lists(), + loadBeatportTop10Releases() + ]); + throwIfBeatportLoadAborted(); + showBeatportDownloadsSection(); + + beatportContentState.loaded = true; + console.log('✅ Beatport content loaded'); + return true; + } catch (error) { + if (error && error.name === 'AbortError') { + console.log('⏹ Beatport content load aborted'); + return false; + } + console.error('❌ Error loading Beatport content:', error); + return false; + } finally { + beatportContentState.loadingPromise = null; + if (beatportContentState.abortController && beatportContentState.abortController.signal.aborted) { + beatportContentState.abortController = null; + } + } + })(); + + return beatportContentState.loadingPromise; +} + +async function checkForActiveProcesses() { + try { + const response = await fetch('/api/active-processes'); + if (!response.ok) return; + + const data = await response.json(); + const processes = data.active_processes || []; + + if (processes.length > 0) { + console.log(`🔄 Found ${processes.length} active process(es) from backend. Rehydrating UI...`); + + // Separate download batch processes from YouTube playlist processes + const downloadProcesses = processes.filter(p => p.type === 'batch'); + const youtubeProcesses = processes.filter(p => p.type === 'youtube_playlist'); + + console.log(`📊 Process breakdown: ${downloadProcesses.length} download batches, ${youtubeProcesses.length} YouTube playlists`); + + // Rehydrate download modal processes (existing Spotify system) + for (const processInfo of downloadProcesses) { + if (!activeDownloadProcesses[processInfo.playlist_id]) { + rehydrateModal(processInfo); + } + } + + // Note: YouTube playlists are handled by loadYouTubePlaylistsFromBackend() and rehydrateYouTubePlaylist() + // in loadSyncData(), which provides more complete data than active processes and handles download modal rehydration. + console.log(`ℹ️ Skipping ${youtubeProcesses.length} YouTube playlists - handled by full backend loading`); + } + } catch (error) { + console.error('Failed to check for active processes:', error); + } +} + +async function rehydrateArtistAlbumModal(virtualPlaylistId, playlistName, batchId) { + /** + * Rehydrates an artist album download modal from backend process data. + * Extracts artist/album info from virtual playlist ID and recreates the modal. + */ + try { + console.log(`💧 Rehydrating artist album modal: ${virtualPlaylistId} (${playlistName})`); + + // Extract artist_id and album_id from virtualPlaylistId format: artist_album_[artist_id]_[album_id] + const parts = virtualPlaylistId.split('_'); + if (parts.length < 4 || parts[0] !== 'artist' || parts[1] !== 'album') { + console.error(`❌ Invalid virtual playlist ID format: ${virtualPlaylistId}`); + return; + } + + const artistId = parts[2]; + const albumId = parts.slice(3).join('_'); // Handle album IDs that might contain underscores + + console.log(`🔍 Extracted from virtual playlist: artistId=${artistId}, albumId=${albumId}`); + + // Fetch the album tracks to get proper artist and album data + try { + const rehydrateProcess = activeDownloadProcesses[virtualPlaylistId]; + const albumSource = rehydrateProcess?.source || rehydrateProcess?.artist?.source || rehydrateProcess?.album?.source || null; + const artistName = rehydrateProcess?.artist?.name || ''; + const params = new URLSearchParams({ + name: playlistName || '', + artist: artistName + }); + if (albumSource) { + params.set('source', albumSource); + } + + const response = await fetch(`/api/album/${albumId}/tracks?${params}`); + const data = await response.json(); + + if (!data.success || !data.album || !data.tracks) { + console.error('❌ Failed to fetch album data for rehydration:', data.error); + return; + } + + const album = data.album; + const tracks = data.tracks; + const source = data.source || tracks[0]?._source || albumSource || null; + + // Extract artist info from the first track (all tracks should have same artist) + const artist = { + id: artistId, + name: tracks[0].artists[0], // Use first artist name from first track + source + }; + if (source) { + album.source = source; + } + + console.log(`✅ Retrieved album data: "${album.name}" by ${artist.name} (${tracks.length} tracks)`); + + // Create the modal using the same function as normal artist album downloads + await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, tracks, album, artist); + + // Update the rehydrated process with batch info and hide modal for background rehydration + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + + // Update button states to reflect running status + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + if (wishlistBtn) wishlistBtn.style.display = 'none'; + + // Hide the modal - this is background rehydration, not user-requested + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`🔍 Hiding rehydrated modal for background processing: ${album.name}`); + } + + console.log(`✅ Rehydrated artist album modal: ${artist.name} - ${album.name}`); + } else { + console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + + } catch (error) { + console.error(`❌ Error fetching album data for rehydration:`, error); + } + + } catch (error) { + console.error(`❌ Error rehydrating artist album modal:`, error); + } +} + +async function rehydrateDiscoverPlaylistModal(virtualPlaylistId, playlistName, batchId) { + /** + * Rehydrates a discover playlist download modal from backend process data. + * Fetches tracks from the appropriate discover API endpoint and recreates the modal. + */ + try { + console.log(`💧 Rehydrating discover playlist modal: ${virtualPlaylistId} (${playlistName})`); + + // Handle album downloads from Recent Releases + if (virtualPlaylistId.startsWith('discover_album_')) { + const albumId = virtualPlaylistId.replace('discover_album_', ''); + console.log(`💧 Album download - fetching album ${albumId}...`); + + try { + const albumResponse = await fetch(`/api/spotify/album/${albumId}`); + if (!albumResponse.ok) { + console.error(`❌ Failed to fetch album: ${albumResponse.status}`); + return; + } + + const albumData = await albumResponse.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + console.error(`❌ No tracks in album`); + return; + } + + // Convert tracks to expected format + const spotifyTracks = albumData.tracks.map(track => { + let artists = track.artists || []; + if (Array.isArray(artists)) { + artists = artists.map(a => a.name || a); + } + + return { + id: track.id, + name: track.name, + artists: artists, + album: { + name: albumData.name || playlistName.split(' - ')[0], + images: albumData.images || [] + }, + duration_ms: track.duration_ms || 0 + }; + }); + + console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`); + + // Create modal + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + // Update process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Hide modal for background rehydration + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); + } + + console.log(`✅ Rehydrated album modal: ${playlistName}`); + } + return; + + } catch (error) { + console.error(`❌ Error fetching album:`, error); + return; + } + } + + // Determine API endpoint based on playlist ID + let apiEndpoint; + if (virtualPlaylistId === 'discover_release_radar') { + apiEndpoint = '/api/discover/release-radar'; + } else if (virtualPlaylistId === 'discover_discovery_weekly') { + apiEndpoint = '/api/discover/discovery-weekly'; + } else if (virtualPlaylistId === 'discover_seasonal_playlist') { + apiEndpoint = '/api/discover/seasonal-playlist'; + } else if (virtualPlaylistId === 'discover_popular_picks') { + apiEndpoint = '/api/discover/popular-picks'; + } else if (virtualPlaylistId === 'discover_hidden_gems') { + apiEndpoint = '/api/discover/hidden-gems'; + } else if (virtualPlaylistId === 'discover_discovery_shuffle') { + apiEndpoint = '/api/discover/discovery-shuffle'; + } else if (virtualPlaylistId === 'discover_familiar_favorites') { + apiEndpoint = '/api/discover/familiar-favorites'; + } else if (virtualPlaylistId === 'build_playlist_custom') { + apiEndpoint = '/api/discover/build-playlist'; + } else if (virtualPlaylistId.startsWith('discover_lb_')) { + console.log(`💧 ListenBrainz playlist - skipping (no automatic rehydration for ListenBrainz)`); + return; + } else { + console.error(`❌ Unknown discover playlist type: ${virtualPlaylistId}`); + return; + } + + // Fetch tracks from API + console.log(`📡 Fetching tracks from ${apiEndpoint}...`); + const response = await fetch(apiEndpoint); + if (!response.ok) { + console.error(`❌ Failed to fetch discover playlist data: ${response.status}`); + return; + } + + const data = await response.json(); + if (!data.success || !data.tracks) { + console.error(`❌ Invalid discover playlist data:`, data); + return; + } + + const tracks = data.tracks; + console.log(`✅ Retrieved ${tracks.length} tracks for ${playlistName}`); + + // Transform tracks to format expected by download modal (same as openDownloadModalForDiscoverPlaylist) + const spotifyTracks = tracks.map(track => { + let spotifyTrack; + + // Use track_data_json if available, otherwise construct from track data + if (track.track_data_json) { + spotifyTrack = track.track_data_json; + } else { + // Fallback: construct track object from available data + spotifyTrack = { + id: track.spotify_track_id, + name: track.track_name, + artists: [{ name: track.artist_name }], + album: { + name: track.album_name, + images: track.album_cover_url ? [{ url: track.album_cover_url }] : [] + }, + duration_ms: track.duration_ms || 0 + }; + } + + // Normalize artists to array of strings for modal compatibility + if (spotifyTrack.artists && Array.isArray(spotifyTrack.artists)) { + spotifyTrack.artists = spotifyTrack.artists.map(a => a.name || a); + } + + return spotifyTrack; + }); + + // Create the modal using the same function as normal discover downloads + await openDownloadMissingModalForYouTube(virtualPlaylistId, playlistName, spotifyTracks); + + // Update the rehydrated process with batch info and hide modal for background rehydration + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + + // Update button states to reflect running status + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Hide the modal - this is background rehydration, not user-requested + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); + } + + console.log(`✅ Rehydrated discover playlist modal: ${playlistName}`); + } else { + console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + + } catch (error) { + console.error(`❌ Error rehydrating discover playlist modal:`, error); + } +} + +async function rehydrateEnhancedSearchModal(virtualPlaylistId, playlistName, batchId) { + /** + * Rehydrates an enhanced search download modal from backend process data. + * Fetches item data from searchDownloadBubbles and recreates the modal. + */ + try { + console.log(`💧 Rehydrating enhanced search modal: ${virtualPlaylistId} (${playlistName})`); + + // Find the download in searchDownloadBubbles + let downloadData = null; + for (const artistName in searchDownloadBubbles) { + const bubble = searchDownloadBubbles[artistName]; + const download = bubble.downloads.find(d => d.virtualPlaylistId === virtualPlaylistId); + if (download) { + downloadData = download; + break; + } + } + + if (!downloadData) { + console.warn(`⚠️ No download data found in searchDownloadBubbles for ${virtualPlaylistId}`); + return; + } + + const { item, type } = downloadData; + + if (type === 'album') { + // For albums, fetch tracks (pass name/artist for Hydrabase support) + console.log(`💧 Album download - fetching album ${item.id}...`); + + try { + const _sap1 = new URLSearchParams({ name: item.name || '', artist: item.artist || '' }); + const response = await fetch(`/api/spotify/album/${item.id}?${_sap1}`); + if (!response.ok) { + console.error(`❌ Failed to fetch album: ${response.status}`); + return; + } + + const albumData = await response.json(); + if (!albumData.tracks || albumData.tracks.length === 0) { + console.error(`❌ No tracks in album`); + return; + } + + const spotifyTracks = albumData.tracks.map(track => ({ + id: track.id, + name: track.name, + artists: track.artists || [{ name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }], + album: { + name: item.name, + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: track.duration_ms || 0 + })); + + console.log(`✅ Retrieved ${spotifyTracks.length} tracks for album`); + + // Create modal + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, + item.name, + spotifyTracks, + item, + { name: item.artists?.[0]?.name || item.artist || 'Unknown Artist' }, + false // Don't show loading overlay + ); + + // Update process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Hide modal for background rehydration + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); + } + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + + console.log(`✅ Rehydrated enhanced search album modal: ${playlistName}`); + } else { + console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + + } catch (error) { + console.error(`❌ Error fetching album:`, error); + } + + } else { + // For tracks, create enriched track and open modal + console.log(`💧 Track download - creating modal for ${item.name}...`); + + const enrichedTrack = { + id: item.id, + name: item.name, + artists: item.artists || [{ name: item.artist || 'Unknown Artist' }], + album: item.album || { + name: item.album?.name || 'Unknown Album', + images: item.image_url ? [{ url: item.image_url }] : [] + }, + duration_ms: item.duration_ms || 0 + }; + + // Create modal + await openDownloadMissingModalForYouTube( + virtualPlaylistId, + `${enrichedTrack.name} - ${enrichedTrack.artists[0].name || enrichedTrack.artists[0]}`, + [enrichedTrack] + ); + + // Update process + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = batchId; + subscribeToDownloadBatch(batchId); + + const beginBtn = document.getElementById(`begin-analysis-btn-${virtualPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${virtualPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Hide modal for background rehydration + if (process.modalElement) { + process.modalElement.style.display = 'none'; + console.log(`🔍 Hiding rehydrated modal for background processing: ${playlistName}`); + } + + // Start polling for live updates + startModalDownloadPolling(virtualPlaylistId); + + console.log(`✅ Rehydrated enhanced search track modal: ${playlistName}`); + } else { + console.error(`❌ Failed to find rehydrated process for ${virtualPlaylistId}`); + } + } + + } catch (error) { + console.error(`❌ Error rehydrating enhanced search modal:`, error); + } +} + +async function rehydrateModal(processInfo, userRequested = false) { + const { playlist_id, playlist_name, batch_id, current_cycle } = processInfo; + console.log(`💧 Rehydrating modal for "${playlist_name}" (batch: ${batch_id}) - User requested: ${userRequested}`); + + // Handle YouTube virtual playlists - skip rehydration here, handled by YouTube system + if (playlist_id.startsWith('youtube_')) { + console.log(`⏭️ Skipping YouTube virtual playlist rehydration - handled by YouTube system`); + return; + } + + // Handle Beatport virtual playlists - skip rehydration here, handled by Beatport system + if (playlist_id.startsWith('beatport_')) { + console.log(`⏭️ Skipping Beatport virtual playlist rehydration - handled by Beatport system`); + return; + } + + // Handle artist album virtual playlists + if (playlist_id.startsWith('artist_album_')) { + console.log(`💧 Rehydrating artist album virtual playlist: ${playlist_id}`); + await rehydrateArtistAlbumModal(playlist_id, playlist_name, batch_id); + return; + } + + // Handle discover virtual playlists (Fresh Tape, The Archives) + if (playlist_id.startsWith('discover_')) { + console.log(`💧 Rehydrating discover playlist: ${playlist_id}`); + await rehydrateDiscoverPlaylistModal(playlist_id, playlist_name, batch_id); + return; + } + + // Handle enhanced search virtual playlists (albums and tracks) + if (playlist_id.startsWith('enhanced_search_album_') || playlist_id.startsWith('enhanced_search_track_')) { + console.log(`💧 Rehydrating enhanced search virtual playlist: ${playlist_id}`); + await rehydrateEnhancedSearchModal(playlist_id, playlist_name, batch_id); + return; + } + + // Handle wishlist processes specially + if (playlist_id === "wishlist") { + console.log(`💧 [Rehydrate] Handling wishlist modal for active process: ${batch_id}`); + + // Check if modal already exists and is visible + const existingProcess = activeDownloadProcesses[playlist_id]; + const modalAlreadyOpen = existingProcess && existingProcess.modalElement && + existingProcess.modalElement.style.display === 'flex'; + + if (modalAlreadyOpen) { + console.log(`💧 [Rehydrate] Wishlist modal already open - updating existing modal with auto-process state`); + + // Update existing process with new batch info + existingProcess.status = 'running'; + existingProcess.batchId = batch_id; + + // Update UI to reflect running state + const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Ensure polling is active for live updates + if (!existingProcess.intervalId) { + console.log(`💧 [Rehydrate] Starting polling for existing modal`); + startModalDownloadPolling(playlist_id); + } + + console.log(`✅ [Rehydrate] Successfully updated existing wishlist modal for auto-process`); + } else { + // Only create modal if user requested it - don't create for background auto-processing + if (userRequested) { + console.log(`💧 [Rehydrate] User requested - creating wishlist modal for active process: ${batch_id}`); + + // Create the modal with current server state (pass category filter for auto-processing) + await openDownloadMissingWishlistModal(current_cycle); + const process = activeDownloadProcesses[playlist_id]; + if (!process) { + console.error('❌ [Rehydrate] Failed to create wishlist process in activeDownloadProcesses'); + return; + } + + // Sync process state with server + console.log(`✅ [Rehydrate] Syncing wishlist process state - batchId: ${batch_id}, status: running`); + process.status = 'running'; + process.batchId = batch_id; + + // Update UI to reflect running state + const beginBtn = document.getElementById(`begin-analysis-btn-${playlist_id}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlist_id}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for live updates + startModalDownloadPolling(playlist_id); + + // Show modal + console.log('👤 [Rehydrate] User requested - showing wishlist modal'); + process.modalElement.style.display = 'flex'; + WishlistModalState.setVisible(); + WishlistModalState.clearUserClosed(); + } else { + console.log('🔄 [Rehydrate] Background auto-processing detected - NOT creating modal (user must click wishlist button to see progress)'); + // Don't create modal for background auto-processing + // User must click the wishlist button to see the modal + } + } + return; + } + + // Handle Deezer ARL playlist processes — ensure playlist data is in spotifyPlaylists for modal reuse + if (playlist_id.startsWith('deezer_arl_') && !spotifyPlaylists.find(p => p.id === playlist_id)) { + const rawId = playlist_id.replace('deezer_arl_', ''); + const deezerPlaylist = deezerArlPlaylists.find(p => String(p.id) === rawId); + if (deezerPlaylist) { + spotifyPlaylists.push({ + id: playlist_id, + name: deezerPlaylist.name, + track_count: deezerPlaylist.track_count || 0, + image_url: deezerPlaylist.image_url || '', + owner: deezerPlaylist.owner || '', + }); + } else { + // Playlists not loaded yet — use process info as fallback + spotifyPlaylists.push({ + id: playlist_id, + name: playlist_name || 'Deezer Playlist', + track_count: 0, + }); + } + } + + // Handle regular Spotify / Deezer ARL playlist processes + let playlistData = spotifyPlaylists.find(p => p.id === playlist_id); + if (!playlistData) { + console.warn(`Cannot rehydrate modal: Playlist data for ${playlist_id} not loaded.`); + return; + } + await openDownloadMissingModal(playlist_id); + const process = activeDownloadProcesses[playlist_id]; + if (!process) return; + + process.status = 'running'; + process.batchId = batch_id; + updatePlaylistCardUI(playlist_id); + updateRefreshButtonState(); + + document.getElementById(`begin-analysis-btn-${playlist_id}`).style.display = 'none'; + document.getElementById(`cancel-all-btn-${playlist_id}`).style.display = 'inline-block'; + + // Hide wishlist button if it exists + const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlist_id}`); + if (wishlistBtn) wishlistBtn.style.display = 'none'; + + startModalDownloadPolling(playlist_id); + + process.modalElement.style.display = 'none'; +} + +// =================================================================== +// YOUTUBE PLAYLIST BACKEND HYDRATION FUNCTIONS +// =================================================================== + +async function loadYouTubePlaylistsFromBackend() { + // Load all stored YouTube playlists from backend and recreate cards (similar to Spotify hydration) + try { + console.log('📋 Loading YouTube playlists from backend...'); + + const response = await fetch('/api/youtube/playlists'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch YouTube playlists'); + } + + const data = await response.json(); + const playlists = data.playlists || []; + + console.log(`🎬 Found ${playlists.length} stored YouTube playlists in backend`); + + if (playlists.length === 0) { + console.log('📋 No YouTube playlists to hydrate'); + return; + } + + const container = document.getElementById('youtube-playlist-container'); + + // Create cards for playlists that don't already exist (avoid duplicates) + for (const playlistInfo of playlists) { + const urlHash = playlistInfo.url_hash; + + // Check if card already exists (from rehydration or previous loading) + if (youtubePlaylistStates[urlHash] && youtubePlaylistStates[urlHash].cardElement && + document.body.contains(youtubePlaylistStates[urlHash].cardElement)) { + console.log(`⏭️ Skipping existing YouTube playlist card: ${playlistInfo.playlist.name}`); + + // Update existing state with backend data + const state = youtubePlaylistStates[urlHash]; + state.phase = playlistInfo.phase; + state.discoveryProgress = playlistInfo.discovery_progress; + state.spotifyMatches = playlistInfo.spotify_matches; + state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; + + // Fetch discovery results for existing cards too if they don't have them + if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering' && + (!state.discoveryResults || state.discoveryResults.length === 0)) { + try { + console.log(`🔍 Fetching missing discovery results for existing card: ${playlistInfo.playlist.name}`); + const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + if (fullState.discovery_results) { + state.discoveryResults = fullState.discovery_results; + state.syncPlaylistId = fullState.sync_playlist_id; + state.syncProgress = fullState.sync_progress || {}; + console.log(`✅ Restored ${state.discoveryResults.length} discovery results for existing card`); + } + } + } catch (error) { + console.warn(`⚠️ Error fetching discovery results for existing card:`, error.message); + } + } + + continue; + } + + console.log(`🎬 Creating YouTube playlist card: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase})`); + createYouTubeCardFromBackendState(playlistInfo); + + // Fetch discovery results for non-fresh playlists (same logic as rehydrateYouTubePlaylist) + if (playlistInfo.phase !== 'fresh' && playlistInfo.phase !== 'discovering') { + try { + console.log(`🔍 Fetching discovery results for: ${playlistInfo.playlist.name}`); + const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); + + // Store discovery results in local state + const state = youtubePlaylistStates[urlHash]; + if (fullState.discovery_results && state) { + state.discoveryResults = fullState.discovery_results; + state.syncPlaylistId = fullState.sync_playlist_id; + state.syncProgress = fullState.sync_progress || {}; + console.log(`✅ Restored ${state.discoveryResults.length} discovery results for: ${playlistInfo.playlist.name}`); + } + } else { + console.warn(`⚠️ Could not fetch discovery results for: ${playlistInfo.playlist.name}`); + } + } catch (error) { + console.warn(`⚠️ Error fetching discovery results for ${playlistInfo.playlist.name}:`, error.message); + } + } + } + + // Rehydrate download modals for YouTube playlists in downloading/download_complete phases + for (const playlistInfo of playlists) { + if ((playlistInfo.phase === 'downloading' || playlistInfo.phase === 'download_complete') && + playlistInfo.converted_spotify_playlist_id && playlistInfo.download_process_id) { + + const convertedPlaylistId = playlistInfo.converted_spotify_playlist_id; + + if (!activeDownloadProcesses[convertedPlaylistId]) { + console.log(`💧 Rehydrating download modal for YouTube playlist: ${playlistInfo.playlist.name}`); + try { + // Create the download modal using the YouTube-specific function + const spotifyTracks = youtubePlaylistStates[playlistInfo.url_hash]?.discoveryResults + ?.filter(result => result.spotify_data) + ?.map(result => result.spotify_data) || []; + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForYouTube( + convertedPlaylistId, + playlistInfo.playlist.name, + spotifyTracks + ); + + // Set the modal to running state with the correct batch ID + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = 'running'; + process.batchId = playlistInfo.download_process_id; + + // Update UI to running state + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for this process + startModalDownloadPolling(convertedPlaylistId); + + // Hide modal since this is background rehydration + process.modalElement.style.display = 'none'; + console.log(`✅ Rehydrated download modal for YouTube playlist: ${playlistInfo.playlist.name}`); + } + } else { + console.warn(`⚠️ No Spotify tracks found for YouTube download modal: ${playlistInfo.playlist.name}`); + } + } catch (error) { + console.error(`❌ Error rehydrating download modal for ${playlistInfo.playlist.name}:`, error); + } + } + } + } + + console.log(`✅ Successfully hydrated ${playlists.length} YouTube playlists from backend`); + + } catch (error) { + console.error('❌ Error loading YouTube playlists from backend:', error); + showToast(`Error loading YouTube playlists: ${error.message}`, 'error'); + } +} + +async function loadBeatportChartsFromBackend() { + // Load all stored Beatport charts from backend and recreate cards (similar to YouTube hydration) + try { + console.log('📋 Loading Beatport charts from backend...'); + + const signal = getBeatportContentSignal(); + const response = await fetch('/api/beatport/charts', signal ? { signal } : undefined); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch Beatport charts'); + } + + const charts = await response.json(); + + console.log(`🎧 Found ${charts.length} stored Beatport charts in backend`); + + if (charts.length === 0) { + console.log('📋 No Beatport charts to hydrate'); + return; + } + + const container = document.getElementById('beatport-playlist-container'); + + // Create cards for charts that don't already exist (avoid duplicates) + for (const chartInfo of charts) { + const chartHash = chartInfo.hash; + + // Check if card already exists (from previous loading) + if (beatportChartStates[chartHash] && beatportChartStates[chartHash].cardElement && + document.body.contains(beatportChartStates[chartHash].cardElement)) { + console.log(`⏭️ Skipping existing Beatport chart card: ${chartInfo.name}`); + + // Update existing state with backend data + const state = beatportChartStates[chartHash]; + state.phase = chartInfo.phase; + + continue; + } + + console.log(`🎧 Creating Beatport chart card: ${chartInfo.name} (Phase: ${chartInfo.phase})`); + createBeatportCardFromBackendState(chartInfo); + + // Fetch full state for non-fresh charts to restore discovery results + if (chartInfo.phase !== 'fresh') { + try { + console.log(`🔍 Fetching full state for: ${chartInfo.name}`); + const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`, signal ? { signal } : undefined); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); + + // Store in YouTube state system (since Beatport reuses it) + if (fullState.discovery_results && fullState.discovery_results.length > 0) { + // Transform backend results to frontend format (like Tidal does) + const transformedResults = fullState.discovery_results.map((result, index) => ({ + index: result.index !== undefined ? result.index : index, + yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', + yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', + status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), + spotify_track: result.spotify_data ? result.spotify_data.name : '-', + spotify_artist: result.spotify_data && result.spotify_data.artists ? + result.spotify_data.artists.map(a => a.name || a).join(', ') : '-', + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-' + })); + + // Create Beatport state in YouTube system for modal functionality + youtubePlaylistStates[chartHash] = { + phase: fullState.phase, + playlist: { + name: chartInfo.name, + tracks: chartInfo.chart_data.tracks, + description: `${chartInfo.track_count} tracks from ${chartInfo.name}`, + source: 'beatport' + }, + is_beatport_playlist: true, + beatport_chart_type: chartInfo.chart_data.chart_type, + beatport_chart_hash: chartHash, + discovery_progress: fullState.discovery_progress, + discoveryProgress: fullState.discovery_progress, + spotify_matches: fullState.spotify_matches, + spotifyMatches: fullState.spotify_matches, + discovery_results: fullState.discovery_results, + discoveryResults: transformedResults, + convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id, + download_process_id: fullState.download_process_id, + syncPlaylistId: fullState.sync_playlist_id, + syncProgress: fullState.sync_progress || {} + }; + + console.log(`✅ Restored ${transformedResults.length} discovery results for: ${chartInfo.name}`); + } + } else { + console.warn(`⚠️ Could not fetch full state for: ${chartInfo.name}`); + } + } catch (error) { + if (error && error.name === 'AbortError') throw error; + console.warn(`⚠️ Error fetching full state for ${chartInfo.name}:`, error.message); + } + } + } + + // Rehydrate download modals for Beatport charts in downloading/download_complete phases + for (const chartInfo of charts) { + if ((chartInfo.phase === 'downloading' || chartInfo.phase === 'download_complete') && + chartInfo.converted_spotify_playlist_id && chartInfo.download_process_id) { + + const convertedPlaylistId = chartInfo.converted_spotify_playlist_id; + console.log(`📥 Rehydrating download modal for Beatport chart: ${chartInfo.name} (Playlist: ${convertedPlaylistId})`); + + // Set up active download process for Beatport chart (like YouTube/Tidal) + try { + // Rehydrate the chart state first to get discovery results + await rehydrateBeatportChart(chartInfo, false); + + // Create the download modal using the Beatport-specific function (like YouTube) + if (!activeDownloadProcesses[convertedPlaylistId]) { + // Get tracks from the rehydrated state + const ytState = youtubePlaylistStates[chartInfo.hash]; + let spotifyTracks = []; + + if (ytState && ytState.discovery_results) { + spotifyTracks = ytState.discovery_results + .filter(result => result.spotify_data) + .map(result => { + const track = result.spotify_data; + // Ensure artists is an array of strings + if (track.artists && Array.isArray(track.artists)) { + track.artists = track.artists.map(artist => + typeof artist === 'string' ? artist : (artist.name || artist) + ); + } else if (track.artists && typeof track.artists === 'string') { + track.artists = [track.artists]; + } else { + track.artists = ['Unknown Artist']; + } + return { + id: track.id, + name: track.name, + artists: track.artists, + album: track.album || 'Unknown Album', + duration_ms: track.duration_ms || 0, + external_urls: track.external_urls || {} + }; + }); + } + + if (spotifyTracks.length > 0) { + await openDownloadMissingModalForYouTube( + convertedPlaylistId, + chartInfo.name, + spotifyTracks + ); + + // Set the modal to running state with the correct batch ID + const process = activeDownloadProcesses[convertedPlaylistId]; + if (process) { + process.status = chartInfo.phase === 'download_complete' ? 'complete' : 'running'; + process.batchId = chartInfo.download_process_id; + + // Update UI to running state + const beginBtn = document.getElementById(`begin-analysis-btn-${convertedPlaylistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${convertedPlaylistId}`); + if (beginBtn) beginBtn.style.display = 'none'; + if (cancelBtn) cancelBtn.style.display = 'inline-block'; + + // Start polling for this process + startModalDownloadPolling(convertedPlaylistId); + + // Hide modal since this is background rehydration + process.modalElement.style.display = 'none'; + console.log(`✅ Rehydrated download modal for Beatport chart: ${chartInfo.name}`); + } + } else { + console.warn(`⚠️ No Spotify tracks found for Beatport download modal: ${chartInfo.name}`); + } + } + } catch (error) { + if (error && error.name === 'AbortError') throw error; + console.warn(`⚠️ Error setting up download process for Beatport chart "${chartInfo.name}":`, error.message); + } + } + } + + throwIfBeatportLoadAborted(); + console.log(`✅ Successfully loaded and rehydrated ${charts.length} Beatport charts`); + + // Start polling for any charts that are still in discovering phase + for (const chartInfo of charts) { + if (chartInfo.phase === 'discovering') { + console.log(`🔄 [Backend Loading] Auto-starting polling for discovering chart: ${chartInfo.name}`); + throwIfBeatportLoadAborted(); + startBeatportDiscoveryPolling(chartInfo.hash); + } + } + + // Update clear button state after loading charts + updateBeatportClearButtonState(); + + } catch (error) { + if (error && error.name === 'AbortError') { + console.log('⏹ Beatport chart hydration aborted'); + return; + } + console.error('❌ Error loading Beatport charts from backend:', error); + showToast(`Error loading Beatport charts: ${error.message}`, 'error'); + } +} + +async function loadListenBrainzPlaylistsFromBackend() { + // Load all stored ListenBrainz playlist states from backend for persistence (similar to Beatport hydration) + try { + console.log('📋 Loading ListenBrainz playlists from backend...'); + + const response = await fetch('/api/listenbrainz/playlists'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch ListenBrainz playlists'); + } + + const data = await response.json(); + const playlists = data.playlists || []; + + console.log(`🎵 Found ${playlists.length} stored ListenBrainz playlists in backend`); + + if (playlists.length === 0) { + console.log('📋 No ListenBrainz playlists to hydrate'); + listenbrainzPlaylistsLoaded = true; + return; + } + + // Restore state for each playlist + for (const playlistInfo of playlists) { + const playlistMbid = playlistInfo.playlist_mbid; + + console.log(`🎵 Hydrating ListenBrainz playlist: ${playlistInfo.playlist.name} (Phase: ${playlistInfo.phase}, MBID: ${playlistMbid})`); + + // Fetch full state for non-fresh playlists to restore discovery results + if (playlistInfo.phase !== 'fresh') { + try { + console.log(`🔍 Fetching full state for: ${playlistInfo.playlist.name}`); + const stateResponse = await fetch(`/api/listenbrainz/state/${playlistMbid}`); + if (stateResponse.ok) { + const fullState = await stateResponse.json(); + console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); + + // Transform backend results to frontend format (like Beatport does) + const transformedResults = (fullState.discovery_results || []).map((result, index) => ({ + index: result.index !== undefined ? result.index : index, + yt_track: result.lb_track || result.track_name || 'Unknown', + yt_artist: result.lb_artist || result.artist_name || 'Unknown', + status: result.status === 'found' || result.status === '✅ Found' || result.status_class === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: result.status_class || (result.status === 'found' || result.status === '✅ Found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), + spotify_track: result.spotify_data ? result.spotify_data.name : (result.spotify_track || '-'), + spotify_artist: result.spotify_data && result.spotify_data.artists ? + (Array.isArray(result.spotify_data.artists) ? (typeof result.spotify_data.artists[0] === 'object' ? result.spotify_data.artists[0].name : result.spotify_data.artists[0]) : result.spotify_data.artists) : (result.spotify_artist || '-'), + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : (result.spotify_album || '-'), + spotify_data: result.spotify_data, + duration: result.duration || '0:00' + })); + + // Create ListenBrainz state with both naming conventions + listenbrainzPlaylistStates[playlistMbid] = { + phase: fullState.phase, + playlist: fullState.playlist, + is_listenbrainz_playlist: true, + playlist_mbid: playlistMbid, + // Store with both naming conventions + discovery_results: fullState.discovery_results || [], + discoveryResults: transformedResults, + discovery_progress: fullState.discovery_progress || 0, + discoveryProgress: fullState.discovery_progress || 0, + spotify_matches: fullState.spotify_matches || 0, + spotifyMatches: fullState.spotify_matches || 0, + spotify_total: fullState.spotify_total || 0, + spotifyTotal: fullState.spotify_total || 0, + convertedSpotifyPlaylistId: fullState.converted_spotify_playlist_id, + download_process_id: fullState.download_process_id + }; + + console.log(`✅ Restored ${transformedResults.length} discovery results for: ${playlistInfo.playlist.name}`); + } else { + console.warn(`⚠️ Could not fetch full state for: ${playlistInfo.playlist.name}`); + } + } catch (error) { + console.warn(`⚠️ Error fetching full state for ${playlistInfo.playlist.name}:`, error.message); + } + } + } + + // Start polling for any playlists that are still in discovering phase + for (const playlistInfo of playlists) { + if (playlistInfo.phase === 'discovering') { + console.log(`🔄 [Backend Loading] Auto-starting polling for discovering playlist: ${playlistInfo.playlist.name}`); + startListenBrainzDiscoveryPolling(playlistInfo.playlist_mbid); + } + // Show sync button for discovered playlists (hidden by default) + else if (playlistInfo.phase === 'discovered' || playlistInfo.phase === 'syncing' || playlistInfo.phase === 'sync_complete') { + const playlistId = `discover-lb-playlist-${playlistInfo.playlist_mbid}`; + const syncBtn = document.getElementById(`${playlistId}-sync-btn`); + if (syncBtn) { + syncBtn.style.display = 'inline-block'; + console.log(`✅ Showing sync button for discovered playlist: ${playlistInfo.playlist.name}`); + } + } + } + + listenbrainzPlaylistsLoaded = true; + console.log(`✅ Successfully loaded and rehydrated ${playlists.length} ListenBrainz playlists`); + + } catch (error) { + console.error('❌ Error loading ListenBrainz playlists from backend:', error); + listenbrainzPlaylistsLoaded = true; // Mark as loaded even on error to prevent retries + } +} + +function createBeatportCardFromBackendState(chartInfo) { + // Create Beatport chart card from backend state data + const chartHash = chartInfo.hash; + const chartData = chartInfo.chart_data; + const phase = chartInfo.phase; + + const container = document.getElementById('beatport-playlist-container'); + + // Remove placeholder if it exists + const placeholder = container.querySelector('.playlist-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Create card HTML using same structure as createBeatportCard + const cardHtml = ` +
+
🎧
+
+
${escapeHtml(chartInfo.name)}
+
+ ${chartInfo.track_count} tracks + ${getPhaseText(phase)} +
+
+
+ ♪ ${chartInfo.spotify_total} / ✓ ${chartInfo.spotify_matches} / ✗ ${chartInfo.spotify_total - chartInfo.spotify_matches} (${Math.round((chartInfo.spotify_matches / chartInfo.spotify_total) * 100) || 0}%) +
+ +
+ `; + + container.insertAdjacentHTML('beforeend', cardHtml); + + // Initialize state + beatportChartStates[chartHash] = { + phase: phase, + chart: chartData, + cardElement: document.getElementById(`beatport-card-${chartHash}`) + }; + + // Add click handler + const card = document.getElementById(`beatport-card-${chartHash}`); + if (card) { + card.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); + } + + console.log(`🃏 Created Beatport card from backend state: ${chartInfo.name} (${phase})`); +} + +async function rehydrateBeatportChart(chartInfo, userRequested = false) { + // Rehydrate Beatport chart state and optionally open modal (similar to rehydrateYouTubePlaylist) + const chartHash = chartInfo.hash; + const chartName = chartInfo.name; + + try { + console.log(`🔄 [Rehydration] Starting rehydration for Beatport chart: ${chartName}`); + + // Get full state from backend including discovery results + let fullState; + try { + const signal = getBeatportContentSignal(); + const stateResponse = await fetch(`/api/beatport/charts/status/${chartHash}`, signal ? { signal } : undefined); + if (stateResponse.ok) { + fullState = await stateResponse.json(); + console.log(`📋 [Rehydration] Retrieved full backend state with ${fullState.discovery_results?.length || 0} discovery results`); + } else { + console.warn(`⚠️ [Rehydration] Could not fetch full state, using basic info`); + } + } catch (error) { + if (error && error.name === 'AbortError') return; + console.warn(`⚠️ [Rehydration] Error fetching full state:`, error.message); + } + + const phase = chartInfo.phase; + + // Create or update Beatport chart state + if (!beatportChartStates[chartHash]) { + beatportChartStates[chartHash] = { + phase: 'fresh', + chart: chartInfo.chart_data, + cardElement: null + }; + } + + const state = beatportChartStates[chartHash]; + state.phase = phase; + + // Transform discovery results if available (like Tidal does) + let transformedResults = []; + if (fullState && fullState.discovery_results) { + transformedResults = fullState.discovery_results.map((result, index) => ({ + index: result.index !== undefined ? result.index : index, + yt_track: result.beatport_track ? result.beatport_track.title : 'Unknown', + yt_artist: result.beatport_track ? result.beatport_track.artist : 'Unknown', + status: result.status === 'found' ? '✅ Found' : (result.status === 'error' ? '❌ Error' : '❌ Not Found'), + status_class: result.status_class || (result.status === 'found' ? 'found' : (result.status === 'error' ? 'error' : 'not-found')), + spotify_track: result.spotify_data ? result.spotify_data.name : '-', + spotify_artist: result.spotify_data && result.spotify_data.artists ? + result.spotify_data.artists.map(a => a.name || a).join(', ') : '-', + spotify_album: result.spotify_data ? (typeof result.spotify_data.album === 'object' ? result.spotify_data.album.name : result.spotify_data.album) : '-' + })); + } + + // Store in YouTube state system (since Beatport reuses it) + youtubePlaylistStates[chartHash] = { + phase: phase, + playlist: { + name: chartName, + tracks: chartInfo.chart_data.tracks, + description: `${chartInfo.track_count} tracks from ${chartName}`, + source: 'beatport' + }, + is_beatport_playlist: true, + beatport_chart_type: chartInfo.chart_data.chart_type, + beatport_chart_hash: chartHash, + discovery_progress: fullState?.discovery_progress || chartInfo.discovery_progress, + discoveryProgress: fullState?.discovery_progress || chartInfo.discovery_progress, + spotify_matches: fullState?.spotify_matches || chartInfo.spotify_matches, + spotifyMatches: fullState?.spotify_matches || chartInfo.spotify_matches, + discovery_results: fullState?.discovery_results || [], + discoveryResults: transformedResults, + convertedSpotifyPlaylistId: fullState?.converted_spotify_playlist_id || chartInfo.converted_spotify_playlist_id, + download_process_id: fullState?.download_process_id || chartInfo.download_process_id, + syncPlaylistId: fullState?.sync_playlist_id, + syncProgress: fullState?.sync_progress || {} + }; + + // Restore discovery results if we have them + if (fullState && fullState.discovery_results) { + console.log(`✅ Restored ${fullState.discovery_results.length} discovery results from backend`); + + // Update modal if it already exists + const existingModal = document.getElementById(`youtube-discovery-modal-${chartHash}`); + if (existingModal && !existingModal.classList.contains('hidden')) { + console.log(`🔄 Refreshing existing modal with restored discovery results`); + refreshYouTubeDiscoveryModalTable(chartHash); + } + } + + // Update card display + updateBeatportCardPhase(chartHash, phase); + updateBeatportCardProgress(chartHash, { + spotify_total: chartInfo.spotify_total, + spotify_matches: chartInfo.spotify_matches, + failed: chartInfo.spotify_total - chartInfo.spotify_matches + }); + + // Handle active polling resumption + if (phase === 'discovering') { + console.log(`🔍 Resuming discovery polling for: ${chartName}`); + startBeatportDiscoveryPolling(chartHash); + } else if (phase === 'syncing') { + console.log(`🔄 Resuming sync polling for: ${chartName}`); + startBeatportSyncPolling(chartHash); + } + + // Open modal if user requested + if (userRequested) { + switch (phase) { + case 'discovering': + case 'discovered': + case 'syncing': + case 'sync_complete': + openYouTubeDiscoveryModal(chartHash); + break; + case 'downloading': + case 'download_complete': + // Open download modal if we have the converted playlist ID + if (chartInfo.converted_spotify_playlist_id) { + await openDownloadMissingModal(chartInfo.converted_spotify_playlist_id); + } + break; + } + } + + console.log(`✅ Successfully rehydrated Beatport chart: ${chartName}`); + + } catch (error) { + console.error(`❌ Error rehydrating Beatport chart "${chartName}":`, error); + } +} + +function createYouTubeCardFromBackendState(playlistInfo) { + // Create YouTube playlist card from backend state data + const urlHash = playlistInfo.url_hash; + const playlist = playlistInfo.playlist; + const phase = playlistInfo.phase; + + const container = document.getElementById('youtube-playlist-container'); + + // Remove placeholder if it exists + const placeholder = container.querySelector('.youtube-playlist-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + // Create card HTML (using EXACT same structure as createYouTubeCard) + const cardHtml = ` +
+
+
+
${escapeHtml(playlist.name)}
+
+ ${playlist.tracks.length} tracks + ${getPhaseText(phase)} +
+
+
+ ♪ ${playlistInfo.spotify_total} / ✓ ${playlistInfo.spotify_matches} / ✗ ${playlistInfo.spotify_total - playlistInfo.spotify_matches} / ${Math.round(getProgressWidth(playlistInfo))}% +
+ +
+ `; + + container.insertAdjacentHTML('beforeend', cardHtml); + + // Store state for UI management (but backend remains source of truth) + youtubePlaylistStates[urlHash] = { + phase: phase, + url: playlistInfo.url, + playlist: playlist, + cardElement: document.getElementById(`youtube-card-${urlHash}`), + discoveryResults: [], + discoveryProgress: playlistInfo.discovery_progress, + spotifyMatches: playlistInfo.spotify_matches, + convertedSpotifyPlaylistId: playlistInfo.converted_spotify_playlist_id, + backendSynced: true // Flag to indicate this came from backend + }; + + console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`); +} + +function getActionButtonText(phase) { + switch (phase) { + case 'fresh': return 'Discover'; + case 'discovering': return 'View Progress'; + case 'discovered': return 'View Results'; + case 'syncing': return 'View Sync'; + case 'sync_complete': return 'Download'; + case 'downloading': return 'View Downloads'; + case 'download_complete': return 'Complete'; + default: return 'Open'; + } +} + +function getPhaseText(phase) { + switch (phase) { + case 'fresh': return 'Ready to discover'; + case 'discovering': return 'Discovering...'; + case 'discovered': return 'Discovery Complete'; + case 'syncing': return 'Syncing...'; + case 'sync_complete': return 'Sync Complete'; + case 'downloading': return 'Downloading...'; + case 'download_complete': return 'Download Complete'; + default: return phase; + } +} + +function getPhaseColor(phase) { + switch (phase) { + case 'fresh': return '#999'; + case 'discovering': case 'syncing': case 'downloading': return '#ffa500'; + case 'discovered': case 'sync_complete': case 'download_complete': return 'rgb(var(--accent-rgb))'; + default: return '#999'; + } +} + +function getProgressWidth(playlistInfo) { + if (playlistInfo.phase === 'fresh') return 0; + if (playlistInfo.spotify_total === 0) return 0; + return Math.round((playlistInfo.spotify_matches / playlistInfo.spotify_total) * 100); +} + +async function rehydrateYouTubePlaylist(playlistInfo, userRequested = false) { + // Rehydrate a YouTube playlist's discovery modal state (similar to rehydrateModal) + const urlHash = playlistInfo.url_hash; + const playlistName = playlistInfo.playlist_name; + const phase = playlistInfo.phase; + + console.log(`💧 Rehydrating YouTube playlist "${playlistName}" (Phase: ${phase}) - User requested: ${userRequested}`); + + try { + // First, ensure the card exists (create from backend if needed) + if (!youtubePlaylistStates[urlHash] || !youtubePlaylistStates[urlHash].cardElement) { + console.log(`🃏 Creating missing YouTube card for rehydration: ${playlistName}`); + + // Since playlistInfo from active processes doesn't have full playlist data, + // we need to fetch it from the backend first + try { + const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); + if (stateResponse.ok) { + const fullPlaylistState = await stateResponse.json(); + createYouTubeCardFromBackendState(fullPlaylistState); + } else { + console.error(`❌ Could not fetch full playlist state for card creation: ${playlistName}`); + return; // Can't create card without playlist data + } + } catch (error) { + console.error(`❌ Error fetching playlist state for card creation: ${error.message}`); + return; + } + } + + // Fetch full state from backend to get discovery results + let fullState = null; + if (phase !== 'fresh' && phase !== 'discovering') { + try { + console.log(`🔍 Fetching full backend state for: ${playlistName}`); + const stateResponse = await fetch(`/api/youtube/state/${urlHash}`); + if (stateResponse.ok) { + fullState = await stateResponse.json(); + console.log(`📋 Retrieved full state with ${fullState.discovery_results?.length || 0} discovery results`); + } + } catch (error) { + console.warn(`⚠️ Could not fetch full state for ${playlistName}:`, error.message); + } + } + + // Update local state to match backend + const state = youtubePlaylistStates[urlHash]; + state.phase = phase; + state.discoveryProgress = playlistInfo.discovery_progress; + state.spotifyMatches = playlistInfo.spotify_matches; + state.convertedSpotifyPlaylistId = playlistInfo.converted_spotify_playlist_id; + + // Restore discovery results if we have them + if (fullState && fullState.discovery_results) { + state.discoveryResults = fullState.discovery_results; + state.syncPlaylistId = fullState.sync_playlist_id; + state.syncProgress = fullState.sync_progress || {}; + console.log(`✅ Restored ${state.discoveryResults.length} discovery results from backend`); + + // Update modal if it already exists + const existingModal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (existingModal && !existingModal.classList.contains('hidden')) { + console.log(`🔄 Refreshing existing modal with restored discovery results`); + refreshYouTubeDiscoveryModalTable(urlHash); + } + } + + // Update card display + updateYouTubeCardPhase(urlHash, phase); + updateYouTubeCardProgress(urlHash, playlistInfo); + + // Handle active polling resumption + if (phase === 'discovering') { + console.log(`🔍 Resuming discovery polling for: ${playlistName}`); + startYouTubeDiscoveryPolling(urlHash); + } else if (phase === 'syncing') { + console.log(`🔄 Resuming sync polling for: ${playlistName}`); + startYouTubeSyncPolling(urlHash); + } + + // Open modal if user requested + if (userRequested) { + switch (phase) { + case 'discovering': + case 'discovered': + case 'syncing': + case 'sync_complete': + openYouTubeDiscoveryModal(urlHash); + break; + case 'downloading': + case 'download_complete': + // Open download modal if we have the converted playlist ID + if (playlistInfo.converted_spotify_playlist_id) { + await openDownloadMissingModal(playlistInfo.converted_spotify_playlist_id); + } + break; + } + } + + console.log(`✅ Successfully rehydrated YouTube playlist: ${playlistName}`); + + } catch (error) { + console.error(`❌ Error rehydrating YouTube playlist "${playlistName}":`, error); + } +} + +async function removeYouTubePlaylistFromBackend(event, urlHash) { + // Remove YouTube playlist from backend storage and update UI + event.stopPropagation(); // Prevent card click + + const state = youtubePlaylistStates[urlHash]; + if (!state) return; + + const playlistName = state.playlist.name; + + try { + console.log(`🗑️ Removing YouTube playlist from backend: ${playlistName}`); + + const response = await fetch(`/api/youtube/delete/${urlHash}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete playlist'); + } + + // Remove card from UI + if (state.cardElement) { + state.cardElement.remove(); + } + + // Remove from client state + delete youtubePlaylistStates[urlHash]; + + // Stop any active polling + if (activeYouTubePollers[urlHash]) { + clearInterval(activeYouTubePollers[urlHash]); + delete activeYouTubePollers[urlHash]; + } + + // Close discovery modal if open + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + if (modal) { + modal.remove(); + } + + // Show placeholder if no cards left + const container = document.getElementById('youtube-playlist-container'); + const cards = container.querySelectorAll('.youtube-playlist-card'); + if (cards.length === 0) { + container.innerHTML = '
No YouTube playlists added yet. Parse a YouTube playlist URL above to get started!
'; + } + + showToast(`Removed "${playlistName}" from backend storage`, 'success'); + console.log(`✅ Successfully removed YouTube playlist: ${playlistName}`); + + } catch (error) { + console.error(`❌ Error removing YouTube playlist "${playlistName}":`, error); + showToast(`Error removing playlist: ${error.message}`, 'error'); + } +} + +async function loadSpotifyPlaylists() { + const container = document.getElementById('spotify-playlist-container'); + const refreshBtn = document.getElementById('spotify-refresh-btn'); + + container.innerHTML = `
🔄 Loading playlists...
`; + refreshBtn.disabled = true; + refreshBtn.textContent = '🔄 Loading...'; + + try { + const response = await fetch('/api/spotify/playlists'); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to fetch playlists'); + } + spotifyPlaylists = await response.json(); + renderSpotifyPlaylists(); + spotifyPlaylistsLoaded = true; + + await checkForActiveProcesses(); + + } catch (error) { + container.innerHTML = `
❌ Error: ${error.message}
`; + showToast(`Error loading playlists: ${error.message}`, 'error'); + } finally { + refreshBtn.disabled = false; + refreshBtn.textContent = '🔄 Refresh'; + } +} + +function renderSpotifyPlaylists() { + const container = document.getElementById('spotify-playlist-container'); + if (spotifyPlaylists.length === 0) { + container.innerHTML = `
No Spotify playlists found.
`; + return; + } + + container.innerHTML = spotifyPlaylists.map(p => { + let statusClass = 'status-never-synced'; + if (p.sync_status.startsWith('Synced')) statusClass = 'status-synced'; + if (p.sync_status === 'Needs Sync') statusClass = 'status-needs-sync'; + + // This HTML structure creates the interactive playlist cards + return ` +
+
+
+
${escapeHtml(p.name)}
+
+ ${p.track_count} tracks • + ${p.sync_status} +
+
+
+
+ + +
+
+
+ `; + }).join(''); +} + +function handleViewProgressClick(event, playlistId) { + event.stopPropagation(); // Prevent the card selection from toggling + const process = activeDownloadProcesses[playlistId]; + + if (process && process.modalElement) { + // If a process is active, just show its modal + console.log(`Re-opening active download modal for playlist ${playlistId}`); + process.modalElement.style.display = 'flex'; + } +} + +function updatePlaylistCardUI(playlistId) { + const process = activeDownloadProcesses[playlistId]; + const progressBtn = document.getElementById(`progress-btn-${playlistId}`); + const actionBtn = document.getElementById(`action-btn-${playlistId}`); + const card = document.querySelector(`.playlist-card[data-playlist-id="${playlistId}"]`); + + if (!progressBtn || !actionBtn) return; + + if (process && process.status === 'running') { + // A process is running: show the progress button + progressBtn.classList.remove('hidden'); + progressBtn.textContent = 'View Progress'; + progressBtn.style.backgroundColor = ''; // Reset any custom styling + actionBtn.textContent = '📥 Downloading...'; + actionBtn.disabled = true; + + // Remove completion styling from card + if (card) card.classList.remove('download-complete'); + + } else if (process && process.status === 'complete') { + // Process completed: show "ready for review" indicator + progressBtn.classList.remove('hidden'); + progressBtn.textContent = '📋 View Results'; + progressBtn.style.backgroundColor = '#28a745'; // Green success color + progressBtn.style.color = 'white'; + actionBtn.textContent = '✅ Ready for Review'; + actionBtn.disabled = false; // Allow clicking to see results + + // Add completion styling to card + if (card) card.classList.add('download-complete'); + + } else { + // No process or it's been cleaned up: normal state + progressBtn.classList.add('hidden'); + progressBtn.style.backgroundColor = ''; // Reset styling + progressBtn.style.color = ''; // Reset styling + actionBtn.textContent = 'Sync / Download'; + actionBtn.disabled = false; + + // Remove completion styling from card + if (card) card.classList.remove('download-complete'); + } +} + +async function cleanupDownloadProcess(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) return; + + console.log(`🧹 Cleaning up download process for playlist ${playlistId}`); + + // Stop any active polling first + if (process.poller) { + console.log(`🛑 Stopping individual polling for ${playlistId}`); + clearInterval(process.poller); + process.poller = null; + } + + // Mark process as no longer running + if (process.status === 'running') { + process.status = 'complete'; + } + + // If the process has a batchId, tell the server to clean it up. + if (process.batchId) { + try { + console.log(`🚀 Sending cleanup request to server for batch: ${process.batchId}`); + const response = await fetch('/api/playlists/cleanup_batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ batch_id: process.batchId }) + }); + + // Handle deferred cleanup (202 = wishlist processing in progress) + if (response.status === 202) { + console.log(`⏳ Wishlist processing in progress for batch ${process.batchId}, will retry cleanup in 2s...`); + // Retry cleanup after delay to allow wishlist processing to complete + setTimeout(async () => { + try { + await fetch('/api/playlists/cleanup_batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ batch_id: process.batchId }) + }); + console.log(`✅ Delayed cleanup completed for batch: ${process.batchId}`); + } catch (error) { + console.warn(`⚠️ Delayed cleanup failed:`, error); + } + }, 2000); // 2 second delay + } else { + console.log(`✅ Server cleanup completed for batch: ${process.batchId}`); + } + } catch (error) { + console.warn(`⚠️ Failed to send cleanup request to server:`, error); + // Don't show toast for cleanup failures - they're not user-facing + } + } + + // Remove modal from DOM + if (process.modalElement && process.modalElement.parentElement) { + process.modalElement.parentElement.removeChild(process.modalElement); + } + + // Remove from client-side global state + delete activeDownloadProcesses[playlistId]; + + // Check if global polling should be stopped + checkAndCleanupGlobalPolling(); + + // Restore card UI (only for non-wishlist playlists) + if (playlistId !== 'wishlist') { + updatePlaylistCardUI(playlistId); + } + updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist +} + +function togglePlaylistSelection(event) { + const card = event.currentTarget; + const playlistId = card.dataset.playlistId; + + // Don't toggle if clicking the button + if (event.target.tagName === 'BUTTON') return; + + const isSelected = !card.classList.contains('selected'); + card.classList.toggle('selected', isSelected); + + if (isSelected) { + selectedPlaylists.add(playlistId); + } else { + selectedPlaylists.delete(playlistId); + } + updateSyncActionsUI(); +} + +function updateSyncActionsUI() { + // If sequential sync is running, let the manager handle UI updates + if (sequentialSyncManager && sequentialSyncManager.isRunning) { + sequentialSyncManager.updateUI(); + return; + } + + const selectionInfo = document.getElementById('selection-info'); + const startSyncBtn = document.getElementById('start-sync-btn'); + const count = selectedPlaylists.size; + + if (count === 0) { + if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync'; + if (startSyncBtn) startSyncBtn.disabled = true; + } else { + if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`; + if (startSyncBtn) startSyncBtn.disabled = false; + } +} + +async function openPlaylistDetailsModal(event, playlistId) { + event.stopPropagation(); + + const playlist = spotifyPlaylists.find(p => p.id === playlistId); + if (!playlist) return; + + showLoadingOverlay(`Loading playlist: ${playlist.name}...`); + + try { + // --- CACHING LOGIC START --- + if (playlistTrackCache[playlistId]) { + console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`); + // Use the cached tracks instead of fetching + const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] }; + showPlaylistDetailsModal(fullPlaylist); + } else { + console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); + // Fetch from the server if not in cache + const response = await fetch(`/api/spotify/playlist/${playlistId}`); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + + // Store the fetched tracks in the cache + playlistTrackCache[playlistId] = fullPlaylist.tracks; + console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`); + + // Auto-mirror this Spotify playlist + mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ + track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', + album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', + duration_ms: t.duration_ms || 0, + image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null, + source_track_id: t.id || t.spotify_track_id || '' + })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); + + showPlaylistDetailsModal(fullPlaylist); + } + // --- CACHING LOGIC END --- + + } catch (error) { + showToast(`Error: ${error.message}`, 'error'); + } finally { + hideLoadingOverlay(); + } +} + +function showPlaylistDetailsModal(playlist) { + // Create modal if it doesn't exist + let modal = document.getElementById('playlist-details-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'playlist-details-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + // Check if there's a completed download missing tracks process for this playlist + const activeProcess = activeDownloadProcesses[playlist.id]; + const hasCompletedProcess = activeProcess && activeProcess.status === 'complete'; + + // Check if sync is currently running for this playlist + const isSyncing = !!activeSyncPollers[playlist.id]; + + modal.innerHTML = ` + + `; + + modal.style.display = 'flex'; +} + +function closePlaylistDetailsModal() { + const modal = document.getElementById('playlist-details-modal'); + if (modal) { + modal.style.display = 'none'; + } +} + +function formatDuration(ms) { + const minutes = Math.floor(ms / 60000); + const seconds = Math.floor((ms % 60000) / 1000); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +// =============================== +// DOWNLOAD MISSING TRACKS MODAL +// =============================== + +let activeAnalysisTaskId = null; +let currentPlaylistTracks = []; +let analysisResults = []; +let missingTracks = []; + +// New variables for enhanced modal functionality +let currentDownloadBatchId = null; + +// =============================== +// HERO SECTION HELPER FUNCTIONS +// =============================== + +/** + * Generate hero section HTML for download missing tracks modal + * Context-aware display based on available data + */ +function generateDownloadModalHeroSection(context) { + const { type, playlist, artist, album, trackCount } = context; + + let heroContent = ''; + let heroBackgroundImage = ''; + + switch (type) { + case 'album': + case 'artist_album': + // Artist/album context - show artist + album images + const artistImage = artist?.image_url || artist?.images?.[0]?.url; + const albumImage = album?.image_url || album?.images?.[0]?.url; + + // Use album image as background if available + if (albumImage) { + heroBackgroundImage = `
`; + } + + heroContent = ` +
+
+ ${artistImage ? `${escapeHtml(artist.name)}` : ''} + ${albumImage ? `${escapeHtml(album.name)}` : ''} +
+ +
+ `; + break; + + case 'playlist': + // Playlist context - show playlist info + heroContent = ` +
+
🎵
+ +
+ `; + break; + + case 'wishlist': + // Wishlist context - show wishlist icon + heroContent = ` +
+
👁️
+ +
+ `; + break; + + default: + // Fallback - basic display + heroContent = ` +
+
📥
+ +
+ `; + break; + } + + return ` +
+ ${heroBackgroundImage} + ${heroContent} +
+
+
${context.trackCount}
+
Total
+
+
+
-
+
Found
+
+
+
-
+
Missing
+
+
+
0
+
Downloaded
+
+
+
+
+ × +
+ `; +} +let modalDownloadPoller = null; +let currentModalPlaylistId = null; + +// PHASE 2: Local cancelled track management (GUI PARITY) +let cancelledTracks = new Set(); // Track cancelled track indices like GUI's cancelled_tracks + +const TRACK_RENDER_BATCH_SIZE = 100; + +function applyProgressiveTrackRendering(playlistId, totalTrackCount) { + if (totalTrackCount <= TRACK_RENDER_BATCH_SIZE) return; + + const modal = document.getElementById(`download-missing-modal-${playlistId}`); + if (!modal) return; + + const tbody = document.getElementById(`download-tracks-tbody-${playlistId}`); + if (!tbody) return; + + const rows = tbody.querySelectorAll('tr[data-track-index]'); + if (rows.length <= TRACK_RENDER_BATCH_SIZE) return; + + // Hide rows beyond first batch + for (let i = TRACK_RENDER_BATCH_SIZE; i < rows.length; i++) { + rows[i].classList.add('hidden'); + } + + let revealedCount = TRACK_RENDER_BATCH_SIZE; + + // Append indicator into .download-tracks-title + const titleEl = modal.querySelector('.download-tracks-title'); + if (titleEl) { + const indicator = document.createElement('span'); + indicator.className = 'track-render-indicator'; + indicator.id = `track-render-indicator-${playlistId}`; + indicator.textContent = `Showing ${revealedCount} of ${totalTrackCount} tracks`; + titleEl.appendChild(indicator); + } + + // Scroll listener on table container + const container = modal.querySelector('.download-tracks-table-container'); + if (!container) return; + + container.addEventListener('scroll', function onScroll() { + const scrollBottom = container.scrollHeight - container.scrollTop - container.clientHeight; + if (scrollBottom > 200) return; + if (revealedCount >= rows.length) return; + + const nextEnd = Math.min(revealedCount + TRACK_RENDER_BATCH_SIZE, rows.length); + for (let i = revealedCount; i < nextEnd; i++) { + rows[i].classList.remove('hidden'); + } + revealedCount = nextEnd; + + const indicator = document.getElementById(`track-render-indicator-${playlistId}`); + if (indicator) { + indicator.textContent = revealedCount >= rows.length + ? `Showing all ${totalTrackCount} tracks` + : `Showing ${revealedCount} of ${totalTrackCount} tracks`; + } + + if (revealedCount >= rows.length) { + container.removeEventListener('scroll', onScroll); + } + }); +} + +async function openDownloadMissingModal(playlistId) { + showLoadingOverlay('Loading playlist...'); + + // **NEW**: Check if a process is already active for this playlist + if (activeDownloadProcesses[playlistId]) { + console.log(`Modal for ${playlistId} already exists. Showing it.`); + closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal + const process = activeDownloadProcesses[playlistId]; + if (process.modalElement) { + // Show helpful message if it's a completed process + if (process.status === 'complete') { + showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + } + process.modalElement.style.display = 'flex'; + } + hideLoadingOverlay(); + return; // Don't create a new one + } + + console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`); + + closePlaylistDetailsModal(); + const playlist = spotifyPlaylists.find(p => p.id === playlistId); + if (!playlist) { + showToast('Could not find playlist data.', 'error'); + hideLoadingOverlay(); + return; + } + + let tracks = playlistTrackCache[playlistId]; + if (!tracks) { + try { + const fetchUrl = playlistId.startsWith('deezer_arl_') + ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` + : `/api/spotify/playlist/${playlistId}`; + const response = await fetch(fetchUrl); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + tracks = fullPlaylist.tracks; + playlistTrackCache[playlistId] = tracks; + } catch (error) { + showToast(`Failed to fetch tracks: ${error.message}`, 'error'); + hideLoadingOverlay(); + return; + } + } + + currentPlaylistTracks = tracks; + currentModalPlaylistId = playlistId; + + let modal = document.createElement('div'); + modal.id = `download-missing-modal-${playlistId}`; // **NEW**: Unique ID + modal.className = 'download-missing-modal'; // **NEW**: Use class for styling + modal.style.display = 'none'; // Start hidden + document.body.appendChild(modal); + + // **NEW**: Register the new process in our global state tracker + activeDownloadProcesses[playlistId] = { + status: 'idle', // idle, running, complete, cancelled + modalElement: modal, + poller: null, + batchId: null, + playlist: playlist, + tracks: tracks + }; + + // Generate hero section for playlist context + const heroContext = { + type: 'playlist', + playlist: playlist, + trackCount: tracks.length, + playlistId: playlistId + }; + + modal.innerHTML = ` +
+
+ ${generateDownloadModalHeroSection(heroContext)} +
+ +
+
+
+
+ 🔍 Library Analysis + Ready to start +
+
+
+
+
+
+
+ ⏬ Downloads + Waiting for analysis +
+
+
+
+
+
+ +
+
+

📋 Track Analysis & Download Status

+ ${tracks.length} / ${tracks.length} tracks selected +
+
+ + + + + + + + + + + + + + + ${tracks.map((track, index) => ` + + + + + + + + + + + `).join('')} + +
+ + #TrackArtistDurationLibrary MatchDownload StatusActions
+ + ${index + 1}${escapeHtml(track.name)}${escapeHtml(formatArtists(track.artists))}${formatDuration(track.duration_ms)}🔍 Pending--
+
+
+
+ + +
+ `; + + applyProgressiveTrackRendering(playlistId, tracks.length); + modal.style.display = 'flex'; + hideLoadingOverlay(); +} + +async function autoSavePlaylistM3U(playlistId) { + /** + * Automatically save M3U file server-side for playlist modals only. + * Albums are skipped — they're already grouped by media servers. + * The server checks the m3u_export.enabled setting before writing. + * Uses real DB file paths via /api/generate-playlist-m3u. + */ + const process = activeDownloadProcesses[playlistId]; + if (!process || !process.tracks || process.tracks.length === 0) { + return; + } + + const modal = document.getElementById(`download-missing-modal-${playlistId}`); + if (!modal) return; + + // Skip M3U for non-playlist downloads — albums, singles, redownloads, etc. + const nonPlaylistPrefixes = [ + 'artist_album_', 'discover_album_', 'enhanced_search_album_', 'enhanced_search_track_', + 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_', + 'issue_download_', 'library_redownload_', 'redownload_', + ]; + if (nonPlaylistPrefixes.some(p => playlistId.startsWith(p))) return; + + const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; + const artistName = process.artist?.name || ''; + const albumName = process.album?.name || ''; + const releaseDate = process.album?.release_date || ''; + const year = releaseDate ? releaseDate.substring(0, 4) : ''; + + try { + const response = await fetch('/api/generate-playlist-m3u', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_name: playlistName, + tracks: _extractM3UTracks(process.tracks), + context_type: 'playlist', + artist_name: artistName, + album_name: albumName, + year: year, + save_to_disk: true + }) + }); + + if (response.ok) { + console.log(`✅ Auto-saved M3U for playlist: ${playlistName}`); + } else { + console.warn(`⚠️ Failed to auto-save M3U for ${playlistName}`); + } + } catch (error) { + console.debug('Auto-save M3U error (non-critical):', error); + } +} + +function generateM3UContent(playlistId) { + /** + * Generate M3U file content from modal data + * Shared between manual export and auto-save + */ + const process = activeDownloadProcesses[playlistId]; + if (!process || !process.tracks || process.tracks.length === 0) { + return null; + } + + const tracks = process.tracks; + const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; + + // Generate M3U8 content with status information + let m3uContent = '#EXTM3U\n'; + m3uContent += `#PLAYLIST:${playlistName}\n`; + m3uContent += `#GENERATED:${new Date().toISOString()}\n\n`; + + let foundCount = 0; + let downloadedCount = 0; + let missingCount = 0; + + tracks.forEach((track, index) => { + const durationSeconds = track.duration_ms ? Math.floor(track.duration_ms / 1000) : -1; + let artists = 'Unknown Artist'; + if (Array.isArray(track.artists)) { + artists = track.artists.map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : String(a)).filter(Boolean).join(', ') || 'Unknown Artist'; + } else if (typeof track.artists === 'string') { + artists = track.artists; + } else if (track.artist) { + artists = typeof track.artist === 'object' ? (track.artist.name || 'Unknown Artist') : String(track.artist); + } + + // Check library match status from the modal UI + const matchEl = document.getElementById(`match-${playlistId}-${index}`); + const downloadEl = document.getElementById(`download-${playlistId}-${index}`); + + const isFoundInLibrary = matchEl && matchEl.textContent.includes('Found'); + const isDownloaded = downloadEl && downloadEl.textContent.includes('Completed'); + const isMissing = matchEl && matchEl.textContent.includes('Missing'); + + // Track status + let status = 'UNKNOWN'; + if (isDownloaded) { + status = 'DOWNLOADED'; + downloadedCount++; + } else if (isFoundInLibrary) { + status = 'FOUND_IN_LIBRARY'; + foundCount++; + } else if (isMissing) { + status = 'MISSING'; + missingCount++; + } + + // Add track info + m3uContent += `#EXTINF:${durationSeconds},${artists} - ${track.name}\n`; + m3uContent += `#STATUS:${status}\n`; + + // Generate file path + const sanitizedArtist = artists.replace(/[/\\?%*:|"<>]/g, '-'); + const sanitizedTrack = track.name.replace(/[/\\?%*:|"<>]/g, '-'); + + if (isDownloaded || isFoundInLibrary) { + m3uContent += `${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`; + } else { + m3uContent += `# NOT AVAILABLE: ${sanitizedArtist} - ${sanitizedTrack}.mp3\n\n`; + } + }); + + // Add summary + m3uContent += `#SUMMARY\n`; + m3uContent += `#TOTAL_TRACKS:${tracks.length}\n`; + m3uContent += `#FOUND_IN_LIBRARY:${foundCount}\n`; + m3uContent += `#DOWNLOADED:${downloadedCount}\n`; + m3uContent += `#MISSING:${missingCount}\n`; + + return m3uContent; +} + +async function exportPlaylistAsM3U(playlistId) { + /** + * Export the tracks from the download missing tracks modal as an M3U playlist file. + * Downloads via browser AND saves server-side to the relevant folder (force=true). + * Uses real DB file paths via /api/generate-playlist-m3u. + */ + console.log(`📋 Exporting playlist ${playlistId} as M3U`); + + const process = activeDownloadProcesses[playlistId]; + if (!process || !process.tracks || process.tracks.length === 0) { + showToast('No tracks available to export', 'warning'); + return; + } + + const playlistName = process.playlist?.name || process.playlistName || 'Playlist'; + const albumPrefixes = ['artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', 'spotify_library_', 'beatport_release_', 'discover_cache_']; + const isAlbumExport = albumPrefixes.some(p => playlistId.startsWith(p)); + const releaseDate = process.album?.release_date || ''; + const year = releaseDate ? releaseDate.substring(0, 4) : ''; + + let m3uContent, foundCount, missingCount; + try { + const response = await fetch('/api/generate-playlist-m3u', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_name: playlistName, + tracks: _extractM3UTracks(process.tracks), + context_type: isAlbumExport ? 'album' : 'playlist', + artist_name: process.artist?.name || '', + album_name: process.album?.name || '', + year: year, + save_to_disk: true, + force: true + }) + }); + const data = await response.json(); + if (!data.success) throw new Error(data.error || 'Unknown error'); + m3uContent = data.m3u_content; + foundCount = (data.stats?.found || 0) + (data.stats?.downloaded || 0); + missingCount = data.stats?.missing || 0; + } catch (error) { + showToast('Failed to generate M3U content', 'error'); + console.error('M3U export error:', error); + return; + } + + // Browser download + const blob = new Blob([m3uContent], { type: 'audio/x-mpegurl;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${playlistName.replace(/[/\\?%*:|"<>]/g, '-')}.m3u`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + showToast(`Exported M3U: ${foundCount} available, ${missingCount} missing`, 'success'); + console.log(`✅ Exported M3U - Total: ${process.tracks.length}, Available: ${foundCount}, Missing: ${missingCount}`); +} + +function _extractM3UTracks(tracks) { + /** Extract simplified track data for the /api/generate-playlist-m3u endpoint. */ + return tracks.map(t => { + let artist = ''; + if (Array.isArray(t.artists)) { + const first = t.artists[0]; + artist = typeof first === 'object' ? (first.name || '') : String(first || ''); + } else if (typeof t.artists === 'string') { + artist = t.artists; + } else if (t.artist) { + artist = typeof t.artist === 'object' ? (t.artist.name || '') : String(t.artist); + } + return { name: t.name || '', artist, duration_ms: t.duration_ms || 0 }; + }); +} + +// ================================================================================== diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js new file mode 100644 index 00000000..ea476867 --- /dev/null +++ b/webui/static/wishlist-tools.js @@ -0,0 +1,7173 @@ +// DISCOVERY FIX MODAL - Manual Track Matching +// ============================================================================ + +// Global state for discovery fix +let currentDiscoveryFix = { + platform: null, // 'youtube', 'tidal', 'beatport' + identifier: null, // url_hash or playlist_id + trackIndex: null, + sourceTrack: null, + sourceArtist: null +}; + +// Store event handler reference to allow proper removal +let discoveryFixEnterHandler = null; + +/** + * Open discovery fix modal for a specific track + */ +function openDiscoveryFixModal(platform, identifier, trackIndex) { + console.log(`🔧 Opening fix modal: ${platform} - ${identifier} - track ${trackIndex}`); + + // Get the discovery state + // Note: Beatport, Tidal, and ListenBrainz have their own states, but reuse YouTube modal infrastructure + let state, result; + if (platform === 'youtube') { + // Check both states - ListenBrainz also uses YouTube modal infrastructure + state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; + } else if (platform === 'tidal') { + state = youtubePlaylistStates[identifier]; // Tidal uses YouTube state infrastructure + } else if (platform === 'beatport') { + state = youtubePlaylistStates[identifier]; // Beatport uses YouTube state infrastructure + } else if (platform === 'listenbrainz') { + state = listenbrainzPlaylistStates[identifier]; // ListenBrainz has its own state + } else if (platform === 'deezer') { + state = youtubePlaylistStates[identifier]; // Deezer uses YouTube state infrastructure + } else if (platform === 'mirrored') { + state = youtubePlaylistStates[identifier]; // Mirrored playlists use YouTube state infrastructure + } else if (platform === 'spotify_public') { + state = youtubePlaylistStates[identifier]; // Spotify public playlists use YouTube state infrastructure + } + + // Support both camelCase and snake_case for discovery results + const results = state?.discoveryResults || state?.discovery_results; + result = results?.[trackIndex]; + + if (!result) { + console.error('❌ Track data not found'); + console.error(' Platform:', platform); + console.error(' Identifier:', identifier); + console.error(' State:', state); + console.error(' Discovery results (camelCase):', state?.discoveryResults?.length); + console.error(' Discovery results (snake_case):', state?.discovery_results?.length); + showToast('Track data not found', 'error'); + return; + } + + console.log('✅ Found result:', result); + + // Store context + currentDiscoveryFix = { + platform, + identifier, + trackIndex, + sourceTrack: result.lb_track || result.yt_track || result.tidal_track?.name || result.beatport_track?.title || result.track_name || 'Unknown Track', + sourceArtist: result.lb_artist || result.yt_artist || result.tidal_track?.artist || result.beatport_track?.artist || result.artist_name || 'Unknown Artist' + }; + + // Find the fix modal within the active discovery modal + const discoveryModal = document.getElementById(`youtube-discovery-modal-${identifier}`); + if (!discoveryModal) { + console.error('❌ Discovery modal not found:', identifier); + showToast('Discovery modal not found', 'error'); + return; + } + + const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); + if (!fixModalOverlay) { + console.error('❌ Fix modal not found within discovery modal'); + showToast('Fix modal not found', 'error'); + return; + } + + console.log('🔍 Source track:', currentDiscoveryFix.sourceTrack); + console.log('🔍 Source artist:', currentDiscoveryFix.sourceArtist); + console.log('🔍 Fix modal overlay found:', fixModalOverlay); + + // Populate modal - scope within the specific fix modal overlay to handle duplicate IDs + const sourceTrackEl = fixModalOverlay.querySelector('#fix-modal-source-track'); + const sourceArtistEl = fixModalOverlay.querySelector('#fix-modal-source-artist'); + const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input'); + const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input'); + + console.log('🔍 Elements found:', { + sourceTrackEl, + sourceArtistEl, + trackInput, + artistInput + }); + + if (!sourceTrackEl || !sourceArtistEl || !trackInput || !artistInput) { + console.error('❌ Fix modal elements not found in DOM'); + showToast('Fix modal not properly initialized', 'error'); + return; + } + + sourceTrackEl.textContent = currentDiscoveryFix.sourceTrack; + sourceArtistEl.textContent = currentDiscoveryFix.sourceArtist; + trackInput.value = currentDiscoveryFix.sourceTrack; + artistInput.value = currentDiscoveryFix.sourceArtist; + + console.log('✅ Populated modal with:', { + track: trackInput.value, + artist: artistInput.value + }); + + // Remove old enter key handler if exists + if (discoveryFixEnterHandler) { + trackInput.removeEventListener('keypress', discoveryFixEnterHandler); + artistInput.removeEventListener('keypress', discoveryFixEnterHandler); + } + + // Add new enter key handler + discoveryFixEnterHandler = function (e) { + if (e.key === 'Enter') searchDiscoveryFix(); + }; + trackInput.addEventListener('keypress', discoveryFixEnterHandler); + artistInput.addEventListener('keypress', discoveryFixEnterHandler); + + // Show modal BEFORE auto-search so elements are visible + fixModalOverlay.classList.remove('hidden'); + console.log('✅ Fix modal opened, starting auto-search...'); + + // Auto-search with initial values (delay allows modal layout to settle and prevents accidental clicks) + setTimeout(() => searchDiscoveryFix(), 500); +} + +/** + * Close discovery fix modal + */ +function closeDiscoveryFixModal() { + if (!currentDiscoveryFix.identifier) { + console.warn('No active fix modal to close'); + return; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`); + if (discoveryModal) { + const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); + if (fixModalOverlay) { + fixModalOverlay.classList.add('hidden'); + } + } + + currentDiscoveryFix = { platform: null, identifier: null, trackIndex: null, sourceTrack: null, sourceArtist: null }; +} + +/** + * Search for tracks in Spotify + */ +async function searchDiscoveryFix() { + if (!currentDiscoveryFix.identifier) { + console.error('No active fix modal context'); + return; + } + + const discoveryModal = document.getElementById(`youtube-discovery-modal-${currentDiscoveryFix.identifier}`); + if (!discoveryModal) { + console.error('Discovery modal not found'); + return; + } + + const fixModalOverlay = discoveryModal.querySelector('.discovery-fix-modal-overlay'); + if (!fixModalOverlay) { + console.error('Fix modal not found'); + return; + } + + const trackInput = fixModalOverlay.querySelector('#fix-modal-track-input').value.trim(); + const artistInput = fixModalOverlay.querySelector('#fix-modal-artist-input').value.trim(); + + if (!trackInput && !artistInput) { + showToast('Enter track name or artist', 'error'); + return; + } + + const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results'); + + // Build search params + const params = new URLSearchParams(); + if (trackInput) params.set('track', trackInput); + if (artistInput) params.set('artist', artistInput); + if (!trackInput && !artistInput) { + resultsContainer.innerHTML = '
Enter a track name or artist.
'; + return; + } + params.set('limit', '50'); + + // Use the user's active metadata source first, then fall back to others + const activeSource = (currentMusicSourceName || 'Spotify').toLowerCase(); + const allSources = [ + { key: 'spotify', endpoint: '/api/spotify/search_tracks', label: 'Spotify' }, + { key: 'deezer', endpoint: '/api/deezer/search_tracks', label: 'Deezer' }, + { key: 'itunes', endpoint: '/api/itunes/search_tracks', label: 'iTunes' }, + ]; + // Put the active source first, keep others as fallbacks + const activeIdx = allSources.findIndex(s => activeSource.includes(s.key)); + const searchSources = activeIdx > 0 + ? [allSources[activeIdx], ...allSources.filter((_, i) => i !== activeIdx)] + : allSources; + + resultsContainer.innerHTML = `
🔍 Searching ${searchSources[0].label}...
`; + + try { + for (let i = 0; i < searchSources.length; i++) { + const source = searchSources[i]; + try { + const response = await fetch(`${source.endpoint}?${params.toString()}`); + const data = await response.json(); + + if (data.tracks && data.tracks.length > 0) { + renderDiscoveryFixResults(data.tracks, fixModalOverlay); + return; + } + // No results from this source — show next source status if there is one + if (i < searchSources.length - 1) { + resultsContainer.innerHTML = `
🔍 Trying ${searchSources[i + 1].label}...
`; + } + } catch (e) { + console.warn(`Discovery fix search failed on ${source.label}: ${e.message}`); + } + } + // All sources exhausted + resultsContainer.innerHTML = '
No matches found on any source. Try different search terms.
'; + + } catch (error) { + console.error('Search error:', error); + resultsContainer.innerHTML = '
❌ Search failed. Try again.
'; + } +} + +/** + * Render search results as clickable cards + */ +function renderDiscoveryFixResults(tracks, fixModalOverlay) { + const resultsContainer = fixModalOverlay.querySelector('#fix-modal-results'); + resultsContainer.innerHTML = ''; + + // Sort: standard album versions first, live/remix/cover/soundtrack last + const _variantPattern = /\b(live|remix|remaster|refix|cover|acoustic|demo|instrumental|radio edit|single version|deluxe|edition|soundtrack|from .* film|from .* movie|bonus track)\b|\b\w+ mix\b/i; + const _albumVariantPattern = /\b(live|greatest hits|best of|collection|compilation|soundtrack|from .* film|from .* movie|remaster|deluxe|redux|expanded|anniversary)\b/i; + tracks.sort((a, b) => { + const aVariant = _variantPattern.test(a.name || '') || _albumVariantPattern.test(a.album || ''); + const bVariant = _variantPattern.test(b.name || '') || _albumVariantPattern.test(b.album || ''); + if (aVariant !== bVariant) return aVariant ? 1 : -1; + return 0; // preserve original order within same category + }); + + tracks.forEach(track => { + const card = document.createElement('div'); + card.className = 'fix-result-card'; + card.onclick = () => selectDiscoveryFixTrack(track); + + card.innerHTML = ` +
+
${escapeHtml(track.name || 'Unknown Track')}
+
${escapeHtml((track.artists || ['Unknown Artist']).join(', '))}
+
${escapeHtml(track.album || 'Unknown Album')}
+
${formatDuration(track.duration_ms || 0)}
+
+ `; + + resultsContainer.appendChild(card); + }); +} + +/** + * User selected a track - update discovery state + */ +async function selectDiscoveryFixTrack(track) { + console.log('✅ User selected track:', track); + + // Confirm selection to prevent accidental clicks from layout shift + const artists = (track.artists || ['Unknown Artist']).join(', '); + if (!await showConfirmDialog({ title: 'Confirm Match', message: `Match to "${track.name}" by ${artists}?`, confirmText: 'Confirm' })) return; + + const { platform, identifier, trackIndex } = currentDiscoveryFix; + + console.log('📡 Updating backend match:', { platform, identifier, trackIndex, track }); + + // Update backend + try { + // Get the correct backend identifier based on platform + let backendIdentifier = identifier; + + if (platform === 'tidal') { + // For Tidal, backend expects the actual playlist_id, not url_hash + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.tidal_playlist_id || identifier; + } else if (platform === 'deezer') { + // For Deezer, backend expects the actual playlist_id, not url_hash + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.deezer_playlist_id || identifier; + } else if (platform === 'spotify_public') { + // For Spotify Public, backend expects the url_hash + const state = youtubePlaylistStates[identifier]; + backendIdentifier = state?.spotify_public_playlist_id || identifier; + } else if (platform === 'beatport') { + // For Beatport, backend expects url_hash (same as identifier) + backendIdentifier = identifier; + } + + // Mirrored playlists route through the YouTube endpoint (which already handles mirrored_ prefixes) + const apiPlatform = platform === 'mirrored' ? 'youtube' : (platform === 'spotify_public' ? 'spotify-public' : platform); + + const requestBody = { + identifier: backendIdentifier, + track_index: trackIndex, + spotify_track: { + id: track.id, + name: track.name, + artists: track.artists, + album: track.album, + duration_ms: track.duration_ms, + image_url: track.image_url || null + } + }; + + console.log('📡 Request body:', requestBody); + console.log('📡 Backend identifier:', backendIdentifier); + + const response = await fetch(`/api/${apiPlatform}/discovery/update_match`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + console.log('📡 Response status:', response.status); + + const data = await response.json(); + + console.log('📡 Response data:', data); + + if (data.error) { + showToast(`Failed to update: ${data.error}`, 'error'); + console.error('❌ Backend update failed:', data.error); + return; + } + + showToast('Match updated successfully!', 'success'); + console.log('✅ Backend update successful'); + + // Update frontend state + // Note: Beatport and Tidal reuse youtubePlaylistStates for discovery results + // ListenBrainz uses its own state but may also be accessed via YouTube + let state; + if (platform === 'youtube') { + state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; + } else if (platform === 'tidal') { + state = youtubePlaylistStates[identifier]; + } else if (platform === 'deezer') { + state = youtubePlaylistStates[identifier]; + } else if (platform === 'beatport') { + state = youtubePlaylistStates[identifier]; + } else if (platform === 'listenbrainz') { + state = listenbrainzPlaylistStates[identifier]; + } else if (platform === 'mirrored') { + state = youtubePlaylistStates[identifier]; + } else if (platform === 'spotify_public') { + state = youtubePlaylistStates[identifier]; + } + + // Support both camelCase and snake_case + const results = state?.discoveryResults || state?.discovery_results; + if (state && results && results[trackIndex]) { + const result = results[trackIndex]; + const wasNotFound = result.status !== 'found' && result.status_class !== 'found'; + + // Update result + result.status = '✅ Found'; + result.status_class = 'found'; + result.spotify_track = track.name; + result.spotify_artist = Array.isArray(track.artists) + ? track.artists + .map(a => (typeof a === 'object' && a !== null) ? (a.name || '') : a) + .filter(Boolean) + .join(', ') || '-' + : (track.artists || '-'); + result.spotify_album = track.album; + result.spotify_id = track.id; + result.duration = formatDuration(track.duration_ms); + result.manual_match = true; + // User picked a real metadata match — no longer a wing-it track + result.wing_it_fallback = false; + + // IMPORTANT: Also set spotify_data for download/sync compatibility. + // Build album as a dict (not a bare string) so the download + // pipeline can find cover art via album.image_url / album.images. + // This matches the shape that normal discovery produces. + const _fixImageUrl = track.image_url || ''; + let _fixAlbumObj; + if (track.album && typeof track.album === 'object') { + _fixAlbumObj = { ...track.album }; + if (_fixImageUrl && !_fixAlbumObj.image_url) _fixAlbumObj.image_url = _fixImageUrl; + if (_fixImageUrl && !_fixAlbumObj.images) _fixAlbumObj.images = [{ url: _fixImageUrl }]; + } else { + _fixAlbumObj = { name: track.album || '' }; + if (_fixImageUrl) { + _fixAlbumObj.image_url = _fixImageUrl; + _fixAlbumObj.images = [{ url: _fixImageUrl }]; + } + } + result.spotify_data = { + id: track.id, + name: track.name, + artists: track.artists, + album: _fixAlbumObj, + duration_ms: track.duration_ms, + image_url: _fixImageUrl + }; + + // Increment match count if this was previously not_found or error + if (wasNotFound) { + state.spotifyMatches = (state.spotifyMatches || 0) + 1; + + // Update progress bar and text + const spotify_total = state.spotify_total || state.playlist?.tracks?.length || 0; + const progress = spotify_total > 0 ? Math.round((state.spotifyMatches / spotify_total) * 100) : 0; + + const progressBar = document.getElementById(`youtube-discovery-progress-${identifier}`); + const progressText = document.getElementById(`youtube-discovery-progress-text-${identifier}`); + + if (progressBar) { + progressBar.style.width = `${progress}%`; + } + if (progressText) { + progressText.textContent = `${state.spotifyMatches} / ${spotify_total} tracks matched (${progress}%)`; + } + + console.log(`✅ Updated progress: ${state.spotifyMatches}/${spotify_total} (${progress}%)`); + + // Also update the Deezer playlist card if this is a Deezer fix + if (platform === 'deezer' && state.deezer_playlist_id) { + const deezerState = deezerPlaylistStates[state.deezer_playlist_id]; + if (deezerState) { + deezerState.spotifyMatches = state.spotifyMatches; + updateDeezerCardProgress(state.deezer_playlist_id, { + spotify_matches: state.spotifyMatches, + spotify_total: spotify_total + }); + } + } + + // Also update the Tidal playlist card if this is a Tidal fix + if (platform === 'tidal' && state.tidal_playlist_id) { + const tidalState = tidalPlaylistStates?.[state.tidal_playlist_id]; + if (tidalState) { + tidalState.spotifyMatches = state.spotifyMatches; + } + } + + // Also update the Spotify Public playlist card if this is a Spotify Public fix + if (platform === 'spotify_public' && state.spotify_public_playlist_id) { + const spState = spotifyPublicPlaylistStates?.[state.spotify_public_playlist_id]; + if (spState) { + spState.spotifyMatches = state.spotifyMatches; + updateSpotifyPublicCardProgress(state.spotify_public_playlist_id, { + spotify_matches: state.spotifyMatches, + spotify_total: spotify_total + }); + } + } + } + + // Update UI - refresh the table row + updateDiscoveryModalSingleRow(platform, identifier, trackIndex); + } + + // Close modal + closeDiscoveryFixModal(); + + } catch (error) { + console.error('Error updating match:', error); + showToast('Failed to update match', 'error'); + } +} + +/** + * Update a single row in the discovery modal table + */ +function updateDiscoveryModalSingleRow(platform, identifier, trackIndex) { + // Check both state maps - ListenBrainz uses its own, others reuse youtubePlaylistStates + const state = listenbrainzPlaylistStates[identifier] || youtubePlaylistStates[identifier]; + + // Support both camelCase and snake_case + const results = state?.discoveryResults || state?.discovery_results; + if (!state || !results || !results[trackIndex]) { + console.warn(`Cannot update row: state or result not found`); + return; + } + + const result = results[trackIndex]; + const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`); + + if (!row) { + console.warn(`Cannot update row: row element not found for ${identifier}-${trackIndex}`); + return; + } + + // Update cells + const statusCell = row.querySelector('.discovery-status'); + const spotifyTrackCell = row.querySelector('.spotify-track'); + const spotifyArtistCell = row.querySelector('.spotify-artist'); + const spotifyAlbumCell = row.querySelector('.spotify-album'); + const actionsCell = row.querySelector('.discovery-actions'); + + if (statusCell) { + statusCell.textContent = result.status; + statusCell.className = `discovery-status ${result.status_class}`; + } + + if (spotifyTrackCell) spotifyTrackCell.textContent = result.spotify_track || '-'; + if (spotifyArtistCell) spotifyArtistCell.textContent = result.spotify_artist || '-'; + if (spotifyAlbumCell) spotifyAlbumCell.textContent = result.spotify_album || '-'; + + // Update action button + if (actionsCell) { + actionsCell.innerHTML = generateDiscoveryActionButton(result, identifier, platform); + } + + console.log(`✅ Updated row ${trackIndex} in discovery modal`); +} + +async function unmatchDiscoveryTrack(platform, identifier, trackIndex) { + // Determine the correct API base for this platform + const apiBase = platform === 'tidal' ? '/api/tidal' + : platform === 'deezer' ? '/api/deezer' + : platform === 'spotify-public' ? '/api/spotify-public' + : platform === 'beatport' ? '/api/beatport' + : platform === 'listenbrainz' ? '/api/listenbrainz' + : '/api/youtube'; + + try { + const response = await fetch(`${apiBase}/discovery/unmatch`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ identifier, track_index: trackIndex }) + }); + const data = await response.json(); + if (data.success) { + // Update the row in the discovery modal table + const state = youtubePlaylistStates[identifier] + || (window.tidalDiscoveryStates && window.tidalDiscoveryStates[identifier]) + || {}; + if (state.discovery_results && state.discovery_results[trackIndex]) { + const r = state.discovery_results[trackIndex]; + r.status = '❌ Not Found'; + r.status_class = 'not-found'; + r.spotify_track = '-'; + r.spotify_artist = '-'; + r.spotify_album = '-'; + r.spotify_data = null; + r.matched_data = null; + r.confidence = 0; + r.wing_it_fallback = false; + r.manual_match = false; + } + // Re-render the row — discovery rows use id="discovery-row-{urlHash}-{index}" + const row = document.getElementById(`discovery-row-${identifier}-${trackIndex}`); + if (row) { + const statusCell = row.querySelector('.discovery-status'); + if (statusCell) { statusCell.textContent = '❌ Not Found'; statusCell.className = 'discovery-status not-found'; } + const matchedCells = row.querySelectorAll('.spotify-track, .spotify-artist, .spotify-album'); + matchedCells.forEach(c => c.textContent = '-'); + const actionsCell = row.querySelector('.discovery-actions'); + if (actionsCell) { + actionsCell.innerHTML = ``; + } + } + showToast('Match removed', 'success'); + } else { + showToast(data.error || 'Failed to remove match', 'error'); + } + } catch (e) { + console.error('Unmatch error:', e); + showToast('Failed to remove match', 'error'); + } +} + +// Make discovery-fix functions available globally for onclick handlers +window.openDiscoveryFixModal = openDiscoveryFixModal; +window.closeDiscoveryFixModal = closeDiscoveryFixModal; +window.searchDiscoveryFix = searchDiscoveryFix; +window.unmatchDiscoveryTrack = unmatchDiscoveryTrack; +window.openMatchingModal = openMatchingModal; +window.closeMatchingModal = closeMatchingModal; +window.selectArtist = selectArtist; +window.selectAlbum = selectAlbum; + +/** + * Handle post-download cleanup: clear finished downloads from slskd. + * Scan and database update are now handled by system automations + * (batch_complete → scan_library → library_scan_completed → start_database_update). + */ +async function handlePostDownloadAutomation(playlistId, process) { + try { + const successfulDownloads = getSuccessfulDownloadCount(process); + if (successfulDownloads === 0) { + console.log(`🔄 [AUTO] No successful downloads for ${playlistId} - skipping cleanup`); + return; + } + console.log(`🔄 [AUTO] Post-download cleanup for ${playlistId} (${successfulDownloads} successful downloads)`); + + // Clear completed downloads from slskd + try { + const clearResponse = await fetch('/api/downloads/clear-finished', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + if (clearResponse.ok) { + console.log(`✅ [AUTO] Completed downloads cleared`); + } else { + console.warn(`⚠️ [AUTO] Clear downloads failed, continuing anyway`); + } + } catch (error) { + console.warn(`⚠️ [AUTO] Clear error: ${error.message}`); + } + } catch (error) { + console.error(`❌ [AUTO] Error in post-download cleanup: ${error.message}`); + } +} + +/** + * Extract successful download count from a download process + */ +function getSuccessfulDownloadCount(process) { + try { + // For processes that have completed, check the modal for completed count + if (process && process.modalElement) { + const statElement = process.modalElement.querySelector('[id*="stat-downloaded-"]'); + if (statElement && statElement.textContent) { + const count = parseInt(statElement.textContent, 10); + return isNaN(count) ? 0 : count; + } + } + + // Fallback: assume successful if process completed without obvious failure + if (process && process.status === 'complete') { + return 1; // Conservative assumption for single download + } + + return 0; + } catch (error) { + console.warn(`⚠️ [AUTO] Error getting successful download count: ${error.message}`); + return 0; + } +} + +// =============================== +// ADD TO WISHLIST MODAL FUNCTIONS +// =============================== + +let currentWishlistModalData = null; +let wishlistModalVersion = 0; + +/** + * Open the Add to Wishlist modal for an album/EP/single + * @param {Object} album - Album object with id, name, image_url, etc. + * @param {Object} artist - Artist object with id, name, image_url + * @param {Array} tracks - Array of track objects + * @param {string} albumType - Type of release (album, EP, single) + */ +async function openAddToWishlistModal(album, artist, tracks, albumType, trackOwnership) { + wishlistModalVersion++; + showLoadingOverlay('Preparing wishlist...'); + console.log(`🎵 Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`); + + try { + // Store current modal data for use by other functions + currentWishlistModalData = { + album, + artist, + tracks, + albumType + }; + + const modal = document.getElementById('add-to-wishlist-modal'); + const overlay = document.getElementById('add-to-wishlist-modal-overlay'); + + if (!modal || !overlay) { + console.error('Add to wishlist modal elements not found'); + return; + } + + // Generate and populate hero section + const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership); + const heroContainer = document.getElementById('add-to-wishlist-modal-hero'); + if (heroContainer) { + heroContainer.innerHTML = heroContent; + } + + // Generate and populate track list + const trackListHTML = generateWishlistTrackList(tracks, trackOwnership); + const trackListContainer = document.getElementById('wishlist-track-list'); + if (trackListContainer) { + trackListContainer.innerHTML = trackListHTML; + } + + // Set up the "Add to Wishlist" button click handler + const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn'); + if (addToWishlistBtn) { + addToWishlistBtn.onclick = () => handleAddToWishlist(); + } + + // Show the modal + overlay.classList.remove('hidden'); + hideLoadingOverlay(); + + console.log(`✅ Successfully opened Add to Wishlist modal for: ${album.name}`); + + } catch (error) { + console.error('❌ Error opening Add to Wishlist modal:', error); + hideLoadingOverlay(); + showToast(`Error opening wishlist modal: ${error.message}`, 'error'); + } +} + +/** + * Generate the hero section HTML for the wishlist modal + */ +function generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership) { + const artistImage = artist.image_url || ''; + const albumImage = album.image_url || ''; + const trackCount = tracks.length; + + // Calculate missing tracks if ownership info is available + let trackDetailText = `${trackCount} track${trackCount !== 1 ? 's' : ''}`; + if (trackOwnership) { + const ownedCount = Object.values(trackOwnership).filter(v => v === true).length; + const missingCount = trackCount - ownedCount; + if (missingCount > 0 && ownedCount > 0) { + trackDetailText = `${missingCount} of ${trackCount} tracks missing`; + } + } + + let heroBackgroundImage = ''; + if (albumImage) { + heroBackgroundImage = `
`; + } + + const heroContent = ` +
+
+ ${artistImage ? `${escapeHtml(artist.name)}` : ''} + ${albumImage ? `${escapeHtml(album.name)}` : ''} +
+ +
+ `; + + return ` + ${heroBackgroundImage} + ${heroContent} + `; +} + +/** + * Generate the track list HTML for the wishlist modal + */ +function generateWishlistTrackList(tracks, trackOwnership) { + if (!tracks || tracks.length === 0) { + return '
No tracks found
'; + } + + return tracks.map((track, index) => { + const trackNumber = track.track_number || (index + 1); + const trackName = escapeHtml(track.name || 'Unknown Track'); + const artistsString = formatArtists(track.artists) || 'Unknown Artist'; + const duration = formatDuration(track.duration_ms); + + const trackData = trackOwnership ? trackOwnership[track.name] : null; + const isOwned = trackData && (trackData.owned === true || trackData === true); + const isKnown = trackData !== null && trackData !== undefined; + const ownershipClass = isOwned ? 'owned' : (isKnown && !isOwned ? 'missing' : ''); + const badge = isOwned + ? '
' + : ''; + + return ` +
+
${trackNumber}
+
+
${trackName}
+
${artistsString}
+
+
${duration}
+ ${badge} +
+ `; + }).join(''); +} + +/** + * Handle the "Add to Wishlist" button click + */ +async function handleAddToWishlist() { + if (!currentWishlistModalData) { + console.error('❌ No wishlist modal data available'); + return; + } + + const { album, artist, tracks, albumType } = currentWishlistModalData; + const addToWishlistBtn = document.getElementById('confirm-add-to-wishlist-btn'); + + try { + // Show loading state + if (addToWishlistBtn) { + addToWishlistBtn.classList.add('loading'); + addToWishlistBtn.textContent = 'Adding...'; + addToWishlistBtn.disabled = true; + } + + console.log(`🔄 Adding ${tracks.length} tracks to wishlist for: ${artist.name} - ${album.name}`); + + let successCount = 0; + let errorCount = 0; + + // Add each track to wishlist individually + for (const track of tracks) { + try { + // Ensure artists field is in the correct format (array of objects) + let formattedArtists = track.artists; + if (typeof track.artists === 'string') { + // If artists is a string, convert to array of objects + formattedArtists = [{ name: track.artists }]; + } else if (Array.isArray(track.artists)) { + // If artists is already an array, ensure each item is an object + formattedArtists = track.artists.map(artistItem => { + if (typeof artistItem === 'string') { + return { name: artistItem }; + } else if (typeof artistItem === 'object' && artistItem !== null) { + return artistItem; + } else { + return { name: 'Unknown Artist' }; + } + }); + } else { + // Fallback to array with single artist object + formattedArtists = [{ name: artist.name }]; + } + + const formattedTrack = { + ...track, + artists: formattedArtists + }; + + // Use track's album data if available (from API), falling back to modal's album data + // This ensures consistency with how the Artists page handles wishlisting + let trackAlbum = track.album; + let trackAlbumType = albumType || 'album'; + + if (trackAlbum && typeof trackAlbum === 'object') { + // Track has album data from API - use its album_type + trackAlbumType = trackAlbum.album_type || albumType || 'album'; + // Ensure album has required fields + if (!trackAlbum.name) { + trackAlbum.name = album.name; + } + if (!trackAlbum.id) { + trackAlbum.id = album.id; + } + } else { + // Fall back to the album passed to the modal + trackAlbum = album; + } + + console.log(`🔄 Adding track with formatted artists:`, formattedTrack.name, formattedTrack.artists); + console.log(`🔄 Using album_type: ${trackAlbumType} (from ${track.album ? 'track.album' : 'modal album'})`); + + const response = await fetch('/api/add-album-to-wishlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + track: formattedTrack, + artist: artist, + album: trackAlbum, + source_type: 'album', + source_context: { + album_name: trackAlbum.name, + artist_name: artist.name, + album_type: trackAlbumType + } + }) + }); + + const result = await response.json(); + + if (result.success) { + successCount++; + console.log(`✅ Added "${track.name}" to wishlist`); + } else { + errorCount++; + console.error(`❌ Failed to add "${track.name}" to wishlist: ${result.error}`); + } + + } catch (error) { + errorCount++; + console.error(`❌ Error adding "${track.name}" to wishlist:`, error); + } + } + + // Show completion message + if (successCount > 0) { + const message = errorCount > 0 + ? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)` + : `Added ${successCount} tracks to wishlist`; + showToast(message, successCount === tracks.length ? 'success' : 'warning'); + } else { + showToast('Failed to add any tracks to wishlist', 'error'); + } + + // Close the modal + closeAddToWishlistModal(); + + console.log(`✅ Wishlist addition complete: ${successCount} successful, ${errorCount} failed`); + + } catch (error) { + console.error('❌ Error in handleAddToWishlist:', error); + showToast(`Error adding to wishlist: ${error.message}`, 'error'); + } finally { + // Reset button state + if (addToWishlistBtn) { + addToWishlistBtn.classList.remove('loading'); + addToWishlistBtn.textContent = 'Add to Wishlist'; + addToWishlistBtn.disabled = false; + } + } +} + +/** + * Lazy-load per-track ownership indicators into an already-open wishlist modal. + * Fetches ownership from the backend, then updates the modal DOM in-place. + * If all tracks are owned (Spotify metadata discrepancy), also fixes the source card. + */ +async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName = null) { + const myVersion = wishlistModalVersion; + try { + const checkBody = { + artist_name: artistName, + tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number })) + }; + if (albumName) checkBody.album_name = albumName; + const resp = await fetch('/api/library/check-tracks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(checkBody) + }); + const data = await resp.json(); + if (!data.success) return; + + // Guard against stale updates if user reopened modal for a different album + if (myVersion !== wishlistModalVersion) return; + + const ownership = data.owned_tracks; + const trackItems = document.querySelectorAll('#wishlist-track-list .wishlist-track-item'); + + let ownedCount = 0; + trackItems.forEach((item, index) => { + const track = tracks[index]; + if (!track) return; + const trackData = ownership[track.name]; + const isOwned = trackData && trackData.owned === true; + if (isOwned) { + ownedCount++; + item.classList.add('owned'); + // Add metadata line below track name + const trackInfo = item.querySelector('.wishlist-track-info'); + if (trackInfo && (trackData.format || trackData.bitrate)) { + const metaDiv = document.createElement('div'); + metaDiv.className = 'wishlist-track-meta'; + let metaHtml = ''; + if (trackData.format === 'MP3' && trackData.bitrate) { + metaHtml += `MP3 ${trackData.bitrate}`; + } else { + if (trackData.format) { + metaHtml += `${trackData.format}`; + } + if (trackData.bitrate) { + metaHtml += `${trackData.bitrate} kbps`; + } + } + metaDiv.innerHTML = metaHtml; + trackInfo.appendChild(metaDiv); + } + const badge = document.createElement('div'); + badge.className = 'wishlist-track-badge owned'; + badge.innerHTML = ''; + item.appendChild(badge); + } else { + item.classList.add('missing'); + } + }); + + // Aggregate format summary from owned tracks + const formatSet = new Set(); + for (const trackName of Object.keys(ownership)) { + const td = ownership[trackName]; + if (td && td.owned && td.format) { + if (td.format === 'MP3' && td.bitrate) { + formatSet.add(`MP3-${td.bitrate}`); + } else { + formatSet.add(td.format); + } + } + } + if (formatSet.size > 0) { + const heroDetailsContainer = document.querySelector('.add-to-wishlist-modal-hero-details'); + if (heroDetailsContainer) { + // Remove any existing format tag + const existing = heroDetailsContainer.querySelector('.modal-format-tag'); + if (existing) existing.remove(); + const formatTag = document.createElement('span'); + formatTag.className = 'modal-format-tag'; + formatTag.textContent = [...formatSet].sort().join(' / '); + heroDetailsContainer.appendChild(formatTag); + } + } + + // Update hero subtitle with missing count + const missingCount = tracks.length - ownedCount; + const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail'); + const trackDetailEl = heroDetails.length > 1 ? heroDetails[heroDetails.length - 1] : null; + if (trackDetailEl && missingCount > 0 && ownedCount > 0) { + trackDetailEl.textContent = `${missingCount} of ${tracks.length} tracks missing`; + } + + // If ALL returned tracks are owned, this is a Spotify metadata discrepancy + // (e.g. total_tracks says 15 but API only returns 14, and all 14 are owned) + // Fix the source card to show complete + if (missingCount === 0 && sourceCard && sourceCard._releaseData) { + sourceCard._releaseData.track_completion = { + owned_tracks: ownedCount, + total_tracks: tracks.length, + percentage: 100, + missing_tracks: 0 + }; + const completionText = sourceCard.querySelector('.completion-text'); + if (completionText) { + completionText.textContent = `Complete (${ownedCount})`; + completionText.className = 'completion-text complete'; + completionText.title = ''; + } + const completionFill = sourceCard.querySelector('.completion-fill'); + if (completionFill) { + completionFill.style.width = '100%'; + completionFill.classList.remove('partial'); + completionFill.classList.add('complete'); + } + } + } catch (e) { + console.warn('Could not load track ownership:', e); + } +} + +/** + * Close the Add to Wishlist modal + */ +function closeAddToWishlistModal() { + console.log('🔄 Closing Add to Wishlist modal'); + + try { + const overlay = document.getElementById('add-to-wishlist-modal-overlay'); + if (overlay) { + overlay.classList.add('hidden'); + } + + // Clear current modal data + currentWishlistModalData = null; + + // Clear hero content + const heroContainer = document.getElementById('add-to-wishlist-modal-hero'); + if (heroContainer) { + heroContainer.innerHTML = ''; + } + + // Clear track list + const trackListContainer = document.getElementById('wishlist-track-list'); + if (trackListContainer) { + trackListContainer.innerHTML = ''; + } + + console.log('✅ Add to Wishlist modal closed successfully'); + + } catch (error) { + console.error('❌ Error closing Add to Wishlist modal:', error); + } +} + +/** + * Handle "Download Now" button click from the Add to Wishlist modal. + * Captures modal data, closes the wishlist modal, then opens the download missing tracks modal. + */ +async function handleWishlistDownloadNow() { + if (!currentWishlistModalData) { + showToast('No album data available', 'error'); + return; + } + + // Capture data before closeAddToWishlistModal clears it + const { album, artist, tracks, albumType } = currentWishlistModalData; + + // Close the wishlist modal + closeAddToWishlistModal(); + + // Build virtual playlist ID and name (same pattern as createArtistAlbumVirtualPlaylist) + const virtualPlaylistId = `artist_album_${artist.id}_${album.id}`; + const playlistName = `[${artist.name}] ${album.name}`; + + // If a download process already exists for this album, just show the existing modal + if (activeDownloadProcesses[virtualPlaylistId]) { + const process = activeDownloadProcesses[virtualPlaylistId]; + if (process.modalElement) { + process.modalElement.style.display = 'flex'; + } + return; + } + + // Open download missing modal (reuses existing function) + showLoadingOverlay('Loading album...'); + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, playlistName, tracks, album, artist, false + ); + hideLoadingOverlay(); + + // Register download bubble (reuses existing artist bubble system) + registerArtistDownload(artist, album, virtualPlaylistId, albumType); +} + +/** + * Add all tracks from any download modal to the wishlist + * Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.) + */ +async function addModalTracksToWishlist(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) { + console.error('❌ No active process found for:', playlistId); + showToast('Error: Could not find playlist data', 'error'); + return; + } + + // Verify we have tracks + if (!process.tracks || process.tracks.length === 0) { + console.error('❌ No tracks found in process:', process); + showToast('Error: No tracks to add', 'error'); + return; + } + + // Filter tracks based on checkbox selection (if checkboxes exist in this modal) + const wishlistTbody = document.getElementById(`download-tracks-tbody-${playlistId}`); + let tracks = process.tracks; + if (wishlistTbody) { + const allCbs = wishlistTbody.querySelectorAll('.track-select-cb'); + if (allCbs.length > 0) { + const checkedCbs = wishlistTbody.querySelectorAll('.track-select-cb:checked'); + const selectedIndices = new Set([...checkedCbs].map(cb => parseInt(cb.dataset.trackIndex))); + tracks = process.tracks.filter((_, i) => selectedIndices.has(i)); + } + } + + // Get album context if available (for artist album downloads) + // Artist is resolved per-track below — process.artist is only set for album downloads, + // not for playlists, so we must NOT use it as a blanket default. + const processArtist = process.artist || null; + const album = process.album || process.playlist || { name: 'Playlist', id: playlistId }; + + console.log(`🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist (process artist: ${processArtist?.name || 'per-track'})`); + + // Disable the button to prevent double-clicks + const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); + if (wishlistBtn) { + wishlistBtn.disabled = true; + wishlistBtn.classList.add('loading'); + wishlistBtn.textContent = 'Adding...'; + } + + try { + let successCount = 0; + let errorCount = 0; + + // Add each track to wishlist individually + let wingItSkipped = 0; + for (const track of tracks) { + try { + // Skip wing-it fallback tracks — they have no real metadata, + // adding them to wishlist would just retry with raw data + const trackId = track.id || ''; + if (String(trackId).startsWith('wing_it_')) { + wingItSkipped++; + console.log(`⏭️ Skipping wing-it track from wishlist: ${track.name}`); + continue; + } + + // Format artists field to match backend expectations + let formattedArtists = track.artists; + if (typeof track.artists === 'string') { + formattedArtists = [{ name: track.artists }]; + } else if (Array.isArray(track.artists)) { + formattedArtists = track.artists.map(artistItem => { + if (typeof artistItem === 'string') { + return { name: artistItem }; + } else if (typeof artistItem === 'object' && artistItem !== null) { + return artistItem; + } else { + return { name: 'Unknown Artist' }; + } + }); + } else { + formattedArtists = [{ name: artist.name }]; + } + + const formattedTrack = { + ...track, + artists: formattedArtists + }; + + // Use track's own album data if available + // Convert string album names to objects if needed (no Spotify fetch!) + let trackAlbum = track.album; + let trackAlbumType = 'album'; + + // Handle both object and string album formats + if (typeof trackAlbum === 'string') { + // Album is just a string - convert to minimal object + trackAlbum = { + name: trackAlbum, + album_type: 'album', + images: [] + }; + trackAlbumType = 'album'; + } else if (trackAlbum && typeof trackAlbum === 'object') { + // Album is already an object - extract album_type + trackAlbumType = trackAlbum.album_type || 'album'; + // Ensure it has a name + if (!trackAlbum.name) { + trackAlbum.name = 'Unknown Album'; + } + } else { + // No album data at all - create minimal object + trackAlbum = { + name: 'Unknown Album', + album_type: 'album', + images: [] + }; + trackAlbumType = 'album'; + } + + // Resolve artist: for album downloads, use the album-level artist to keep + // all tracks grouped under one artist in the wishlist. Per-track artists + // (like individual vocalists on a soundtrack) should NOT split the album. + let trackArtist; + if (processArtist && processArtist.name) { + // Album context exists — use album artist to keep tracks grouped + trackArtist = processArtist; + } else if (formattedArtists.length > 0 && formattedArtists[0].name && formattedArtists[0].name !== 'Unknown Artist') { + // No album context (playlist/single) — use track's own artist + trackArtist = formattedArtists[0]; + } else { + trackArtist = { name: 'Unknown Artist', id: null }; + } + + const response = await fetch('/api/add-album-to-wishlist', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + track: formattedTrack, + artist: trackArtist, + album: trackAlbum, + source_type: 'album', + source_context: { + album_name: trackAlbum.name, + artist_name: trackArtist.name, + album_type: trackAlbumType + } + }) + }); + + const result = await response.json(); + + if (result.success) { + successCount++; + } else { + errorCount++; + console.error(`❌ Failed to add "${track.name}" to wishlist: ${result.error}`); + } + } catch (error) { + errorCount++; + console.error(`❌ Error adding "${track.name}" to wishlist:`, error); + } + } + + // Show result toast + if (successCount > 0) { + let message = errorCount > 0 + ? `Added ${successCount}/${tracks.length} tracks to wishlist (${errorCount} failed)` + : `Added ${successCount} tracks to wishlist`; + if (wingItSkipped > 0) message += ` (${wingItSkipped} wing-it skipped)`; + showToast(message, 'success'); + + // Close the modal on success + await closeDownloadMissingModal(playlistId); + } else { + showToast('Failed to add any tracks to wishlist', 'error'); + } + + } catch (error) { + console.error('❌ Error in addModalTracksToWishlist:', error); + showToast(`Error adding to wishlist: ${error.message}`, 'error'); + } finally { + // Re-enable button if still on screen (in case of error) + if (wishlistBtn) { + wishlistBtn.disabled = false; + wishlistBtn.classList.remove('loading'); + wishlistBtn.textContent = 'Add to Wishlist'; + } + } +} + +/** + * Format duration from milliseconds to MM:SS format + */ +function formatDuration(durationMs) { + if (!durationMs || durationMs <= 0) { + return '--:--'; + } + + const totalSeconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +// Note: Functions from other modules (downloads.js, sync-spotify.js, sync-services.js, artists.js) +// are already global via their function declarations and do not need window.X = X assignments. + +// Add to Wishlist Modal functions (new) +window.openAddToWishlistModal = openAddToWishlistModal; +window.closeAddToWishlistModal = closeAddToWishlistModal; +window.handleAddToWishlist = handleAddToWishlist; +window.handleWishlistDownloadNow = handleWishlistDownloadNow; +window.addModalTracksToWishlist = addModalTracksToWishlist; + + +// APPEND THIS JAVASCRIPT SNIPPET (B) + +function initializeFilters() { + const toggleBtn = document.getElementById('filter-toggle-btn'); + const container = document.getElementById('filters-container'); + const content = document.getElementById('filter-content'); + + if (toggleBtn && container && content) { + // Using .onclick ensures we only ever have one click handler + toggleBtn.onclick = () => { + const isExpanded = container.classList.contains('expanded'); + + if (isExpanded) { + // Collapse the container + container.classList.remove('expanded'); + toggleBtn.textContent = '⏷ Filters'; + } else { + // Expand the container + content.classList.remove('hidden'); // Make sure content is visible for animation + container.classList.add('expanded'); + toggleBtn.textContent = '⏶ Filters'; + } + }; + } + + // This part is correct and doesn't need to change + document.querySelectorAll('.filter-btn').forEach(button => { + button.addEventListener('click', handleFilterClick); + }); +} + +function handleFilterClick(event) { + const button = event.target; + const filterType = button.dataset.filterType; + const value = button.dataset.value; + + if (filterType === 'type') currentFilterType = value; + if (filterType === 'format') currentFilterFormat = value; + if (filterType === 'sort') currentSortBy = value; + + if (button.id === 'sort-order-btn') { + isSortReversed = !isSortReversed; + button.textContent = isSortReversed ? '↑' : '↓'; + } + + document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => { + btn.classList.remove('active'); + }); + if (filterType) { // Don't try to activate the sort order button + button.classList.add('active'); + } + + applyFiltersAndSort(); +} + +function resetFilters() { + currentFilterType = 'all'; + currentFilterFormat = 'all'; + currentSortBy = 'quality_score'; + isSortReversed = false; + + document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); + document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active'); + document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active'); + document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active'); + document.getElementById('sort-order-btn').textContent = '↓'; +} + +function applyFiltersAndSort() { + let processedResults = [...allSearchResults]; + const query = document.getElementById('downloads-search-input').value.trim().toLowerCase(); + + // 1. Filter by Type + if (currentFilterType !== 'all') { + processedResults = processedResults.filter(r => r.result_type === currentFilterType); + } + + // 2. Filter by Format + if (currentFilterFormat !== 'all') { + processedResults = processedResults.filter(r => { + const quality = (r.dominant_quality || r.quality || '').toLowerCase(); + return quality === currentFilterFormat; + }); + } + + // 3. Sort Results + processedResults.sort((a, b) => { + let valA, valB; + + // Special handling for relevance sort + if (currentSortBy === 'relevance') { + valA = calculateRelevanceScore(a, query); + valB = calculateRelevanceScore(b, query); + return valB - valA; // Higher score is better + } + + // Special handling for availability + if (currentSortBy === 'availability') { + valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1; + valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1; + return valB - valA; + } + + valA = a[currentSortBy] || 0; + valB = b[currentSortBy] || 0; + + if (typeof valA === 'string') { + // For name/title sort, use the correct property + const titleA = (a.album_title || a.title || '').toLowerCase(); + const titleB = (b.album_title || b.title || '').toLowerCase(); + return titleA.localeCompare(titleB); + } + + // Default numeric sort (descending) + return valB - valA; + }); + + // Handle sort direction toggle + const sortDefaults = { + relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', + upload_speed: 'desc', duration: 'desc', availability: 'desc', + title: 'asc', username: 'asc' + }; + + const defaultOrder = sortDefaults[currentSortBy] || 'desc'; + if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) { + processedResults.reverse(); + } + + displayDownloadsResults(processedResults); +} + +function calculateRelevanceScore(result, query) { + let score = 0.0; + const queryTerms = query.split(' ').filter(t => t.length > 1); + + // 1. Search Term Matching (40%) + let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase(); + let termMatches = 0; + for (const term of queryTerms) { + if (searchableText.includes(term)) { + termMatches++; + } + } + score += (termMatches / queryTerms.length) * 0.40; + + // 2. Quality Score (25%) + score += (result.quality_score || 0) * 0.25; + + // 3. User Reliability (Availability & Speed) (20%) + const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5; + score += reliability * 0.20; + + // 4. File Completeness (Bitrate & Duration) (15%) + const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0); + score += completeness * 0.15; + + return score; +} +// APPEND THIS JAVASCRIPT SNIPPET (B) + +function initializeFilters() { + const toggleBtn = document.getElementById('filter-toggle-btn'); + const container = document.getElementById('filters-container'); + const content = document.getElementById('filter-content'); + + if (toggleBtn && container && content) { + // Using .onclick ensures we only ever have one click handler + toggleBtn.onclick = () => { + const isExpanded = container.classList.contains('expanded'); + + if (isExpanded) { + // Collapse the container + container.classList.remove('expanded'); + toggleBtn.textContent = '⏷ Filters'; + } else { + // Expand the container + content.classList.remove('hidden'); // Make sure content is visible for animation + container.classList.add('expanded'); + toggleBtn.textContent = '⏶ Filters'; + } + }; + } + + // This part is correct and doesn't need to change + document.querySelectorAll('.filter-btn').forEach(button => { + button.addEventListener('click', handleFilterClick); + }); +} + +function handleFilterClick(event) { + const button = event.target; + const filterType = button.dataset.filterType; + const value = button.dataset.value; + + if (filterType === 'type') currentFilterType = value; + if (filterType === 'format') currentFilterFormat = value; + if (filterType === 'sort') currentSortBy = value; + + if (button.id === 'sort-order-btn') { + isSortReversed = !isSortReversed; + button.textContent = isSortReversed ? '↑' : '↓'; + } + + document.querySelectorAll(`.filter-btn[data-filter-type="${filterType}"]`).forEach(btn => { + btn.classList.remove('active'); + }); + if (filterType) { // Don't try to activate the sort order button + button.classList.add('active'); + } + + applyFiltersAndSort(); +} + +function resetFilters() { + currentFilterType = 'all'; + currentFilterFormat = 'all'; + currentSortBy = 'quality_score'; + isSortReversed = false; + + document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.remove('active')); + document.querySelector('.filter-btn[data-filter-type="type"][data-value="all"]').classList.add('active'); + document.querySelector('.filter-btn[data-filter-type="format"][data-value="all"]').classList.add('active'); + document.querySelector('.filter-btn[data-filter-type="sort"][data-value="quality_score"]').classList.add('active'); + document.getElementById('sort-order-btn').textContent = '↓'; +} + +function applyFiltersAndSort() { + let processedResults = [...allSearchResults]; + const query = document.getElementById('downloads-search-input').value.trim().toLowerCase(); + + // 1. Filter by Type + if (currentFilterType !== 'all') { + processedResults = processedResults.filter(r => r.result_type === currentFilterType); + } + + // 2. Filter by Format + if (currentFilterFormat !== 'all') { + processedResults = processedResults.filter(r => { + const quality = (r.dominant_quality || r.quality || '').toLowerCase(); + return quality === currentFilterFormat; + }); + } + + // 3. Sort Results + processedResults.sort((a, b) => { + let valA, valB; + + // Special handling for relevance sort + if (currentSortBy === 'relevance') { + valA = calculateRelevanceScore(a, query); + valB = calculateRelevanceScore(b, query); + return valB - valA; // Higher score is better + } + + // Special handling for availability + if (currentSortBy === 'availability') { + valA = (a.free_upload_slots || 0) - (a.queue_length || 0) * 0.1; + valB = (b.free_upload_slots || 0) - (b.queue_length || 0) * 0.1; + return valB - valA; + } + + valA = a[currentSortBy] || 0; + valB = b[currentSortBy] || 0; + + if (typeof valA === 'string') { + // For name/title sort, use the correct property + const titleA = (a.album_title || a.title || '').toLowerCase(); + const titleB = (b.album_title || b.title || '').toLowerCase(); + return titleA.localeCompare(titleB); + } + + // Default numeric sort (descending) + return valB - valA; + }); + + // Handle sort direction toggle + const sortDefaults = { + relevance: 'desc', quality_score: 'desc', size: 'desc', bitrate: 'desc', + upload_speed: 'desc', duration: 'desc', availability: 'desc', + title: 'asc', username: 'asc' + }; + + const defaultOrder = sortDefaults[currentSortBy] || 'desc'; + if ((defaultOrder === 'asc' && isSortReversed) || (defaultOrder === 'desc' && !isSortReversed)) { + processedResults.reverse(); + } + + displayDownloadsResults(processedResults); +} + +function calculateRelevanceScore(result, query) { + let score = 0.0; + const queryTerms = query.split(' ').filter(t => t.length > 1); + + // 1. Search Term Matching (40%) + let searchableText = `${result.title || ''} ${result.artist || ''} ${result.album || ''} ${result.album_title || ''}`.toLowerCase(); + let termMatches = 0; + for (const term of queryTerms) { + if (searchableText.includes(term)) { + termMatches++; + } + } + score += (termMatches / queryTerms.length) * 0.40; + + // 2. Quality Score (25%) + score += (result.quality_score || 0) * 0.25; + + // 3. User Reliability (Availability & Speed) (20%) + const reliability = ((result.free_upload_slots || 0) > 0 ? 0.5 : 0) + Math.min(1, (result.upload_speed || 0) / 500) * 0.5; + score += reliability * 0.20; + + // 4. File Completeness (Bitrate & Duration) (15%) + const completeness = (Math.min(1, (result.bitrate || 0) / 320) * 0.5) + (result.duration > 0 ? 0.5 : 0); + score += completeness * 0.15; + + return score; +} + +// Add to global scope for onclick +window.handleFilterClick = handleFilterClick; + +// =============================== +// MATCHED DOWNLOADS MODAL +// =============================== + +// Global state for matching modal +let currentMatchingData = { + searchResult: null, + isAlbumDownload: false, + albumResult: null, + selectedArtist: null, + selectedAlbum: null, + currentStage: 'artist' // 'artist' or 'album' +}; + +let searchTimers = { + artist: null, + album: null +}; + +function openMatchingModal(searchResult, isAlbumDownload = false, albumResult = null) { + console.log('🎯 Opening matching modal for:', searchResult); + + // Store the current matching data + currentMatchingData = { + searchResult: searchResult, + isAlbumDownload: isAlbumDownload, + albumResult: albumResult, + selectedArtist: null, + selectedAlbum: null, + currentStage: 'artist' + }; + + // Show modal + const overlay = document.getElementById('matching-modal-overlay'); + overlay.classList.remove('hidden'); + + // Reset modal state + resetModalState(); + + // Set appropriate title and stage + const modalTitle = document.getElementById('matching-modal-title'); + const artistStageTitle = document.getElementById('artist-stage-title'); + + if (isAlbumDownload) { + modalTitle.textContent = 'Match Album Download to Spotify'; + artistStageTitle.textContent = 'Step 1: Select the correct Artist'; + document.getElementById('album-selection-stage').style.display = 'block'; + } else { + modalTitle.textContent = 'Match Download to Spotify'; + artistStageTitle.textContent = 'Select the correct Artist for this Single'; + document.getElementById('album-selection-stage').style.display = 'none'; + } + + // Generate initial artist suggestions + fetchArtistSuggestions(); + + // Setup event listeners + setupModalEventListeners(); +} + +function closeMatchingModal() { + const overlay = document.getElementById('matching-modal-overlay'); + overlay.classList.add('hidden'); + + // Clear timers + Object.values(searchTimers).forEach(timer => { + if (timer) clearTimeout(timer); + }); + + // Reset state + currentMatchingData = { + searchResult: null, + isAlbumDownload: false, + albumResult: null, + selectedArtist: null, + selectedAlbum: null, + currentStage: 'artist' + }; +} + +function resetModalState() { + // Show artist stage, hide album stage + document.getElementById('artist-selection-stage').classList.remove('hidden'); + document.getElementById('album-selection-stage').classList.add('hidden'); + + // Clear all suggestion containers + document.getElementById('artist-suggestions').innerHTML = ''; + document.getElementById('artist-manual-results').innerHTML = ''; + document.getElementById('album-suggestions').innerHTML = ''; + document.getElementById('album-manual-results').innerHTML = ''; + + // Clear search inputs + document.getElementById('artist-search-input').value = ''; + document.getElementById('album-search-input').value = ''; + + // Reset button states + document.getElementById('confirm-match-btn').disabled = true; + + // Reset selections + currentMatchingData.selectedArtist = null; + currentMatchingData.selectedAlbum = null; + currentMatchingData.currentStage = 'artist'; +} + +function setupModalEventListeners() { + // Search input listeners + const artistInput = document.getElementById('artist-search-input'); + const albumInput = document.getElementById('album-search-input'); + + artistInput.removeEventListener('input', handleArtistSearch); + artistInput.addEventListener('input', handleArtistSearch); + + albumInput.removeEventListener('input', handleAlbumSearch); + albumInput.addEventListener('input', handleAlbumSearch); + + // Button listeners + const skipBtn = document.getElementById('skip-matching-btn'); + const cancelBtn = document.getElementById('cancel-match-btn'); + const confirmBtn = document.getElementById('confirm-match-btn'); + + skipBtn.onclick = skipMatching; + cancelBtn.onclick = closeMatchingModal; + confirmBtn.onclick = confirmMatch; +} + +async function fetchArtistSuggestions() { + try { + showLoadingCards('artist-suggestions', 'Finding artist...'); + + const response = await fetch('/api/match/suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: currentMatchingData.searchResult, + context: 'artist', + is_album: currentMatchingData.isAlbumDownload, + album_result: currentMatchingData.albumResult + }) + }); + + const data = await response.json(); + if (data.suggestions) { + renderArtistSuggestions(data.suggestions); + } else { + showNoResultsMessage('artist-suggestions', 'No artist suggestions found'); + } + } catch (error) { + console.error('Error fetching artist suggestions:', error); + showNoResultsMessage('artist-suggestions', 'Error loading suggestions'); + } +} + +async function fetchAlbumSuggestions() { + if (!currentMatchingData.selectedArtist) return; + + try { + showLoadingCards('album-suggestions', 'Finding album...'); + + const response = await fetch('/api/match/suggestions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: currentMatchingData.searchResult, + context: 'album', + selected_artist: currentMatchingData.selectedArtist + }) + }); + + const data = await response.json(); + if (data.suggestions) { + renderAlbumSuggestions(data.suggestions); + } else { + showNoResultsMessage('album-suggestions', 'No album suggestions found'); + } + } catch (error) { + console.error('Error fetching album suggestions:', error); + showNoResultsMessage('album-suggestions', 'Error loading suggestions'); + } +} + +function renderArtistSuggestions(suggestions) { + const container = document.getElementById('artist-suggestions'); + container.innerHTML = ''; + + if (!suggestions.length) { + showNoResultsMessage('artist-suggestions', 'No artist matches found'); + return; + } + + suggestions.forEach(suggestion => { + const card = createArtistCard(suggestion.artist, suggestion.confidence); + container.appendChild(card); + }); +} + +function renderAlbumSuggestions(suggestions) { + const container = document.getElementById('album-suggestions'); + container.innerHTML = ''; + + if (!suggestions.length) { + showNoResultsMessage('album-suggestions', 'No album matches found'); + return; + } + + suggestions.forEach(suggestion => { + const card = createAlbumCard(suggestion.album, suggestion.confidence); + container.appendChild(card); + }); +} + +function createArtistCard(artist, confidence) { + const card = document.createElement('div'); + card.className = 'suggestion-card'; + card.onclick = () => selectArtist(artist); + + const imageUrl = artist.image_url || ''; + const confidencePercent = Math.round(confidence * 100); + + // Add data attribute for lazy loading + card.dataset.artistId = artist.id; + card.dataset.needsImage = imageUrl ? 'false' : 'true'; + + card.innerHTML = ` +
+
+
${escapeHtml(artist.name)}
+
+ ${artist.genres && artist.genres.length ? escapeHtml(artist.genres.slice(0, 2).join(', ')) : 'Artist'} +
+
${confidencePercent}% match
+
+ `; + + // Set background image if available + if (imageUrl) { + card.style.backgroundImage = `url(${imageUrl})`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } + + return card; +} + +function createAlbumCard(album, confidence) { + const card = document.createElement('div'); + card.className = 'suggestion-card'; + card.onclick = () => selectAlbum(album); + + const imageUrl = album.image_url || ''; + const confidencePercent = Math.round(confidence * 100); + const year = album.release_date ? album.release_date.split('-')[0] : ''; + + card.innerHTML = ` +
+
+
${escapeHtml(album.name)}
+
+ ${album.album_type ? escapeHtml(album.album_type.charAt(0).toUpperCase() + album.album_type.slice(1)) : 'Album'}${year ? ` • ${year}` : ''} +
+
${confidencePercent}% match
+
+ `; + + // Set background image if available + if (imageUrl) { + card.style.backgroundImage = `url(${imageUrl})`; + card.style.backgroundSize = 'cover'; + card.style.backgroundPosition = 'center'; + } + + return card; +} + +function selectArtist(artist) { + // Clear previous selections + document.querySelectorAll('#artist-suggestions .suggestion-card').forEach(card => { + card.classList.remove('selected'); + }); + document.querySelectorAll('#artist-manual-results .suggestion-card').forEach(card => { + card.classList.remove('selected'); + }); + + // Mark new selection + event.currentTarget.classList.add('selected'); + + // Store selection + currentMatchingData.selectedArtist = artist; + + console.log('🎯 Selected artist:', artist.name); + + if (currentMatchingData.isAlbumDownload) { + // Transition to album selection stage + transitionToAlbumStage(); + } else { + // Enable confirm button for single downloads + document.getElementById('confirm-match-btn').disabled = false; + } +} + +function selectAlbum(album) { + // Clear previous selections + document.querySelectorAll('#album-suggestions .suggestion-card').forEach(card => { + card.classList.remove('selected'); + }); + document.querySelectorAll('#album-manual-results .suggestion-card').forEach(card => { + card.classList.remove('selected'); + }); + + // Mark new selection + event.currentTarget.classList.add('selected'); + + // Store selection + currentMatchingData.selectedAlbum = album; + + console.log('🎯 Selected album:', album.name); + + // Enable confirm button + document.getElementById('confirm-match-btn').disabled = false; +} + +function transitionToAlbumStage() { + // Hide artist stage + document.getElementById('artist-selection-stage').classList.add('hidden'); + + // Show album stage + const albumStage = document.getElementById('album-selection-stage'); + albumStage.classList.remove('hidden'); + + // Update selected artist name + document.getElementById('selected-artist-name').textContent = currentMatchingData.selectedArtist.name; + + // Update current stage + currentMatchingData.currentStage = 'album'; + + // Fetch album suggestions + fetchAlbumSuggestions(); +} + +function handleArtistSearch(event) { + const query = event.target.value.trim(); + + // Clear previous timer + if (searchTimers.artist) { + clearTimeout(searchTimers.artist); + } + + if (query.length < 2) { + document.getElementById('artist-manual-results').innerHTML = ''; + return; + } + + // Debounce search + searchTimers.artist = setTimeout(() => { + performArtistSearch(query); + }, 400); +} + +function handleAlbumSearch(event) { + const query = event.target.value.trim(); + + // Clear previous timer + if (searchTimers.album) { + clearTimeout(searchTimers.album); + } + + if (query.length < 2) { + document.getElementById('album-manual-results').innerHTML = ''; + return; + } + + // Debounce search + searchTimers.album = setTimeout(() => { + performAlbumSearch(query); + }, 400); +} + +async function performArtistSearch(query) { + try { + showLoadingCards('artist-manual-results', 'Searching artists...'); + + const requestBody = { + query: query, + context: 'artist' + }; + console.log('Manual search request:', requestBody); + + const response = await fetch('/api/match/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody) + }); + + const data = await response.json(); + console.log('Manual search response:', data); + if (data.provider) currentMatchingData.provider = data.provider; + if (data.results) { + console.log('Results array:', data.results); + renderArtistSearchResults(data.results); + } else { + showNoResultsMessage('artist-manual-results', 'No artists found'); + } + } catch (error) { + console.error('Error searching artists:', error); + showNoResultsMessage('artist-manual-results', 'Error searching artists'); + } +} + +async function performAlbumSearch(query) { + if (!currentMatchingData.selectedArtist) return; + + try { + showLoadingCards('album-manual-results', 'Searching albums...'); + + const response = await fetch('/api/match/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: query, + context: 'album', + artist_id: currentMatchingData.selectedArtist.id + }) + }); + + const data = await response.json(); + if (data.results) { + renderAlbumSearchResults(data.results); + } else { + showNoResultsMessage('album-manual-results', 'No albums found'); + } + } catch (error) { + console.error('Error searching albums:', error); + showNoResultsMessage('album-manual-results', 'Error searching albums'); + } +} + +function renderArtistSearchResults(results) { + const container = document.getElementById('artist-manual-results'); + container.innerHTML = ''; + + results.forEach((result, index) => { + console.log(`Manual search result ${index}:`, result); + console.log(` result.artist:`, result.artist); + console.log(` result.confidence:`, result.confidence); + try { + const card = createArtistCard(result.artist, result.confidence); + console.log(`createArtistCard returned:`, card, typeof card, card instanceof Element); + if (card && card instanceof Element) { + container.appendChild(card); + } else { + console.error(`Invalid card returned for result ${index}:`, card); + } + } catch (error) { + console.error(`Error calling createArtistCard for result ${index}:`, error); + } + }); + + // Lazy load missing artist images + console.log('🖼️ Starting lazy load for artist images in matching modal...'); + if (typeof lazyLoadArtistImages === 'function') { + lazyLoadArtistImages(container); + } else if (typeof window.lazyLoadArtistImages === 'function') { + window.lazyLoadArtistImages(container); + } else { + console.error('❌ lazyLoadArtistImages function not found!'); + } +} + +function renderAlbumSearchResults(results) { + const container = document.getElementById('album-manual-results'); + container.innerHTML = ''; + + results.forEach(result => { + const card = createAlbumCard(result.album, result.confidence); + container.appendChild(card); + }); +} + +function showLoadingCards(containerId, message) { + const container = document.getElementById(containerId); + container.innerHTML = `
${message}
`; +} + +function showNoResultsMessage(containerId, message) { + const container = document.getElementById(containerId); + container.innerHTML = `
${message}
`; +} + +function skipMatching() { + console.log('🎯 Skipping matching, proceeding with normal download'); + + // Close modal + closeMatchingModal(); + + // Start normal download + if (currentMatchingData.isAlbumDownload) { + // For albums, we need to download each track + showToast('⬇️ Starting album download (unmatched)', 'info'); + // This would need to be implemented to download all album tracks + } else { + // Single track download + startDownload(window.currentSearchResults.indexOf(currentMatchingData.searchResult)); + } +} + +function matchSlskdTracksToSpotify(slskdTracks, spotifyTracks) { + /** + * Matches Soulseek tracks to Spotify tracks based on filename analysis. + * Returns enhanced tracks with full Spotify metadata. + */ + console.log(`🎯 Starting track matching: ${slskdTracks.length} Soulseek tracks vs ${spotifyTracks.length} Spotify tracks`); + + const matched = []; + const unmatched = []; + + for (const slskdTrack of slskdTracks) { + const filename = slskdTrack.filename || slskdTrack.title || ''; + const parsedMeta = parseTrackFilename(filename); + + console.log(`🔍 Matching: "${filename}" -> parsed as: "${parsedMeta.title}" (track #${parsedMeta.trackNumber})`); + + // Find best matching Spotify track + let bestMatch = null; + let bestScore = 0; + + for (const spotifyTrack of spotifyTracks) { + let score = 0; + + // Match by track number (highest priority if available) + if (parsedMeta.trackNumber && spotifyTrack.track_number === parsedMeta.trackNumber) { + score += 50; + console.log(` ✓ Track number match: ${parsedMeta.trackNumber} == ${spotifyTrack.track_number} (+50)`); + } + + // Match by title similarity + const titleScore = calculateStringSimilarity( + parsedMeta.title.toLowerCase(), + spotifyTrack.name.toLowerCase() + ); + score += titleScore * 50; // Max 50 points for perfect title match + + console.log(` Spotify track "${spotifyTrack.name}" (${spotifyTrack.track_number}): score ${score.toFixed(2)}`); + + if (score > bestScore) { + bestScore = score; + bestMatch = spotifyTrack; + } + } + + // Accept match if score is above threshold (70/100) + if (bestMatch && bestScore >= 70) { + console.log(`✅ MATCHED: "${filename}" -> "${bestMatch.name}" (score: ${bestScore.toFixed(2)})`); + matched.push({ + slskd_track: slskdTrack, + spotify_track: bestMatch, + confidence: bestScore / 100 + }); + } else { + console.log(`❌ NO MATCH: "${filename}" (best score: ${bestScore.toFixed(2)})`); + unmatched.push(slskdTrack); + } + } + + console.log(`🎯 Matching complete: ${matched.length} matched, ${unmatched.length} unmatched`); + + return { + matched: matched, + unmatched: unmatched, + total: slskdTracks.length + }; +} + +function parseTrackFilename(filename) { + /** + * Parse track metadata from filename. + * Handles common patterns like: + * - "01 - Title.flac" + * - "01. Title.flac" + * - "Artist - Title.flac" + * - "Title.flac" + * - YouTube: "video_id||title" (extract title part) + */ + // YouTube special handling: Extract title from encoded format + if (filename && filename.includes('||')) { + const parts = filename.split('||'); + const youtubeTitle = parts[1] || parts[0]; // Use title part, fallback to video_id + // Remove common YouTube suffixes + const cleanTitle = youtubeTitle + .replace(/\s*\[.*?\]\s*/g, '') // Remove [Official Video], [Lyrics], etc. + .replace(/\s*\(.*?\)\s*/g, '') // Remove (Official), (Audio), etc. + .trim(); + return { title: cleanTitle, trackNumber: null }; + } + + // Remove file extension and path + let basename = filename.split('/').pop().split('\\').pop(); + basename = basename.replace(/\.(flac|mp3|m4a|ogg|wav)$/i, ''); + + let trackNumber = null; + let title = basename; + + // Pattern 1: "01 - Title" or "01. Title" + const pattern1 = /^(\d{1,2})\s*[-\.]\s*(.+)$/; + const match1 = basename.match(pattern1); + if (match1) { + trackNumber = parseInt(match1[1]); + title = match1[2].trim(); + return { title, trackNumber }; + } + + // Pattern 2: "Artist - Title" (extract title only) + const pattern2 = /^.+?\s*[-–]\s*(.+)$/; + const match2 = basename.match(pattern2); + if (match2) { + title = match2[1].trim(); + return { title, trackNumber }; + } + + // Fallback: use whole basename as title + return { title: basename.trim(), trackNumber }; +} + +function calculateStringSimilarity(str1, str2) { + /** + * Calculate similarity between two strings (0-1 range). + * Uses Levenshtein distance for fuzzy matching. + */ + // Normalize strings + str1 = str1.trim().toLowerCase(); + str2 = str2.trim().toLowerCase(); + + if (str1 === str2) return 1.0; + + // Simple contains check + if (str1.includes(str2) || str2.includes(str1)) { + return 0.9; + } + + // Levenshtein distance calculation + const matrix = []; + const len1 = str1.length; + const len2 = str2.length; + + for (let i = 0; i <= len1; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= len2; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= len1; i++) { + for (let j = 1; j <= len2; j++) { + const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[i][j] = Math.min( + matrix[i - 1][j] + 1, // deletion + matrix[i][j - 1] + 1, // insertion + matrix[i - 1][j - 1] + cost // substitution + ); + } + } + + const maxLen = Math.max(len1, len2); + const distance = matrix[len1][len2]; + const similarity = 1 - (distance / maxLen); + + return Math.max(0, similarity); +} + +async function confirmMatch() { + if (!currentMatchingData.selectedArtist) { + showToast('⚠️ Please select an artist first', 'error'); + return; + } + + if (currentMatchingData.isAlbumDownload && !currentMatchingData.selectedAlbum) { + showToast('⚠️ Please select an album first', 'error'); + return; + } + + const confirmBtn = document.getElementById('confirm-match-btn'); + const originalText = confirmBtn.textContent; + + try { + console.log('🎯 Confirming match with:', { + artist: currentMatchingData.selectedArtist.name, + album: currentMatchingData.selectedAlbum?.name + }); + + confirmBtn.disabled = true; + confirmBtn.textContent = 'Starting...'; + + // Determine the correct data to send + const downloadPayload = currentMatchingData.isAlbumDownload + ? currentMatchingData.albumResult + : currentMatchingData.searchResult; + + // --- NEW: For album downloads, fetch Spotify tracklist and match tracks --- + if (currentMatchingData.isAlbumDownload && currentMatchingData.selectedAlbum) { + confirmBtn.textContent = 'Matching tracks...'; + console.log('🎵 Fetching Spotify tracklist for album:', currentMatchingData.selectedAlbum.name); + + try { + // Fetch album tracks (pass name/artist for Hydrabase support) + const artistId = currentMatchingData.selectedArtist.id; + const albumId = currentMatchingData.selectedAlbum.id; + const _aat3 = new URLSearchParams({ name: currentMatchingData.selectedAlbum.name || '', artist: currentMatchingData.selectedArtist.name || '' }); + const albumSource = currentMatchingData.selectedAlbum?.source || currentMatchingData.selectedArtist?.source || null; + if (albumSource) { + _aat3.set('source', albumSource); + } + const tracksResponse = await fetch(`/api/album/${albumId}/tracks?${_aat3}`); + + if (!tracksResponse.ok) { + throw new Error(`Failed to fetch Spotify tracks: ${tracksResponse.status}`); + } + + const tracksData = await tracksResponse.json(); + const spotifyTracks = tracksData.tracks || []; + + console.log(`✅ Fetched ${spotifyTracks.length} Spotify tracks for matching`); + + // Match each Soulseek track to a Spotify track + const enhancedTracks = matchSlskdTracksToSpotify( + downloadPayload.tracks || [], + spotifyTracks + ); + + console.log(`🎯 Matched ${enhancedTracks.matched.length}/${enhancedTracks.total} tracks to Spotify`); + + // Send enhanced data with full Spotify track objects + confirmBtn.textContent = 'Downloading...'; + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + spotify_artist: currentMatchingData.selectedArtist, + spotify_album: currentMatchingData.selectedAlbum, + enhanced_tracks: enhancedTracks.matched, // Send matched tracks with full Spotify data + unmatched_tracks: enhancedTracks.unmatched // Send unmatched tracks for basic processing + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`🎯 Matched ${enhancedTracks.matched.length} tracks to Spotify`, 'success'); + closeMatchingModal(); + } else { + throw new Error(data.error || 'Failed to start matched download'); + } + + } catch (trackMatchError) { + console.error('❌ Track matching failed, falling back to simple matching:', trackMatchError); + showToast('⚠️ Track matching failed, using basic matching', 'warning'); + + // Fallback to simple matching (current behavior) + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + spotify_artist: currentMatchingData.selectedArtist, + spotify_album: currentMatchingData.selectedAlbum || null + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); + closeMatchingModal(); + } else { + throw new Error(data.error || 'Failed to start matched download'); + } + } + } else { + // Single track download - fetch Spotify track for full metadata + confirmBtn.textContent = 'Searching Spotify...'; + + try { + // Parse track name from Soulseek filename + const filename = downloadPayload.filename || downloadPayload.title || ''; + const parsedMeta = parseTrackFilename(filename); + + console.log(`🔍 Searching Spotify for: "${parsedMeta.title}" by ${currentMatchingData.selectedArtist.name}`); + + // Search Spotify for this track + const searchQuery = `track:${parsedMeta.title} artist:${currentMatchingData.selectedArtist.name}`; + const searchResponse = await fetch(`/api/spotify/search?q=${encodeURIComponent(searchQuery)}&type=track&limit=5`); + + if (!searchResponse.ok) { + throw new Error('Failed to search Spotify for track'); + } + + const searchData = await searchResponse.json(); + const spotifyTracks = searchData.tracks?.items || []; + + if (spotifyTracks.length === 0) { + throw new Error('No Spotify tracks found for this search'); + } + + // Find best match (prefer exact artist match) + let bestMatch = spotifyTracks.find(track => + track.artists.some(artist => artist.id === currentMatchingData.selectedArtist.id) + ) || spotifyTracks[0]; + + console.log(`✅ Found Spotify track: "${bestMatch.name}" (${bestMatch.id})`); + + // Get full track details with album info + const trackResponse = await fetch(`/api/spotify/track/${bestMatch.id}`); + if (!trackResponse.ok) { + throw new Error('Failed to fetch Spotify track details'); + } + + const fullTrack = await trackResponse.json(); + + // Send with full Spotify metadata (single track enhanced) + confirmBtn.textContent = 'Downloading...'; + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + spotify_artist: currentMatchingData.selectedArtist, + spotify_album: null, // Singles don't have album context + spotify_track: fullTrack, // Full Spotify track object + is_single_track: true // Flag for single track processing + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`🎯 Matched single: "${fullTrack.name}"`, 'success'); + closeMatchingModal(); + } else { + throw new Error(data.error || 'Failed to start matched download'); + } + + } catch (singleMatchError) { + console.error('❌ Spotify track matching failed, falling back to basic:', singleMatchError); + showToast('⚠️ Spotify matching failed, using basic metadata', 'warning'); + + // Fallback to basic matching (current behavior) + const response = await fetch('/api/download/matched', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + search_result: downloadPayload, + spotify_artist: currentMatchingData.selectedArtist, + spotify_album: currentMatchingData.selectedAlbum || null + }) + }); + + const data = await response.json(); + + if (data.success) { + showToast(`🎯 Matched download started for "${currentMatchingData.selectedArtist.name}"`, 'success'); + closeMatchingModal(); + } else { + throw new Error(data.error || 'Failed to start matched download'); + } + } + } + + } catch (error) { + console.error('Error starting matched download:', error); + showToast(`❌ Error starting matched download: ${error.message}`, 'error'); + + // Re-enable confirm button on failure + confirmBtn.disabled = false; + confirmBtn.textContent = originalText; + } +} + + + + +function matchedDownloadTrack(trackIndex) { + const results = window.currentSearchResults; + if (!results || !results[trackIndex]) { + console.error('Could not find track for matched download:', trackIndex); + showToast('Error preparing matched download.', 'error'); + return; + } + const trackData = results[trackIndex]; + // It's a single track, so isAlbumDownload is false and there's no album context. + openMatchingModal(trackData, false, null); +} + +function matchedDownloadAlbum(albumIndex) { + const results = window.currentSearchResults; + if (!results || !results[albumIndex]) { + console.error('Could not find album for matched download:', albumIndex); + showToast('Error preparing matched download.', 'error'); + return; + } + const albumData = results[albumIndex]; + // The first track is used as a reference for the initial artist search. + const firstTrack = albumData.tracks ? albumData.tracks[0] : albumData; + openMatchingModal(firstTrack, true, albumData); +} + +function matchedDownloadAlbumTrack(albumIndex, trackIndex) { + const results = window.currentSearchResults; + if (!results || !results[albumIndex] || !results[albumIndex].tracks || !results[albumIndex].tracks[trackIndex]) { + console.error('Could not find album track for matched download:', albumIndex, trackIndex); + showToast('Error preparing matched download.', 'error'); + return; + } + const albumData = results[albumIndex]; + const trackData = albumData.tracks[trackIndex]; + + // This is the definitive fix. + // The second argument MUST be 'false' to treat this as a single track download, + // which prevents the modal from asking for an album selection. + openMatchingModal(trackData, false, albumData); +} + +// =========================================== +// == DASHBOARD DATABASE UPDATER FUNCTIONALITY == +// =========================================== + +// --- State and Polling Management --- + +function stopDbStatsPolling() { + if (dbStatsInterval) { + clearInterval(dbStatsInterval); + dbStatsInterval = null; + } +} + +function stopDbUpdatePolling() { + if (dbUpdateStatusInterval) { + console.log('⏹️ Stopping database update polling'); + clearInterval(dbUpdateStatusInterval); + dbUpdateStatusInterval = null; + } +} + +// =================================================================== +// QUALITY SCANNER TOOL +// =================================================================== + +async function handleQualityScanButtonClick() { + const button = document.getElementById('quality-scan-button'); + const currentAction = button.textContent; + + if (currentAction === 'Scan Library') { + const scopeSelect = document.getElementById('quality-scan-scope'); + const scope = scopeSelect.value; + + try { + button.disabled = true; + button.textContent = 'Starting...'; + const response = await fetch('/api/quality-scanner/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope: scope }) + }); + + if (response.ok) { + showToast('Quality scan started!', 'success'); + // Start polling immediately to get live status + checkAndUpdateQualityScanProgress(); + } else { + const errorData = await response.json(); + showToast(`Error: ${errorData.error}`, 'error'); + button.disabled = false; + button.textContent = 'Scan Library'; + } + } catch (error) { + showToast('Failed to start quality scan.', 'error'); + button.disabled = false; + button.textContent = 'Scan Library'; + } + + } else { // "Stop Scan" + try { + const response = await fetch('/api/quality-scanner/stop', { method: 'POST' }); + if (response.ok) { + showToast('Stop request sent.', 'info'); + } else { + showToast('Failed to send stop request.', 'error'); + } + } catch (error) { + showToast('Error sending stop request.', 'error'); + } + } +} + +async function checkAndUpdateQualityScanProgress() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/quality-scanner/status', { + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + if (!response.ok) return; + + const state = await response.json(); + console.debug('🔍 Quality Scanner Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); + updateQualityScanProgressUI(state); + + // Start polling only if not already polling and status is running + if (state.status === 'running' && !qualityScannerStatusInterval) { + console.log('🔄 Starting quality scanner polling (1 second interval)'); + qualityScannerStatusInterval = setInterval(checkAndUpdateQualityScanProgress, 1000); + } + + } catch (error) { + console.warn('Could not fetch quality scanner status:', error); + // Don't stop polling on network errors - keep trying + } +} + +function updateQualityScanProgressFromData(data) { + const prev = _lastToolStatus['quality-scanner']; + _lastToolStatus['quality-scanner'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateQualityScanProgressUI(data); +} + +function updateQualityScanProgressUI(state) { + const button = document.getElementById('quality-scan-button'); + const phaseLabel = document.getElementById('quality-phase-label'); + const progressLabel = document.getElementById('quality-progress-label'); + const progressBar = document.getElementById('quality-progress-bar'); + const scopeSelect = document.getElementById('quality-scan-scope'); + + // Stats + const processedStat = document.getElementById('quality-stat-processed'); + const metStat = document.getElementById('quality-stat-met'); + const lowStat = document.getElementById('quality-stat-low'); + const matchedStat = document.getElementById('quality-stat-matched'); + + if (!button || !phaseLabel || !progressLabel || !progressBar || !scopeSelect) return; + + // Update stats + if (processedStat) processedStat.textContent = state.processed || 0; + if (metStat) metStat.textContent = state.quality_met || 0; + if (lowStat) lowStat.textContent = state.low_quality || 0; + if (matchedStat) matchedStat.textContent = state.matched || 0; + + if (state.status === 'running') { + button.textContent = 'Stop Scan'; + button.disabled = false; + scopeSelect.disabled = true; + + phaseLabel.textContent = state.phase || 'Scanning...'; + progressLabel.textContent = `${state.processed} / ${state.total} tracks scanned (${state.progress.toFixed(1)}%)`; + progressBar.style.width = `${state.progress}%`; + } else { // idle, finished, or error + stopQualityScannerPolling(); + button.textContent = 'Scan Library'; + button.disabled = false; + scopeSelect.disabled = false; + + if (state.status === 'error') { + phaseLabel.textContent = `Error: ${state.error_message}`; + progressBar.style.backgroundColor = '#ff4444'; // Red for error + } else { + phaseLabel.textContent = state.phase || 'Ready to scan'; + progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal + } + + if (state.status === 'finished') { + // Show completion toast with results + showToast(`Scan complete! ${state.matched} tracks added to wishlist`, 'success'); + } + } +} + +function stopQualityScannerPolling() { + if (qualityScannerStatusInterval) { + console.log('⏹️ Stopping quality scanner polling'); + clearInterval(qualityScannerStatusInterval); + qualityScannerStatusInterval = null; + } +} + +// ============================================ +// == DUPLICATE CLEANER FUNCTIONS == +// ============================================ + +async function handleDuplicateCleanButtonClick() { + const button = document.getElementById('duplicate-clean-button'); + const currentAction = button.textContent; + + if (currentAction === 'Clean Duplicates') { + try { + button.disabled = true; + button.textContent = 'Starting...'; + const response = await fetch('/api/duplicate-cleaner/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + if (response.ok) { + showToast('Duplicate cleaner started!', 'success'); + // Start polling immediately to get live status + checkAndUpdateDuplicateCleanProgress(); + } else { + const errorData = await response.json(); + showToast(`Error: ${errorData.error}`, 'error'); + button.disabled = false; + button.textContent = 'Clean Duplicates'; + } + } catch (error) { + showToast('Failed to start duplicate cleaner.', 'error'); + button.disabled = false; + button.textContent = 'Clean Duplicates'; + } + + } else { // "Stop Cleaning" + try { + const response = await fetch('/api/duplicate-cleaner/stop', { method: 'POST' }); + if (response.ok) { + showToast('Stop request sent.', 'info'); + } else { + showToast('Failed to send stop request.', 'error'); + } + } catch (error) { + showToast('Error sending stop request.', 'error'); + } + } +} + +async function checkAndUpdateDuplicateCleanProgress() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/duplicate-cleaner/status', { + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + if (!response.ok) return; + + const state = await response.json(); + console.debug('🧹 Duplicate Cleaner Status:', state.status, `${state.files_scanned}/${state.total_files}`, `${state.progress.toFixed(1)}%`); + updateDuplicateCleanProgressUI(state); + + // Start polling only if not already polling and status is running + if (state.status === 'running' && !duplicateCleanerStatusInterval) { + console.log('🔄 Starting duplicate cleaner polling (1 second interval)'); + duplicateCleanerStatusInterval = setInterval(checkAndUpdateDuplicateCleanProgress, 1000); + } + + } catch (error) { + console.warn('Could not fetch duplicate cleaner status:', error); + // Don't stop polling on network errors - keep trying + } +} + +function updateDuplicateCleanProgressFromData(data) { + const prev = _lastToolStatus['duplicate-cleaner']; + _lastToolStatus['duplicate-cleaner'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateDuplicateCleanProgressUI(data); +} + +function updateDuplicateCleanProgressUI(state) { + const button = document.getElementById('duplicate-clean-button'); + const phaseLabel = document.getElementById('duplicate-phase-label'); + const progressLabel = document.getElementById('duplicate-progress-label'); + const progressBar = document.getElementById('duplicate-progress-bar'); + + // Stats + const scannedStat = document.getElementById('duplicate-stat-scanned'); + const foundStat = document.getElementById('duplicate-stat-found'); + const deletedStat = document.getElementById('duplicate-stat-deleted'); + const spaceStat = document.getElementById('duplicate-stat-space'); + + if (!button || !phaseLabel || !progressLabel || !progressBar) return; + + // Update stats + if (scannedStat) scannedStat.textContent = state.files_scanned || 0; + if (foundStat) foundStat.textContent = state.duplicates_found || 0; + if (deletedStat) deletedStat.textContent = state.deleted || 0; + if (spaceStat) { + const spaceMB = state.space_freed_mb || 0; + if (spaceMB >= 1024) { + spaceStat.textContent = `${(spaceMB / 1024).toFixed(2)} GB`; + } else { + spaceStat.textContent = `${spaceMB.toFixed(2)} MB`; + } + } + + if (state.status === 'running') { + button.textContent = 'Stop Cleaning'; + button.disabled = false; + + phaseLabel.textContent = state.phase || 'Scanning...'; + progressLabel.textContent = `${state.files_scanned} / ${state.total_files} files scanned (${state.progress.toFixed(1)}%)`; + progressBar.style.width = `${state.progress}%`; + } else { // idle, finished, or error + stopDuplicateCleanerPolling(); + button.textContent = 'Clean Duplicates'; + button.disabled = false; + + if (state.status === 'error') { + phaseLabel.textContent = `Error: ${state.error_message}`; + progressBar.style.backgroundColor = '#ff4444'; // Red for error + } else { + phaseLabel.textContent = state.phase || 'Ready to scan'; + progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal + } + + if (state.status === 'finished') { + // Show completion toast with results + const spaceMB = state.space_freed_mb || 0; + const spaceDisplay = spaceMB >= 1024 ? `${(spaceMB / 1024).toFixed(2)} GB` : `${spaceMB.toFixed(1)} MB`; + showToast(`Cleaning complete! ${state.deleted} files removed, ${spaceDisplay} freed`, 'success'); + } + } +} + +function stopDuplicateCleanerPolling() { + if (duplicateCleanerStatusInterval) { + console.log('⏹️ Stopping duplicate cleaner polling'); + clearInterval(duplicateCleanerStatusInterval); + duplicateCleanerStatusInterval = null; + } +} + +// ============================================ +// == BACKUP MANAGER == +// ============================================ + +async function loadBackupList() { + try { + const res = await fetch('/api/database/backups'); + const data = await res.json(); + if (data.success) { + updateBackupManagerUI(data); + renderBackupList(data.backups); + } + } catch (e) { + console.error('Failed to load backup list:', e); + } +} + +function updateBackupManagerUI(data) { + const lastEl = document.getElementById('backup-stat-last'); + const countEl = document.getElementById('backup-stat-count'); + const latestSizeEl = document.getElementById('backup-stat-latest-size'); + const dbSizeEl = document.getElementById('backup-stat-db-size'); + + if (countEl) countEl.textContent = data.count; + if (dbSizeEl) dbSizeEl.textContent = data.db_size_mb + ' MB'; + + if (data.backups && data.backups.length > 0) { + const newest = data.backups[0]; + if (lastEl) lastEl.textContent = timeAgo(newest.created); + if (latestSizeEl) latestSizeEl.textContent = newest.size_mb + ' MB'; + } else { + if (lastEl) lastEl.textContent = 'Never'; + if (latestSizeEl) latestSizeEl.textContent = '—'; + } +} + +function renderBackupList(backups) { + const container = document.getElementById('backup-list-container'); + if (!container) return; + if (!backups || backups.length === 0) { + container.innerHTML = ''; + return; + } + + container.innerHTML = backups.map(b => { + const date = new Date(b.created + (b.created.includes('Z') ? '' : 'Z')); + const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + + ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); + const safeName = escapeForInlineJs(b.filename); + const versionBadge = b.version ? `v${escapeHtml(b.version)}` : ''; + return `
+
+ ${escapeHtml(dateStr)} + ${b.size_mb} MB + ${versionBadge} +
+
+ + + +
+
`; + }).join(''); +} + +async function handleBackupNowClick() { + const button = document.getElementById('backup-now-button'); + if (!button) return; + const origText = button.textContent; + button.disabled = true; + button.textContent = 'Backing up...'; + try { + const res = await fetch('/api/database/backup', { method: 'POST' }); + const data = await res.json(); + if (data.success) { + showToast(`Database backed up (${data.size_mb} MB)`, 'success'); + await loadBackupList(); + } else { + showToast(`Backup failed: ${data.error}`, 'error'); + } + } catch (e) { + showToast('Backup request failed', 'error'); + } + button.disabled = false; + button.textContent = origText; +} + +function downloadBackup(filename) { + const a = document.createElement('a'); + a.href = `/api/database/backups/${encodeURIComponent(filename)}/download`; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); +} + +async function restoreBackup(filename, force = false) { + if (!force) { + if (!await showConfirmDialog({ title: 'Restore Backup', message: `Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`, confirmText: 'Restore' })) return; + } + try { + const fetchOpts = { method: 'POST' }; + if (force) { + fetchOpts.headers = { 'Content-Type': 'application/json' }; + fetchOpts.body = JSON.stringify({ force: true }); + } + const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, fetchOpts); + const data = await res.json(); + if (data.success) { + let msg = `Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`; + if (data.version_warning) msg += `\n⚠️ ${data.version_warning}`; + showToast(msg, 'success'); + await loadBackupList(); + } else if (data.version_mismatch) { + // Version mismatch — ask user to confirm + const confirmed = await showConfirmDialog({ + title: 'Version Mismatch', + message: `This backup was created on SoulSync v${data.backup_version}, but you're running v${data.current_version}.\n\nRestoring an older backup may cause issues if the database schema has changed. A safety backup will be created first.\n\nProceed anyway?`, + confirmText: 'Restore Anyway', + destructive: true + }); + if (confirmed) { + await restoreBackup(filename, true); + } + } else { + showToast(`Restore failed: ${data.error}`, 'error'); + } + } catch (e) { + showToast('Restore request failed', 'error'); + } +} + +async function deleteBackup(filename) { + if (!await showConfirmDialog({ title: 'Delete Backup', message: `Delete backup "${filename}"? This cannot be undone.`, confirmText: 'Delete', destructive: true })) return; + try { + const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + showToast(`Backup deleted: ${data.deleted}`, 'success'); + await loadBackupList(); + } else { + showToast(`Delete failed: ${data.error}`, 'error'); + } + } catch (e) { + showToast('Delete request failed', 'error'); + } +} + +// ============================================ +// == METADATA CACHE == +// ============================================ + +async function loadMetadataCacheStats() { + try { + const response = await fetch('/api/metadata-cache/stats'); + if (!response.ok) return; + const stats = await response.json(); + + const artistsEl = document.getElementById('mcache-stat-artists'); + const albumsEl = document.getElementById('mcache-stat-albums'); + const tracksEl = document.getElementById('mcache-stat-tracks'); + const hitsEl = document.getElementById('mcache-stat-hits'); + + if (artistsEl) artistsEl.textContent = (stats.artists?.spotify || 0) + (stats.artists?.itunes || 0) + (stats.artists?.deezer || 0) + (stats.artists?.beatport || 0); + if (albumsEl) albumsEl.textContent = (stats.albums?.spotify || 0) + (stats.albums?.itunes || 0) + (stats.albums?.deezer || 0) + (stats.albums?.beatport || 0); + if (tracksEl) tracksEl.textContent = (stats.tracks?.spotify || 0) + (stats.tracks?.itunes || 0) + (stats.tracks?.deezer || 0) + (stats.tracks?.beatport || 0); + if (hitsEl) hitsEl.textContent = stats.total_hits || 0; + } catch (e) { + // Silently fail — cache may not be initialized yet + } +} + +// ── Library History Modal ──────────────────────────────────────────── +let _libraryHistoryState = { tab: 'download', page: 1, limit: 50 }; + +function openLibraryHistoryModal() { + const overlay = document.getElementById('library-history-overlay'); + if (overlay) { + overlay.classList.remove('hidden'); + _libraryHistoryState.page = 1; + loadLibraryHistory(); + } +} + +function closeLibraryHistoryModal() { + const overlay = document.getElementById('library-history-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +function switchHistoryTab(tab) { + _libraryHistoryState.tab = tab; + _libraryHistoryState.page = 1; + document.querySelectorAll('.library-history-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + loadLibraryHistory(); +} + +async function loadLibraryHistory() { + const { tab, page, limit } = _libraryHistoryState; + const list = document.getElementById('library-history-list'); + const pagination = document.getElementById('library-history-pagination'); + if (!list) return; + list.innerHTML = '
Loading...
'; + if (pagination) pagination.innerHTML = ''; + + try { + const resp = await fetch(`/api/library/history?type=${tab}&page=${page}&limit=${limit}`); + const data = await resp.json(); + + // Update tab counts + const dlCount = document.getElementById('history-download-count'); + const imCount = document.getElementById('history-import-count'); + if (dlCount) dlCount.textContent = data.stats?.downloads || 0; + if (imCount) imCount.textContent = data.stats?.imports || 0; + + // Source breakdown bar (downloads tab only) + const sourceBar = document.getElementById('history-source-bar'); + if (sourceBar) { + const sc = data.stats?.source_counts || {}; + const srcEntries = Object.entries(sc).sort((a, b) => b[1] - a[1]); + if (srcEntries.length > 0 && tab === 'download') { + const _srcColors = { Soulseek: '#4caf50', Tidal: '#000', YouTube: '#ff0000', Qobuz: '#4285f4', HiFi: '#00bcd4', Deezer: '#a238ff' }; + sourceBar.innerHTML = srcEntries.map(([src, cnt]) => + `${src}: ${cnt}` + ).join(''); + sourceBar.style.display = ''; + } else { + sourceBar.style.display = 'none'; + } + } + + if (!data.entries || data.entries.length === 0) { + const emptyIcon = tab === 'download' ? '📥' : '📚'; + const emptyText = tab === 'download' + ? 'No downloads recorded yet. Completed downloads will appear here.' + : 'No server imports recorded yet. New tracks from library scans will appear here.'; + list.innerHTML = `
${emptyIcon}

${emptyText}
`; + return; + } + + list.innerHTML = data.entries.map(renderHistoryEntry).join(''); + renderHistoryPagination(data.total, page, limit); + } catch (err) { + console.error('Error loading library history:', err); + list.innerHTML = '
Error loading history
'; + } +} + +function renderHistoryEntry(entry) { + // Server import thumb_urls are relative paths (e.g. /library/metadata/...) — use placeholder + const hasValidThumb = entry.thumb_url && (entry.thumb_url.startsWith('http://') || entry.thumb_url.startsWith('https://')); + const thumb = hasValidThumb + ? `` + : `
${entry.event_type === 'download' ? '📥' : '📚'}
`; + + let badge = ''; + if (entry.event_type === 'download') { + const parts = []; + if (entry.download_source) parts.push(entry.download_source); + if (entry.quality) parts.push(entry.quality); + badge = parts.map(p => `${escapeHtml(p)}`).join(''); + } else if (entry.event_type === 'import' && entry.server_source) { + const sourceName = { plex: 'Plex', jellyfin: 'Jellyfin', navidrome: 'Navidrome' }[entry.server_source] || entry.server_source; + badge = `${escapeHtml(sourceName)}`; + } + + // AcoustID badge + let acoustidBadge = ''; + if (entry.acoustid_result) { + const _aidColors = { pass: '#4caf50', fail: '#ef5350', skip: '#ff9800', disabled: '#666', error: '#ef5350' }; + const _aidLabels = { pass: 'Verified', fail: 'Failed', skip: 'Skipped', disabled: 'Off', error: 'Error' }; + const color = _aidColors[entry.acoustid_result] || '#666'; + const label = _aidLabels[entry.acoustid_result] || entry.acoustid_result; + acoustidBadge = `AcoustID: ${label}`; + } + + const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — '); + + // Source provenance — expected vs downloaded + let sourceDetail = ''; + if (entry.event_type === 'download') { + const lines = []; + // Expected line (what we asked for) + if (entry.title || entry.artist_name) { + lines.push(`Expected: ${escapeHtml(entry.title || '?')} by ${escapeHtml(entry.artist_name || '?')}`); + } + // Downloaded line (what the source provided) + const srcTitle = entry.source_track_title || ''; + const srcArtist = entry.source_artist || ''; + if (srcTitle || srcArtist) { + const isMismatch = (srcTitle && entry.title && srcTitle.toLowerCase() !== entry.title.toLowerCase()) + || (srcArtist && entry.artist_name && srcArtist.toLowerCase() !== entry.artist_name.toLowerCase()); + const mismatchClass = isMismatch ? ' lh-prov-mismatch' : ''; + lines.push(`Downloaded: ${escapeHtml(srcTitle || '?')} by ${escapeHtml(srcArtist || '?')}`); + } + // Source file + ID line + if (entry.source_filename || entry.source_track_id) { + const fileParts = []; + if (entry.source_filename) fileParts.push(`File: ${escapeHtml(entry.source_filename)}`); + if (entry.source_track_id) fileParts.push(`${entry.source_filename ? '' : 'Source '}ID: ${escapeHtml(entry.source_track_id)}`); + lines.push(fileParts.join(` · `)); + } + if (lines.length > 0) { + sourceDetail = `
${lines.join('
')}
`; + } + } + + const hasDetails = sourceDetail || acoustidBadge; + const expandIndicator = hasDetails ? `` : ''; + + return `
+ ${thumb} +
+
+
+
${escapeHtml(entry.title || 'Unknown')}
+ +
+
${badge}
+
${formatHistoryTime(entry.created_at)}
+ ${expandIndicator} +
+ ${hasDetails ? `
+ ${sourceDetail} + ${acoustidBadge ? `
${acoustidBadge}
` : ''} +
` : ''} +
+
`; +} + +function formatHistoryTime(isoStr) { + if (!isoStr) return ''; + try { + // SQLite CURRENT_TIMESTAMP is UTC but lacks timezone marker — append Z + let normalized = isoStr; + if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(normalized) && !normalized.includes('Z') && !normalized.includes('+')) { + normalized = normalized.replace(' ', 'T') + 'Z'; + } + const date = new Date(normalized); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); + } catch { return ''; } +} + +function renderHistoryPagination(total, page, limit) { + const pagination = document.getElementById('library-history-pagination'); + if (!pagination) return; + + const totalPages = Math.ceil(total / limit); + if (totalPages <= 1) { pagination.innerHTML = ''; return; } + + pagination.innerHTML = ` + + Page ${page} of ${totalPages} + + `; +} + +function changeHistoryPage(newPage) { + if (newPage < 1) return; + _libraryHistoryState.page = newPage; + loadLibraryHistory(); +} + +// ── Sync History Modal ────────────────────────────────────────────── +const _syncHistoryState = { source: null, page: 1, limit: 20 }; + +function openSyncHistoryModal() { + const overlay = document.getElementById('sync-history-overlay'); + if (overlay) { + overlay.classList.remove('hidden'); + _syncHistoryState.page = 1; + _syncHistoryState.source = null; + loadSyncHistory(); + } +} + +function closeSyncHistoryModal() { + const overlay = document.getElementById('sync-history-overlay'); + if (overlay) overlay.classList.add('hidden'); +} + +function switchSyncHistoryTab(source) { + _syncHistoryState.source = source; + _syncHistoryState.page = 1; + document.querySelectorAll('.sync-history-tab').forEach(t => { + t.classList.toggle('active', t.dataset.source === (source || 'all')); + }); + loadSyncHistory(); +} + +async function loadSyncHistory() { + const { source, page, limit } = _syncHistoryState; + const list = document.getElementById('sync-history-list'); + const tabsContainer = document.getElementById('sync-history-tabs'); + if (!list) return; + list.innerHTML = '
Loading...
'; + + try { + const params = new URLSearchParams({ page, limit }); + if (source) params.set('source', source); + const resp = await fetch(`/api/sync/history?${params}`); + const data = await resp.json(); + + // Build tabs from stats + if (tabsContainer && data.stats) { + const totalCount = Object.values(data.stats).reduce((a, b) => a + b, 0); + const sourceLabels = { + spotify: 'Spotify', beatport: 'Beatport', youtube: 'YouTube', + tidal: 'Tidal', deezer: 'Deezer', wishlist: 'Wishlist', + library: 'Library', discover: 'Discover', listenbrainz: 'ListenBrainz', + spotify_public: 'Spotify Public', mirrored: 'Mirrored' + }; + let tabsHtml = ``; + for (const [src, count] of Object.entries(data.stats).sort((a, b) => b[1] - a[1])) { + const label = sourceLabels[src] || src; + const isActive = source === src ? ' active' : ''; + tabsHtml += ``; + } + tabsContainer.innerHTML = tabsHtml; + } + + // Filter to only show playlist syncs — not album downloads, wishlist, or redownloads + const syncEntries = (data.entries || []).filter(e => e.sync_type === 'playlist' || !e.sync_type); + + if (syncEntries.length === 0) { + list.innerHTML = '
No sync history yet. Completed syncs will appear here.
'; + return; + } + + list.innerHTML = syncEntries.map(renderSyncHistoryEntry).join(''); + renderSyncHistoryPagination(data.total, page, limit); + } catch (err) { + console.error('Error loading sync history:', err); + list.innerHTML = '
Error loading sync history
'; + } +} + +function renderSyncHistoryEntry(entry) { + const thumb = entry.thumb_url + ? `` + : `
${_syncSourceIcon(entry.source)}
`; + + const sourceBadge = `${escapeHtml(entry.source)}`; + + const title = entry.playlist_name || 'Unknown'; + const meta = [entry.artist_name, entry.album_name].filter(Boolean).join(' — ') || entry.sync_type; + + // Stats + let statsHtml = ''; + if (entry.completed_at) { + const parts = []; + if (entry.tracks_found > 0) parts.push(`${entry.tracks_found} found`); + if (entry.tracks_downloaded > 0) parts.push(`${entry.tracks_downloaded} downloaded`); + if (entry.tracks_failed > 0) parts.push(`${entry.tracks_failed} failed`); + if (parts.length === 0) parts.push(`${entry.total_tracks} in library`); + statsHtml = `
${parts.join('')}
`; + } else { + statsHtml = `
In progress
`; + } + + const timeStr = formatHistoryTime(entry.started_at); + + return `
+
+ ${thumb} +
+
${escapeHtml(title)}
+ +
+ ${sourceBadge} + ${statsHtml} +
${timeStr}
+ + +
+ +
`; +} + +function _syncSourceIcon(source) { + const icons = { + spotify: '🎵', beatport: '🎶', youtube: '▶', + tidal: '🌊', deezer: '🎧', wishlist: '⭐', + library: '📚', discover: '🔍', mirrored: '🔗', + listenbrainz: '🎧', spotify_public: '🎵' + }; + return icons[source] || '📥'; +} + +function renderSyncHistoryPagination(total, page, limit) { + const pagination = document.getElementById('sync-history-pagination'); + if (!pagination) return; + const totalPages = Math.ceil(total / limit); + if (totalPages <= 1) { pagination.innerHTML = ''; return; } + pagination.innerHTML = ` + + Page ${page} of ${totalPages} + + `; +} + +function changeSyncHistoryPage(newPage) { + if (newPage < 1) return; + _syncHistoryState.page = newPage; + loadSyncHistory(); +} + +// Track active re-syncs from history +let _activeSyncHistoryResyncs = {}; + +// Sources that do server playlist sync (match to media server) vs download (Soulseek download) +const _serverSyncSources = new Set(['spotify', 'tidal', 'deezer', 'youtube', 'mirrored', 'listenbrainz', 'spotify_public', 'beatport']); +const _downloadSyncSources = new Set(['discover', 'library', 'wishlist']); + +async function retriggerSync(entryId) { + try { + const resp = await fetch(`/api/sync/history/${entryId}`); + const data = await resp.json(); + + if (!data.success || !data.entry) { + showToast('Failed to load sync data', 'error'); + return; + } + + const entry = data.entry; + + // Determine if this is a download-type sync or a server-sync-type + const isDownloadSync = entry.is_album_download || _downloadSyncSources.has(entry.source); + const isServerSync = _serverSyncSources.has(entry.source) && !entry.is_album_download; + + if (isDownloadSync) { + // Download syncs open the download modal (existing behavior) + closeSyncHistoryModal(); + + const virtualPlaylistId = entry.playlist_id || `resync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const albumObj = entry.album_context || { + id: `resync_album_${entryId}`, + name: entry.playlist_name, + album_type: entry.sync_type === 'album' ? 'album' : 'compilation', + images: entry.thumb_url ? [{ url: entry.thumb_url }] : [], + total_tracks: entry.total_tracks + }; + const artistObj = entry.artist_context || { id: 'resync_artist', name: 'Various Artists' }; + const contextType = entry.sync_type === 'album' ? 'artist_album' : 'playlist'; + + await openDownloadMissingModalForArtistAlbum( + virtualPlaylistId, entry.playlist_name, entry.tracks, + albumObj, artistObj, false, contextType + ); + } else { + // Server sync — start sync and show live progress in the card + await _startSyncHistoryResync(entryId, entry); + } + } catch (err) { + console.error('Error re-triggering sync:', err); + showToast('Error loading sync data', 'error'); + } +} + +async function _startSyncHistoryResync(entryId, entry) { + // Disable the re-sync button + const btn = document.getElementById(`resync-btn-${entryId}`); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } + + // Show the progress area + const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`); + const progressArea = document.getElementById(`sync-history-progress-${entryId}`); + if (wrapper) wrapper.classList.add('syncing'); + if (progressArea) progressArea.style.display = ''; + + // Build a unique sync playlist ID for this re-sync + const syncPlaylistId = `resync_${entryId}_${Date.now()}`; + + // Prepare tracks for the sync API + const tracks = (entry.tracks || []).map(t => { + const artists = Array.isArray(t.artists) + ? (typeof t.artists[0] === 'object' ? t.artists.map(a => a.name || a) : t.artists) + : [t.artists || 'Unknown Artist']; + const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); + return { + id: t.id || '', + name: t.name || '', + artists: artists, + album: albumName, + duration_ms: t.duration_ms || 0, + popularity: t.popularity || 0 + }; + }); + + try { + const response = await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: syncPlaylistId, + playlist_name: entry.playlist_name, + tracks: tracks + }) + }); + + const result = await response.json(); + if (!result.success) { + showToast(`Sync failed: ${result.error || 'Unknown error'}`, 'error'); + _cleanupSyncHistoryResync(entryId); + return; + } + + // Store active re-sync state + _activeSyncHistoryResyncs[entryId] = { syncPlaylistId, entryId }; + + // Start polling for progress + _pollSyncHistoryProgress(entryId, syncPlaylistId); + + } catch (err) { + console.error('Error starting re-sync:', err); + showToast('Failed to start sync', 'error'); + _cleanupSyncHistoryResync(entryId); + } +} + +function _pollSyncHistoryProgress(entryId, syncPlaylistId) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/sync/status/${syncPlaylistId}`); + if (!resp.ok) { + clearInterval(pollInterval); + _cleanupSyncHistoryResync(entryId, 'error'); + return; + } + const state = await resp.json(); + + if (state.status === 'syncing' || state.status === 'starting') { + const progress = state.progress || {}; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const step = progress.current_step || 'Processing'; + const currentTrack = progress.current_track || ''; + const processed = matched + failed; + const percent = total > 0 ? Math.round((processed / total) * 100) : 0; + + const bar = document.getElementById(`sync-history-bar-${entryId}`); + const stepEl = document.getElementById(`sync-history-step-${entryId}`); + const matchedEl = document.getElementById(`sync-history-matched-${entryId}`); + const failedEl = document.getElementById(`sync-history-failed-${entryId}`); + + if (bar) bar.style.width = `${percent}%`; + if (stepEl) stepEl.textContent = currentTrack ? `${step} — ${currentTrack}` : step; + if (matchedEl) matchedEl.textContent = `${matched} matched`; + if (failedEl) failedEl.textContent = `${failed} failed`; + + } else if (state.status === 'finished') { + clearInterval(pollInterval); + const progress = state.progress || state.result || {}; + const matched = progress.matched_tracks || 0; + const failed = progress.failed_tracks || 0; + const total = progress.total_tracks || 0; + const synced = progress.synced_tracks || 0; + + const bar = document.getElementById(`sync-history-bar-${entryId}`); + const stepEl = document.getElementById(`sync-history-step-${entryId}`); + const matchedEl = document.getElementById(`sync-history-matched-${entryId}`); + const failedEl = document.getElementById(`sync-history-failed-${entryId}`); + + if (bar) bar.style.width = '100%'; + if (stepEl) stepEl.textContent = `Sync complete — ${matched}/${total} matched, ${synced} synced`; + if (matchedEl) matchedEl.textContent = `${matched} matched`; + if (failedEl) failedEl.textContent = `${failed} failed`; + + // Hide cancel button + const cancelBtn = document.getElementById(`sync-history-cancel-${entryId}`); + if (cancelBtn) cancelBtn.style.display = 'none'; + + showToast(`Re-sync complete: ${matched}/${total} matched`, 'success'); + + // Auto-collapse after 5 seconds + setTimeout(() => _cleanupSyncHistoryResync(entryId, 'finished'), 5000); + + } else if (state.status === 'cancelled' || state.status === 'error') { + clearInterval(pollInterval); + const stepEl = document.getElementById(`sync-history-step-${entryId}`); + if (stepEl) stepEl.textContent = state.status === 'cancelled' ? 'Sync cancelled' : `Sync error: ${state.error || 'Unknown'}`; + + const cancelBtn = document.getElementById(`sync-history-cancel-${entryId}`); + if (cancelBtn) cancelBtn.style.display = 'none'; + + setTimeout(() => _cleanupSyncHistoryResync(entryId, state.status), 3000); + } + } catch (err) { + console.error('Error polling sync status:', err); + clearInterval(pollInterval); + _cleanupSyncHistoryResync(entryId, 'error'); + } + }, 2000); + + // Store interval so cancel can clear it + if (_activeSyncHistoryResyncs[entryId]) { + _activeSyncHistoryResyncs[entryId].pollInterval = pollInterval; + } +} + +async function cancelSyncHistoryResync(entryId) { + const active = _activeSyncHistoryResyncs[entryId]; + if (!active) return; + + try { + await fetch('/api/sync/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_id: active.syncPlaylistId }) + }); + + const stepEl = document.getElementById(`sync-history-step-${entryId}`); + if (stepEl) stepEl.textContent = 'Cancelling...'; + + } catch (err) { + console.error('Error cancelling sync:', err); + showToast('Failed to cancel sync', 'error'); + } +} + +function _cleanupSyncHistoryResync(entryId, finalStatus) { + const active = _activeSyncHistoryResyncs[entryId]; + if (active && active.pollInterval) { + clearInterval(active.pollInterval); + } + delete _activeSyncHistoryResyncs[entryId]; + + const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`); + const progressArea = document.getElementById(`sync-history-progress-${entryId}`); + const btn = document.getElementById(`resync-btn-${entryId}`); + + if (wrapper) wrapper.classList.remove('syncing'); + if (progressArea) progressArea.style.display = 'none'; + if (btn) { btn.disabled = false; btn.textContent = 'Re-sync'; } +} + +async function deleteSyncHistoryEntry(entryId) { + try { + const resp = await fetch(`/api/sync/history/${entryId}`, { method: 'DELETE' }); + const data = await resp.json(); + if (data.success) { + const wrapper = document.getElementById(`sync-history-wrapper-${entryId}`); + if (wrapper) { + wrapper.style.transition = 'opacity 0.2s ease, max-height 0.3s ease'; + wrapper.style.opacity = '0'; + wrapper.style.maxHeight = wrapper.offsetHeight + 'px'; + requestAnimationFrame(() => { wrapper.style.maxHeight = '0'; wrapper.style.overflow = 'hidden'; }); + setTimeout(() => wrapper.remove(), 300); + } + } else { + showToast('Failed to delete entry', 'error'); + } + } catch (err) { + console.error('Error deleting sync history entry:', err); + showToast('Failed to delete entry', 'error'); + } +} + +// ── Sync Playlist to Server (from Download Modal) ────────────────── + +// Track active modal syncs +let _activeModalSyncs = {}; + +function _isBeatportPlaylistId(id) { + return id.startsWith('beatport_chart_') || id.startsWith('beatport_top100_') || id.startsWith('beatport_hype100_'); +} + +async function syncPlaylistToServer(playlistId) { + const process = activeDownloadProcesses[playlistId]; + if (!process) { showToast('No playlist data found', 'error'); return; } + + // Disable the sync button + const btn = document.getElementById(`sync-server-btn-${playlistId}`); + if (btn) { btn.disabled = true; btn.textContent = 'Syncing...'; } + + // Show progress area + const progressArea = document.getElementById(`modal-sync-progress-${playlistId}`); + if (progressArea) progressArea.style.display = ''; + + const syncPlaylistId = `beatport_sync_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const playlistName = process.playlist?.name || 'Beatport Playlist'; + + // Format tracks for the sync API + const tracks = (process.tracks || []).map(t => { + const artists = Array.isArray(t.artists) + ? (typeof t.artists[0] === 'object' ? t.artists.map(a => a.name || a) : t.artists) + : [t.artists || 'Unknown Artist']; + const albumName = typeof t.album === 'object' ? (t.album?.name || '') : (t.album || ''); + return { + id: t.id || '', + name: t.name || '', + artists: artists, + album: albumName, + duration_ms: t.duration_ms || 0, + popularity: t.popularity || 0 + }; + }); + + try { + const response = await fetch('/api/sync/start', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + playlist_id: syncPlaylistId, + playlist_name: playlistName, + tracks: tracks + }) + }); + + const result = await response.json(); + if (!result.success) { + showToast(`Sync failed: ${result.error || 'Unknown error'}`, 'error'); + _cleanupModalSync(playlistId); + return; + } + + _activeModalSyncs[playlistId] = { syncPlaylistId }; + _pollModalSyncProgress(playlistId, syncPlaylistId); + + } catch (err) { + console.error('Error starting playlist sync:', err); + showToast('Failed to start sync', 'error'); + _cleanupModalSync(playlistId); + } +} + +function _pollModalSyncProgress(playlistId, syncPlaylistId) { + const pollInterval = setInterval(async () => { + try { + const resp = await fetch(`/api/sync/status/${syncPlaylistId}`); + if (!resp.ok) { clearInterval(pollInterval); _cleanupModalSync(playlistId, 'error'); return; } + const state = await resp.json(); + + const bar = document.getElementById(`modal-sync-bar-${playlistId}`); + const stepEl = document.getElementById(`modal-sync-step-${playlistId}`); + const matchedEl = document.getElementById(`modal-sync-matched-${playlistId}`); + const failedEl = document.getElementById(`modal-sync-failed-${playlistId}`); + + if (state.status === 'syncing' || state.status === 'starting') { + const p = state.progress || {}; + const matched = p.matched_tracks || 0; + const failed = p.failed_tracks || 0; + const total = p.total_tracks || 0; + const step = p.current_step || 'Processing'; + const currentTrack = p.current_track || ''; + const processed = matched + failed; + const percent = total > 0 ? Math.round((processed / total) * 100) : 0; + + if (bar) bar.style.width = `${percent}%`; + if (stepEl) stepEl.textContent = currentTrack ? `${step} — ${currentTrack}` : step; + if (matchedEl) matchedEl.textContent = `${matched} matched`; + if (failedEl) failedEl.textContent = `${failed} failed`; + + } else if (state.status === 'finished') { + clearInterval(pollInterval); + const p = state.progress || state.result || {}; + const matched = p.matched_tracks || 0; + const failed = p.failed_tracks || 0; + const total = p.total_tracks || 0; + const synced = p.synced_tracks || 0; + + if (bar) bar.style.width = '100%'; + if (stepEl) stepEl.textContent = `Sync complete — ${matched}/${total} matched, ${synced} synced`; + if (matchedEl) matchedEl.textContent = `${matched} matched`; + if (failedEl) failedEl.textContent = `${failed} failed`; + + const cancelBtn = document.getElementById(`modal-sync-cancel-${playlistId}`); + if (cancelBtn) cancelBtn.style.display = 'none'; + + showToast(`Server sync complete: ${matched}/${total} matched`, 'success'); + + // Re-enable sync button after a delay + setTimeout(() => _cleanupModalSync(playlistId, 'finished'), 5000); + + } else if (state.status === 'cancelled' || state.status === 'error') { + clearInterval(pollInterval); + if (stepEl) stepEl.textContent = state.status === 'cancelled' ? 'Sync cancelled' : `Sync error`; + const cancelBtn = document.getElementById(`modal-sync-cancel-${playlistId}`); + if (cancelBtn) cancelBtn.style.display = 'none'; + setTimeout(() => _cleanupModalSync(playlistId, state.status), 3000); + } + } catch (err) { + console.error('Error polling modal sync status:', err); + clearInterval(pollInterval); + _cleanupModalSync(playlistId, 'error'); + } + }, 2000); + + if (_activeModalSyncs[playlistId]) { + _activeModalSyncs[playlistId].pollInterval = pollInterval; + } +} + +async function cancelModalSync(playlistId) { + const active = _activeModalSyncs[playlistId]; + if (!active) return; + + try { + await fetch('/api/sync/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ playlist_id: active.syncPlaylistId }) + }); + const stepEl = document.getElementById(`modal-sync-step-${playlistId}`); + if (stepEl) stepEl.textContent = 'Cancelling...'; + } catch (err) { + console.error('Error cancelling modal sync:', err); + } +} + +function _cleanupModalSync(playlistId, finalStatus) { + const active = _activeModalSyncs[playlistId]; + if (active && active.pollInterval) clearInterval(active.pollInterval); + delete _activeModalSyncs[playlistId]; + + const progressArea = document.getElementById(`modal-sync-progress-${playlistId}`); + const btn = document.getElementById(`sync-server-btn-${playlistId}`); + + if (finalStatus === 'finished') { + // Keep progress visible but hide after fade + if (progressArea) setTimeout(() => { progressArea.style.display = 'none'; }, 300); + } else { + if (progressArea) progressArea.style.display = 'none'; + } + if (btn) { btn.disabled = false; btn.textContent = 'Sync to Server'; } +} + +// ── Metadata Cache Modal ──────────────────────────────────────────── +let _mcacheCurrentTab = 'artist'; +let _mcachePage = 0; +let _mcacheSearchTimeout = null; +// ================================================================================== +// DOWNLOAD BLACKLIST VIEWER +// ================================================================================== + +async function loadBlacklistCount() { + try { + const res = await fetch('/api/library/blacklist'); + const data = await res.json(); + const el = document.getElementById('blacklist-count'); + if (el) el.textContent = data.entries?.length || 0; + } catch (e) { /* ignore */ } +} + +async function openBlacklistModal() { + const existing = document.getElementById('blacklist-modal-overlay'); + if (existing) existing.remove(); + + const overlay = document.createElement('div'); + overlay.id = 'blacklist-modal-overlay'; + overlay.className = 'redownload-overlay'; + overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; + + overlay.innerHTML = ` +
+
+

Download Blacklist

+ +
+
+
Loading...
+
+
+ `; + + document.body.appendChild(overlay); + + const escH = e => { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escH); } }; + document.addEventListener('keydown', escH); + + try { + const res = await fetch('/api/library/blacklist'); + const data = await res.json(); + const body = document.getElementById('blacklist-modal-body'); + + if (!data.success || !data.entries || data.entries.length === 0) { + body.innerHTML = '
No blocked sources. Sources can be blacklisted from the Source Info (ℹ) button on tracks in the enhanced library view.
'; + return; + } + + const serviceIcons = { soulseek: '🔍', youtube: '▶️', tidal: '🌊', qobuz: '🎵', hifi: '🎧', deezer_dl: '💜' }; + + body.innerHTML = data.entries.map(e => { + const displayFile = (e.blocked_filename || '').replace(/\\/g, '/').split('/').pop() || 'Unknown'; + const svc = e.blocked_username && ['youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'].includes(e.blocked_username) ? e.blocked_username : 'soulseek'; + const icon = serviceIcons[svc] || '🔍'; + const ago = e.created_at ? timeAgo(e.created_at) : ''; + return ` +
+
${icon}
+ + + +
`; + }).join(''); + + } catch (e) { + document.getElementById('blacklist-modal-body').innerHTML = `
Error: ${e.message}
`; + } +} + +async function _removeBlacklistEntry(id, btn) { + if (!await showConfirmDialog({ title: 'Remove from Blacklist', message: 'Allow this source to be used for downloads again?', confirmText: 'Remove' })) return; + try { + const res = await fetch(`/api/library/blacklist/${id}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + btn.closest('.blacklist-entry').remove(); + showToast('Removed from blacklist', 'success'); + loadBlacklistCount(); + } + } catch (e) { + showToast('Error: ' + e.message, 'error'); + } +} + +const MCACHE_PAGE_SIZE = 48; + +function openMetadataCacheModal() { + const modal = document.getElementById('mcache-browse-modal'); + if (modal) { + modal.style.display = 'flex'; + _mcacheCurrentTab = 'artist'; + _mcachePage = 0; + // Reset UI + document.querySelectorAll('.mcache-tab').forEach(t => t.classList.remove('active')); + document.querySelector('.mcache-tab[data-tab="artist"]')?.classList.add('active'); + const searchInput = document.getElementById('mcache-search'); + if (searchInput) searchInput.value = ''; + const sourceFilter = document.getElementById('mcache-source-filter'); + if (sourceFilter) sourceFilter.value = ''; + const sortFilter = document.getElementById('mcache-sort-filter'); + if (sortFilter) sortFilter.value = 'last_accessed_at'; + loadMetadataCacheBrowseStats(); + loadMetadataCacheBrowse(); + } +} + +function closeMetadataCacheModal() { + const modal = document.getElementById('mcache-browse-modal'); + if (modal) modal.style.display = 'none'; +} + +async function loadMetadataCacheBrowseStats() { + try { + const response = await fetch('/api/metadata-cache/stats'); + if (!response.ok) return; + const stats = await response.json(); + + const el = (id, val) => { + const e = document.getElementById(id); + if (e) e.textContent = val; + }; + + const spotifyTotal = (stats.artists?.spotify || 0) + (stats.albums?.spotify || 0) + (stats.tracks?.spotify || 0); + const itunesTotal = (stats.artists?.itunes || 0) + (stats.albums?.itunes || 0) + (stats.tracks?.itunes || 0); + const deezerTotal = (stats.artists?.deezer || 0) + (stats.albums?.deezer || 0) + (stats.tracks?.deezer || 0); + const beatportTotal = (stats.artists?.beatport || 0) + (stats.albums?.beatport || 0) + (stats.tracks?.beatport || 0); + el('mcache-browse-spotify-count', spotifyTotal); + el('mcache-browse-itunes-count', itunesTotal); + el('mcache-browse-deezer-count', deezerTotal); + el('mcache-browse-beatport-count', beatportTotal); + const discogsTotal = (stats.artists?.discogs || 0) + (stats.albums?.discogs || 0) + (stats.tracks?.discogs || 0); + el('mcache-browse-discogs-count', discogsTotal); + el('mcache-browse-musicbrainz-count', stats.musicbrainz_total || 0); + el('mcache-browse-hits', stats.total_hits || 0); + el('mcache-browse-searches', stats.searches || 0); + } catch (e) { /* ignore */ } +} + +function switchMetadataCacheTab(tab) { + _mcacheCurrentTab = tab; + _mcachePage = 0; + document.querySelectorAll('.mcache-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + loadMetadataCacheBrowse(); +} + +async function loadMetadataCacheBrowse() { + const grid = document.getElementById('mcache-grid'); + if (!grid) return; + + const source = document.getElementById('mcache-source-filter')?.value || ''; + const search = document.getElementById('mcache-search')?.value || ''; + const sort = document.getElementById('mcache-sort-filter')?.value || 'last_accessed_at'; + + grid.innerHTML = '
...
Loading...
'; + + try { + let data; + if (source === 'musicbrainz') { + // MusicBrainz is a separate cache table — use dedicated endpoint + const params = new URLSearchParams({ + entity_type: _mcacheCurrentTab, + page: _mcachePage + 1, + limit: MCACHE_PAGE_SIZE + }); + if (search) params.set('search', search); + const response = await fetch(`/api/metadata-cache/browse-musicbrainz?${params}`); + if (!response.ok) throw new Error('Failed to load'); + data = await response.json(); + } else { + const params = new URLSearchParams({ + type: _mcacheCurrentTab, + sort: sort, + sort_dir: sort === 'name' ? 'asc' : 'desc', + offset: _mcachePage * MCACHE_PAGE_SIZE, + limit: MCACHE_PAGE_SIZE + }); + if (source) params.set('source', source); + if (search) params.set('search', search); + const response = await fetch(`/api/metadata-cache/browse?${params}`); + if (!response.ok) throw new Error('Failed to load'); + data = await response.json(); + } + + if (!data.items || data.items.length === 0) { + grid.innerHTML = ` +
+
📦
+
No cached ${_mcacheCurrentTab}s yet
+
As you search and browse music in SoulSync, API responses will be cached here automatically.
+
`; + renderMetadataCachePagination(0, 0); + return; + } + + renderMetadataCacheGrid(data.items, _mcacheCurrentTab); + renderMetadataCachePagination(data.total, data.offset); + } catch (e) { + grid.innerHTML = '
Failed to load cache data.
'; + } +} + +function renderMetadataCacheGrid(items, entityType) { + const grid = document.getElementById('mcache-grid'); + if (!grid) return; + + grid.innerHTML = items.map(item => { + const source = item.source || 'spotify'; + const sourceBadge = `${source}`; + const cacheAge = formatCacheAge(item.last_accessed_at); + const hits = item.access_count || 1; + + let imageHtml = ''; + const isArtist = entityType === 'artist'; + const shapeClass = isArtist ? ' artist' : ''; + + if (item.image_url) { + imageHtml = ``; + } else { + imageHtml = `
${(item.name || '?')[0].toUpperCase()}
`; + } + + let subText = ''; + let metaText = ''; + + if (source === 'musicbrainz') { + subText = item.artist_name || ''; + metaText = item._mb_matched ? `MBID: ${(item._mb_id || '').substring(0, 8)}…` : 'No match found'; + } else if (entityType === 'artist') { + const genres = item.genres ? (typeof item.genres === 'string' ? JSON.parse(item.genres || '[]') : item.genres) : []; + subText = genres.length > 0 ? genres.slice(0, 2).join(', ') : ''; + if (item.popularity) metaText = `Pop: ${item.popularity}`; + } else if (entityType === 'album') { + subText = item.artist_name || ''; + const parts = []; + if (item.release_date) parts.push(item.release_date.substring(0, 4)); + if (item.total_tracks) parts.push(`${item.total_tracks} tracks`); + if (item.album_type) parts.push(item.album_type); + metaText = parts.join(' · '); + } else if (entityType === 'track') { + subText = item.artist_name || ''; + const parts = []; + if (item.album_name) parts.push(item.album_name); + if (item.duration_ms) parts.push(formatDuration(item.duration_ms)); + metaText = parts.join(' · '); + } + + const clickAttr = source === 'musicbrainz' ? '' : `onclick="openMetadataCacheDetail('${source}', '${entityType}', '${encodeURIComponent(item.entity_id)}')"`; + const mbStatusClass = source === 'musicbrainz' ? (item._mb_matched ? ' mb-matched' : ' mb-failed') : ''; + + return ` +
+
+ ${imageHtml} +
+
${item.name || 'Unknown'}
+ ${subText ? `
${subText}
` : ''} + ${metaText ? `
${metaText}
` : ''} +
+
+
+ ${sourceBadge} + ${cacheAge} · ${hits}x +
+
`; + }).join(''); +} + +function renderMetadataCachePagination(total, offset) { + const container = document.getElementById('mcache-pagination'); + if (!container) return; + + const totalPages = Math.ceil(total / MCACHE_PAGE_SIZE); + const currentPage = Math.floor(offset / MCACHE_PAGE_SIZE); + + if (totalPages <= 1) { + container.innerHTML = total > 0 ? `${total} result${total !== 1 ? 's' : ''}` : ''; + return; + } + + let html = ''; + html += ``; + + const maxVisible = 7; + let start = Math.max(0, currentPage - Math.floor(maxVisible / 2)); + let end = Math.min(totalPages, start + maxVisible); + if (end - start < maxVisible) start = Math.max(0, end - maxVisible); + + if (start > 0) { + html += ``; + if (start > 1) html += `...`; + } + + for (let i = start; i < end; i++) { + html += ``; + } + + if (end < totalPages) { + if (end < totalPages - 1) html += `...`; + html += ``; + } + + html += ``; + html += `${total} total`; + + container.innerHTML = html; +} + +async function openMetadataCacheDetail(source, entityType, entityId) { + const modal = document.getElementById('mcache-detail-modal'); + const body = document.getElementById('mcache-detail-body'); + const title = document.getElementById('mcache-detail-title'); + if (!modal || !body) return; + + modal.style.display = 'flex'; + body.innerHTML = '
Loading...
'; + if (title) title.textContent = 'Loading...'; + + try { + const response = await fetch(`/api/metadata-cache/entity/${source}/${entityType}/${entityId}`); + if (!response.ok) throw new Error('Not found'); + const data = await response.json(); + + if (title) title.textContent = data.name || 'Unknown'; + + const isArtist = entityType === 'artist'; + const shapeClass = isArtist ? ' artist' : ''; + let imageHtml = ''; + if (data.image_url) { + imageHtml = ``; + } else { + imageHtml = `
${(data.name || '?')[0].toUpperCase()}
`; + } + + const sourceBadge = `${source}`; + const typeBadge = `${entityType}`; + + // Build structured fields table + let fieldsHtml = ''; + const addRow = (label, value) => { + if (value !== null && value !== undefined && value !== '') { + fieldsHtml += ``; + } + }; + + addRow('Entity ID', data.entity_id); + addRow('Name', data.name); + + if (entityType === 'artist') { + const genres = data.genres ? (typeof data.genres === 'string' ? JSON.parse(data.genres || '[]') : data.genres) : []; + if (genres.length) addRow('Genres', genres.join(', ')); + if (data.popularity) addRow('Popularity', data.popularity); + if (data.followers) addRow('Followers', data.followers.toLocaleString()); + } else if (entityType === 'album') { + addRow('Artist', data.artist_name); + addRow('Release Date', data.release_date); + addRow('Total Tracks', data.total_tracks); + addRow('Album Type', data.album_type); + addRow('Label', data.label); + } else if (entityType === 'track') { + addRow('Artist', data.artist_name); + addRow('Album', data.album_name); + if (data.duration_ms) addRow('Duration', formatDuration(data.duration_ms)); + addRow('Track Number', data.track_number); + addRow('Disc Number', data.disc_number); + addRow('Explicit', data.explicit ? 'Yes' : 'No'); + addRow('ISRC', data.isrc); + if (data.preview_url) addRow('Preview', `Listen`); + } + + fieldsHtml += '
${label}${value}
'; + + // Cache metadata section + let cacheHtml = '
Cache Metadata
'; + cacheHtml += ''; + if (data.created_at) cacheHtml += ``; + if (data.last_accessed_at) cacheHtml += ``; + if (data.access_count) cacheHtml += ``; + if (data.ttl_days) cacheHtml += ``; + cacheHtml += '
Cached At${new Date(data.created_at).toLocaleString()}
Last Accessed${new Date(data.last_accessed_at).toLocaleString()}
Access Count${data.access_count}
TTL${data.ttl_days} days
'; + + // Raw JSON section + let rawJsonHtml = ''; + if (data.raw_json) { + const rawStr = typeof data.raw_json === 'string' ? data.raw_json : JSON.stringify(data.raw_json, null, 2); + const escapedJson = rawStr.replace(/&/g, '&').replace(//g, '>'); + rawJsonHtml = ` +
Raw API Response
+ + `; + } + + body.innerHTML = ` +
+ ${imageHtml} +
+
${data.name || 'Unknown'}
+ ${entityType !== 'artist' && data.artist_name ? `
${data.artist_name}
` : ''} +
+ ${sourceBadge} + ${typeBadge} +
+
+
+
Details
+ ${fieldsHtml} + ${cacheHtml} + ${rawJsonHtml}`; + } catch (e) { + body.innerHTML = '
Failed to load entity details.
'; + } +} + +function closeMetadataCacheDetail() { + const modal = document.getElementById('mcache-detail-modal'); + if (modal) modal.style.display = 'none'; +} + +function toggleMcacheClearDropdown(event) { + event.stopPropagation(); + const menu = document.getElementById('mcache-clear-dropdown-menu'); + if (!menu) return; + const isOpen = menu.style.display === 'block'; + menu.style.display = isOpen ? 'none' : 'block'; + if (!isOpen) { + const closeHandler = (e) => { + if (!e.target.closest('#mcache-clear-dropdown')) { + menu.style.display = 'none'; + document.removeEventListener('click', closeHandler); + } + }; + setTimeout(() => document.addEventListener('click', closeHandler), 0); + } +} + +async function clearMetadataCache() { + if (!confirm('Clear ALL cached metadata? This removes all cached API responses.')) return; + document.getElementById('mcache-clear-dropdown-menu').style.display = 'none'; + + try { + const response = await fetch('/api/metadata-cache/clear', { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + showToast(`Cleared ${data.cleared} cached entries`, 'success'); + loadMetadataCacheBrowseStats(); + loadMetadataCacheBrowse(); + loadMetadataCacheStats(); + } else { + showToast('Failed to clear cache', 'error'); + } + } catch (e) { + showToast('Error clearing cache', 'error'); + } +} + +async function clearMetadataCacheBySource(source) { + if (!confirm(`Clear all ${source} cached metadata?`)) return; + document.getElementById('mcache-clear-dropdown-menu').style.display = 'none'; + + try { + const response = await fetch(`/api/metadata-cache/clear?source=${source}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + showToast(`Cleared ${data.cleared} ${source} cache entries`, 'success'); + loadMetadataCacheBrowseStats(); + loadMetadataCacheBrowse(); + loadMetadataCacheStats(); + } else { + showToast(`Failed to clear ${source} cache`, 'error'); + } + } catch (e) { + showToast(`Error clearing ${source} cache`, 'error'); + } +} + +async function clearMusicBrainzCache(failedOnly = false) { + const label = failedOnly ? 'failed MusicBrainz lookups' : 'ALL MusicBrainz cache entries'; + if (!confirm(`Clear ${label}?`)) return; + document.getElementById('mcache-clear-dropdown-menu').style.display = 'none'; + + try { + const url = failedOnly ? '/api/metadata-cache/clear-musicbrainz?failed_only=true' : '/api/metadata-cache/clear-musicbrainz'; + const response = await fetch(url, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + showToast(`Cleared ${data.cleared} MusicBrainz cache entries`, 'success'); + loadMetadataCacheBrowseStats(); + loadMetadataCacheBrowse(); + loadMetadataCacheStats(); + } else { + showToast('Failed to clear MusicBrainz cache', 'error'); + } + } catch (e) { + showToast('Error clearing MusicBrainz cache', 'error'); + } +} + +function debouncedMetadataCacheSearch() { + if (_mcacheSearchTimeout) clearTimeout(_mcacheSearchTimeout); + _mcacheSearchTimeout = setTimeout(() => { + _mcachePage = 0; + loadMetadataCacheBrowse(); + }, 400); +} + +function formatCacheAge(timestamp) { + if (!timestamp) return '—'; + const now = new Date(); + const then = new Date(timestamp); + const diffMs = now - then; + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return 'now'; + if (diffMin < 60) return `${diffMin}m`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h`; + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d`; + return `${Math.floor(diffDays / 30)}mo`; +} + +// ============================================ +// == TOOL HELP MODAL == +// ============================================ + +const TOOL_HELP_CONTENT = { + 'db-updater': { + title: 'Database Updater', + content: ` +

What does this tool do?

+

The Database Updater syncs your media server library (Plex, Jellyfin, or Navidrome) with SoulSync's internal database.

+ +

Update Modes

+
    +
  • Incremental Update: Only scans for new artists, albums, and tracks that have been added since the last update. Fast and efficient for regular updates.
  • +
  • Full Refresh: Completely rebuilds the database from scratch. Use this if you've made significant changes to your library or if data seems out of sync.
  • +
+ +

When to use it?

+
    +
  • After adding new music to your media server
  • +
  • When library statistics seem incorrect
  • +
  • After changing media server settings
  • +
+ +

Progress Persistence

+

The update runs in the background. You can close this page and return later - progress will be preserved and continue where it left off.

+ ` + }, + 'metadata-updater': { + title: 'Metadata Updater', + content: ` +

What does this tool do?

+

The Metadata Updater triggers all enrichment workers simultaneously, re-checking every item in your library against all connected services (Spotify, MusicBrainz, iTunes, Deezer, AudioDB, Last.fm, Genius, Tidal, Qobuz).

+ +

Refresh Interval Options

+
    +
  • 6 months: Only updates metadata for artists not updated in the last 180 days
  • +
  • 3 months: Updates metadata for artists not updated in the last 90 days
  • +
  • 1 month: Updates metadata for artists not updated in the last 30 days
  • +
  • Force All: Updates all artists regardless of when they were last updated
  • +
+ +

What gets updated?

+
    +
  • Artist profile photos, genres, and descriptions
  • +
  • Album cover artwork, labels, and release info
  • +
  • Track ISRCs, explicit flags, and external IDs
  • +
  • Service match status for all 9 enrichment workers
  • +
+ +

Note

+

Available for Plex and Jellyfin media servers. Each enrichment worker only runs if its service is authenticated.

+ ` + }, + 'quality-scanner': { + title: 'Quality Scanner', + content: ` +

What does this tool do?

+

The Quality Scanner identifies tracks in your library that don't meet your preferred quality settings and automatically matches them to Spotify to add to your wishlist for re-downloading.

+ +

Scan Scope

+
    +
  • Watchlist Artists Only: Only scans tracks from artists you're watching. Faster and more focused.
  • +
  • All Library Tracks: Scans your entire music library. Comprehensive but takes longer.
  • +
+ +

How it works

+
    +
  1. Scans tracks and checks file format against your quality preferences
  2. +
  3. Identifies tracks below your quality threshold (e.g., MP3 when you prefer FLAC)
  4. +
  5. Uses fuzzy matching to find the track on Spotify (70% confidence minimum)
  6. +
  7. Automatically adds matched tracks to your wishlist for re-download
  8. +
+ +

Quality Tiers

+
    +
  • Tier 1 (Best): FLAC, WAV, ALAC, AIFF - Lossless formats
  • +
  • Tier 2: OPUS, OGG - High quality lossy
  • +
  • Tier 3: M4A, AAC - Standard lossy
  • +
  • Tier 4: MP3, WMA - Lower quality lossy
  • +
+ +

Stats Explained

+
    +
  • Processed: Total tracks scanned so far
  • +
  • Quality Met: Tracks that meet your quality standards
  • +
  • Low Quality: Tracks below your quality threshold
  • +
  • Matched: Low quality tracks successfully matched to Spotify and added to wishlist
  • +
+ ` + }, + 'duplicate-cleaner': { + title: 'Duplicate Cleaner', + content: ` +

What does this tool do?

+

The Duplicate Cleaner scans your output folder for duplicate audio files and automatically removes lower-quality versions, keeping only the best copy.

+ +

How it detects duplicates

+

Files are considered duplicates when:

+
    +
  • They are in the same folder
  • +
  • They have the exact same filename (ignoring file extension)
  • +
+

Example: Song.flac and Song.mp3 in the same folder = duplicates ✓

+

Example: Song.flac and Song (Remaster).flac = NOT duplicates ✗

+ +

Which file is kept?

+

Priority order (best to worst):

+
    +
  1. Format priority: FLAC/Lossless > OPUS/OGG > M4A/AAC > MP3/WMA
  2. +
  3. If same format: Larger file size is kept (usually indicates better bitrate)
  4. +
+ +

Where do deleted files go?

+

Removed files are moved to Transfer/deleted/ folder (not permanently deleted). You can review and recover them if needed.

+ +

Safety Features

+
    +
  • Only processes audio files (FLAC, MP3, M4A, etc.)
  • +
  • Only removes files with identical names in the same folder
  • +
  • Files are moved, not deleted - fully recoverable
  • +
  • Preserves original folder structure in the deleted folder
  • +
+ +

Stats Explained

+
    +
  • Files Scanned: Total audio files checked
  • +
  • Duplicates Found: Number of duplicate files detected
  • +
  • Deleted: Files moved to deleted folder
  • +
  • Space Freed: Total disk space reclaimed
  • +
+ ` + }, + 'media-scan': { + title: 'Media Server Scan', + content: ` +

What does this tool do?

+

The Media Server Scan tool manually triggers a Plex media library scan to detect newly downloaded music files.

+ +

When to use it?

+
    +
  • After downloading new tracks to refresh your Plex library
  • +
  • When new music isn't showing up in Plex
  • +
  • To force an immediate library update instead of waiting for auto-scan
  • +
+ +

What happens when you scan?

+
    +
  1. Plex library scan: Plex scans your music folder for new/changed files
  2. +
  3. Automatic database update: After the scan completes, SoulSync automatically updates its internal database with new tracks
  4. +
  5. Library refreshed: New music appears in Plex and SoulSync within moments
  6. +
+ +

Plex only?

+

Yes! This tool only appears when Plex is your active media server because:

+
    +
  • Jellyfin automatically detects new files instantly (real-time monitoring)
  • +
  • Navidrome automatically detects new files instantly (real-time monitoring)
  • +
  • Plex requires manual scans or has delayed auto-scanning
  • +
+ +

Stats Explained

+
    +
  • Last Scan: Time of the most recent scan request
  • +
  • Status: Current scan state (Idle, Scanning, Error)
  • +
+ +

Scan workflow

+

This tool replicates the same scan process that runs automatically after completing a download modal - ensuring your new tracks are immediately available in your library!

+ ` + }, + 'retag-tool': { + title: 'Retag Tool', + content: ` +

What does this tool do?

+

The Retag Tool lets you fix metadata on files that have already been downloaded and processed. If an album was tagged with wrong metadata, you can search for the correct match and re-apply tags.

+ +

How it works

+
    +
  • Browse your past downloads organized by artist
  • +
  • Expand an album or single to see individual tracks
  • +
  • Click Retag to search for the correct album match
  • +
  • Select the right album and confirm — metadata and file paths are updated automatically
  • +
+ +

What gets updated?

+
    +
  • File tags: Title, artist, album, track number, genre, cover art
  • +
  • File paths: Files are moved/renamed to match new metadata (based on your path template)
  • +
  • Cover art: cover.jpg is updated in the album folder
  • +
+ +

Stats Explained

+
    +
  • Groups: Number of album/single download groups tracked
  • +
  • Tracks: Total individual track files tracked
  • +
  • Artists: Number of unique artists across all groups
  • +
+ +

Notes

+
    +
  • Only album and single downloads are tracked (not playlists)
  • +
  • Deleting a group from the list does not delete the files
  • +
  • Only one retag operation can run at a time
  • +
+ ` + }, + 'discover-page': { + title: 'Discover Page Guide', + content: ` +

What is the Discover page?

+

The Discover page is your personalized music discovery hub. It uses your watchlist, library listening history, and MusicMap to surface new music, create curated playlists, and organize your collection in dynamic ways.

+ +

🎯 Hero Section (Featured Artists)

+

The rotating hero showcases similar artists discovered via MusicMap. These are artists you don't already have in your library but might enjoy based on your watchlist.

+
    +
  • Auto-rotates every 8 seconds through 10 featured artists
  • +
  • Similar artists sourced from MusicMap and matched to Spotify
  • +
  • Click arrows to navigate manually
  • +
  • Add artists to watchlist or view their full discography
  • +
  • Data refreshed when watchlist scanner runs
  • +
+ +

📀 Recent Releases

+

New albums from artists you're watching and their MusicMap similar artists. Cached from Spotify and updated during watchlist scans.

+
    +
  • Shows up to 20 recent albums
  • +
  • Click any album to view tracks and add to wishlist
  • +
  • Automatically filtered to show albums released in the last 90 days
  • +
  • Includes both watchlist artists and similar artists from MusicMap
  • +
+ +

🍂 Seasonal Content (Auto-detected)

+

Seasonal albums and playlists that appear automatically based on the current season (Winter, Spring, Summer, Fall).

+
    +
  • Seasonal Albums: Albums matching the current season's vibe
  • +
  • Seasonal Playlist: Curated playlist that refreshes with each season
  • +
  • Only visible during the matching season
  • +
  • Can download and sync to your media server
  • +
+ +

🎵 Fresh Tape (Release Radar)

+

Curated playlist of brand new releases from your discovery pool. Focuses on tracks released in the past 30 days.

+
    +
  • 50 tracks, refreshed weekly by watchlist scanner
  • +
  • Stays consistent until next update (not random)
  • +
  • Download missing tracks or sync to media server
  • +
  • Tracks from watchlist artists and MusicMap similar artists
  • +
+ +

📚 The Archives (Discovery Weekly)

+

Curated playlist from your entire discovery pool - a mix of new and catalog tracks from MusicMap discoveries.

+
    +
  • 50 tracks, refreshed weekly by watchlist scanner
  • +
  • Stays consistent until next update (not random)
  • +
  • Broader selection than Fresh Tape (includes older releases)
  • +
  • Download missing tracks or sync to media server
  • +
+ +

📊 Personalized Library Playlists

+

Playlists generated from your existing library using listening statistics:

+
    +
  • Recently Added: Latest 50 tracks added to your library
  • +
  • Your Top 50: All-time most played tracks (requires play count data)
  • +
  • Forgotten Favorites: Tracks you loved but haven't played recently
  • +
+ +

🎲 Discovery Pool Playlists

+

Playlists generated from your discovery pool (tracks from watchlist/similar artists you don't own yet):

+
    +
  • Popular Picks: High-popularity tracks (Spotify popularity 70+)
  • +
  • Hidden Gems: Underground discoveries (Spotify popularity <40)
  • +
  • Discovery Shuffle: 50 random tracks - different every time you load
  • +
  • Familiar Favorites: Reliable, mid-popularity tracks (40-70)
  • +
+ +

🎨 Build a Playlist

+

Create custom playlists using MusicMap similar artists from seed artists you select.

+
    +
  • Search for any artist on Spotify (even if not in your library)
  • +
  • Select 1-5 seed artists
  • +
  • Choose playlist size: 25, 50, 75, or 100 tracks
  • +
  • Uses cached MusicMap similar artists from your database
  • +
  • Pulls albums from those similar artists to build the playlist
  • +
  • Download and sync like any other discover playlist
  • +
+ +

🧠 ListenBrainz Playlists

+

Access playlists from your ListenBrainz account (requires ListenBrainz authentication).

+
    +
  • Created For You: Playlists generated by ListenBrainz for you
  • +
  • Your Playlists: Playlists you've created on ListenBrainz
  • +
  • Collaborative: Collaborative playlists you're part of
  • +
  • Cached locally for performance - click Refresh to update from ListenBrainz
  • +
  • Click any playlist to view tracks and download/sync
  • +
+ +

⏰ Time Machine (Browse by Decade)

+

Explore your discovery pool organized by release decade.

+
    +
  • Dynamically generated tabs for decades with available content (1950s-2020s)
  • +
  • Each decade shows up to 100 tracks from that era
  • +
  • Great for discovering older catalog releases from your favorite artists
  • +
+ +

🎵 Browse by Genre

+

Explore your discovery pool filtered by music genre.

+
    +
  • Shows top genres from your discovery pool
  • +
  • Click any genre tab to see up to 100 tracks in that genre
  • +
  • Genres sourced from Spotify metadata
  • +
+ +

💾 What is the Discovery Pool?

+

The discovery pool is a database of tracks from:

+
    +
  • Artists in your watchlist
  • +
  • Similar artists found via MusicMap
  • +
  • Populated during watchlist scanner runs (scrapes music-map.com, matches to Spotify)
  • +
  • Filtered to exclude tracks already in your library
  • +
  • Used to generate Fresh Tape, The Archives, and discovery pool playlists
  • +
  • Caches up to 50 top similar artists across your watchlist
  • +
+ +

🗺️ How MusicMap Integration Works

+

SoulSync uses MusicMap (music-map.com) instead of Spotify's recommendation API to find similar artists:

+
    +
  • During watchlist scans, each watchlist artist is looked up on MusicMap
  • +
  • MusicMap's artist similarity graph is scraped to find related artists
  • +
  • Similar artist names are matched to Spotify IDs
  • +
  • Up to 10 similar artists per watchlist artist are cached (refreshed every 30 days)
  • +
  • These cached similar artists power all discovery features
  • +
  • This approach gives you more diverse, community-driven recommendations
  • +
+ +

⬇️ Download & Sync Features

+

Most discover playlists support two actions:

+
    +
  • Download: Opens modal to match tracks to Soulseek and add to download queue
  • +
  • Sync: Downloads tracks and automatically transfers them to your media server
  • +
  • Sync progress persists - you can close the page and it continues in the background
  • +
  • Sync status shows: ✓ completed, ⏳ pending, ✗ failed
  • +
+ +

🔄 When is data refreshed?

+
    +
  • MusicMap Similar Artists: Fetched during watchlist scans, cached for 30 days
  • +
  • Hero, Recent Releases, Fresh Tape, The Archives: Updated during watchlist scanner runs (Dashboard page)
  • +
  • Discovery Pool: Fully refreshed every 24 hours during watchlist scans (50 top similar artists, 10 albums each)
  • +
  • Seasonal Content: Auto-detected based on current date
  • +
  • Personalized Library Playlists: Generated on-demand from current library data
  • +
  • Discovery Pool Playlists: Generated on-demand from current discovery pool
  • +
  • Build a Playlist: Generated on-demand from cached MusicMap similar artists
  • +
  • ListenBrainz: Cached locally, manually refreshed via Refresh button
  • +
  • Time Machine & Genre: Generated on-demand from current discovery pool
  • +
+ +

💡 Pro Tips

+
    +
  • Curated playlists (Fresh Tape, The Archives) stay consistent until next watchlist scan - great for weekly listening routines
  • +
  • Discovery Shuffle changes every page load - perfect when you want spontaneous recommendations
  • +
  • Use Build a Playlist to explore artists not in your watchlist (if seed artist isn't in watchlist, MusicMap data must be cached first)
  • +
  • The discovery pool only includes tracks you don't own yet - download them to build your collection!
  • +
  • Sync feature is ideal for batch downloading entire playlists to your media server
  • +
  • MusicMap provides more diverse recommendations than Spotify's algorithm - expect deeper cuts and underground artists!
  • +
  • Add more artists to your watchlist to expand your discovery pool with their MusicMap similar artists
  • +
+ ` + }, + + // ==================== Automation Trigger Help ==================== + + 'auto-schedule': { + title: 'Schedule Timer', + content: ` +

What is this trigger?

+

Runs your automation on a repeating interval — every X minutes, hours, or days.

+ +

Configuration

+
    +
  • Interval: How often to repeat (e.g. every 6 hours)
  • +
  • Unit: Minutes, Hours, or Days
  • +
+ +

When does it first run?

+

The timer starts when SoulSync boots. If the automation was previously scheduled, it resumes from where it left off.

+ +

Good for

+
    +
  • Regular wishlist processing (every 30 minutes)
  • +
  • Periodic database backups (every 12 hours)
  • +
  • Any recurring maintenance task
  • +
+ ` + }, + 'auto-daily_time': { + title: 'Daily Time', + content: ` +

What is this trigger?

+

Runs your automation once per day at a specific time.

+ +

Configuration

+
    +
  • Time: The wall-clock time to run (e.g. 03:00 for 3 AM)
  • +
+ +

Good for

+
    +
  • Nightly watchlist scans
  • +
  • Off-peak database updates
  • +
  • Daily backups at a consistent time
  • +
+ ` + }, + 'auto-weekly_time': { + title: 'Weekly Schedule', + content: ` +

What is this trigger?

+

Runs your automation on specific days of the week at a set time.

+ +

Configuration

+
    +
  • Days: Select one or more days (Mon–Sun)
  • +
  • Time: The time to run on those days
  • +
+ +

Good for

+
    +
  • Weekend-only quality scans
  • +
  • Weekly playlist refreshes
  • +
  • Scheduled maintenance on quiet days
  • +
+ ` + }, + 'auto-app_started': { + title: 'App Started', + content: ` +

What is this trigger?

+

Fires once when SoulSync starts up. Useful for tasks you want to run on every boot.

+ +

Good for

+
    +
  • Refreshing mirrored playlists on startup
  • +
  • Running a quick database sync
  • +
  • Sending a "SoulSync is online" notification
  • +
+ +

Note

+

This trigger fires only once per startup — it will not fire again until SoulSync is restarted.

+ ` + }, + 'auto-track_downloaded': { + title: 'Track Downloaded', + content: ` +

What is this trigger?

+

Fires every time a single track finishes downloading and post-processing (tagging, moving to library).

+ +

Conditions

+

You can filter which downloads trigger this automation:

+
    +
  • Artist: Only fire for specific artists
  • +
  • Title: Match on track title
  • +
  • Album: Match on album name
  • +
  • Quality: Match on file format (FLAC, MP3, etc.)
  • +
+ +

Available variables for notifications

+

{artist}, {title}, {album}, {quality}

+ +

Note

+

This fires per-track, not per-album. For an album with 12 tracks, it fires 12 times. Use Batch Complete if you want one event per album.

+ ` + }, + 'auto-batch_complete': { + title: 'Batch Complete', + content: ` +

What is this trigger?

+

Fires when an entire album or playlist download batch finishes — all tracks in the batch are done (whether successful or failed).

+ +

Conditions

+
    +
  • Playlist name: Filter by the name of the album or playlist
  • +
+ +

Available variables for notifications

+

{playlist_name}, {total_tracks}, {completed_tracks}, {failed_tracks}

+ +

Good for

+
    +
  • Triggering a media server scan after downloads finish
  • +
  • Sending a notification when an album is fully downloaded
  • +
  • Running a database update after new content arrives
  • +
+ ` + }, + 'auto-watchlist_new_release': { + title: 'New Release Found', + content: ` +

What is this trigger?

+

Fires when the watchlist scanner detects new music from an artist you're watching. This means a new album, EP, or single has been released that you don't already have.

+ +

Conditions

+
    +
  • Artist: Only fire for specific watched artists
  • +
+ +

Available variables for notifications

+

{artist}, {new_tracks}, {added_to_wishlist}

+ +

Good for

+
    +
  • Getting notified when your favorite artists drop new music
  • +
  • Auto-processing the wishlist immediately after new releases are found
  • +
+ ` + }, + 'auto-playlist_synced': { + title: 'Playlist Synced', + content: ` +

What is this trigger?

+

Fires after a mirrored playlist is synced to your media server (Plex, Jellyfin, or Navidrome). This means the playlist has been matched and created/updated on your server.

+ +

Conditions

+
    +
  • Playlist name: Only fire for specific playlists
  • +
+ +

Available variables for notifications

+

{playlist_name}, {total_tracks}, {matched_tracks}, {synced_tracks}, {failed_tracks}

+ ` + }, + 'auto-playlist_changed': { + title: 'Playlist Changed', + content: ` +

What is this trigger?

+

Fires when a mirrored playlist detects that the source playlist (on Spotify, Tidal, YouTube, etc.) has changed — tracks were added or removed.

+ +

Conditions

+
    +
  • Playlist name: Only fire for specific playlists
  • +
+ +

Available variables for notifications

+

{playlist_name}, {old_count}, {new_count}, {added}, {removed}

+ +

Good for

+
    +
  • Auto-discovering new tracks after a playlist updates
  • +
  • Auto-syncing the playlist to your media server
  • +
  • Getting notified when your followed playlists change
  • +
+ ` + }, + 'auto-discovery_completed': { + title: 'Discovery Complete', + content: ` +

What is this trigger?

+

Fires when Spotify/iTunes metadata discovery finishes for a mirrored playlist. Discovery is the process of matching playlist tracks to official Spotify or iTunes metadata.

+ +

Conditions

+
    +
  • Playlist name: Only fire for specific playlists
  • +
+ +

Available variables for notifications

+

{playlist_name}, {total_tracks}, {discovered_count}, {failed_count}, {skipped_count}

+ +

Good for

+
    +
  • Auto-syncing a playlist after discovery completes
  • +
  • Getting notified about discovery results (how many matched vs failed)
  • +
+ ` + }, + 'auto-wishlist_processing_completed': { + title: 'Wishlist Processed', + content: ` +

What is this trigger?

+

Fires when the auto-wishlist processing batch finishes. This is the automated download cycle that searches Soulseek for wishlist tracks.

+ +

Available variables for notifications

+

{tracks_processed}, {tracks_found}, {tracks_failed}

+ ` + }, + 'auto-watchlist_scan_completed': { + title: 'Watchlist Scan Done', + content: ` +

What is this trigger?

+

Fires when the watchlist artist scan completes. The scan checks all watched artists for new releases and adds new tracks to your wishlist.

+ +

Available variables for notifications

+

{artists_scanned}, {new_tracks_found}, {tracks_added}

+ ` + }, + 'auto-database_update_completed': { + title: 'Database Updated', + content: ` +

What is this trigger?

+

Fires when the library database refresh finishes — either incremental or full. This means SoulSync's internal database has been synced with your media server.

+ +

Available variables for notifications

+

{total_artists}, {total_albums}, {total_tracks}

+ +

Good for

+
    +
  • Running a quality scan after the database is refreshed
  • +
  • Sending a summary notification with library stats
  • +
+ ` + }, + 'auto-download_failed': { + title: 'Download Failed', + content: ` +

What is this trigger?

+

Fires when a track permanently fails to download. This means all retry attempts and sources have been exhausted.

+ +

Conditions

+
    +
  • Artist: Only fire for specific artists
  • +
  • Title: Match on track title
  • +
  • Reason: Match on failure reason
  • +
+ +

Available variables for notifications

+

{artist}, {title}, {reason}

+ ` + }, + 'auto-download_quarantined': { + title: 'File Quarantined', + content: ` +

What is this trigger?

+

Fires when a downloaded file fails AcoustID verification and is moved to the quarantine folder. This means the audio fingerprint didn't match what was expected — the file might be the wrong song.

+ +

Conditions

+
    +
  • Artist: Only fire for specific artists
  • +
  • Title: Match on track title
  • +
+ +

Available variables for notifications

+

{artist}, {title}, {reason}

+ +

What is quarantine?

+

Files that fail audio fingerprint verification are moved to a quarantine folder instead of your library. This prevents wrong songs from polluting your collection. You can review quarantined files manually.

+ ` + }, + 'auto-wishlist_item_added': { + title: 'Wishlist Item Added', + content: ` +

What is this trigger?

+

Fires when a track is added to your wishlist — whether manually, by the quality scanner, or by the watchlist scan.

+ +

Conditions

+
    +
  • Artist: Only fire for specific artists
  • +
  • Title: Match on track title
  • +
+ +

Available variables for notifications

+

{artist}, {title}, {reason}

+ ` + }, + 'auto-watchlist_artist_added': { + title: 'Artist Watched', + content: ` +

What is this trigger?

+

Fires when an artist is added to your watchlist. Watched artists are periodically scanned for new releases.

+ +

Conditions

+
    +
  • Artist: Only fire for specific artists
  • +
+ +

Available variables for notifications

+

{artist}, {artist_id}

+ ` + }, + 'auto-watchlist_artist_removed': { + title: 'Artist Unwatched', + content: ` +

What is this trigger?

+

Fires when an artist is removed from your watchlist.

+ +

Conditions

+
    +
  • Artist: Only fire for specific artists
  • +
+ +

Available variables for notifications

+

{artist}, {artist_id}

+ ` + }, + 'auto-import_completed': { + title: 'Import Complete', + content: ` +

What is this trigger?

+

Fires when an album or track import operation finishes. Imports bring music from external sources into your library.

+ +

Conditions

+
    +
  • Artist: Only fire for specific artists
  • +
  • Album name: Match on album name
  • +
+ +

Available variables for notifications

+

{track_count}, {album_name}, {artist}

+ ` + }, + 'auto-mirrored_playlist_created': { + title: 'Playlist Mirrored', + content: ` +

What is this trigger?

+

Fires when a new playlist mirror is created — a playlist from Spotify, Tidal, YouTube, ListenBrainz, or Beatport is set up for mirroring.

+ +

Conditions

+
    +
  • Playlist name: Match on playlist name
  • +
  • Source: Match on platform (spotify, tidal, youtube, etc.)
  • +
+ +

Available variables for notifications

+

{playlist_name}, {source}, {track_count}

+ +

Good for

+
    +
  • Auto-discovering tracks immediately after a new mirror is created
  • +
  • Getting notified when new playlists are mirrored
  • +
+ ` + }, + 'auto-quality_scan_completed': { + title: 'Quality Scan Done', + content: ` +

What is this trigger?

+

Fires when the quality scanner finishes. The scanner identifies tracks below your quality preferences and adds them to your wishlist for re-downloading.

+ +

Available variables for notifications

+

{quality_met}, {low_quality}, {total_scanned}

+ ` + }, + 'auto-duplicate_scan_completed': { + title: 'Duplicate Scan Done', + content: ` +

What is this trigger?

+

Fires when the duplicate cleaner finishes scanning your output folder for duplicate audio files.

+ +

Available variables for notifications

+

{files_scanned}, {duplicates_found}, {space_freed}

+ ` + }, + 'auto-library_scan_completed': { + title: 'Library Scan Done', + content: ` +

What is this trigger?

+

Fires when a media server library scan is considered complete. This only happens after a Scan Library action was triggered — it cannot fire on its own.

+ +

How does it know the scan is done?

+

Your media server (Plex, Jellyfin, Navidrome) doesn't send a "scan finished" signal back to SoulSync. So after telling the server to scan, SoulSync waits approximately 5 minutes and then assumes the scan has finished. This is a generous estimate that works for most libraries.

+ +

Timing

+

From the moment a download finishes to when this trigger fires, expect roughly 6-7 minutes:

+
    +
  1. 60 second debounce wait (groups multiple downloads together)
  2. +
  3. Media server scan triggered
  4. +
  5. ~5 minute wait (assumed scan completion)
  6. +
  7. This event fires
  8. +
+ +

Default use

+

The system automation Auto-Update Database After Scan listens for this trigger to start an incremental database update, keeping your SoulSync library in sync with your media server.

+ +

Available variables

+

{server_type} — which media server was scanned (plex, jellyfin, navidrome)

+ ` + }, + + // ==================== Automation Action Help ==================== + + 'auto-process_wishlist': { + title: 'Process Wishlist', + content: ` +

What does this action do?

+

Searches Soulseek for tracks in your wishlist and downloads them. This is the same process that runs automatically on a timer — this action lets you trigger it manually or chain it to events.

+ +

Configuration

+
    +
  • Category: Process all wishlist tracks, or only Albums/EPs, or only Singles
  • +
+ +

How it works

+
    +
  1. Picks tracks from the wishlist (alternating Albums and Singles cycles)
  2. +
  3. Searches Soulseek for each track
  4. +
  5. Downloads the best quality match found
  6. +
  7. Tags and moves files to your library
  8. +
+ ` + }, + 'auto-scan_watchlist': { + title: 'Scan Watchlist', + content: ` +

What does this action do?

+

Checks all watched artists for new releases you don't already have. New tracks are automatically added to your wishlist for downloading.

+ +

How it works

+
    +
  1. Goes through each artist in your watchlist
  2. +
  3. Fetches their discography from Spotify
  4. +
  5. Compares against your library to find missing releases
  6. +
  7. Adds new tracks to your wishlist
  8. +
+ ` + }, + 'auto-scan_library': { + title: 'Scan Library', + content: ` +

What does this action do?

+

Tells your media server (Plex, Jellyfin, or Navidrome) to scan its music library folder for new or changed files. This makes newly downloaded music appear in your media server.

+ +

How it works

+
    +
  1. A 60 second debounce groups rapid requests — if multiple downloads finish close together, only one scan is triggered
  2. +
  3. After the debounce, your media server is told to scan
  4. +
  5. SoulSync waits ~5 minutes (your media server doesn't report when it's finished, so this is an assumed completion time)
  6. +
  7. The Library Scan Done event fires, which can trigger follow-up actions like a database update
  8. +
+ +

Default use

+

The system automation Auto-Scan After Downloads uses this action to automatically scan your library when a batch download completes. You can disable that automation if you prefer to scan manually.

+ +

Note

+

Jellyfin and Navidrome often detect new files automatically, but the scan ensures nothing is missed.

+ ` + }, + 'auto-refresh_mirrored': { + title: 'Refresh Mirrored Playlist', + content: ` +

What does this action do?

+

Re-fetches a mirrored playlist from its source platform (Spotify, Tidal, YouTube, etc.) and updates the local mirror with any track changes.

+ +

Configuration

+
    +
  • Playlist: Select a specific mirrored playlist, or check "Refresh all" to update all mirrors
  • +
+ +

Good for

+
    +
  • Keeping mirrors in sync with playlists that change frequently
  • +
  • Detecting added/removed tracks on the source platform
  • +
+ ` + }, + 'auto-sync_playlist': { + title: 'Sync Playlist', + content: ` +

What does this action do?

+

Syncs a mirrored playlist to your media server. It matches discovered tracks against your library and creates or updates the playlist on Plex, Jellyfin, or Navidrome.

+ +

Configuration

+
    +
  • Playlist: Select which mirrored playlist to sync
  • +
+ +

Prerequisites

+

Tracks should be discovered first (matched to Spotify/iTunes metadata) before syncing. Undiscovered tracks will be skipped.

+ ` + }, + 'auto-discover_playlist': { + title: 'Discover Playlist', + content: ` +

What does this action do?

+

Finds official Spotify or iTunes metadata for tracks in a mirrored playlist. This is required before syncing — it matches each track to a known release so it can be found in your library.

+ +

Configuration

+
    +
  • Playlist: Select a specific playlist, or check "Discover all" to process all mirrored playlists
  • +
+ +

How it works

+
    +
  1. Takes each track name and artist from the mirror
  2. +
  3. Searches Spotify (or iTunes as fallback) for a match
  4. +
  5. Stores the best match with confidence score in the discovery cache
  6. +
  7. Already-discovered tracks are skipped for efficiency
  8. +
+ ` + }, + 'auto-playlist_pipeline': { + title: 'Playlist Pipeline', + content: ` +

What does this action do?

+

Runs the full playlist lifecycle in one automation — no signal wiring needed. Executes four phases sequentially:

+
    +
  1. Refresh — Re-fetches playlist tracks from the source platform (Spotify, Tidal, YouTube, Deezer)
  2. +
  3. Discover — Matches each track to official metadata (Spotify/iTunes/Deezer IDs)
  4. +
  5. Sync — Pushes the playlist to your media server (Plex, Jellyfin, Navidrome)
  6. +
  7. Download Missing — Queues unmatched tracks to the wishlist for automatic download
  8. +
+ +

Configuration

+
    +
  • Playlist: Select a specific mirrored playlist, or check "Process all" to run the pipeline for every mirrored playlist
  • +
  • Skip wishlist: Check this to skip the download phase (useful if you only want to sync, not download)
  • +
+ +

How the re-sync loop works

+

Set this on a schedule (e.g., every 6 hours). Between runs, the wishlist processor downloads missing tracks in the background. On the next pipeline run, those newly downloaded tracks will match during the sync phase — so your server playlist gets more complete with each cycle until fully synced.

+ +

Replaces

+

This single automation replaces the 4-automation signal chain pattern (Refresh → signal → Discover → signal → Sync → signal → Download). No signals, no chaining, no room for misconfiguration.

+ ` + }, + 'auto-notify_only': { + title: 'Notify Only', + content: ` +

What does this action do?

+

Nothing — it performs no action. It just passes the event data through to the notification step.

+ +

Good for

+
    +
  • Getting notified about events without taking any automated action
  • +
  • Monitoring what's happening in SoulSync (downloads, failures, changes)
  • +
  • Pair with any event trigger + Discord/Telegram/Pushbullet notification
  • +
+ ` + }, + 'auto-start_database_update': { + title: 'Update Database', + content: ` +

What does this action do?

+

Refreshes SoulSync's internal library database by syncing with your media server (Plex, Jellyfin, or Navidrome).

+ +

Configuration

+
    +
  • Full refresh: When checked, completely rebuilds the database from scratch. When unchecked, only scans for new content (faster).
  • +
+ ` + }, + 'auto-run_duplicate_cleaner': { + title: 'Run Duplicate Cleaner', + content: ` +

What does this action do?

+

Scans your output folder for duplicate audio files (same filename, different format) and removes the lower-quality version. For example, if you have both Song.flac and Song.mp3, the MP3 is removed.

+ +

Safety

+

Removed files are moved to a deleted/ subfolder, not permanently deleted. You can recover them if needed.

+ ` + }, + 'auto-clear_quarantine': { + title: 'Clear Quarantine', + content: ` +

What does this action do?

+

Permanently deletes all files in the quarantine folder. Quarantined files are downloads that failed AcoustID audio fingerprint verification — they might be the wrong song.

+ +

Warning

+

This permanently deletes files. Make sure you've reviewed quarantined files before setting up an automation for this.

+ ` + }, + 'auto-cleanup_wishlist': { + title: 'Clean Up Wishlist', + content: ` +

What does this action do?

+

Removes duplicate entries and tracks you already own from your wishlist. Keeps the wishlist lean by removing items that no longer need downloading.

+ ` + }, + 'auto-update_discovery_pool': { + title: 'Update Discovery Pool', + content: ` +

What does this action do?

+

Refreshes the discovery pool with new tracks from your mirrored playlists. The discovery pool tracks which playlist tracks have been successfully matched and which ones failed.

+ ` + }, + 'auto-start_quality_scan': { + title: 'Run Quality Scan', + content: ` +

What does this action do?

+

Scans your library for tracks that don't meet your quality preferences (e.g., MP3 when you prefer FLAC). Low-quality tracks are matched to Spotify and added to your wishlist for re-downloading in better quality.

+ +

Configuration

+
    +
  • Scope: Scan only watchlist artists (faster) or your entire library (thorough)
  • +
+ ` + }, + 'auto-backup_database': { + title: 'Backup Database', + content: ` +

What does this action do?

+

Creates a timestamped backup of SoulSync's SQLite database. Uses the SQLite backup API for a safe hot-copy while the app is running.

+ +

Retention

+

Keeps the last 5 backups automatically. Older backups are cleaned up to save disk space.

+ +

Good for

+
    +
  • Nightly automated backups
  • +
  • Pre-update safety backups
  • +
  • Peace of mind for your library data
  • +
+ ` + }, + 'auto-refresh_beatport_cache': { + title: 'Refresh Beatport Cache', + content: ` +

What does this action do?

+

Scrapes the Beatport homepage for top charts and caches the results locally. Keeps the Beatport charts page loading instantly without needing to scrape on every visit.

+ +

Cache duration

+

Cache lasts 24 hours. This action refreshes it early so it's always warm when you visit the charts page.

+ +

Good for

+
    +
  • Keeping Beatport charts available instantly
  • +
  • Scheduling daily cache refreshes (e.g. every morning)
  • +
+ ` + }, + 'auto-clean_search_history': { + title: 'Clean Search History', + content: ` +

What does this action do?

+

Removes old search queries from Soulseek. This keeps your search history clean and prevents buildup over time.

+ +

Good for

+
    +
  • Periodic housekeeping
  • +
  • Keeping Soulseek search history tidy
  • +
+ ` + }, + 'auto-clean_completed_downloads': { + title: 'Clean Completed Downloads', + content: ` +

What does this action do?

+

Clears completed downloads from the transfer list and removes any empty directories left behind in the import folder.

+ +

Good for

+
    +
  • Automatic cleanup after batch downloads
  • +
  • Preventing import folder clutter
  • +
  • Chaining after a batch complete trigger
  • +
+ ` + }, + 'auto-full_cleanup': { + title: 'Full Cleanup', + content: ` +

What does this action do?

+

Runs all housekeeping tasks in a single sweep:

+
    +
  1. Clear Quarantine — permanently deletes all quarantined files
  2. +
  3. Clear Download Queue — removes completed, errored, and cancelled downloads from Soulseek
  4. +
  5. Sweep Empty Directories — removes empty folders left behind in the input directory
  6. +
  7. Sweep Import Folder — removes empty directories from the import folder
  8. +
  9. Clean Search History — trims old Soulseek search queries
  10. +
+ +

Safety

+

Skips download queue cleanup if batches are actively downloading or post-processing. Each step runs independently — a failure in one step won't stop the others.

+ +

Good for

+
    +
  • Scheduled housekeeping every 12 hours
  • +
  • Keeping disk usage and queue clutter under control
  • +
  • Running after large batch downloads complete
  • +
+ ` + }, + 'auto-deep_scan_library': { + title: 'Deep Scan Library', + content: ` +

What does this action do?

+

Walks your entire media server library and compares it against SoulSync's database. Adds any new tracks found and removes stale entries that no longer exist on the server.

+ +

How is this different from Database Update?

+
    +
  • Database Update: Incremental — only looks for new artists/albums added since last update
  • +
  • Deep Scan: Full comparison — checks every track on the server against the database, catches anything missed
  • +
+ +

Safety

+
    +
  • Never overwrites existing enrichment data (genres, Spotify IDs, artwork)
  • +
  • Only inserts tracks that don't already exist in the database
  • +
  • Stale track removal has a 50% safety threshold — if more than half the library appears missing, removal is skipped
  • +
+ ` + }, + + // ==================== Notification/Then-Action Help ==================== + + 'auto-discord_webhook': { + title: 'Discord Webhook', + content: ` +

What does this then-action do?

+

Sends a notification to a Discord channel via webhook when the automation's action completes.

+ +

Configuration

+
    +
  • Webhook URL: The Discord webhook URL for your channel (found in Channel Settings → Integrations → Webhooks)
  • +
  • Message Template: Custom message with variable placeholders
  • +
+ +

Available variables

+

Use these in your message template:

+
    +
  • {time} — When the automation ran
  • +
  • {name} — Automation name
  • +
  • {run_count} — How many times this automation has run
  • +
  • {status} — Result status of the action
  • +
+ ` + }, + 'auto-pushbullet': { + title: 'Pushbullet', + content: ` +

What does this then-action do?

+

Sends a push notification to your phone or desktop via Pushbullet when the automation's action completes.

+ +

Configuration

+
    +
  • API Key: Your Pushbullet access token (found in Pushbullet Settings → Access Tokens)
  • +
  • Message Template: Custom message with variable placeholders
  • +
+ +

Available variables

+

Use these in your message template:

+
    +
  • {time} — When the automation ran
  • +
  • {name} — Automation name
  • +
  • {run_count} — How many times this automation has run
  • +
  • {status} — Result status of the action
  • +
+ ` + }, + 'auto-telegram': { + title: 'Telegram', + content: ` +

What does this then-action do?

+

Sends a message to a Telegram chat via bot when the automation's action completes.

+ +

Configuration

+
    +
  • Bot Token: Your Telegram bot token (from @BotFather)
  • +
  • Chat ID: The chat/group ID to send messages to
  • +
  • Message Template: Custom message with variable placeholders
  • +
+ +

Available variables

+

Use these in your message template:

+
    +
  • {time} — When the automation ran
  • +
  • {name} — Automation name
  • +
  • {run_count} — How many times this automation has run
  • +
  • {status} — Result status of the action
  • +
+ ` + }, + + 'auto-webhook': { + title: 'Webhook (POST)', + content: ` +

What does this then-action do?

+

Sends an HTTP POST request with a JSON payload to any URL when the automation's action completes. Use it to integrate with Gotify, Home Assistant, Slack, n8n, or any service that accepts webhooks.

+ +

Configuration

+
    +
  • URL: The endpoint to POST to (e.g. https://gotify.example.com/message?token=xxx)
  • +
  • Headers: Optional custom headers, one per line in Key: Value format. Useful for auth tokens.
  • +
  • Custom Message: Optional message with variable placeholders. Added as a "message" field in the JSON payload.
  • +
+ +

JSON payload

+

The POST body always includes all event variables as JSON fields:

+
{"time": "2026-04-02 ...", "name": "My Automation", "status": "success", ...}
+ +

Available variables

+

Use these in your message or header values:

+
    +
  • {time} — When the automation ran
  • +
  • {name} — Automation name
  • +
  • {run_count} — How many times this automation has run
  • +
  • {status} — Result status of the action
  • +
+ ` + }, + + // ==================== Signal System Help ==================== + + 'auto-signal_received': { + title: 'Signal Received', + content: ` +

What is this trigger?

+

Fires when another automation sends a named signal using the Fire Signal then-action. This lets you chain automations together — one automation finishes and wakes up another.

+ +

Configuration

+
    +
  • Signal Name: The name to listen for (e.g. library_ready, scan_done). Must match the name used in the Fire Signal action.
  • +
+ +

How chaining works

+
    +
  1. Automation A: Trigger = Batch Complete, Action = Scan Library, Then = Fire Signal "scan_done"
  2. +
  3. Automation B: Trigger = Signal Received "scan_done", Action = Update Database
  4. +
  5. When a download finishes → A scans library → fires signal → B wakes up → updates database
  6. +
+ +

Safety

+
    +
  • Circular signal chains are detected and blocked when you save
  • +
  • Maximum chain depth of 5 levels to prevent runaway cascades
  • +
  • Same signal can only fire once every 10 seconds (cooldown)
  • +
+ +

Signal names

+

Use descriptive lowercase names with underscores: library_ready, scan_complete, downloads_done. Existing signal names from other automations appear as suggestions.

+ ` + }, + 'auto-fire_signal': { + title: 'Fire Signal', + content: ` +

What does this then-action do?

+

Fires a named signal after the automation's action completes. Any other automation with a Signal Received trigger listening for this signal name will wake up and run.

+ +

Configuration

+
    +
  • Signal Name: The signal to fire (e.g. library_ready). Use the same name in a Signal Received trigger on another automation to connect them.
  • +
+ +

Use cases

+
    +
  • Multi-step workflows: Scan library → fire signal → update database → fire signal → send notification
  • +
  • Fan-out: One signal can trigger multiple automations simultaneously
  • +
  • Decoupled logic: Keep each automation simple with one job, chain them via signals
  • +
+ +

Combining with notifications

+

You can add up to 3 then-actions per automation. For example: Fire Signal + Discord notification + Telegram notification — all run after the action completes.

+ ` + }, + 'backup-manager': { + title: 'Backup Manager', + content: ` +

What does this tool do?

+

The Backup Manager lets you create, view, download, restore, and delete database backups directly from the dashboard.

+ +

Features

+
    +
  • Backup Now: Create an instant backup of the current database using SQLite's hot-copy API
  • +
  • Download: Download any backup file to your local machine
  • +
  • Restore: Roll back the database to a previous backup state
  • +
  • Delete: Remove old backups you no longer need
  • +
+ +

Auto-Backups

+

SoulSync automatically creates a backup every 3 days via the automation engine. Up to 5 rolling backups are kept (oldest are removed when the limit is exceeded).

+ +

Restore Safety

+

When you restore from a backup, a safety backup of your current database is created first. This means you can always undo a restore if something goes wrong.

+ +

Stats Explained

+
    +
  • Last Backup: When the most recent backup was created
  • +
  • Backups: Total number of backup files available
  • +
  • Latest Size: Size of the most recent backup
  • +
  • DB Size: Current size of the live database
  • +
+ ` + }, + 'metadata-cache': { + title: 'Metadata Cache', + content: ` +

What is this?

+

The Metadata Cache stores every API response from Spotify and iTunes so SoulSync can reuse them instead of making duplicate API calls. This reduces rate limit pressure and speeds up lookups.

+ +

How it works

+

When SoulSync fetches artist, album, or track data from Spotify or iTunes, the response is cached locally. The next time the same data is needed, it's served from cache instantly — no API call required. Cached data is even served during Spotify rate limit bans.

+ +

Browsing the Cache

+

Click Browse Cache to explore all cached metadata. You can filter by entity type (artists, albums, tracks), search by name, filter by source (Spotify/iTunes), and sort by different fields. Click any card to see full details including the raw API response.

+ +

Cache Management

+
    +
  • TTL: Entities expire after 30 days, search mappings after 7 days
  • +
  • Eviction: Expired entries are automatically cleaned up
  • +
  • Clear: You can clear the entire cache or filter by source/type
  • +
+ +

Stats Explained

+
    +
  • Artists: Total cached artist profiles
  • +
  • Albums: Total cached album records
  • +
  • Tracks: Total cached track records
  • +
  • Hits: Total number of times cached data was served instead of making an API call
  • +
+ ` + } +}; + +function initializeToolHelpButtons() { + const helpButtons = document.querySelectorAll('.tool-help-button'); + const modal = document.getElementById('tool-help-modal'); + const closeButton = modal.querySelector('.tool-help-modal-close'); + + // Attach click handlers to all help buttons + helpButtons.forEach(button => { + button.addEventListener('click', (e) => { + e.stopPropagation(); + const toolId = button.getAttribute('data-tool'); + openToolHelpModal(toolId); + }); + }); + + // Close modal when clicking close button + closeButton.addEventListener('click', closeToolHelpModal); + + // Close modal when clicking outside content + modal.addEventListener('click', (e) => { + if (e.target === modal) { + closeToolHelpModal(); + } + }); + + // Close modal on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.classList.contains('active')) { + closeToolHelpModal(); + } + }); +} + +function openToolHelpModal(toolId) { + const modal = document.getElementById('tool-help-modal'); + const titleElement = document.getElementById('tool-help-modal-title'); + const bodyElement = document.getElementById('tool-help-modal-body'); + + const helpData = TOOL_HELP_CONTENT[toolId]; + if (!helpData) { + console.warn(`No help content found for tool: ${toolId}`); + return; + } + + titleElement.textContent = helpData.title; + bodyElement.innerHTML = helpData.content; + + modal.classList.add('active'); + document.body.style.overflow = 'hidden'; // Prevent background scrolling +} + +function closeToolHelpModal() { + const modal = document.getElementById('tool-help-modal'); + if (modal) modal.classList.remove('active'); + document.body.style.overflow = ''; // Restore scrolling +} +// Global Escape key handler for tool help modal (works even if Tools page wasn't visited) +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const modal = document.getElementById('tool-help-modal'); + if (modal && modal.classList.contains('active')) closeToolHelpModal(); + } +}); + +// =============================== +// == RETAG TOOL FUNCTIONS == +// =============================== + +let retagStatusInterval = null; +let retagCurrentGroupId = null; + +async function loadRetagStats() { + try { + const response = await fetch('/api/retag/stats'); + const data = await response.json(); + if (data.success !== false) { + const groupsEl = document.getElementById('retag-stat-groups'); + const tracksEl = document.getElementById('retag-stat-tracks'); + const artistsEl = document.getElementById('retag-stat-artists'); + if (groupsEl) groupsEl.textContent = data.groups || 0; + if (tracksEl) tracksEl.textContent = data.tracks || 0; + if (artistsEl) artistsEl.textContent = data.artists || 0; + } + } catch (e) { + console.warn('Failed to load retag stats:', e); + } +} + +async function openRetagModal() { + const modal = document.getElementById('retag-modal'); + if (!modal) return; + modal.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + + // Reset batch bar and clear-all button + const batchBar = document.getElementById('retag-batch-bar'); + if (batchBar) batchBar.style.display = 'none'; + const clearBtn = document.getElementById('retag-clear-all-btn'); + if (clearBtn) { clearBtn.textContent = 'Clear All'; clearBtn.dataset.confirming = ''; clearBtn.style.background = ''; } + + const body = document.getElementById('retag-modal-body'); + body.innerHTML = '
Loading downloads...
'; + + try { + const response = await fetch('/api/retag/groups'); + const data = await response.json(); + if (!data.success || !data.groups || data.groups.length === 0) { + body.innerHTML = '

No downloads recorded yet. Downloads will appear here after completing album or single downloads.

'; + if (clearBtn) clearBtn.style.display = 'none'; + return; + } + if (clearBtn) clearBtn.style.display = ''; + renderRetagGroups(data.groups, body); + } catch (e) { + body.innerHTML = '

Failed to load downloads.

'; + } +} + +function closeRetagModal() { + const modal = document.getElementById('retag-modal'); + if (modal) modal.style.display = 'none'; + document.body.style.overflow = ''; +} + +function renderRetagGroups(groups, container) { + // Group by artist_name + const byArtist = {}; + groups.forEach(g => { + const artist = g.artist_name || 'Unknown Artist'; + if (!byArtist[artist]) byArtist[artist] = []; + byArtist[artist].push(g); + }); + + let html = ''; + Object.keys(byArtist).sort((a, b) => a.localeCompare(b)).forEach(artist => { + html += `
+

${escapeHtml(artist)}

+
`; + + byArtist[artist].forEach(group => { + const imgHtml = group.image_url + ? `` + : '
'; + const trackCount = group.track_count || group.total_tracks || 0; + const typeLabel = (group.group_type || 'album').charAt(0).toUpperCase() + (group.group_type || 'album').slice(1); + const releaseDate = group.release_date ? group.release_date.substring(0, 4) : ''; + const defaultQuery = (artist + ' ' + (group.album_name || '')).trim(); + + html += `
+
+ + ${imgHtml} +
+ ${escapeHtml(group.album_name || 'Unknown')} + ${typeLabel}${releaseDate ? ' \u00b7 ' + releaseDate : ''} \u00b7 ${trackCount} track${trackCount !== 1 ? 's' : ''} +
+ +
+ +
+
+ +
`; + }); + + html += `
`; + }); + + container.innerHTML = html; + _attachRetagDelegation(container); +} + +function _attachRetagDelegation(container) { + // Single click handler for all retag group interactions + container.addEventListener('click', (e) => { + const target = e.target; + + // Skip checkbox wrapper clicks — handled by change listener + if (target.closest('.retag-group-checkbox')) return; + + // Retag button + const retagBtn = target.closest('.retag-group-btn'); + if (retagBtn) { + e.stopPropagation(); + const groupId = parseInt(retagBtn.dataset.groupId); + const header = retagBtn.closest('.retag-group-header'); + const defaultQuery = header ? header.dataset.defaultQuery || '' : ''; + openRetagSearch(groupId, defaultQuery); + return; + } + + // Delete confirm buttons (dynamically injected) + const confirmYes = target.closest('.retag-confirm-yes'); + if (confirmYes) { + e.stopPropagation(); + const card = confirmYes.closest('.retag-group-card'); + if (card) executeRetagGroupDelete(parseInt(card.dataset.groupId)); + return; + } + const confirmNo = target.closest('.retag-confirm-no'); + if (confirmNo) { + e.stopPropagation(); + const card = confirmNo.closest('.retag-group-card'); + if (card) cancelRetagDeleteConfirm(parseInt(card.dataset.groupId)); + return; + } + + // Delete button + const delBtn = target.closest('.retag-group-delete-btn'); + if (delBtn) { + e.stopPropagation(); + showRetagDeleteConfirm(parseInt(delBtn.dataset.groupId)); + return; + } + + // Group header click (expand/collapse) + const header = target.closest('.retag-group-header'); + if (header) { + toggleRetagGroup(parseInt(header.dataset.groupId)); + return; + } + }); + + // Separate change handler for checkboxes + container.addEventListener('change', (e) => { + if (e.target.classList.contains('retag-select-cb')) { + updateRetagBatchBar(); + } + }); +} + +async function toggleRetagGroup(groupId) { + const tracksDiv = document.getElementById(`retag-tracks-${groupId}`); + if (!tracksDiv) return; + + if (tracksDiv.style.display === 'none') { + tracksDiv.style.display = 'block'; + if (tracksDiv.querySelector('.retag-tracks-loading')) { + try { + const response = await fetch(`/api/retag/groups/${groupId}/tracks`); + const data = await response.json(); + if (data.success && data.tracks && data.tracks.length > 0) { + tracksDiv.innerHTML = data.tracks.map(t => { + const discPrefix = t.disc_number > 1 ? `${t.disc_number}-` : ''; + const trackNum = t.track_number != null ? `${discPrefix}${String(t.track_number).padStart(2, '0')}` : '--'; + return `
+ ${trackNum} + ${escapeHtml(t.title || 'Unknown')} + ${(t.file_format || '').toUpperCase()} +
`; + }).join(''); + } else { + tracksDiv.innerHTML = '

No tracks found

'; + } + } catch (e) { + tracksDiv.innerHTML = '

Failed to load tracks

'; + } + } + } else { + tracksDiv.style.display = 'none'; + } +} + +function openRetagSearch(groupId, defaultQuery) { + retagCurrentGroupId = groupId; + const modal = document.getElementById('retag-search-modal'); + if (!modal) return; + modal.style.display = 'flex'; + + const input = document.getElementById('retag-search-input'); + if (input) { + input.value = defaultQuery || ''; + input.focus(); + if (defaultQuery) { + searchRetagAlbums(defaultQuery); + } + } +} + +function closeRetagSearch() { + const modal = document.getElementById('retag-search-modal'); + if (modal) modal.style.display = 'none'; + retagCurrentGroupId = null; +} + +let retagSearchTimeout = null; +document.addEventListener('DOMContentLoaded', () => { + const retagSearchInput = document.getElementById('retag-search-input'); + if (retagSearchInput) { + retagSearchInput.addEventListener('input', (e) => { + clearTimeout(retagSearchTimeout); + retagSearchTimeout = setTimeout(() => searchRetagAlbums(e.target.value), 400); + }); + retagSearchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + clearTimeout(retagSearchTimeout); + searchRetagAlbums(e.target.value); + } + }); + } + + // Close retag modals on escape + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + const searchModal = document.getElementById('retag-search-modal'); + if (searchModal && searchModal.style.display === 'flex') { + closeRetagSearch(); + return; + } + const mainModal = document.getElementById('retag-modal'); + if (mainModal && mainModal.style.display === 'flex') { + closeRetagModal(); + } + } + }); + + // Close retag modal on overlay click + const retagModal = document.getElementById('retag-modal'); + if (retagModal) { + retagModal.addEventListener('click', (e) => { + if (e.target === retagModal) closeRetagModal(); + }); + } + const retagSearchModal = document.getElementById('retag-search-modal'); + if (retagSearchModal) { + retagSearchModal.addEventListener('click', (e) => { + if (e.target === retagSearchModal) closeRetagSearch(); + }); + } +}); + +async function searchRetagAlbums(query) { + if (!query || !query.trim()) return; + const resultsDiv = document.getElementById('retag-search-results'); + if (!resultsDiv) return; + resultsDiv.innerHTML = '
Searching...
'; + + try { + const response = await fetch(`/api/retag/search?q=${encodeURIComponent(query.trim())}`); + const data = await response.json(); + if (data.success && data.albums && data.albums.length > 0) { + resultsDiv.innerHTML = data.albums.map(a => { + const imgHtml = a.image_url + ? `` + : '
'; + const typeLabel = (a.album_type || 'album').charAt(0).toUpperCase() + (a.album_type || 'album').slice(1); + const releaseYear = a.release_date ? a.release_date.substring(0, 4) : ''; + return `
+ ${imgHtml} +
+ ${escapeHtml(a.name || 'Unknown')} + ${escapeHtml(a.artist || 'Unknown')} + ${typeLabel}${releaseYear ? ' \u00b7 ' + releaseYear : ''} \u00b7 ${a.total_tracks || 0} tracks +
+
`; + }).join(''); + } else { + resultsDiv.innerHTML = '

No albums found.

'; + } + } catch (e) { + resultsDiv.innerHTML = '

Search failed.

'; + } +} + +/** + * Show inline confirmation on a search result before retagging + */ +function showRetagConfirm(el, groupId, albumId, albumName) { + // Clear any other confirming states + document.querySelectorAll('.retag-search-result.retag-confirming').forEach(r => { + r.classList.remove('retag-confirming'); + const bar = r.querySelector('.retag-result-confirm-bar'); + if (bar) bar.remove(); + r.onclick = r._originalOnclick || null; + }); + + el.classList.add('retag-confirming'); + el._originalOnclick = el.onclick; + el.onclick = null; // Disable clicking the row again + + const confirmBar = document.createElement('div'); + confirmBar.className = 'retag-result-confirm-bar'; + confirmBar.innerHTML = ` + Re-tag with "${escapeHtml(albumName)}"? +
+ + +
+ `; + el.appendChild(confirmBar); +} + +function cancelRetagConfirm(cancelBtn) { + const result = cancelBtn.closest('.retag-search-result'); + if (!result) return; + result.classList.remove('retag-confirming'); + const bar = result.querySelector('.retag-result-confirm-bar'); + if (bar) bar.remove(); + if (result._originalOnclick) { + result.onclick = result._originalOnclick; + } +} + +async function executeRetag(groupId, albumId, albumName) { + + closeRetagSearch(); + closeRetagModal(); + + try { + const response = await fetch('/api/retag/execute', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_id: groupId, album_id: albumId }) + }); + const data = await response.json(); + if (data.success) { + showToast('Retag operation started', 'success'); + startRetagPolling(); + } else { + showToast(`Error: ${data.error || 'Unknown error'}`, 'error'); + } + } catch (e) { + showToast('Failed to start retag operation', 'error'); + } +} + +function startRetagPolling() { + if (retagStatusInterval) return; + retagStatusInterval = setInterval(checkRetagStatus, 1000); + checkRetagStatus(); +} + +async function checkRetagStatus() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/retag/status'); + const state = await response.json(); + updateRetagProgressUI(state); + + if (state.status === 'running' && !retagStatusInterval) { + startRetagPolling(); + } + + if (state.status !== 'running' && retagStatusInterval) { + clearInterval(retagStatusInterval); + retagStatusInterval = null; + if (state.status === 'finished') { + showToast('Retag completed successfully', 'success'); + loadRetagStats(); + } else if (state.status === 'error') { + showToast(`Retag error: ${state.error_message || 'Unknown error'}`, 'error'); + } + } + } catch (e) { + // Ignore fetch errors during polling + } +} + +function updateRetagStatusFromData(data) { + const prev = _lastToolStatus['retag']; + _lastToolStatus['retag'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateRetagProgressUI(data); + // Handle terminal state toasts (only on transition) + if (prev === 'running' || prev === undefined) { + if (data.status === 'finished') { + showToast('Retag completed successfully', 'success'); + loadRetagStats(); + } else if (data.status === 'error') { + showToast(`Retag error: ${data.error_message || 'Unknown error'}`, 'error'); + } + } +} + +function updateRetagProgressUI(state) { + const phaseLabel = document.getElementById('retag-phase-label'); + const progressBar = document.getElementById('retag-progress-bar'); + const progressLabel = document.getElementById('retag-progress-label'); + const statusEl = document.getElementById('retag-stat-status'); + + if (phaseLabel) phaseLabel.textContent = state.phase || 'Ready'; + if (progressBar) progressBar.style.width = `${state.progress || 0}%`; + if (progressLabel) { + progressLabel.textContent = `${state.processed || 0} / ${state.total_tracks || 0} tracks (${(state.progress || 0).toFixed(1)}%)`; + } + if (statusEl) { + statusEl.textContent = state.status === 'running' ? 'Running' : 'Idle'; + } + + // Color the progress bar red on error + if (progressBar) { + progressBar.style.backgroundColor = state.status === 'error' ? '#ff4444' : ''; + } +} + +/** + * Show inline delete confirmation for a retag group + */ +function showRetagDeleteConfirm(groupId) { + const area = document.getElementById(`retag-delete-area-${groupId}`); + if (!area) return; + area.innerHTML = `
+ Remove? + + +
`; +} + +function cancelRetagDeleteConfirm(groupId) { + const area = document.getElementById(`retag-delete-area-${groupId}`); + if (!area) return; + area.innerHTML = ``; +} + +async function executeRetagGroupDelete(groupId) { + try { + const response = await fetch(`/api/retag/groups/${groupId}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.success) { + const card = document.querySelector(`.retag-group-card[data-group-id="${groupId}"]`); + if (card) { + const section = card.closest('.retag-artist-section'); + card.remove(); + if (section && section.querySelectorAll('.retag-group-card').length === 0) { + section.remove(); + } + } + loadRetagStats(); + updateRetagBatchBar(); + showToast('Group removed', 'success'); + } else { + showToast('Failed to remove group', 'error'); + } + } catch (e) { + showToast('Failed to remove group', 'error'); + } +} + +/** + * Update the retag batch action bar based on checkbox selection + */ +function updateRetagBatchBar() { + const checked = document.querySelectorAll('.retag-select-cb:checked'); + const bar = document.getElementById('retag-batch-bar'); + const countEl = document.getElementById('retag-batch-count'); + if (!bar) return; + + if (checked.length > 0) { + bar.style.display = 'flex'; + countEl.textContent = `${checked.length} selected`; + } else { + bar.style.display = 'none'; + } +} + +/** + * Batch remove selected retag groups + */ +async function batchRemoveRetagGroups() { + const checked = document.querySelectorAll('.retag-select-cb:checked'); + if (checked.length === 0) return; + + const groupIds = Array.from(checked).map(cb => parseInt(cb.getAttribute('data-group-id'))); + + try { + const response = await fetch('/api/retag/groups/delete-batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ group_ids: groupIds }) + }); + const data = await response.json(); + if (data.success) { + showToast(`Removed ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success'); + openRetagModal(); // Refresh + } else { + showToast('Failed to remove groups', 'error'); + } + } catch (e) { + showToast('Failed to remove groups', 'error'); + } +} + +/** + * Clear all retag groups — inline confirm on the button itself + */ +function clearAllRetagGroups(btn) { + if (!btn) return; + if (btn.dataset.confirming === 'true') { + // Already confirming — execute + btn.dataset.confirming = ''; + btn.textContent = 'Clear All'; + executeClearAllRetag(); + return; + } + // First click — show confirm state + btn.dataset.confirming = 'true'; + btn.textContent = 'Confirm Clear?'; + btn.style.background = 'rgba(255, 59, 48, 0.15)'; + // Auto-reset after 3 seconds if not clicked again + setTimeout(() => { + if (btn.dataset.confirming === 'true') { + btn.dataset.confirming = ''; + btn.textContent = 'Clear All'; + btn.style.background = ''; + } + }, 3000); +} + +async function executeClearAllRetag() { + try { + const response = await fetch('/api/retag/groups/clear-all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + const data = await response.json(); + if (data.success) { + showToast(`Cleared ${data.removed} group${data.removed !== 1 ? 's' : ''}`, 'success'); + openRetagModal(); // Refresh + } else { + showToast('Failed to clear groups', 'error'); + } + } catch (e) { + showToast('Failed to clear groups', 'error'); + } +} + +function stopWishlistCountPolling() { + if (wishlistCountInterval) { + clearInterval(wishlistCountInterval); + wishlistCountInterval = null; + } +} + + + +function resetWishlistModalToIdleState() { + // Reset wishlist modal to idle state after background processing completes + const playlistId = 'wishlist'; + const process = activeDownloadProcesses[playlistId]; + + if (process) { + console.log('🔄 Resetting wishlist modal to idle state...'); + + // Reset button states + const beginBtn = document.getElementById(`begin-analysis-btn-${playlistId}`); + const cancelBtn = document.getElementById(`cancel-all-btn-${playlistId}`); + if (beginBtn) { + beginBtn.style.display = 'inline-block'; + beginBtn.disabled = false; + beginBtn.textContent = 'Begin Analysis'; + } + if (cancelBtn) { + cancelBtn.style.display = 'none'; + } + + // Show the force download toggle again + const forceToggleContainer = document.querySelector(`#force-download-all-${playlistId}`)?.closest('.force-download-toggle-container'); + if (forceToggleContainer) { + forceToggleContainer.style.display = 'flex'; + } + + // Reset progress displays + const analysisText = document.getElementById(`analysis-progress-text-${playlistId}`); + const analysisBar = document.getElementById(`analysis-progress-fill-${playlistId}`); + const downloadText = document.getElementById(`download-progress-text-${playlistId}`); + const downloadBar = document.getElementById(`download-progress-fill-${playlistId}`); + + if (analysisText) analysisText.textContent = 'Ready to start'; + if (analysisBar) analysisBar.style.width = '0%'; + if (downloadText) downloadText.textContent = 'Waiting for analysis'; + if (downloadBar) downloadBar.style.width = '0%'; + + // Reset all track rows to pending state + const trackRows = document.querySelectorAll(`#download-missing-modal-${CSS.escape(playlistId)} tr[data-track-index]`); + trackRows.forEach((row, index) => { + const matchCell = row.querySelector(`#match-${playlistId}-${index}`); + const downloadCell = row.querySelector(`#download-${playlistId}-${index}`); + const actionsCell = row.querySelector(`#actions-${playlistId}-${index}`); + + if (matchCell) matchCell.textContent = '🔍 Pending'; + if (downloadCell) downloadCell.textContent = '-'; + if (actionsCell) actionsCell.innerHTML = '-'; + }); + + // Reset stats + const foundElement = document.getElementById(`stat-found-${playlistId}`); + const missingElement = document.getElementById(`stat-missing-${playlistId}`); + const downloadedElement = document.getElementById(`stat-downloaded-${playlistId}`); + if (foundElement) foundElement.textContent = '-'; + if (missingElement) missingElement.textContent = '-'; + if (downloadedElement) downloadedElement.textContent = '0'; + + // Reset process status + process.status = 'idle'; + process.batchId = null; + if (process.poller) { + clearInterval(process.poller); + process.poller = null; + } + + console.log('✅ Wishlist modal fully reset to idle state'); + } else { + console.log('⚠️ No wishlist process found to reset'); + } +} + +let toolsPageState = { isInitialized: false }; + +async function initializeToolsPage() { + // Attach event listeners for tool buttons (idempotent — getElementById returns null if already wired) + const updateButton = document.getElementById('db-update-button'); + if (updateButton && !updateButton._toolsWired) { + updateButton.addEventListener('click', handleDbUpdateButtonClick); + updateButton._toolsWired = true; + } + + const metadataButton = document.getElementById('metadata-update-button'); + if (metadataButton && !metadataButton._toolsWired) { + metadataButton.addEventListener('click', handleMetadataUpdateButtonClick); + metadataButton._toolsWired = true; + } + + const qualityScanButton = document.getElementById('quality-scan-button'); + if (qualityScanButton && !qualityScanButton._toolsWired) { + qualityScanButton.addEventListener('click', handleQualityScanButtonClick); + qualityScanButton._toolsWired = true; + } + + const duplicateCleanButton = document.getElementById('duplicate-clean-button'); + if (duplicateCleanButton && !duplicateCleanButton._toolsWired) { + duplicateCleanButton.addEventListener('click', handleDuplicateCleanButtonClick); + duplicateCleanButton._toolsWired = true; + } + + const retagOpenButton = document.getElementById('retag-open-button'); + if (retagOpenButton && !retagOpenButton._toolsWired) { + retagOpenButton.addEventListener('click', openRetagModal); + retagOpenButton._toolsWired = true; + } + + const mediaScanButton = document.getElementById('media-scan-button'); + if (mediaScanButton && !mediaScanButton._toolsWired) { + mediaScanButton.addEventListener('click', handleMediaScanButtonClick); + mediaScanButton._toolsWired = true; + } + + const backupNowButton = document.getElementById('backup-now-button'); + if (backupNowButton && !backupNowButton._toolsWired) { + backupNowButton.addEventListener('click', handleBackupNowClick); + backupNowButton._toolsWired = true; + } + + // Tool-specific init + await checkAndHideMetadataUpdaterForNonPlex(); + await checkAndRestoreMetadataUpdateState(); + await checkAndShowMediaScanForPlex(); + loadBackupList(); + initializeToolHelpButtons(); + loadRetagStats(); + checkRetagStatus(); + await fetchAndUpdateDbStats(); + loadDiscoveryPoolStats(); + loadMetadataCacheStats(); + + // Start polling (cleared when navigating away via loadPageData preamble) + stopDbStatsPolling(); + dbStatsInterval = setInterval(fetchAndUpdateDbStats, 10000); + + // Check for ongoing operations + await checkAndUpdateDbProgress(); + await checkAndUpdateQualityScanProgress(); + await checkAndUpdateDuplicateCleanProgress(); + + // Initialize library maintenance section + updateRepairStatus(); + switchRepairTab('jobs'); + + toolsPageState.isInitialized = true; +} + +async function loadDashboardData() { + // Initial load of wishlist count + await updateWishlistCount(); + + // Start periodic refresh of wishlist count (every 10 seconds) + stopWishlistCountPolling(); // Ensure no duplicates + wishlistCountInterval = setInterval(updateWishlistCount, 10000); + + // Initial load of service status, system statistics, and library status + await fetchAndUpdateServiceStatus(); + await fetchAndUpdateSystemStats(); + await fetchAndUpdateDbStats(); + + // Service status is already polled globally (line 311) + // System stats polling kept here (dashboard-specific) + setInterval(fetchAndUpdateSystemStats, 10000); + + // Initial load of activity feed + await fetchAndUpdateActivityFeed(); + + // Start periodic refresh of activity feed (every 2 seconds for responsiveness) + setInterval(fetchAndUpdateActivityFeed, 2000); + + // Start periodic toast checking (every 3 seconds) + setInterval(checkForActivityToasts, 3000); + + // Check for any active download processes that need rehydration + await checkForActiveProcesses(); + + // Populate the Active Downloads dashboard section with any existing downloads + updateDashboardDownloads(); + + // Automatic wishlist processing now runs server-side +} + +// --- Data Fetching and UI Updates --- + +async function fetchAndUpdateDbStats() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/database/stats'); + if (!response.ok) return; + + const stats = await response.json(); + + // This function updates the stat cards in the top grid + updateDashboardStatCards(stats); + + // This function updates the info within the DB Updater tool card + updateDbUpdaterCardInfo(stats); + + } catch (error) { + console.warn('Could not fetch DB stats:', error); + } +} + +function updateDashboardStatCards(stats) { + // Update the Library Status card on the dashboard + updateLibraryStatusCard(stats); +} + +/** + * Smart Library Status card on the Dashboard. + * Shows different states: no server, empty library, healthy library, scanning. + */ +function updateLibraryStatusCard(dbStats) { + const card = document.getElementById('library-status-card'); + if (!card) return; + + const title = document.getElementById('library-status-title'); + const subtitle = document.getElementById('library-status-subtitle'); + const statsRow = document.getElementById('library-status-stats'); + const scanBtn = document.getElementById('library-status-scan-btn'); + const scanLabel = document.getElementById('library-status-scan-label'); + const deepBtn = document.getElementById('library-status-deep-btn'); + const progressDiv = document.getElementById('library-status-progress'); + const messageDiv = document.getElementById('library-status-message'); + + const artists = dbStats ? (dbStats.artists || 0) : 0; + const albums = dbStats ? (dbStats.albums || 0) : 0; + const tracks = dbStats ? (dbStats.tracks || 0) : 0; + const sizeMb = dbStats ? (dbStats.database_size_mb || 0) : 0; + const lastUpdate = dbStats ? dbStats.last_update : null; + const serverSource = dbStats ? dbStats.server_source : null; + + // Check if a scan is in progress + const isScanning = window._libraryStatusScanning || false; + + // Determine state + const serverConnected = _lastServiceStatus && _lastServiceStatus.media_server && _lastServiceStatus.media_server.connected; + const serverType = _lastServiceStatus && _lastServiceStatus.active_media_server; + const hasData = tracks > 0; + const hasServer = !!serverType && serverType !== 'none'; + + // Reset classes + card.className = 'library-status-card'; + + if (isScanning) { + // State: Scanning + card.classList.add('scanning'); + if (title) title.textContent = 'Library Scan'; + if (subtitle) subtitle.textContent = 'Updating library database...'; + if (scanBtn) { + scanBtn.style.display = ''; + scanBtn.classList.add('scanning'); + scanLabel.textContent = 'Stop'; + scanBtn.disabled = false; + } + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = hasData ? '' : 'none'; + if (progressDiv) progressDiv.style.display = ''; + if (messageDiv) messageDiv.style.display = 'none'; + + } else if (!hasServer) { + // State: No server configured + card.classList.add('needs-setup'); + if (title) title.textContent = 'No Media Server'; + if (subtitle) subtitle.textContent = 'Connect a server to get started'; + if (scanBtn) scanBtn.style.display = 'none'; + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = 'none'; + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) { + messageDiv.style.display = ''; + messageDiv.innerHTML = 'SoulSync needs a media server to manage your library. ' + + 'Go to Settings ' + + 'to connect Plex, Jellyfin, or Navidrome.'; + } + + } else if (!serverConnected) { + // State: Server configured but not connected + card.classList.add('needs-setup'); + const serverName = _capitalize(serverType); + if (title) title.textContent = `${serverName} — Disconnected`; + if (subtitle) subtitle.textContent = 'Cannot reach your media server'; + if (scanBtn) scanBtn.style.display = 'none'; + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = 'none'; + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) { + messageDiv.style.display = ''; + messageDiv.innerHTML = `Your ${serverName} server is configured but not responding. ` + + 'Check that it\'s running and the connection details are correct in ' + + 'Settings.'; + } + + } else if (!hasData) { + // State: Server connected but library is empty + card.classList.add('empty-library'); + const serverName = _capitalize(serverType); + if (title) title.textContent = `${serverName} Connected`; + if (subtitle) subtitle.textContent = 'Library database is empty'; + if (scanBtn) { + scanBtn.style.display = ''; + scanBtn.classList.remove('scanning'); + scanLabel.textContent = 'Scan Now'; + scanBtn.disabled = false; + } + if (deepBtn) deepBtn.style.display = 'none'; + if (statsRow) statsRow.style.display = 'none'; + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) { + messageDiv.style.display = ''; + messageDiv.innerHTML = 'Your server is connected but SoulSync hasn\'t imported your library yet. ' + + 'Click Scan Now to pull your artists, albums, and tracks into SoulSync.'; + } + + } else { + // State: Healthy library with data + card.classList.add('has-data'); + const serverName = _capitalize(serverType); + let lastRefreshText = 'Never'; + if (lastUpdate) { + const d = new Date(lastUpdate); + if (!isNaN(d.getTime())) { + lastRefreshText = typeof _formatTimeAgo === 'function' ? _formatTimeAgo(d) : d.toLocaleDateString(); + } + } + if (title) title.textContent = `${serverName} Library`; + if (subtitle) subtitle.textContent = `Last refreshed ${lastRefreshText}`; + if (scanBtn) { + scanBtn.style.display = ''; + scanBtn.classList.remove('scanning'); + scanLabel.textContent = 'Refresh'; + scanBtn.disabled = false; + } + if (deepBtn) deepBtn.style.display = ''; + if (statsRow) { + statsRow.style.display = ''; + document.getElementById('library-status-artists').textContent = artists.toLocaleString(); + document.getElementById('library-status-albums').textContent = albums.toLocaleString(); + document.getElementById('library-status-tracks').textContent = tracks.toLocaleString(); + document.getElementById('library-status-size').textContent = sizeMb < 1 ? `${Math.round(sizeMb * 1024)} KB` : `${sizeMb.toFixed(1)} MB`; + } + if (progressDiv) progressDiv.style.display = 'none'; + if (messageDiv) messageDiv.style.display = 'none'; + } +} + +// _lastServiceStatus and _isSoulsyncStandalone are declared in core.js +const _origFetchServiceStatus = typeof fetchAndUpdateServiceStatus === 'function' ? fetchAndUpdateServiceStatus : null; + +function _capitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : ''; } + +/** + * Dashboard library scan button handler — triggers incremental DB update. + */ +async function dashboardLibraryScan(fullRefresh = false) { + const scanBtn = document.getElementById('library-status-scan-btn'); + const scanLabel = document.getElementById('library-status-scan-label'); + + // If already scanning, stop it + if (window._libraryStatusScanning) { + try { + await fetch('/api/database/update/stop', { method: 'POST' }); + window._libraryStatusScanning = false; + showToast('Library scan stopped', 'info'); + // Refresh the card + try { + const r = await fetch('/api/database/stats'); + if (r.ok) updateLibraryStatusCard(await r.json()); + } catch (e) {} + } catch (e) { + showToast('Failed to stop scan', 'error'); + } + return; + } + + // Start scan + try { + window._libraryStatusScanning = true; + updateLibraryStatusCard(null); // Update to scanning state + + const response = await fetch('/api/database/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ full_refresh: fullRefresh }) + }); + const data = await response.json(); + if (!data.success) { + window._libraryStatusScanning = false; + showToast(data.error || 'Failed to start scan', 'error'); + return; + } + + showToast('Library scan started', 'success'); + + // Poll for progress + const pollInterval = setInterval(async () => { + try { + const statusResp = await fetch('/api/database/update/status'); + if (!statusResp.ok) return; + const status = await statusResp.json(); + + const phase = document.getElementById('library-status-phase'); + const barFill = document.getElementById('library-status-bar-fill'); + const detail = document.getElementById('library-status-progress-detail'); + + if (phase) phase.textContent = status.phase || 'Scanning...'; + if (barFill) barFill.style.width = `${status.progress || 0}%`; + if (detail && status.processed !== undefined) { + detail.textContent = `${status.processed} / ${status.total || '?'}`; + } + + if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + + if (status.status === 'completed' || status.status === 'finished') { + showToast('Library scan complete', 'success'); + } else if (status.status === 'error') { + showToast(`Scan error: ${status.error_message || 'Unknown'}`, 'error'); + } + + // Refresh stats + try { + const r = await fetch('/api/database/stats'); + if (r.ok) updateLibraryStatusCard(await r.json()); + } catch (e) {} + } + } catch (e) { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + } + }, 2000); + + } catch (e) { + window._libraryStatusScanning = false; + showToast(`Scan failed: ${e.message}`, 'error'); + } +} + +/** + * Dashboard deep scan — finds new tracks, removes stale ones, preserves enrichment data. + */ +async function dashboardLibraryDeepScan() { + if (window._libraryStatusScanning) { + showToast('A scan is already running', 'warning'); + return; + } + + if (!await showConfirmDialog({ + title: 'Deep Scan Library', + message: 'A deep scan re-checks every track in your media server library.\n\n' + + '• Adds any new tracks that were missed\n' + + '• Removes tracks no longer on your server\n' + + '• Preserves all existing metadata and enrichment data\n\n' + + 'This may take a while for large libraries. Continue?', + })) return; + + // Use the same scan flow as dashboardLibraryScan but with deep_scan flag + try { + window._libraryStatusScanning = true; + updateLibraryStatusCard(null); + + const response = await fetch('/api/database/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ deep_scan: true }) + }); + const data = await response.json(); + if (!data.success) { + window._libraryStatusScanning = false; + showToast(data.error || 'Failed to start deep scan', 'error'); + try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {} + return; + } + + showToast('Deep scan started — this may take a while', 'success'); + + const pollInterval = setInterval(async () => { + try { + const statusResp = await fetch('/api/database/update/status'); + if (!statusResp.ok) return; + const status = await statusResp.json(); + + const phase = document.getElementById('library-status-phase'); + const barFill = document.getElementById('library-status-bar-fill'); + const detail = document.getElementById('library-status-progress-detail'); + + if (phase) phase.textContent = status.phase || 'Deep scanning...'; + if (barFill) barFill.style.width = `${status.progress || 0}%`; + if (detail && status.processed !== undefined) { + detail.textContent = `${status.processed} / ${status.total || '?'}`; + } + + if (status.status === 'completed' || status.status === 'finished' || status.status === 'error' || status.status === 'idle') { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + + if (status.status === 'completed' || status.status === 'finished') { + showToast('Deep scan complete', 'success'); + } else if (status.status === 'error') { + showToast(`Deep scan error: ${status.error_message || 'Unknown'}`, 'error'); + } + + try { const r = await fetch('/api/database/stats'); if (r.ok) updateLibraryStatusCard(await r.json()); } catch (e) {} + } + } catch (e) { + clearInterval(pollInterval); + window._libraryStatusScanning = false; + } + }, 2000); + + } catch (e) { + window._libraryStatusScanning = false; + showToast(`Deep scan failed: ${e.message}`, 'error'); + } +} + +/** + * Update the Active Downloads section on the dashboard. + * Called from artist, search, and discover update points (event-driven, no polling). + */ +function updateDashboardDownloads() { + const section = document.getElementById('dashboard-active-downloads-section'); + const container = document.getElementById('dashboard-downloads-container'); + if (!section || !container) return; + + // Collect active entries from each source + const activeArtists = Object.keys(artistDownloadBubbles).filter(id => + artistDownloadBubbles[id].downloads.length > 0 + ); + const activeSearch = Object.keys(searchDownloadBubbles).filter(name => + searchDownloadBubbles[name].downloads.length > 0 + ); + const activeDiscover = Object.keys(discoverDownloads); + const activeBeatport = Object.keys(beatportDownloadBubbles).filter(key => + beatportDownloadBubbles[key].downloads.length > 0 + ); + + const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length + activeBeatport.length; + + if (totalCount === 0) { + section.style.display = 'none'; + container.innerHTML = ''; + return; + } + + section.style.display = ''; + let html = ''; + + // --- Artists group --- + if (activeArtists.length > 0) { + html += ` +
+
+ Artists + ${activeArtists.length} +
+
+ ${activeArtists.map(id => createArtistBubbleCard(artistDownloadBubbles[id])).join('')} +
+
`; + } + + // --- Search group --- + if (activeSearch.length > 0) { + html += ` +
+
+ Search + ${activeSearch.length} +
+
+ ${activeSearch.map(name => createSearchBubbleCard(searchDownloadBubbles[name])).join('')} +
+
`; + } + + // --- Discover group --- + if (activeDiscover.length > 0) { + html += ` +
+
+ Discover + ${activeDiscover.length} +
+
+ ${activeDiscover.map(pid => createDashboardDiscoverBubble(pid)).join('')} +
+
`; + } + + // --- Beatport group --- + if (activeBeatport.length > 0) { + html += ` +
+
+ Beatport + ${activeBeatport.length} +
+
+ ${activeBeatport.map(key => createBeatportBubbleCard(beatportDownloadBubbles[key])).join('')} +
+
`; + } + + container.innerHTML = html; + + // Post-render: attach artist bubble click handlers + dynamic glow + activeArtists.forEach(artistId => { + const card = container.querySelector(`.artist-bubble-card[data-artist-id="${artistId}"]`); + if (card) { + card.addEventListener('click', () => openArtistDownloadModal(artistId)); + const artist = artistDownloadBubbles[artistId].artist; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); + // Beatport bubble click handlers + glow + activeBeatport.forEach(chartKey => { + const card = container.querySelector(`.artist-bubble-card[data-chart-key="${chartKey}"]`); + if (card) { + card.addEventListener('click', () => openBeatportBubbleModal(chartKey)); + const chartImage = beatportDownloadBubbles[chartKey].chart.image; + if (chartImage) { + extractImageColors(chartImage, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); + // Search and discover cards use inline onclick — no post-render needed +} + +/** + * Create a 150px circle card for a discover download (dashboard variant). + * Matches artist/search bubble sizing. + */ +function createDashboardDiscoverBubble(playlistId) { + const download = discoverDownloads[playlistId]; + if (!download) return ''; + + const isCompleted = download.status === 'completed'; + const imageUrl = download.imageUrl || ''; + const backgroundStyle = imageUrl + ? `background-image: url('${imageUrl}');` + : `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(download.name)}
+
${isCompleted ? 'Completed' : 'In Progress'}
+
+
+ `; +} + + + +function updateDbUpdaterCardInfo(stats) { + // Update the detailed stats within the DB Updater tool card + const lastRefreshEl = document.getElementById('db-last-refresh'); + const artistsStatEl = document.getElementById('db-stat-artists'); + const albumsStatEl = document.getElementById('db-stat-albums'); + const tracksStatEl = document.getElementById('db-stat-tracks'); + const sizeStatEl = document.getElementById('db-stat-size'); + + if (lastRefreshEl) { + if (stats.last_full_refresh) { + const date = new Date(stats.last_full_refresh); + lastRefreshEl.textContent = date.toLocaleString(); + } else { + lastRefreshEl.textContent = 'Never'; + } + } + + if (artistsStatEl) artistsStatEl.textContent = stats.artists.toLocaleString() || '0'; + if (albumsStatEl) albumsStatEl.textContent = stats.albums.toLocaleString() || '0'; + if (tracksStatEl) tracksStatEl.textContent = stats.tracks.toLocaleString() || '0'; + if (sizeStatEl) sizeStatEl.textContent = `${stats.database_size_mb.toFixed(2)} MB`; + + // Update the title of the tool card to show which server is active + const toolCardTitle = document.querySelector('#db-updater-card .tool-card-title'); + if (toolCardTitle && stats.server_source) { + const serverName = stats.server_source.charAt(0).toUpperCase() + stats.server_source.slice(1); + toolCardTitle.textContent = `${serverName} Database Updater`; + } +} + +// --- Wishlist Count Functions --- + +async function updateWishlistCount() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/wishlist/count'); + if (!response.ok) return; + + const data = await response.json(); + 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'); + } + } + + // Check for auto-initiated wishlist processes that user should see immediately + await checkForAutoInitiatedWishlistProcess(); + + } catch (error) { + console.warn('Could not fetch wishlist count:', error); + } +} + +async function checkForAutoInitiatedWishlistProcess() { + try { + const playlistId = 'wishlist'; + + // Only check if we're on the dashboard and no modal is currently visible + if (currentPage !== 'dashboard') { + return; + } + + // Don't override if user has manually closed the modal during auto-processing + if (WishlistModalState.wasUserClosed()) { + return; + } + + // Check for active wishlist processes + const response = await fetch('/api/active-processes'); + if (!response.ok) return; + + const data = await response.json(); + const processes = data.active_processes || []; + const serverWishlistProcess = processes.find(p => p.playlist_id === playlistId); + const clientWishlistProcess = activeDownloadProcesses[playlistId]; + + if (serverWishlistProcess && serverWishlistProcess.auto_initiated) { + console.log('🤖 [Auto-Processing] Detected auto-initiated wishlist process during polling'); + + // Only sync frontend state if needed, but don't auto-show modal + const needsSync = !clientWishlistProcess || + clientWishlistProcess.batchId !== serverWishlistProcess.batch_id || + !clientWishlistProcess.modalElement || + !document.body.contains(clientWishlistProcess.modalElement); + + if (needsSync) { + console.log('🔄 [Auto-Processing] Syncing frontend state for auto-processing (background mode)'); + await rehydrateModal(serverWishlistProcess, false); // Background sync only + } + + // Note: Modal visibility is controlled by user interaction only + // User must click wishlist button to see auto-processing progress + } + + } catch (error) { + console.warn('Error checking for auto-initiated wishlist process:', error); + } +} + +async function checkAndUpdateDbProgress() { + if (socketConnected) return; // WebSocket handles this + try { + const response = await fetch('/api/database/update/status', { + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + if (!response.ok) return; + + const state = await response.json(); + console.debug('📊 DB Status:', state.status, `${state.processed}/${state.total}`, `${state.progress.toFixed(1)}%`); + updateDbProgressUI(state); + + // Start polling only if not already polling and status is running + if (state.status === 'running' && !dbUpdateStatusInterval) { + console.log('🔄 Starting database update polling (1 second interval)'); + dbUpdateStatusInterval = setInterval(checkAndUpdateDbProgress, 1000); + } + + } catch (error) { + console.warn('Could not fetch DB update status:', error); + // Don't stop polling on network errors - keep trying + } +} + +function updateDbProgressFromData(data) { + const prev = _lastToolStatus['db-update']; + _lastToolStatus['db-update'] = data.status; + if (prev !== undefined && data.status === prev && data.status !== 'running') return; + updateDbProgressUI(data); +} + +function updateDbProgressUI(state) { + const button = document.getElementById('db-update-button'); + const phaseLabel = document.getElementById('db-phase-label'); + const progressLabel = document.getElementById('db-progress-label'); + const progressBar = document.getElementById('db-progress-bar'); + const refreshSelect = document.getElementById('db-refresh-type'); + + if (!button || !phaseLabel || !progressLabel || !progressBar || !refreshSelect) return; + + if (state.status === 'running') { + button.textContent = 'Stop Update'; + button.disabled = false; + refreshSelect.disabled = true; + + phaseLabel.textContent = state.phase || 'Processing...'; + progressLabel.textContent = `${state.processed} / ${state.total} artists (${state.progress.toFixed(1)}%)`; + progressBar.style.width = `${state.progress}%`; + } else { // idle, finished, or error + stopDbUpdatePolling(); + button.textContent = 'Update Database'; + button.disabled = false; + refreshSelect.disabled = false; + + if (state.status === 'error') { + phaseLabel.textContent = `Error: ${state.error_message}`; + progressBar.style.backgroundColor = '#ff4444'; // Red for error + } else { + phaseLabel.textContent = state.phase || 'Idle'; + progressBar.style.backgroundColor = 'rgb(var(--accent-rgb))'; // Green for normal + } + + if (state.status === 'finished' || state.status === 'error') { + // Final stats refresh after completion/error + setTimeout(fetchAndUpdateDbStats, 500); + } + } +} + +// ===================================================================