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 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 (0‑5 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 != '':
|
||||||
|
|||||||
@ -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 (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]:
|
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 (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]:
|
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
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