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

View File

@ -17,7 +17,7 @@ import requests
from PIL import Image from PIL import Image
from mutagen import File as MutagenFile from mutagen import File as MutagenFile
from mutagen.mp4 import MP4, MP4Cover from mutagen.mp4 import MP4, MP4Cover
from mutagen.id3 import ID3, APIC, TIT2, TPE1, TALB, TRCK from mutagen.id3 import ID3, APIC, POPM, TIT2, TPE1, TALB, TRCK
from mutagen.easyid3 import EasyID3 from mutagen.easyid3 import EasyID3
from track_info import TrackInfo from track_info import TrackInfo
@ -552,7 +552,8 @@ class MetadataHandler:
file_path: Path to audio file file_path: Path to audio file
tags: Dict of tag_name -> value tags: Dict of tag_name -> value
Supported keys: title, artist, album, album_artist, genre, Supported keys: title, artist, album, album_artist, genre,
date, track_number, track_total, disc_number, disc_total, comment date, track_number, track_total, disc_number, disc_total,
comment, rating (05 stars)
new_cover_path: Path to new cover image, None to keep existing, new_cover_path: Path to new cover image, None to keep existing,
empty string '' to remove cover art empty string '' to remove cover art
@ -637,6 +638,13 @@ class MetadataHandler:
elif 'disk' in audio: elif 'disk' in audio:
del audio['disk'] del audio['disk']
if 'rating' in tags:
val = tags['rating'] * 20 # stars → 0-100
if val > 0:
audio['\xa9rt'] = [val]
elif '\xa9rt' in audio:
del audio['\xa9rt']
if new_cover_path == '': if new_cover_path == '':
if 'covr' in audio: if 'covr' in audio:
del audio['covr'] del audio['covr']
@ -704,6 +712,16 @@ class MetadataHandler:
elif 'discnumber' in audio: elif 'discnumber' in audio:
del audio['discnumber'] del audio['discnumber']
if 'rating' in tags:
stars = tags['rating']
popm_val = [0, 64, 128, 192, 224, 255][min(stars, 5)]
audio = ID3(file_path)
audio.delall("POPM")
if popm_val > 0:
audio.add(POPM(email="", rating=popm_val))
audio.save(file_path)
audio = EasyID3(file_path)
audio.save() audio.save()
if new_cover_path is not None: if new_cover_path is not None:
@ -798,6 +816,16 @@ class MetadataHandler:
except (KeyError, TypeError): except (KeyError, TypeError):
pass pass
if 'rating' in tags:
val = str(tags['rating'] * 20) # stars → "0" … "100"
if int(val) > 0:
audio.tags['RATING'] = val
elif 'RATING' in audio.tags:
try:
del audio.tags['RATING']
except (KeyError, TypeError):
pass
if new_cover_path is not None: if new_cover_path is not None:
self._clear_cover_vorbis(audio) self._clear_cover_vorbis(audio)
if new_cover_path != '': if new_cover_path != '':

View File

@ -31,6 +31,8 @@ from PyQt6.QtGui import QPixmap, QDragEnterEvent, QDropEvent
from metadata_handler import MetadataHandler from metadata_handler import MetadataHandler
from artwork.cache import cover_cache from artwork.cache import cover_cache
from services.itunes_search import search_itunes 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 COVER_SIZE = 250
@ -72,6 +74,8 @@ class MetadataEditorDialog(QDialog):
self._original_tags = dict(first_tags) self._original_tags = dict(first_tags)
self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0]) self._existing_cover_data = self._extract_cover_from_path(self.file_paths[0])
self._load_ratings_from_cache()
if self._batch_mode: if self._batch_mode:
all_tags = [first_tags] all_tags = [first_tags]
all_different = False all_different = False
@ -84,6 +88,31 @@ class MetadataEditorDialog(QDialog):
if tags.get(key) != self._original_tags.get(key): if tags.get(key) != self._original_tags.get(key):
self._mixed_fields.add(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]: 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.""" """Load tags from a single file. Returns dict of tag_key -> value."""
result: Dict[str, Any] = {} result: Dict[str, Any] = {}
@ -110,12 +139,40 @@ class MetadataEditorDialog(QDialog):
result[our_key] = val result[our_key] = val
result.update(self._load_track_disc(tags)) result.update(self._load_track_disc(tags))
result.update(self._load_rating_from_tags(tags, ext))
except Exception: except Exception:
pass pass
return result 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]: def _load_track_disc(self, tags) -> Dict[str, Any]:
result = {} result = {}
if tags is None: if tags is None:
@ -366,6 +423,11 @@ class MetadataEditorDialog(QDialog):
self._genre_label = QLabel("Genre:") self._genre_label = QLabel("Genre:")
form_layout.addRow(self._genre_label, self.genre_edit) 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:") self._year_label = QLabel("Year:")
form_layout.addRow(self._year_label, self.year_spin) form_layout.addRow(self._year_label, self.year_spin)
@ -429,6 +491,10 @@ class MetadataEditorDialog(QDialog):
for key in self._mixed_fields: for key in self._mixed_fields:
self._set_mixed(key) 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): def _set_mixed(self, key: str):
placeholders = { placeholders = {
'title': "(different values)", 'title': "(different values)",
@ -488,8 +554,12 @@ class MetadataEditorDialog(QDialog):
self.disc_num_spin.setValue(t.get('disc_number', 0)) self.disc_num_spin.setValue(t.get('disc_number', 0))
self.disc_total_spin.setValue(t.get('disc_total', 0)) self.disc_total_spin.setValue(t.get('disc_total', 0))
self.rating_widget.set_rating(t.get("rating", 0))
self._update_cover_display() self._update_cover_display()
def _on_rating_changed(self, stars: int) -> None:
pass # placeholder for future live-preview
def _update_cover_display(self): def _update_cover_display(self):
if self._cover_removed: if self._cover_removed:
self.cover_label.setText("No cover") self.cover_label.setText("No cover")
@ -629,6 +699,7 @@ class MetadataEditorDialog(QDialog):
'disc_number': self.disc_num_spin.value(), 'disc_number': self.disc_num_spin.value(),
'disc_total': self.disc_total_spin.value(), 'disc_total': self.disc_total_spin.value(),
'comment': self.comment_edit.text().strip(), 'comment': self.comment_edit.text().strip(),
'rating': self.rating_widget.rating(),
} }
def _collect_changes(self) -> Dict[str, Any]: def _collect_changes(self) -> Dict[str, Any]:
@ -709,6 +780,9 @@ class MetadataEditorDialog(QDialog):
else: else:
self._invalidate_cover_cache(path, changes, cover_arg) self._invalidate_cover_cache(path, changes, cover_arg)
if not failed:
self._persist_rating(paths, changes)
if failed == len(paths): if failed == len(paths):
QMessageBox.warning(self, "Error", "Failed to save metadata changes.") QMessageBox.warning(self, "Error", "Failed to save metadata changes.")
elif failed: elif failed:
@ -722,6 +796,21 @@ class MetadataEditorDialog(QDialog):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save metadata:\n{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], def _invalidate_cover_cache(self, file_path: str, changes: Dict[str, Any],
cover_arg: Optional[str]) -> None: cover_arg: Optional[str]) -> None:
"""Update cover cache after successful save.""" """Update cover cache after successful save."""

107
src/ui/star_rating.py Normal file
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()