feat: add star rating to metadata editor with write support to audio tags

- ClickableStarRating widget — 5 stars with hover preview, toggle to unrate
- Rating field in MetadataEditorDialog (0–5 stars), saved to LibraryCache (0–100)
- MetadataHandler writes rating to MP3 (POPM), M4A (\xa9rt), FLAC/Ogg (RATING)
This commit is contained in:
Maksim Totmin
2026-06-01 19:59:56 +07:00
parent 309ba994a5
commit bfd146a4a0
3 changed files with 226 additions and 2 deletions
+89
View File
@@ -31,6 +31,8 @@ from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
from metadata_handler import MetadataHandler
from artwork.cache import cover_cache
from services.itunes_search import search_itunes
from library_cache import get_library_cache, save_library_cache
from .star_rating import ClickableStarRating
COVER_SIZE = 250
@@ -72,6 +74,8 @@ class MetadataEditorDialog(QDialog):
self._original_tags = dict(first_tags)
self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0])
self._load_ratings_from_cache()
if self._batch_mode:
all_tags = [first_tags]
all_different = False
@@ -84,6 +88,31 @@ class MetadataEditorDialog(QDialog):
if tags.get(key) != self._original_tags.get(key):
self._mixed_fields.add(key)
def _load_ratings_from_cache(self):
"""Load rating from LibraryCache for all selected files.
Stores the rating (05 stars) in ``self._original_tags['rating']``
(only when cache has a value; filetag rating takes precedence if
cache is empty). In batch mode, marks ``rating`` as mixed when
values differ.
"""
cache = get_library_cache()
ratings = []
for p in self.file_paths:
entry = cache.get(p)
r = entry.get("rating", 0) if entry else 0
ratings.append(max(0, min(5, round(r / 20))))
cache_val = ratings[0]
if cache_val > 0 or "rating" not in self._original_tags:
self._original_tags["rating"] = cache_val
if self._batch_mode and len(set(ratings)) > 1:
self._mixed_fields.add("rating")
self._ratings = {p: r for p, r in zip(self.file_paths, ratings)}
else:
self._ratings = {}
def _load_single_file(self, file_path: str, skip_cover: bool = False) -> Dict[str, Any]:
"""Load tags from a single file. Returns dict of tag_key -> value."""
result: Dict[str, Any] = {}
@@ -110,12 +139,40 @@ class MetadataEditorDialog(QDialog):
result[our_key] = val
result.update(self._load_track_disc(tags))
result.update(self._load_rating_from_tags(tags, ext))
except Exception:
pass
return result
@staticmethod
def _load_rating_from_tags(tags, ext: str) -> dict:
"""Read rating (05 stars) from audio file tags, if present."""
if tags is None:
return {}
try:
if ext in ('.m4a', '.aac', '.mp4') and '\xa9rt' in tags:
rt = tags['\xa9rt']
if isinstance(rt, list) and rt:
val = int(rt[0]) // 20 # 0-100 → 0-5
return {"rating": max(0, min(5, val))}
elif ext == '.mp3':
for frame in tags.getall('POPM'):
if frame.rating > 0:
pops = [0, 64, 128, 192, 224, 255]
stars = max(i for i, v in enumerate(pops) if v <= frame.rating)
return {"rating": stars}
elif ext in ('.flac', '.ogg', '.opus') and 'RATING' in tags:
raw = tags['RATING']
if isinstance(raw, list):
raw = raw[0]
val = int(str(raw)) // 20
return {"rating": max(0, min(5, val))}
except Exception:
pass
return {}
def _load_track_disc(self, tags) -> Dict[str, Any]:
result = {}
if tags is None:
@@ -366,6 +423,11 @@ class MetadataEditorDialog(QDialog):
self._genre_label = QLabel("Genre:")
form_layout.addRow(self._genre_label, self.genre_edit)
self.rating_widget = ClickableStarRating()
self.rating_widget.rating_changed.connect(self._on_rating_changed)
self._rating_label = QLabel("Rating:")
form_layout.addRow(self._rating_label, self.rating_widget)
self._year_label = QLabel("Year:")
form_layout.addRow(self._year_label, self.year_spin)
@@ -429,6 +491,10 @@ class MetadataEditorDialog(QDialog):
for key in self._mixed_fields:
self._set_mixed(key)
if "rating" in self._mixed_fields:
self.rating_widget.set_rating(0)
self._rating_label.setText("Rating: (mixed)")
def _set_mixed(self, key: str):
placeholders = {
'title': "(different values)",
@@ -488,8 +554,12 @@ class MetadataEditorDialog(QDialog):
self.disc_num_spin.setValue(t.get('disc_number', 0))
self.disc_total_spin.setValue(t.get('disc_total', 0))
self.rating_widget.set_rating(t.get("rating", 0))
self._update_cover_display()
def _on_rating_changed(self, stars: int) -> None:
pass # placeholder for future live-preview
def _update_cover_display(self):
if self._cover_removed:
self.cover_label.setText("No cover")
@@ -629,6 +699,7 @@ class MetadataEditorDialog(QDialog):
'disc_number': self.disc_num_spin.value(),
'disc_total': self.disc_total_spin.value(),
'comment': self.comment_edit.text().strip(),
'rating': self.rating_widget.rating(),
}
def _collect_changes(self) -> Dict[str, Any]:
@@ -709,6 +780,9 @@ class MetadataEditorDialog(QDialog):
else:
self._invalidate_cover_cache(path, changes, cover_arg)
if not failed:
self._persist_rating(paths, changes)
if failed == len(paths):
QMessageBox.warning(self, "Error", "Failed to save metadata changes.")
elif failed:
@@ -722,6 +796,21 @@ class MetadataEditorDialog(QDialog):
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save metadata:\n{e}")
def _persist_rating(self, paths: list[str], changes: dict[str, Any]) -> None:
if "rating" not in changes:
return
try:
cache = get_library_cache()
for p in paths:
entry = cache.get(p)
if entry is None:
continue
entry["rating"] = changes["rating"] * 20
cache.put(p, entry)
save_library_cache()
except Exception:
logger.exception("Failed to persist rating to LibraryCache")
def _invalidate_cover_cache(self, file_path: str, changes: Dict[str, Any],
cover_arg: Optional[str]) -> None:
"""Update cover cache after successful save."""
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""
Clickable 5star rating widget.
Scale: 0 (unrated) … 5 (★★★★★). Stored internally as stars;
callers convert to/from the iPod 0100 scale as needed.
"""
import math
from PyQt6.QtCore import Qt, QRectF, pyqtSignal
from PyQt6.QtGui import QPainter, QColor, QPainterPath, QBrush, QPen
from PyQt6.QtWidgets import QWidget
class ClickableStarRating(QWidget):
"""Five clickable stars with hover preview and toggle-to-unrate."""
rating_changed = pyqtSignal(int)
STAR_COUNT = 5
STAR_SIZE = 22 # diameter of each star
PADDING = 4
FILLED_COLOR = QColor("#ffb347") # warm amber
EMPTY_COLOR = QColor("#555555")
HOVER_COLOR = QColor("#ffd700") # gold
def __init__(self, parent=None):
super().__init__(parent)
self._rating = 0
self._hover_idx = -1
w = self.STAR_COUNT * self.STAR_SIZE + (self.STAR_COUNT - 1) * self.PADDING
self.setFixedSize(w, self.STAR_SIZE + 8)
self.setMouseTracking(True)
# ── public API ────────────────────────────────────────────────
def set_rating(self, stars: int) -> None:
self._rating = max(0, min(self.STAR_COUNT, stars))
self.update()
def rating(self) -> int:
return self._rating
# ── painting ───────────────────────────────────────────────────
def paintEvent(self, event): # noqa: N802
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(Qt.PenStyle.NoPen)
hovering = self._hover_idx >= 0
for i in range(self.STAR_COUNT):
if hovering:
filled = i <= self._hover_idx
else:
filled = i < self._rating
color = (
self.HOVER_COLOR if hovering and filled
else self.FILLED_COLOR if filled
else self.EMPTY_COLOR
)
painter.setBrush(QBrush(color))
cx = self.STAR_SIZE // 2 + i * (self.STAR_SIZE + self.PADDING)
cy = self.height() // 2
self._draw_star(painter, cx, cy, self.STAR_SIZE // 2 - 1)
@staticmethod
def _draw_star(painter: QPainter, cx: int, cy: int, r: int) -> None:
"""Draw a five-pointed star centred at (cx, cy) with outer radius *r*."""
path = QPainterPath()
inner_r = r * 0.4
points = []
for i in range(10):
angle = math.pi / 2 + i * math.pi / 5
radius = r if i % 2 == 0 else inner_r
x = cx + radius * math.cos(angle)
y = cy - radius * math.sin(angle)
points.append((x, y))
path.moveTo(points[0][0], points[0][1])
for x, y in points[1:]:
path.lineTo(x, y)
path.closeSubpath()
painter.drawPath(path)
# ── mouse interaction ──────────────────────────────────────────
def _star_at(self, x: int) -> int:
idx = x // (self.STAR_SIZE + self.PADDING)
return min(max(idx, 0), self.STAR_COUNT - 1)
def mousePressEvent(self, event): # noqa: N802
star = self._star_at(int(event.position().x()))
new_val = star + 1 if self._rating != star + 1 else 0
self._rating = new_val
self.rating_changed.emit(new_val)
self.update()
def mouseMoveEvent(self, event): # noqa: N802
self._hover_idx = self._star_at(int(event.position().x()))
self.update()
def leaveEvent(self, event): # noqa: N802
self._hover_idx = -1
self.update()