diff --git a/src/metadata_handler.py b/src/metadata_handler.py index 0d49577..951f6ce 100644 --- a/src/metadata_handler.py +++ b/src/metadata_handler.py @@ -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 != '': diff --git a/src/ui/metadata_editor.py b/src/ui/metadata_editor.py index 1a375c0..d033023 100644 --- a/src/ui/metadata_editor.py +++ b/src/ui/metadata_editor.py @@ -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.""" diff --git a/src/ui/star_rating.py b/src/ui/star_rating.py new file mode 100644 index 0000000..c5c75fd --- /dev/null +++ b/src/ui/star_rating.py @@ -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()