Merge pull request #352 from JohnBaumb/refactor/split-script-js
Split monolithic script.js into 17 domain modules
This commit is contained in:
commit
00f116ebee
20 changed files with 78235 additions and 77958 deletions
304
tests/test_script_split_integrity.py
Normal file
304
tests/test_script_split_integrity.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
"""Tests for the script.js → module split integrity.
|
||||
|
||||
Verifies that:
|
||||
- The monolithic script.js no longer exists
|
||||
- All expected split modules are present on disk
|
||||
- index.html loads all split modules via <script> tags
|
||||
- core.js loads first and init.js loads last (ordering contract)
|
||||
- No duplicate top-level function declarations across modules
|
||||
- Every onclick="fn(…)" in index.html has a matching function
|
||||
declaration in one of the split modules
|
||||
- No module references undefined globals at parse time via
|
||||
window.X = X assignments (the bug pattern we fixed)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import defaultdict
|
||||
|
||||
import pytest
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_STATIC = _ROOT / "webui" / "static"
|
||||
_INDEX = _ROOT / "webui" / "index.html"
|
||||
|
||||
# The 17 modules that replaced script.js (order matters for first/last checks)
|
||||
SPLIT_MODULES = [
|
||||
"core.js",
|
||||
"media-player.js",
|
||||
"settings.js",
|
||||
"search.js",
|
||||
"sync-spotify.js",
|
||||
"downloads.js",
|
||||
"wishlist-tools.js",
|
||||
"sync-services.js",
|
||||
"artists.js",
|
||||
"api-monitor.js",
|
||||
"library.js",
|
||||
"beatport-ui.js",
|
||||
"discover.js",
|
||||
"enrichment.js",
|
||||
"stats-automations.js",
|
||||
"pages-extra.js",
|
||||
"init.js",
|
||||
]
|
||||
|
||||
# Other JS files that exist in static/ but are NOT part of the split
|
||||
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js"}
|
||||
|
||||
# Pre-existing duplicate helper functions that lived in the original monolith.
|
||||
# In a plain <script> context the last-loaded declaration wins. These are NOT
|
||||
# regressions from the split — they should be deduplicated in a follow-up.
|
||||
KNOWN_CROSS_FILE_DUPES = {
|
||||
"escapeHtml", # downloads.js, artists.js, discover.js
|
||||
"formatDuration", # sync-spotify.js, wishlist-tools.js, sync-services.js
|
||||
"matchedDownloadTrack", # downloads.js, wishlist-tools.js
|
||||
"matchedDownloadAlbum", # downloads.js, wishlist-tools.js
|
||||
"matchedDownloadAlbumTrack", # downloads.js, wishlist-tools.js
|
||||
"_esc", # library.js, stats-automations.js
|
||||
"_escAttr", # downloads.js, stats-automations.js
|
||||
"_formatDuration", # stats-automations.js, pages-extra.js
|
||||
"loadDashboardData", # search.js, wishlist-tools.js
|
||||
}
|
||||
|
||||
# Pre-existing same-file duplicates (two filter UIs reuse the same names).
|
||||
KNOWN_SAME_FILE_DUPES = {
|
||||
"applyFiltersAndSort",
|
||||
"calculateRelevanceScore",
|
||||
"handleFilterClick",
|
||||
"initializeFilters",
|
||||
"resetFilters",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FUNC_DECL_RE = re.compile(r"^(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(", re.MULTILINE)
|
||||
_ONCLICK_RE = re.compile(r'onclick="([^"]*)"')
|
||||
_ONCLICK_FN_RE = re.compile(r"^([A-Za-z_$][A-Za-z0-9_$]*)\s*\(")
|
||||
_WINDOW_ASSIGN_RE = re.compile(
|
||||
r"^window\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*;",
|
||||
re.MULTILINE,
|
||||
)
|
||||
_SCRIPT_SRC_RE = re.compile(r"filename='([^']+\.js)'")
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _all_function_decls(js_text: str) -> list[str]:
|
||||
"""Return all top-level function declaration names in a JS file."""
|
||||
return _FUNC_DECL_RE.findall(js_text)
|
||||
|
||||
|
||||
def _script_load_order(html: str) -> list[str]:
|
||||
"""Return the ordered list of JS filenames loaded from index.html."""
|
||||
return _SCRIPT_SRC_RE.findall(html)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group A — File Existence
|
||||
# =========================================================================
|
||||
|
||||
class TestFileExistence:
|
||||
"""The old monolith is gone and all split modules are present."""
|
||||
|
||||
def test_monolith_removed(self):
|
||||
assert not (_STATIC / "script.js").exists(), "script.js should have been removed"
|
||||
|
||||
@pytest.mark.parametrize("module", SPLIT_MODULES)
|
||||
def test_split_module_exists(self, module):
|
||||
path = _STATIC / module
|
||||
assert path.exists(), f"{module} missing from webui/static/"
|
||||
assert path.stat().st_size > 0, f"{module} is empty"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group B — index.html Script Loading
|
||||
# =========================================================================
|
||||
|
||||
class TestScriptLoading:
|
||||
"""index.html references every split module in the correct order."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _load_html(self):
|
||||
self.html = _read(_INDEX)
|
||||
self.loaded = _script_load_order(self.html)
|
||||
|
||||
@pytest.mark.parametrize("module", SPLIT_MODULES)
|
||||
def test_module_loaded_in_html(self, module):
|
||||
assert module in self.loaded, f"{module} not loaded in index.html"
|
||||
|
||||
def test_core_loads_first(self):
|
||||
"""core.js must be the first split module loaded."""
|
||||
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
|
||||
assert split_in_html[0] == "core.js", (
|
||||
f"Expected core.js first, got {split_in_html[0]}"
|
||||
)
|
||||
|
||||
def test_init_loads_last(self):
|
||||
"""init.js must be the last split module loaded."""
|
||||
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
|
||||
assert split_in_html[-1] == "init.js", (
|
||||
f"Expected init.js last, got {split_in_html[-1]}"
|
||||
)
|
||||
|
||||
def test_no_duplicate_script_tags(self):
|
||||
"""Each module should only be loaded once."""
|
||||
split_in_html = [f for f in self.loaded if f in SPLIT_MODULES]
|
||||
assert len(split_in_html) == len(set(split_in_html)), (
|
||||
"Duplicate script tags detected"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group C — No Duplicate Function Declarations
|
||||
# =========================================================================
|
||||
|
||||
class TestNoDuplicateFunctions:
|
||||
"""No two split modules should declare the same top-level function."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scan_all(self):
|
||||
self.func_map: dict[str, list[str]] = defaultdict(list)
|
||||
for module in SPLIT_MODULES:
|
||||
text = _read(_STATIC / module)
|
||||
for fn_name in _all_function_decls(text):
|
||||
self.func_map[fn_name].append(module)
|
||||
|
||||
def test_no_new_cross_file_duplicates(self):
|
||||
"""Catch NEW duplicate declarations; known pre-existing ones are allowed."""
|
||||
dupes = {
|
||||
fn: files
|
||||
for fn, files in self.func_map.items()
|
||||
if len(files) > 1
|
||||
and fn not in KNOWN_CROSS_FILE_DUPES
|
||||
and fn not in KNOWN_SAME_FILE_DUPES
|
||||
}
|
||||
assert not dupes, (
|
||||
"NEW duplicate function declarations across modules:\n"
|
||||
+ "\n".join(f" {fn}: {files}" for fn, files in sorted(dupes.items()))
|
||||
)
|
||||
|
||||
def test_known_dupes_still_tracked(self):
|
||||
"""Ensure the known-dupe set stays current (remove entries when deduped)."""
|
||||
actual_dupes = {fn for fn, files in self.func_map.items() if len(files) > 1}
|
||||
stale = (KNOWN_CROSS_FILE_DUPES | KNOWN_SAME_FILE_DUPES) - actual_dupes
|
||||
assert not stale, (
|
||||
f"These known duplicates were resolved — remove from KNOWN_*_DUPES:\n"
|
||||
+ "\n".join(f" {fn}" for fn in sorted(stale))
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group D — onclick Handler Coverage
|
||||
# =========================================================================
|
||||
|
||||
class TestOnclickCoverage:
|
||||
"""Every onclick="fn(…)" in index.html should have a matching
|
||||
function declaration in the combined split modules."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scan(self):
|
||||
# Collect all function declarations from split modules
|
||||
self.all_fns: set[str] = set()
|
||||
for module in SPLIT_MODULES:
|
||||
text = _read(_STATIC / module)
|
||||
self.all_fns.update(_all_function_decls(text))
|
||||
|
||||
# Also include non-split JS files that are loaded
|
||||
for extra in ("setup-wizard.js", "docs.js", "helper.js"):
|
||||
path = _STATIC / extra
|
||||
if path.exists():
|
||||
self.all_fns.update(_all_function_decls(_read(path)))
|
||||
|
||||
# Extract all onclick function references from HTML
|
||||
html = _read(_INDEX)
|
||||
self.onclick_fns: set[str] = set()
|
||||
for onclick_val in _ONCLICK_RE.findall(html):
|
||||
m = _ONCLICK_FN_RE.match(onclick_val.strip())
|
||||
if m:
|
||||
fn_name = m.group(1)
|
||||
# Skip JS keywords that happen to match (if, return, etc.)
|
||||
if fn_name not in ("if", "return", "var", "let", "const", "this"):
|
||||
self.onclick_fns.add(fn_name)
|
||||
|
||||
def test_all_onclick_handlers_defined(self):
|
||||
missing = self.onclick_fns - self.all_fns
|
||||
assert not missing, (
|
||||
f"onclick handlers reference undefined functions:\n"
|
||||
+ "\n".join(f" {fn}" for fn in sorted(missing))
|
||||
)
|
||||
|
||||
def test_onclick_count_sanity(self):
|
||||
"""Sanity check: there should be a substantial number of onclick handlers."""
|
||||
assert len(self.onclick_fns) > 50, (
|
||||
f"Only found {len(self.onclick_fns)} onclick handlers — expected 100+"
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group E — No Dangerous Cross-File window.X = X Assignments
|
||||
# =========================================================================
|
||||
|
||||
class TestNoCrossFileWindowAssignments:
|
||||
"""window.X = X at the top level of a module is only safe if X is
|
||||
defined in that same module. If X lives in a later-loading module,
|
||||
this causes a ReferenceError at parse time."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _scan(self):
|
||||
self.module_fns: dict[str, set[str]] = {}
|
||||
self.window_assigns: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
|
||||
for module in SPLIT_MODULES:
|
||||
text = _read(_STATIC / module)
|
||||
self.module_fns[module] = set(_all_function_decls(text))
|
||||
for prop, value in _WINDOW_ASSIGN_RE.findall(text):
|
||||
self.window_assigns[module].append((prop, value))
|
||||
|
||||
def test_no_cross_file_references(self):
|
||||
bad = []
|
||||
for module, assigns in self.window_assigns.items():
|
||||
local_fns = self.module_fns[module]
|
||||
for prop, value in assigns:
|
||||
if value not in local_fns:
|
||||
bad.append(f" {module}: window.{prop} = {value} "
|
||||
f"('{value}' not declared in {module})")
|
||||
assert not bad, (
|
||||
"Cross-file window.X = X assignments found (will cause ReferenceError):\n"
|
||||
+ "\n".join(bad)
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Group F — Module Size Sanity
|
||||
# =========================================================================
|
||||
|
||||
class TestModuleSizes:
|
||||
"""No single module should be unreasonably large (regression guard)."""
|
||||
|
||||
MAX_LINES = 15000 # generous; largest module (wishlist-tools) is ~7200
|
||||
|
||||
@pytest.mark.parametrize("module", SPLIT_MODULES)
|
||||
def test_module_size(self, module):
|
||||
text = _read(_STATIC / module)
|
||||
lines = text.count("\n") + 1
|
||||
assert lines < self.MAX_LINES, (
|
||||
f"{module} has {lines} lines (max {self.MAX_LINES})"
|
||||
)
|
||||
|
||||
def test_total_lines_reasonable(self):
|
||||
"""Combined split modules should be in the same ballpark as the original."""
|
||||
total = 0
|
||||
for module in SPLIT_MODULES:
|
||||
total += _read(_STATIC / module).count("\n") + 1
|
||||
# The original was ~78K lines; allow 60K-100K for flexibility
|
||||
assert 50000 < total < 120000, f"Total lines: {total}"
|
||||
|
|
@ -8031,7 +8031,24 @@
|
|||
<script src="{{ url_for('static', filename='vendor/socket.io.min.js') }}"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='setup-wizard.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
<!-- Split modules (was: script.js) — core.js must load first, init.js last -->
|
||||
<script src="{{ url_for('static', filename='core.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='media-player.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='settings.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='search.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-spotify.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='downloads.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='wishlist-tools.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='sync-services.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='artists.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='api-monitor.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='library.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='beatport-ui.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='discover.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='enrichment.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='stats-automations.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='pages-extra.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='init.js') }}"></script>
|
||||
<!-- Notification bell + floating helper toggle — always accessible above modals -->
|
||||
<!-- Global Search Bar — Spotlight-style search from anywhere -->
|
||||
<div class="gsearch-bar" id="gsearch-bar">
|
||||
|
|
|
|||
3799
webui/static/api-monitor.js
Normal file
3799
webui/static/api-monitor.js
Normal file
File diff suppressed because it is too large
Load diff
4611
webui/static/artists.js
Normal file
4611
webui/static/artists.js
Normal file
File diff suppressed because it is too large
Load diff
3903
webui/static/beatport-ui.js
Normal file
3903
webui/static/beatport-ui.js
Normal file
File diff suppressed because it is too large
Load diff
879
webui/static/core.js
Normal file
879
webui/static/core.js
Normal file
|
|
@ -0,0 +1,879 @@
|
|||
// SoulSync WebUI JavaScript - Replicating PyQt6 GUI Functionality
|
||||
|
||||
// Global state management
|
||||
let currentPage = 'dashboard';
|
||||
let currentTrack = null;
|
||||
let isPlaying = false;
|
||||
let mediaPlayerExpanded = false;
|
||||
let searchResults = [];
|
||||
let currentStream = {
|
||||
status: 'stopped',
|
||||
progress: 0,
|
||||
track: null
|
||||
};
|
||||
let currentMusicSourceName = 'Spotify'; // 'Spotify', 'iTunes', or 'Deezer' - updated from status endpoint
|
||||
|
||||
// Streaming state management (enhanced functionality)
|
||||
let streamStatusPoller = null;
|
||||
let audioPlayer = null;
|
||||
let streamPollingRetries = 0;
|
||||
let streamPollingInterval = 1000; // Start with 1-second polling
|
||||
const maxStreamPollingRetries = 10;
|
||||
let allSearchResults = [];
|
||||
let currentFilterType = 'all';
|
||||
let currentFilterFormat = 'all';
|
||||
let currentSortBy = 'quality_score';
|
||||
let isSortReversed = false;
|
||||
let searchAbortController = null;
|
||||
let dbStatsInterval = null;
|
||||
let dbUpdateStatusInterval = null;
|
||||
let qualityScannerStatusInterval = null;
|
||||
let duplicateCleanerStatusInterval = null;
|
||||
let wishlistCountInterval = null;
|
||||
let wishlistCountdownInterval = null; // Countdown timer for wishlist overview modal
|
||||
let watchlistCountdownInterval = null; // Countdown timer for watchlist overview modal
|
||||
|
||||
// Page state for Watchlist & Wishlist sidebar pages
|
||||
let watchlistPageState = { isInitialized: false, artists: [] };
|
||||
let wishlistPageState = { isInitialized: false };
|
||||
|
||||
// --- Add these globals for the Sync Page ---
|
||||
let spotifyPlaylists = [];
|
||||
let selectedPlaylists = new Set();
|
||||
let activeSyncPollers = {}; // Key: playlist_id, Value: intervalId
|
||||
// Phase 5: WebSocket sync/discovery/scan state
|
||||
let _syncProgressCallbacks = {};
|
||||
let _discoveryProgressCallbacks = {};
|
||||
let _lastWatchlistScanStatus = null;
|
||||
let _lastMediaScanStatus = null;
|
||||
let _lastWishlistStats = null;
|
||||
let playlistTrackCache = {}; // Key: playlist_id, Value: tracks array
|
||||
let spotifyPlaylistsLoaded = false;
|
||||
let activeDownloadProcesses = {};
|
||||
let sequentialSyncManager = null;
|
||||
|
||||
// --- YouTube Playlist State Management ---
|
||||
let youtubePlaylistStates = {}; // Key: url_hash, Value: playlist state
|
||||
let activeYouTubePollers = {}; // Key: url_hash, Value: intervalId
|
||||
|
||||
// --- Tidal Playlist State Management (Similar to YouTube but loads from API like Spotify) ---
|
||||
let tidalPlaylists = [];
|
||||
let tidalPlaylistStates = {}; // Key: playlist_id, Value: playlist state with phases
|
||||
let tidalPlaylistsLoaded = false;
|
||||
let deezerPlaylists = [];
|
||||
let deezerPlaylistStates = {};
|
||||
let deezerArlPlaylists = [];
|
||||
let deezerArlPlaylistsLoaded = false;
|
||||
|
||||
// --- Beatport Chart State Management (Similar to YouTube/Tidal) ---
|
||||
let beatportChartStates = {}; // Key: chart_hash, Value: chart state with phases
|
||||
let beatportContentState = {
|
||||
loaded: false,
|
||||
loadingPromise: null,
|
||||
abortController: null
|
||||
};
|
||||
|
||||
function getBeatportContentSignal() {
|
||||
return beatportContentState.abortController ? beatportContentState.abortController.signal : null;
|
||||
}
|
||||
|
||||
function throwIfBeatportLoadAborted() {
|
||||
if (beatportContentState.abortController && beatportContentState.abortController.signal.aborted) {
|
||||
throw new DOMException('Beatport load aborted', 'AbortError');
|
||||
}
|
||||
}
|
||||
|
||||
function stopBeatportDiscoveryAndSyncPolling() {
|
||||
Object.entries(activeYouTubePollers).forEach(([identifier, poller]) => {
|
||||
const isBeatportChart = !!youtubePlaylistStates[identifier]?.is_beatport_playlist ||
|
||||
!!beatportChartStates[identifier];
|
||||
if (isBeatportChart) {
|
||||
clearInterval(poller);
|
||||
delete activeYouTubePollers[identifier];
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(_discoveryProgressCallbacks).forEach(([identifier]) => {
|
||||
const isBeatportChart = !!youtubePlaylistStates[identifier]?.is_beatport_playlist ||
|
||||
!!beatportChartStates[identifier];
|
||||
if (isBeatportChart) {
|
||||
if (socketConnected) socket.emit('discovery:unsubscribe', { ids: [identifier] });
|
||||
delete _discoveryProgressCallbacks[identifier];
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(_syncProgressCallbacks).forEach(([syncPlaylistId]) => {
|
||||
const beatportState = Object.values(youtubePlaylistStates).find(state =>
|
||||
state && state.is_beatport_playlist && state.syncPlaylistId === syncPlaylistId
|
||||
);
|
||||
if (beatportState) {
|
||||
if (socketConnected) socket.emit('sync:unsubscribe', { playlist_ids: [syncPlaylistId] });
|
||||
delete _syncProgressCallbacks[syncPlaylistId];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function resetBeatportSliderInitFlags() {
|
||||
const rebuildSlider = document.getElementById('beatport-rebuild-slider');
|
||||
if (rebuildSlider) rebuildSlider.dataset.initialized = 'false';
|
||||
|
||||
const releasesSlider = document.getElementById('beatport-releases-slider');
|
||||
if (releasesSlider) releasesSlider.dataset.initialized = 'false';
|
||||
beatportReleasesSliderState.isInitialized = false;
|
||||
|
||||
beatportHypePicksSliderState.isInitialized = false;
|
||||
|
||||
const chartsSlider = document.getElementById('beatport-charts-slider');
|
||||
if (chartsSlider) chartsSlider.dataset.initialized = 'false';
|
||||
beatportChartsSliderState.isInitialized = false;
|
||||
|
||||
const djSlider = document.getElementById('beatport-dj-slider');
|
||||
if (djSlider) djSlider.dataset.initialized = 'false';
|
||||
beatportDJSliderState.isInitialized = false;
|
||||
}
|
||||
|
||||
function cleanupBeatportContent() {
|
||||
const wasLoaded = beatportContentState.loaded || !!beatportContentState.loadingPromise;
|
||||
if (!wasLoaded) return;
|
||||
|
||||
console.log('🧹 Cleaning up Beatport content...');
|
||||
|
||||
if (beatportContentState.abortController) {
|
||||
beatportContentState.abortController.abort();
|
||||
beatportContentState.abortController = null;
|
||||
}
|
||||
|
||||
stopBeatportDiscoveryAndSyncPolling();
|
||||
cleanupBeatportRebuildSlider();
|
||||
cleanupBeatportReleasesSlider();
|
||||
cleanupBeatportHypePicksSlider();
|
||||
cleanupBeatportChartsSlider();
|
||||
cleanupBeatportDJSlider();
|
||||
resetBeatportSliderInitFlags();
|
||||
|
||||
beatportContentState.loadingPromise = null;
|
||||
beatportContentState.loaded = false;
|
||||
|
||||
console.log('✅ Beatport content cleaned up');
|
||||
}
|
||||
|
||||
// --- ListenBrainz Playlist State Management (Similar to YouTube/Tidal/Beatport) ---
|
||||
let listenbrainzPlaylistStates = {}; // Key: playlist_mbid, Value: playlist state with phases
|
||||
let listenbrainzPlaylistsLoaded = false; // Track if playlists have been loaded from backend
|
||||
|
||||
// --- Artists Page State Management ---
|
||||
let artistsPageState = {
|
||||
currentView: 'search', // 'search', 'results', 'detail'
|
||||
searchQuery: '',
|
||||
searchResults: [],
|
||||
selectedArtist: null,
|
||||
sourceOverride: null, // Set when navigating from an alternate search tab
|
||||
artistDiscography: {
|
||||
albums: [],
|
||||
singles: []
|
||||
},
|
||||
cache: {
|
||||
searches: {}, // Cache search results by query
|
||||
discography: {}, // Cache discography by artist ID
|
||||
colors: {}, // Cache extracted colors by image URL
|
||||
completionData: {} // Cache completion data by artist ID
|
||||
},
|
||||
isInitialized: false // Track if the page has been initialized
|
||||
};
|
||||
|
||||
// --- Artist Downloads Management State ---
|
||||
let artistDownloadBubbles = {}; // Track artist download bubbles: artistId -> { artist, downloads: [], element }
|
||||
let artistDownloadModalOpen = false; // Track if artist download modal is open
|
||||
let downloadsUpdateTimeout = null; // Debounce downloads section updates
|
||||
|
||||
// --- Search Downloads Management State ---
|
||||
let searchDownloadBubbles = {}; // Track search download bubbles: artistName -> { artist, downloads: [] }
|
||||
let searchDownloadModalOpen = false; // Track if search download modal is open
|
||||
|
||||
// --- Beatport Downloads Management State ---
|
||||
let beatportDownloadBubbles = {}; // Track Beatport download bubbles: chartKey -> { chart: { name, image }, downloads: [] }
|
||||
let beatportDownloadsUpdateTimeout = null; // Debounce Beatport downloads section updates
|
||||
|
||||
let artistsSearchTimeout = null;
|
||||
let artistsSearchController = null;
|
||||
let artistCompletionController = null; // Track ongoing completion check to cancel when navigating away
|
||||
let similarArtistsController = null; // Track ongoing similar artists stream to cancel when navigating away
|
||||
|
||||
// --- Lazy Background Image Observer ---
|
||||
// Watches elements with data-bg-src, applies background-image when visible, unobserves after.
|
||||
const lazyBgObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const el = entry.target;
|
||||
const src = el.dataset.bgSrc;
|
||||
if (src) {
|
||||
el.style.backgroundImage = `url('${src}')`;
|
||||
delete el.dataset.bgSrc;
|
||||
}
|
||||
lazyBgObserver.unobserve(el);
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '200px' });
|
||||
|
||||
/**
|
||||
* Observe all elements with data-bg-src within a container for lazy background loading.
|
||||
*/
|
||||
function observeLazyBackgrounds(container) {
|
||||
if (!container) return;
|
||||
const elements = container.querySelectorAll('[data-bg-src]');
|
||||
elements.forEach(el => lazyBgObserver.observe(el));
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// CONFIRM DIALOG (themed replacement for native confirm())
|
||||
// ===============================
|
||||
let _confirmResolver = null;
|
||||
|
||||
function showConfirmDialog({ title = 'Confirm', message = '', confirmText = 'Confirm', cancelText = 'Cancel', destructive = false } = {}) {
|
||||
// Resolve any pending dialog as cancelled before opening a new one
|
||||
if (_confirmResolver) {
|
||||
_confirmResolver(false);
|
||||
_confirmResolver = null;
|
||||
}
|
||||
|
||||
const overlay = document.getElementById('confirm-modal-overlay');
|
||||
const titleEl = document.getElementById('confirm-modal-title');
|
||||
const messageEl = document.getElementById('confirm-modal-message');
|
||||
const confirmBtn = document.getElementById('confirm-modal-confirm');
|
||||
const cancelBtn = document.getElementById('confirm-modal-cancel');
|
||||
|
||||
titleEl.textContent = title;
|
||||
messageEl.textContent = message;
|
||||
confirmBtn.textContent = confirmText;
|
||||
cancelBtn.textContent = cancelText;
|
||||
|
||||
// Toggle destructive (red) vs primary (accent) confirm button
|
||||
confirmBtn.className = destructive
|
||||
? 'modal-button modal-button--cancel'
|
||||
: 'modal-button modal-button--primary';
|
||||
|
||||
overlay.classList.remove('hidden');
|
||||
|
||||
return new Promise(resolve => {
|
||||
_confirmResolver = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfirmDialog(result) {
|
||||
const overlay = document.getElementById('confirm-modal-overlay');
|
||||
overlay.classList.add('hidden');
|
||||
if (_confirmResolver) {
|
||||
_confirmResolver(result);
|
||||
_confirmResolver = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Nuclear confirmation dialog for mass-destructive operations.
|
||||
* User must type an exact phrase to proceed.
|
||||
*/
|
||||
function showWitnessMeDialog(orphanCount) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'confirm-modal-overlay';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--bg-secondary, #1e1e2e);border:2px solid #e74c3c;border-radius:12px;padding:28px;max-width:480px;width:90%;color:var(--text-primary, #fff);font-family:inherit;">
|
||||
<h3 style="margin:0 0 8px;color:#e74c3c;font-size:1.2em;">Mass Deletion Warning</h3>
|
||||
<p style="margin:0 0 12px;font-size:0.95em;opacity:0.9;">
|
||||
You are about to <strong>permanently delete ${orphanCount.toLocaleString()} files</strong> from your disk.
|
||||
</p>
|
||||
<p style="margin:0 0 12px;font-size:0.9em;opacity:0.75;">
|
||||
This many orphans usually means a path mismatch between your database and filesystem
|
||||
— not actual orphan files. A previous user lost their entire library this way.
|
||||
</p>
|
||||
<p style="margin:0 0 6px;font-size:0.9em;opacity:0.9;">
|
||||
To confirm you understand the risk, type <strong style="color:#e74c3c;">witness me</strong> below:
|
||||
</p>
|
||||
<input type="text" id="witness-me-input" autocomplete="off" spellcheck="false"
|
||||
placeholder="Type the phrase here..."
|
||||
style="width:100%;padding:10px;border:1px solid #555;border-radius:6px;background:var(--bg-primary, #111);color:var(--text-primary, #fff);font-size:1em;margin:8px 0 16px;box-sizing:border-box;">
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||
<button id="witness-cancel" style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:var(--text-primary, #fff);cursor:pointer;font-size:0.9em;">
|
||||
Cancel
|
||||
</button>
|
||||
<button id="witness-confirm" disabled
|
||||
style="padding:8px 20px;border:none;border-radius:6px;background:#555;color:#888;cursor:not-allowed;font-size:0.9em;font-weight:600;transition:all 0.2s;">
|
||||
Delete Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const input = overlay.querySelector('#witness-me-input');
|
||||
const confirmBtn = overlay.querySelector('#witness-confirm');
|
||||
const cancelBtn = overlay.querySelector('#witness-cancel');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const match = input.value.trim().toLowerCase() === 'witness me';
|
||||
confirmBtn.disabled = !match;
|
||||
confirmBtn.style.background = match ? '#e74c3c' : '#555';
|
||||
confirmBtn.style.color = match ? '#fff' : '#888';
|
||||
confirmBtn.style.cursor = match ? 'pointer' : 'not-allowed';
|
||||
});
|
||||
|
||||
confirmBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(overlay);
|
||||
resolve(true);
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', () => {
|
||||
document.body.removeChild(overlay);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) {
|
||||
document.body.removeChild(overlay);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => input.focus(), 100);
|
||||
});
|
||||
}
|
||||
|
||||
const MASS_ORPHAN_THRESHOLD = 20;
|
||||
|
||||
function _isMassOrphanFix(jobId, count) {
|
||||
if (count <= MASS_ORPHAN_THRESHOLD) return false;
|
||||
// Only trigger if mass_orphan flag is actually set on visible findings
|
||||
// (flag is set by backend when >50% of files are orphans — likely path mismatch)
|
||||
if (jobId === 'orphan_file_detector' || !jobId) {
|
||||
const massCards = document.querySelectorAll('.repair-finding-card[data-mass-orphan="true"]');
|
||||
if (massCards.length > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// WEBSOCKET CONNECTION MANAGER
|
||||
// ===============================
|
||||
let socket = null;
|
||||
let socketConnected = false;
|
||||
|
||||
function initializeWebSocket() {
|
||||
if (typeof io === 'undefined') {
|
||||
console.warn('Socket.IO client not loaded — falling back to HTTP polling');
|
||||
return;
|
||||
}
|
||||
|
||||
socket = io({
|
||||
transports: ['polling', 'websocket'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 10000,
|
||||
timeout: 20000
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('WebSocket connected');
|
||||
socketConnected = true;
|
||||
resubscribeDownloadBatches();
|
||||
// Re-subscribe to any active sync/discovery rooms after reconnect
|
||||
const activeSyncIds = Object.keys(_syncProgressCallbacks);
|
||||
if (activeSyncIds.length > 0) {
|
||||
socket.emit('sync:subscribe', { playlist_ids: activeSyncIds });
|
||||
console.log('🔄 Re-subscribed to sync rooms:', activeSyncIds);
|
||||
}
|
||||
const activeDiscoveryIds = Object.keys(_discoveryProgressCallbacks);
|
||||
if (activeDiscoveryIds.length > 0) {
|
||||
socket.emit('discovery:subscribe', { ids: activeDiscoveryIds });
|
||||
console.log('🔄 Re-subscribed to discovery rooms:', activeDiscoveryIds);
|
||||
}
|
||||
// Join profile room for scoped watchlist/wishlist count updates
|
||||
if (currentProfile) {
|
||||
socket.emit('profile:join', { profile_id: currentProfile.id });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', (reason) => {
|
||||
console.warn('WebSocket disconnected:', reason);
|
||||
socketConnected = false;
|
||||
});
|
||||
|
||||
socket.on('reconnect', (attemptNumber) => {
|
||||
console.log(`WebSocket reconnected after ${attemptNumber} attempts`);
|
||||
// Rejoin profile room for scoped WebSocket emits
|
||||
if (currentProfile) {
|
||||
socket.emit('profile:join', { profile_id: currentProfile.id });
|
||||
}
|
||||
// Phase 1: Full state refresh on reconnect
|
||||
fetchAndUpdateServiceStatus();
|
||||
updateWatchlistButtonCount();
|
||||
resubscribeDownloadBatches();
|
||||
// Phase 2: Refresh dashboard data if on dashboard page
|
||||
if (currentPage === 'dashboard') {
|
||||
fetchAndUpdateSystemStats();
|
||||
fetchAndUpdateActivityFeed();
|
||||
fetchAndUpdateDbStats();
|
||||
updateWishlistCount();
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 1 event listeners
|
||||
socket.on('status:update', handleServiceStatusUpdate);
|
||||
socket.on('watchlist:count', handleWatchlistCountUpdate);
|
||||
socket.on('downloads:batch_update', handleDownloadBatchUpdate);
|
||||
|
||||
// Phase 2 event listeners (dashboard pollers)
|
||||
socket.on('rate-monitor:update', _handleRateMonitorUpdate);
|
||||
socket.on('dashboard:stats', handleDashboardStats);
|
||||
socket.on('dashboard:activity', handleDashboardActivity);
|
||||
socket.on('dashboard:toast', handleDashboardToast);
|
||||
socket.on('dashboard:db_stats', handleDashboardDbStats);
|
||||
socket.on('dashboard:wishlist_count', handleDashboardWishlistCount);
|
||||
|
||||
// Phase 3 event listeners (enrichment sidebar workers)
|
||||
socket.on('enrichment:musicbrainz', (data) => updateMusicBrainzStatusFromData(data));
|
||||
socket.on('enrichment:audiodb', (data) => updateAudioDBStatusFromData(data));
|
||||
socket.on('enrichment:discogs', (data) => updateDiscogsStatusFromData(data));
|
||||
socket.on('enrichment:deezer', (data) => updateDeezerStatusFromData(data));
|
||||
socket.on('enrichment:spotify-enrichment', (data) => updateSpotifyEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:itunes-enrichment', (data) => updateiTunesEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:lastfm-enrichment', (data) => updateLastFMEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:genius-enrichment', (data) => updateGeniusEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:tidal-enrichment', (data) => updateTidalEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:qobuz-enrichment', (data) => updateQobuzEnrichmentStatusFromData(data));
|
||||
socket.on('enrichment:hydrabase', (data) => updateHydrabaseStatusFromData(data));
|
||||
socket.on('enrichment:repair', (data) => updateRepairStatusFromData(data));
|
||||
socket.on('enrichment:soulid', (data) => updateSoulIDStatusFromData(data));
|
||||
socket.on('enrichment:listening-stats', () => { }); // Status only, no UI update needed
|
||||
socket.on('repair:progress', (data) => updateRepairJobProgressFromData(data));
|
||||
|
||||
// Phase 4 event listeners (tool progress)
|
||||
socket.on('tool:stream', (data) => updateStreamStatusFromData(data));
|
||||
socket.on('tool:quality-scanner', (data) => updateQualityScanProgressFromData(data));
|
||||
socket.on('tool:duplicate-cleaner', (data) => updateDuplicateCleanProgressFromData(data));
|
||||
socket.on('tool:retag', (data) => updateRetagStatusFromData(data));
|
||||
socket.on('tool:db-update', (data) => updateDbProgressFromData(data));
|
||||
socket.on('tool:metadata', (data) => updateMetadataStatusFromData(data));
|
||||
socket.on('tool:logs', (data) => updateLogsFromData(data));
|
||||
|
||||
// Phase 5 event listeners (sync/discovery progress + scans)
|
||||
socket.on('sync:progress', (data) => updateSyncProgressFromData(data));
|
||||
socket.on('discovery:progress', (data) => updateDiscoveryProgressFromData(data));
|
||||
socket.on('scan:watchlist', (data) => updateWatchlistScanFromData(data));
|
||||
socket.on('scan:media', (data) => updateMediaScanFromData(data));
|
||||
socket.on('wishlist:stats', (data) => updateWishlistStatsFromData(data));
|
||||
// Phase 6: Automation progress
|
||||
socket.on('automation:progress', (data) => updateAutomationProgressFromData(data));
|
||||
}
|
||||
|
||||
function handleServiceStatusUpdate(data) {
|
||||
// Cache for library status card
|
||||
_lastServiceStatus = data;
|
||||
|
||||
// Same logic as fetchAndUpdateServiceStatus response handler
|
||||
updateServiceStatus('spotify', data.spotify);
|
||||
updateServiceStatus('media-server', data.media_server);
|
||||
updateServiceStatus('soulseek', data.soulseek);
|
||||
|
||||
updateSidebarServiceStatus('spotify', data.spotify);
|
||||
updateSidebarServiceStatus('media-server', data.media_server);
|
||||
updateSidebarServiceStatus('soulseek', data.soulseek);
|
||||
|
||||
// Update downloads nav badge from status push
|
||||
if (data.active_downloads !== undefined) _updateDlNavBadge(data.active_downloads);
|
||||
|
||||
// Hide sync buttons (not the page) for standalone mode — playlists still browsable/downloadable
|
||||
const isSoulsyncStandalone = data.media_server?.type === 'soulsync';
|
||||
_isSoulsyncStandalone = isSoulsyncStandalone;
|
||||
document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => {
|
||||
if (isSoulsyncStandalone) {
|
||||
btn.dataset.hiddenByStandalone = '1';
|
||||
btn.style.display = 'none';
|
||||
} else if (btn.dataset.hiddenByStandalone) {
|
||||
delete btn.dataset.hiddenByStandalone;
|
||||
btn.style.display = '';
|
||||
}
|
||||
// If not standalone and not previously hidden by standalone, leave display untouched
|
||||
// (preserves display:none on undiscovered LB/Last.fm playlist sync buttons)
|
||||
});
|
||||
|
||||
// Update enrichment service cards
|
||||
if (data.enrichment) renderEnrichmentCards(data.enrichment);
|
||||
|
||||
// Spotify rate limit / cooldown / recovery
|
||||
if (data.spotify?.rate_limited && data.spotify.rate_limit) {
|
||||
handleSpotifyRateLimit(data.spotify.rate_limit);
|
||||
_spotifyInCooldown = false;
|
||||
} else if (data.spotify?.post_ban_cooldown > 0) {
|
||||
if (_spotifyRateLimitShown && !_spotifyInCooldown) {
|
||||
_spotifyRateLimitShown = false;
|
||||
_spotifyInCooldown = true;
|
||||
closeRateLimitModal();
|
||||
showToast('Spotify ban expired \u2014 recovering shortly', 'info');
|
||||
}
|
||||
} else {
|
||||
if (_spotifyInCooldown) {
|
||||
_spotifyInCooldown = false;
|
||||
showToast('Spotify access restored', 'success');
|
||||
if (currentPage === 'discover') {
|
||||
loadDiscoverPage();
|
||||
}
|
||||
} else if (_spotifyRateLimitShown) {
|
||||
handleSpotifyRateLimit(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _updateHeroBtnCount(buttonId, badgeId, count) {
|
||||
const badge = document.getElementById(badgeId);
|
||||
if (badge) {
|
||||
badge.textContent = count;
|
||||
badge.classList.toggle('has-items', count > 0);
|
||||
}
|
||||
}
|
||||
|
||||
function handleWatchlistCountUpdate(data) {
|
||||
if (data.success) {
|
||||
_updateHeroBtnCount('watchlist-button', 'watchlist-badge', data.count);
|
||||
// Update sidebar nav badge
|
||||
const wlNavBadge = document.getElementById('watchlist-nav-badge');
|
||||
if (wlNavBadge) {
|
||||
wlNavBadge.textContent = data.count;
|
||||
wlNavBadge.classList.toggle('hidden', data.count === 0);
|
||||
}
|
||||
const watchlistButton = document.getElementById('watchlist-button');
|
||||
if (watchlistButton) {
|
||||
const countdownText = data.next_run_in_seconds ? formatCountdownTime(data.next_run_in_seconds) : '';
|
||||
if (countdownText) {
|
||||
watchlistButton.title = `Next auto-scan in ${countdownText}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadBatchUpdate(payload) {
|
||||
const { batch_id, data } = payload;
|
||||
// Find which playlistId maps to this batch_id
|
||||
for (const [playlistId, process] of Object.entries(activeDownloadProcesses)) {
|
||||
if (process.batchId === batch_id) {
|
||||
processModalStatusUpdate(playlistId, data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resubscribeDownloadBatches() {
|
||||
if (!socket || !socketConnected) return;
|
||||
const activeBatchIds = [];
|
||||
Object.entries(activeDownloadProcesses).forEach(([playlistId, process]) => {
|
||||
if (process.batchId && (process.status === 'running' || process.status === 'complete')) {
|
||||
activeBatchIds.push(process.batchId);
|
||||
}
|
||||
});
|
||||
if (activeBatchIds.length > 0) {
|
||||
socket.emit('downloads:subscribe', { batch_ids: activeBatchIds });
|
||||
console.log(`WebSocket subscribed to ${activeBatchIds.length} download batches`);
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToDownloadBatch(batchId) {
|
||||
if (socket && socketConnected && batchId) {
|
||||
socket.emit('downloads:subscribe', { batch_ids: [batchId] });
|
||||
}
|
||||
}
|
||||
|
||||
function unsubscribeFromDownloadBatch(batchId) {
|
||||
if (socket && socketConnected && batchId) {
|
||||
socket.emit('downloads:unsubscribe', { batch_ids: [batchId] });
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 2: Dashboard event handlers ---
|
||||
|
||||
function handleDashboardStats(data) {
|
||||
// Same logic as fetchAndUpdateSystemStats response handler
|
||||
updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading');
|
||||
updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session');
|
||||
updateStatCard('download-speed-card', data.download_speed, 'Combined speed');
|
||||
updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing');
|
||||
updateStatCard('uptime-card', data.uptime, 'Application runtime');
|
||||
updateStatCard('memory-card', data.memory_usage, 'Current usage');
|
||||
}
|
||||
|
||||
function handleDashboardActivity(data) {
|
||||
// Same logic as fetchAndUpdateActivityFeed response handler
|
||||
updateActivityFeed(data.activities || []);
|
||||
}
|
||||
|
||||
function handleDashboardToast(activity) {
|
||||
// Same logic as checkForActivityToasts response handler
|
||||
let toastType = 'info';
|
||||
if (activity.icon === '\u2705' || activity.title.includes('Complete')) {
|
||||
toastType = 'success';
|
||||
} else if (activity.icon === '\u274C' || activity.title.includes('Failed') || activity.title.includes('Error')) {
|
||||
toastType = 'error';
|
||||
} else if (activity.icon === '\uD83D\uDEAB' || activity.title.includes('Cancelled')) {
|
||||
toastType = 'warning';
|
||||
}
|
||||
showToast(`${activity.title}: ${activity.subtitle}`, toastType);
|
||||
}
|
||||
|
||||
function handleDashboardDbStats(stats) {
|
||||
// Same logic as fetchAndUpdateDbStats response handler
|
||||
updateDashboardStatCards(stats);
|
||||
updateDbUpdaterCardInfo(stats);
|
||||
}
|
||||
|
||||
function handleDashboardWishlistCount(data) {
|
||||
const count = data.count || 0;
|
||||
_updateHeroBtnCount('wishlist-button', 'wishlist-badge', count);
|
||||
// Update sidebar nav badge
|
||||
const wlNavBadge = document.getElementById('wishlist-nav-badge');
|
||||
if (wlNavBadge) {
|
||||
wlNavBadge.textContent = count;
|
||||
wlNavBadge.classList.toggle('hidden', count === 0);
|
||||
}
|
||||
const wishlistButton = document.getElementById('wishlist-button');
|
||||
if (wishlistButton) {
|
||||
if (count === 0) {
|
||||
wishlistButton.classList.remove('wishlist-active');
|
||||
wishlistButton.classList.add('wishlist-inactive');
|
||||
} else {
|
||||
wishlistButton.classList.remove('wishlist-inactive');
|
||||
wishlistButton.classList.add('wishlist-active');
|
||||
}
|
||||
}
|
||||
checkForAutoInitiatedWishlistProcess();
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// END WEBSOCKET CONNECTION MANAGER
|
||||
// ===============================
|
||||
|
||||
// --- Service Integration Logo Constants ---
|
||||
const MUSICBRAINZ_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/9/9e/MusicBrainz_Logo_%282016%29.svg/500px-MusicBrainz_Logo_%282016%29.svg.png';
|
||||
const DEEZER_LOGO_URL = 'https://cdn.brandfetch.io/idEUKgCNtu/theme/dark/symbol.svg?c=1bxid64Mup7aczewSAYMX&t=1758260798610';
|
||||
const SPOTIFY_LOGO_URL = 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png';
|
||||
const ITUNES_LOGO_URL = 'https://upload.wikimedia.org/wikipedia/commons/thumb/d/df/ITunes_logo.svg/960px-ITunes_logo.svg.png';
|
||||
const LASTFM_LOGO_URL = 'https://www.last.fm/static/images/lastfm_avatar_twitter.52a5d69a85ac.png';
|
||||
const GENIUS_LOGO_URL = 'https://images.genius.com/8ed669cadd956443e29c70361ec4f372.1000x1000x1.png';
|
||||
const TIDAL_LOGO_URL = 'https://www.svgrepo.com/show/519734/tidal.svg';
|
||||
const QOBUZ_LOGO_URL = 'https://www.svgrepo.com/show/504778/qobuz.svg';
|
||||
const DISCOGS_LOGO_URL = 'https://www.svgrepo.com/show/305957/discogs.svg';
|
||||
function getAudioDBLogoURL() { const el = document.querySelector('img.audiodb-logo'); return el ? el.src : null; }
|
||||
|
||||
// --- Wishlist Modal Persistence State Management ---
|
||||
const WishlistModalState = {
|
||||
// Track if wishlist modal was visible before page refresh
|
||||
setVisible: function () {
|
||||
localStorage.setItem('wishlist_modal_visible', 'true');
|
||||
console.log('📱 [Modal State] Wishlist modal marked as visible in localStorage');
|
||||
},
|
||||
|
||||
setHidden: function () {
|
||||
localStorage.setItem('wishlist_modal_visible', 'false');
|
||||
console.log('📱 [Modal State] Wishlist modal marked as hidden in localStorage');
|
||||
},
|
||||
|
||||
wasVisible: function () {
|
||||
const visible = localStorage.getItem('wishlist_modal_visible') === 'true';
|
||||
console.log(`📱 [Modal State] Checking if wishlist modal was visible: ${visible}`);
|
||||
return visible;
|
||||
},
|
||||
|
||||
clear: function () {
|
||||
localStorage.removeItem('wishlist_modal_visible');
|
||||
console.log('📱 [Modal State] Cleared wishlist modal visibility state');
|
||||
},
|
||||
|
||||
// Track if user manually closed the modal during auto-processing
|
||||
setUserClosed: function () {
|
||||
localStorage.setItem('wishlist_modal_user_closed', 'true');
|
||||
console.log('📱 [Modal State] User manually closed wishlist modal during auto-processing');
|
||||
},
|
||||
|
||||
clearUserClosed: function () {
|
||||
localStorage.removeItem('wishlist_modal_user_closed');
|
||||
console.log('📱 [Modal State] Cleared user closed state');
|
||||
},
|
||||
|
||||
wasUserClosed: function () {
|
||||
const closed = localStorage.getItem('wishlist_modal_user_closed') === 'true';
|
||||
console.log(`📱 [Modal State] Checking if user closed modal: ${closed}`);
|
||||
return closed;
|
||||
}
|
||||
};
|
||||
|
||||
// Sequential Sync Manager Class
|
||||
class SequentialSyncManager {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.currentIndex = 0;
|
||||
this.isRunning = false;
|
||||
this.startTime = null;
|
||||
}
|
||||
|
||||
start(playlistIds) {
|
||||
if (this.isRunning) {
|
||||
console.warn('Sequential sync already running');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert playlist IDs to ordered array (maintain display order)
|
||||
this.queue = Array.from(playlistIds);
|
||||
this.currentIndex = 0;
|
||||
this.isRunning = true;
|
||||
this.startTime = Date.now();
|
||||
|
||||
console.log(`🚀 Starting sequential sync for ${this.queue.length} playlists:`, this.queue);
|
||||
this.updateUI();
|
||||
this.syncNext();
|
||||
}
|
||||
|
||||
async syncNext() {
|
||||
if (this.currentIndex >= this.queue.length) {
|
||||
this.complete();
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistId = this.queue[this.currentIndex];
|
||||
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||||
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`);
|
||||
|
||||
this.updateUI();
|
||||
|
||||
try {
|
||||
// Use existing single sync function
|
||||
await startPlaylistSync(playlistId);
|
||||
|
||||
// Wait for sync to complete by monitoring the poller
|
||||
await this.waitForSyncCompletion(playlistId);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, error);
|
||||
showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error');
|
||||
}
|
||||
|
||||
// Move to next playlist
|
||||
this.currentIndex++;
|
||||
setTimeout(() => this.syncNext(), 1000); // Small delay between syncs
|
||||
}
|
||||
|
||||
async waitForSyncCompletion(playlistId) {
|
||||
return new Promise((resolve) => {
|
||||
// Monitor the existing sync poller for completion
|
||||
const checkCompletion = () => {
|
||||
if (!activeSyncPollers[playlistId]) {
|
||||
// Poller stopped = sync completed
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Check again in 1 second
|
||||
setTimeout(checkCompletion, 1000);
|
||||
};
|
||||
checkCompletion();
|
||||
});
|
||||
}
|
||||
|
||||
complete() {
|
||||
const duration = ((Date.now() - this.startTime) / 1000).toFixed(1);
|
||||
const completedCount = this.queue.length;
|
||||
console.log(`🏁 Sequential sync completed in ${duration}s`);
|
||||
|
||||
this.isRunning = false;
|
||||
this.queue = [];
|
||||
this.currentIndex = 0;
|
||||
this.startTime = null;
|
||||
|
||||
// Re-enable playlist selection
|
||||
disablePlaylistSelection(false);
|
||||
|
||||
this.updateUI();
|
||||
updateRefreshButtonState(); // Refresh button state after completion
|
||||
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
|
||||
|
||||
// Hide sidebar after completion
|
||||
hideSyncSidebar();
|
||||
}
|
||||
|
||||
cancel() {
|
||||
if (!this.isRunning) return;
|
||||
|
||||
console.log('🛑 Cancelling sequential sync');
|
||||
this.isRunning = false;
|
||||
this.queue = [];
|
||||
this.currentIndex = 0;
|
||||
this.startTime = null;
|
||||
|
||||
// Re-enable playlist selection
|
||||
disablePlaylistSelection(false);
|
||||
|
||||
this.updateUI();
|
||||
updateRefreshButtonState(); // Refresh button state after cancellation
|
||||
showToast('Sequential sync cancelled', 'info');
|
||||
|
||||
// Hide sidebar after cancellation
|
||||
hideSyncSidebar();
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
const startSyncBtn = document.getElementById('start-sync-btn');
|
||||
const selectionInfo = document.getElementById('selection-info');
|
||||
|
||||
if (!this.isRunning) {
|
||||
// Reset to normal state
|
||||
if (startSyncBtn) {
|
||||
startSyncBtn.textContent = 'Start Sync';
|
||||
startSyncBtn.disabled = selectedPlaylists.size === 0;
|
||||
}
|
||||
if (selectionInfo) {
|
||||
const count = selectedPlaylists.size;
|
||||
selectionInfo.textContent = count === 0
|
||||
? 'Select playlists to sync'
|
||||
: `${count} playlist${count > 1 ? 's' : ''} selected`;
|
||||
}
|
||||
} else {
|
||||
// Show sequential sync status
|
||||
if (startSyncBtn) {
|
||||
startSyncBtn.textContent = 'Cancel Sequential Sync';
|
||||
startSyncBtn.disabled = false;
|
||||
}
|
||||
if (selectionInfo) {
|
||||
const current = this.currentIndex + 1;
|
||||
const total = this.queue.length;
|
||||
const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]);
|
||||
selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// API endpoints
|
||||
const API = {
|
||||
status: '/status',
|
||||
config: '/config',
|
||||
settings: '/api/settings',
|
||||
testConnection: '/api/test-connection',
|
||||
testDashboardConnection: '/api/test-dashboard-connection',
|
||||
playlists: '/api/playlists',
|
||||
sync: '/api/sync',
|
||||
search: '/api/search',
|
||||
artists: '/api/artists',
|
||||
activity: '/api/activity',
|
||||
stream: {
|
||||
start: '/api/stream/start',
|
||||
status: '/api/stream/status',
|
||||
toggle: '/api/stream/toggle',
|
||||
stop: '/api/stream/stop'
|
||||
}
|
||||
};
|
||||
|
||||
// Track last service status for library card (used by websocket handler in core + artists)
|
||||
let _lastServiceStatus = null;
|
||||
let _isSoulsyncStandalone = false; // Global flag: true when no media server (sync buttons hidden)
|
||||
|
||||
// ===============================
|
||||
|
||||
8921
webui/static/discover.js
Normal file
8921
webui/static/discover.js
Normal file
File diff suppressed because it is too large
Load diff
6399
webui/static/downloads.js
Normal file
6399
webui/static/downloads.js
Normal file
File diff suppressed because it is too large
Load diff
3552
webui/static/enrichment.js
Normal file
3552
webui/static/enrichment.js
Normal file
File diff suppressed because it is too large
Load diff
2359
webui/static/init.js
Normal file
2359
webui/static/init.js
Normal file
File diff suppressed because it is too large
Load diff
6653
webui/static/library.js
Normal file
6653
webui/static/library.js
Normal file
File diff suppressed because it is too large
Load diff
2399
webui/static/media-player.js
Normal file
2399
webui/static/media-player.js
Normal file
File diff suppressed because it is too large
Load diff
2875
webui/static/pages-extra.js
Normal file
2875
webui/static/pages-extra.js
Normal file
File diff suppressed because it is too large
Load diff
77957
webui/static/script.js
77957
webui/static/script.js
File diff suppressed because it is too large
Load diff
1543
webui/static/search.js
Normal file
1543
webui/static/search.js
Normal file
File diff suppressed because it is too large
Load diff
3658
webui/static/settings.js
Normal file
3658
webui/static/settings.js
Normal file
File diff suppressed because it is too large
Load diff
7576
webui/static/stats-automations.js
Normal file
7576
webui/static/stats-automations.js
Normal file
File diff suppressed because it is too large
Load diff
9077
webui/static/sync-services.js
Normal file
9077
webui/static/sync-services.js
Normal file
File diff suppressed because it is too large
Load diff
2539
webui/static/sync-spotify.js
Normal file
2539
webui/static/sync-spotify.js
Normal file
File diff suppressed because it is too large
Load diff
7170
webui/static/wishlist-tools.js
Normal file
7170
webui/static/wishlist-tools.js
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue