Add repair scanning, tracklist fallbacks, UI polling
Introduce deferred album repair scanning and robust tracklist resolution plus UI/presentation tweaks. - core/repair_worker.py: Add lazy MusicBrainz and AudioDB client accessors, per-batch folder queues, and register_folder/process_batch to defer folder scans until a batch completes. Implement cascading tracklist resolution (_resolve_album_tracklist) using Spotify/iTunes IDs, Spotify track→album lookup, album search, MusicBrainz release lookup, and AudioDB→MusicBrainz fallback. Add helpers to read Spotify track IDs, MusicBrainz album IDs, album/artist tags from files, MB/AudioDB fetchers, placeholder-ID filtering, and rename associated .lrc files when renaming audio files. Cache/locking and background-threaded scanning included. Improves resilience to missing/placeholder IDs and avoids circular imports. - web_server.py: Register album folders for repair in post-processing and trigger repair_worker.process_batch when batches complete (multiple completion paths) so scans run after downloads finish. - webui/static/script.js: Reduce unnecessary background work by skipping many fetches when the tab is hidden, and refresh dashboard-specific data on visibility change to ensure UI updates after OAuth or tab switch. - webui/static/style.css: Replace glassmorphic backdrop-filters with opaque dark gradients for sidebar and main content to improve GPU rendering and visual consistency. Overall: Adds reliable post-download album repair scanning using multiple metadata sources and reduces unnecessary client polling and heavy CSS effects for better performance and robustness.
This commit is contained in:
parent
129f69fce9
commit
666e65ed3b
4 changed files with 465 additions and 30 deletions
|
|
@ -61,6 +61,22 @@ class RepairWorker:
|
|||
# SpotifyClient (lazy-init to avoid circular imports)
|
||||
self._spotify_client = None
|
||||
|
||||
# MusicBrainzClient (lazy-init)
|
||||
self._mb_client = None
|
||||
|
||||
# AudioDBClient (lazy-init)
|
||||
self._audiodb_client = None
|
||||
|
||||
# Per-batch folder queues: batch_id -> set of folder paths
|
||||
self._batch_folders: Dict[str, set] = {}
|
||||
self._batch_folders_lock = threading.Lock()
|
||||
|
||||
# Known placeholder album IDs that are not real API identifiers
|
||||
self._placeholder_ids = {
|
||||
'wishlist_album', 'explicit_album', 'explicit_artist',
|
||||
'unknown', 'none', 'null', '',
|
||||
}
|
||||
|
||||
logger.info("Repair worker initialized (transfer_folder=%s)", self.transfer_folder)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -76,6 +92,29 @@ class RepairWorker:
|
|||
logger.error("Failed to initialize SpotifyClient: %s", e)
|
||||
return self._spotify_client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lazy client accessors
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def mb_client(self):
|
||||
if self._mb_client is None:
|
||||
try:
|
||||
from core.musicbrainz_client import MusicBrainzClient
|
||||
self._mb_client = MusicBrainzClient()
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize MusicBrainzClient: %s", e)
|
||||
return self._mb_client
|
||||
|
||||
@property
|
||||
def audiodb_client(self):
|
||||
if self._audiodb_client is None:
|
||||
try:
|
||||
from core.audiodb_client import AudioDBClient
|
||||
self._audiodb_client = AudioDBClient()
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize AudioDBClient: %s", e)
|
||||
return self._audiodb_client
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lifecycle (identical to AudioDB worker)
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -263,6 +302,54 @@ class RepairWorker:
|
|||
self.stats['pending'] -= 1
|
||||
time.sleep(1) # Rate limit for API calls
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# On-demand single-folder scan (called from post-processing)
|
||||
# ------------------------------------------------------------------
|
||||
def register_folder(self, batch_id: str, folder_path: str):
|
||||
"""Register an album folder for repair scanning when its batch completes.
|
||||
|
||||
Called during post-processing for each track. The actual scan is
|
||||
deferred until process_batch() is called at batch completion.
|
||||
"""
|
||||
if not folder_path:
|
||||
return
|
||||
with self._batch_folders_lock:
|
||||
self._batch_folders.setdefault(batch_id, set()).add(folder_path)
|
||||
|
||||
def process_batch(self, batch_id: str):
|
||||
"""Scan all folders registered for a completed batch.
|
||||
|
||||
Called when a download modal/batch finishes all its tracks.
|
||||
Runs in a background thread to avoid blocking the caller.
|
||||
"""
|
||||
with self._batch_folders_lock:
|
||||
folders = self._batch_folders.pop(batch_id, set())
|
||||
|
||||
if not folders:
|
||||
return
|
||||
|
||||
def _do_scan():
|
||||
for folder_path in folders:
|
||||
try:
|
||||
if not os.path.isdir(folder_path):
|
||||
continue
|
||||
|
||||
filenames = [
|
||||
f for f in os.listdir(folder_path)
|
||||
if os.path.splitext(f)[1].lower() in AUDIO_EXTENSIONS
|
||||
]
|
||||
if not filenames:
|
||||
continue
|
||||
|
||||
logger.info("[Repair] Batch %s scan: %s (%d files)",
|
||||
batch_id, os.path.basename(folder_path), len(filenames))
|
||||
self._repair_album_track_numbers(folder_path, filenames)
|
||||
except Exception as e:
|
||||
logger.error("[Repair] Error scanning %s for batch %s: %s",
|
||||
folder_path, batch_id, e, exc_info=True)
|
||||
|
||||
threading.Thread(target=_do_scan, daemon=True).start()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Album-level track number repair
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -314,24 +401,9 @@ class RepairWorker:
|
|||
os.path.basename(folder_path), sum(duped.values()), duped
|
||||
)
|
||||
|
||||
# --- Step 1: Read album ID from file tags ---
|
||||
album_id = None
|
||||
id_source = None
|
||||
for fpath, fname, _ in file_track_data:
|
||||
album_id, id_source = self._read_album_id_from_file(fpath)
|
||||
if album_id:
|
||||
break
|
||||
|
||||
if not album_id:
|
||||
logger.debug("No album ID found in any file in %s — skipping", folder_path)
|
||||
self.stats['skipped'] += len(filenames)
|
||||
self.stats['scanned'] += len(filenames)
|
||||
return
|
||||
|
||||
# --- Step 2: Fetch album tracklist from API (with cache) ---
|
||||
api_tracks = self._get_album_tracklist(album_id)
|
||||
# --- Step 1-2: Resolve album tracklist via cascading fallbacks ---
|
||||
api_tracks = self._resolve_album_tracklist(file_track_data, folder_path)
|
||||
if not api_tracks:
|
||||
logger.debug("API returned no tracks for album %s — skipping %s", album_id, folder_path)
|
||||
self.stats['skipped'] += len(filenames)
|
||||
self.stats['scanned'] += len(filenames)
|
||||
return
|
||||
|
|
@ -487,6 +559,150 @@ class RepairWorker:
|
|||
|
||||
return None, None
|
||||
|
||||
def _is_valid_album_id(self, album_id: Optional[str]) -> bool:
|
||||
"""Check if an album ID is a real API identifier, not a placeholder."""
|
||||
if not album_id:
|
||||
return False
|
||||
if album_id.strip().lower() in self._placeholder_ids:
|
||||
return False
|
||||
if len(album_id.strip()) < 5:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _read_spotify_track_id_from_file(self, file_path: str) -> Optional[str]:
|
||||
"""Read SPOTIFY_TRACK_ID from embedded tags."""
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.mp4 import MP4
|
||||
|
||||
audio = MutagenFile(file_path)
|
||||
if audio is None:
|
||||
return None
|
||||
|
||||
if hasattr(audio, 'tags') and audio.tags is not None:
|
||||
if isinstance(audio.tags, ID3):
|
||||
for key in ['TXXX:SPOTIFY_TRACK_ID', 'TXXX:spotify_track_id']:
|
||||
frame = audio.tags.getall(key)
|
||||
if frame and frame[0].text:
|
||||
return str(frame[0].text[0])
|
||||
|
||||
elif isinstance(audio, (FLAC, OggVorbis)):
|
||||
for key in ['spotify_track_id', 'SPOTIFY_TRACK_ID']:
|
||||
val = audio.get(key)
|
||||
if val:
|
||||
return str(val[0])
|
||||
|
||||
elif isinstance(audio, MP4):
|
||||
for key in ['----:com.apple.iTunes:SPOTIFY_TRACK_ID',
|
||||
'----:com.apple.iTunes:spotify_track_id']:
|
||||
val = audio.tags.get(key)
|
||||
if val:
|
||||
raw = val[0]
|
||||
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error reading Spotify track ID from %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
def _read_musicbrainz_album_id_from_file(self, file_path: str) -> Optional[str]:
|
||||
"""Read MusicBrainz Album Id (release MBID) from embedded tags."""
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.mp4 import MP4
|
||||
|
||||
audio = MutagenFile(file_path)
|
||||
if audio is None:
|
||||
return None
|
||||
|
||||
if hasattr(audio, 'tags') and audio.tags is not None:
|
||||
if isinstance(audio.tags, ID3):
|
||||
for key in ['TXXX:MusicBrainz Album Id', 'TXXX:MUSICBRAINZ_ALBUMID',
|
||||
'TXXX:musicbrainz_albumid']:
|
||||
frame = audio.tags.getall(key)
|
||||
if frame and frame[0].text:
|
||||
return str(frame[0].text[0])
|
||||
|
||||
elif isinstance(audio, (FLAC, OggVorbis)):
|
||||
for key in ['musicbrainz_albumid', 'MUSICBRAINZ_ALBUMID',
|
||||
'MusicBrainz Album Id']:
|
||||
val = audio.get(key)
|
||||
if val:
|
||||
return str(val[0])
|
||||
|
||||
elif isinstance(audio, MP4):
|
||||
for key in ['----:com.apple.iTunes:MusicBrainz Album Id',
|
||||
'----:com.apple.iTunes:MUSICBRAINZ_ALBUMID',
|
||||
'----:com.apple.music.albums:MUSICBRAINZ_ALBUMID']:
|
||||
val = audio.tags.get(key)
|
||||
if val:
|
||||
raw = val[0]
|
||||
return raw.decode('utf-8') if isinstance(raw, bytes) else str(raw)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error reading MusicBrainz album ID from %s: %s", file_path, e)
|
||||
return None
|
||||
|
||||
def _read_album_artist_from_file(self, file_path: str) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Read album name and artist name from embedded tags.
|
||||
Returns (album_name, artist_name)."""
|
||||
try:
|
||||
from mutagen import File as MutagenFile
|
||||
from mutagen.id3 import ID3
|
||||
from mutagen.flac import FLAC
|
||||
from mutagen.oggvorbis import OggVorbis
|
||||
from mutagen.mp4 import MP4
|
||||
|
||||
audio = MutagenFile(file_path)
|
||||
if audio is None:
|
||||
return None, None
|
||||
|
||||
album_name = None
|
||||
artist_name = None
|
||||
|
||||
if hasattr(audio, 'tags') and audio.tags is not None:
|
||||
if isinstance(audio.tags, ID3):
|
||||
frames = audio.tags.getall('TALB')
|
||||
if frames and frames[0].text:
|
||||
album_name = str(frames[0].text[0])
|
||||
# Prefer album artist (TPE2), fall back to track artist (TPE1)
|
||||
for tag in ['TPE2', 'TPE1']:
|
||||
frames = audio.tags.getall(tag)
|
||||
if frames and frames[0].text:
|
||||
artist_name = str(frames[0].text[0])
|
||||
break
|
||||
|
||||
elif isinstance(audio, (FLAC, OggVorbis)):
|
||||
val = audio.get('album')
|
||||
if val:
|
||||
album_name = str(val[0])
|
||||
for key in ['albumartist', 'artist']:
|
||||
val = audio.get(key)
|
||||
if val:
|
||||
artist_name = str(val[0])
|
||||
break
|
||||
|
||||
elif isinstance(audio, MP4):
|
||||
val = audio.tags.get('\xa9alb')
|
||||
if val:
|
||||
album_name = str(val[0])
|
||||
for key in ['aART', '\xa9ART']:
|
||||
val = audio.tags.get(key)
|
||||
if val:
|
||||
artist_name = str(val[0])
|
||||
break
|
||||
|
||||
return album_name, artist_name
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error reading album/artist from %s: %s", file_path, e)
|
||||
return None, None
|
||||
|
||||
def _read_title_tag(self, audio) -> Optional[str]:
|
||||
"""Read the title tag from an already-opened Mutagen file."""
|
||||
from mutagen.id3 import ID3
|
||||
|
|
@ -593,6 +809,178 @@ class RepairWorker:
|
|||
logger.error("Error fetching album tracks for %s: %s", album_id, e)
|
||||
return None
|
||||
|
||||
def _get_tracklist_from_musicbrainz(self, mbid: str) -> Optional[List[Dict]]:
|
||||
"""Fetch album tracklist from MusicBrainz by release MBID.
|
||||
Returns list of dicts with {name, track_number, disc_number} matching
|
||||
the Spotify tracklist format, or None on failure."""
|
||||
cache_key = f"mb:{mbid}"
|
||||
if cache_key in self._album_tracks_cache:
|
||||
return self._album_tracks_cache[cache_key]
|
||||
|
||||
client = self.mb_client
|
||||
if not client:
|
||||
logger.warning("No MusicBrainzClient available for release lookup")
|
||||
return None
|
||||
|
||||
try:
|
||||
release = client.get_release(mbid, includes=['recordings'])
|
||||
if not release or 'media' not in release:
|
||||
logger.debug("No media returned for MusicBrainz release %s", mbid)
|
||||
self._album_tracks_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
tracks = []
|
||||
for medium in release['media']:
|
||||
disc_num = medium.get('position', 1)
|
||||
for track in medium.get('tracks', []):
|
||||
tracks.append({
|
||||
'name': track.get('title', ''),
|
||||
'track_number': track.get('position'),
|
||||
'disc_number': disc_num,
|
||||
})
|
||||
|
||||
if not tracks:
|
||||
self._album_tracks_cache[cache_key] = None
|
||||
return None
|
||||
|
||||
self._album_tracks_cache[cache_key] = tracks
|
||||
return tracks
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error fetching MusicBrainz release %s: %s", mbid, e)
|
||||
return None
|
||||
|
||||
def _get_musicbrainz_id_via_audiodb(self, artist_name: str, album_name: str) -> Optional[str]:
|
||||
"""Search AudioDB for an album and extract its MusicBrainz release ID."""
|
||||
client = self.audiodb_client
|
||||
if not client:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = client.search_album(artist_name, album_name)
|
||||
if result:
|
||||
mb_id = result.get('strMusicBrainzAlbumID')
|
||||
if mb_id and mb_id.strip():
|
||||
logger.debug("AudioDB returned MusicBrainz ID %s for '%s - %s'",
|
||||
mb_id, artist_name, album_name)
|
||||
return mb_id.strip()
|
||||
except Exception as e:
|
||||
logger.debug("AudioDB lookup failed for '%s - %s': %s", artist_name, album_name, e)
|
||||
return None
|
||||
|
||||
def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]],
|
||||
folder_path: str) -> Optional[List[Dict]]:
|
||||
"""Cascading resolution to find the correct album tracklist.
|
||||
|
||||
Tries in order:
|
||||
1. SPOTIFY_ALBUM_ID from tags (skip if placeholder)
|
||||
2. ITUNES_ALBUM_ID from tags
|
||||
3. SPOTIFY_TRACK_ID → get_track_details() → real album ID (requires Spotify auth)
|
||||
4. Search Spotify/iTunes by album name + artist
|
||||
5. MusicBrainz Album Id from tags → MusicBrainz release lookup
|
||||
6. AudioDB search → MusicBrainz album ID → MusicBrainz release
|
||||
"""
|
||||
folder_name = os.path.basename(folder_path)
|
||||
|
||||
# --- Collect all available IDs from files in one pass ---
|
||||
spotify_album_id = None
|
||||
itunes_album_id = None
|
||||
spotify_track_id = None
|
||||
mb_album_id = None
|
||||
album_name = None
|
||||
artist_name = None
|
||||
|
||||
for fpath, fname, _ in file_track_data:
|
||||
# Album IDs (split Spotify and iTunes)
|
||||
if not spotify_album_id or not itunes_album_id:
|
||||
aid, source = self._read_album_id_from_file(fpath)
|
||||
if aid and source == 'spotify' and not spotify_album_id:
|
||||
spotify_album_id = aid
|
||||
elif aid and source == 'itunes' and not itunes_album_id:
|
||||
itunes_album_id = aid
|
||||
|
||||
# Spotify track ID
|
||||
if not spotify_track_id:
|
||||
spotify_track_id = self._read_spotify_track_id_from_file(fpath)
|
||||
|
||||
# MusicBrainz album ID
|
||||
if not mb_album_id:
|
||||
mb_album_id = self._read_musicbrainz_album_id_from_file(fpath)
|
||||
|
||||
# Album name + artist
|
||||
if not album_name:
|
||||
album_name, artist_name = self._read_album_artist_from_file(fpath)
|
||||
|
||||
# Stop early if we have everything
|
||||
if (spotify_album_id and itunes_album_id and spotify_track_id
|
||||
and mb_album_id and album_name):
|
||||
break
|
||||
|
||||
# --- Fallback 1: Spotify album ID ---
|
||||
if spotify_album_id and self._is_valid_album_id(spotify_album_id):
|
||||
tracks = self._get_album_tracklist(spotify_album_id)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via Spotify album ID: %s", folder_name, spotify_album_id)
|
||||
return tracks
|
||||
|
||||
# --- Fallback 2: iTunes album ID ---
|
||||
if itunes_album_id and self._is_valid_album_id(itunes_album_id):
|
||||
tracks = self._get_album_tracklist(itunes_album_id)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via iTunes album ID: %s", folder_name, itunes_album_id)
|
||||
return tracks
|
||||
|
||||
# --- Fallback 3: Spotify track ID → discover album ID (requires Spotify auth) ---
|
||||
client = self.spotify_client
|
||||
if spotify_track_id and client and client.is_spotify_authenticated():
|
||||
try:
|
||||
track_details = client.get_track_details(spotify_track_id)
|
||||
if track_details and track_details.get('album', {}).get('id'):
|
||||
real_album_id = track_details['album']['id']
|
||||
tracks = self._get_album_tracklist(real_album_id)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via Spotify track ID %s → album %s",
|
||||
folder_name, spotify_track_id, real_album_id)
|
||||
return tracks
|
||||
except Exception as e:
|
||||
logger.debug("Spotify track lookup failed for %s: %s", spotify_track_id, e)
|
||||
|
||||
# --- Fallback 4: Search Spotify/iTunes by album name + artist ---
|
||||
if album_name and client:
|
||||
try:
|
||||
query = f"{artist_name} {album_name}" if artist_name else album_name
|
||||
results = client.search_albums(query, limit=5)
|
||||
if results:
|
||||
# Pick the first result (best match from API)
|
||||
best = results[0]
|
||||
tracks = self._get_album_tracklist(best.id)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via album search: '%s' → %s",
|
||||
folder_name, query, best.id)
|
||||
return tracks
|
||||
except Exception as e:
|
||||
logger.debug("Album search failed for '%s': %s", album_name, e)
|
||||
|
||||
# --- Fallback 5: MusicBrainz album ID from tags ---
|
||||
if mb_album_id:
|
||||
tracks = self._get_tracklist_from_musicbrainz(mb_album_id)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via MusicBrainz album ID: %s", folder_name, mb_album_id)
|
||||
return tracks
|
||||
|
||||
# --- Fallback 6: AudioDB → MusicBrainz ---
|
||||
if album_name and artist_name:
|
||||
adb_mb_id = self._get_musicbrainz_id_via_audiodb(artist_name, album_name)
|
||||
if adb_mb_id and adb_mb_id != mb_album_id: # Don't retry same MBID
|
||||
tracks = self._get_tracklist_from_musicbrainz(adb_mb_id)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via AudioDB → MusicBrainz: %s",
|
||||
folder_name, adb_mb_id)
|
||||
return tracks
|
||||
|
||||
logger.warning("[Repair] %s — all tracklist resolution strategies exhausted", folder_name)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Title matching
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -694,6 +1082,15 @@ class RepairWorker:
|
|||
|
||||
os.rename(file_path, new_path)
|
||||
logger.info("Renamed: %s → %s", filename, new_filename)
|
||||
|
||||
# Rename associated .lrc file if it exists
|
||||
lrc_path = os.path.join(parent_dir, basename + '.lrc')
|
||||
if os.path.isfile(lrc_path):
|
||||
new_lrc_path = os.path.join(parent_dir, new_basename + '.lrc')
|
||||
if not os.path.exists(new_lrc_path):
|
||||
os.rename(lrc_path, new_lrc_path)
|
||||
logger.info("Renamed LRC: %s.lrc → %s.lrc", basename, new_basename)
|
||||
|
||||
return new_path
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -10265,6 +10265,17 @@ def _post_process_matched_download(context_key, context, file_path):
|
|||
|
||||
print(f"✅ Post-processing complete for: {context.get('_final_processed_path', final_path)}")
|
||||
|
||||
# REPAIR: Register album folder for repair scanning when batch completes
|
||||
try:
|
||||
completed_path = context.get('_final_processed_path', final_path)
|
||||
batch_id_for_repair = context.get('batch_id')
|
||||
if completed_path and batch_id_for_repair and repair_worker:
|
||||
album_folder = os.path.dirname(str(completed_path))
|
||||
if album_folder:
|
||||
repair_worker.register_folder(batch_id_for_repair, album_folder)
|
||||
except Exception as repair_err:
|
||||
print(f"⚠️ [Post-Process] Repair folder registration failed: {repair_err}")
|
||||
|
||||
# WISHLIST REMOVAL: Check if this track should be removed from wishlist after successful download
|
||||
try:
|
||||
_check_and_remove_from_wishlist(context)
|
||||
|
|
@ -13607,6 +13618,10 @@ def _on_download_completed(batch_id, task_id, success=True):
|
|||
print(f"🎉 [Batch Manager] Batch {batch_id} complete - stopping monitor")
|
||||
download_monitor.stop_monitoring(batch_id)
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if repair_worker:
|
||||
repair_worker.process_batch(batch_id)
|
||||
|
||||
# Mark that wishlist processing is starting (prevents premature cleanup)
|
||||
batch['wishlist_processing_started'] = True
|
||||
|
||||
|
|
@ -15863,7 +15878,11 @@ def _check_batch_completion_v2(batch_id):
|
|||
|
||||
print(f"🎉 [Completion Check V2] Batch {batch_id} complete - stopping monitor")
|
||||
download_monitor.stop_monitoring(batch_id)
|
||||
|
||||
|
||||
# REPAIR: Scan all album folders from this batch for track number issues
|
||||
if repair_worker:
|
||||
repair_worker.process_batch(batch_id)
|
||||
|
||||
# Process wishlist outside of the lock to prevent threading issues
|
||||
if all_tasks_started and no_active_workers and all_tasks_truly_finished and not has_retrying_tasks:
|
||||
# Call wishlist processing outside the lock
|
||||
|
|
|
|||
|
|
@ -355,9 +355,17 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
fetchAndUpdateServiceStatus();
|
||||
setInterval(fetchAndUpdateServiceStatus, 10000); // Every 10 seconds
|
||||
|
||||
// Refresh status immediately when user returns to this tab (e.g. after OAuth in new tab)
|
||||
// Refresh key data immediately when user returns to this tab
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) fetchAndUpdateServiceStatus();
|
||||
if (!document.hidden) {
|
||||
fetchAndUpdateServiceStatus();
|
||||
// Refresh dashboard-specific data if on dashboard
|
||||
const dashboardPage = document.getElementById('dashboard-page');
|
||||
if (dashboardPage && dashboardPage.classList.contains('active')) {
|
||||
fetchAndUpdateSystemStats();
|
||||
fetchAndUpdateActivityFeed();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start always-on download polling (batched, minimal overhead)
|
||||
|
|
@ -7970,6 +7978,7 @@ function startGlobalDownloadPolling() {
|
|||
console.log('🔄 [Global Polling] Starting batched download status polling');
|
||||
|
||||
globalDownloadStatusPoller = setInterval(async () => {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
// Get all active processes that need polling
|
||||
const activeBatchIds = [];
|
||||
const batchToPlaylistMap = {};
|
||||
|
|
@ -9374,6 +9383,7 @@ function stopDownloadPolling() {
|
|||
}
|
||||
|
||||
async function updateDownloadQueues() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/downloads/status');
|
||||
const data = await response.json();
|
||||
|
|
@ -24077,6 +24087,7 @@ function escapeHtml(text) {
|
|||
// --- Service Status and System Stats Functions ---
|
||||
|
||||
async function fetchAndUpdateServiceStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/status');
|
||||
if (!response.ok) return;
|
||||
|
|
@ -24167,6 +24178,7 @@ function updateSidebarServiceStatus(service, statusData) {
|
|||
}
|
||||
|
||||
async function fetchAndUpdateSystemStats() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/system/stats');
|
||||
if (!response.ok) return;
|
||||
|
|
@ -24202,6 +24214,7 @@ function updateStatCard(cardId, value, subtitle) {
|
|||
}
|
||||
|
||||
async function fetchAndUpdateActivityFeed() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/activity/feed');
|
||||
if (!response.ok) {
|
||||
|
|
@ -24271,6 +24284,7 @@ function updateActivityFeed(activities) {
|
|||
}
|
||||
|
||||
async function checkForActivityToasts() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/activity/toasts');
|
||||
if (!response.ok) return;
|
||||
|
|
@ -24385,6 +24399,7 @@ async function toggleWatchlist(event, artistId, artistName) {
|
|||
* Update the watchlist button count on dashboard
|
||||
*/
|
||||
async function updateWatchlistButtonCount() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/watchlist/count');
|
||||
const data = await response.json();
|
||||
|
|
@ -35656,6 +35671,7 @@ if (document.readyState === 'loading') {
|
|||
* Poll MusicBrainz status every 2 seconds and update UI
|
||||
*/
|
||||
async function updateMusicBrainzStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/musicbrainz/status');
|
||||
if (!response.ok) {
|
||||
|
|
@ -35801,6 +35817,7 @@ if (document.readyState === 'loading') {
|
|||
* Poll AudioDB status every 2 seconds and update UI
|
||||
*/
|
||||
async function updateAudioDBStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/audiodb/status');
|
||||
if (!response.ok) {
|
||||
|
|
@ -35933,6 +35950,7 @@ if (document.readyState === 'loading') {
|
|||
// ===================================================================
|
||||
|
||||
async function updateDeezerStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/deezer/status');
|
||||
if (!response.ok) {
|
||||
|
|
@ -36058,6 +36076,7 @@ if (document.readyState === 'loading') {
|
|||
// ===================================================================
|
||||
|
||||
async function updateSpotifyEnrichmentStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/spotify-enrichment/status');
|
||||
if (!response.ok) {
|
||||
|
|
@ -36191,6 +36210,7 @@ if (document.readyState === 'loading') {
|
|||
// ===================================================================
|
||||
|
||||
async function updateiTunesEnrichmentStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/itunes-enrichment/status');
|
||||
if (!response.ok) {
|
||||
|
|
@ -36312,6 +36332,7 @@ if (document.readyState === 'loading') {
|
|||
// ===================================================================
|
||||
|
||||
async function updateHydrabaseStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/hydrabase-worker/status');
|
||||
if (!response.ok) return;
|
||||
|
|
@ -36382,6 +36403,7 @@ if (document.readyState === 'loading') {
|
|||
// ===================================================================
|
||||
|
||||
async function updateRepairStatus() {
|
||||
if (document.hidden) return; // Skip polling when tab is not visible
|
||||
try {
|
||||
const response = await fetch('/api/repair/status');
|
||||
if (!response.ok) {
|
||||
|
|
|
|||
|
|
@ -36,12 +36,10 @@ body {
|
|||
.sidebar {
|
||||
width: 240px;
|
||||
|
||||
/* Apple-style liquid glassmorphic foundation */
|
||||
/* Opaque dark foundation (GPU-optimized: no backdrop-filter needed on solid dark body) */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(20, 20, 20, 0.65) 0%,
|
||||
rgba(12, 12, 12, 0.75) 100%);
|
||||
backdrop-filter: blur(40px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.8);
|
||||
rgba(18, 18, 18, 1) 0%,
|
||||
rgba(12, 12, 12, 1) 100%);
|
||||
|
||||
/* Soft translucent borders */
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
|
@ -887,12 +885,11 @@ body {
|
|||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
/* Opaque dark background (GPU-optimized: no backdrop-filter needed on solid dark body) */
|
||||
background: linear-gradient(135deg,
|
||||
rgba(12, 12, 12, 0.6) 0%,
|
||||
rgba(16, 16, 16, 0.7) 50%,
|
||||
rgba(8, 8, 8, 0.6) 100%);
|
||||
backdrop-filter: blur(40px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(1.8);
|
||||
rgba(10, 10, 10, 1) 0%,
|
||||
rgba(15, 15, 15, 1) 50%,
|
||||
rgba(11, 11, 11, 1) 100%);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue