Merge pull request #352 from JohnBaumb/refactor/split-script-js

Split monolithic script.js into 17 domain modules
This commit is contained in:
BoulderBadgeDad 2026-04-22 10:49:17 -07:00 committed by GitHub
commit 00f116ebee
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 78235 additions and 77958 deletions

View 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}"

View file

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

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

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

8921
webui/static/discover.js Normal file

File diff suppressed because it is too large Load diff

6399
webui/static/downloads.js Normal file

File diff suppressed because it is too large Load diff

3552
webui/static/enrichment.js Normal file

File diff suppressed because it is too large Load diff

2359
webui/static/init.js Normal file

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1543
webui/static/search.js Normal file

File diff suppressed because it is too large Load diff

3658
webui/static/settings.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff