Add version tracking to database backup manager & Fix radio mode next track closing modal and losing playback state

pull/253/head
Broque Thomas 1 month ago
parent abec2965ac
commit b3d607752b

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

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

@ -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 ? `<span class="backup-list-version">v${escapeHtml(b.version)}</span>` : '';
return `<div class="backup-list-item">
<div class="backup-list-info">
<span class="backup-list-date">${escapeHtml(dateStr)}</span>
<span class="backup-list-size">${b.size_mb} MB</span>
${versionBadge}
</div>
<div class="backup-list-actions">
<button class="backup-dl-btn" onclick="downloadBackup('${safeName}')" title="Download">DL</button>
@ -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');
}

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

Loading…
Cancel
Save