Add version tracking to database backup manager & Fix radio mode next track closing modal and losing playback state
This commit is contained in:
parent
abec2965ac
commit
b3d607752b
4 changed files with 134 additions and 19 deletions
|
|
@ -7351,6 +7351,8 @@ class MusicDatabase:
|
||||||
JOIN albums al ON al.id = t.album_id
|
JOIN albums al ON al.id = t.album_id
|
||||||
JOIN artists ar ON ar.id = t.artist_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):
|
def _collect(rows, cap=None):
|
||||||
"""Append rows to collected. Stop at cap or limit."""
|
"""Append rows to collected. Stop at cap or limit."""
|
||||||
|
|
@ -7379,7 +7381,7 @@ class MusicDatabase:
|
||||||
same_artist_cap = max(5, limit * 3 // 10)
|
same_artist_cap = max(5, limit * 3 // 10)
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_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()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap])
|
""", [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
|
genre_params = [f'%{g}%' for g in all_genres] * 2
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_track_select}
|
||||||
WHERE ({genre_conditions})
|
WHERE {_file_filter} AND ({genre_conditions})
|
||||||
AND ar.name != ?
|
AND ar.name != ?
|
||||||
AND t.id NOT IN ({_exclude_placeholders()})
|
AND t.id NOT IN ({_exclude_placeholders()})
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
|
|
@ -7424,7 +7426,7 @@ class MusicDatabase:
|
||||||
tag_params = [f'%{t}%' for t in all_tags] * 2
|
tag_params = [f'%{t}%' for t in all_tags] * 2
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_track_select}
|
||||||
WHERE ({tag_conditions})
|
WHERE {_file_filter} AND ({tag_conditions})
|
||||||
AND ar.name != ?
|
AND ar.name != ?
|
||||||
AND t.id NOT IN ({_exclude_placeholders()})
|
AND t.id NOT IN ({_exclude_placeholders()})
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
|
|
@ -7437,7 +7439,7 @@ class MusicDatabase:
|
||||||
if len(collected) < limit:
|
if len(collected) < limit:
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
{_track_select}
|
{_track_select}
|
||||||
WHERE t.id NOT IN ({_exclude_placeholders()})
|
WHERE {_file_filter} AND t.id NOT IN ({_exclude_placeholders()})
|
||||||
ORDER BY RANDOM()
|
ORDER BY RANDOM()
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""", _exclude_values() + [limit - len(collected)])
|
""", _exclude_values() + [limit - len(collected)])
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ from config.settings import config_manager
|
||||||
# Initialize logger
|
# Initialize logger
|
||||||
logger = get_logger("web_server")
|
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
|
# Dedicated source reuse logger — writes to logs/source_reuse.log
|
||||||
import logging as _logging
|
import logging as _logging
|
||||||
import logging.handlers as _logging_handlers
|
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.
|
This provides the same data that the GUI version modal displays.
|
||||||
"""
|
"""
|
||||||
version_data = {
|
version_data = {
|
||||||
"version": "1.8",
|
"version": SOULSYNC_VERSION,
|
||||||
"title": "What's New in SoulSync",
|
"title": "What's New in SoulSync",
|
||||||
"subtitle": "Version 1.8 — Latest Changes",
|
"subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
|
||||||
"sections": [
|
"sections": [
|
||||||
{
|
{
|
||||||
"title": "🎵 Now Playing Overhaul",
|
"title": "🎵 Now Playing Overhaul",
|
||||||
|
|
@ -16386,14 +16389,27 @@ def backup_database_endpoint():
|
||||||
dst.close()
|
dst.close()
|
||||||
src.close()
|
src.close()
|
||||||
size_mb = round(os.path.getsize(backup_path) / (1024 * 1024), 1)
|
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
|
# Rolling cleanup
|
||||||
existing = sorted(_glob.glob(f"{db_path}.backup_*"), key=os.path.getmtime)
|
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:
|
while len(existing) > max_backups:
|
||||||
try:
|
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:
|
except Exception:
|
||||||
pass
|
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:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
@ -16414,11 +16430,21 @@ def list_backups_endpoint():
|
||||||
if not _BACKUP_FILENAME_RE.match(fname):
|
if not _BACKUP_FILENAME_RE.match(fname):
|
||||||
continue
|
continue
|
||||||
stat = os.stat(fp)
|
stat = os.stat(fp)
|
||||||
backups.append({
|
entry = {
|
||||||
'filename': fname,
|
'filename': fname,
|
||||||
'size_mb': round(stat.st_size / (1024 * 1024), 2),
|
'size_mb': round(stat.st_size / (1024 * 1024), 2),
|
||||||
'created': datetime.utcfromtimestamp(stat.st_mtime).isoformat()
|
'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
|
db_size_mb = round(os.path.getsize(db_path) / (1024 * 1024), 2) if os.path.exists(db_path) else 0
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
|
|
@ -16440,6 +16466,13 @@ def delete_backup_endpoint(filename):
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
return jsonify({"success": False, "error": "Backup not found"}), 404
|
return jsonify({"success": False, "error": "Backup not found"}), 404
|
||||||
os.remove(backup_path)
|
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})
|
return jsonify({"success": True, "deleted": filename})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
@ -16457,6 +16490,31 @@ def restore_backup_endpoint(filename):
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
return jsonify({"success": False, "error": "Backup not found"}), 404
|
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
|
# Create safety backup of current DB before restoring
|
||||||
safety_ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
safety_ts = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
safety_filename = f"music_library.db.backup_{safety_ts}"
|
safety_filename = f"music_library.db.backup_{safety_ts}"
|
||||||
|
|
@ -16466,6 +16524,12 @@ def restore_backup_endpoint(filename):
|
||||||
src_conn.backup(dst_conn)
|
src_conn.backup(dst_conn)
|
||||||
dst_conn.close()
|
dst_conn.close()
|
||||||
src_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)
|
# Restore using SQLite backup API (handles concurrent access safely)
|
||||||
from database.music_database import close_database, get_database
|
from database.music_database import close_database, get_database
|
||||||
|
|
@ -16484,12 +16548,17 @@ def restore_backup_endpoint(filename):
|
||||||
cursor.execute("SELECT COUNT(*) FROM artists")
|
cursor.execute("SELECT COUNT(*) FROM artists")
|
||||||
artist_count = cursor.fetchone()[0]
|
artist_count = cursor.fetchone()[0]
|
||||||
|
|
||||||
return jsonify({
|
result = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"restored_from": filename,
|
"restored_from": filename,
|
||||||
"safety_backup": safety_filename,
|
"safety_backup": safety_filename,
|
||||||
"artist_count": artist_count
|
"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:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2327,7 +2327,10 @@ async function startAudioPlayback() {
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast(userMessage, 'error');
|
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');
|
showToast(userMessage, 'error');
|
||||||
clearTrack();
|
// Only clear track if not in queue playback — queue handles its own recovery
|
||||||
|
if (npQueue.length === 0) {
|
||||||
|
clearTrack();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
@ -3275,8 +3281,15 @@ async function playQueueItem(index) {
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Queue playback error:', error);
|
console.error('Queue playback error:', error);
|
||||||
showToast(`Playback error: ${error.message}`, 'error');
|
showToast(`Skipping track: ${error.message}`, 'error');
|
||||||
hideLoadingAnimation();
|
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 {
|
} finally {
|
||||||
npLoadingQueueItem = false;
|
npLoadingQueueItem = false;
|
||||||
}
|
}
|
||||||
|
|
@ -16340,10 +16353,12 @@ function renderBackupList(backups) {
|
||||||
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
const dateStr = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
+ ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
+ ' ' + date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
const safeName = escapeForInlineJs(b.filename);
|
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">
|
return `<div class="backup-list-item">
|
||||||
<div class="backup-list-info">
|
<div class="backup-list-info">
|
||||||
<span class="backup-list-date">${escapeHtml(dateStr)}</span>
|
<span class="backup-list-date">${escapeHtml(dateStr)}</span>
|
||||||
<span class="backup-list-size">${b.size_mb} MB</span>
|
<span class="backup-list-size">${b.size_mb} MB</span>
|
||||||
|
${versionBadge}
|
||||||
</div>
|
</div>
|
||||||
<div class="backup-list-actions">
|
<div class="backup-list-actions">
|
||||||
<button class="backup-dl-btn" onclick="downloadBackup('${safeName}')" title="Download">DL</button>
|
<button class="backup-dl-btn" onclick="downloadBackup('${safeName}')" title="Download">DL</button>
|
||||||
|
|
@ -16385,14 +16400,34 @@ function downloadBackup(filename) {
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restoreBackup(filename) {
|
async function restoreBackup(filename, force = false) {
|
||||||
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;
|
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 {
|
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();
|
const data = await res.json();
|
||||||
if (data.success) {
|
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();
|
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 {
|
} else {
|
||||||
showToast(`Restore failed: ${data.error}`, 'error');
|
showToast(`Restore failed: ${data.error}`, 'error');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5758,6 +5758,15 @@ body {
|
||||||
color: #888;
|
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 {
|
.backup-list-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue