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 `
${escapeHtml(dateStr)} ${b.size_mb} MB + ${versionBadge}
@@ -16385,14 +16400,34 @@ function downloadBackup(filename) { document.body.removeChild(a); } -async function restoreBackup(filename) { - if (!await showConfirmDialog({ title: 'Restore Backup', message: `Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`, confirmText: 'Restore' })) return; +async function restoreBackup(filename, force = false) { + if (!force) { + if (!await showConfirmDialog({ title: 'Restore Backup', message: `Restore database from "${filename}"?\n\nA safety backup of the current database will be created first.`, confirmText: 'Restore' })) return; + } try { - const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, { method: 'POST' }); + const fetchOpts = { method: 'POST' }; + if (force) { + fetchOpts.headers = { 'Content-Type': 'application/json' }; + fetchOpts.body = JSON.stringify({ force: true }); + } + const res = await fetch(`/api/database/backups/${encodeURIComponent(filename)}/restore`, fetchOpts); const data = await res.json(); if (data.success) { - showToast(`Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`, 'success'); + let msg = `Database restored from ${data.restored_from} (${data.artist_count} artists). Safety backup: ${data.safety_backup}`; + if (data.version_warning) msg += `\nāš ļø ${data.version_warning}`; + showToast(msg, 'success'); await loadBackupList(); + } else if (data.version_mismatch) { + // Version mismatch — ask user to confirm + const confirmed = await showConfirmDialog({ + title: 'Version Mismatch', + message: `This backup was created on SoulSync v${data.backup_version}, but you're running v${data.current_version}.\n\nRestoring an older backup may cause issues if the database schema has changed. A safety backup will be created first.\n\nProceed anyway?`, + confirmText: 'Restore Anyway', + destructive: true + }); + if (confirmed) { + await restoreBackup(filename, true); + } } else { showToast(`Restore failed: ${data.error}`, 'error'); } diff --git a/webui/static/style.css b/webui/static/style.css index c67d043f..72ee155a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5758,6 +5758,15 @@ body { color: #888; } +.backup-list-version { + font-size: 10px; + color: var(--accent-primary, #1db954); + background: rgba(29, 185, 84, 0.12); + padding: 1px 6px; + border-radius: 4px; + font-weight: 600; +} + .backup-list-actions { display: flex; gap: 4px;