From b3d607752b4f727bc4e0e2d3726ace266915c5ba Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 9 Mar 2026 13:10:25 -0700 Subject: [PATCH] Add version tracking to database backup manager & Fix radio mode next track closing modal and losing playback state --- database/music_database.py | 10 +++-- web_server.py | 85 ++++++++++++++++++++++++++++++++++---- webui/static/script.js | 49 ++++++++++++++++++---- webui/static/style.css | 9 ++++ 4 files changed, 134 insertions(+), 19 deletions(-) diff --git a/database/music_database.py b/database/music_database.py index cdcf5bbf..05c8fbfb 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -7351,6 +7351,8 @@ class MusicDatabase: JOIN albums al ON al.id = t.album_id JOIN artists ar ON ar.id = t.artist_id """ + # Only return tracks that have actual files on disk + _file_filter = "t.file_path IS NOT NULL AND t.file_path != ''" def _collect(rows, cap=None): """Append rows to collected. Stop at cap or limit.""" @@ -7379,7 +7381,7 @@ class MusicDatabase: same_artist_cap = max(5, limit * 3 // 10) cursor.execute(f""" {_track_select} - WHERE ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()}) + WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()}) ORDER BY RANDOM() LIMIT ? """, [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap]) @@ -7401,7 +7403,7 @@ class MusicDatabase: genre_params = [f'%{g}%' for g in all_genres] * 2 cursor.execute(f""" {_track_select} - WHERE ({genre_conditions}) + WHERE {_file_filter} AND ({genre_conditions}) AND ar.name != ? AND t.id NOT IN ({_exclude_placeholders()}) ORDER BY RANDOM() @@ -7424,7 +7426,7 @@ class MusicDatabase: tag_params = [f'%{t}%' for t in all_tags] * 2 cursor.execute(f""" {_track_select} - WHERE ({tag_conditions}) + WHERE {_file_filter} AND ({tag_conditions}) AND ar.name != ? AND t.id NOT IN ({_exclude_placeholders()}) ORDER BY RANDOM() @@ -7437,7 +7439,7 @@ class MusicDatabase: if len(collected) < limit: cursor.execute(f""" {_track_select} - WHERE t.id NOT IN ({_exclude_placeholders()}) + WHERE {_file_filter} AND t.id NOT IN ({_exclude_placeholders()}) ORDER BY RANDOM() LIMIT ? """, _exclude_values() + [limit - len(collected)]) diff --git a/web_server.py b/web_server.py index a24f7bc7..1cb9c840 100644 --- a/web_server.py +++ b/web_server.py @@ -29,6 +29,9 @@ from config.settings import config_manager # Initialize logger logger = get_logger("web_server") +# App version ā single source of truth for backup metadata, version-info endpoint, etc. +SOULSYNC_VERSION = "1.8" + # Dedicated source reuse logger ā writes to logs/source_reuse.log import logging as _logging import logging.handlers as _logging_handlers @@ -14522,9 +14525,9 @@ def get_version_info(): This provides the same data that the GUI version modal displays. """ version_data = { - "version": "1.8", + "version": SOULSYNC_VERSION, "title": "What's New in SoulSync", - "subtitle": "Version 1.8 ā Latest Changes", + "subtitle": f"Version {SOULSYNC_VERSION} ā Latest Changes", "sections": [ { "title": "šµ Now Playing Overhaul", @@ -16386,14 +16389,27 @@ def backup_database_endpoint(): dst.close() src.close() size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1) + # Write version metadata sidecar + meta_path = backup_path + '.meta.json' + try: + with open(meta_path, 'w') as mf: + json.dump({"version": SOULSYNC_VERSION, "created": timestamp}, mf) + except Exception: + pass # Non-critical ā backup still works without metadata # Rolling cleanup existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime) + # Filter out .meta.json files from the backup list + existing = [f for f in existing if not f.endswith('.meta.json')] while len(existing) > max_backups: try: - os.remove(existing.pop(0)) + removed = existing.pop(0) + os.remove(removed) + # Also remove sidecar if present + if os.path.exists(removed + '.meta.json'): + os.remove(removed + '.meta.json') except Exception: pass - return jsonify({"success": True, "backup_path": backup_path, "size_mb": size_mb}) + return jsonify({"success": True, "backup_path": backup_path, "size_mb": size_mb, "version": SOULSYNC_VERSION}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @@ -16414,11 +16430,21 @@ def list_backups_endpoint(): if not _BACKUP_FILENAME_RE.match(fname): continue stat = os.stat(fp) - backups.append({ + entry = { 'filename': fname, 'size_mb': round(stat.st_size / (1024 * 1024), 2), 'created': datetime.utcfromtimestamp(stat.st_mtime).isoformat() - }) + } + # Read version from sidecar metadata if available + meta_path = fp + '.meta.json' + if os.path.exists(meta_path): + try: + with open(meta_path, 'r') as mf: + meta = json.load(mf) + entry['version'] = meta.get('version') + except Exception: + pass + backups.append(entry) db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2) if os.path.exists(db_path) else 0 return jsonify({ 'success': True, @@ -16440,6 +16466,13 @@ def delete_backup_endpoint(filename): if not os.path.exists(backup_path): return jsonify({"success": False, "error": "Backup not found"}), 404 os.remove(backup_path) + # Also remove sidecar metadata if present + meta_path = backup_path + '.meta.json' + if os.path.exists(meta_path): + try: + os.remove(meta_path) + except Exception: + pass return jsonify({"success": True, "deleted": filename}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 @@ -16457,6 +16490,31 @@ def restore_backup_endpoint(filename): if not os.path.exists(backup_path): return jsonify({"success": False, "error": "Backup not found"}), 404 + # Check version compatibility + backup_version = None + meta_path = backup_path + '.meta.json' + if os.path.exists(meta_path): + try: + with open(meta_path, 'r') as mf: + meta = json.load(mf) + backup_version = meta.get('version') + except Exception: + pass + + version_warning = None + if backup_version and backup_version != SOULSYNC_VERSION: + # Allow restore but warn ā the caller must pass force=true to confirm + force = request.json.get('force', False) if request.is_json else False + if not force: + return jsonify({ + "success": False, + "version_mismatch": True, + "backup_version": backup_version, + "current_version": SOULSYNC_VERSION, + "error": f"This backup was created on SoulSync v{backup_version}, but you're running v{SOULSYNC_VERSION}. Restoring may cause issues. Send force=true to proceed." + }), 409 + version_warning = f"Restored from v{backup_version} backup (current: v{SOULSYNC_VERSION})" + # Create safety backup of current DB before restoring safety_ts = datetime.now().strftime('%Y%m%d_%H%M%S') safety_filename = f"music_library.db.backup_{safety_ts}" @@ -16466,6 +16524,12 @@ def restore_backup_endpoint(filename): src_conn.backup(dst_conn) dst_conn.close() src_conn.close() + # Write version metadata for the safety backup too + try: + with open(safety_path + '.meta.json', 'w') as mf: + json.dump({"version": SOULSYNC_VERSION, "created": safety_ts}, mf) + except Exception: + pass # Restore using SQLite backup API (handles concurrent access safely) from database.music_database import close_database, get_database @@ -16484,12 +16548,17 @@ def restore_backup_endpoint(filename): cursor.execute("SELECT COUNT(*) FROM artists") artist_count = cursor.fetchone()[0] - return jsonify({ + result = { "success": True, "restored_from": filename, "safety_backup": safety_filename, "artist_count": artist_count - }) + } + if backup_version: + result["backup_version"] = backup_version + if version_warning: + result["version_warning"] = version_warning + return jsonify(result) except Exception as e: return jsonify({"success": False, "error": str(e)}), 500 diff --git a/webui/static/script.js b/webui/static/script.js index 671fd7d3..54e15f4e 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -2327,7 +2327,10 @@ async function startAudioPlayback() { } showToast(userMessage, 'error'); - clearTrack(); + // Only clear track if not in queue playback mode ā queue handles its own error recovery + if (npQueue.length === 0) { + clearTrack(); + } } } @@ -2503,7 +2506,10 @@ function onAudioError(event) { } showToast(userMessage, 'error'); - clearTrack(); + // Only clear track if not in queue playback ā queue handles its own recovery + if (npQueue.length === 0) { + clearTrack(); + } } }, 2000); } @@ -3275,8 +3281,15 @@ async function playQueueItem(index) { } } catch (error) { console.error('Queue playback error:', error); - showToast(`Playback error: ${error.message}`, 'error'); + showToast(`Skipping track: ${error.message}`, 'error'); hideLoadingAnimation(); + // Auto-skip to next track on failure instead of stopping the queue + npLoadingQueueItem = false; + const nextIdx = npQueueIndex + 1; + if (nextIdx < npQueue.length) { + setTimeout(() => playQueueItem(nextIdx), 500); + } + return; } finally { npLoadingQueueItem = false; } @@ -16340,10 +16353,12 @@ function renderBackupList(backups) { const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }) + ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }); const safeName = escapeForInlineJs(b.filename); + const versionBadge = b.version ? `v${escapeHtml(b.version)}` : ''; return `