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 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…
Reference in a new issue