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:
parent
309ba994a5
commit
bfd146a4a0
@ -17,7 +17,7 @@ import requests
|
||||
from PIL import Image
|
||||
from mutagen import File as MutagenFile
|
||||
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 track_info import TrackInfo
|
||||
@ -552,7 +552,8 @@ class MetadataHandler:
|
||||
file_path: Path to audio file
|
||||
tags: Dict of tag_name -> value
|
||||
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 (0‑5 stars)
|
||||
new_cover_path: Path to new cover image, None to keep existing,
|
||||
empty string '' to remove cover art
|
||||
|
||||
@ -637,6 +638,13 @@ class MetadataHandler:
|
||||
elif 'disk' in audio:
|
||||
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 'covr' in audio:
|
||||
del audio['covr']
|
||||
@ -704,6 +712,16 @@ class MetadataHandler:
|
||||
elif 'discnumber' in audio:
|
||||
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()
|
||||
|
||||
if new_cover_path is not None:
|
||||
@ -798,6 +816,16 @@ class MetadataHandler:
|
||||
except (KeyError, TypeError):
|
||||
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:
|
||||
self._clear_cover_vorbis(audio)
|
||||
if new_cover_path != '':
|
||||
|
||||
@ -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 (0–5 stars) in ``self._original_tags['rating']``
|
||||
(only when cache has a value; file‑tag 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 (0–5 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
src/ui/star_rating.py
Normal file
107
src/ui/star_rating.py
Normal file
@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clickable 5‑star rating widget.
|
||||
|
||||
Scale: 0 (unrated) … 5 (★★★★★). Stored internally as stars;
|
||||
callers convert to/from the iPod 0–100 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()
|
||||
Loading…
x
Reference in New Issue
Block a user