feat: migrate LibraryCache to SQLite, add SessionStore + splash overlay
- Rewrite LibraryCache from JSON (cache dir) to SQLite (data dir with WAL) - Each track gets a stable UUID so play stats survive file renames/re-encodes - Thread-safe via PRAGMA busy_timeout + RLock - Automatic backup rotation (.backup, .bak) on every write - One-shot migration from old ~/.cache/library_cache.json - Add SessionStore (~/.local/share/session.json) for playback state - Add SplashOverlay with spinning animation on first (empty-DB) launch - _scan_library() now accepts on_progress callback for the overlay - Fix _mtime → mtime SQLite column name lookup in recently-added filter - PlaylistManager path migrated to ~/.local/share/ with auto-migration - Metadata sync: remove dead consume_play_deltas(), use library API
This commit is contained in:
+30
-14
@@ -9,7 +9,7 @@ import sys
|
||||
import time
|
||||
import logging
|
||||
import shutil
|
||||
from typing import List, Dict, Optional, Tuple, Any
|
||||
from typing import List, Dict, Optional, Tuple, Any, Callable
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
@@ -450,7 +450,8 @@ class LibraryTab(QWidget):
|
||||
return f"{size_bytes / 1024:.0f} KB"
|
||||
return f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||
|
||||
def _scan_library(self, force: bool = False):
|
||||
def _scan_library(self, force: bool = False,
|
||||
on_progress: Callable[[int, int], None] | None = None):
|
||||
output_dir = self.get_output_dir()
|
||||
if not output_dir or not os.path.isdir(output_dir):
|
||||
self.library_ready.clear()
|
||||
@@ -458,10 +459,23 @@ class LibraryTab(QWidget):
|
||||
self._refresh_library_ui()
|
||||
return
|
||||
|
||||
# Pre-count total scannable files so the overlay can show progress.
|
||||
total = 0
|
||||
if on_progress:
|
||||
for root, _dirs, files in os.walk(output_dir):
|
||||
for fname in files:
|
||||
fpath = os.path.join(root, fname)
|
||||
ext = os.path.splitext(fname)[1].lower()
|
||||
if os.path.isfile(fpath) and (ext in AUDIO_EXTS or ext in SOURCE_EXTS):
|
||||
total += 1
|
||||
if total == 0:
|
||||
total = 1
|
||||
|
||||
ready = []
|
||||
sources = []
|
||||
cache_hits = 0
|
||||
cache_misses = 0
|
||||
processed = 0
|
||||
|
||||
cache = get_library_cache()
|
||||
handler = MetadataHandler()
|
||||
@@ -473,6 +487,7 @@ class LibraryTab(QWidget):
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
|
||||
processed += 1
|
||||
cached = None if force else cache.get(fpath)
|
||||
|
||||
if cached is not None:
|
||||
@@ -483,18 +498,19 @@ class LibraryTab(QWidget):
|
||||
elif ext in SOURCE_EXTS:
|
||||
cached.pop("_mtime", None)
|
||||
sources.append(cached)
|
||||
continue
|
||||
else:
|
||||
cache_misses += 1
|
||||
if ext in AUDIO_EXTS:
|
||||
track_data = self._extract_ready_track(fpath, fname, handler)
|
||||
ready.append(track_data)
|
||||
cache.put(fpath, track_data)
|
||||
elif ext in SOURCE_EXTS:
|
||||
track_data = self._extract_source_track(fpath, fname, handler)
|
||||
sources.append(track_data)
|
||||
cache.put(fpath, track_data)
|
||||
|
||||
cache_misses += 1
|
||||
|
||||
if ext in AUDIO_EXTS:
|
||||
track_data = self._extract_ready_track(fpath, fname, handler)
|
||||
ready.append(track_data)
|
||||
cache.put(fpath, track_data)
|
||||
elif ext in SOURCE_EXTS:
|
||||
track_data = self._extract_source_track(fpath, fname, handler)
|
||||
sources.append(track_data)
|
||||
cache.put(fpath, track_data)
|
||||
if on_progress:
|
||||
on_progress(processed, total)
|
||||
|
||||
cache.save()
|
||||
|
||||
@@ -1384,7 +1400,7 @@ class LibraryTab(QWidget):
|
||||
entry = cache.get(path) if path else None
|
||||
visible = False
|
||||
if entry and isinstance(entry, dict):
|
||||
added = entry.get("_mtime", 0) or entry.get("added_at", 0) or 0
|
||||
added = entry.get("mtime", 0) or entry.get("added_at", 0) or 0
|
||||
visible = added >= cutoff
|
||||
self.library_table.setRowHidden(row, not visible)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Splash overlay shown while scanning the music library for the first time."""
|
||||
|
||||
from PyQt6.QtWidgets import QDialog, QVBoxLayout, QLabel, QWidget, QApplication
|
||||
from PyQt6.QtCore import Qt, QTimer
|
||||
from PyQt6.QtGui import QPainter, QPen, QColor, QFont
|
||||
|
||||
|
||||
class _Spinner(QWidget):
|
||||
"""A spinning arc — like a circular progress indicator."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(64, 64)
|
||||
self._angle = 0.0
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
self._timer.start(30)
|
||||
|
||||
def _tick(self):
|
||||
self._angle = (self._angle + 6) % 360
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
p.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
r = self.rect().adjusted(4, 4, -4, -4)
|
||||
|
||||
p.setPen(QPen(QColor("#e0e0e0"), 3, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||
p.drawEllipse(r)
|
||||
|
||||
p.setPen(QPen(QColor("#3b82f6"), 4, Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap))
|
||||
p.drawArc(r, int(self._angle * 16), 60 * 16)
|
||||
|
||||
|
||||
class SplashOverlay(QDialog):
|
||||
"""Modal overlay with a spinning animation + progress counter.
|
||||
|
||||
Usage::
|
||||
|
||||
splash = SplashOverlay(window)
|
||||
splash.show()
|
||||
# … scan …
|
||||
splash.set_progress(current, total)
|
||||
splash.close()
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setModal(True)
|
||||
|
||||
self._card = QWidget(self)
|
||||
self._card.setObjectName("card")
|
||||
self._card.setFixedSize(340, 240)
|
||||
|
||||
layout = QVBoxLayout(self._card)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.setSpacing(16)
|
||||
|
||||
self._spinner = _Spinner()
|
||||
layout.addWidget(self._spinner, alignment=Qt.AlignmentFlag.AlignCenter)
|
||||
|
||||
self._title = QLabel("Создаём музыкальную\nмедиатеку…")
|
||||
self._title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
f = self._title.font()
|
||||
f.setPointSize(16)
|
||||
f.setBold(True)
|
||||
self._title.setFont(f)
|
||||
layout.addWidget(self._title)
|
||||
|
||||
self._count = QLabel("")
|
||||
self._count.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
f2 = self._count.font()
|
||||
f2.setPointSize(12)
|
||||
self._count.setFont(f2)
|
||||
layout.addWidget(self._count)
|
||||
|
||||
self._card.setStyleSheet("""
|
||||
#card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
}
|
||||
QLabel {
|
||||
color: #333;
|
||||
background: transparent;
|
||||
}
|
||||
""")
|
||||
|
||||
def _recenter(self):
|
||||
if not self.parent():
|
||||
return
|
||||
pr = self.parent().rect()
|
||||
self.setGeometry(pr)
|
||||
cx = (pr.width() - self._card.width()) // 2
|
||||
cy = (pr.height() - self._card.height()) // 2
|
||||
self._card.move(cx, cy)
|
||||
|
||||
def showEvent(self, event):
|
||||
self._recenter()
|
||||
super().showEvent(event)
|
||||
QApplication.processEvents()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
self._recenter()
|
||||
super().resizeEvent(event)
|
||||
|
||||
def set_progress(self, current: int, total: int) -> None:
|
||||
"""Update the file counter shown on the overlay."""
|
||||
if total > 0:
|
||||
self._count.setText(f"Файл {current} из {total}")
|
||||
else:
|
||||
self._count.setText(f"Файл {current}")
|
||||
QApplication.processEvents()
|
||||
Reference in New Issue
Block a user