From c36091081653a38d202485b3a3b9abf67b77a47d Mon Sep 17 00:00:00 2001 From: Bonehead <47313866+B0N3head@users.noreply.github.com> Date: Mon, 4 May 2026 23:50:43 +1000 Subject: [PATCH 1/5] search debounce and fixed QPixmap conversion/scaling being recreated constantly --- GUI/app.py | 6 ++++ GUI/widgets/MBGridView.py | 9 ++++- GUI/widgets/MBGridViewItem.py | 64 +++++++++++++++++++++++++++++------ 3 files changed, 67 insertions(+), 12 deletions(-) diff --git a/GUI/app.py b/GUI/app.py index 7ec6dae..090cb3f 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -472,7 +472,9 @@ def onDeviceChanged(self, path: str): thread_pool.clear() from .imgMaker import clear_artworkdb_cache + from .widgets.MBGridViewItem import clear_pixmap_cache clear_artworkdb_cache() + clear_pixmap_cache() if self._apply_effective_theme(): self._schedule_themed_rebuild(restore_page=0) @@ -754,7 +756,9 @@ def _settle_background_device_reads_for_eject(self) -> bool: try: from .imgMaker import clear_artworkdb_cache + from .widgets.MBGridViewItem import clear_pixmap_cache clear_artworkdb_cache() + clear_pixmap_cache() except Exception: logger.debug("Failed to clear artwork cache before eject", exc_info=True) @@ -1483,7 +1487,9 @@ def _rescanAfterSync(self): # Clear artwork cache — sync may have added/changed album art from .imgMaker import clear_artworkdb_cache + from .widgets.MBGridViewItem import clear_pixmap_cache clear_artworkdb_cache() + clear_pixmap_cache() # Clear UI so the reload starts from a clean slate self.musicBrowser.reloadData() diff --git a/GUI/widgets/MBGridView.py b/GUI/widgets/MBGridView.py index e369ec3..f2c0033 100644 --- a/GUI/widgets/MBGridView.py +++ b/GUI/widgets/MBGridView.py @@ -191,6 +191,9 @@ def __init__( self._sort_key: str = "title" self._sort_reverse: bool = False self._search_query: str = "" + self._search_timer = QTimer(self) + self._search_timer.setSingleShot(True) + self._search_timer.timeout.connect(self._apply_filter_and_sort) def attachScrollArea(self, scroll_area: QScrollArea | None) -> None: """Bind the grid to a QScrollArea to drive virtualized updates.""" @@ -516,13 +519,17 @@ def setSort(self, key: str, reverse: bool = False) -> None: def setSearchFilter(self, query: str) -> None: """Filter grid items whose title contains *query* (case-insensitive).""" self._search_query = query - self._apply_filter_and_sort() + if self._search_timer.isActive(): + self._search_timer.stop() + self._search_timer.start(250) def resetFilters(self) -> None: """Reset sort and search to defaults without reloading source data.""" self._sort_key = "title" self._sort_reverse = False self._search_query = "" + if self._search_timer.isActive(): + self._search_timer.stop() self._apply_filter_and_sort() @staticmethod diff --git a/GUI/widgets/MBGridViewItem.py b/GUI/widgets/MBGridViewItem.py index 3df7344..4ae7658 100644 --- a/GUI/widgets/MBGridViewItem.py +++ b/GUI/widgets/MBGridViewItem.py @@ -1,4 +1,6 @@ import logging +from collections import OrderedDict +import threading from PyQt6.QtCore import Qt, QSize, pyqtSignal from PyQt6.QtWidgets import QLabel, QFrame, QVBoxLayout from PyQt6.QtGui import QFont, QPixmap, QCursor, QImage @@ -16,6 +18,41 @@ log = logging.getLogger(__name__) +_PIXMAP_CACHE_MAX = 500 +_pixmap_cache: OrderedDict[tuple, QPixmap] = OrderedDict() +_pixmap_cache_lock = threading.Lock() + + +def _pixmap_cache_get(key: tuple) -> QPixmap | None: + with _pixmap_cache_lock: + pix = _pixmap_cache.get(key) + if pix is not None: + _pixmap_cache.move_to_end(key) + return pix + + +def _pixmap_cache_put(key: tuple, pixmap: QPixmap) -> None: + with _pixmap_cache_lock: + _pixmap_cache[key] = pixmap + _pixmap_cache.move_to_end(key) + while len(_pixmap_cache) > _PIXMAP_CACHE_MAX: + _pixmap_cache.popitem(last=False) + + +def clear_pixmap_cache() -> None: + with _pixmap_cache_lock: + _pixmap_cache.clear() + + +def _pixmap_cache_key(mhiiLink, size: int, widget: QLabel) -> tuple | None: + if mhiiLink is None: + return None + try: + dpr = widget.devicePixelRatioF() + except Exception: + dpr = 1.0 + return (int(mhiiLink), size, int(dpr * 1000)) + class MusicBrowserGridItem(QFrame): """A clickable grid item that displays album art, title, and subtitle.""" @@ -128,17 +165,22 @@ def applyImageResult(self, pil_image, dcol, album_colors): if pil_image is not None: self._art_applied_link = self.mhiiLink - pil_image = pil_image.convert("RGBA") - data = pil_image.tobytes("raw", "RGBA") - qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888) - qimage = qimage.copy() - pixmap = scale_pixmap_for_display( - QPixmap.fromImage(qimage), - Metrics.GRID_ART_SIZE, Metrics.GRID_ART_SIZE, - widget=self.img_label, - aspect_mode=Qt.AspectRatioMode.KeepAspectRatio, - transform_mode=Qt.TransformationMode.SmoothTransformation, - ) + cache_key = _pixmap_cache_key(self.mhiiLink, Metrics.GRID_ART_SIZE, self.img_label) + pixmap = _pixmap_cache_get(cache_key) if cache_key else None + if pixmap is None: + pil_image = pil_image.convert("RGBA") + data = pil_image.tobytes("raw", "RGBA") + qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888) + qimage = qimage.copy() + pixmap = scale_pixmap_for_display( + QPixmap.fromImage(qimage), + Metrics.GRID_ART_SIZE, Metrics.GRID_ART_SIZE, + widget=self.img_label, + aspect_mode=Qt.AspectRatioMode.KeepAspectRatio, + transform_mode=Qt.TransformationMode.SmoothTransformation, + ) + if cache_key is not None: + _pixmap_cache_put(cache_key, pixmap) self.img_label.setPixmap(pixmap) self.img_label.setStyleSheet(f""" border: none; From 65b6a72ae6b9bf8a3be845ee2224173125e53f8b Mon Sep 17 00:00:00 2001 From: Bonehead <47313866+B0N3head@users.noreply.github.com> Date: Fri, 8 May 2026 18:20:12 +1000 Subject: [PATCH 2/5] decoded images no longer trigger redundant loads on scroll/sorting --- GUI/app.py | 11 +++-------- GUI/widgets/MBGridView.py | 10 +++++----- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/GUI/app.py b/GUI/app.py index 090cb3f..b0c0a46 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -867,9 +867,9 @@ def _create_back_sync_artwork_provider(self, ipod_path: str): return None try: - from GUI.imgMaker import find_image_by_img_id, get_artworkdb_cached + from GUI.imgMaker import configure_artwork_api, get_artwork - db, idx = get_artworkdb_cached(str(artworkdb_path)) + configure_artwork_api(str(artworkdb_path), str(artwork_folder)) artwork_folder_str = str(artwork_folder) except Exception: logger.debug("Back Sync artwork context unavailable", exc_info=True) @@ -887,12 +887,7 @@ def _provider(track: dict) -> bytes | None: try: import io - result = find_image_by_img_id( - db, - artwork_folder_str, - int(img_id), - img_id_index=idx, - ) + result = get_artwork(int(img_id), mode="with_colors") if not result: return None img = result[0].convert("RGB") diff --git a/GUI/widgets/MBGridView.py b/GUI/widgets/MBGridView.py index f2c0033..38859d0 100644 --- a/GUI/widgets/MBGridView.py +++ b/GUI/widgets/MBGridView.py @@ -382,11 +382,11 @@ def _apply_cached_art(self, widget: MusicBrowserGridItem) -> bool: return True try: - from ..imgMaker import get_cached_image_by_img_id + from ..imgMaker import get_artwork except Exception: return False - cached = get_cached_image_by_img_id(int(link)) + cached = get_artwork(int(link), mode="cache_only") if cached is None: return False @@ -445,19 +445,19 @@ def _load_art_batch( cancellation_token: Any, ) -> dict: """Background worker: decode artwork + colors for a batch of mhiiLinks.""" - from ..imgMaker import find_image_by_img_id, get_artworkdb_cached + from ..imgMaker import configure_artwork_api, get_artwork import os if not artworkdb_path or not os.path.exists(artworkdb_path): return {} - artworkdb_data, img_id_index = get_artworkdb_cached(artworkdb_path) + configure_artwork_api(artworkdb_path, artwork_folder) results: dict[int, tuple | None] = {} for link in links: if cancellation_token.is_cancelled(): break - result = find_image_by_img_id(artworkdb_data, artwork_folder, link, img_id_index) + result = get_artwork(int(link), mode="with_colors") if result is not None: pil_img, dcol, album_colors = result # Serialize PIL image to RGBA bytes for thread-safe transfer From 889698d525d2d11c826c39b81a83016114834353 Mon Sep 17 00:00:00 2001 From: Bonehead <47313866+B0N3head@users.noreply.github.com> Date: Fri, 8 May 2026 20:17:37 +1000 Subject: [PATCH 3/5] attempt to run concurrent fpcalc if iPod is not HDD based --- .gitignore | 1 + GUI/app.py | 18 ++-- GUI/widgets/MBGridView.py | 99 ++++++++++++++++++++- app_core/jobs.py | 175 +++++++++++++++++++++++--------------- 4 files changed, 216 insertions(+), 77 deletions(-) diff --git a/.gitignore b/.gitignore index 3ad4b07..545126f 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,4 @@ cython_debug/ # Claude Code .claude/ CLAUDE.md +uv.lock \ No newline at end of file diff --git a/GUI/app.py b/GUI/app.py index b0c0a46..50edb22 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -471,9 +471,9 @@ def onDeviceChanged(self, path: str): thread_pool = ThreadPoolSingleton.get_instance() thread_pool.clear() - from .imgMaker import clear_artworkdb_cache + from .imgMaker import clear_artwork_api from .widgets.MBGridViewItem import clear_pixmap_cache - clear_artworkdb_cache() + clear_artwork_api() clear_pixmap_cache() if self._apply_effective_theme(): @@ -755,9 +755,9 @@ def _settle_background_device_reads_for_eject(self) -> bool: logger.debug("Failed to clear music browser before eject", exc_info=True) try: - from .imgMaker import clear_artworkdb_cache + from .imgMaker import clear_artwork_api from .widgets.MBGridViewItem import clear_pixmap_cache - clear_artworkdb_cache() + clear_artwork_api() clear_pixmap_cache() except Exception: logger.debug("Failed to clear artwork cache before eject", exc_info=True) @@ -1208,9 +1208,13 @@ def _onSyncError(self, error_msg: str): self.syncReview.show_error(error_msg) def _onBackSyncComplete(self, result: dict): - """Called when Back Sync export completes.""" + """Called when Back Sync export completes or is cancelled.""" self._back_sync_worker = None + if result.get("cancelled"): + self.hideSyncReview() + return + exported = int(result.get("exported", 0) or 0) missing = int(result.get("missing_on_pc", 0) or 0) self.syncReview.show_back_sync_result(result) @@ -1481,9 +1485,9 @@ def _rescanAfterSync(self): cache.clear() # Clear artwork cache — sync may have added/changed album art - from .imgMaker import clear_artworkdb_cache + from .imgMaker import clear_artwork_api from .widgets.MBGridViewItem import clear_pixmap_cache - clear_artworkdb_cache() + clear_artwork_api() clear_pixmap_cache() # Clear UI so the reload starts from a clean slate diff --git a/GUI/widgets/MBGridView.py b/GUI/widgets/MBGridView.py index 38859d0..ab547e2 100644 --- a/GUI/widgets/MBGridView.py +++ b/GUI/widgets/MBGridView.py @@ -1,7 +1,8 @@ import difflib import logging from collections import Counter, deque -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, NamedTuple from PyQt6.QtCore import QEvent, QRect, QSize, QTimer, pyqtSignal from PyQt6.QtWidgets import QFrame, QLayout, QLayoutItem, QScrollArea, QSizePolicy @@ -136,6 +137,35 @@ def _do_layout(self, width: int, *, dry_run: bool) -> int: _VIRTUAL_SCROLL_THROTTLE_MS = 16 +# --------------------------------------------------------------------------- +# Public types used by subclasses (e.g. selectiveSyncBrowser.PCMusicBrowserGrid) +# --------------------------------------------------------------------------- + +class ArtworkResult(NamedTuple): + """Decoded artwork ready to apply to a grid item.""" + image: Any # PIL.Image.Image + dominant_color: tuple[int, int, int] + album_colors: dict + + +# Sentinel: artwork key is known but the image hasn't been decoded yet. +# Distinct from None (= no artwork for this item at all). +_ART_CACHE_UNSET = object() + + +@dataclass +class GridRecord: + """Lightweight descriptor for a single grid item that needs artwork.""" + artwork_key: Any # int (iPod mhiiLink) or str (PC _grid_art_key) or None + + +# Return type of _load_cached_artwork: +# ArtworkResult → in RAM cache, ready to apply +# None → no artwork (item has no key, or confirmed missing) +# _ART_CACHE_UNSET → key present, not yet loaded; caller should queue a load +CachedArtworkLookup = ArtworkResult | None + + class MusicBrowserGrid(QFrame): """Grid view that displays albums, artists, or genres as clickable items.""" item_selected = pyqtSignal(dict) # Emits when an item is clicked @@ -185,6 +215,7 @@ def __init__( self._items_by_link: dict[int, list[MusicBrowserGridItem]] = {} # mhiiLink -> items waiting for art self._art_pending: set[int] = set() # links currently being loaded self._art_seen: set[int] = set() # links confirmed missing artwork + self._art_cache: dict = {} # per-instance cache (used by PC subclass) # Sort / filter state self._all_items: list[dict] = [] @@ -360,6 +391,7 @@ def _normalize_item(item: dict) -> tuple[str, str, Any, dict]: "filter_value": item.get("filter_value", title), "album": item.get("album"), "artist": item.get("artist"), + "_grid_art_key": item.get("_grid_art_key"), } return title, subtitle, mhiiLink, item_data @@ -504,6 +536,70 @@ def _on_art_loaded(self, results: dict | None, load_id: int): except RuntimeError: pass # Widget deleted + # ── Extended API used by subclasses ────────────────────────────────────── + + def _set_source_items(self, items: list[dict], *, reset_scroll: bool = False) -> None: + """Set source items directly and re-apply filter/sort.""" + self._all_items = items + self._apply_filter_and_sort() + if reset_scroll and self._scroll_area is not None: + self._scroll_area.verticalScrollBar().setValue(0) + + def _load_cached_artwork(self, record: GridRecord) -> CachedArtworkLookup: + """Check the RAM cache for *record*'s artwork (iPod mode). + + Returns ArtworkResult if cached, None if confirmed missing, + or _ART_CACHE_UNSET if the key exists but hasn't been decoded yet. + """ + key = record.artwork_key + if key is None: + return None + if key in self._art_seen: + return None + try: + from ..imgMaker import get_artwork + result = get_artwork(int(key), mode="cache_only") + if result is not None: + img, dcol, colors = result + return ArtworkResult(img, dcol, colors) + except Exception: + pass + return _ART_CACHE_UNSET + + def _visible_records_needing_art(self) -> list[GridRecord]: + """Return a GridRecord for each visible item that still needs artwork.""" + records: list[GridRecord] = [] + seen: set = set() + for widget in self.gridItems: + art_key = widget.item_data.get("_grid_art_key") + if art_key is None and widget.mhiiLink is not None: + art_key = int(widget.mhiiLink) + if art_key is None: + continue + if art_key in seen or art_key in self._art_seen: + continue + if getattr(widget, "_art_applied_link", None) == widget.mhiiLink: + continue + if isinstance(art_key, str) and art_key in self._art_cache: + continue + seen.add(art_key) + records.append(GridRecord(artwork_key=art_key)) + return records + + def _apply_art_to_visible_widgets(self, key) -> None: + """Apply cached artwork for *key* to all visible widgets that want it.""" + art = self._art_cache.get(key) + for widget in self.gridItems: + widget_key = widget.item_data.get("_grid_art_key") + if widget_key is None and widget.mhiiLink is not None: + widget_key = int(widget.mhiiLink) + if widget_key != key: + continue + if art is None: + widget.applyImageResult(None, None, None) + else: + widget.applyImageResult(art.image, art.dominant_color, art.album_colors) + def _onItemClicked(self, item_data: dict): """Handle grid item click.""" self.item_selected.emit(item_data) @@ -845,6 +941,7 @@ def clearGrid(self, preserve_all_items: bool = False): self._items_by_link.clear() self._art_pending.clear() self._art_seen.clear() + self._art_cache.clear() self._item_widgets_by_key.clear() self._item_order_keys = [] self._virtual_items = [] diff --git a/app_core/jobs.py b/app_core/jobs.py index 897400e..609d18b 100644 --- a/app_core/jobs.py +++ b/app_core/jobs.py @@ -598,6 +598,7 @@ def _short_label(value: str, limit: int = 72) -> str: return text[:keep] + "..." def run(self) -> None: + _finished_emitted = False try: from SyncEngine._formats import MEDIA_EXTENSIONS from SyncEngine.audio_fingerprint import get_or_compute_fingerprint @@ -614,53 +615,55 @@ def run(self) -> None: pc_tracks = list(pc_library.scan(include_video=True)) total_pc = len(pc_tracks) - self.progress.emit( - "backsync_pc_fingerprint", - 0, - total_pc, - ( - f"Building fingerprints for {total_pc:,} PC track" - f"{'s' if total_pc != 1 else ''}." - ), - ) pc_fps: set[str] = set() pc_fingerprint_errors: list[str] = [] - workers = min(os.cpu_count() or 4, 8) - - def _fp_pc(path: str) -> str | None: - return get_or_compute_fingerprint(path, write_to_file=False) - with ThreadPoolExecutor(max_workers=workers) as pool: - futures = { - pool.submit(_fp_pc, track.path): track - for track in pc_tracks - } - done = 0 - for fut in as_completed(futures): - if self.isInterruptionRequested(): - for pending in futures: - pending.cancel() - return - done += 1 - pc_track = futures[fut] - try: - fp = fut.result() - except Exception as exc: - fp = None - pc_fingerprint_errors.append(f"{pc_track.filename}: {exc}") - if fp: - pc_fps.add(fp) - if done == total_pc or done % 25 == 0: - self.progress.emit( - "backsync_pc_fingerprint", - done, - total_pc, - ( - f"{done:,}/{total_pc:,} checked - " - f"{len(pc_fps):,} usable fingerprints - " - f"{self._short_label(pc_track.filename)}" - ), - ) + if total_pc > 0: + self.progress.emit( + "backsync_pc_fingerprint", + 0, + total_pc, + ( + f"Building fingerprints for {total_pc:,} PC track" + f"{'s' if total_pc != 1 else ''}." + ), + ) + workers = min(os.cpu_count() or 4, 8) + + def _fp_pc(path: str) -> str | None: + return get_or_compute_fingerprint(path, write_to_file=False) + + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit(_fp_pc, track.path): track + for track in pc_tracks + } + done = 0 + for fut in as_completed(futures): + if self.isInterruptionRequested(): + for pending in futures: + pending.cancel() + return + done += 1 + pc_track = futures[fut] + try: + fp = fut.result() + except Exception as exc: + fp = None + pc_fingerprint_errors.append(f"{pc_track.filename}: {exc}") + if fp: + pc_fps.add(fp) + if done == total_pc or done % 25 == 0: + self.progress.emit( + "backsync_pc_fingerprint", + done, + total_pc, + ( + f"{done:,}/{total_pc:,} checked - " + f"{len(pc_fps):,} usable fingerprints - " + f"{self._short_label(pc_track.filename)}" + ), + ) ipod_candidates: list[tuple[dict, Path]] = [] unresolved_ipod_tracks = 0 @@ -680,40 +683,68 @@ def _fp_pc(path: str) -> str | None: ipod_candidates.append((track, ipod_file)) total_ipod = len(ipod_candidates) - self.progress.emit( - "backsync_ipod_fingerprint", - 0, - total_ipod, - ( - f"Comparing {total_ipod:,} iPod media file" - f"{'s' if total_ipod != 1 else ''} against your PC library." - ), - ) - to_export: list[tuple[dict, Path]] = [] ipod_fingerprint_errors: list[str] = [] - for idx, (track, ipod_file) in enumerate(ipod_candidates, start=1): - if self.isInterruptionRequested(): - return - title = track.get("Title") or ipod_file.name - try: - fp = get_or_compute_fingerprint(ipod_file, write_to_file=False) - except Exception as exc: - fp = None - ipod_fingerprint_errors.append(f"{title}: {exc}") - if fp and fp not in pc_fps: - to_export.append((track, ipod_file)) + + if not pc_fps: + # PC folder is empty (or all fingerprints failed) — every iPod + # track is missing by definition; skip fingerprinting entirely. + to_export = list(ipod_candidates) + self.progress.emit( + "backsync_ipod_fingerprint", + total_ipod, + total_ipod, + f"PC library empty — all {total_ipod:,} iPod tracks will be exported.", + ) + else: self.progress.emit( "backsync_ipod_fingerprint", - idx, + 0, total_ipod, ( - f"{idx:,}/{total_ipod:,} checked - " - f"{len(to_export):,} missing so far - " - f"{self._short_label(title)}" + f"Comparing {total_ipod:,} iPod media file" + f"{'s' if total_ipod != 1 else ''} against your PC library." ), ) + def _fp_ipod(pair: tuple[dict, Path]) -> tuple[dict, Path, str | None, str]: + track, ipod_file = pair + title = track.get("Title") or ipod_file.name + try: + fp = get_or_compute_fingerprint(ipod_file, write_to_file=False) + except Exception as exc: + ipod_fingerprint_errors.append(f"{title}: {exc}") + fp = None + return track, ipod_file, fp, title + + # Cap USB workers at 3 — USB 2.0 saturates quickly with concurrent reads. + ipod_workers = min(3, total_ipod or 1) + with ThreadPoolExecutor(max_workers=ipod_workers) as pool: + futures = { + pool.submit(_fp_ipod, pair): pair for pair in ipod_candidates + } + done = 0 + for fut in as_completed(futures): + if self.isInterruptionRequested(): + for pending in futures: + pending.cancel() + return + done += 1 + track, ipod_file, fp, title = fut.result() + if fp and fp not in pc_fps: + to_export.append((track, ipod_file)) + if done == total_ipod or done % 10 == 0: + self.progress.emit( + "backsync_ipod_fingerprint", + done, + total_ipod, + ( + f"{done:,}/{total_ipod:,} checked - " + f"{len(to_export):,} missing so far - " + f"{self._short_label(title)}" + ), + ) + output_root = Path(request.pc_folder) / "iOpenPod Back Sync" output_root.mkdir(parents=True, exist_ok=True) @@ -783,6 +814,7 @@ def _fp_pc(path: str) -> str | None: ), ) + _finished_emitted = True self.finished.emit( { "pc_scanned": total_pc, @@ -801,10 +833,15 @@ def _fp_pc(path: str) -> str | None: } ) except Exception as exc: + _finished_emitted = True if self.isInterruptionRequested(): + self.finished.emit({"cancelled": True}) return logger.exception("BackSyncWorker failed") self.error.emit(str(exc)) + finally: + if not _finished_emitted: + self.finished.emit({"cancelled": True}) def _resolve_location_to_path(self, location: str) -> Path | None: if not location: From 7a5e24cf0fb7af8f92088f730a2da836f827be50 Mon Sep 17 00:00:00 2001 From: Bonehead <47313866+B0N3head@users.noreply.github.com> Date: Mon, 11 May 2026 21:39:17 +1000 Subject: [PATCH 4/5] rework of sync engine and other iPod disk related functions Initial attempt at allowing for higher speed read/write with multiple workers for SSD based iPods --- GUI/app.py | 56 ++++++++++- GUI/widgets/backupBrowser.py | 8 ++ GUI/widgets/settingsPage.py | 50 ++++++++++ GUI/widgets/syncReview.py | 89 ++++++++++++++++-- SyncEngine/backup_manager.py | 14 ++- SyncEngine/fingerprint_diff_engine.py | 106 +++++++++++++++------ SyncEngine/sync_executor.py | 12 +++ app_core/jobs.py | 15 ++- infrastructure/device_tags.py | 129 ++++++++++++++++++++++++++ infrastructure/settings_runtime.py | 1 - 10 files changed, 437 insertions(+), 43 deletions(-) create mode 100644 infrastructure/device_tags.py diff --git a/GUI/app.py b/GUI/app.py index 50edb22..aeba27b 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -443,6 +443,7 @@ def selectDevice(self): return folder = selected_ipod.path or folder + self._ensure_ipod_drive_tag(selected_ipod, folder) device_manager.discovered_ipod = selected_ipod device_manager.device_path = folder # Persist selection @@ -476,6 +477,12 @@ def onDeviceChanged(self, path: str): clear_artwork_api() clear_pixmap_cache() + if path and self.device_manager.discovered_ipod is not None: + self._ensure_ipod_drive_tag( + self.device_manager.discovered_ipod, + path, + ) + if self._apply_effective_theme(): self._schedule_themed_rebuild(restore_page=0) @@ -519,6 +526,43 @@ def onDeviceSettingsFailed(self, path: str, error: str): if getattr(self.settingsPage, "_settings_scope", "global") == "device": self.settingsPage.load_from_settings() + def _get_ipod_hdd_tag(self) -> bool: + from infrastructure.device_tags import get_ipod_hdd_tag + + device = self.device_manager.discovered_ipod + ipod_root = self.device_manager.device_path or "" + value = get_ipod_hdd_tag(device, ipod_root) + return bool(value) if value is not None else False + + def _set_ipod_hdd_tag(self, ipod_hdd: bool) -> None: + from infrastructure.device_tags import set_ipod_hdd_tag + + device = self.device_manager.discovered_ipod + if device is None: + return + ipod_root = self.device_manager.device_path or "" + set_ipod_hdd_tag(device, ipod_root, ipod_hdd) + + def _ensure_ipod_drive_tag(self, device_info, ipod_root: str) -> None: + from infrastructure.device_tags import get_ipod_hdd_tag, set_ipod_hdd_tag + + if get_ipod_hdd_tag(device_info, ipod_root) is not None: + return + + msg = QMessageBox(self) + msg.setWindowTitle("iPod Storage Type") + msg.setText( + "Does this iPod have solid state storage (SSD / microSD / flash) " + "or the original hard drive (HDD)?\n\n" + "If you are not sure, select HDD." + ) + btn_ssd = msg.addButton("SSD / Flash", QMessageBox.ButtonRole.YesRole) + btn_hdd = msg.addButton("HDD (Original)", QMessageBox.ButtonRole.NoRole) + msg.setDefaultButton(btn_hdd) + msg.exec() + is_ssd = msg.clickedButton() is btn_ssd + set_ipod_hdd_tag(device_info, ipod_root, not is_ssd) + def resyncDevice(self): """Rebuild the cache from the current device.""" device = self.device_manager @@ -946,10 +990,16 @@ def startPCSync(self): # User clicked Continue Anyway (only possible when fpcalc is present) # Show folder selection dialog - dialog = PCFolderDialog(self, self._last_pc_folder) + dialog = PCFolderDialog( + self, + self._last_pc_folder, + ipod_hdd=self._get_ipod_hdd_tag(), + ) if dialog.exec() != dialog.DialogCode.Accepted: return + self._set_ipod_hdd_tag(bool(dialog.is_ipod_hdd)) + self._last_pc_folder = dialog.selected_folder # Persist the folder choice global_settings = self.settings_service.get_global_settings() @@ -977,6 +1027,7 @@ def startPCSync(self): pc_folder=self._last_pc_folder, ipod_tracks=ipod_tracks, ipod_path=device_manager.device_path or "", + ipod_hdd=bool(dialog.is_ipod_hdd), ), artwork_provider=self._create_back_sync_artwork_provider( device_manager.device_path or "", @@ -1036,6 +1087,7 @@ def startPCSync(self): "fit_photo_thumbnails": settings.fit_photo_thumbnails, }, transcode_options=build_transcode_options(settings), + ipod_hdd=self._get_ipod_hdd_tag(), ) ) self._sync_worker.progress.connect(self.syncReview.update_progress) @@ -1277,6 +1329,7 @@ def _onSelectiveSyncDone(self, folder: str, selected_paths): }, transcode_options=build_transcode_options(settings), allowed_paths=frozenset(selected_track_paths), + ipod_hdd=self._get_ipod_hdd_tag(), ) ) self._sync_worker.progress.connect(self.syncReview.update_progress) @@ -1429,6 +1482,7 @@ def _on_sync_complete(): user_playlists=user_playlists, device_info=device_session.identity, on_sync_complete=_on_sync_complete, + ipod_hdd=self._get_ipod_hdd_tag(), ) self._sync_execute_worker.progress.connect(self.syncReview.update_execute_progress) self._sync_execute_worker.finished.connect(self._onSyncExecuteComplete) diff --git a/GUI/widgets/backupBrowser.py b/GUI/widgets/backupBrowser.py index 8f0970d..0ff2e88 100644 --- a/GUI/widgets/backupBrowser.py +++ b/GUI/widgets/backupBrowser.py @@ -898,6 +898,9 @@ def _on_backup_now(self): return settings = self._settings_service.get_effective_settings() + from infrastructure.device_tags import get_ipod_hdd_tag + _hdd_tag = get_ipod_hdd_tag(device.discovered_ipod, device.device_path) + ipod_hdd = _hdd_tag if _hdd_tag is not None else True # unknown → treat as HDD (safe) backup_context = build_backup_device_context( device.device_path, device.discovered_ipod, @@ -924,6 +927,7 @@ def _on_backup_now(self): backup_dir=settings.backup_dir, max_backups=settings.max_backups, device_meta=backup_context.device_meta, + ipod_hdd=ipod_hdd, ) ) self._backup_worker.progress.connect(self._on_backup_progress) @@ -1042,6 +1046,9 @@ def _on_restore(self, snapshot_id: str): device.discovered_ipod, ) connected_id = connected_context.device_id + from infrastructure.device_tags import get_ipod_hdd_tag + _hdd_tag = get_ipod_hdd_tag(device.discovered_ipod, device.device_path) + ipod_hdd = _hdd_tag if _hdd_tag is not None else True # unknown → treat as HDD (safe) # Safety: only restore to the matching device if connected_id != self._current_device_id: @@ -1087,6 +1094,7 @@ def _on_restore(self, snapshot_id: str): ipod_path=device.device_path, device_id=connected_id, backup_dir=settings.backup_dir, + ipod_hdd=ipod_hdd, ) ) self._restore_worker.progress.connect(self._on_restore_progress) diff --git a/GUI/widgets/settingsPage.py b/GUI/widgets/settingsPage.py index 5e48bac..709c18d 100644 --- a/GUI/widgets/settingsPage.py +++ b/GUI/widgets/settingsPage.py @@ -1179,10 +1179,24 @@ def _build_general_page(self) -> QScrollArea: ) ) + self.storage_type_row = ComboRow( + "Storage Type", + "Does this iPod have solid state storage (SSD / microSD / flash) " + "or the original hard drive (HDD)? " + "This controls how many parallel operations are used during sync and backup. " + "If unsure, select HDD.", + options=["HDD (Original)", "SSD / Flash"], + current="HDD (Original)", + ) + self.storage_type_row.changed.connect(self._on_storage_type_changed) + self._manage_card = _SettingsCard( self.use_global_settings, self.reset_device_settings, ) + self._device_card = _SettingsCard( + self.storage_type_row, + ) self._appearance_card = _SettingsCard( self.theme_combo, self.high_contrast, @@ -1200,6 +1214,8 @@ def _build_general_page(self) -> QScrollArea: "General", "Manage", self._manage_card, + "Device", + self._device_card, "Appearance", self._appearance_card, "About", @@ -1711,6 +1727,13 @@ def _apply_scope_visibility(self) -> None: self._manage_card.setVisible(device_scope) self._set_section_visible("General", "Manage", device_scope) + + # Device card (storage type) — always visible, enabled only when device connected + has_device = self._current_device_context() is not None + self._device_card.setVisible(True) + self._set_section_visible("General", "Device", True) + self.storage_type_row.combo.setEnabled(has_device) + self._appearance_card.set_row_visible(self.theme_combo, not device_scope) self._appearance_card.set_row_visible(self.high_contrast, not device_scope) self._appearance_card.set_row_visible(self.font_scale, not device_scope) @@ -1941,6 +1964,19 @@ def load_from_settings(self): if idx >= 0: self.device_write_workers.combo.setCurrentIndex(idx) + # Storage type — keyed per device in device_tags.json, not AppSettings + try: + from infrastructure.device_tags import get_ipod_hdd_tag + session = self._device_sessions.current_session() + tag = get_ipod_hdd_tag(session.discovered_ipod, session.device_path or "") + st_text = "HDD (Original)" if (tag is None or tag is True) else "SSD / Flash" + idx = self.storage_type_row.combo.findText(st_text) + if idx >= 0: + self.storage_type_row.combo.setCurrentIndex(idx) + self.storage_type_row.combo.setEnabled(session.device_path is not None and session.device_path != "") + except Exception: + self.storage_type_row.combo.setEnabled(False) + self._apply_scope_visibility() # Connect signals to auto-save (only once) @@ -2317,6 +2353,20 @@ def _reset_device_settings_to_global(self) -> None: self.load_from_settings() self._apply_theme_change_if_needed(theme_before) + def _on_storage_type_changed(self, text: str) -> None: + """Persist the HDD/SSD selection to device_tags.json for the connected device.""" + if self._loading_settings: + return + try: + from infrastructure.device_tags import set_ipod_hdd_tag + session = self._device_sessions.current_session() + if not session.device_path: + return + is_ssd = text == "SSD / Flash" + set_ipod_hdd_tag(session.discovered_ipod, session.device_path, not is_ssd) + except Exception: + pass + def _save(self, *_args): """Read controls back into the active settings scope and persist.""" if self._loading_settings or self._device_settings_pending: diff --git a/GUI/widgets/syncReview.py b/GUI/widgets/syncReview.py index 2f1d94c..6f514d1 100644 --- a/GUI/widgets/syncReview.py +++ b/GUI/widgets/syncReview.py @@ -10,6 +10,8 @@ from __future__ import annotations +import time + from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QRectF from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, @@ -767,6 +769,7 @@ def __init__( self._cancelled = False self._ipod_tracks_cache: list = [] self._eta_tracker = ETATracker() + self._loading_start_time: float = 0.0 self._skip_presync_backup: bool = False self._pending_sync_items: list = [] self._is_auto_presync: bool = False @@ -890,6 +893,26 @@ def _setup_ui(self): self._backup_hint.setVisible(False) loading_layout.addWidget(self._backup_hint) + loading_layout.addSpacing(24) + + # Centered cancel button \u2014 shown only on the loading/progress page. + # The footer cancel is hidden while this page is active. + self._loading_cancel_btn = QPushButton("Cancel", loading_widget) + self._loading_cancel_btn.setFixedWidth(120) + self._loading_cancel_btn.setStyleSheet(btn_css( + bg=Colors.SURFACE_RAISED, + bg_hover=Colors.SURFACE_ACTIVE, + bg_press=Colors.SURFACE_ALT, + border=f"1px solid {Colors.BORDER}", + radius=Metrics.BORDER_RADIUS_SM, + padding="7px 20px", + )) + self._loading_cancel_btn.clicked.connect(self._on_cancel_clicked) + self._loading_cancel_btn.setVisible(False) + loading_layout.addWidget( + self._loading_cancel_btn, alignment=Qt.AlignmentFlag.AlignCenter + ) + loading_layout.addStretch(4) self.stack.addWidget(loading_widget) # Index 0 @@ -1233,6 +1256,7 @@ def _setup_ui(self): footer_layout.addWidget(self.cancel_btn) footer_layout.addWidget(self.apply_btn) + self._footer = footer layout.addWidget(footer) # Map internal stage names → user-friendly labels @@ -1275,6 +1299,12 @@ def _set_footer_for_state(self, state: str): States: 'loading', 'plan', 'empty', 'executing', 'results', 'presync' """ + # During loading/executing the page-embedded cancel is shown centered; + # the footer is hidden entirely so it doesn't compete with the layout. + loading_active = state in ("loading", "executing", "presync") + self._footer.setVisible(not loading_active) + self._loading_cancel_btn.setVisible(loading_active) + show_plan_btns = (state == "plan") self.select_all_btn.setVisible(show_plan_btns) self.select_none_btn.setVisible(show_plan_btns) @@ -1286,6 +1316,8 @@ def _set_footer_for_state(self, state: str): if state == "loading": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) + self._loading_cancel_btn.setText("Cancel") + self._loading_cancel_btn.setEnabled(True) elif state == "plan": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) @@ -1295,15 +1327,30 @@ def _set_footer_for_state(self, state: str): elif state == "executing": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) + self._loading_cancel_btn.setText("Cancel") + self._loading_cancel_btn.setEnabled(True) elif state == "presync": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) + self._loading_cancel_btn.setText("Cancel") + self._loading_cancel_btn.setEnabled(True) elif state == "results": self.cancel_btn.setText("Done") self.cancel_btn.setEnabled(True) + def _format_elapsed(self) -> str: + """Return a human-readable elapsed-time string, or '' if under 2 seconds.""" + secs = int(time.monotonic() - self._loading_start_time) + if secs < 2: + return "" + if secs < 60: + return f"{secs}s elapsed" + m, s = divmod(secs, 60) + return f"{m}m {s:02d}s elapsed" if s else f"{m}m elapsed" + def show_loading(self): """Show loading state.""" + self._loading_start_time = time.monotonic() self.stack.setCurrentIndex(0) self.loading_label.setText("Scanning library...") self.progress_bar.setRange(0, 0) # Indeterminate @@ -1316,6 +1363,7 @@ def show_loading(self): def show_back_sync_loading(self): """Show the Back Sync progress state.""" + self._loading_start_time = time.monotonic() self._cancelled = False self.stack.setCurrentIndex(0) self.loading_label.setText("Preparing Back Sync") @@ -1346,10 +1394,14 @@ def update_progress(self, stage: str, current: int, total: int, message: str): self.progress_bar.setRange(0, total) self.progress_bar.setValue(current) self._eta_tracker.update(stage, current, total) - self.eta_label.setText(self._eta_tracker.format_stage_progress(stage, current, total)) + eta_text = self._eta_tracker.format_stage_progress(stage, current, total) + elapsed_text = self._format_elapsed() + parts = [p for p in (elapsed_text, eta_text) if p] + self.eta_label.setText(" · ".join(parts)) else: self.progress_bar.setRange(0, 0) # Indeterminate - self.eta_label.setText("") + elapsed_text = self._format_elapsed() + self.eta_label.setText(elapsed_text) def show_plan(self, plan: Any): """Display the sync plan as styled category cards.""" @@ -1910,6 +1962,7 @@ def _render_storage(self, net_change: int): def show_executing(self): """Show executing state - similar to loading but for sync execution.""" + self._loading_start_time = time.monotonic() self._cancelled = False self._scrobble_timeout_retrying = False self._completed_stages = [] @@ -2052,22 +2105,24 @@ def update_execute_progress(self, prog): elapsed = stats.elapsed remaining = elapsed / size_progress * (1.0 - size_progress) eta = ETATracker._format_duration(remaining) - parts = [f"{current} of {total}"] + parts = [self._format_elapsed(), f"{current} of {total}"] if eta: parts.append(eta) - self.eta_label.setText(" \u00b7 ".join(parts)) + self.eta_label.setText(" \u00b7 ".join(p for p in parts if p)) elif total > 0: self.progress_bar.setRange(0, total) self.progress_bar.setValue(current) if is_substep: - # Sub-step stages: bar moves but don't show "3 of 8" - self.eta_label.setText("") + self.eta_label.setText(self._format_elapsed()) else: self._eta_tracker.update(stage, current, total) - self.eta_label.setText(self._eta_tracker.format_stage_progress(stage, current, total)) + eta_text = self._eta_tracker.format_stage_progress(stage, current, total) + elapsed_text = self._format_elapsed() + parts = [p for p in (elapsed_text, eta_text) if p] + self.eta_label.setText(" \u00b7 ".join(parts)) else: self.progress_bar.setRange(0, 0) # Indeterminate - self.eta_label.setText("") + self.eta_label.setText(self._format_elapsed()) def show_result(self, result): """Show sync completion results in a styled view.""" @@ -2365,16 +2420,22 @@ def _on_cancel_clicked(self): # Skip the in-progress backup and proceed to sync self.cancel_btn.setEnabled(False) self.cancel_btn.setText("Skipping backup…") + self._loading_cancel_btn.setEnabled(False) + self._loading_cancel_btn.setText("Skipping backup…") self.skip_backup_signal.emit() elif self._current_exec_stage == "scrobble" and self._scrobble_timeout_retrying: self.cancel_btn.setEnabled(False) self.cancel_btn.setText("Giving up…") + self._loading_cancel_btn.setEnabled(False) + self._loading_cancel_btn.setText("Giving up…") self.give_up_scrobble_signal.emit() else: # Full cancel self._cancelled = True self.cancel_btn.setEnabled(False) self.cancel_btn.setText("Cancelling...") + self._loading_cancel_btn.setEnabled(False) + self._loading_cancel_btn.setText("Cancelling...") self.cancelled.emit() else: # Plan view, empty view, or results view — just go back @@ -2662,13 +2723,14 @@ def _apply_sync(self): class PCFolderDialog(QDialog): """Dialog to select PC media folder for syncing.""" - def __init__(self, parent=None, last_folder: str = ""): + def __init__(self, parent=None, last_folder: str = "", ipod_hdd: bool = False): super().__init__(parent) self.setWindowTitle("Select Media Folder") self.setMinimumWidth((440)) self.selected_folder = "" self.sync_mode = "" # "full" | "selective" | "back_sync" self.last_folder = last_folder + self.is_ipod_hdd = bool(ipod_hdd) # Dark theme stylesheet self.setStyleSheet(f""" @@ -2762,14 +2824,23 @@ def _setup_ui(self): btn_row.addWidget(cancel_btn) selective_btn = QPushButton("Selective Sync", self) + selective_btn.setToolTip( + "Browse the chosen media folder and pick specific tracks/photos to sync." + ) selective_btn.clicked.connect(self._accept_selective) btn_row.addWidget(selective_btn) back_sync_btn = QPushButton("Back Sync", self) + back_sync_btn.setToolTip( + "Copy iPod-only tracks back to your chosen media folder" + ) back_sync_btn.clicked.connect(self._accept_back_sync) btn_row.addWidget(back_sync_btn) full_btn = QPushButton("Full Sync", self) + full_btn.setToolTip( + "Compare the entire chosen media folder with your iPod, then sync all changes" + ) full_btn.setStyleSheet(f""" QPushButton {{ background: {Colors.ACCENT}; diff --git a/SyncEngine/backup_manager.py b/SyncEngine/backup_manager.py index a17f254..50ca4d4 100644 --- a/SyncEngine/backup_manager.py +++ b/SyncEngine/backup_manager.py @@ -125,10 +125,12 @@ class BackupManager: def __init__(self, device_id: str, backup_dir: str = "", device_name: str = "iPod", - device_meta: dict | None = None): + device_meta: dict | None = None, + max_workers: int = 0): self.device_id = self._sanitize_id(device_id) self.device_name = device_name self.device_meta = device_meta or {} + self._num_workers = max(1, max_workers) if max_workers and max_workers > 0 else _NUM_WORKERS self.backup_root = Path(backup_dir or _DEFAULT_BACKUP_DIR) self.device_dir = self.backup_root / self.device_id self.blobs_dir = self.backup_root / "blobs" # Shared across devices @@ -185,6 +187,10 @@ def create_backup( )) all_files = self._walk_device(ipod_root) + if self._num_workers == 1: + # Single-worker (HDD) path: sort by path so files in the same + # directory are read together, minimising rotational head seeks. + all_files.sort(key=lambda t: t[0]) total_files = len(all_files) if total_files == 0: @@ -277,7 +283,7 @@ def _process_file(rel_path: str, full_path: Path, is_new = self._store_blob(full_path, file_hash) return rel_path, fsize, fmtime, file_hash, is_new - with ThreadPoolExecutor(max_workers=_NUM_WORKERS) as pool: + with ThreadPoolExecutor(max_workers=self._num_workers) as pool: futures = { pool.submit(_process_file, rp, fp, sz, mt): rp for rp, fp, sz, mt in uncached @@ -557,7 +563,7 @@ def _hash_ipod_file(rel_path: str, full_path: Path) -> tuple[str, str]: file_hash = self._hash_file(full_path) return rel_path, file_hash - with ThreadPoolExecutor(max_workers=_NUM_WORKERS) as pool: + with ThreadPoolExecutor(max_workers=self._num_workers) as pool: futures = { pool.submit(_hash_ipod_file, rp, fp): rp for rp, fp in uncached_scan @@ -696,7 +702,7 @@ def _restore_file(rel_path: str, file_hash: str) -> tuple[str, bool, str]: except (OSError, PermissionError) as exc: return rel_path, False, str(exc) - with ThreadPoolExecutor(max_workers=_NUM_WORKERS) as pool: + with ThreadPoolExecutor(max_workers=self._num_workers) as pool: futures = { pool.submit(_restore_file, rp, target_files[rp]["hash"]): rp for rp in to_copy diff --git a/SyncEngine/fingerprint_diff_engine.py b/SyncEngine/fingerprint_diff_engine.py index 0a7f52c..30811e4 100644 --- a/SyncEngine/fingerprint_diff_engine.py +++ b/SyncEngine/fingerprint_diff_engine.py @@ -440,6 +440,7 @@ def compute_diff( sync_workers: int = 0, rating_strategy: str = "ipod_wins", allowed_paths: Optional[frozenset[str]] = None, + bootstrap_workers: int = 0, ) -> SyncPlan: """ Compute the full sync plan. @@ -663,6 +664,7 @@ def _fingerprint_one(track: PCTrack) -> tuple[PCTrack, Optional[str]]: pc_by_fp, progress_callback=progress_callback, is_cancelled=is_cancelled, + bootstrap_workers=bootstrap_workers, ) if boot_added > 0: @@ -725,24 +727,35 @@ def _fingerprint_one(track: PCTrack) -> tuple[PCTrack, Optional[str]]: # mapping entries have already been claimed so each PC track gets its own. claimed_db_track_ids: set[int] = set() - # Sort identity groups so that groups whose album matches an existing + # Partition identity groups so that groups whose album matches an existing # iPod mapping entry process first. Without this, iteration order is # arbitrary (dict insertion order) and a *new* album variant can claim # a mapping entry before the *matching* album group processes — causing # a spurious duplicate ADD and a misattributed match. - def _album_match_priority(item): - (fp, album_key), _tracks = item - for entry in mapping.get_entries(fp): - ipod_track = ipod_by_db_track_id.get(entry.db_track_id) - if ipod_track: - ipod_album = (ipod_track.get("Album", "") or "").strip().lower() - if ipod_album == album_key: - return 0 # has a matching entry → process first - return 1 # no match → process after confident matches - - sorted_groups = sorted(identity_groups.items(), key=_album_match_priority) + # Partition is O(n) vs sorted()'s O(n log n) — same correctness guarantee. + _matched_first: list = [] + _unmatched: list = [] + for _item in identity_groups.items(): + (fp, album_key), _ = _item + _has_match = False + for _entry in mapping.get_entries(fp): + _ipod_t = ipod_by_db_track_id.get(_entry.db_track_id) + if _ipod_t and (_ipod_t.get("Album", "") or "").strip().lower() == album_key: + _has_match = True + break + (_matched_first if _has_match else _unmatched).append(_item) + sorted_groups = _matched_first + _unmatched + n_groups = len(sorted_groups) + + for _diff_idx, ((fp, _album_key), pc_tracks_for_group) in enumerate(sorted_groups): + if progress_callback and _diff_idx % 100 == 0 and n_groups > 0: + progress_callback( + "diff", + _diff_idx, + n_groups, + f"Comparing {_diff_idx:,} / {n_groups:,} tracks…", + ) - for (fp, _album_key), pc_tracks_for_group in sorted_groups: # Pick representative track (first one from this album group) pc_track = pc_tracks_for_group[0] mapping_entries = mapping.get_entries(fp) @@ -1254,6 +1267,7 @@ def _bootstrap_mapping_from_existing_ipod_tracks( *, progress_callback: Optional[Callable[[str, int, int, str], None]] = None, is_cancelled: Optional[Callable[[], bool]] = None, + bootstrap_workers: int = 0, ) -> tuple[int, int, set[int]]: """Seed mapping entries by fingerprinting unmapped iPod tracks. @@ -1287,24 +1301,58 @@ def _bootstrap_mapping_from_existing_ipod_tracks( skipped_no_fp = 0 skipped_no_pc_match = 0 sample_no_match_fp: Optional[str] = None - for index, (db_track_id, ipod_track) in enumerate(bootstrap_candidates, start=1): - if is_cancelled and is_cancelled(): - break - - if progress_callback: - label = ipod_track.get("Title") or ipod_track.get("Location") or str(db_track_id) - progress_callback("bootstrap_mapping", index, total, str(label)) - - ipod_path = self._ipod_track_file_path(ipod_track) - if ipod_path is None: - skipped_no_path += 1 - continue + # Phase A — parallel path resolution + fingerprinting (I/O bound). + # Results stored in original candidate order so Phase B is deterministic. + workers = max(1, bootstrap_workers) + fp_results: list[tuple[Optional[Path], Optional[str]]] = [(None, None)] * total + + def _resolve_and_fingerprint( + idx: int, + ipod_track: dict, + ) -> tuple[int, Optional[Path], Optional[str]]: + path = self._ipod_track_file_path(ipod_track) + if path is None: + return idx, None, None fp = get_or_compute_fingerprint( - ipod_path, + path, fpcalc_path=self.fpcalc_path, write_to_file=False, ) + return idx, path, fp + + if workers > 1 and total > 1: + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit(_resolve_and_fingerprint, idx, trk): (db_id, trk) + for idx, (db_id, trk) in enumerate(bootstrap_candidates) + } + completed = 0 + for fut in as_completed(futures): + if is_cancelled and is_cancelled(): + pool.shutdown(wait=False, cancel_futures=True) + break + completed += 1 + db_id, trk = futures[fut] + idx, path, fp = fut.result() + fp_results[idx] = (path, fp) + if progress_callback and completed % 5 == 0: + label = trk.get("Title") or trk.get("Location") or str(db_id) + progress_callback("bootstrap_mapping", completed, total, str(label)) + else: + for idx, (db_id, trk) in enumerate(bootstrap_candidates): + if is_cancelled and is_cancelled(): + break + if progress_callback: + label = trk.get("Title") or trk.get("Location") or str(db_id) + progress_callback("bootstrap_mapping", idx + 1, total, str(label)) + fp_results[idx] = _resolve_and_fingerprint(idx, trk)[1:] + + # Phase B — serial match + mapping (shared state: mapping, claimed_pc_paths_by_fp). + for (db_track_id, ipod_track), (ipod_path, fp) in zip(bootstrap_candidates, fp_results): + if ipod_path is None: + skipped_no_path += 1 + continue if not fp: skipped_no_fp += 1 continue @@ -1320,7 +1368,6 @@ def _bootstrap_mapping_from_existing_ipod_tracks( protected_db_track_ids.add(db_track_id) used_paths = claimed_pc_paths_by_fp.setdefault(fp, set()) - pc_track = self._select_bootstrap_pc_candidate( ipod_track, pc_candidates, @@ -1537,6 +1584,11 @@ def _compare_metadata(self, pc_track: PCTrack, ipod_track: dict) -> dict: pc_value = getattr(pc_track, pc_field, None) ipod_value = ipod_track.get(ipod_field) + # Fast path: identical raw values — skip all normalization (covers + # the vast majority of fields on stable, already-synced tracks). + if pc_value == ipod_value: + continue + # Normalize None → "" if pc_value is None: pc_value = "" diff --git a/SyncEngine/sync_executor.py b/SyncEngine/sync_executor.py index 8528d5f..36bfa3f 100644 --- a/SyncEngine/sync_executor.py +++ b/SyncEngine/sync_executor.py @@ -191,6 +191,7 @@ def __init__( photo_sync_settings: dict[str, bool] | None = None, transcode_options: TranscodeOptions | None = None, device_info: object | None = None, + ipod_hdd: bool | None = None, ): from .transcode_cache import TranscodeCache @@ -218,6 +219,7 @@ def __init__( max_device_write_workers, self._max_workers, device_info, + ipod_hdd=ipod_hdd, ) self._device_write_semaphore = threading.Semaphore( self._max_device_write_workers @@ -244,11 +246,21 @@ def _resolve_device_write_workers( configured_write_workers: int, max_workers: int, device_info: object | None, + *, + ipod_hdd: bool | None = None, ) -> int: overall_workers = max(1, max_workers) if configured_write_workers > 0: return max(1, min(configured_write_workers, overall_workers)) + # Explicit tag takes precedence over model-family heuristic. + # True = HDD (sequential), False = SSD/flash (parallel). + if ipod_hdd is True: + return 1 + if ipod_hdd is False: + return min(overall_workers, 4) + + # Fall back to model-family detection for untagged devices. if device_info is None: return overall_workers diff --git a/app_core/jobs.py b/app_core/jobs.py index 609d18b..268ad68 100644 --- a/app_core/jobs.py +++ b/app_core/jobs.py @@ -388,6 +388,7 @@ class BackupCreateRequest: backup_dir: str max_backups: int device_meta: dict[str, str] + ipod_hdd: bool = False class BackupCreateWorker(QThread): @@ -411,6 +412,7 @@ def run(self) -> None: backup_dir=request.backup_dir, device_name=request.device_name, device_meta=request.device_meta, + max_workers=(1 if request.ipod_hdd else 0), ) def on_progress(prog) -> None: @@ -448,6 +450,7 @@ class BackupRestoreRequest: ipod_path: str device_id: str backup_dir: str + ipod_hdd: bool = False class BackupRestoreWorker(QThread): @@ -469,6 +472,7 @@ def run(self) -> None: manager = BackupManager( device_id=request.device_id, backup_dir=request.backup_dir, + max_workers=(1 if request.ipod_hdd else 0), ) def on_progress(prog) -> None: @@ -509,6 +513,7 @@ class SyncDiffRequest: fpcalc_path: str = "" photo_sync_settings: dict[str, bool] | None = None transcode_options: Any = None + ipod_hdd: bool = False class SyncDiffWorker(QThread): @@ -539,6 +544,8 @@ def run(self) -> None: transcode_options=request.transcode_options, ) + # HDD: 1 bootstrap worker (avoid seek contention). SSD/flash: up to 3. + bootstrap_workers = 1 if request.ipod_hdd else min(3, os.cpu_count() or 2) plan = diff_engine.compute_diff( request.ipod_tracks, progress_callback=lambda stage, cur, tot, msg: self.progress.emit( @@ -553,6 +560,7 @@ def run(self) -> None: sync_workers=request.sync_workers, rating_strategy=request.rating_strategy, allowed_paths=request.allowed_paths, + bootstrap_workers=bootstrap_workers, ) if not self.isInterruptionRequested(): @@ -571,6 +579,7 @@ class BackSyncRequest: pc_folder: str ipod_tracks: list ipod_path: str + ipod_hdd: bool = False class BackSyncWorker(QThread): @@ -718,7 +727,7 @@ def _fp_ipod(pair: tuple[dict, Path]) -> tuple[dict, Path, str | None, str]: return track, ipod_file, fp, title # Cap USB workers at 3 — USB 2.0 saturates quickly with concurrent reads. - ipod_workers = min(3, total_ipod or 1) + ipod_workers = 1 if request.ipod_hdd else min(3, total_ipod or 1) with ThreadPoolExecutor(max_workers=ipod_workers) as pool: futures = { pool.submit(_fp_ipod, pair): pair for pair in ipod_candidates @@ -1594,6 +1603,7 @@ def __init__( user_playlists: list | None = None, device_info: DeviceIdentitySnapshot | None = None, on_sync_complete: Callable[[], None] | None = None, + ipod_hdd: bool = False, ): super().__init__() self.ipod_path = ipod_path @@ -1604,6 +1614,7 @@ def __init__( self.settings = settings self.device_info = device_info self.on_sync_complete = on_sync_complete + self.ipod_hdd = ipod_hdd self._give_up_scrobble_requested = False self._partial_save_event: threading.Event | None = None self._partial_save_decision: list[bool] = [True] @@ -1658,6 +1669,7 @@ def _on_cancel_with_partial(n_added: int, n_skipped: int) -> bool: fpcalc_path=settings.fpcalc_path, transcode_options=build_transcode_options(settings), device_info=self.device_info, + ipod_hdd=self.ipod_hdd, photo_sync_settings={ "rotate_tall_photos_for_device": ( settings.rotate_tall_photos_for_device @@ -1724,6 +1736,7 @@ def _create_presync_backup(self, settings: AppSettings, progress_type) -> None: backup_dir=settings.backup_dir, device_name=device_name, device_meta=device_meta, + max_workers=(1 if self.ipod_hdd else 0), ) def on_backup_progress(prog) -> None: diff --git a/infrastructure/device_tags.py b/infrastructure/device_tags.py new file mode 100644 index 0000000..41029bc --- /dev/null +++ b/infrastructure/device_tags.py @@ -0,0 +1,129 @@ +"""Persisted per-device tags keyed by iPod identity (serial/firewire).""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from .settings_paths import get_settings_dir +from .settings_secrets import ( + normalized_device_identity_value, + normalized_device_mount_key, +) + +_TAGS_FILENAME = "device_tags.json" + + +def _tags_path() -> str: + return os.path.join(get_settings_dir(), _TAGS_FILENAME) + + +def _device_tag_key(device_info: Any | None, ipod_root: str = "") -> str: + if device_info is not None: + for attr in ( + "serial", + "serial_number", + "firewire_guid", + "usb_serial", + "vpd_serial", + ): + value = normalized_device_identity_value(getattr(device_info, attr, "")) + if value: + return value + return normalized_device_mount_key(ipod_root) + + +def _load_tags() -> dict[str, dict[str, Any]]: + path = _tags_path() + if not os.path.exists(path): + return {} + try: + with open(path, encoding="utf-8") as file: + raw = json.load(file) + except (json.JSONDecodeError, UnicodeDecodeError, OSError): + return {} + + if isinstance(raw, dict): + if "devices" in raw and isinstance(raw.get("devices"), dict): + return dict(raw.get("devices") or {}) + return dict(raw) + return {} + + +def _save_tags(tags: dict[str, dict[str, Any]]) -> None: + path = _tags_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + payload = {"version": 1, "devices": tags} + tmp = path + ".tmp" + try: + with open(tmp, "w", encoding="utf-8") as file: + json.dump(payload, file, indent=2, ensure_ascii=False) + os.replace(tmp, path) + except Exception: + try: + os.remove(tmp) + except OSError: + pass + raise + + +def get_ipod_hdd_tag(device_info: Any | None, ipod_root: str = "") -> bool | None: + key = _device_tag_key(device_info, ipod_root) + if not key: + return None + tags = _load_tags() + entry = tags.get(key) + if not isinstance(entry, dict): + return None + value = entry.get("ipod_hdd") + return value if isinstance(value, bool) else None + + +def set_ipod_hdd_tag( + device_info: Any | None, + ipod_root: str, + ipod_hdd: bool, + *, + device_name: str = "", + serial: str = "", +) -> None: + key = _device_tag_key(device_info, ipod_root) + if not key: + return + + # Derive name/serial from device_info if not supplied explicitly. + if not device_name and device_info is not None: + device_name = str(getattr(device_info, "display_name", "") or "").strip() + if not serial and device_info is not None: + for attr in ("serial", "serial_number", "firewire_guid", "usb_serial", "vpd_serial"): + v = str(getattr(device_info, attr, "") or "").strip() + if v: + serial = v + break + + tags = _load_tags() + existing = tags.get(key) or {} + tags[key] = { + **existing, + "ipod_hdd": bool(ipod_hdd), + "device_name": device_name or existing.get("device_name", ""), + "serial": serial or existing.get("serial", ""), + } + _save_tags(tags) + + +def list_all_device_tags() -> list[dict]: + """Return all stored device entries for display in settings UI.""" + tags = _load_tags() + result = [] + for key, entry in tags.items(): + if not isinstance(entry, dict): + continue + result.append({ + "key": key, + "device_name": entry.get("device_name", "") or key, + "serial": entry.get("serial", ""), + "ipod_hdd": entry.get("ipod_hdd"), + }) + return result diff --git a/infrastructure/settings_runtime.py b/infrastructure/settings_runtime.py index f2179a1..d8325b2 100644 --- a/infrastructure/settings_runtime.py +++ b/infrastructure/settings_runtime.py @@ -44,7 +44,6 @@ def _copy_device_settings_state(state: DeviceSettingsState) -> DeviceSettingsSta path=state.path, ) - def _coerce_setting_value(current_value, value): expected_type = type(current_value) if expected_type is bool: From 908977389d52b02d2fc1322838f4b6a2644f11a8 Mon Sep 17 00:00:00 2001 From: Bonehead <47313866+B0N3head@users.noreply.github.com> Date: Tue, 12 May 2026 02:51:29 +1000 Subject: [PATCH 5/5] UNTESTED - sync to v1.0.51 main (with performance changes) --- .gitignore | 1 - GUI/app.py | 79 +- GUI/artwork_rendering.py | 100 ++ GUI/imgMaker.py | 35 +- GUI/widgets/MBGridView.py | 1235 ++++++----------- GUI/widgets/MBGridViewItem.py | 396 ++++-- GUI/widgets/MBListView.py | 580 ++++++-- GUI/widgets/browserChrome.py | 5 +- GUI/widgets/musicBrowser.py | 9 +- GUI/widgets/photoViewer.py | 7 +- GUI/widgets/playlistBrowser.py | 2 +- GUI/widgets/podcastBrowser.py | 1 + GUI/widgets/pooledCardGrid.py | 30 +- GUI/widgets/selectiveSyncBrowser.py | 12 +- GUI/widgets/settingsPage.py | 51 +- GUI/widgets/syncReview.py | 204 +-- GUI/widgets/trackListTitleBar.py | 206 ++- SyncEngine/contracts.py | 1 + SyncEngine/scrobbler.py | 137 +- SyncEngine/sync_executor.py | 82 +- app_core/jobs.py | 196 ++- app_core/services.py | 6 + infrastructure/settings_runtime.py | 3 + infrastructure/settings_schema.py | 3 + infrastructure/version.py | 2 +- pyproject.toml | 2 +- scripts/generate_fake_music_library.py | 216 ++- .../manual_tracklist_column_persistence.py | 491 +++++++ tests/test_artwork_rendering.py | 25 + tests/test_mb_grid_view.py | 74 +- tests/test_mb_list_view.py | 611 +++++++- tests/test_scrobbling.py | 267 ++++ tests/test_settings_persistence.py | 10 + tests/test_settings_snapshot.py | 13 + uv.lock | 2 +- 35 files changed, 3569 insertions(+), 1525 deletions(-) create mode 100644 GUI/artwork_rendering.py create mode 100644 scripts/manual_tracklist_column_persistence.py create mode 100644 tests/test_artwork_rendering.py create mode 100644 tests/test_scrobbling.py diff --git a/.gitignore b/.gitignore index 545126f..3ad4b07 100644 --- a/.gitignore +++ b/.gitignore @@ -178,4 +178,3 @@ cython_debug/ # Claude Code .claude/ CLAUDE.md -uv.lock \ No newline at end of file diff --git a/GUI/app.py b/GUI/app.py index aeba27b..330958e 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from PyQt6.QtCore import Qt, QTimer, pyqtSlot from PyQt6.QtGui import QFont @@ -50,11 +50,11 @@ ThreadPoolSingleton, same_device_path, ) +from app_core.sync_options import build_transcode_options from app_core.sync_plan_builder import ( build_filtered_sync_plan, build_removal_sync_plan, ) -from app_core.sync_options import build_transcode_options from GUI.glyphs import glyph_pixmap from GUI.notifications import Notifier from GUI.styles import FONT_FAMILY, Colors, Metrics, btn_css @@ -243,6 +243,9 @@ def _build_ui(self): ) self.settingsPage.closed.connect(self.hideSettings) self.settingsPage.theme_changed.connect(self._on_theme_changed) + self.settingsPage.artwork_appearance_changed.connect( + self._on_artwork_appearance_changed + ) self.centralStack.addWidget(self.settingsPage) # Index 2 # Backup browser page @@ -268,7 +271,7 @@ def _build_ui(self): self.noDeviceWidget = QWidget() no_device_layout = QVBoxLayout(self.noDeviceWidget) no_device_layout.setContentsMargins((36), (36), (36), (36)) - no_device_layout.setSpacing((12)) + no_device_layout.setSpacing(12) no_device_layout.addStretch(1) @@ -289,7 +292,7 @@ def _build_ui(self): select_btn = QPushButton("Select Device") select_btn.setCursor(Qt.CursorShape.PointingHandCursor) - select_btn.setFixedWidth((170)) + select_btn.setFixedWidth(170) select_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD, QFont.Weight.DemiBold)) select_btn.setStyleSheet(btn_css( bg=Colors.ACCENT, @@ -315,7 +318,7 @@ def _build_ui(self): self.loadingDeviceWidget = QWidget() loading_layout = QVBoxLayout(self.loadingDeviceWidget) loading_layout.setContentsMargins((36), (36), (36), (36)) - loading_layout.setSpacing((12)) + loading_layout.setSpacing(12) loading_layout.addStretch(1) loading_title = QLabel("Loading iPod...") @@ -421,6 +424,11 @@ def _on_theme_changed(self): if settings_scope == "device" and hasattr(self.settingsPage, "set_settings_scope"): self.settingsPage.set_settings_scope("device") + def _on_artwork_appearance_changed(self): + """Refresh visible artwork after UI-only artwork settings change.""" + self.musicBrowser.refresh_artwork_appearance() + self.selectiveSyncBrowser.refresh_artwork_appearance() + def selectDevice(self): """Open device picker dialog to scan and select an iPod.""" from GUI.widgets.devicePicker import DevicePickerDialog @@ -528,7 +536,6 @@ def onDeviceSettingsFailed(self, path: str, error: str): def _get_ipod_hdd_tag(self) -> bool: from infrastructure.device_tags import get_ipod_hdd_tag - device = self.device_manager.discovered_ipod ipod_root = self.device_manager.device_path or "" value = get_ipod_hdd_tag(device, ipod_root) @@ -536,7 +543,6 @@ def _get_ipod_hdd_tag(self) -> bool: def _set_ipod_hdd_tag(self, ipod_hdd: bool) -> None: from infrastructure.device_tags import set_ipod_hdd_tag - device = self.device_manager.discovered_ipod if device is None: return @@ -545,10 +551,8 @@ def _set_ipod_hdd_tag(self, ipod_hdd: bool) -> None: def _ensure_ipod_drive_tag(self, device_info, ipod_root: str) -> None: from infrastructure.device_tags import get_ipod_hdd_tag, set_ipod_hdd_tag - if get_ipod_hdd_tag(device_info, ipod_root) is not None: return - msg = QMessageBox(self) msg.setWindowTitle("iPod Storage Type") msg.setText( @@ -733,7 +737,8 @@ def _onDeviceRenamed(self, new_name: str): dev = device.discovered_ipod if dev is not None: - setattr(dev, "ipod_name", new_name) + from typing import cast + cast(Any, dev).ipod_name = new_name # Update master playlist Title in the cache playlists = cache.get_playlists() @@ -914,27 +919,35 @@ def _create_back_sync_artwork_provider(self, ipod_path: str): from GUI.imgMaker import configure_artwork_api, get_artwork configure_artwork_api(str(artworkdb_path), str(artwork_folder)) - artwork_folder_str = str(artwork_folder) except Exception: logger.debug("Back Sync artwork context unavailable", exc_info=True) return None - def _provider(track: dict) -> bytes | None: - img_id = ( + def _track_artwork_id(track: dict) -> int | None: + artwork_id = ( track.get("artwork_id_ref") or track.get("mhii_link") or track.get("mhiiLink") or 0 ) - if not img_id: + if not artwork_id: + return None + try: + return int(artwork_id) + except (TypeError, ValueError): + return None + + def _provider(track: dict) -> bytes | None: + artwork_id = _track_artwork_id(track) + if artwork_id is None: return None try: import io - result = get_artwork(int(img_id), mode="with_colors") - if not result: + img = get_artwork(artwork_id, mode="image_only") + if not img: return None - img = result[0].convert("RGB") + img = img.convert("RGB") buf = io.BytesIO() img.save(buf, format="JPEG", quality=90) return buf.getvalue() @@ -990,16 +1003,10 @@ def startPCSync(self): # User clicked Continue Anyway (only possible when fpcalc is present) # Show folder selection dialog - dialog = PCFolderDialog( - self, - self._last_pc_folder, - ipod_hdd=self._get_ipod_hdd_tag(), - ) + dialog = PCFolderDialog(self, self._last_pc_folder) if dialog.exec() != dialog.DialogCode.Accepted: return - self._set_ipod_hdd_tag(bool(dialog.is_ipod_hdd)) - self._last_pc_folder = dialog.selected_folder # Persist the folder choice global_settings = self.settings_service.get_global_settings() @@ -1027,7 +1034,7 @@ def startPCSync(self): pc_folder=self._last_pc_folder, ipod_tracks=ipod_tracks, ipod_path=device_manager.device_path or "", - ipod_hdd=bool(dialog.is_ipod_hdd), + ipod_hdd=self._get_ipod_hdd_tag(), ), artwork_provider=self._create_back_sync_artwork_provider( device_manager.device_path or "", @@ -1752,7 +1759,7 @@ def __init__( ): super().__init__(parent) self.setWindowTitle("Missing Tools") - self.setFixedWidth((420)) + self.setFixedWidth(420) self.setStyleSheet(f""" QDialog {{ background: {Colors.DIALOG_BG}; @@ -1762,7 +1769,7 @@ def __init__( layout = QVBoxLayout(self) layout.setContentsMargins((28), (24), (28), (24)) - layout.setSpacing((10)) + layout.setSpacing(10) # Icon + title row icon_label = QLabel() @@ -1782,7 +1789,7 @@ def __init__( title.setWordWrap(True) layout.addWidget(title) - layout.addSpacing((4)) + layout.addSpacing(4) if can_download: body = QLabel( @@ -1797,17 +1804,17 @@ def __init__( body.setWordWrap(True) layout.addWidget(body) - layout.addSpacing((12)) + layout.addSpacing(12) # Buttons btn_row = QHBoxLayout() - btn_row.setSpacing((12)) + btn_row.setSpacing(12) if can_download: no_btn = QPushButton("Not Now") no_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG)) no_btn.setCursor(Qt.CursorShape.PointingHandCursor) - no_btn.setMinimumHeight((40)) + no_btn.setMinimumHeight(40) no_btn.setStyleSheet(btn_css( bg=Colors.SURFACE_RAISED, bg_hover=Colors.SURFACE_HOVER, @@ -1821,7 +1828,7 @@ def __init__( yes_btn = QPushButton("Download") yes_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG)) yes_btn.setCursor(Qt.CursorShape.PointingHandCursor) - yes_btn.setMinimumHeight((40)) + yes_btn.setMinimumHeight(40) yes_btn.setStyleSheet(btn_css( bg=Colors.ACCENT_DIM, bg_hover=Colors.ACCENT_HOVER, @@ -1835,7 +1842,7 @@ def __init__( ok_btn = QPushButton("OK") ok_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG)) ok_btn.setCursor(Qt.CursorShape.PointingHandCursor) - ok_btn.setMinimumHeight((40)) + ok_btn.setMinimumHeight(40) ok_btn.setStyleSheet(btn_css( bg=Colors.SURFACE_RAISED, bg_hover=Colors.SURFACE_HOVER, @@ -1862,7 +1869,7 @@ def add_continue_option(self): cont_btn = QPushButton("Continue Anyway") cont_btn.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG)) cont_btn.setCursor(Qt.CursorShape.PointingHandCursor) - cont_btn.setMinimumHeight((40)) + cont_btn.setMinimumHeight(40) cont_btn.setStyleSheet(btn_css( bg=Colors.ACCENT_DIM, bg_hover=Colors.ACCENT_HOVER, @@ -1895,7 +1902,7 @@ def __init__(self, parent: QWidget): layout = QVBoxLayout(self) layout.setContentsMargins((28), (24), (28), (24)) - layout.setSpacing((14)) + layout.setSpacing(14) title = QLabel("Downloading Tools…") title.setFont(QFont(FONT_FAMILY, Metrics.FONT_XXL, QFont.Weight.Bold)) @@ -1911,7 +1918,7 @@ def __init__(self, parent: QWidget): bar = QProgressBar() bar.setRange(0, 0) # indeterminate - bar.setFixedHeight((6)) + bar.setFixedHeight(6) bar.setTextVisible(False) bar.setStyleSheet(f""" QProgressBar {{ diff --git a/GUI/artwork_rendering.py b/GUI/artwork_rendering.py new file mode 100644 index 0000000..1cc967e --- /dev/null +++ b/GUI/artwork_rendering.py @@ -0,0 +1,100 @@ +"""Helpers for UI-only artwork presentation effects.""" + +from __future__ import annotations + +from PIL import Image, ImageEnhance, ImageFilter +from PyQt6.QtCore import QRectF, Qt +from PyQt6.QtGui import QPainter, QPainterPath, QPixmap + + +def nested_artwork_radius( + surface_radius: int, + inset: int, + *, + min_radius: int = 4, +) -> int: + """Return an artwork radius that visually echoes its containing surface.""" + + radius = int(round(float(surface_radius) - (max(0, inset) * 0.4))) + return max(min_radius, min(surface_radius, radius)) + + +def rounded_artwork_pixmap(pixmap: QPixmap, radius: int) -> QPixmap: + """Return a copy of *pixmap* clipped to a rounded rectangle.""" + + if pixmap.isNull() or radius <= 0: + return pixmap + + target = QPixmap(pixmap.size()) + target.setDevicePixelRatio(pixmap.devicePixelRatio()) + target.fill(Qt.GlobalColor.transparent) + + dpr = target.devicePixelRatio() + rect = QRectF( + 0.0, + 0.0, + target.width() / dpr, + target.height() / dpr, + ) + + path = QPainterPath() + path.addRoundedRect(rect, float(radius), float(radius)) + + painter = QPainter(target) + painter.setRenderHint(QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, True) + painter.setClipPath(path) + painter.drawPixmap(0, 0, pixmap) + painter.end() + return target + + +def enhance_artwork_image( + image: Image.Image, + *, + enabled: bool = True, +) -> Image.Image: + """Apply UI-only post-processing to decoded artwork.""" + + if not enabled: + return image + + width, height = image.size + min_dim = min(width, height) + + if min_dim <= 0: + return image + + sharpen_percent = 105 + contrast_factor = 1.03 + color_factor = 1.02 + + if min_dim <= 80: + sharpen_percent = 120 + contrast_factor = 1.05 + color_factor = 1.03 + elif min_dim <= 140: + sharpen_percent = 112 + contrast_factor = 1.04 + color_factor = 1.025 + + enhanced = image.filter( + ImageFilter.UnsharpMask(radius=0.8, percent=sharpen_percent, threshold=3) + ) + enhanced = ImageEnhance.Contrast(enhanced).enhance(contrast_factor) + enhanced = ImageEnhance.Color(enhanced).enhance(color_factor) + return enhanced + + +def virtual_artwork_payload( + image: Image.Image, + *, + sharpen: bool = True, +) -> tuple[Image.Image, tuple[int, int, int], dict[str, tuple[int, int, int]]]: + """Return the UI-only enhanced image plus derived display colors.""" + + from .imgMaker import get_artwork_colors + + enhanced = enhance_artwork_image(image, enabled=sharpen) + dominant_color, album_colors = get_artwork_colors(enhanced) + return enhanced, dominant_color, album_colors diff --git a/GUI/imgMaker.py b/GUI/imgMaker.py index 37ebe60..0efb552 100644 --- a/GUI/imgMaker.py +++ b/GUI/imgMaker.py @@ -34,7 +34,7 @@ from typing import Any, Literal, overload import numpy as np -from PIL import Image, ImageEnhance, ImageFilter +from PIL import Image from ArtworkDB_Writer.ithmb_codecs import decode_pixels_for_format @@ -251,35 +251,6 @@ def read_rgb565_pixels(img_data, fmt): return pixels -def _enhance_decoded_artwork(img_pil): - """Apply mild post-processing to small decoded ithmb artwork.""" - width, height = img_pil.size - min_dim = min(width, height) - - if min_dim <= 0: - return img_pil - - sharpen_percent = 105 - contrast_factor = 1.03 - color_factor = 1.02 - - if min_dim <= 80: - sharpen_percent = 120 - contrast_factor = 1.05 - color_factor = 1.03 - elif min_dim <= 140: - sharpen_percent = 112 - contrast_factor = 1.04 - color_factor = 1.025 - - enhanced = img_pil.filter( - ImageFilter.UnsharpMask(radius=0.8, percent=sharpen_percent, threshold=3) - ) - enhanced = ImageEnhance.Contrast(enhanced).enhance(contrast_factor) - enhanced = ImageEnhance.Color(enhanced).enhance(color_factor) - return enhanced - - def generate_image(ithmb_filename, image_info): """Generate image from the ithmb file based on image_info.""" try: @@ -413,7 +384,7 @@ def generate_image(ithmb_filename, image_info): if img_pil.size != (target_width, target_height): img_pil = img_pil.resize( (target_width, target_height), Image.Resampling.LANCZOS) - return _enhance_decoded_artwork(img_pil) + return img_pil # Non-RGB565 formats (UYVY, I420, RGB555 variants, JPEG) go through # the shared format-aware decoder. @@ -431,7 +402,7 @@ def generate_image(ithmb_filename, image_info): return None if decoded.size != (target_width, target_height): decoded = decoded.resize((target_width, target_height), Image.Resampling.LANCZOS) - return _enhance_decoded_artwork(decoded) + return decoded logger.warning("Unsupported image format: %s", fmt) return None diff --git a/GUI/widgets/MBGridView.py b/GUI/widgets/MBGridView.py index ab547e2..afc4b35 100644 --- a/GUI/widgets/MBGridView.py +++ b/GUI/widgets/MBGridView.py @@ -1,35 +1,40 @@ import difflib import logging -from collections import Counter, deque +from collections.abc import Hashable, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, NamedTuple +from typing import TYPE_CHECKING, Any -from PyQt6.QtCore import QEvent, QRect, QSize, QTimer, pyqtSignal -from PyQt6.QtWidgets import QFrame, QLayout, QLayoutItem, QScrollArea, QSizePolicy -from .MBGridViewItem import MusicBrowserGridItem -from ..styles import Metrics +from PIL import Image +from PyQt6.QtCore import pyqtSignal + +from ..artwork_rendering import virtual_artwork_payload +from .MBGridViewItem import GridItemModel, MusicBrowserGridItem +from .pooledCardGrid import PooledCardGrid if TYPE_CHECKING: - from app_core.services import DeviceSessionService, LibraryCacheLike + from app_core.services import DeviceSessionService, LibraryCacheLike, SettingsService # Fuzzy search: only attempt fuzzy matching for tokens at least this long, # and require a SequenceMatcher ratio above the threshold. _FUZZY_MIN_LEN = 3 _FUZZY_THRESHOLD = 0.78 +_ART_BATCH_SIZE = 20 + + +class _ArtCacheUnset: + """Sentinel returned when artwork is not yet cached but may still exist.""" + -def _token_matches(token: str, corpus_words: list[str]) -> bool: - """Return True if *token* matches any word in *corpus_words*. +_ART_CACHE_UNSET = _ArtCacheUnset() - Two-pass: - 1. Exact substring (fast) — handles normal typing and partial words. - 2. Fuzzy ratio (difflib) — handles typos for tokens >= _FUZZY_MIN_LEN. - """ - # Pass 1: exact substring against each corpus word + +def _token_matches(token: str, corpus_words: tuple[str, ...]) -> bool: + """Return True if *token* matches any word in *corpus_words*.""" for word in corpus_words: if token in word: return True - # Pass 2: fuzzy match for tokens long enough to be meaningful + if len(token) >= _FUZZY_MIN_LEN: for word in corpus_words: if len(word) >= _FUZZY_MIN_LEN: @@ -44,238 +49,75 @@ def _token_matches(token: str, corpus_words: list[str]) -> bool: log = logging.getLogger(__name__) -# -- Flow layout ────────────────────────────────────────────────────────────── -# Lays out fixed-size children left-to-right, wrapping to the next row. -# Items are always left-aligned; no centering hack needed. - -class _FlowLayout(QLayout): - """Left-aligned, wrapping flow layout for fixed-size grid items.""" - - def __init__(self, parent=None, spacing: int = 0): - super().__init__(parent) - self._items: list[QLayoutItem] = [] - self._spacing = spacing - - # -- QLayout API -- - - def addItem(self, a0: QLayoutItem | None): - if a0 is not None: - self._items.append(a0) - - def count(self) -> int: - return len(self._items) - - def itemAt(self, index: int) -> QLayoutItem | None: - if 0 <= index < len(self._items): - return self._items[index] - return None - - def takeAt(self, index: int) -> QLayoutItem | None: - if 0 <= index < len(self._items): - return self._items.pop(index) - return None - - def spacing(self) -> int: - return self._spacing - - def setSpacing(self, a0: int): - self._spacing = a0 - - def hasHeightForWidth(self) -> bool: - return True - - def heightForWidth(self, a0: int) -> int: - return self._do_layout(a0, dry_run=True) - - def sizeHint(self) -> QSize: - return self.minimumSize() - - def minimumSize(self) -> QSize: - # Minimum: one item wide - w = h = 0 - for item in self._items: - sz = item.sizeHint() - w = max(w, sz.width()) - h = max(h, sz.height()) - m = self.contentsMargins() - return QSize(w + m.left() + m.right(), h + m.top() + m.bottom()) - - def setGeometry(self, a0): - super().setGeometry(a0) - self._do_layout(a0.width(), dry_run=False) - - # -- Layout engine -- - - def _do_layout(self, width: int, *, dry_run: bool) -> int: - m = self.contentsMargins() - x = m.left() - y = m.top() - right_edge = width - m.right() - row_height = 0 - sp = self._spacing - - for item in self._items: - sz = item.sizeHint() - # Wrap to next row if this item exceeds the right edge - if x + sz.width() > right_edge and x > m.left(): - x = m.left() - y += row_height + sp - row_height = 0 - - if not dry_run: - item.setGeometry(QRect(x, y, sz.width(), sz.height())) - - x += sz.width() + sp - row_height = max(row_height, sz.height()) - - return y + row_height + m.bottom() - - -_ART_BATCH_SIZE = 20 # mhiiLinks per background worker -_VIRTUALIZE_MIN_ITEMS = 200 # switch to pooled widgets beyond this count -_VIRTUAL_ROW_BUFFER = 1 # extra rows above/below viewport -_VIRTUAL_SCROLL_THROTTLE_MS = 16 - - -# --------------------------------------------------------------------------- -# Public types used by subclasses (e.g. selectiveSyncBrowser.PCMusicBrowserGrid) -# --------------------------------------------------------------------------- - -class ArtworkResult(NamedTuple): - """Decoded artwork ready to apply to a grid item.""" - image: Any # PIL.Image.Image - dominant_color: tuple[int, int, int] - album_colors: dict +@dataclass(frozen=True) +class GridRecord: + """Normalized grid data used by the pooled viewport.""" + source: dict[str, Any] + key: tuple[Any, ...] + title: str + subtitle: str + payload: dict[str, Any] + artwork_id: int | None + artwork_key: Hashable | None + search_words: tuple[str, ...] -# Sentinel: artwork key is known but the image hasn't been decoded yet. -# Distinct from None (= no artwork for this item at all). -_ART_CACHE_UNSET = object() +@dataclass(frozen=True) +class ArtworkResult: + """Artwork payload cached by artwork key.""" -@dataclass -class GridRecord: - """Lightweight descriptor for a single grid item that needs artwork.""" - artwork_key: Any # int (iPod mhiiLink) or str (PC _grid_art_key) or None + image: Image.Image + dominant_color: tuple[int, int, int] | None + album_colors: dict[str, Any] | None -# Return type of _load_cached_artwork: -# ArtworkResult → in RAM cache, ready to apply -# None → no artwork (item has no key, or confirmed missing) -# _ART_CACHE_UNSET → key present, not yet loaded; caller should queue a load -CachedArtworkLookup = ArtworkResult | None +CachedArtworkLookup = ArtworkResult | None | _ArtCacheUnset -class MusicBrowserGrid(QFrame): +class MusicBrowserGrid(PooledCardGrid): """Grid view that displays albums, artists, or genres as clickable items.""" - item_selected = pyqtSignal(dict) # Emits when an item is clicked + + item_selected = pyqtSignal(dict) def __init__( self, *, device_sessions: "DeviceSessionService | None" = None, library_cache: "LibraryCacheLike | None" = None, + settings_service: "SettingsService | None" = None, ): super().__init__() self._device_sessions = device_sessions self._library_cache = library_cache - self._flow = _FlowLayout(self, spacing=Metrics.GRID_SPACING) - self._flow.setContentsMargins(Metrics.GRID_SPACING, Metrics.GRID_SPACING, - Metrics.GRID_SPACING, Metrics.GRID_SPACING) - - # Allow the widget to shrink inside a QScrollArea. - self.setMinimumWidth(0) - self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) - - self.gridItems: list[MusicBrowserGridItem] = [] - self.pendingItems: deque = deque() - self.timerActive = False - self.columnCount = 1 # kept for external compat, not used by layout + self._settings_service = settings_service + self._current_category = "Albums" - self._load_id = 0 - - # Scroll area binding (for virtualized layout updates) - self._scroll_area: QScrollArea | None = None - - # Virtualized grid state - self._virtual_enabled = False - self._virtual_items: list[dict] = [] - self._virtual_pool: list[MusicBrowserGridItem] = [] - self._virtual_visible: dict[int, MusicBrowserGridItem] = {} - self._virtual_columns = 1 - self._virtual_refresh_scheduled = False - self._virtual_force_refresh = False - self._virtual_last_range: tuple[int, int, int] | None = None - - # Widget reuse tracking (non-virtual) - self._item_widgets_by_key: dict[tuple, MusicBrowserGridItem] = {} - self._item_order_keys: list[tuple] = [] - - # Artwork loading state - self._items_by_link: dict[int, list[MusicBrowserGridItem]] = {} # mhiiLink -> items waiting for art - self._art_pending: set[int] = set() # links currently being loaded - self._art_seen: set[int] = set() # links confirmed missing artwork - self._art_cache: dict = {} # per-instance cache (used by PC subclass) - - # Sort / filter state - self._all_items: list[dict] = [] - self._sort_key: str = "title" - self._sort_reverse: bool = False - self._search_query: str = "" - self._search_timer = QTimer(self) - self._search_timer.setSingleShot(True) - self._search_timer.timeout.connect(self._apply_filter_and_sort) - - def attachScrollArea(self, scroll_area: QScrollArea | None) -> None: - """Bind the grid to a QScrollArea to drive virtualized updates.""" - if self._scroll_area is scroll_area: - return - if self._scroll_area is not None: - try: - self._scroll_area.verticalScrollBar().valueChanged.disconnect( - self._on_scroll_changed - ) - except Exception: - pass - try: - self._scroll_area.viewport().removeEventFilter(self) - except Exception: - pass - - self._scroll_area = scroll_area - if scroll_area is None: - return + self._all_items: list[dict[str, Any]] = [] + self._records: list[GridRecord] = [] + self._visible_records: list[GridRecord] = [] + self._sort_key = "title" + self._sort_reverse = False + self._search_query = "" - scroll_area.verticalScrollBar().valueChanged.connect( - self._on_scroll_changed - ) - scroll_area.viewport().installEventFilter(self) - - def eventFilter(self, obj, event): - if ( - self._scroll_area is not None - and obj is self._scroll_area.viewport() - and event.type() in (QEvent.Type.Resize, QEvent.Type.Show) - ): - self._schedule_virtual_refresh(force=True) - return super().eventFilter(obj, event) - - def loadCategory(self, category: str): + self._art_cache: dict[Hashable, ArtworkResult | None] = {} + self._art_pending: set[Hashable] = set() + self._art_seen: set[Hashable] = set() + + def loadCategory(self, category: str) -> None: """Load and display items for the specified category.""" from app_core.runtime import ( build_album_list, build_artist_list, build_genre_list, ) - log.debug(f"loadCategory() called: {category}") + log.debug("loadCategory() called: %s", category) self._current_category = category cache = self._library_cache - if cache is None: - return - if not cache.is_ready(): + if cache is None or not cache.is_ready(): return if category == "Albums": @@ -287,87 +129,53 @@ def loadCategory(self, category: str): else: return - self._all_items = items - self._apply_filter_and_sort() - - def populateGrid(self, items): - """Populate the grid with items.""" - if self._virtual_enabled: - self._set_virtual_items(items) - return - - self.clearGrid(preserve_all_items=True) - current_load_id = self._load_id - - self._item_order_keys = [self._item_key(item) for item in items] - self.pendingItems = deque(zip(self._item_order_keys, items)) - - if self.pendingItems and not self.timerActive: - self.timerActive = True - self._addNextItem(current_load_id) - - def _addNextItem(self, load_id: int): - """Add the next batch of items.""" - if load_id != self._load_id: - self.timerActive = False - return + self._set_source_items(items, reset_scroll=True) - if not self.pendingItems: - self.timerActive = False - # All items added — kick off batched artwork loading - self._load_art_async() - return + def populateGrid( + self, + items: Sequence[dict[str, Any] | MusicBrowserGridItem], + ) -> None: + """Compatibility entry point for setting grid contents directly.""" + normalized_items: list[dict[str, Any]] = [] + for item in items: + if isinstance(item, MusicBrowserGridItem): + normalized_items.append(dict(item.item_data)) + elif isinstance(item, dict): + normalized_items.append(dict(item)) + self._set_source_items(normalized_items, reset_scroll=True) - try: - batch_size = 5 - for _ in range(batch_size): - if not self.pendingItems: - break - - key, item = self.pendingItems.popleft() - - if isinstance(item, dict): - gridItem = self._create_grid_item(item) - self.gridItems.append(gridItem) - self._item_widgets_by_key[key] = gridItem - - # Track which items need artwork - if gridItem.mhiiLink is not None: - if getattr(gridItem, "_art_applied_link", None) != gridItem.mhiiLink: - if not self._apply_cached_art(gridItem) and int(gridItem.mhiiLink) not in self._art_seen: - self._items_by_link.setdefault(int(gridItem.mhiiLink), []).append(gridItem) - - elif isinstance(item, MusicBrowserGridItem): - gridItem = item - if not getattr(gridItem, "_click_connected", False): - gridItem.clicked.connect(self._onItemClicked) - gridItem._click_connected = True - else: - continue + def setSort(self, key: str, reverse: bool = False) -> None: + """Apply a new sort order to the current item list.""" + self._sort_key = key + self._sort_reverse = reverse + self._apply_filter_and_sort(reset_scroll=False) - self._flow.addWidget(gridItem) + def setSearchFilter(self, query: str) -> None: + """Filter grid items whose title contains *query* (case-insensitive).""" + self._search_query = query + self._apply_filter_and_sort(reset_scroll=False) - # Update minimum height so the scroll area can size correctly. - w = self.width() - if w > 0: - self.setMinimumHeight(self._flow.heightForWidth(w)) + def resetFilters(self) -> None: + """Reset sort and search to defaults without reloading source data.""" + self._sort_key = "title" + self._sort_reverse = False + self._search_query = "" + self._apply_filter_and_sort(reset_scroll=False) - if self.pendingItems and load_id == self._load_id: - QTimer.singleShot(8, lambda: self._addNextItem(load_id)) - else: - self.timerActive = False - # All items added — kick off batched artwork loading - self._load_art_async() + def clearGrid(self, preserve_all_items: bool = False) -> None: + """Clear all rendered widgets and cancel pending artwork work.""" + self._art_pending.clear() + self._art_seen.clear() + self._visible_records = [] - except RuntimeError: - # Qt has destroyed the underlying C++ layout/widget (e.g. the - # MusicBrowserGrid was deleted while this timer was pending). - # Nothing to do — just stop the loading chain. - self.timerActive = False + if not preserve_all_items: + self._all_items = [] + self._records = [] + self._art_cache.clear() + super().clearGrid(preserve_all_items=False) @staticmethod - def _item_key(item: dict) -> tuple: - """Stable identity key for reuse across sorts/filters.""" + def _item_key(item: dict[str, Any]) -> tuple[Any, ...]: return ( item.get("category", ""), item.get("album") or "", @@ -377,86 +185,246 @@ def _item_key(item: dict) -> tuple: item.get("filter_value") or "", ) - @staticmethod - def _normalize_item(item: dict) -> tuple[str, str, Any, dict]: - title = item.get("title") or item.get("album", "Unknown") - subtitle = item.get("subtitle") or item.get("artist", "") - mhiiLink = item.get("artwork_id_ref") - item_data = { - "title": title, - "subtitle": subtitle, - "artwork_id_ref": mhiiLink, - "category": item.get("category", "Albums"), - "filter_key": item.get("filter_key", "Album"), - "filter_value": item.get("filter_value", title), - "album": item.get("album"), - "artist": item.get("artist"), - "_grid_art_key": item.get("_grid_art_key"), + @classmethod + def _build_record(cls, item: dict[str, Any]) -> GridRecord: + source = dict(item) + title = source.get("title") or source.get("album", "Unknown") + subtitle = source.get("subtitle") or source.get("artist", "") + artwork_id = source.get("artwork_id_ref") + artwork_key = source.get("_grid_art_key", artwork_id) + + payload = { + key: value + for key, value in source.items() + if not str(key).startswith("_") } - return title, subtitle, mhiiLink, item_data - - def _create_grid_item(self, item: dict) -> MusicBrowserGridItem: - title, subtitle, mhiiLink, item_data = self._normalize_item(item) - gridItem = MusicBrowserGridItem(title, subtitle, mhiiLink, item_data) - gridItem.setParent(self) - gridItem.clicked.connect(self._onItemClicked) - gridItem._click_connected = True - gridItem._item_key = self._item_key(item) - return gridItem - - def _apply_cached_art(self, widget: MusicBrowserGridItem) -> bool: - """Apply cached art immediately if available.""" - link = widget.mhiiLink - if link is None: - return False + payload["title"] = title + payload["subtitle"] = subtitle + payload["artwork_id_ref"] = artwork_id + payload.setdefault("category", "Albums") + payload.setdefault("filter_key", "Album") + payload.setdefault("filter_value", title) + payload.setdefault("album", source.get("album")) + payload.setdefault("artist", source.get("artist")) + + parts: list[str] = [] + for field in ("title", "artist"): + value = payload.get(field) + if value: + parts.append(str(value).lower()) + year = payload.get("year") + if year: + parts.append(str(year)) - if getattr(widget, "_art_applied_link", None) == link: - return True + return GridRecord( + source=source, + key=cls._item_key(payload), + title=title, + subtitle=subtitle, + payload=payload, + artwork_id=artwork_id, + artwork_key=artwork_key, + search_words=tuple(" ".join(parts).split()), + ) + + def _set_source_items( + self, + items: list[dict[str, Any]], + *, + reset_scroll: bool, + ) -> None: + self._all_items = [dict(item) for item in items] + self._records = [self._build_record(item) for item in self._all_items] + self._art_pending.clear() + self._apply_filter_and_sort(reset_scroll=reset_scroll) + + def _apply_filter_and_sort(self, *, reset_scroll: bool) -> None: + # Any active artwork batch was bound to the previous viewport/load_id. + # Clear pending markers so filtered/re-sorted cards can request art again. + self._art_pending.clear() + records = self._records + + if self._search_query: + tokens = self._search_query.lower().split() + filtered: list[GridRecord] = [] + for record in records: + if all(_token_matches(token, record.search_words) for token in tokens): + filtered.append(record) + records = filtered + + def _key_fn(record: GridRecord): + value = record.source.get(self._sort_key) + if isinstance(value, str): + return value.lower() + return value if value is not None else 0 + + self._visible_records = sorted( + records, + key=_key_fn, + reverse=self._sort_reverse, + ) + self._set_viewport_records( + self._visible_records, + reset_scroll=reset_scroll, + preserve_selection=False, + fallback_index=-1, + ) + + def _model_for_record( + self, + record: GridRecord, + cached_artwork: CachedArtworkLookup, + ) -> GridItemModel: + if isinstance(cached_artwork, ArtworkResult): + return GridItemModel( + title=record.title, + subtitle=record.subtitle, + artwork_id=record.artwork_id, + payload=record.payload, + image=cached_artwork.image, + dominant_color=cached_artwork.dominant_color, + album_colors=cached_artwork.album_colors, + ) + + return GridItemModel( + title=record.title, + subtitle=record.subtitle, + artwork_id=record.artwork_id, + payload=record.payload, + ) + + def _record_identity(self, record: GridRecord) -> Hashable: + return record.key + + def _create_pooled_widget(self) -> MusicBrowserGridItem: + return MusicBrowserGridItem() + + def _connect_widget(self, widget) -> None: + if isinstance(widget, MusicBrowserGridItem): + widget.clicked.connect(self._onItemClicked) + + def _bind_widget( + self, + widget, + record_index: int, + record: GridRecord, + ) -> None: + assert isinstance(widget, MusicBrowserGridItem) + cached_artwork = self._lookup_cached_artwork(record) + widget.set_rounded_artwork(self._rounded_artwork_enabled()) + widget.set_model(self._model_for_record(record, cached_artwork)) + + def _after_viewport_refresh(self) -> None: + self._load_art_async() + + def _lookup_cached_artwork( + self, + record: GridRecord, + ) -> CachedArtworkLookup: + art_key = record.artwork_key + if art_key is None: + return None + if art_key in self._art_cache: + return self._art_cache[art_key] + if art_key in self._art_seen: + return None + + cached = self._load_cached_artwork(record) + if isinstance(cached, _ArtCacheUnset): + return _ART_CACHE_UNSET + + self._art_cache[art_key] = cached + if cached is None: + self._art_seen.add(art_key) + return cached + + def _load_cached_artwork( + self, + record: GridRecord, + ) -> CachedArtworkLookup: + if record.artwork_id is None: + return None try: from ..imgMaker import get_artwork except Exception: - return False + return _ART_CACHE_UNSET - cached = get_artwork(int(link), mode="cache_only") + cached = get_artwork(int(record.artwork_id), mode="cache_only") if cached is None: - return False + return _ART_CACHE_UNSET - img, dcol, album_colors = cached - widget.applyImageResult(img, dcol, album_colors) - return True + image, _dominant_color, _album_colors = cached + image, dominant_color, album_colors = virtual_artwork_payload( + image, + sharpen=self._sharpen_artwork_enabled(), + ) + return ArtworkResult(image, dominant_color, album_colors) - # ------------------------------------------------------------------------- - # Batched artwork loading - # ------------------------------------------------------------------------- + def _apply_art_to_widget( + self, + widget: MusicBrowserGridItem, + record: GridRecord, + ) -> None: + cached = self._lookup_cached_artwork(record) + if isinstance(cached, _ArtCacheUnset): + widget.apply_image_result(None, None, None) + return + if cached is None: + widget.apply_image_result(None, None, None) + return + widget.apply_image_result( + cached.image, + cached.dominant_color, + cached.album_colors, + ) - def _load_art_async(self): - """Collect unique mhiiLinks and load artwork in background batches.""" + def _visible_records_needing_art(self) -> list[GridRecord]: + needed: list[GridRecord] = [] + seen_keys: set[Hashable] = set() + for record_index in sorted(self._visible_widgets): + record = self._visible_records[record_index] + art_key = record.artwork_key + if ( + art_key is None + or art_key in seen_keys + or art_key in self._art_cache + or art_key in self._art_pending + or art_key in self._art_seen + ): + continue + if self._lookup_cached_artwork(record) is _ART_CACHE_UNSET: + needed.append(record) + seen_keys.add(art_key) + return needed + + def _load_art_async(self) -> None: + """Collect visible artwork keys and load missing art in batches.""" from app_core.runtime import ThreadPoolSingleton, Worker - links_to_load = ( - set(self._items_by_link.keys()) - - self._art_pending - - self._art_seen - ) - if not links_to_load: - return - if self._device_sessions is None: + records = self._visible_records_needing_art() + if not records or self._device_sessions is None: return session = self._device_sessions.current_session() if not session.device_path or not session.artworkdb_path: return + artwork_folder = session.artwork_folder_path or "" cancellation_token = self._device_sessions.manager().cancellation_token - - self._art_pending |= links_to_load load_id = self._load_id - links_list = list(links_to_load) pool = ThreadPoolSingleton.get_instance() - for i in range(0, len(links_list), _ART_BATCH_SIZE): - chunk = links_list[i:i + _ART_BATCH_SIZE] + pairs: list[tuple[Hashable, int]] = [] + for record in records: + art_key = record.artwork_key + if art_key is None or record.artwork_id is None: + continue + self._art_pending.add(art_key) + pairs.append((art_key, int(record.artwork_id))) + + for i in range(0, len(pairs), _ART_BATCH_SIZE): + chunk = pairs[i:i + _ART_BATCH_SIZE] worker = Worker( self._load_art_batch, chunk, @@ -469,533 +437,118 @@ def _load_art_async(self): ) pool.start(worker) - @staticmethod def _load_art_batch( - links: list[int], + self, + pairs: list[tuple[Hashable, int]], artworkdb_path: str, artwork_folder: str, cancellation_token: Any, - ) -> dict: - """Background worker: decode artwork + colors for a batch of mhiiLinks.""" - from ..imgMaker import configure_artwork_api, get_artwork + ) -> dict[Hashable, tuple[int, int, bytes, tuple[int, int, int] | None, dict[str, Any] | None] | None]: + """Background worker: decode artwork + colors for a batch of artwork keys.""" import os + from ..imgMaker import configure_artwork_api, get_artwork + if not artworkdb_path or not os.path.exists(artworkdb_path): return {} configure_artwork_api(artworkdb_path, artwork_folder) - results: dict[int, tuple | None] = {} + results: dict[ + Hashable, + tuple[int, int, bytes, tuple[int, int, int] | None, dict[str, Any] | None] + | None, + ] = {} - for link in links: + for art_key, link in pairs: if cancellation_token.is_cancelled(): break - result = get_artwork(int(link), mode="with_colors") - if result is not None: - pil_img, dcol, album_colors = result - # Serialize PIL image to RGBA bytes for thread-safe transfer - pil_img = pil_img.convert("RGBA") - results[link] = (pil_img.width, pil_img.height, - pil_img.tobytes("raw", "RGBA"), - dcol, album_colors) - else: - results[link] = None + image = get_artwork(link, mode="image_only") + if image is None: + results[art_key] = None + continue + + pil_img, dominant_color, album_colors = virtual_artwork_payload( + image, + sharpen=self._sharpen_artwork_enabled(), + ) + pil_img = pil_img.convert("RGBA") + results[art_key] = ( + pil_img.width, + pil_img.height, + pil_img.tobytes("raw", "RGBA"), + dominant_color, + album_colors, + ) return results - def _on_art_loaded(self, results: dict | None, load_id: int): - """Main-thread callback: apply loaded artwork to grid items.""" + def _on_art_loaded( + self, + results: dict[ + Hashable, + tuple[int, int, bytes, tuple[int, int, int] | None, dict[str, Any] | None] + | None, + ] + | None, + load_id: int, + ) -> None: + """Main-thread callback: apply artwork to currently bound widgets.""" if results is None or self._load_id != load_id: return - from PIL import Image - try: - for link, data in results.items(): - self._art_pending.discard(link) - items = self._items_by_link.get(link, []) - if not items: - continue - + for art_key, data in results.items(): + self._art_pending.discard(art_key) if data is None: - self._art_seen.add(link) - for item in items: - if item.mhiiLink == link: - item.applyImageResult(None, None, None) + self._art_cache[art_key] = None + self._art_seen.add(art_key) + self._apply_art_to_visible_widgets(art_key) continue - w, h, rgba, dcol, album_colors = data - pil_img = Image.frombytes("RGBA", (w, h), rgba) - - for item in items: - if item.mhiiLink == link: - item.applyImageResult(pil_img, dcol, album_colors) - - # Remove from tracking — these items are done - self._items_by_link.pop(link, None) - + width, height, rgba, dominant_color, album_colors = data + pil_img = Image.frombytes("RGBA", (width, height), rgba) + self._art_cache[art_key] = ArtworkResult( + pil_img, + dominant_color, + album_colors, + ) + self._apply_art_to_visible_widgets(art_key) except RuntimeError: - pass # Widget deleted - - # ── Extended API used by subclasses ────────────────────────────────────── - - def _set_source_items(self, items: list[dict], *, reset_scroll: bool = False) -> None: - """Set source items directly and re-apply filter/sort.""" - self._all_items = items - self._apply_filter_and_sort() - if reset_scroll and self._scroll_area is not None: - self._scroll_area.verticalScrollBar().setValue(0) - - def _load_cached_artwork(self, record: GridRecord) -> CachedArtworkLookup: - """Check the RAM cache for *record*'s artwork (iPod mode). - - Returns ArtworkResult if cached, None if confirmed missing, - or _ART_CACHE_UNSET if the key exists but hasn't been decoded yet. - """ - key = record.artwork_key - if key is None: - return None - if key in self._art_seen: - return None - try: - from ..imgMaker import get_artwork - result = get_artwork(int(key), mode="cache_only") - if result is not None: - img, dcol, colors = result - return ArtworkResult(img, dcol, colors) - except Exception: pass - return _ART_CACHE_UNSET - def _visible_records_needing_art(self) -> list[GridRecord]: - """Return a GridRecord for each visible item that still needs artwork.""" - records: list[GridRecord] = [] - seen: set = set() - for widget in self.gridItems: - art_key = widget.item_data.get("_grid_art_key") - if art_key is None and widget.mhiiLink is not None: - art_key = int(widget.mhiiLink) - if art_key is None: + def _apply_art_to_visible_widgets(self, artwork_key: Hashable) -> None: + for record_index, widget in list(self._visible_widgets.items()): + if record_index >= len(self._visible_records): continue - if art_key in seen or art_key in self._art_seen: + if not isinstance(widget, MusicBrowserGridItem): continue - if getattr(widget, "_art_applied_link", None) == widget.mhiiLink: + record = self._visible_records[record_index] + if record.artwork_key != artwork_key: continue - if isinstance(art_key, str) and art_key in self._art_cache: - continue - seen.add(art_key) - records.append(GridRecord(artwork_key=art_key)) - return records - - def _apply_art_to_visible_widgets(self, key) -> None: - """Apply cached artwork for *key* to all visible widgets that want it.""" - art = self._art_cache.get(key) - for widget in self.gridItems: - widget_key = widget.item_data.get("_grid_art_key") - if widget_key is None and widget.mhiiLink is not None: - widget_key = int(widget.mhiiLink) - if widget_key != key: - continue - if art is None: - widget.applyImageResult(None, None, None) - else: - widget.applyImageResult(art.image, art.dominant_color, art.album_colors) + self._apply_art_to_widget(widget, record) - def _onItemClicked(self, item_data: dict): - """Handle grid item click.""" + def _onItemClicked(self, item_data: dict) -> None: self.item_selected.emit(item_data) - # ── Sort / filter ───────────────────────────────────────────────────────── - - def setSort(self, key: str, reverse: bool = False) -> None: - """Apply a new sort order to the current item list.""" - self._sort_key = key - self._sort_reverse = reverse - self._apply_filter_and_sort() - - def setSearchFilter(self, query: str) -> None: - """Filter grid items whose title contains *query* (case-insensitive).""" - self._search_query = query - if self._search_timer.isActive(): - self._search_timer.stop() - self._search_timer.start(250) - - def resetFilters(self) -> None: - """Reset sort and search to defaults without reloading source data.""" - self._sort_key = "title" - self._sort_reverse = False - self._search_query = "" - if self._search_timer.isActive(): - self._search_timer.stop() - self._apply_filter_and_sort() - - @staticmethod - def _search_corpus(item: dict) -> str: - """Build a single lowercase string of every searchable field for *item*. - - Albums: album title + artist name + year - Artists: artist name (= title) - Genres: genre name (= title) - """ - parts = [] - for field in ("title", "artist"): - v = item.get(field) - if v: - parts.append(str(v).lower()) - year = item.get("year") - if year: - parts.append(str(year)) - return " ".join(parts) - - def _apply_filter_and_sort(self) -> None: - items = self._all_items - - if self._search_query: - tokens = self._search_query.lower().split() - filtered = [] - for x in items: - words = self._search_corpus(x).split() - if all(_token_matches(t, words) for t in tokens): - filtered.append(x) - items = filtered - - def _key_fn(x): - v = x.get(self._sort_key) - if isinstance(v, str): - return v.lower() - return v if v is not None else 0 - - items = sorted(items, key=_key_fn, reverse=self._sort_reverse) - self._update_grid(items) - - def _update_grid(self, items: list[dict]) -> None: - if self.timerActive or self.pendingItems: - # Cancel any in-progress batch build before re-sorting. - self.clearGrid(preserve_all_items=True) - - if self._should_virtualize(items): - if not self._virtual_enabled: - self._enter_virtual_mode() - self._set_virtual_items(items) - return - - if self._virtual_enabled: - self._exit_virtual_mode() - - self._update_non_virtual(items) - - def _should_virtualize(self, items: list[dict]) -> bool: - return len(items) >= _VIRTUALIZE_MIN_ITEMS and self._scroll_area is not None - - def _enter_virtual_mode(self) -> None: - self.clearGrid(preserve_all_items=True) - self._virtual_enabled = True - - def _exit_virtual_mode(self) -> None: - self._clear_virtual_widgets(delete_widgets=True) - self._virtual_enabled = False - - def _update_non_virtual(self, items: list[dict]) -> None: - if not items: - self.clearGrid(preserve_all_items=True) - return - - new_keys = [self._item_key(item) for item in items] - - if not self.gridItems: - self.populateGrid(items) - return - - if Counter(new_keys) == Counter(self._item_order_keys): - if new_keys != self._item_order_keys: - self._reorder_existing_widgets(new_keys) - return - - self._diff_rebuild_non_virtual(items, new_keys) - - def _reorder_existing_widgets(self, new_keys: list[tuple]) -> None: - while self._flow.count(): - self._flow.takeAt(0) - - new_items: list[MusicBrowserGridItem] = [] - for key in new_keys: - widget = self._item_widgets_by_key.get(key) - if widget is None: - continue - self._flow.addWidget(widget) - new_items.append(widget) - - self.gridItems = new_items - self._item_order_keys = new_keys - - w = self.width() - if w > 0: - self.setMinimumHeight(self._flow.heightForWidth(w)) - - def _diff_rebuild_non_virtual(self, items: list[dict], new_keys: list[tuple]) -> None: - old_map = dict(self._item_widgets_by_key) - self._item_widgets_by_key.clear() - self._item_order_keys = new_keys - self.gridItems = [] - self._items_by_link.clear() - - while self._flow.count(): - self._flow.takeAt(0) - - for item, key in zip(items, new_keys): - widget = old_map.pop(key, None) - if widget is None: - widget = self._create_grid_item(item) - else: - title, subtitle, mhiiLink, item_data = self._normalize_item(item) - widget.update_item_data(title, subtitle, mhiiLink, item_data) - widget._item_key = key - - self._item_widgets_by_key[key] = widget - self.gridItems.append(widget) - self._flow.addWidget(widget) - - if widget.mhiiLink is not None: - if not self._apply_cached_art(widget) and int(widget.mhiiLink) not in self._art_seen: - self._items_by_link.setdefault(int(widget.mhiiLink), []).append(widget) - - for widget in old_map.values(): - try: - widget.cleanup() - except Exception: - pass - widget.deleteLater() - - w = self.width() - if w > 0: - self.setMinimumHeight(self._flow.heightForWidth(w)) - - self._load_art_async() - - # ── Virtualized layout ─────────────────────────────────────────────── - - def _set_virtual_items(self, items: list[dict]) -> None: - self._virtual_items = items - self._virtual_force_refresh = True - self._schedule_virtual_refresh(force=True) - - def _schedule_virtual_refresh(self, *, force: bool = False) -> None: - if not self._virtual_enabled: - return - if force: - self._virtual_force_refresh = True - if self._virtual_refresh_scheduled: - return - self._virtual_refresh_scheduled = True - delay = 0 if force else _VIRTUAL_SCROLL_THROTTLE_MS - QTimer.singleShot(delay, self._refresh_virtual_viewport) - - def _on_scroll_changed(self, _value: int) -> None: - if self._virtual_enabled: - self._schedule_virtual_refresh() - - def _refresh_virtual_viewport(self) -> None: - self._virtual_refresh_scheduled = False - if not self._virtual_enabled: - return - - items = self._virtual_items - count = len(items) - if count == 0: - self._clear_virtual_widgets(delete_widgets=False) - self.setMinimumHeight(0) - return - - width = self.width() - if width <= 0: - self._schedule_virtual_refresh(force=True) - return - - columns = self._compute_columns(width) - force_rebuild = self._virtual_force_refresh or columns != self._virtual_columns - if columns != self._virtual_columns or self._virtual_force_refresh: - self._virtual_columns = max(1, columns) - self.columnCount = self._virtual_columns - self._recycle_virtual_visible() - self._virtual_last_range = None - - margin = Metrics.GRID_SPACING - row_height = Metrics.GRID_ITEM_H + Metrics.GRID_SPACING - total_rows = (count + self._virtual_columns - 1) // self._virtual_columns - total_height = ( - margin * 2 - + total_rows * Metrics.GRID_ITEM_H - + max(0, total_rows - 1) * Metrics.GRID_SPACING - ) - self.setMinimumHeight(total_height) - - scroll_value = 0 - viewport_height = self.height() - if self._scroll_area is not None and self._scroll_area.viewport() is not None: - scroll_value = self._scroll_area.verticalScrollBar().value() - viewport_height = self._scroll_area.viewport().height() - if viewport_height <= 0: - self._schedule_virtual_refresh(force=True) - return - - first_row = max(0, (scroll_value - margin) // row_height) - last_row = min(total_rows - 1, (scroll_value + viewport_height - margin) // row_height) - first_row = max(0, first_row - _VIRTUAL_ROW_BUFFER) - last_row = min(total_rows - 1, last_row + _VIRTUAL_ROW_BUFFER) - - start_index = first_row * self._virtual_columns - end_index = min(count, (last_row + 1) * self._virtual_columns) - - current_range = (start_index, end_index, self._virtual_columns) - if self._virtual_last_range == current_range and not force_rebuild: - return - self._virtual_last_range = current_range - self._virtual_force_refresh = False - - visible_indices = set(range(start_index, end_index)) - for idx in list(self._virtual_visible.keys()): - if idx not in visible_indices: - widget = self._virtual_visible.pop(idx) - widget.hide() - self._virtual_pool.append(widget) - - for idx in range(start_index, end_index): - widget = self._virtual_visible.get(idx) - if widget is None: - widget = self._virtual_pool.pop() if self._virtual_pool else None - if widget is None: - widget = self._create_grid_item(items[idx]) - else: - title, subtitle, mhiiLink, item_data = self._normalize_item(items[idx]) - widget.update_item_data(title, subtitle, mhiiLink, item_data) - widget._item_key = self._item_key(items[idx]) - if not getattr(widget, "_click_connected", False): - widget.clicked.connect(self._onItemClicked) - widget._click_connected = True - self._virtual_visible[idx] = widget - - row = idx // self._virtual_columns - col = idx % self._virtual_columns - x = margin + col * (Metrics.GRID_ITEM_W + Metrics.GRID_SPACING) - y = margin + row * (Metrics.GRID_ITEM_H + Metrics.GRID_SPACING) - widget.setGeometry(QRect(x, y, Metrics.GRID_ITEM_W, Metrics.GRID_ITEM_H)) - widget.show() - - ordered_indices = sorted(self._virtual_visible) - self.gridItems = [self._virtual_visible[i] for i in ordered_indices] - - self._items_by_link.clear() - for widget in self.gridItems: - if widget.mhiiLink is None: - continue - if getattr(widget, "_art_applied_link", None) == widget.mhiiLink: - continue - if not self._apply_cached_art(widget) and int(widget.mhiiLink) not in self._art_seen: - self._items_by_link.setdefault(int(widget.mhiiLink), []).append(widget) - - self._load_art_async() - - @staticmethod - def _compute_columns(width: int) -> int: - margin = Metrics.GRID_SPACING - usable = max(1, width - (margin * 2)) - cell = Metrics.GRID_ITEM_W + Metrics.GRID_SPACING - return max(1, (usable + Metrics.GRID_SPACING) // cell) - - def _recycle_virtual_visible(self) -> None: - for widget in self._virtual_visible.values(): - widget.hide() - self._virtual_pool.append(widget) - self._virtual_visible.clear() - - def _clear_virtual_widgets(self, *, delete_widgets: bool) -> None: - for widget in list(self._virtual_visible.values()) + list(self._virtual_pool): - widget.hide() - if delete_widgets: - try: - widget.cleanup() - except Exception: - pass - widget.deleteLater() - - self._virtual_visible.clear() - self._virtual_pool.clear() - self._virtual_items = [] - self._virtual_last_range = None - self.gridItems = [] - self._items_by_link.clear() - - # ── Grid management ─────────────────────────────────────────────────────── - - def rearrangeGrid(self): - """Trigger a re-layout (flow layout handles this automatically).""" - if self._virtual_enabled: - self._schedule_virtual_refresh(force=True) - else: - self._flow.activate() - - def clearGrid(self, preserve_all_items: bool = False): - """Clear all grid items to prepare for reloading.""" - self.timerActive = False - self.pendingItems = deque() - self._load_id += 1 - self._items_by_link.clear() - self._art_pending.clear() - self._art_seen.clear() - self._art_cache.clear() - self._item_widgets_by_key.clear() - self._item_order_keys = [] - self._virtual_items = [] - - if self._virtual_enabled: - self._clear_virtual_widgets(delete_widgets=True) - - while self._flow.count(): - item = self._flow.takeAt(0) - if item: - widget = item.widget() - if widget: - if isinstance(widget, MusicBrowserGridItem): - widget.cleanup() - widget.deleteLater() + def refresh_artwork_appearance(self) -> None: + """Re-render visible artwork using the current UI appearance settings.""" + rounded = self._rounded_artwork_enabled() + for widget in list(self._visible_widgets.values()): + if isinstance(widget, MusicBrowserGridItem): + widget.set_rounded_artwork(rounded) - self.gridItems = [] - - if not preserve_all_items: - self._all_items = [] - - self.setMinimumHeight(0) + def _rounded_artwork_enabled(self) -> bool: + if self._settings_service is None: + return False + try: + return bool(self._settings_service.get_effective_settings().rounded_artwork) + except Exception: + return False - def resizeEvent(self, a0): - super().resizeEvent(a0) - if self._virtual_enabled: - self._schedule_virtual_refresh(force=True) - return - # Explicitly set minimum height from the flow layout's heightForWidth - # so the scroll area knows the correct content height. QScrollArea's - # built-in heightForWidth propagation is unreliable when items are - # added incrementally via QTimer while the widget is hidden or the - # viewport hasn't settled yet. - w = a0.size().width() if a0 else self.width() - if w > 0 and self._flow.count(): - self.setMinimumHeight(self._flow.heightForWidth(w)) - - def showEvent(self, a0): - super().showEvent(a0) - if self._virtual_enabled: - self._schedule_virtual_refresh(force=True) - return - # When the widget becomes visible (e.g. stacked-widget page switch), - # defer the relayout to after Qt finishes settling geometry. - # Items added while hidden (width=0) are all at (0,0); activate() is - # a no-op when Qt thinks the layout is current, so we call - # setGeometry() directly to force a real repositioning pass. - if self._flow.count(): - QTimer.singleShot(0, self._force_relayout) - - def _force_relayout(self): - if self._virtual_enabled: - return - w = self.width() - if w > 0 and self._flow.count(): - self._flow.setGeometry(self.rect()) - self.setMinimumHeight(self._flow.heightForWidth(w)) + def _sharpen_artwork_enabled(self) -> bool: + if self._settings_service is None: + return True + try: + return bool(self._settings_service.get_effective_settings().sharpen_artwork) + except Exception: + return True diff --git a/GUI/widgets/MBGridViewItem.py b/GUI/widgets/MBGridViewItem.py index 4ae7658..280956b 100644 --- a/GUI/widgets/MBGridViewItem.py +++ b/GUI/widgets/MBGridViewItem.py @@ -1,23 +1,16 @@ -import logging +from collections.abc import Mapping from collections import OrderedDict +from dataclasses import dataclass import threading -from PyQt6.QtCore import Qt, QSize, pyqtSignal -from PyQt6.QtWidgets import QLabel, QFrame, QVBoxLayout -from PyQt6.QtGui import QFont, QPixmap, QCursor, QImage -from ..hidpi import scale_pixmap_for_display -from ..styles import ( - Colors, - FONT_FAMILY, - Metrics, - current_accent_rgb, - display_accent_rgb, - text_rgb_for_background, -) -from ..glyphs import glyph_pixmap -from .scrollingLabel import ScrollingLabel +from typing import Any -log = logging.getLogger(__name__) +from PIL import Image +from PyQt6.QtCore import QSize, Qt, pyqtSignal +from PyQt6.QtGui import QCursor, QFont, QImage, QPixmap +from PyQt6.QtWidgets import QFrame, QLabel, QVBoxLayout +# ── QPixmap LRU cache ──────────────────────────────────────────────────────── +# Avoids re-converting PIL→QImage→QPixmap on every grid rebuild / re-sort. _PIXMAP_CACHE_MAX = 500 _pixmap_cache: OrderedDict[tuple, QPixmap] = OrderedDict() _pixmap_cache_lock = threading.Lock() @@ -43,27 +36,71 @@ def clear_pixmap_cache() -> None: with _pixmap_cache_lock: _pixmap_cache.clear() +from ..artwork_rendering import ( + nested_artwork_radius, + rounded_artwork_pixmap, +) +from ..glyphs import glyph_pixmap +from ..hidpi import scale_pixmap_for_display +from ..styles import ( + FONT_FAMILY, + Colors, + Metrics, + current_accent_rgb, + display_accent_rgb, + text_rgb_for_background, +) +from .scrollingLabel import ScrollingLabel + + +@dataclass(frozen=True) +class GridItemModel: + """Immutable view model for a grid card. + + Attributes: + title: Primary display text. + subtitle: Secondary display text. + artwork_id: Artwork identifier used by the active artwork pipeline. + payload: Event payload emitted to consumers when the card is clicked. + image: Artwork image if already available. + dominant_color: Dominant artwork color. + album_colors: Derived artwork palette. + """ + title: str + subtitle: str + artwork_id: int | None + payload: Mapping[str, Any] | None = None + image: Image.Image | None = None + dominant_color: tuple[int, int, int] | None = None + album_colors: dict[str, Any] | None = None -def _pixmap_cache_key(mhiiLink, size: int, widget: QLabel) -> tuple | None: - if mhiiLink is None: - return None - try: - dpr = widget.devicePixelRatioF() - except Exception: - dpr = 1.0 - return (int(mhiiLink), size, int(dpr * 1000)) + +@dataclass +class GridItemRenderState: + """Computed styling state derived from the current model.""" + display_dominant_color: tuple[int, int, int] | None = None + display_album_colors: dict[str, Any] | None = None class MusicBrowserGridItem(QFrame): - """A clickable grid item that displays album art, title, and subtitle.""" - clicked = pyqtSignal(dict) # Emits item data when clicked + """Reusable, clickable grid card for albums, artists, and genres.""" + clicked = pyqtSignal(dict) - def __init__(self, title: str, subtitle: str, mhiiLink, item_data: dict | None = None): + def __init__(self): + """Initialize an empty widget that can be populated and recycled.""" super().__init__() - self.title_text = title - self.subtitle_text = subtitle - self.mhiiLink = mhiiLink - self.item_data = item_data or {"title": title, "subtitle": subtitle, "artwork_id_ref": mhiiLink} + + self._model: GridItemModel | None = None + self._base_item_data: dict[str, Any] = {} + self.item_data: dict[str, Any] = {} + self.artwork_id: int | None = None + + self._image: Image.Image | None = None + self._dominant_color: tuple[int, int, int] | None = None + self._album_colors: dict[str, Any] | None = None + self._render_state: GridItemRenderState | None = None + self._applied_artwork_id: int | None = None + self._rounded_artwork = False self.setFixedSize(QSize(Metrics.GRID_ITEM_W, Metrics.GRID_ITEM_H)) self.setCursor(QCursor(Qt.CursorShape.PointingHandCursor)) @@ -71,9 +108,8 @@ def __init__(self, title: str, subtitle: str, mhiiLink, item_data: dict | None = self.gridItemLayout = QVBoxLayout(self) self.gridItemLayout.setContentsMargins((10), (10), (10), (10)) - self.gridItemLayout.setSpacing((6)) + self.gridItemLayout.setSpacing(6) - # Album art self.img_label = QLabel() self.img_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.img_label.setFixedSize(QSize(Metrics.GRID_ART_SIZE, Metrics.GRID_ART_SIZE)) @@ -84,26 +120,61 @@ def __init__(self, title: str, subtitle: str, mhiiLink, item_data: dict | None = """) self.gridItemLayout.addWidget(self.img_label) - if mhiiLink is None: - self._setPlaceholderImage() - - # Title - self.title_label = ScrollingLabel(title) + self.title_label = ScrollingLabel("") self.title_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD, QFont.Weight.DemiBold)) self.title_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.title_label.setStyleSheet(f"border: none; background: transparent; color: {Colors.TEXT_PRIMARY};") - self.title_label.setFixedHeight((20)) + self.title_label.setFixedHeight(20) self.gridItemLayout.addWidget(self.title_label) - # Subtitle - self.subtitle_label = ScrollingLabel(subtitle) + self.subtitle_label = ScrollingLabel("") self.subtitle_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) self.subtitle_label.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) self.subtitle_label.setStyleSheet(f"border: none; background: transparent; color: {Colors.TEXT_SECONDARY};") - self.subtitle_label.setFixedHeight((18)) + self.subtitle_label.setFixedHeight(18) self.gridItemLayout.addWidget(self.subtitle_label) - def _setupStyle(self): + self._render_placeholder() + + def set_model(self, model: GridItemModel) -> None: + """Apply a new view model to the widget.""" + keep_existing_art = ( + model.image is None + and model.artwork_id is not None + and model.artwork_id == self._applied_artwork_id + and self._image is not None + ) + + self._model = model + self.artwork_id = model.artwork_id + self._base_item_data = self._build_item_data(model) + self.item_data = dict(self._base_item_data) + + if model.image is not None: + self._image = model.image + self._dominant_color = model.dominant_color + self._album_colors = model.album_colors + self._applied_artwork_id = model.artwork_id + elif not keep_existing_art: + self._clear_art_state() + + self._render_state = None + self._render_model() + + def _build_item_data(self, model: GridItemModel) -> dict[str, Any]: + payload = dict(model.payload or {}) + payload.setdefault("title", model.title) + payload.setdefault("subtitle", model.subtitle) + payload.setdefault("artwork_id_ref", model.artwork_id) + return payload + + def _clear_art_state(self) -> None: + self._image = None + self._dominant_color = None + self._album_colors = None + self._applied_artwork_id = None + + def _setupStyle(self) -> None: self.setStyleSheet(f""" QFrame {{ background-color: {Colors.SURFACE_RAISED}; @@ -117,8 +188,8 @@ def _setupStyle(self): }} """) - def _setPlaceholderImage(self): - """Set a placeholder when no artwork is available.""" + def _render_placeholder(self) -> None: + """Render the default empty-art state.""" r, g, b = display_accent_rgb( current_accent_rgb(), background=Colors.BG_DARK, @@ -139,24 +210,135 @@ def _setPlaceholderImage(self): color: {Colors.TEXT_TERTIARY}; """) - def _reset_art_background(self, mhiiLink): - """Reset artwork frame for a new item assignment.""" - if mhiiLink is None: - self._setPlaceholderImage() - self._art_applied_link = mhiiLink - return - - self.img_label.setText("") - self.img_label.setPixmap(QPixmap()) + def _render_image(self, pil_image: Image.Image) -> None: + """Render a PIL image into the artwork label.""" + try: + dpr = int(self.img_label.devicePixelRatioF() * 1000) + except Exception: + dpr = 1000 + cache_key = (self.artwork_id, Metrics.GRID_ART_SIZE, dpr, self._rounded_artwork) + pixmap = _pixmap_cache_get(cache_key) + if pixmap is None: + data = pil_image.tobytes("raw", "RGBA") + qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888) + qimage = qimage.copy() + pixmap = scale_pixmap_for_display( + QPixmap.fromImage(qimage), + Metrics.GRID_ART_SIZE, Metrics.GRID_ART_SIZE, + widget=self.img_label, + aspect_mode=Qt.AspectRatioMode.KeepAspectRatio, + transform_mode=Qt.TransformationMode.SmoothTransformation, + ) + if self._rounded_artwork: + pixmap = rounded_artwork_pixmap( + pixmap, + nested_artwork_radius(Metrics.BORDER_RADIUS_XL, 10), + ) + _pixmap_cache_put(cache_key, pixmap) + self.img_label.setPixmap(pixmap) self.img_label.setStyleSheet(f""" border: none; - background: {Colors.SURFACE_ALT}; + background: transparent; border-radius: {Metrics.BORDER_RADIUS}px; """) - self._art_applied_link = None - def applyImageResult(self, pil_image, dcol, album_colors): - """Apply a pre-loaded image result (called by MusicBrowserGrid).""" + def _apply_color_theme(self, render_state: GridItemRenderState) -> None: + """Apply theme styling derived from the artwork.""" + if not render_state.display_dominant_color: + return + + r, g, b = render_state.display_dominant_color + self.setStyleSheet(f""" + QFrame {{ + background-color: rgba({r}, {g}, {b}, 30); + border: 1px solid rgba({r}, {g}, {b}, 25); + border-radius: {Metrics.BORDER_RADIUS_XL}px; + color: {Colors.TEXT_PRIMARY}; + }} + QFrame:hover {{ + background-color: rgba({r}, {g}, {b}, 55); + border: 1px solid rgba({r}, {g}, {b}, 45); + }} + """) + + def _render_model(self) -> None: + """Render the current model state to the widget.""" + if self._model is None: + self.title_label.setText("") + self.subtitle_label.setText("") + self._setupStyle() + self.item_data = {} + self._render_placeholder() + return + + self.title_label.setText(self._model.title) + self.subtitle_label.setText(self._model.subtitle) + self.item_data = dict(self._base_item_data) + self._setupStyle() + + if self._image is not None: + self._render_image(self._image.convert("RGBA")) + self._render_state = self._compute_render_state( + self._dominant_color, + self._album_colors, + ) + + if self._dominant_color: + self.item_data["dominant_color"] = self._dominant_color + if self._render_state.display_dominant_color: + self.item_data["display_dominant_color"] = self._render_state.display_dominant_color + if self._album_colors: + self.item_data["album_colors"] = self._album_colors + if self._render_state.display_album_colors: + self.item_data["display_album_colors"] = self._render_state.display_album_colors + + self._apply_color_theme(self._render_state) + else: + self._render_state = GridItemRenderState() + self._render_placeholder() + + def _compute_render_state( + self, + dcol: tuple[int, int, int] | None, + album_colors: dict[str, Any] | None, + ) -> GridItemRenderState: + """Compute theme colors used for rendering.""" + if not dcol: + return GridItemRenderState() + + display_color = display_accent_rgb( + dcol, + background=Colors.BG_DARK, + target_ratio=Colors.GRID_ART_CONTRAST_TARGET, + ) + + display_album = None + if album_colors and display_color: + text = text_rgb_for_background(display_color) + secondary = ( + (225, 230, 238) + if text == (255, 255, 255) + else (45, 50, 60) + ) + display_album = dict(album_colors) + display_album.update({ + "bg": display_color, + "text": text, + "text_secondary": secondary, + }) + + return GridItemRenderState( + display_dominant_color=display_color, + display_album_colors=display_album, + ) + + def apply_image_result( + self, + pil_image: Image.Image | None, + dominant_color: tuple[int, int, int] | None = None, + album_colors: dict[str, Any] | None = None, + ) -> None: + """Apply artwork loaded asynchronously for the current model.""" try: if not self.isVisible() and not self.parent(): return @@ -164,93 +346,25 @@ def applyImageResult(self, pil_image, dcol, album_colors): return if pil_image is not None: - self._art_applied_link = self.mhiiLink - cache_key = _pixmap_cache_key(self.mhiiLink, Metrics.GRID_ART_SIZE, self.img_label) - pixmap = _pixmap_cache_get(cache_key) if cache_key else None - if pixmap is None: - pil_image = pil_image.convert("RGBA") - data = pil_image.tobytes("raw", "RGBA") - qimage = QImage(data, pil_image.width, pil_image.height, QImage.Format.Format_RGBA8888) - qimage = qimage.copy() - pixmap = scale_pixmap_for_display( - QPixmap.fromImage(qimage), - Metrics.GRID_ART_SIZE, Metrics.GRID_ART_SIZE, - widget=self.img_label, - aspect_mode=Qt.AspectRatioMode.KeepAspectRatio, - transform_mode=Qt.TransformationMode.SmoothTransformation, - ) - if cache_key is not None: - _pixmap_cache_put(cache_key, pixmap) - self.img_label.setPixmap(pixmap) - self.img_label.setStyleSheet(f""" - border: none; - background: transparent; - border-radius: {Metrics.BORDER_RADIUS}px; - """) - - display_color = None - if dcol: - display_color = display_accent_rgb( - dcol, - background=Colors.BG_DARK, - target_ratio=Colors.GRID_ART_CONTRAST_TARGET, - ) - self.item_data["dominant_color"] = dcol - self.item_data["display_dominant_color"] = display_color - if album_colors: - self.item_data["album_colors"] = album_colors - if display_color: - text = text_rgb_for_background(display_color) - secondary = ( - (225, 230, 238) - if text == (255, 255, 255) - else (45, 50, 60) - ) - rendered_album_colors = dict(album_colors) - rendered_album_colors["bg"] = display_color - rendered_album_colors["text"] = text - rendered_album_colors["text_secondary"] = secondary - self.item_data["display_album_colors"] = rendered_album_colors - - if display_color: - r, g, b = display_color - self.setStyleSheet(f""" - QFrame {{ - background-color: rgba({r}, {g}, {b}, 30); - border: 1px solid rgba({r}, {g}, {b}, 25); - border-radius: {Metrics.BORDER_RADIUS_XL}px; - color: {Colors.TEXT_PRIMARY}; - }} - QFrame:hover {{ - background-color: rgba({r}, {g}, {b}, 55); - border: 1px solid rgba({r}, {g}, {b}, 45); - }} - """) + self._image = pil_image + self._dominant_color = dominant_color + self._album_colors = album_colors + self._applied_artwork_id = self.artwork_id else: - self._setPlaceholderImage() - self._art_applied_link = self.mhiiLink - - def update_item_data(self, title: str, subtitle: str, mhiiLink, item_data: dict): - """Update widget contents for a new item without re-creating widgets.""" - self.title_text = title - self.subtitle_text = subtitle - self.title_label.setText(title) - self.subtitle_label.setText(subtitle) - - if mhiiLink != self.mhiiLink: - # Reset style and artwork when the linked image changes. - self._setupStyle() - self._reset_art_background(mhiiLink) - self._art_applied_link = None + self._clear_art_state() + + self._render_model() - self.mhiiLink = mhiiLink - self.item_data = item_data + def set_rounded_artwork(self, enabled: bool) -> None: + """Update whether artwork pixmaps should render with rounded corners.""" + enabled = bool(enabled) + if self._rounded_artwork == enabled: + return + self._rounded_artwork = enabled + if self._model is not None: + self._render_model() def mousePressEvent(self, a0): if a0 and a0.button() == Qt.MouseButton.LeftButton: self.clicked.emit(self.item_data) super().mousePressEvent(a0) - - def cleanup(self): - """Mark widget for destruction (no-op now that loading is centralized).""" - pass diff --git a/GUI/widgets/MBListView.py b/GUI/widgets/MBListView.py index 06fc9b5..f76816f 100644 --- a/GUI/widgets/MBListView.py +++ b/GUI/widgets/MBListView.py @@ -29,6 +29,11 @@ QWidget, ) +from ..artwork_rendering import ( + enhance_artwork_image, + nested_artwork_radius, + rounded_artwork_pixmap, +) from ..glyphs import glyph_icon from ..hidpi import scale_pixmap_for_display from ..styles import FONT_FAMILY, Colors, Metrics, table_css @@ -173,6 +178,13 @@ def format_samples(val: int) -> str: return f"{val:,}" +def _named_qcolor(value: str) -> QColor: + """Build a QColor from a CSS-style color string in a type-checker-friendly way.""" + color = QColor() + color.setNamedColor(value) + return color + + def build_new_regular_playlist( selected_tracks: list[dict], *, @@ -420,6 +432,40 @@ def build_new_regular_playlist( # Artwork thumbnail size in pixels for the track list ART_THUMB_SIZE = 32 +ART_THUMB_COLUMN_PADDING = 12 +COLUMN_LAYOUT_SAVE_DELAY_MS = 150 + + +def _art_column_width() -> int: + """Return the fixed artwork column width with enough room for Qt icon insets.""" + return ART_THUMB_SIZE + ART_THUMB_COLUMN_PADDING + + +def _column_width_map(value: object) -> dict[str, int]: + """Normalize a persisted compact column layout.""" + if not isinstance(value, dict): + return {} + normalized: dict[str, int] = {} + for key, raw in value.items(): + if isinstance(key, str) and isinstance(raw, int) and not isinstance(raw, bool): + normalized[key] = raw + return normalized + + +def _normalize_column_layouts(value: object) -> dict[str, dict[str, int]]: + """Return compact per-content column layouts from settings.""" + if not isinstance(value, dict): + return {} + + normalized: dict[str, dict[str, int]] = {} + for content_key, raw_layout in value.items(): + if not isinstance(content_key, str) or not isinstance(raw_layout, dict): + continue + + layout = _column_width_map(raw_layout) + if layout: + normalized[content_key] = layout + return normalized class _SortableItem(QTableWidgetItem): @@ -629,12 +675,14 @@ def __init__( *, library_cache: LibraryCacheLike | None = None, show_art_override: bool | None = None, + content_type_override: str | None = None, ): super().__init__() self._settings_service = settings_service self._device_sessions = device_sessions self._library_cache = library_cache self._show_art_override = show_art_override + self._content_type_override = content_type_override # Layout self._layout = QVBoxLayout(self) @@ -673,19 +721,40 @@ def __init__( # Artwork state self._show_art = False # Controlled by settings - self._art_cache: dict[int, QPixmap] = {} # artwork_id -> QPixmap + self._art_cache: dict[int, QPixmap] = {} # artwork_id -> scaled source pixmap + self._art_display_cache: dict[tuple[int, bool], QPixmap] = {} self._art_pending: set[int] = set() # artwork_ids currently being loaded # Shared resources (created once, reused) self._font = QFont(FONT_FAMILY, Metrics.FONT_MD) self._advisory_icon_cache: dict[tuple[int, int], QIcon] = {} - # Column visibility state: keys the user has explicitly hidden - self._hidden_columns: set[str] = set() # Column widths the user has set (col_key → pixels) self._user_col_widths: dict[str, int] = {} # Column visual order set by user (logical index list) self._user_col_order: list[str] | None = None + self._column_layouts = _normalize_column_layouts( + getattr( + self._settings_service.get_global_settings(), + "track_list_columns_by_content", + {}, + ) + ) + self._active_column_content_key: str | None = None + self._applying_column_layout = False + self._column_layout_dirty = False + self._header_interaction_signature: tuple[tuple[str, ...], tuple[tuple[str, int], ...]] | None = None + self._column_layout_save_timer = QTimer(self) + self._column_layout_save_timer.setSingleShot(True) + self._column_layout_save_timer.timeout.connect( + self.flush_pending_column_changes + ) + # Separate debounce timer for width resizes (prevents spamming during drag) + self._width_resize_debounce_timer = QTimer(self) + self._width_resize_debounce_timer.setSingleShot(True) + self._width_resize_debounce_timer.timeout.connect( + self._on_width_resize_debounce_timeout + ) # Middle-mouse grab-scroll state self._grab_scrolling = False @@ -894,6 +963,9 @@ def _setup_table(self) -> None: header.setStretchLastSection(True) header.setDefaultSectionSize(150) header.setMinimumSectionSize(40) + header.sectionMoved.connect(self._on_header_section_moved) + header.sectionResized.connect(self._on_header_section_resized) + header.installEventFilter(self) vp = header.viewport() if vp: vp.installEventFilter(self) @@ -1029,6 +1101,15 @@ def filterByPlaylist(self, track_ids: list[int], track_id_index: dict[int, dict] def clearTable(self, clear_cache: bool = False) -> None: """Clear the table completely, cancelling any pending population.""" + if ( + self._active_column_content_key + and ( + self._column_layout_dirty + or self._width_resize_debounce_timer.isActive() + or self._column_layout_save_timer.isActive() + ) + ): + self._store_current_column_layout(self._active_column_content_key) self._cancel_population() self._all_tracks = [] self._tracks = [] @@ -1038,6 +1119,7 @@ def clearTable(self, clear_cache: bool = False) -> None: self._current_playlist = None if clear_cache: self._art_cache.clear() + self._art_display_cache.clear() self._art_pending.clear() try: @@ -1074,8 +1156,185 @@ def _ensure_tracks_loaded(self) -> None: or (t.get("media_type", 1) & mf) ] + def _content_type_key(self) -> str: + """Return the current logical content type for column persistence.""" + if self._content_type_override: + return self._content_type_override + if self._is_playlist_mode: + return "playlist" + + mf = getattr(self, "_media_type_filter", None) + is_video = mf is not None and (mf & 0x62) and not (mf & 0x01) + is_podcast = mf is not None and (mf & 0x04) != 0 and not is_video + is_audiobook = mf is not None and (mf & 0x08) != 0 and not is_video + + if is_video: + return "video" + if is_podcast: + return "podcast" + if is_audiobook: + return "audiobook" + return "music" + + def _apply_saved_column_layout(self, content_key: str) -> None: + """Load the persisted ordered column widths for one content type.""" + layout = self._column_layouts.get(content_key, {}) + self._user_col_order = list(layout) or None + self._user_col_widths = dict(layout) + + def _store_current_column_layout(self, content_key: str | None) -> None: + """Capture the current table layout into the per-content settings map.""" + if not content_key: + return + + if self.table.columnCount() > 0: + self._save_user_widths() + + layout: dict[str, int] = {} + for key in list(self._user_col_order or self._columns): + width = self._user_col_widths.get(key) + if isinstance(width, int) and width > 0: + layout[key] = int(width) + self._column_layouts[content_key] = layout + + def _ensure_column_layout_for_current_content(self) -> None: + """Swap in the saved layout when the list changes content type.""" + content_key = self._content_type_key() + if content_key == self._active_column_content_key: + return + + if ( + self._active_column_content_key + and ( + self._column_layout_dirty + or self._width_resize_debounce_timer.isActive() + or self._column_layout_save_timer.isActive() + ) + ): + self.flush_pending_column_changes() + self._apply_saved_column_layout(content_key) + self._active_column_content_key = content_key + + def _queue_column_layout_save(self) -> None: + """Persist per-content column settings on the next event loop.""" + if self._applying_column_layout: + return + self._column_layout_dirty = True + self._column_layout_save_timer.start(COLUMN_LAYOUT_SAVE_DELAY_MS) + + def _current_column_header_signature( + self, + ) -> tuple[tuple[str, ...], tuple[tuple[str, int], ...]]: + """Return the user-visible column order and widths for change detection.""" + header = self.table.horizontalHeader() + if header is None: + return (), () + + visible_order: list[str] = [] + widths: dict[str, int] = {} + for visual_index in range(self.table.columnCount()): + logical_index = header.logicalIndex(visual_index) + key = self._col_key_for_logical(logical_index) + if key is None: + continue + visible_order.append(key) + widths[key] = header.sectionSize(logical_index) + + return tuple(visible_order), tuple(sorted(widths.items())) + + def _begin_header_interaction(self) -> None: + """Snapshot header state before a real mouse-driven reorder/resize.""" + if self._applying_column_layout: + return + self._header_interaction_signature = self._current_column_header_signature() + + def _finish_header_interaction(self) -> None: + """Queue persistence after a real header mouse interaction settles.""" + if self._applying_column_layout: + self._header_interaction_signature = None + return + + previous = self._header_interaction_signature + self._header_interaction_signature = None + if previous is None or previous != self._current_column_header_signature(): + self._queue_column_layout_save() + + def flush_pending_column_changes(self, *, force: bool = False) -> None: + """Immediately save any pending column layout changes. + + Call this before the view is destroyed or hidden to ensure + all column changes are persisted to settings. + """ + if self._applying_column_layout: + return + + should_persist = force or self._column_layout_dirty + + # Stop the debounce timer and process any pending resize changes + if self._width_resize_debounce_timer.isActive(): + self._width_resize_debounce_timer.stop() + self._save_user_widths() + should_persist = True + # Force the layout save timer to fire immediately + if self._column_layout_save_timer.isActive(): + self._column_layout_save_timer.stop() + should_persist = True + if not should_persist: + return + self._persist_column_layout_settings() + self._column_layout_dirty = False + + def _persist_column_layout_settings(self) -> None: + """Write per-content column layouts into the global settings payload.""" + self._store_current_column_layout(self._active_column_content_key) + settings = self._settings_service.get_global_settings() + settings.track_list_columns_by_content = { + content_key: { + key: int(width) + for key, width in layout.items() + if isinstance(width, int) and width > 0 + } + for content_key, layout in self._column_layouts.items() + if layout + } + self._settings_service.save_global_settings(settings) + + def _on_header_section_moved( + self, + _logical_index: int, + _old_visual: int, + _new_visual: int, + ) -> None: + """Persist user-driven header reordering.""" + if self._applying_column_layout: + return + # Stop any pending width resize debounce and process it first + if self._width_resize_debounce_timer.isActive(): + self._width_resize_debounce_timer.stop() + self._save_user_widths() + self._queue_column_layout_save() + + def _on_header_section_resized( + self, + _logical_index: int, + old_size: int, + new_size: int, + ) -> None: + """Debounce user-driven header width changes (prevents spam during drag).""" + if self._applying_column_layout or old_size == new_size: + return + # Restart debounce timer to batch resize events while dragging + self._width_resize_debounce_timer.start(50) # 50ms to catch rapid resizes + + def _on_width_resize_debounce_timeout(self) -> None: + """After resize drag finishes, save widths and queue settings update.""" + self._save_user_widths() + self._queue_column_layout_save() + def _setup_columns(self) -> None: """Determine which columns to display based on available data.""" + self._ensure_column_layout_for_current_content() + # Choose appropriate defaults based on media type filter mf = getattr(self, "_media_type_filter", None) is_video = mf is not None and (mf & 0x62) and not (mf & 0x01) @@ -1090,8 +1349,10 @@ def _setup_columns(self) -> None: else: defaults = DEFAULT_COLUMNS + using_saved_layout = self._user_col_order is not None + if not self._tracks: - self._columns = [c for c in defaults if c not in self._hidden_columns] + self._columns = list(self._user_col_order or defaults) return # Sample tracks to find available keys @@ -1099,19 +1360,20 @@ def _setup_columns(self) -> None: for track in self._tracks[:100]: available_keys.update(track.keys()) - # If user has a saved column order, respect it (filtering out unavailable) - if self._user_col_order is not None: - base = [k for k in self._user_col_order - if k in available_keys and k not in self._hidden_columns] + # If the user has a saved compact layout, its keys are the visible columns. + if using_saved_layout: + base = [ + k for k in self._user_col_order or [] + if k in available_keys or (self._is_playlist_mode and k == "_pl_pos") + ] else: # Show only the media-type defaults (user can add more via header menu) - base = [k for k in defaults - if k in available_keys and k not in self._hidden_columns] + base = [k for k in defaults if k in available_keys] self._columns = base # Prepend playlist position column when in playlist mode - if self._is_playlist_mode and "_pl_pos" not in self._columns and "_pl_pos" not in self._hidden_columns: + if not using_saved_layout and self._is_playlist_mode and "_pl_pos" not in self._columns: self._columns.insert(0, "_pl_pos") # ------------------------------------------------------------------------- @@ -1174,7 +1436,7 @@ def _populate_table(self) -> None: h_item.setData(Qt.ItemDataRole.UserRole, key) if self._show_art: - self.table.setColumnWidth(0, ART_THUMB_SIZE + 8) + self.table.setColumnWidth(0, _art_column_width()) self.table.setIconSize(QSize(ART_THUMB_SIZE, ART_THUMB_SIZE)) # Always use incremental population to keep UI responsive @@ -1258,6 +1520,7 @@ def _populate_row(self, row: int, track: dict, columns: list[str]) -> None: artwork_id = self._track_artwork_id(track) if artwork_id is not None: + art_item.setData(Qt.ItemDataRole.UserRole + 2, artwork_id) self._link_to_rows.setdefault(artwork_id, []).append(row) pixmap = self._thumbnail_for_artwork_id(artwork_id) if pixmap is not None: @@ -1284,7 +1547,7 @@ def _populate_row(self, row: int, track: dict, columns: list[str]) -> None: item.setData(Qt.ItemDataRole.UserRole, numeric) if key == "rating" and display: - item.setForeground(QColor(Colors.STAR)) + item.setForeground(_named_qcolor(Colors.STAR)) if key == "explicit_flag": self._apply_explicit_cell_visuals(item, raw_value) if key in NUMERIC_COLUMNS: @@ -1315,52 +1578,65 @@ def _finish_population(self) -> None: if header and self._columns: start_col = 1 if self._show_art else 0 total_cols = self.table.columnCount() - - # Art column: fixed width - if self._show_art and total_cols > 0: - header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) - self.table.setColumnWidth(0, ART_THUMB_SIZE + 8) - - # Data columns: interactive (user-resizable) - for i in range(start_col, total_cols): - header.setSectionResizeMode(i, QHeaderView.ResizeMode.Interactive) - - # Re-apply header interaction properties (defensive — survives - # column-count changes and setSortingEnabled toggling) - header.setSectionsMovable(True) - - # Apply saved column widths, or auto-size columns that have none - for i in range(start_col, total_cols): - col_key = self._col_key_at(i) - if col_key and col_key in self._user_col_widths: - self.table.setColumnWidth(i, self._user_col_widths[col_key]) + self._applying_column_layout = True + try: + # Art column: fixed width + if self._show_art and total_cols > 0: + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.table.setColumnWidth(0, _art_column_width()) + + # Data columns: interactive (user-resizable) + for i in range(start_col, total_cols): + header.setSectionResizeMode( + i, QHeaderView.ResizeMode.Interactive + ) + + # Re-apply header interaction properties (defensive — survives + # column-count changes and setSortingEnabled toggling) + header.setSectionsMovable(True) + + # Apply saved column widths, or auto-size columns that have none + for i in range(start_col, total_cols): + col_key = self._col_key_for_logical(i) + if col_key and col_key in self._user_col_widths: + self.table.setColumnWidth(i, self._user_col_widths[col_key]) + else: + self.table.resizeColumnToContents(i) + + # Restore saved visual column order (from user drag-reorder) + if self._user_col_order: + # Build a map from column key → current logical index + key_to_logical: dict[str, int] = {} + for li in range(start_col, total_cols): + k = self._col_key_for_logical(li) + if k: + key_to_logical[k] = li + # Move sections to match the saved visual order + for target_vis, key in enumerate(self._user_col_order): + logical = key_to_logical.get(key) + if logical is None: + continue + current_vis = header.visualIndex(logical) + if current_vis != target_vis + start_col: + header.moveSection(current_vis, target_vis + start_col) else: - self.table.resizeColumnToContents(i) - - # Restore saved visual column order (from user drag-reorder) - if self._user_col_order: - # Build a map from column key → current logical index - key_to_logical: dict[str, int] = {} - for li in range(start_col, total_cols): - k = self._col_key_at(li) - if k: - key_to_logical[k] = li - # Move sections to match the saved visual order - for target_vis, key in enumerate(self._user_col_order): - logical = key_to_logical.get(key) - if logical is None: - continue - current_vis = header.visualIndex(logical) - if current_vis != target_vis + start_col: - header.moveSection(current_vis, target_vis + start_col) - - # Stretch the last column - header.setStretchLastSection(True) - - # Re-install event filter (defensive — survives population) - vp = header.viewport() - if vp: - vp.installEventFilter(self) + if self._show_art and total_cols > 0 and header.visualIndex(0) != 0: + header.moveSection(header.visualIndex(0), 0) + for logical in range(start_col, total_cols): + current_vis = header.visualIndex(logical) + if current_vis != logical: + header.moveSection(current_vis, logical) + + # Stretch the last column + header.setStretchLastSection(True) + + # Re-install event filter (defensive — survives population) + header.installEventFilter(self) + vp = header.viewport() + if vp: + vp.installEventFilter(self) + finally: + self._applying_column_layout = False # Kick off async artwork loading if self._show_art: @@ -1452,6 +1728,10 @@ def _load_art_batch( break pil_img = get_artwork(int(artwork_id), mode="image_only") if pil_img is not None: + pil_img = enhance_artwork_image( + pil_img, + enabled=self._sharpen_artwork_enabled(), + ) pil_img = pil_img.convert("RGBA") results[artwork_id] = ( pil_img.width, @@ -1489,6 +1769,7 @@ def _on_art_loaded(self, results: dict | None, load_id: int) -> None: transform_mode=Qt.TransformationMode.SmoothTransformation, ) self._art_cache[artwork_id] = pixmap + self._invalidate_art_display_cache(artwork_id) new_artwork_ids.add(artwork_id) if not new_artwork_ids: @@ -1500,7 +1781,9 @@ def _on_art_loaded(self, results: dict | None, load_id: int) -> None: if artwork_id not in self._link_to_rows: continue rows = self._link_to_rows[artwork_id] - pixmap = self._art_cache[artwork_id] + pixmap = self._display_thumbnail_for_artwork_id(artwork_id) + if pixmap is None: + continue icon = QIcon(pixmap) for row in rows: item = self.table.item(row, 0) @@ -1547,7 +1830,7 @@ def _track_artwork_id(track: dict[str, Any]) -> int | None: def _thumbnail_for_artwork_id(self, artwork_id: int) -> QPixmap | None: """Return a cached/scaled thumbnail for *artwork_id* when available.""" - pixmap = self._art_cache.get(artwork_id) + pixmap = self._display_thumbnail_for_artwork_id(artwork_id) if pixmap is not None: return pixmap @@ -1561,6 +1844,10 @@ def _thumbnail_for_artwork_id(self, artwork_id: int) -> QPixmap | None: return None pil_img, _dominant_color, _album_colors = cached + pil_img = enhance_artwork_image( + pil_img, + enabled=self._sharpen_artwork_enabled(), + ) qimg = QImage( pil_img.convert("RGBA").tobytes("raw", "RGBA"), pil_img.width, @@ -1576,8 +1863,83 @@ def _thumbnail_for_artwork_id(self, artwork_id: int) -> QPixmap | None: transform_mode=Qt.TransformationMode.SmoothTransformation, ) self._art_cache[artwork_id] = pixmap + self._invalidate_art_display_cache(artwork_id) + return self._display_thumbnail_for_artwork_id(artwork_id) + + def _display_thumbnail_for_artwork_id(self, artwork_id: int) -> QPixmap | None: + """Return the UI-rendered thumbnail for *artwork_id*.""" + raw_pixmap = self._art_cache.get(artwork_id) + if raw_pixmap is None: + return None + + rounded = self._rounded_artwork_enabled() + cache_key = (artwork_id, rounded) + cached = self._art_display_cache.get(cache_key) + if cached is not None: + return cached + + pixmap = raw_pixmap + if rounded: + pixmap = rounded_artwork_pixmap( + raw_pixmap, + nested_artwork_radius(Metrics.BORDER_RADIUS_SM, 4), + ) + self._art_display_cache[cache_key] = pixmap return pixmap + def _rounded_artwork_enabled(self) -> bool: + try: + return bool(self._settings_service.get_effective_settings().rounded_artwork) + except Exception: + return False + + def _sharpen_artwork_enabled(self) -> bool: + try: + return bool(self._settings_service.get_effective_settings().sharpen_artwork) + except Exception: + return True + + def _invalidate_art_display_cache(self, artwork_id: int) -> None: + for key in list(self._art_display_cache): + if key[0] == artwork_id: + self._art_display_cache.pop(key, None) + + def refresh_artwork_appearance(self) -> None: + """Refresh the track list's artwork column for current appearance settings.""" + desired_show_art = ( + self._show_art_override + if self._show_art_override is not None + else bool(self._settings_service.get_effective_settings().show_art_in_tracklist) + ) + self._art_display_cache.clear() + + if desired_show_art != self._show_art: + self._populate_table() + return + + if not self._show_art: + return + + for row in range(self.table.rowCount()): + item = self.table.item(row, 0) + if item is None: + continue + artwork_id = item.data(Qt.ItemDataRole.UserRole + 2) + if not artwork_id: + continue + try: + art_id = int(artwork_id) + except (TypeError, ValueError): + continue + pixmap = self._display_thumbnail_for_artwork_id(art_id) + if pixmap is None: + continue + item.setIcon(QIcon(pixmap)) + + viewport = self.table.viewport() + if viewport is not None: + viewport.update() + def _advisory_badge_icon(self, flag: int, size: int = 14) -> QIcon | None: """Create a compact badge icon for explicit/clean advisory values.""" if flag not in (1, 2): @@ -1601,12 +1963,12 @@ def _advisory_badge_icon(self, flag: int, size: int = 14) -> QIcon | None: return svg_icon if flag == 1: - bg = QColor(Colors.DANGER) - border = QColor(Colors.DANGER_BORDER) + bg = _named_qcolor(Colors.DANGER) + border = _named_qcolor(Colors.DANGER_BORDER) glyph = "E" else: - bg = QColor(Colors.SUCCESS) - border = QColor(Colors.SUCCESS_BORDER) + bg = _named_qcolor(Colors.SUCCESS) + border = _named_qcolor(Colors.SUCCESS_BORDER) glyph = "C" px = QPixmap(size, size) @@ -1622,7 +1984,7 @@ def _advisory_badge_icon(self, flag: int, size: int = 14) -> QIcon | None: font = QFont(FONT_FAMILY, max(7, size - 6), QFont.Weight.Bold) painter.setFont(font) - painter.setPen(QColor(Colors.TEXT_ON_ACCENT)) + painter.setPen(_named_qcolor(Colors.TEXT_ON_ACCENT)) painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, glyph) painter.end() @@ -1640,7 +2002,7 @@ def _apply_explicit_cell_visuals(self, cell: QTableWidgetItem, raw_value) -> Non icon = self._advisory_badge_icon(1) if icon is not None: cell.setIcon(icon) - cell.setForeground(QColor(Colors.DANGER)) + cell.setForeground(_named_qcolor(Colors.DANGER)) cell.setToolTip("Content Advisory: Explicit") return @@ -1648,11 +2010,11 @@ def _apply_explicit_cell_visuals(self, cell: QTableWidgetItem, raw_value) -> Non icon = self._advisory_badge_icon(2) if icon is not None: cell.setIcon(icon) - cell.setForeground(QColor(Colors.SUCCESS)) + cell.setForeground(_named_qcolor(Colors.SUCCESS)) cell.setToolTip("Content Advisory: Clean") return - cell.setForeground(QColor(Colors.TEXT_TERTIARY)) + cell.setForeground(_named_qcolor(Colors.TEXT_TERTIARY)) def _update_status(self) -> None: """Update the status label with track count info.""" @@ -1702,14 +2064,30 @@ def _format_value(key: str, value) -> str: return str(value) - def _col_key_at(self, visual_col: int) -> str | None: - """Return the column key for a given visual column index.""" + def _col_key_for_logical(self, logical_col: int) -> str | None: + """Return the column key for a logical header section index.""" + header_item = self.table.horizontalHeaderItem(logical_col) + if header_item is not None: + key = header_item.data(Qt.ItemDataRole.UserRole) + if isinstance(key, str): + return key + offset = 1 if self._show_art else 0 - logical = visual_col - offset - if 0 <= logical < len(self._columns): - return self._columns[logical] + column_index = logical_col - offset + if 0 <= column_index < len(self._columns): + return self._columns[column_index] return None + def _col_key_at(self, visual_col: int) -> str | None: + """Return the column key for a visual column index.""" + header = self.table.horizontalHeader() + if header is None: + return None + logical_col = header.logicalIndex(visual_col) + if logical_col < 0: + return None + return self._col_key_for_logical(logical_col) + # ------------------------------------------------------------------------- # Event Filter — catch right-click on header viewport # ------------------------------------------------------------------------- @@ -1737,12 +2115,30 @@ def eventFilter(self, obj, event): # type: ignore[override] self._move_selected_rows(1) return True - # ── Header viewport: right-click context menu ── - if header and obj is header.viewport(): - if event.type() == QEvent.Type.MouseButtonPress: - if event.button() == Qt.MouseButton.RightButton: - self._on_header_context_menu(event.pos()) + # ── Header: context menu and human drag/resize persistence ── + header_vp = header.viewport() if header else None + if header and (obj is header or obj is header_vp): + etype = event.type() + if etype == QEvent.Type.MouseButtonPress: + me: QMouseEvent = event # type: ignore[assignment] + if me.button() == Qt.MouseButton.RightButton: + self._on_header_context_menu(me.pos()) return True + if me.button() == Qt.MouseButton.LeftButton: + self._begin_header_interaction() + elif etype == QEvent.Type.MouseMove: + me = event # type: ignore[assignment] + if ( + me.buttons() & Qt.MouseButton.LeftButton + and self._header_interaction_signature is None + ): + self._begin_header_interaction() + elif etype == QEvent.Type.MouseButtonRelease: + me = event # type: ignore[assignment] + if me.button() == Qt.MouseButton.LeftButton: + self._finish_header_interaction() + elif etype in {QEvent.Type.FocusOut, QEvent.Type.Hide}: + self._finish_header_interaction() # ── Table viewport: scroll & grab ── table_vp = self.table.viewport() @@ -1838,6 +2234,16 @@ def eventFilter(self, obj, event): # type: ignore[override] return super().eventFilter(obj, event) + def hideEvent(self, event) -> None: # type: ignore[override] + """Flush pending column changes when view is hidden.""" + self.flush_pending_column_changes() + super().hideEvent(event) + + def closeEvent(self, event) -> None: # type: ignore[override] + """Flush pending column changes when view is closed.""" + self.flush_pending_column_changes() + super().closeEvent(event) + # ------------------------------------------------------------------------- # Header Context Menu — hide / show / reorder columns # ------------------------------------------------------------------------- @@ -1848,8 +2254,8 @@ def _on_header_context_menu(self, pos) -> None: if not header: return - clicked_visual = header.logicalIndexAt(pos) - clicked_key = self._col_key_at(clicked_visual) + clicked_logical = header.logicalIndexAt(pos) + clicked_key = self._col_key_for_logical(clicked_logical) menu = QMenu(self) menu.setStyleSheet(f""" @@ -1996,26 +2402,28 @@ def _hide_column(self, key: str) -> None: if len(self._columns) <= 1: return self._save_user_widths() - self._hidden_columns.add(key) if key in self._columns: self._columns.remove(key) + self._user_col_order = list(self._columns) + self._queue_column_layout_save() self._repopulate_keeping_state() def _show_column(self, key: str) -> None: - """Show a previously hidden column.""" + """Show a column by adding it to the explicit layout.""" self._save_user_widths() - self._hidden_columns.discard(key) # Insert at end (user can drag to reorder) if key not in self._columns: self._columns.append(key) + self._user_col_order = list(self._columns) + self._queue_column_layout_save() self._repopulate_keeping_state() def _reset_columns(self) -> None: """Reset to default column set and widths.""" - self._hidden_columns.clear() self._user_col_widths.clear() self._user_col_order = None self._setup_columns() + self._queue_column_layout_save() self._populate_table() def _save_user_widths(self) -> None: @@ -2028,7 +2436,7 @@ def _save_user_widths(self) -> None: # Save widths for i in range(offset, col_count): - key = self._col_key_at(i) + key = self._col_key_for_logical(i) if key: self._user_col_widths[key] = header.sectionSize(i) @@ -2036,7 +2444,7 @@ def _save_user_widths(self) -> None: visual_keys: list[str] = [] for vis in range(offset, col_count): logical = header.logicalIndex(vis) - key = self._col_key_at(logical) + key = self._col_key_for_logical(logical) if key: visual_keys.append(key) if visual_keys: diff --git a/GUI/widgets/browserChrome.py b/GUI/widgets/browserChrome.py index 064fdd5..a942f9b 100644 --- a/GUI/widgets/browserChrome.py +++ b/GUI/widgets/browserChrome.py @@ -4,7 +4,7 @@ from PyQt6.QtGui import QFont from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget -from ..styles import Colors, FONT_FAMILY, Metrics, btn_css +from ..styles import FONT_FAMILY, Colors, Metrics, btn_css def chrome_action_btn_css() -> str: @@ -101,7 +101,8 @@ def addWidget(self, widget, stretch: int = 0): def style_browser_splitter(splitter: QSplitter) -> None: - splitter.setHandleWidth(1) + if splitter.handleWidth() != 0: + splitter.setHandleWidth(1) splitter.setStyleSheet(f""" QSplitter::handle {{ background: {Colors.BORDER_SUBTLE}; diff --git a/GUI/widgets/musicBrowser.py b/GUI/widgets/musicBrowser.py index d92f7b9..2f63d4f 100644 --- a/GUI/widgets/musicBrowser.py +++ b/GUI/widgets/musicBrowser.py @@ -62,6 +62,7 @@ def __init__( self.browserGrid = MusicBrowserGrid( device_sessions=self._device_sessions, library_cache=self._library_cache, + settings_service=self._settings_service, ) self.browserGrid.item_selected.connect(self._onGridItemSelected) @@ -119,7 +120,7 @@ def __init__( handle.setEnabled(True) self.gridTrackSplitter.setCollapsible(0, True) self.gridTrackSplitter.setCollapsible(1, True) - self.gridTrackSplitter.setHandleWidth(3) + self.gridTrackSplitter.setHandleWidth(0) self.gridTrackSplitter.setStretchFactor(0, 2) self.gridTrackSplitter.setStretchFactor(1, 1) self.gridTrackSplitter.setMinimumSize(0, 0) @@ -393,6 +394,12 @@ def _onGridItemSelected(self, item_data: dict): elif category == "Genres": self.browserTrack.filterByGenre(title) + def refresh_artwork_appearance(self) -> None: + """Refresh list and grid artwork after an appearance setting changes.""" + self.browserGrid.refresh_artwork_appearance() + self.browserTrack.refresh_artwork_appearance() + self.playlistBrowser.trackList.refresh_artwork_appearance() + def _ensure_podcast_device(self): """Bind the podcast browser to the current iPod device if not done.""" session = self._device_sessions.current_session() diff --git a/GUI/widgets/photoViewer.py b/GUI/widgets/photoViewer.py index 9a6d524..17ce586 100644 --- a/GUI/widgets/photoViewer.py +++ b/GUI/widgets/photoViewer.py @@ -8,6 +8,7 @@ QHeaderView, QLabel, QPushButton, + QSizePolicy, QSplitter, QTreeWidget, QTreeWidgetItem, @@ -42,6 +43,8 @@ def __init__( self._empty_summary = empty_summary self._source_pixmap = QPixmap() self._preview_placeholder_text = "Select a photo" + self.setMinimumWidth(0) + self.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Expanding) self.setStyleSheet(f""" QFrame#photoViewer {{ @@ -121,7 +124,9 @@ def __init__( preview_layout.setSpacing(0) self.preview_label = QLabel(preview_host) - self.preview_label.setMinimumSize(280, 280) + self.preview_label.setMinimumWidth(0) + self.preview_label.setMinimumHeight(280) + self.preview_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.preview_label.setStyleSheet(f""" QLabel {{ diff --git a/GUI/widgets/playlistBrowser.py b/GUI/widgets/playlistBrowser.py index 40998a1..0ddc057 100644 --- a/GUI/widgets/playlistBrowser.py +++ b/GUI/widgets/playlistBrowser.py @@ -883,7 +883,7 @@ def __init__( # Splitter styling self.rightSplitter.setCollapsible(0, True) self.rightSplitter.setCollapsible(1, True) - self.rightSplitter.setHandleWidth(3) + self.rightSplitter.setHandleWidth(0) self.rightSplitter.setStretchFactor(0, 1) self.rightSplitter.setStretchFactor(1, 3) self.rightSplitter.setSizes([250, 600]) diff --git a/GUI/widgets/podcastBrowser.py b/GUI/widgets/podcastBrowser.py index c8721ce..393dad7 100644 --- a/GUI/widgets/podcastBrowser.py +++ b/GUI/widgets/podcastBrowser.py @@ -228,6 +228,7 @@ def create(owner: PodcastBrowser): device_sessions=owner._device_sessions, library_cache=owner._library_cache, show_art_override=False, + content_type_override="podcast_episodes", ) # Override the music-library defaults with podcast-appropriate columns diff --git a/GUI/widgets/pooledCardGrid.py b/GUI/widgets/pooledCardGrid.py index 7d56e84..a644d31 100644 --- a/GUI/widgets/pooledCardGrid.py +++ b/GUI/widgets/pooledCardGrid.py @@ -281,7 +281,11 @@ def _refresh_viewport(self) -> None: row = index // columns col = index % columns - x = margin + col * (Metrics.GRID_ITEM_W + Metrics.GRID_SPACING) + x = self._row_x_layout( + width=width, + column_count=columns, + column_index=col, + ) y = margin + row * (Metrics.GRID_ITEM_H + Metrics.GRID_SPACING) widget.setGeometry(QRect(x, y, Metrics.GRID_ITEM_W, Metrics.GRID_ITEM_H)) self._apply_widget_selection(widget, index == self._current_index) @@ -310,6 +314,30 @@ def _compute_columns(width: int) -> int: cell = Metrics.GRID_ITEM_W + Metrics.GRID_SPACING return max(1, (usable + Metrics.GRID_SPACING) // cell) + @staticmethod + def _row_x_layout( + *, + width: int, + column_count: int, + column_index: int, + ) -> int: + base_margin = Metrics.GRID_SPACING + base_gap = Metrics.GRID_SPACING + + if column_count <= 0: + return base_margin + + inner_width = max(0, width - (base_margin * 2)) + min_content_width = ( + column_count * Metrics.GRID_ITEM_W + + max(0, column_count - 1) * base_gap + ) + extra_width = max(0, inner_width - min_content_width) + + edge_padding = base_margin + (extra_width / (column_count * 2)) + gap = base_gap + (extra_width / column_count) if column_count > 1 else 0.0 + return int(round(edge_padding + column_index * (Metrics.GRID_ITEM_W + gap))) + @staticmethod def _compute_visible_range( *, diff --git a/GUI/widgets/selectiveSyncBrowser.py b/GUI/widgets/selectiveSyncBrowser.py index 5635210..01d223d 100644 --- a/GUI/widgets/selectiveSyncBrowser.py +++ b/GUI/widgets/selectiveSyncBrowser.py @@ -144,8 +144,8 @@ class PCMusicBrowserGrid(MusicBrowserGrid): """Subclass of MusicBrowserGrid that loads artwork from embedded tags (or folder images) instead of the iPod ArtworkDB.""" - def __init__(self): - super().__init__() + def __init__(self, *, settings_service: SettingsService | None = None): + super().__init__(settings_service=settings_service) self._pc_art_map: dict[str, list[str]] = {} self._pc_mode = False @@ -312,6 +312,7 @@ def create(owner: PCTrackListView): settings_service=owner._settings_service, device_sessions=owner._device_sessions, show_art_override=False, + content_type_override="pc_tracks", ) # Disable iPod-specific features @@ -1319,7 +1320,7 @@ def _make_separator() -> QFrame: for cat in ("Albums", "Artists", "Genres", "Podcasts", "Audiobooks", "TV Shows", "Music Videos"): - grid = PCMusicBrowserGrid() + grid = PCMusicBrowserGrid(settings_service=self._settings_service) grid.item_selected.connect(self._on_grid_item_clicked) scroll = make_scroll_area() scroll.setVerticalScrollBarPolicy( @@ -1399,6 +1400,11 @@ def _make_separator() -> QFrame: # ── Public API ─────────────────────────────────────────────────────── + def refresh_artwork_appearance(self) -> None: + """Refresh visible grid artwork for the current global appearance.""" + for grid in self._grids.values(): + grid.refresh_artwork_appearance() + def _cleanup_scan_worker(self): """Disconnect and clean up the current scan worker, if any.""" if self._scan_worker is None: diff --git a/GUI/widgets/settingsPage.py b/GUI/widgets/settingsPage.py index 709c18d..82fe4c6 100644 --- a/GUI/widgets/settingsPage.py +++ b/GUI/widgets/settingsPage.py @@ -880,6 +880,7 @@ class SettingsPage(QWidget): closed = pyqtSignal() # Emitted when user closes settings theme_changed = pyqtSignal() # Emitted when theme or contrast changes + artwork_appearance_changed = pyqtSignal() # Emitted when artwork UI styling changes def __init__( self, @@ -1143,6 +1144,19 @@ def _build_general_page(self) -> QScrollArea: "Show album art thumbnails next to tracks in the list view.", checked=True, ) + self.rounded_artwork = ToggleRow( + "Rounded Artwork", + "Round album art corners in grid cards and track lists. " + "This only changes how artwork is drawn in iOpenPod and does not " + "modify anything written to your iPod.", + checked=False, + ) + self.sharpen_artwork = ToggleRow( + "Sharpen Artwork", + "Apply a subtle display-only sharpening pass to album art in grid cards " + "and track lists. This does not modify artwork written to your iPod.", + checked=True, + ) from infrastructure.version import get_version self.version_row = ActionRow( @@ -1203,6 +1217,8 @@ def _build_general_page(self) -> QScrollArea: self.accent_color, self.font_scale, self.show_art, + self.rounded_artwork, + self.sharpen_artwork, ) self._about_card = _SettingsCard( self.version_row, @@ -1728,7 +1744,7 @@ def _apply_scope_visibility(self) -> None: self._manage_card.setVisible(device_scope) self._set_section_visible("General", "Manage", device_scope) - # Device card (storage type) — always visible, enabled only when device connected + # Device card — always visible but only interactive when device connected has_device = self._current_device_context() is not None self._device_card.setVisible(True) self._set_section_visible("General", "Device", True) @@ -1737,6 +1753,8 @@ def _apply_scope_visibility(self) -> None: self._appearance_card.set_row_visible(self.theme_combo, not device_scope) self._appearance_card.set_row_visible(self.high_contrast, not device_scope) self._appearance_card.set_row_visible(self.font_scale, not device_scope) + self._appearance_card.set_row_visible(self.rounded_artwork, not device_scope) + self._appearance_card.set_row_visible(self.sharpen_artwork, not device_scope) self._about_card.setVisible(not device_scope) self._set_section_visible("General", "About", not device_scope) @@ -1812,6 +1830,8 @@ def load_from_settings(self): self.listenbrainz_token_row.set_disconnected() self.show_art.value = s.show_art_in_tracklist + self.rounded_artwork.value = s.rounded_artwork + self.sharpen_artwork.value = s.sharpen_artwork # Theme theme_display = { @@ -1964,7 +1984,7 @@ def load_from_settings(self): if idx >= 0: self.device_write_workers.combo.setCurrentIndex(idx) - # Storage type — keyed per device in device_tags.json, not AppSettings + # Storage type — per-device tag, not in AppSettings try: from infrastructure.device_tags import get_ipod_hdd_tag session = self._device_sessions.current_session() @@ -1973,7 +1993,9 @@ def load_from_settings(self): idx = self.storage_type_row.combo.findText(st_text) if idx >= 0: self.storage_type_row.combo.setCurrentIndex(idx) - self.storage_type_row.combo.setEnabled(session.device_path is not None and session.device_path != "") + self.storage_type_row.combo.setEnabled( + bool(session.device_path) + ) except Exception: self.storage_type_row.combo.setEnabled(False) @@ -2012,6 +2034,8 @@ def load_from_settings(self): self.sync_workers.changed.connect(self._save) self.device_write_workers.changed.connect(self._save) self.show_art.changed.connect(self._save) + self.rounded_artwork.changed.connect(self._save) + self.sharpen_artwork.changed.connect(self._save) self.accent_color.changed.connect(self._save) self.theme_combo.changed.connect(self._save) self.high_contrast.changed.connect(self._save) @@ -2228,6 +2252,8 @@ def _read_controls_into_settings(self, s, include_global_only: bool) -> None: s.show_art_in_tracklist = self.show_art.value if include_global_only: + s.rounded_artwork = self.rounded_artwork.value + s.sharpen_artwork = self.sharpen_artwork.value # Theme theme_keys = { "Dark": "dark", "Light": "light", "System": "system", @@ -2373,6 +2399,11 @@ def _save(self, *_args): return effective_before = self._settings_service.get_effective_settings() + artwork_before = ( + effective_before.show_art_in_tracklist, + effective_before.rounded_artwork, + effective_before.sharpen_artwork, + ) theme_before = ( effective_before.theme, effective_before.high_contrast, @@ -2392,8 +2423,15 @@ def _save(self, *_args): use_global_settings=self.use_global_settings.value, device_key=key, ) + effective_after = self._settings_service.get_effective_settings() self._apply_scope_visibility() self._apply_theme_change_if_needed(theme_before) + if artwork_before != ( + effective_after.show_art_in_tracklist, + effective_after.rounded_artwork, + effective_after.sharpen_artwork, + ): + self.artwork_appearance_changed.emit() return s = self._settings_service.get_global_settings() @@ -2404,6 +2442,7 @@ def _save(self, *_args): and (s.max_cache_size_gb == 0 or s.max_cache_size_gb < old_cache_limit) ) self._settings_service.save_global_settings(s) + effective_after = self._settings_service.get_effective_settings() # If limit was lowered, evict immediately so cache stays within bounds. if limit_lowered: @@ -2419,6 +2458,12 @@ def _save(self, *_args): pass self._apply_theme_change_if_needed(theme_before) + if artwork_before != ( + effective_after.show_art_in_tracklist, + effective_after.rounded_artwork, + effective_after.sharpen_artwork, + ): + self.artwork_appearance_changed.emit() @staticmethod def _current_ipod_image() -> str: diff --git a/GUI/widgets/syncReview.py b/GUI/widgets/syncReview.py index 6f514d1..719bb90 100644 --- a/GUI/widgets/syncReview.py +++ b/GUI/widgets/syncReview.py @@ -10,16 +10,29 @@ from __future__ import annotations +import html +import logging +import os +import shutil import time +from typing import TYPE_CHECKING, Any -from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QRectF +from PyQt6.QtCore import QRectF, Qt, QTimer, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QPainter from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, - QProgressBar, QFrame, QStackedWidget, QMessageBox, - QFileDialog, QDialog, QCheckBox, + QCheckBox, + QDialog, + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressBar, + QPushButton, + QStackedWidget, + QVBoxLayout, + QWidget, ) -from PyQt6.QtGui import QFont, QColor, QPainter -import shutil from app_core.progress import ETATracker from app_core.sync_review_model import ( @@ -36,14 +49,10 @@ sync_item_size_delta, ) -from .formatters import format_size as _format_size, format_duration_mmss as _format_duration from ..glyphs import glyph_pixmap -from ..styles import Colors, FONT_FAMILY, Metrics, btn_css, make_scroll_area - -import html -import os -import logging -from typing import TYPE_CHECKING, Any +from ..styles import FONT_FAMILY, Colors, Metrics, btn_css, make_scroll_area +from .formatters import format_duration_mmss as _format_duration +from .formatters import format_size as _format_size logger = logging.getLogger(__name__) @@ -104,7 +113,7 @@ class _StorageBarWidget(QWidget): def __init__(self, parent=None): super().__init__(parent) - self.setFixedHeight((10)) + self.setFixedHeight(10) self._total: int = 1 self._current_used: int = 0 self._sync_delta: int = 0 # positive = adding, negative = removing @@ -226,7 +235,7 @@ def __init__(self, item: Any, accent: str, checkable: bool = True, parent=None): row = QHBoxLayout(self) row.setContentsMargins((14), (8), (14), (8)) - row.setSpacing((10)) + row.setSpacing(10) # Checkbox self.cb = QCheckBox(self) @@ -259,7 +268,7 @@ def __init__(self, item: Any, accent: str, checkable: bool = True, parent=None): # Two-line text block text_col = QVBoxLayout() text_col.setContentsMargins(0, 0, 0, 0) - text_col.setSpacing((2)) + text_col.setSpacing(2) self.title_label = QLabel(self) self.title_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_LG)) @@ -483,11 +492,11 @@ def __init__(self, title: str, detail: str, accent: str, badge: str = "", parent """) row = QHBoxLayout(self) row.setContentsMargins((40), (4), (14), (4)) - row.setSpacing((10)) + row.setSpacing(10) text_col = QVBoxLayout() text_col.setContentsMargins(0, 0, 0, 0) - text_col.setSpacing((1)) + text_col.setSpacing(1) t = QLabel(title, self) t.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD)) @@ -557,7 +566,7 @@ def __init__( self._header_frame.setStyleSheet("background: transparent; border: none;") hdr = QHBoxLayout(self._header_frame) hdr.setContentsMargins((14), (10), (14), (10)) - hdr.setSpacing((10)) + hdr.setSpacing(10) # Select-all checkbox (only for checkable cards) self._select_all_cb = QCheckBox(self._header_frame) @@ -617,8 +626,8 @@ def __init__( count_lbl = QLabel(str(count), self._header_frame) count_lbl.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM, QFont.Weight.Bold)) count_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - count_lbl.setFixedHeight((20)) - count_lbl.setMinimumWidth((28)) + count_lbl.setFixedHeight(20) + count_lbl.setMinimumWidth(28) count_lbl.setStyleSheet(f""" background: {accent}; color: {Colors.BG_DARK}; @@ -893,26 +902,6 @@ def _setup_ui(self): self._backup_hint.setVisible(False) loading_layout.addWidget(self._backup_hint) - loading_layout.addSpacing(24) - - # Centered cancel button \u2014 shown only on the loading/progress page. - # The footer cancel is hidden while this page is active. - self._loading_cancel_btn = QPushButton("Cancel", loading_widget) - self._loading_cancel_btn.setFixedWidth(120) - self._loading_cancel_btn.setStyleSheet(btn_css( - bg=Colors.SURFACE_RAISED, - bg_hover=Colors.SURFACE_ACTIVE, - bg_press=Colors.SURFACE_ALT, - border=f"1px solid {Colors.BORDER}", - radius=Metrics.BORDER_RADIUS_SM, - padding="7px 20px", - )) - self._loading_cancel_btn.clicked.connect(self._on_cancel_clicked) - self._loading_cancel_btn.setVisible(False) - loading_layout.addWidget( - self._loading_cancel_btn, alignment=Qt.AlignmentFlag.AlignCenter - ) - loading_layout.addStretch(4) self.stack.addWidget(loading_widget) # Index 0 @@ -933,7 +922,7 @@ def _setup_ui(self): """) stats_lay = QHBoxLayout(self._stats_bar) stats_lay.setContentsMargins((16), (8), (16), (8)) - stats_lay.setSpacing((16)) + stats_lay.setSpacing(16) self._stats_layout = stats_lay self._stats_pills: list[QLabel] = [] stats_lay.addStretch() @@ -949,7 +938,7 @@ def _setup_ui(self): """) storage_outer = QHBoxLayout(self._storage_frame) storage_outer.setContentsMargins((16), (8), (16), (8)) - storage_outer.setSpacing((12)) + storage_outer.setSpacing(12) # iPod image self._storage_ipod_img = QLabel(self._storage_frame) @@ -959,11 +948,11 @@ def _setup_ui(self): # Right side: name + bar + detail text stacked vertically storage_right = QVBoxLayout() - storage_right.setSpacing((3)) + storage_right.setSpacing(3) # Top row: iPod name on left, detail text on right storage_top = QHBoxLayout() - storage_top.setSpacing((8)) + storage_top.setSpacing(8) self._storage_name = QLabel("iPod", self._storage_frame) self._storage_name.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM, QFont.Weight.DemiBold)) self._storage_name.setStyleSheet(f"color:{Colors.TEXT_PRIMARY}; background:transparent;") @@ -981,7 +970,7 @@ def _setup_ui(self): # Legend row beneath bar legend_row = QHBoxLayout() - legend_row.setSpacing((12)) + legend_row.setSpacing(12) self._legend_labels: list[QLabel] = [] for color_hex, text in [ (Colors.ACCENT, "Current"), @@ -1014,7 +1003,7 @@ def _setup_ui(self): self._cards_container.setStyleSheet("background: transparent;") self._cards_layout = QVBoxLayout(self._cards_container) self._cards_layout.setContentsMargins((16), (12), (16), (12)) - self._cards_layout.setSpacing((10)) + self._cards_layout.setSpacing(10) self._cards_layout.addStretch() # push cards to top self._scroll.setWidget(self._cards_container) @@ -1029,7 +1018,7 @@ def _setup_ui(self): empty_widget = QWidget(self.stack) empty_layout = QVBoxLayout(empty_widget) empty_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - empty_layout.setSpacing((8)) + empty_layout.setSpacing(8) empty_icon = QLabel("✓", empty_widget) empty_icon.setFont(QFont(FONT_FAMILY, Metrics.FONT_ICON_XL)) @@ -1054,7 +1043,7 @@ def _setup_ui(self): results_widget = QWidget(self.stack) results_layout = QVBoxLayout(results_widget) results_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - results_layout.setSpacing((12)) + results_layout.setSpacing(12) self.result_icon = QLabel("", results_widget) self.result_icon.setFont(QFont(FONT_FAMILY, Metrics.FONT_ICON_XL)) @@ -1070,7 +1059,7 @@ def _setup_ui(self): self.result_details.setStyleSheet(f"color: {Colors.TEXT_SECONDARY}; font-size: {Metrics.FONT_XXL}px;") self.result_details.setAlignment(Qt.AlignmentFlag.AlignCenter) self.result_details.setWordWrap(True) - self.result_details.setMaximumWidth((500)) + self.result_details.setMaximumWidth(500) results_layout.addWidget(self.result_details, alignment=Qt.AlignmentFlag.AlignCenter) self.stack.addWidget(results_widget) # Index 3 @@ -1083,10 +1072,10 @@ def _setup_ui(self): # Inner container — all content lives here, centered as one block presync_inner = QWidget(presync_widget) - presync_inner.setFixedWidth((460)) + presync_inner.setFixedWidth(460) presync_layout = QVBoxLayout(presync_inner) presync_layout.setContentsMargins(0, 0, 0, 0) - presync_layout.setSpacing((16)) + presync_layout.setSpacing(16) self._presync_icon = QLabel("", presync_inner) _px = glyph_pixmap("download", Metrics.FONT_ICON_XL, Colors.ACCENT) @@ -1111,10 +1100,10 @@ def _setup_ui(self): self._presync_text.setWordWrap(True) presync_layout.addWidget(self._presync_text) - presync_layout.addSpacing((8)) + presync_layout.addSpacing(8) presync_btn_row = QHBoxLayout() - presync_btn_row.setSpacing((12)) + presync_btn_row.setSpacing(12) presync_btn_row.addStretch() # "Skip Backup & Sync Now" / "Sync Without Backup" — secondary action @@ -1219,7 +1208,7 @@ def _setup_ui(self): self.selection_label.setStyleSheet(f"color: {Colors.TEXT_SECONDARY};") footer_layout.addWidget(self.selection_label) - footer_layout.addSpacing((20)) + footer_layout.addSpacing(20) # Cancel and Apply buttons self.cancel_btn = QPushButton("Cancel", footer) @@ -1256,7 +1245,6 @@ def _setup_ui(self): footer_layout.addWidget(self.cancel_btn) footer_layout.addWidget(self.apply_btn) - self._footer = footer layout.addWidget(footer) # Map internal stage names → user-friendly labels @@ -1299,12 +1287,6 @@ def _set_footer_for_state(self, state: str): States: 'loading', 'plan', 'empty', 'executing', 'results', 'presync' """ - # During loading/executing the page-embedded cancel is shown centered; - # the footer is hidden entirely so it doesn't compete with the layout. - loading_active = state in ("loading", "executing", "presync") - self._footer.setVisible(not loading_active) - self._loading_cancel_btn.setVisible(loading_active) - show_plan_btns = (state == "plan") self.select_all_btn.setVisible(show_plan_btns) self.select_none_btn.setVisible(show_plan_btns) @@ -1316,8 +1298,6 @@ def _set_footer_for_state(self, state: str): if state == "loading": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) - self._loading_cancel_btn.setText("Cancel") - self._loading_cancel_btn.setEnabled(True) elif state == "plan": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) @@ -1327,19 +1307,15 @@ def _set_footer_for_state(self, state: str): elif state == "executing": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) - self._loading_cancel_btn.setText("Cancel") - self._loading_cancel_btn.setEnabled(True) elif state == "presync": self.cancel_btn.setText("Cancel") self.cancel_btn.setEnabled(True) - self._loading_cancel_btn.setText("Cancel") - self._loading_cancel_btn.setEnabled(True) elif state == "results": self.cancel_btn.setText("Done") self.cancel_btn.setEnabled(True) def _format_elapsed(self) -> str: - """Return a human-readable elapsed-time string, or '' if under 2 seconds.""" + """Return human-readable elapsed time string, or '' if under 2 seconds.""" secs = int(time.monotonic() - self._loading_start_time) if secs < 2: return "" @@ -1400,8 +1376,7 @@ def update_progress(self, stage: str, current: int, total: int, message: str): self.eta_label.setText(" · ".join(parts)) else: self.progress_bar.setRange(0, 0) # Indeterminate - elapsed_text = self._format_elapsed() - self.eta_label.setText(elapsed_text) + self.eta_label.setText(self._format_elapsed()) def show_plan(self, plan: Any): """Display the sync plan as styled category cards.""" @@ -1536,7 +1511,7 @@ def _insert_card(card: SyncCategoryCard): for t in ir.missing_files: card.add_info_row(t.get("Title", "Unknown"), f"{t.get('Artist', 'Unknown')} · File missing from iPod") - for fp, db_track_id in ir.stale_mappings: + for _fp, db_track_id in ir.stale_mappings: card.add_info_row(f"Stale mapping (db_track_id={db_track_id})", "Removed from mapping") for orphan in ir.orphan_files[:20]: card.add_info_row(orphan.name, "Orphan file deleted") @@ -2050,7 +2025,7 @@ def update_execute_progress(self, prog): is_backup = (stage == "backup") is_scrobble_timeout = ( stage == "scrobble" - and "still trying to connect" in message.lower() + and "keep trying" in message.lower() ) self._scrobble_timeout_retrying = is_scrobble_timeout self._backup_hint.setVisible(is_backup and self._is_auto_presync) @@ -2058,7 +2033,7 @@ def update_execute_progress(self, prog): self.cancel_btn.setText("Skip Backup && Sync") self.cancel_btn.setEnabled(True) elif is_scrobble_timeout: - self.cancel_btn.setText("Give Up Scrobble") + self.cancel_btn.setText("Stop Retrying") self.cancel_btn.setEnabled(True) else: self.cancel_btn.setText("Cancel") @@ -2198,14 +2173,21 @@ def _set_result(glyph_name: str, fallback: str, color: str, title: str) -> None: if not lines: lines.append("No changes were made.") + def _format_scrobble_message(message: str) -> str: + text = message.strip() + if text.startswith("listenbrainz:"): + text = "ListenBrainz:" + text[len("listenbrainz:"):] + return text + # Partial save banner — explain what happened and reassure the user if partial_save: lines.append("") # Separate storage-full and cancelled into different messages storage_errors = [m for d, m in errors if d == "storage"] cancel_errors = [m for d, m in errors if d == "cancelled"] + scrobble_errors = [m for d, m in errors if d == "scrobble"] other_errors = [(d, m) for d, m in errors - if d not in ("storage", "cancelled")] + if d not in ("storage", "cancelled", "scrobble")] if storage_errors: lines.append( f"" @@ -2248,13 +2230,47 @@ def _set_result(glyph_name: str, fallback: str, color: str, title: str) -> None: f"" f" …and {len(other_errors) - 8} more" ) + if scrobble_errors: + lines.append("") + lines.append( + f"" + f"ListenBrainz needs attention." + ) + for msg in scrobble_errors[:3]: + lines.append( + f"" + f"{_format_scrobble_message(msg)}" + ) + if len(scrobble_errors) > 3: + lines.append( + f"" + f"…and {len(scrobble_errors) - 3} more ListenBrainz issue" + f"{'s' if len(scrobble_errors) - 3 != 1 else ''}." + ) elif errors: + scrobble_errors = [m for d, m in errors if d == "scrobble"] + other_errors = [(d, m) for d, m in errors if d != "scrobble"] lines.append("") lines.append(f"{len(errors)} error{'s' if len(errors) != 1 else ''}:") - for desc, msg in errors[:10]: # Show max 10 + if scrobble_errors: + lines.append( + f"ListenBrainz" + ) + for msg in scrobble_errors[:3]: + lines.append( + f"" + f"{_format_scrobble_message(msg)}" + ) + if len(scrobble_errors) > 3: + lines.append( + f"" + f"…and {len(scrobble_errors) - 3} more ListenBrainz issue" + f"{'s' if len(scrobble_errors) - 3 != 1 else ''}." + ) + for desc, msg in other_errors[:10]: # Show max 10 lines.append(f" {desc}: {msg}") - if len(errors) > 10: - lines.append(f" ...and {len(errors) - 10} more") + if len(other_errors) > 10: + lines.append(f" ...and {len(other_errors) - 10} more") # Safe-eject reminder if (success or partial_save) and (added or removed or updated_file or updated_meta): @@ -2420,22 +2436,16 @@ def _on_cancel_clicked(self): # Skip the in-progress backup and proceed to sync self.cancel_btn.setEnabled(False) self.cancel_btn.setText("Skipping backup…") - self._loading_cancel_btn.setEnabled(False) - self._loading_cancel_btn.setText("Skipping backup…") self.skip_backup_signal.emit() elif self._current_exec_stage == "scrobble" and self._scrobble_timeout_retrying: self.cancel_btn.setEnabled(False) - self.cancel_btn.setText("Giving up…") - self._loading_cancel_btn.setEnabled(False) - self._loading_cancel_btn.setText("Giving up…") + self.cancel_btn.setText("Stopping retries…") self.give_up_scrobble_signal.emit() else: # Full cancel self._cancelled = True self.cancel_btn.setEnabled(False) self.cancel_btn.setText("Cancelling...") - self._loading_cancel_btn.setEnabled(False) - self._loading_cancel_btn.setText("Cancelling...") self.cancelled.emit() else: # Plan view, empty view, or results view — just go back @@ -2640,7 +2650,7 @@ def _apply_sync(self): # Styled confirmation dialog (matches dark theme) confirm = QDialog(self) confirm.setWindowTitle("Confirm Sync") - confirm.setMinimumWidth((420)) + confirm.setMinimumWidth(420) confirm.setStyleSheet(f""" QDialog {{ background: {Colors.BG_DARK}; @@ -2653,7 +2663,7 @@ def _apply_sync(self): """) cl = QVBoxLayout(confirm) cl.setContentsMargins((20), (16), (20), (16)) - cl.setSpacing((12)) + cl.setSpacing(12) confirm_title = QLabel("Confirm Sync", confirm) confirm_title.setFont(QFont(FONT_FAMILY, Metrics.FONT_TITLE, QFont.Weight.Bold)) @@ -2665,7 +2675,7 @@ def _apply_sync(self): confirm_body.setStyleSheet(f"color:{Colors.TEXT_SECONDARY}; background:transparent;") cl.addWidget(confirm_body) - cl.addSpacing((8)) + cl.addSpacing(8) btn_row = QHBoxLayout() btn_row.addStretch() cancel_btn = QPushButton("Cancel", confirm) @@ -2723,14 +2733,13 @@ def _apply_sync(self): class PCFolderDialog(QDialog): """Dialog to select PC media folder for syncing.""" - def __init__(self, parent=None, last_folder: str = "", ipod_hdd: bool = False): + def __init__(self, parent=None, last_folder: str = ""): super().__init__(parent) self.setWindowTitle("Select Media Folder") - self.setMinimumWidth((440)) + self.setMinimumWidth(440) self.selected_folder = "" self.sync_mode = "" # "full" | "selective" | "back_sync" self.last_folder = last_folder - self.is_ipod_hdd = bool(ipod_hdd) # Dark theme stylesheet self.setStyleSheet(f""" @@ -2758,7 +2767,7 @@ def __init__(self, parent=None, last_folder: str = "", ipod_hdd: bool = False): def _setup_ui(self): layout = QVBoxLayout(self) - layout.setSpacing((12)) + layout.setSpacing(12) layout.setContentsMargins((20), (16), (20), (16)) # Title @@ -2814,7 +2823,7 @@ def _setup_ui(self): layout.addLayout(folder_layout) - layout.addSpacing((8)) + layout.addSpacing(8) # Buttons btn_row = QHBoxLayout() @@ -2824,23 +2833,14 @@ def _setup_ui(self): btn_row.addWidget(cancel_btn) selective_btn = QPushButton("Selective Sync", self) - selective_btn.setToolTip( - "Browse the chosen media folder and pick specific tracks/photos to sync." - ) selective_btn.clicked.connect(self._accept_selective) btn_row.addWidget(selective_btn) back_sync_btn = QPushButton("Back Sync", self) - back_sync_btn.setToolTip( - "Copy iPod-only tracks back to your chosen media folder" - ) back_sync_btn.clicked.connect(self._accept_back_sync) btn_row.addWidget(back_sync_btn) full_btn = QPushButton("Full Sync", self) - full_btn.setToolTip( - "Compare the entire chosen media folder with your iPod, then sync all changes" - ) full_btn.setStyleSheet(f""" QPushButton {{ background: {Colors.ACCENT}; diff --git a/GUI/widgets/trackListTitleBar.py b/GUI/widgets/trackListTitleBar.py index 2bf162a..3370ac0 100644 --- a/GUI/widgets/trackListTitleBar.py +++ b/GUI/widgets/trackListTitleBar.py @@ -1,20 +1,70 @@ -from PyQt6.QtCore import Qt, QPoint, QSize -from PyQt6.QtWidgets import QHBoxLayout, QFrame, QLabel, QPushButton, QWidget +from PyQt6.QtCore import QPoint, QSize, Qt from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, QWidget -from ..styles import Colors, FONT_FAMILY, Metrics from ..glyphs import glyph_icon - - -def _title_bar_css(r1: int, g1: int, b1: int, r2: int, g2: int, b2: int, - text_color: str = Colors.TEXT_ON_ACCENT, - text_secondary: str = Colors.TEXT_PRIMARY) -> str: - """Generate the title bar stylesheet for given gradient colors.""" +from ..styles import ( + FONT_FAMILY, + Colors, + Metrics, + display_accent_rgb, + text_rgb_for_background, +) + +_TITLE_BAR_CONTRAST_TARGET = 2.95 +_TITLE_BAR_CORNER_RADIUS = 8 + + +def _mix_rgb( + left: tuple[int, int, int], + right: tuple[int, int, int], + amount: float, +) -> tuple[int, int, int]: + amount = max(0.0, min(1.0, float(amount))) + left_red, left_green, left_blue = left + right_red, right_green, right_blue = right + return ( + int(round((left_red * (1.0 - amount)) + (right_red * amount))), + int(round((left_green * (1.0 - amount)) + (right_green * amount))), + int(round((left_blue * (1.0 - amount)) + (right_blue * amount))), + ) + + +def _css_rgb(rgb: tuple[int, int, int]) -> str: + return f"rgb({rgb[0]},{rgb[1]},{rgb[2]})" + + +def _css_rgba(rgb: tuple[int, int, int], alpha: int) -> str: + return f"rgba({rgb[0]},{rgb[1]},{rgb[2]},{alpha})" + + +def _title_bar_css( + *, + top_rgb: tuple[int, int, int], + bottom_rgb: tuple[int, int, int], + border_rgb: tuple[int, int, int], + text_rgb: tuple[int, int, int], + text_secondary_rgb: tuple[int, int, int], +) -> str: + """Generate a refined, contrast-limited title bar stylesheet.""" + text_color = _css_rgb(text_rgb) + text_secondary = _css_rgba(text_secondary_rgb, 205) + button_bg = _css_rgba(text_rgb, 18) + button_hover = _css_rgba(text_rgb, 30) + button_press = _css_rgba(text_rgb, 24) return f""" QFrame {{ - background: rgba({r1},{g1},{b1},180); + background: qlineargradient( + x1: 0, y1: 0, x2: 0, y2: 1, + stop: 0 {_css_rgba(top_rgb, 190)}, + stop: 1 {_css_rgba(bottom_rgb, 178)} + ); border: none; - border-radius: 0px; + border-bottom: 1px solid {_css_rgba(border_rgb, 130)}; + border-top-left-radius: {_TITLE_BAR_CORNER_RADIUS}px; + border-top-right-radius: {_TITLE_BAR_CORNER_RADIUS}px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; }} QLabel {{ font-weight: 700; @@ -23,7 +73,7 @@ def _title_bar_css(r1: int, g1: int, b1: int, r2: int, g2: int, b2: int, background: transparent; }} QPushButton {{ - background-color: transparent; + background-color: {button_bg}; border: none; color: {text_secondary}; font-size: {Metrics.FONT_TITLE}px; @@ -33,18 +83,39 @@ def _title_bar_css(r1: int, g1: int, b1: int, r2: int, g2: int, b2: int, border-radius: {(6)}px; }} QPushButton:hover {{ - background-color: rgba(255,255,255,30); + background-color: {button_hover}; }} QPushButton:pressed {{ - background-color: rgba(255,255,255,18); + background-color: {button_press}; }} """ -# Default blue gradient — call at runtime since values aren't ready at import time -def _default_css() -> str: - r, g, b = Colors.PLAYLIST_REGULAR - return _title_bar_css(r, g, b, max(0, r - 25), max(0, g - 25), max(0, b - 25)) +def _resolve_bar_palette( + base_rgb: tuple[int, int, int], + *, + text: tuple[int, int, int] | None = None, + text_secondary: tuple[int, int, int] | None = None, +) -> dict[str, tuple[int, int, int]]: + """Limit and shape a title-bar palette so it sits comfortably in the app.""" + bg = display_accent_rgb( + base_rgb, + background=Colors.BG_DARK, + target_ratio=_TITLE_BAR_CONTRAST_TARGET, + ) + top = _mix_rgb(bg, (255, 255, 255), 0.08) + bottom = _mix_rgb(bg, (0, 0, 0), 0.16) + primary_text = text or text_rgb_for_background(bg) + secondary_text = text_secondary or _mix_rgb(primary_text, bg, 0.3) + border = _mix_rgb(bg, (0, 0, 0), 0.28) + return { + "bg": bg, + "top": top, + "bottom": bottom, + "border": border, + "text": primary_text, + "text_secondary": secondary_text, + } class TrackListTitleBar(QFrame): @@ -61,33 +132,19 @@ def __init__(self, splitterToControl): self.titleBarLayout.setContentsMargins((14), 0, (10), 0) self.splitter.splitterMoved.connect(self.enforceMinHeight) - self.setMinimumHeight((40)) - self.setMaximumHeight((40)) - self.setFixedHeight((40)) - - self.setStyleSheet(_default_css()) + self.setMinimumHeight(40) + self.setMaximumHeight(40) + self.setFixedHeight(40) self.title = QLabel("Tracks") self.title.setFont(QFont(FONT_FAMILY, Metrics.FONT_TITLE, QFont.Weight.Bold)) self.button1 = QPushButton() - _ic_sz = QSize((18), (18)) - _ic_dn = glyph_icon("chevron-down", (18), Colors.TEXT_ON_ACCENT) - if _ic_dn: - self.button1.setIcon(_ic_dn) - self.button1.setIconSize(_ic_sz) - else: - self.button1.setText("▼") + self._icon_size = QSize(18, 18) self.button1.setToolTip("Minimize") self.button1.clicked.connect(self._toggleMinimize) self.button2 = QPushButton() - _ic_up = glyph_icon("chevron-up", (18), Colors.TEXT_ON_ACCENT) - if _ic_up: - self.button2.setIcon(_ic_up) - self.button2.setIconSize(_ic_sz) - else: - self.button2.setText("▲") self.button2.setToolTip("Maximize") self.button2.clicked.connect(self._toggleMaximize) @@ -96,25 +153,21 @@ def __init__(self, splitterToControl): self.titleBarLayout.addWidget(self.button1) self.titleBarLayout.addWidget(self.button2) + self.resetColor() + def setTitle(self, title: str): """Set the title text.""" self.title.setText(title) def setColor(self, r: int, g: int, b: int, text: tuple | None = None, text_secondary: tuple | None = None): - """Set the title bar gradient to the given RGB color with optional text colors.""" - r2 = min(255, r + 25) - g2 = min(255, g + 25) - b2 = min(255, b + 25) - r3 = max(0, r - 25) - g3 = max(0, g - 25) - b3 = max(0, b - 25) - txt = f"rgb({text[0]},{text[1]},{text[2]})" if text else Colors.TEXT_ON_ACCENT - txt_sec = f"rgba({text_secondary[0]},{text_secondary[1]},{text_secondary[2]},180)" if text_secondary else Colors.TEXT_PRIMARY - self.setStyleSheet(_title_bar_css(r2, g2, b2, r3, g3, b3, - text_color=txt, - text_secondary=txt_sec)) - self._set_handle_color(f"rgba({r2},{g2},{b2},180)") + """Set the title bar color using a limited, contrast-aware palette.""" + palette = _resolve_bar_palette( + (r, g, b), + text=text, + text_secondary=text_secondary, + ) + self._apply_palette(palette) def setFullscreenMode(self, fullscreen: bool): """Enable/disable fullscreen mode. Hides buttons and disables dragging.""" @@ -124,20 +177,53 @@ def setFullscreenMode(self, fullscreen: bool): self.unsetCursor() def resetColor(self): - """Reset to the default blue gradient.""" - self.setStyleSheet(_default_css()) - # Clear any per-album override so the app-level splitter style (with - # hover/pressed states) takes over again. - self.splitter.setStyleSheet("") - - def _set_handle_color(self, color: str): - """Update the splitter handle to match the title bar color.""" - self.splitter.setStyleSheet(f""" - QSplitter::handle {{ - background: {color}; + """Reset to the default limited title-bar palette.""" + self._apply_palette(_resolve_bar_palette(Colors.PLAYLIST_REGULAR)) + + def _set_handle_color(self): + """Keep the splitter handle invisible in every interaction state.""" + self.splitter.setStyleSheet(""" + QSplitter::handle:vertical {{ + background: transparent; + }} + QSplitter::handle:vertical:hover {{ + background: transparent; + }} + QSplitter::handle:vertical:pressed {{ + background: transparent; }} """) + def _apply_palette(self, palette: dict[str, tuple[int, int, int]]) -> None: + self.setStyleSheet( + _title_bar_css( + top_rgb=palette["top"], + bottom_rgb=palette["bottom"], + border_rgb=palette["border"], + text_rgb=palette["text"], + text_secondary_rgb=palette["text_secondary"], + ) + ) + self._set_handle_color() + self._refresh_button_icons(palette["text_secondary"]) + + def _refresh_button_icons(self, rgb: tuple[int, int, int]) -> None: + down_icon = glyph_icon("chevron-down", 18, _css_rgb(rgb)) + if down_icon: + self.button1.setIcon(down_icon) + self.button1.setIconSize(self._icon_size) + self.button1.setText("") + else: + self.button1.setText("▼") + + up_icon = glyph_icon("chevron-up", 18, _css_rgb(rgb)) + if up_icon: + self.button2.setIcon(up_icon) + self.button2.setIconSize(self._icon_size) + self.button2.setText("") + else: + self.button2.setText("▲") + def _toggleMinimize(self): """Minimize the track list panel.""" sizes = self.splitter.sizes() diff --git a/SyncEngine/contracts.py b/SyncEngine/contracts.py index e7daa06..10cc53b 100644 --- a/SyncEngine/contracts.py +++ b/SyncEngine/contracts.py @@ -110,5 +110,6 @@ class SyncRequest: compute_sound_check: bool = False scrobble_on_sync: bool = False listenbrainz_token: str = "" + listenbrainz_username: str = "" is_scrobble_cancelled: Callable[[], bool] | None = None on_cancel_with_partial: Callable[[int, int], bool] | None = None diff --git a/SyncEngine/scrobbler.py b/SyncEngine/scrobbler.py index de811ef..47d5c73 100644 --- a/SyncEngine/scrobbler.py +++ b/SyncEngine/scrobbler.py @@ -34,8 +34,8 @@ import urllib.error import urllib.parse import urllib.request +from collections.abc import Callable from dataclasses import dataclass, field -from typing import Callable, Optional logger = logging.getLogger(__name__) @@ -49,6 +49,7 @@ SUBMISSION_CLIENT = "iOpenPod" SUBMISSION_CLIENT_VERSION = "1.0.0" MEDIA_PLAYER = "iPod" +IMPORT_SERVICE = "iopenpod" # The minimum acceptable value for listened_at (from LB source). LISTEN_MINIMUM_TS = 1033430400 # 2002-10-01 00:00:00 UTC @@ -110,7 +111,7 @@ class RateLimitInfo: reset_in: float = 0.0 # seconds until the window resets @classmethod - def from_headers(cls, headers) -> "RateLimitInfo": + def from_headers(cls, headers) -> RateLimitInfo: """Parse X-RateLimit-* headers from an HTTP response.""" def _int(name: str) -> int: try: @@ -135,6 +136,21 @@ class ScrobbleAborted(Exception): """Raised when the user chooses to stop retrying scrobbles.""" +def _sleep_with_abort( + seconds: float, + should_abort: Callable[[], bool] | None = None, +) -> None: + """Sleep in short increments so cancellation remains responsive.""" + deadline = time.monotonic() + max(seconds, 0.0) + while True: + if should_abort and should_abort(): + raise ScrobbleAborted("User gave up while connecting to ListenBrainz") + remaining = deadline - time.monotonic() + if remaining <= 0: + return + time.sleep(min(remaining, 0.25)) + + def _is_timeout_error(exc: BaseException) -> bool: """Return True when an exception represents a network timeout.""" if isinstance(exc, TimeoutError | socket.timeout): @@ -157,11 +173,11 @@ def _make_request( method: str, path: str, token: str = "", - body: Optional[bytes] = None, - params: Optional[dict[str, str]] = None, + body: bytes | None = None, + params: dict[str, str] | None = None, timeout: int = 30, - on_timeout: Optional[Callable[[float, int, int], None]] = None, - should_abort: Optional[Callable[[], bool]] = None, + on_timeout: Callable[[float, int, int], None] | None = None, + should_abort: Callable[[], bool] | None = None, ) -> tuple[dict, RateLimitInfo]: """Send an HTTP request to the ListenBrainz API. @@ -210,7 +226,7 @@ def _make_request( "ListenBrainz 429 rate-limited; sleeping %.1fs (attempt %d/%d)", wait, rate_limit_attempt, MAX_RATE_LIMIT_RETRIES, ) - time.sleep(wait) + _sleep_with_abort(wait, should_abort) continue raise @@ -236,7 +252,7 @@ def _make_request( # ── Public API: token validation ──────────────────────────────────────────── -def listenbrainz_validate_token(token: str) -> Optional[str]: +def listenbrainz_validate_token(token: str) -> str | None: """Validate a ListenBrainz user token. Returns: @@ -253,7 +269,14 @@ def listenbrainz_validate_token(token: str) -> Optional[str]: # ── Public API: latest-import tracking ────────────────────────────────────── -def get_latest_import(username: str, token: str = "") -> int: +def get_latest_import( + username: str, + token: str = "", + service: str = IMPORT_SERVICE, + *, + on_timeout: Callable[[float, int, int], None] | None = None, + should_abort: Callable[[], bool] | None = None, +) -> int: """Get the Unix timestamp of the newest listen previously imported. Returns 0 if the user has never imported. @@ -262,29 +285,44 @@ def get_latest_import(username: str, token: str = "") -> int: data, _rl = _make_request( "GET", "/1/latest-import", token=token, - params={"user_name": username}, + params={"user_name": username, "service": service}, timeout=15, + on_timeout=on_timeout, + should_abort=should_abort, ) return int(data.get("latest_import", 0)) + except ScrobbleAborted: + raise except Exception as exc: logger.warning("Failed to get latest import timestamp: %s", exc) return 0 -def set_latest_import(ts: int, token: str) -> bool: +def set_latest_import( + ts: int, + token: str, + service: str = IMPORT_SERVICE, + *, + on_timeout: Callable[[float, int, int], None] | None = None, + should_abort: Callable[[], bool] | None = None, +) -> bool: """Update the latest-import timestamp for the authenticated user. Returns `True` on success. """ try: - body = json.dumps({"ts": ts}).encode("utf-8") + body = json.dumps({"ts": ts, "service": service}).encode("utf-8") data, _rl = _make_request( "POST", "/1/latest-import", token=token, body=body, timeout=15, + on_timeout=on_timeout, + should_abort=should_abort, ) return data.get("status") == "ok" + except ScrobbleAborted: + raise except Exception as exc: logger.warning("Failed to set latest import timestamp: %s", exc) return False @@ -302,7 +340,6 @@ def _build_listen_payload(entry: ScrobbleEntry) -> dict: "submission_client": SUBMISSION_CLIENT, "submission_client_version": SUBMISSION_CLIENT_VERSION, "media_player": MEDIA_PLAYER, - "music_service_name": "iOpenPod", } if entry.duration_secs > 0: additional_info["duration_ms"] = entry.duration_secs * 1000 @@ -335,8 +372,9 @@ def scrobble_listenbrainz( entries: list[ScrobbleEntry], token: str, *, - on_timeout: Optional[Callable[[float, int, int], None]] = None, - should_abort: Optional[Callable[[], bool]] = None, + listenbrainz_username: str = "", + on_timeout: Callable[[float, int, int], None] | None = None, + should_abort: Callable[[], bool] | None = None, ) -> ScrobbleResult: """Submit listens to ListenBrainz. @@ -356,6 +394,33 @@ def scrobble_listenbrainz( logger.info("Skipped %d entries with timestamp below LISTEN_MINIMUM_TS", skipped) result.ignored += skipped + if listenbrainz_username: + try: + latest_import = get_latest_import( + listenbrainz_username, + token, + service=IMPORT_SERVICE, + on_timeout=on_timeout, + should_abort=should_abort, + ) + except ScrobbleAborted: + result.errors.append("User gave up while connecting to ListenBrainz") + logger.info("ListenBrainz latest-import lookup aborted by user") + return result + if latest_import > 0: + filtered_entries = [ + entry for entry in valid_entries if entry.timestamp > latest_import + ] + skipped = len(valid_entries) - len(filtered_entries) + if skipped: + logger.info( + "Skipped %d entries at or before latest-import %d", + skipped, + latest_import, + ) + result.ignored += skipped + valid_entries = filtered_entries + if not valid_entries: return result @@ -394,7 +459,7 @@ def scrobble_listenbrainz( # If we're getting close to the rate limit, proactively sleep. if rl.remaining is not None and rl.remaining <= 1 and rl.reset_in > 0: logger.debug("Proactive rate-limit sleep: %.1fs", rl.reset_in) - time.sleep(rl.reset_in) + _sleep_with_abort(rl.reset_in, should_abort) except urllib.error.HTTPError as exc: body_text = exc.read().decode("utf-8", errors="replace") @@ -410,7 +475,24 @@ def scrobble_listenbrainz( # Update latest-import so LB knows how far we've gotten. if max_ts > 0: - set_latest_import(max_ts, token) + try: + if not set_latest_import( + max_ts, + token, + service=IMPORT_SERVICE, + on_timeout=on_timeout, + should_abort=should_abort, + ): + result.errors.append( + "Latest-import timestamp could not be updated; " + "future duplicate protection may be affected" + ) + except ScrobbleAborted: + result.errors.append( + "Latest-import update aborted after submission; " + "future duplicate protection may be affected" + ) + logger.info("ListenBrainz latest-import update aborted by user") logger.info( "ListenBrainz: %d submitted, %d accepted, %d ignored, %d errors", @@ -425,8 +507,8 @@ def get_listens( username: str, token: str = "", *, - max_ts: Optional[int] = None, - min_ts: Optional[int] = None, + max_ts: int | None = None, + min_ts: int | None = None, count: int = 25, ) -> list[dict]: """Fetch listen history for a user. @@ -478,7 +560,8 @@ def build_scrobble_entries( These are the plays that need to be scrobbled. For each play in the delta, a timestamp is generated by spacing - backwards from ``lastPlayed`` by the track's duration. + backwards from ``lastPlayed`` by the track's duration, using the + playback start time for ListenBrainz's ``listened_at`` field. Tracks shorter than 30 s or missing artist/title are skipped. @@ -522,12 +605,14 @@ def build_scrobble_entries( last_played = int(time.time()) # Generate timestamps for each play, spaced backwards by duration. - # Most recent play = last_played, earlier plays spaced by duration. + # ListenBrainz expects listened_at to be the playback start time, + # so shift each play back by one full play spacing from last_played. + play_spacing_secs = max(duration_secs, 180) for play_idx in range(delta): - ts = last_played - (play_idx * max(duration_secs, 180)) + ts = last_played - ((play_idx + 1) * play_spacing_secs) # Ensure timestamp is positive and >= LISTEN_MINIMUM_TS if ts < LISTEN_MINIMUM_TS: - ts = int(time.time()) - (play_idx * max(duration_secs, 180)) + ts = int(time.time()) - ((play_idx + 1) * play_spacing_secs) entries.append(ScrobbleEntry( artist=artist, @@ -550,8 +635,9 @@ def scrobble_plays( playcount_items: list, listenbrainz_token: str = "", *, - on_timeout: Optional[Callable[[float, int, int], None]] = None, - should_abort: Optional[Callable[[], bool]] = None, + listenbrainz_username: str = "", + on_timeout: Callable[[float, int, int], None] | None = None, + should_abort: Callable[[], bool] | None = None, ) -> list[ScrobbleResult]: """Submit scrobbles to ListenBrainz. @@ -579,6 +665,7 @@ def scrobble_plays( r = scrobble_listenbrainz( entries, listenbrainz_token, + listenbrainz_username=listenbrainz_username, on_timeout=on_timeout, should_abort=should_abort, ) diff --git a/SyncEngine/sync_executor.py b/SyncEngine/sync_executor.py index 97ca85a..4f2878d 100644 --- a/SyncEngine/sync_executor.py +++ b/SyncEngine/sync_executor.py @@ -123,6 +123,7 @@ class _SyncContext: compute_sound_check: bool = False scrobble_on_sync: bool = False listenbrainz_token: str = "" + listenbrainz_username: str = "" _is_scrobble_cancelled: Callable[[], bool] | None = None # ── Result accumulator ────────────────────────────────────────── @@ -254,7 +255,6 @@ def _resolve_device_write_workers( return max(1, min(configured_write_workers, overall_workers)) # Explicit tag takes precedence over model-family heuristic. - # True = HDD (sequential), False = SSD/flash (parallel). if ipod_hdd is True: return 1 if ipod_hdd is False: @@ -278,6 +278,9 @@ def execute_request(self, request: SyncRequest) -> SyncOutcome: engine; app-core/UI orchestration should prefer this method so the sync boundary has one explicit contract. """ + # Be tolerant of older callers that may still construct SyncRequest + # without the ListenBrainz username field. + listenbrainz_username = getattr(request, "listenbrainz_username", "") return self.execute( plan=request.plan, mapping=request.mapping, @@ -290,6 +293,7 @@ def execute_request(self, request: SyncRequest) -> SyncOutcome: compute_sound_check=request.compute_sound_check, scrobble_on_sync=request.scrobble_on_sync, listenbrainz_token=request.listenbrainz_token, + listenbrainz_username=listenbrainz_username, is_scrobble_cancelled=request.is_scrobble_cancelled, on_cancel_with_partial=request.on_cancel_with_partial, ) @@ -308,6 +312,7 @@ def execute( compute_sound_check: bool = False, scrobble_on_sync: bool = False, listenbrainz_token: str = "", + listenbrainz_username: str = "", is_scrobble_cancelled: Callable[[], bool] | None = None, on_cancel_with_partial: Callable[[int, int], bool] | None = None, ) -> SyncOutcome: @@ -334,6 +339,7 @@ def execute( compute_sound_check=compute_sound_check, scrobble_on_sync=scrobble_on_sync, listenbrainz_token=listenbrainz_token, + listenbrainz_username=listenbrainz_username, _is_scrobble_cancelled=is_scrobble_cancelled, ) @@ -781,11 +787,12 @@ def _db_progress(msg: str) -> None: ctx.final_photo_db = read_photo_db(self.ipod_path) self._apply_itunes_protections(ctx, all_tracks) - self._delete_playcounts_file() - # Scrobble AFTER DB write + Play Counts deletion + # Scrobble before deleting Play Counts so an interrupted + # submission doesn't discard pending listens before we even try. if ctx.plan.to_sync_playcount: self._execute_scrobble(ctx) + self._delete_playcounts_file() except Exception as e: ctx.result.errors.append(("database write", str(e))) @@ -1644,14 +1651,17 @@ def _execute_playcount_sync(self, ctx: _SyncContext) -> None: ) ctx.result.playcounts_synced += 1 - def _execute_scrobble(self, ctx: _SyncContext) -> None: - """Submit new plays to ListenBrainz (non-fatal).""" + def _execute_scrobble(self, ctx: _SyncContext) -> bool: + """Submit new plays to ListenBrainz. + + Returns True when no scrobble errors occurred. + """ if not ctx.scrobble_on_sync: - return + return True lb_token = ctx.listenbrainz_token if not lb_token: - return + return True ctx.progress("scrobble", 0, 1, message="Scrobbling plays...") @@ -1674,8 +1684,8 @@ def _on_timeout(elapsed: float, attempt: int, timeout_s: int) -> None: 0, 1, message=( - "Connecting to ListenBrainz is taking a while. " - "Still trying to connect. " + "ListenBrainz is taking longer than usual to respond. " + "iOpenPod will keep trying. " f"Elapsed {_format_elapsed(elapsed)} " f"(attempt {attempt}, request timeout {timeout_s}s)." ), @@ -1691,18 +1701,21 @@ def _should_abort_scrobble() -> bool: scrobble_results = scrobble_plays( playcount_items=ctx.plan.to_sync_playcount, listenbrainz_token=lb_token, + listenbrainz_username=ctx.listenbrainz_username, on_timeout=_on_timeout, should_abort=_should_abort_scrobble, ) total_accepted = 0 gave_up = False + scrobble_errors: list[str] = [] for sr in scrobble_results: total_accepted += sr.accepted for err in sr.errors: if "User gave up" in err: gave_up = True logger.warning("Scrobble error (%s): %s", sr.service, err) + scrobble_errors.append(f"{sr.service}: {err}") ctx.result.scrobbles_submitted = total_accepted logger.info("Scrobbled %d plays total", total_accepted) @@ -1713,17 +1726,56 @@ def _should_abort_scrobble() -> bool: 1, 1, message=( - "Stopped trying to connect to ListenBrainz. " - "Sync is complete; you can retry scrobbling on the next sync." + "Stopped retrying ListenBrainz. " + "Your sync is complete, but those plays were not submitted." ), ) - return + elif scrobble_errors: + if total_accepted: + ctx.progress( + "scrobble", + 1, + 1, + message=( + f"Submitted {total_accepted} play" + f"{'s' if total_accepted != 1 else ''} to ListenBrainz, " + f"with {len(scrobble_errors)} follow-up issue" + f"{'s' if len(scrobble_errors) != 1 else ''}." + ), + ) + else: + ctx.progress( + "scrobble", + 1, + 1, + message="ListenBrainz did not accept any plays from this sync.", + ) + else: + ctx.progress( + "scrobble", + 1, + 1, + message=( + f"Submitted {ctx.result.scrobbles_submitted} play" + f"{'s' if ctx.result.scrobbles_submitted != 1 else ''} " + "to ListenBrainz." + ), + ) + + for error in scrobble_errors: + ctx.result.errors.append(("scrobble", error)) + return not scrobble_errors except Exception as exc: logger.warning("Scrobbling failed (non-fatal): %s", exc) - - ctx.progress("scrobble", 1, 1, - message=f"Scrobbled {ctx.result.scrobbles_submitted} plays") + ctx.result.errors.append(("scrobble", str(exc))) + ctx.progress( + "scrobble", + 1, + 1, + message="ListenBrainz did not accept any plays from this sync.", + ) + return False def _execute_rating_sync(self, ctx: _SyncContext) -> None: if not ctx.plan.to_sync_rating: diff --git a/app_core/jobs.py b/app_core/jobs.py index 268ad68..a764df2 100644 --- a/app_core/jobs.py +++ b/app_core/jobs.py @@ -624,55 +624,53 @@ def run(self) -> None: pc_tracks = list(pc_library.scan(include_video=True)) total_pc = len(pc_tracks) + self.progress.emit( + "backsync_pc_fingerprint", + 0, + total_pc, + ( + f"Building fingerprints for {total_pc:,} PC track" + f"{'s' if total_pc != 1 else ''}." + ), + ) pc_fps: set[str] = set() pc_fingerprint_errors: list[str] = [] + workers = min(os.cpu_count() or 4, 8) - if total_pc > 0: - self.progress.emit( - "backsync_pc_fingerprint", - 0, - total_pc, - ( - f"Building fingerprints for {total_pc:,} PC track" - f"{'s' if total_pc != 1 else ''}." - ), - ) - workers = min(os.cpu_count() or 4, 8) - - def _fp_pc(path: str) -> str | None: - return get_or_compute_fingerprint(path, write_to_file=False) - - with ThreadPoolExecutor(max_workers=workers) as pool: - futures = { - pool.submit(_fp_pc, track.path): track - for track in pc_tracks - } - done = 0 - for fut in as_completed(futures): - if self.isInterruptionRequested(): - for pending in futures: - pending.cancel() - return - done += 1 - pc_track = futures[fut] - try: - fp = fut.result() - except Exception as exc: - fp = None - pc_fingerprint_errors.append(f"{pc_track.filename}: {exc}") - if fp: - pc_fps.add(fp) - if done == total_pc or done % 25 == 0: - self.progress.emit( - "backsync_pc_fingerprint", - done, - total_pc, - ( - f"{done:,}/{total_pc:,} checked - " - f"{len(pc_fps):,} usable fingerprints - " - f"{self._short_label(pc_track.filename)}" - ), - ) + def _fp_pc(path: str) -> str | None: + return get_or_compute_fingerprint(path, write_to_file=False) + + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = { + pool.submit(_fp_pc, track.path): track + for track in pc_tracks + } + done = 0 + for fut in as_completed(futures): + if self.isInterruptionRequested(): + for pending in futures: + pending.cancel() + return + done += 1 + pc_track = futures[fut] + try: + fp = fut.result() + except Exception as exc: + fp = None + pc_fingerprint_errors.append(f"{pc_track.filename}: {exc}") + if fp: + pc_fps.add(fp) + if done == total_pc or done % 25 == 0: + self.progress.emit( + "backsync_pc_fingerprint", + done, + total_pc, + ( + f"{done:,}/{total_pc:,} checked - " + f"{len(pc_fps):,} usable fingerprints - " + f"{self._short_label(pc_track.filename)}" + ), + ) ipod_candidates: list[tuple[dict, Path]] = [] unresolved_ipod_tracks = 0 @@ -692,67 +690,56 @@ def _fp_pc(path: str) -> str | None: ipod_candidates.append((track, ipod_file)) total_ipod = len(ipod_candidates) + self.progress.emit( + "backsync_ipod_fingerprint", + 0, + total_ipod, + ( + f"Comparing {total_ipod:,} iPod media file" + f"{'s' if total_ipod != 1 else ''} against your PC library." + ), + ) + to_export: list[tuple[dict, Path]] = [] ipod_fingerprint_errors: list[str] = [] - if not pc_fps: - # PC folder is empty (or all fingerprints failed) — every iPod - # track is missing by definition; skip fingerprinting entirely. - to_export = list(ipod_candidates) - self.progress.emit( - "backsync_ipod_fingerprint", - total_ipod, - total_ipod, - f"PC library empty — all {total_ipod:,} iPod tracks will be exported.", - ) - else: - self.progress.emit( - "backsync_ipod_fingerprint", - 0, - total_ipod, - ( - f"Comparing {total_ipod:,} iPod media file" - f"{'s' if total_ipod != 1 else ''} against your PC library." - ), - ) - - def _fp_ipod(pair: tuple[dict, Path]) -> tuple[dict, Path, str | None, str]: - track, ipod_file = pair - title = track.get("Title") or ipod_file.name - try: - fp = get_or_compute_fingerprint(ipod_file, write_to_file=False) - except Exception as exc: - ipod_fingerprint_errors.append(f"{title}: {exc}") - fp = None - return track, ipod_file, fp, title - - # Cap USB workers at 3 — USB 2.0 saturates quickly with concurrent reads. - ipod_workers = 1 if request.ipod_hdd else min(3, total_ipod or 1) - with ThreadPoolExecutor(max_workers=ipod_workers) as pool: - futures = { - pool.submit(_fp_ipod, pair): pair for pair in ipod_candidates - } - done = 0 - for fut in as_completed(futures): - if self.isInterruptionRequested(): - for pending in futures: - pending.cancel() - return - done += 1 - track, ipod_file, fp, title = fut.result() - if fp and fp not in pc_fps: - to_export.append((track, ipod_file)) - if done == total_ipod or done % 10 == 0: - self.progress.emit( - "backsync_ipod_fingerprint", - done, - total_ipod, - ( - f"{done:,}/{total_ipod:,} checked - " - f"{len(to_export):,} missing so far - " - f"{self._short_label(title)}" - ), - ) + def _fp_ipod(pair: tuple[dict, Path]) -> tuple[dict, Path, str | None, str]: + track, ipod_file = pair + title = track.get("Title") or ipod_file.name + try: + fp = get_or_compute_fingerprint(ipod_file, write_to_file=False) + except Exception as exc: + ipod_fingerprint_errors.append(f"{title}: {exc}") + fp = None + return track, ipod_file, fp, title + + # HDD: 1 worker (avoid seek contention). SSD/flash: up to 3. + ipod_workers = 1 if request.ipod_hdd else min(3, total_ipod or 1) + with ThreadPoolExecutor(max_workers=ipod_workers) as pool: + futures = { + pool.submit(_fp_ipod, pair): pair for pair in ipod_candidates + } + done = 0 + for fut in as_completed(futures): + if self.isInterruptionRequested(): + for pending in futures: + pending.cancel() + return + done += 1 + track, ipod_file, fp, title = fut.result() + if fp and fp not in pc_fps: + to_export.append((track, ipod_file)) + if done == total_ipod or done % 10 == 0: + self.progress.emit( + "backsync_ipod_fingerprint", + done, + total_ipod, + ( + f"{done:,}/{total_ipod:,} checked - " + f"{len(to_export):,} missing so far - " + f"{self._short_label(title)}" + ), + ) output_root = Path(request.pc_folder) / "iOpenPod Back Sync" output_root.mkdir(parents=True, exist_ok=True) @@ -1699,6 +1686,7 @@ def on_progress(prog: SyncProgress) -> None: compute_sound_check=settings.compute_sound_check, scrobble_on_sync=settings.scrobble_on_sync, listenbrainz_token=settings.listenbrainz_token or "", + listenbrainz_username=settings.listenbrainz_username or "", is_scrobble_cancelled=lambda: self._give_up_scrobble_requested, on_cancel_with_partial=_on_cancel_with_partial, ) diff --git a/app_core/services.py b/app_core/services.py index 824e979..8576446 100644 --- a/app_core/services.py +++ b/app_core/services.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from dataclasses import dataclass, fields from typing import Any, Protocol, TypeGuard, runtime_checkable @@ -65,6 +66,9 @@ class SettingsSnapshot: smart_quality_by_type: bool last_device_path: str show_art_in_tracklist: bool + rounded_artwork: bool + sharpen_artwork: bool + track_list_columns_by_content: dict[str, dict[str, int]] theme: str high_contrast: str font_scale: str @@ -85,6 +89,8 @@ def from_settings(cls, settings: AppSettings) -> SettingsSnapshot: value = getattr(settings, field_info.name) if field_info.name == "splitter_sizes": value = tuple(value) + elif field_info.name == "track_list_columns_by_content": + value = copy.deepcopy(value) data[field_info.name] = value return cls(**data) diff --git a/infrastructure/settings_runtime.py b/infrastructure/settings_runtime.py index d8325b2..62631cc 100644 --- a/infrastructure/settings_runtime.py +++ b/infrastructure/settings_runtime.py @@ -44,6 +44,7 @@ def _copy_device_settings_state(state: DeviceSettingsState) -> DeviceSettingsSta path=state.path, ) + def _coerce_setting_value(current_value, value): expected_type = type(current_value) if expected_type is bool: @@ -56,6 +57,8 @@ def _coerce_setting_value(current_value, value): if isinstance(value, int) and not isinstance(value, bool): return value return None + if expected_type is dict: + return value if isinstance(value, dict) else None if expected_type is list: return value if isinstance(value, list) else None return value if isinstance(value, expected_type) else None diff --git a/infrastructure/settings_schema.py b/infrastructure/settings_schema.py index 559266e..ae1251f 100644 --- a/infrastructure/settings_schema.py +++ b/infrastructure/settings_schema.py @@ -84,6 +84,9 @@ class AppSettings: last_device_path: str = "" show_art_in_tracklist: bool = True + rounded_artwork: bool = False + sharpen_artwork: bool = True + track_list_columns_by_content: dict[str, dict[str, int]] = field(default_factory=dict) theme: str = "dark" high_contrast: str = "off" font_scale: str = "100%" diff --git a/infrastructure/version.py b/infrastructure/version.py index 8e51ec9..d26b2b1 100644 --- a/infrastructure/version.py +++ b/infrastructure/version.py @@ -15,4 +15,4 @@ def get_version() -> str: return package_version("iopenpod") except Exception: logger.debug("Failed to read installed iopenpod version", exc_info=True) - return "1.0.49" + return "1.0.51" diff --git a/pyproject.toml b/pyproject.toml index 57f7de3..1e8cfea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "iopenpod" -version = "1.0.49" +version = "1.0.51" description = "Open-source iPod Classic sync tool — manage your iPod media library without iTunes. Reads and writes iTunesDB directly." authors = [ {name = "John Gibbons", email = "johngibbons167@gmail.com"} diff --git a/scripts/generate_fake_music_library.py b/scripts/generate_fake_music_library.py index d4f25b8..35b64ba 100644 --- a/scripts/generate_fake_music_library.py +++ b/scripts/generate_fake_music_library.py @@ -9,7 +9,8 @@ import sys import time import wave -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed +from concurrent.futures.process import BrokenProcessPool from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -74,7 +75,7 @@ def _default_workers() -> int: - return max(1, min((os.cpu_count() or 4) * 2, 16)) + return max(1, min(os.cpu_count() or 4, 8)) @dataclass(frozen=True) @@ -109,6 +110,7 @@ class MusicalRecipe: chord_degrees: tuple[int, ...] melody_degrees: tuple[int, ...] melody_rests: tuple[bool, ...] + signature_offsets: tuple[int, ...] bass_pattern: tuple[int, ...] kick_hits: tuple[float, ...] snare_hits: tuple[float, ...] @@ -499,6 +501,12 @@ def _mix_u32(value: int) -> int: return value +def _serial_chroma_signature(track_serial: int, length: int = 32) -> tuple[int, ...]: + # Encodes the serial in base-12 so every track gets a distinct pitch-class path. + serial_value = track_serial + 1 + return tuple(((serial_value // (12**step)) + step * 5 + (step // 4) * 2) % 12 for step in range(length)) + + def _build_music_recipe(track_serial: int) -> MusicalRecipe: seed = _mix_u32(track_serial + 1) modes = ( @@ -532,7 +540,7 @@ def _build_music_recipe(track_serial: int) -> MusicalRecipe: melody_degrees: list[int] = [] melody_rests: list[bool] = [] - intervals = (0, 1, 2, 4, 5, 7, 9, -1, -2) + intervals = (0, 1, 2, 3, 4, 5, -1, -2) for step in range(32): mixed = _mix_u32(seed + step * 0x45D9F3B) bar = (step // 8) % 4 @@ -558,6 +566,7 @@ def _build_music_recipe(track_serial: int) -> MusicalRecipe: chord_degrees=progression, melody_degrees=tuple(melody_degrees), melody_rests=tuple(melody_rests), + signature_offsets=_serial_chroma_signature(track_serial), bass_pattern=bass_patterns[(seed >> 17) % len(bass_patterns)], kick_hits=tuple(sorted(kick_hits)), snare_hits=tuple(snare_hits), @@ -575,6 +584,14 @@ def _midi_to_hz(midi_note: int) -> float: return 440.0 * (2.0 ** ((midi_note - 69) / 12.0)) +def _fit_midi_range(midi_note: int, low: int, high: int) -> int: + while midi_note < low: + midi_note += 12 + while midi_note > high: + midi_note -= 12 + return midi_note + + def _note_envelope(phase: float, *, attack: float, release: float) -> float: if phase < attack: return phase / max(attack, 1e-9) @@ -603,15 +620,64 @@ def _noise(index: int, seed: int) -> float: return ((mixed & 0xFFFF) / 32767.5) - 1.0 -def _voice(hz: float, t: float, tone_color: int) -> float: - phase = math.tau * hz * t - if tone_color == 0: - return 0.72 * math.sin(phase) + 0.18 * math.sin(2.0 * phase) + 0.10 * math.sin(3.0 * phase) - if tone_color == 1: - return 0.65 * math.sin(phase) + 0.25 * math.sin(3.0 * phase) + 0.10 * math.sin(5.0 * phase) - if tone_color == 2: - return 0.58 * math.sin(phase) + 0.28 * math.sin(2.0 * phase) + 0.14 * math.sin(4.0 * phase) - return 0.80 * math.sin(phase) + 0.14 * math.sin(phase * 1.997) + 0.06 * math.sin(phase * 2.996) +def _sine_stack(hz: float, t: float, partials: tuple[tuple[float, float], ...]) -> float: + total = 0.0 + for harmonic, level in partials: + total += level * math.sin(math.tau * hz * harmonic * t) + return total + + +def _warm_pad_voice(hz: float, t: float, tone_color: int) -> float: + detune = 1.0015 + tone_color * 0.0005 + return ( + _sine_stack(hz, t, ((1.0, 0.72), (2.0, 0.10), (3.0, 0.04))) + + _sine_stack(hz * detune, t, ((1.0, 0.14), (2.0, 0.03))) + ) + + +def _round_bass_voice(hz: float, t: float) -> float: + return _sine_stack(hz, t, ((0.5, 0.18), (1.0, 0.78), (2.0, 0.10), (3.0, 0.035))) + + +def _clean_lead_voice(hz: float, t: float, note_phase: float, tone_color: int) -> float: + brightness = math.exp(-note_phase * (3.2 + tone_color * 0.2)) + return _sine_stack( + hz, + t, + ( + (1.0, 0.86), + (2.0, 0.08 + brightness * 0.045), + (3.0, brightness * 0.018), + ), + ) + + +def _smooth_noise(index: int, seed: int) -> float: + return ( + _noise(index, seed) * 0.55 + + _noise(index - 1, seed) * 0.30 + + _noise(index - 2, seed) * 0.15 + ) + + +def _kick_voice(t_since: float) -> float: + body_env = math.exp(-t_since / 0.16) + thump = math.sin(math.tau * 52.0 * t_since) * body_env + low_mid = math.sin(math.tau * 96.0 * t_since) * math.exp(-t_since / 0.045) * 0.14 + return thump + low_mid + + +def _snare_voice(index: int, t_since: float, seed: int) -> float: + noise_env = math.exp(-t_since / 0.10) + body_env = math.exp(-t_since / 0.065) + noise = _smooth_noise(index * 5, seed ^ 0x55AA55AA) + body = math.sin(math.tau * 175.0 * t_since) * 0.32 + return noise * noise_env * 0.55 + body * body_env + + +def _hat_voice(index: int, t_since: float, seed: int) -> float: + hat_env = math.exp(-t_since / 0.035) + return _smooth_noise(index * 13, seed ^ 0xA5A5A5A5) * hat_env def write_tone_wav( @@ -642,23 +708,25 @@ def write_tone_wav( global_env = (frame_count - index - 1) / fade_frames global_env = max(global_env, 0.0) - chord_offsets = (0, 2, 4, 6) + chord_offsets = (0, 2, 4) pad_env = _bar_envelope(beat_in_bar) pulse = 1.0 - recipe.pulse_depth + recipe.pulse_depth * math.sin(math.tau * beat / 2.0) ** 2 pad = 0.0 for offset in chord_offsets: note = _scale_midi(recipe.root_midi, recipe.mode, chord_degree + offset, 2) - pad += _voice(_midi_to_hz(note), t, recipe.tone_color) - pad = (pad / len(chord_offsets)) * pad_env * pulse * 0.28 + note = _fit_midi_range(note, 50, 70) + pad += _warm_pad_voice(_midi_to_hz(note), t, recipe.tone_color) + pad = (pad / len(chord_offsets)) * pad_env * pulse * 0.24 bass_position = beat_in_loop * 2.0 bass_step = int(bass_position) bass_phase = bass_position - bass_step bass_degree = chord_degree + recipe.bass_pattern[bass_step % len(recipe.bass_pattern)] bass_note = _scale_midi(recipe.root_midi, recipe.mode, bass_degree, 0) + bass_note = _fit_midi_range(bass_note, 36, 52) bass_env = _note_envelope(bass_phase, attack=0.05, release=0.42) bass_accent = 1.15 if bass_step % 8 in {0, 4} else 0.82 - bass = _voice(_midi_to_hz(bass_note), t, 1) * bass_env * bass_accent * 0.34 + bass = _round_bass_voice(_midi_to_hz(bass_note), t) * bass_env * bass_accent * 0.36 melody_position = beat_in_loop * 2.0 melody_step = int(melody_position) @@ -666,34 +734,36 @@ def write_tone_wav( lead = 0.0 if not recipe.melody_rests[melody_step % len(recipe.melody_rests)]: melody_degree = recipe.melody_degrees[melody_step % len(recipe.melody_degrees)] - lead_octave = 3 + ((melody_step // 8 + recipe.tone_color) % 2) + lead_octave = 2 + ((melody_step // 16 + recipe.tone_color) % 2) lead_note = _scale_midi(recipe.root_midi, recipe.mode, melody_degree, lead_octave) - vibrato = 1.0 + 0.0035 * math.sin(math.tau * (4.8 + recipe.tone_color * 0.4) * t) - lead_env = _note_envelope(melody_phase, attack=0.08, release=0.48) - lead = _voice(_midi_to_hz(lead_note) * vibrato, t, (recipe.tone_color + 2) % 4) * lead_env * 0.22 + lead_note = _fit_midi_range(lead_note, 57, 76) + lead_env = _note_envelope(melody_phase, attack=0.09, release=0.62) + lead = _clean_lead_voice(_midi_to_hz(lead_note), t, melody_phase, recipe.tone_color) * lead_env * 0.16 + + signature_step = int(melody_position) + signature_phase = melody_position - signature_step + signature_offset = recipe.signature_offsets[signature_step % len(recipe.signature_offsets)] + signature_note = _fit_midi_range(recipe.root_midi + 24 + signature_offset, 55, 74) + signature_env = _note_envelope(signature_phase, attack=0.06, release=0.70) + signature_accent = 1.0 if signature_step % 4 in {0, 2} else 0.72 + signature = _clean_lead_voice(_midi_to_hz(signature_note), t, signature_phase, recipe.tone_color) * signature_env * signature_accent * 0.095 kick_delta = _recent_hit_delta(beat_in_loop, recipe.kick_hits, max_delta=0.55) kick = 0.0 if kick_delta is not None: - kick_env = math.exp(-kick_delta / 0.16) - kick_hz = 44.0 + 88.0 * math.exp(-kick_delta / 0.05) - kick = math.sin(math.tau * kick_hz * t) * kick_env * 0.42 + kick = _kick_voice(kick_delta / beats_per_second) * 0.43 snare_delta = _recent_hit_delta(beat_in_loop, recipe.snare_hits, max_delta=0.35) snare = 0.0 if snare_delta is not None: - snare_env = math.exp(-snare_delta / 0.07) - snare_noise = _noise(index * 3, recipe.seed) * 0.70 - snare_tone = math.sin(math.tau * 185.0 * t) * 0.30 - snare = (snare_noise + snare_tone) * snare_env * 0.23 + snare = _snare_voice(index, snare_delta / beats_per_second, recipe.seed) * 0.16 hat_delta = (beat_in_loop * 2.0) % 1.0 / 2.0 - hat_env = math.exp(-hat_delta / 0.045) - hat_accent = 0.13 if int(beat_in_loop * 2.0) % 2 == 0 else 0.08 - hat = _noise(index * 11, recipe.seed ^ 0xA5A5A5A5) * hat_env * hat_accent + hat_accent = 0.048 if int(beat_in_loop * 2.0) % 2 == 0 else 0.028 + hat = _hat_voice(index, hat_delta / beats_per_second, recipe.seed) * hat_accent - sample = (pad + bass + lead + kick + snare + hat) * global_env - value = int(32767.0 * 0.82 * math.tanh(sample * 1.35)) + sample = (pad + bass + lead + signature + kick + snare + hat) * global_env + value = int(32767.0 * 0.86 * math.tanh(sample * 1.08)) frames.extend(value.to_bytes(2, byteorder="little", signed=True)) with wave.open(str(path), "wb") as wav_file: @@ -774,6 +844,59 @@ def generate_track_file( return spec.path +def _generate_tracks_concurrently( + track_specs: list[TrackSpec], + *, + sample_rate: int, + duration: float, + worker_count: int, + track_bar, +) -> dict[int, Path]: + created_by_serial: dict[int, Path] = {} + executor_class = ProcessPoolExecutor if worker_count > 1 else ThreadPoolExecutor + try: + with executor_class(max_workers=worker_count) as executor: + futures = { + executor.submit( + generate_track_file, + spec, + sample_rate=sample_rate, + duration=duration, + ): spec + for spec in track_specs + } + for future in as_completed(futures): + spec = futures[future] + created_path = future.result() + created_by_serial[spec.serial] = created_path + track_bar.set_postfix_str(created_path.name[:40], refresh=False) + track_bar.update(1) + except (BrokenProcessPool, RuntimeError, OSError): + if executor_class is ThreadPoolExecutor: + raise + track_bar.set_postfix_str("process workers unavailable; retrying with threads", refresh=True) + created_by_serial.clear() + track_bar.reset(total=len(track_specs)) + with ThreadPoolExecutor(max_workers=worker_count) as executor: + futures = { + executor.submit( + generate_track_file, + spec, + sample_rate=sample_rate, + duration=duration, + ): spec + for spec in track_specs + } + for future in as_completed(futures): + spec = futures[future] + created_path = future.result() + created_by_serial[spec.serial] = created_path + track_bar.set_postfix_str(created_path.name[:40], refresh=False) + track_bar.update(1) + return created_by_serial + return created_by_serial + + def generate_library( output: Path, *, @@ -859,8 +982,8 @@ def generate_library( workers=0, ) - worker_count = max(1, min(workers, len(track_specs))) - created_by_serial: dict[int, Path] = {} + cpu_count = os.cpu_count() or 1 + worker_count = max(1, min(workers, len(track_specs), cpu_count)) generation_started_at = time.perf_counter() with tqdm( @@ -870,22 +993,15 @@ def generate_library( dynamic_ncols=True, mininterval=0.1, ) as track_bar: - with ThreadPoolExecutor(max_workers=worker_count) as executor: - futures = { - executor.submit( - generate_track_file, - spec, - sample_rate=sample_rate, - duration=duration, - ): spec - for spec in track_specs - } - for future in as_completed(futures): - spec = futures[future] - created_path = future.result() - created_by_serial[spec.serial] = created_path - track_bar.set_postfix_str(created_path.name[:40], refresh=False) - track_bar.update(1) + executor_label = "process" if worker_count > 1 else "thread" + track_bar.set_postfix_str(f"starting {worker_count} {executor_label} worker{'s' if worker_count != 1 else ''}", refresh=True) + created_by_serial = _generate_tracks_concurrently( + track_specs, + sample_rate=sample_rate, + duration=duration, + worker_count=worker_count, + track_bar=track_bar, + ) generation_elapsed = time.perf_counter() - generation_started_at created = [created_by_serial[spec.serial] for spec in track_specs] diff --git a/scripts/manual_tracklist_column_persistence.py b/scripts/manual_tracklist_column_persistence.py new file mode 100644 index 0000000..fedade6 --- /dev/null +++ b/scripts/manual_tracklist_column_persistence.py @@ -0,0 +1,491 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import cast + +from PyQt6.QtCore import QEvent, Qt, QTimer +from PyQt6.QtGui import QFont, QMouseEvent +from PyQt6.QtWidgets import ( + QApplication, + QFrame, + QHBoxLayout, + QLabel, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from app_core.context import RuntimeSettingsService +from app_core.services import ( + DeviceCapabilitySnapshot, + DeviceIdentitySnapshot, + DeviceManagerLike, + DeviceSession, +) +from GUI.styles import FONT_FAMILY, Metrics +from GUI.widgets.browserChrome import chrome_action_btn_css +from GUI.widgets.formatters import format_duration_mmss +from GUI.widgets.MBListView import MusicBrowserList +from infrastructure.settings_paths import get_settings_path +from infrastructure.settings_runtime import SettingsRuntime + +_HARNESS_BG = "#f4f1ea" +_HARNESS_PANEL = "#fffaf0" +_HARNESS_TEXT = "#1f1a14" +_HARNESS_TEXT_MUTED = "#5c5146" +_HARNESS_BORDER = "#cdbfae" + + +@dataclass +class _CancellationToken: + def is_cancelled(self) -> bool: + return False + + +class _DeviceManager: + device_changed = None + device_settings_loaded = None + device_settings_failed = None + + def __init__(self) -> None: + self.cancellation_token = _CancellationToken() + self._device_path: str | None = None + self._discovered_ipod: object | None = None + self._device_settings_loading = False + self._itunesdb_path: str | None = None + self._artworkdb_path: str | None = None + self._artwork_folder_path: str | None = None + + @property + def device_path(self) -> str | None: + return self._device_path + + @device_path.setter + def device_path(self, path: str | None) -> None: + self._device_path = path + + @property + def discovered_ipod(self) -> object | None: + return self._discovered_ipod + + @discovered_ipod.setter + def discovered_ipod(self, ipod: object | None) -> None: + self._discovered_ipod = ipod + + @property + def device_settings_loading(self) -> bool: + return self._device_settings_loading + + @property + def itunesdb_path(self) -> str | None: + return self._itunesdb_path + + @property + def artworkdb_path(self) -> str | None: + return self._artworkdb_path + + @property + def artwork_folder_path(self) -> str | None: + return self._artwork_folder_path + + def is_valid_ipod_root(self, path: str) -> bool: + return True + + def cancel_all_operations(self) -> None: + return None + + +@dataclass +class _Session: + device_path: str | None = None + itunesdb_path: str | None = None + artworkdb_path: str | None = None + artwork_folder_path: str | None = None + device_settings_loading: bool = False + discovered_ipod: object | None = None + identity: DeviceIdentitySnapshot | None = None + capabilities: DeviceCapabilitySnapshot | None = None + + @property + def has_device(self) -> bool: + return bool(self.device_path) + + +class _DeviceSessions: + def __init__(self) -> None: + self._manager = _DeviceManager() + + def current_session(self) -> DeviceSession: + return cast(DeviceSession, _Session()) + + def manager(self) -> DeviceManagerLike: + return cast(DeviceManagerLike, self._manager) + + +def _sample_tracks() -> list[dict[str, object]]: + tracks: list[dict[str, object]] = [] + for index in range(1, 16): + minutes = 3 + (index % 4) + seconds = (index * 11) % 60 + tracks.append( + { + "Title": f"Track {index:02d}", + "Artist": f"Artist {((index - 1) % 4) + 1}", + "Album": f"Album {((index - 1) % 3) + 1}", + "Genre": ("Rock", "Jazz", "Pop")[index % 3], + "year": 2000 + (index % 8), + "track_number": index, + "length": ((minutes * 60) + seconds) * 1000, + "rating": ((index % 5) + 1) * 20, + "play_count_1": index * 3, + "date_added": 1710000000 + (index * 12345), + } + ) + return tracks + + +class ManualTracklistPersistenceHarness(QWidget): + def __init__(self) -> None: + super().__init__() + self._settings_service = RuntimeSettingsService(runtime=SettingsRuntime()) + self._device_sessions = _DeviceSessions() + self._settings_path = Path(get_settings_path()) + self._last_settings_mtime_ns: int | None = None + self._last_preview_text = "" + self._last_flushed_header_signature: tuple[tuple[str, ...], tuple[tuple[str, int], ...]] | None = None + self._pending_auto_flush_reason = "" + self._auto_flush_count = 0 + self._header_events: list[str] = [] + + self.setWindowTitle("Tracklist Column Persistence Harness") + self.resize(1320, 900) + self.setStyleSheet( + f""" + QWidget {{ + background: {_HARNESS_BG}; + color: {_HARNESS_TEXT}; + }} + QLabel {{ + background: transparent; + color: {_HARNESS_TEXT}; + }} + """ + ) + + layout = QVBoxLayout(self) + layout.setContentsMargins(18, 18, 18, 18) + layout.setSpacing(12) + + intro = QLabel( + "Move or resize columns in the track list below. " + "The panel at the bottom watches the live settings file from disk and " + "rerenders the saved `track_list_columns_by_content.music` payload automatically. " + "The flush button is only there as a manual fallback." + ) + intro.setWordWrap(True) + intro.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD)) + intro.setStyleSheet(f"color: {_HARNESS_TEXT};") + layout.addWidget(intro) + + path_label = QLabel(f"Settings file: {self._settings_path}") + path_label.setTextInteractionFlags( + Qt.TextInteractionFlag.TextSelectableByMouse + ) + path_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + path_label.setStyleSheet(f"color: {_HARNESS_TEXT_MUTED};") + layout.addWidget(path_label) + + button_row = QHBoxLayout() + button_row.setSpacing(10) + + self._done_btn = QPushButton("Force Flush") + self._done_btn.setStyleSheet(chrome_action_btn_css()) + self._done_btn.clicked.connect(self._flush_and_refresh) + button_row.addWidget(self._done_btn) + + self._reload_btn = QPushButton("Reload File") + self._reload_btn.setStyleSheet(chrome_action_btn_css()) + self._reload_btn.clicked.connect(self._refresh_file_preview) + button_row.addWidget(self._reload_btn) + + self._reset_btn = QPushButton("Reset Stored Music Layout") + self._reset_btn.setStyleSheet(chrome_action_btn_css()) + self._reset_btn.clicked.connect(self._reset_music_layout) + button_row.addWidget(self._reset_btn) + + self._close_btn = QPushButton("Close") + self._close_btn.setStyleSheet(chrome_action_btn_css()) + self._close_btn.clicked.connect(self.close) + button_row.addWidget(self._close_btn) + + button_row.addStretch(1) + layout.addLayout(button_row) + + self._status = QLabel("") + self._status.setWordWrap(True) + self._status.setFont(QFont(FONT_FAMILY, Metrics.FONT_SM)) + self._status.setStyleSheet(f"color: {_HARNESS_TEXT_MUTED};") + layout.addWidget(self._status) + + self._list = MusicBrowserList( + settings_service=self._settings_service, + device_sessions=self._device_sessions, + show_art_override=False, + ) + self._list.setFrameShape(QFrame.Shape.NoFrame) + layout.addWidget(self._list, 1) + + preview_label = QLabel("Saved Layout Preview") + preview_label.setFont(QFont(FONT_FAMILY, Metrics.FONT_MD, QFont.Weight.Bold)) + preview_label.setStyleSheet(f"color: {_HARNESS_TEXT};") + layout.addWidget(preview_label) + + self._preview = QTextEdit() + self._preview.setReadOnly(True) + self._preview.setFont(QFont("Menlo", max(11, Metrics.FONT_SM))) + self._preview.setMinimumHeight(220) + self._preview.setStyleSheet( + f""" + QTextEdit {{ + background: {_HARNESS_PANEL}; + color: {_HARNESS_TEXT}; + border: 1px solid {_HARNESS_BORDER}; + border-radius: 10px; + padding: 8px; + selection-background-color: #d7b98e; + selection-color: {_HARNESS_TEXT}; + }} + """ + ) + layout.addWidget(self._preview) + + self._load_tracks() + self._install_header_observer() + self._last_flushed_header_signature = self._current_header_signature() + self._refresh_file_preview() + self._preview_timer = QTimer(self) + self._preview_timer.setInterval(250) + self._preview_timer.timeout.connect(self._refresh_file_preview_if_changed) + self._preview_timer.start() + + self._auto_flush_timer = QTimer(self) + self._auto_flush_timer.setSingleShot(True) + self._auto_flush_timer.timeout.connect(self._auto_flush_if_header_changed) + + def _load_tracks(self) -> None: + tracks = _sample_tracks() + self._list.clearTable() + self._list._all_tracks = tracks + self._list._tracks = tracks + self._list._media_type_filter = 0x01 + self._list._is_playlist_mode = False + self._list._setup_columns() + self._list._populate_table() + self._install_header_observer() + + def _current_visible_order(self) -> list[str]: + keys: list[str] = [] + for visual_index in range(self._list.table.columnCount()): + key = self._list._col_key_at(visual_index) + if key is not None: + keys.append(key) + return keys + + def _current_header_signature(self) -> tuple[tuple[str, ...], tuple[tuple[str, int], ...]]: + header = self._list.table.horizontalHeader() + if header is None: + return (), () + + widths: dict[str, int] = {} + for logical_index in range(self._list.table.columnCount()): + key = self._list._col_key_for_logical(logical_index) + if key is not None: + widths[key] = header.sectionSize(logical_index) + + return tuple(self._current_visible_order()), tuple(sorted(widths.items())) + + def _install_header_observer(self) -> None: + header = self._list.table.horizontalHeader() + if header is None: + return + + header.installEventFilter(self) + viewport = header.viewport() + if viewport is not None: + viewport.installEventFilter(self) + + try: + header.sectionMoved.disconnect(self._on_observed_header_moved) + except TypeError: + pass + try: + header.sectionResized.disconnect(self._on_observed_header_resized) + except TypeError: + pass + header.sectionMoved.connect(self._on_observed_header_moved) + header.sectionResized.connect(self._on_observed_header_resized) + + def _remember_header_event(self, label: str) -> None: + self._header_events.append(label) + self._header_events = self._header_events[-10:] + + def _schedule_auto_flush(self, reason: str) -> None: + self._pending_auto_flush_reason = reason + self._remember_header_event(f"queued: {reason}") + self._auto_flush_timer.start(250) + + def _on_observed_header_moved( + self, + logical_index: int, + old_visual: int, + new_visual: int, + ) -> None: + self._remember_header_event( + f"sectionMoved logical={logical_index} {old_visual}->{new_visual}" + ) + self._schedule_auto_flush("sectionMoved settled") + + def _on_observed_header_resized( + self, + logical_index: int, + old_size: int, + new_size: int, + ) -> None: + self._remember_header_event( + f"sectionResized logical={logical_index} {old_size}->{new_size}" + ) + self._schedule_auto_flush("sectionResized settled") + + def _auto_flush_if_header_changed(self) -> None: + current_signature = self._current_header_signature() + if current_signature == self._last_flushed_header_signature: + self._remember_header_event("auto flush skipped: no header delta") + self._refresh_file_preview() + return + + reason = self._pending_auto_flush_reason or "header changed" + self._list.flush_pending_column_changes() + QApplication.processEvents() + self._last_flushed_header_signature = current_signature + self._auto_flush_count += 1 + self._remember_header_event(f"auto flushed: {reason}") + self._refresh_file_preview() + + def _flush_and_refresh(self) -> None: + self._list.flush_pending_column_changes(force=True) + QApplication.processEvents() + self._last_flushed_header_signature = self._current_header_signature() + self._auto_flush_count += 1 + self._remember_header_event("manual force flush") + self._refresh_file_preview() + + def _reset_music_layout(self) -> None: + settings = self._settings_service.get_global_settings() + layouts = dict(settings.track_list_columns_by_content) + layouts.pop("music", None) + settings.track_list_columns_by_content = layouts + self._settings_service.save_global_settings(settings) + + self._list._column_layouts.pop("music", None) + self._list._user_col_widths.clear() + self._list._user_col_order = None + self._list._active_column_content_key = None + self._load_tracks() + self._install_header_observer() + self._last_flushed_header_signature = self._current_header_signature() + self._refresh_file_preview() + + def _refresh_file_preview(self) -> None: + if not self._settings_path.exists(): + self._status.setText(f"Settings file does not exist yet: {self._settings_path}") + self._preview.setPlainText("") + self._last_settings_mtime_ns = None + self._last_preview_text = "" + return + + try: + payload = json.loads(self._settings_path.read_text(encoding="utf-8")) + except Exception as exc: + self._status.setText(f"Failed to read settings file: {exc}") + self._preview.setPlainText("") + return + + music_layout = ( + payload.get("track_list_columns_by_content", {}) + .get("music", {}) + ) + visible_order = self._current_visible_order() + mtime_ns = self._settings_path.stat().st_mtime_ns + self._status.setText( + "Visible order in widget: " + f"{visible_order}\n" + f"Auto flushes: {self._auto_flush_count}; " + f"pending reason: {self._pending_auto_flush_reason or 'none'}\n" + "Recent header activity: " + f"{' | '.join(self._header_events[-5:]) or 'none'}\n" + "Saved file payload read from disk. " + f"Last file write: {mtime_ns}" + ) + preview_text = json.dumps( + { + "settings_path": str(self._settings_path), + "music_layout": music_layout, + "sample_duration_display": format_duration_mmss(245000), + }, + indent=2, + ) + if preview_text != self._last_preview_text: + self._preview.setPlainText(preview_text) + self._last_preview_text = preview_text + self._last_settings_mtime_ns = mtime_ns + + def _refresh_file_preview_if_changed(self) -> None: + if not self._settings_path.exists(): + if self._last_settings_mtime_ns is not None: + self._refresh_file_preview() + return + try: + current_mtime_ns = self._settings_path.stat().st_mtime_ns + except OSError: + return + if self._last_settings_mtime_ns != current_mtime_ns: + self._refresh_file_preview() + + def eventFilter(self, obj, event): # type: ignore[override] + header = self._list.table.horizontalHeader() + if header is not None and obj in {header, header.viewport()}: + event_type = event.type() + if event_type == QEvent.Type.MouseButtonPress: + mouse_event: QMouseEvent = event # type: ignore[assignment] + if mouse_event.button() == Qt.MouseButton.LeftButton: + self._remember_header_event("left press") + elif event_type == QEvent.Type.MouseMove: + mouse_event = event # type: ignore[assignment] + if mouse_event.buttons() & Qt.MouseButton.LeftButton: + self._remember_header_event("left drag") + elif event_type == QEvent.Type.MouseButtonRelease: + mouse_event = event # type: ignore[assignment] + if mouse_event.button() == Qt.MouseButton.LeftButton: + self._schedule_auto_flush("left mouse release") + elif event_type in { + QEvent.Type.FocusOut, + QEvent.Type.Leave, + QEvent.Type.Hide, + }: + self._schedule_auto_flush(event_type.name) + + return super().eventFilter(obj, event) + + +def main() -> int: + app = QApplication.instance() or QApplication([]) + window = ManualTracklistPersistenceHarness() + window.show() + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_artwork_rendering.py b/tests/test_artwork_rendering.py new file mode 100644 index 0000000..99927cf --- /dev/null +++ b/tests/test_artwork_rendering.py @@ -0,0 +1,25 @@ +from PIL import Image + +from GUI.artwork_rendering import enhance_artwork_image, nested_artwork_radius + + +def test_nested_artwork_radius_preserves_parent_shape_language() -> None: + assert nested_artwork_radius(12, 10) == 8 + assert nested_artwork_radius(6, 4) == 4 + assert nested_artwork_radius(8, 0) == 8 + + +def test_enhance_artwork_image_preserves_size() -> None: + image = Image.new("RGB", (64, 64), (120, 90, 60)) + + enhanced = enhance_artwork_image(image) + + assert enhanced.size == image.size + + +def test_enhance_artwork_image_can_be_disabled() -> None: + image = Image.new("RGB", (64, 64), (120, 90, 60)) + + enhanced = enhance_artwork_image(image, enabled=False) + + assert enhanced is image diff --git a/tests/test_mb_grid_view.py b/tests/test_mb_grid_view.py index 1f473c9..1121ef3 100644 --- a/tests/test_mb_grid_view.py +++ b/tests/test_mb_grid_view.py @@ -1,7 +1,9 @@ from __future__ import annotations +from typing import Any, cast + from PIL import Image -from PyQt6.QtWidgets import QScrollArea +from PyQt6.QtWidgets import QScrollArea, QScrollBar import GUI.imgMaker as img_maker from GUI.widgets.MBGridView import MusicBrowserGrid @@ -13,8 +15,8 @@ def _build_items( *, with_art: bool = False, start: int = 0, -) -> list[dict]: - items: list[dict] = [] +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] for index in range(start, start + count): items.append( { @@ -46,6 +48,16 @@ def _mount_grid(qtbot, *, width: int = 920, height: int = 620) -> tuple[QScrollA return scroll, grid +def _grid_items(grid: MusicBrowserGrid) -> list[MusicBrowserGridItem]: + return [cast(MusicBrowserGridItem, widget) for widget in grid.gridItems] + + +def _scroll_bar(scroll: QScrollArea) -> QScrollBar: + bar = scroll.verticalScrollBar() + assert bar is not None + return bar + + def _art_result(rgb: tuple[int, int, int]) -> tuple[int, int, bytes, tuple[int, int, int], dict]: image = Image.new("RGBA", (16, 16), (*rgb, 255)) return ( @@ -66,16 +78,16 @@ def test_grid_uses_bounded_widget_pool_and_recycles_on_scroll(qtbot): initial_widgets = grid.findChildren(MusicBrowserGridItem) initial_widget_ids = {id(widget) for widget in initial_widgets} - initial_titles = [widget.item_data.get("title") for widget in grid.gridItems] + initial_titles = [widget.item_data.get("title") for widget in _grid_items(grid)] assert len(initial_widgets) < 100 assert len(initial_widgets) == len(grid.gridItems) + len(grid._widget_pool) - bar = scroll.verticalScrollBar() + bar = _scroll_bar(scroll) bar.setValue(max(1, bar.maximum() // 2)) qtbot.waitUntil( - lambda: grid.gridItems - and grid.gridItems[0].item_data.get("title") not in initial_titles, + lambda: _grid_items(grid) + and _grid_items(grid)[0].item_data.get("title") not in initial_titles, timeout=2000, ) @@ -98,19 +110,19 @@ def test_grid_rebinds_cleanly_for_search_and_sort(qtbot): qtbot.waitUntil( lambda: len(grid._visible_records) == 1 and len(grid.gridItems) == 1 - and grid.gridItems[0].item_data.get("title") == "Album 0315", + and _grid_items(grid)[0].item_data.get("title") == "Album 0315", timeout=2000, ) grid.resetFilters() grid.setSort("title", reverse=True) qtbot.waitUntil( - lambda: grid.gridItems - and grid.gridItems[0].item_data.get("title") == "Album 0399", + lambda: _grid_items(grid) + and _grid_items(grid)[0].item_data.get("title") == "Album 0399", timeout=2000, ) - assert grid.gridItems[0].item_data.get("subtitle") == "Artist 08" + assert _grid_items(grid)[0].item_data.get("subtitle") == "Artist 08" def test_stale_art_results_are_ignored_after_dataset_switch(qtbot, monkeypatch): @@ -128,20 +140,50 @@ def test_stale_art_results_are_ignored_after_dataset_switch(qtbot, monkeypatch): grid.populateGrid(new_items) qtbot.waitUntil( - lambda: grid.gridItems - and grid.gridItems[0].item_data.get("title") == "Album 0200", + lambda: _grid_items(grid) + and _grid_items(grid)[0].item_data.get("title") == "Album 0200", timeout=2000, ) grid._on_art_loaded({stale_art_key: _art_result((1, 2, 3))}, stale_load_id) qtbot.wait(20) - assert grid.gridItems[0].item_data.get("title") == "Album 0200" - assert grid.gridItems[0].item_data.get("dominant_color") != (1, 2, 3) + assert _grid_items(grid)[0].item_data.get("title") == "Album 0200" + assert _grid_items(grid)[0].item_data.get("dominant_color") != (1, 2, 3) current_art_key = new_items[0]["artwork_id_ref"] grid._on_art_loaded({current_art_key: _art_result((4, 5, 6))}, grid._load_id) qtbot.waitUntil( - lambda: grid.gridItems[0].item_data.get("dominant_color") == (4, 5, 6), + lambda: _grid_items(grid)[0].item_data.get("dominant_color") == (4, 5, 6), timeout=2000, ) + + +def test_search_requeues_artwork_after_pending_request_is_invalidated(qtbot, monkeypatch): + monkeypatch.setattr(img_maker, "get_artwork", lambda *args, **kwargs: None) + + _scroll, grid = _mount_grid(qtbot) + items = _build_items(400, with_art=True) + + grid.populateGrid(items) + qtbot.waitUntil(lambda: len(grid.gridItems) > 0, timeout=2000) + + target = items[315] + art_key = target["artwork_id_ref"] + assert art_key is not None + + # Simulate an in-flight art batch from the pre-search viewport. + grid._art_pending.add(art_key) + + grid.setSearchFilter("Album 0315") + qtbot.waitUntil( + lambda: len(grid._visible_records) == 1 + and _grid_items(grid) + and _grid_items(grid)[0].item_data.get("title") == "Album 0315", + timeout=2000, + ) + + needed_keys = [record.artwork_key for record in grid._visible_records_needing_art()] + + assert art_key not in grid._art_pending + assert needed_keys == [art_key] diff --git a/tests/test_mb_list_view.py b/tests/test_mb_list_view.py index 6080ee1..8c0ecbf 100644 --- a/tests/test_mb_list_view.py +++ b/tests/test_mb_list_view.py @@ -1,4 +1,613 @@ -from GUI.widgets.MBListView import build_new_regular_playlist +from __future__ import annotations + +import json +import shutil +from dataclasses import dataclass +from pathlib import Path +from typing import Any, cast +from uuid import uuid4 + +from PyQt6.QtCore import QPoint, Qt +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QHeaderView + +from app_core.context import RuntimeSettingsService +from app_core.services import DeviceCapabilitySnapshot, DeviceIdentitySnapshot, DeviceManagerLike, DeviceSession, SettingsService, SettingsSnapshot +from GUI.widgets.MBListView import MusicBrowserList, build_new_regular_playlist +from infrastructure import settings_persistence +from infrastructure.settings_runtime import SettingsRuntime +from infrastructure.settings_schema import AppSettings, DeviceSettingsState + +_QTEST: Any = QTest + + +@dataclass +class _CancellationToken: + def is_cancelled(self) -> bool: + return False + + +class _DeviceManager: + """Mock DeviceManagerLike for testing.""" + device_changed = None + device_settings_loaded = None + device_settings_failed = None + + def __init__(self) -> None: + self.cancellation_token: _CancellationToken = _CancellationToken() + self._device_path: str | None = None + self._discovered_ipod: object | None = None + self._device_settings_loading = False + self._itunesdb_path: str | None = None + self._artworkdb_path: str | None = None + self._artwork_folder_path: str | None = None + + @property + def device_path(self) -> str | None: + return self._device_path + + @device_path.setter + def device_path(self, path: str | None) -> None: + self._device_path = path + + @property + def discovered_ipod(self) -> object | None: + return self._discovered_ipod + + @discovered_ipod.setter + def discovered_ipod(self, ipod: object | None) -> None: + self._discovered_ipod = ipod + + @property + def device_settings_loading(self) -> bool: + return self._device_settings_loading + + @property + def itunesdb_path(self) -> str | None: + return self._itunesdb_path + + @property + def artworkdb_path(self) -> str | None: + return self._artworkdb_path + + @property + def artwork_folder_path(self) -> str | None: + return self._artwork_folder_path + + def is_valid_ipod_root(self, path: str) -> bool: + return True + + def cancel_all_operations(self) -> None: + pass + + +@dataclass +class _Session: + device_path: str | None = None + itunesdb_path: str | None = None + artworkdb_path: str | None = None + artwork_folder_path: str | None = None + device_settings_loading: bool = False + discovered_ipod: object | None = None + identity: DeviceIdentitySnapshot | None = None + capabilities: DeviceCapabilitySnapshot | None = None + + @property + def has_device(self) -> bool: + return bool(self.device_path) + + +class _SettingsService: + """Mock SettingsService for testing.""" + + def __init__(self) -> None: + self._settings = AppSettings() + + def get_global_settings(self) -> AppSettings: + return self._settings + + def get_effective_settings(self) -> AppSettings: + return self._settings + + def save_global_settings(self, settings: AppSettings) -> SettingsSnapshot: + self._settings = settings + return SettingsSnapshot.from_settings(settings) + + def device_settings_key( + self, + ipod_root: str = "", + device_info: object | None = None, + ) -> str: + return "test_device_key" + + def get_device_settings_for_edit( + self, + ipod_root: str, + device_key: str = "", + ) -> DeviceSettingsState: + return DeviceSettingsState(settings=self._settings) + + def save_device_settings( + self, + ipod_root: str, + settings: AppSettings, + use_global_settings: bool = False, + device_key: str = "", + ) -> None: + pass + + def reset_device_settings_to_global( + self, + ipod_root: str, + device_key: str = "", + use_global_settings: bool = False, + ) -> AppSettings: + return self._settings + + def get_global_snapshot(self) -> SettingsSnapshot: + return SettingsSnapshot.from_settings(self._settings) + + def get_effective_snapshot(self) -> SettingsSnapshot: + return SettingsSnapshot.from_settings(self._settings) + + def reload(self) -> SettingsSnapshot: + return SettingsSnapshot.from_settings(self._settings) + + +class _DeviceSessions: + def __init__(self) -> None: + self._manager = _DeviceManager() + + def current_session(self) -> DeviceSession: + return cast(DeviceSession, _Session()) + + def manager(self) -> DeviceManagerLike: + return cast(DeviceManagerLike, self._manager) + + +class _RepoTempDir: + def __enter__(self) -> Path: + repo_root = Path(__file__).resolve().parents[1] + self.path = repo_root / ".tmp" / f"mb-list-view-{uuid4().hex}" + self.path.mkdir(parents=True, exist_ok=False) + return self.path + + def __exit__(self, exc_type, exc, tb) -> None: + shutil.rmtree(self.path, ignore_errors=True) + + +def _tracks_for_music() -> list[dict[str, object]]: + return [ + { + "Title": "Song A", + "Artist": "Artist A", + "Album": "Album A", + "Genre": "Rock", + "year": 2001, + "track_number": 1, + "length": 180000, + "rating": 80, + "play_count_1": 5, + "date_added": 1710000000, + } + ] + + +def _tracks_for_album_filters() -> list[dict[str, object]]: + return [ + { + "Title": "Alpha One", + "Artist": "Artist A", + "Album": "Album A", + "Genre": "Rock", + "year": 2001, + "track_number": 1, + "length": 180000, + "rating": 80, + "play_count_1": 5, + "date_added": 1710000000, + }, + { + "Title": "Alpha Two", + "Artist": "Artist A", + "Album": "Album A", + "Genre": "Rock", + "year": 2001, + "track_number": 2, + "length": 181000, + "rating": 80, + "play_count_1": 6, + "date_added": 1710000100, + }, + { + "Title": "Beta One", + "Artist": "Artist B", + "Album": "Album B", + "Genre": "Jazz", + "year": 2002, + "track_number": 1, + "length": 190000, + "rating": 60, + "play_count_1": 2, + "date_added": 1710000200, + }, + { + "Title": "Beta Two", + "Artist": "Artist B", + "Album": "Album B", + "Genre": "Jazz", + "year": 2002, + "track_number": 2, + "length": 191000, + "rating": 60, + "play_count_1": 3, + "date_added": 1710000300, + }, + ] + + +def _tracks_for_video() -> list[dict[str, object]]: + return [ + { + "Title": "Video A", + "Artist": "Director A", + "Album": "Collection A", + "length": 240000, + "media_type": 0x02, + "size": 900_000_000, + "bitrate": 2400, + "date_added": 1711000000, + "rating": 60, + "play_count_1": 2, + } + ] + + +def _mount_list( + qtbot, + settings_service: SettingsService | None = None, +) -> MusicBrowserList: + view = MusicBrowserList( + settings_service=settings_service or _SettingsService(), + device_sessions=_DeviceSessions(), + show_art_override=False, + ) + qtbot.addWidget(view) + view.resize(900, 500) + view.show() + qtbot.wait(50) + return view + + +def _load_content( + qtbot, + view: MusicBrowserList, + *, + tracks: list[dict[str, object]], + media_type_filter: int | None, +) -> None: + view.clearTable() + view._all_tracks = tracks + view._tracks = tracks + view._media_type_filter = media_type_filter + view._is_playlist_mode = False + view._setup_columns() + view._populate_table() + qtbot.waitUntil(lambda: view.table.rowCount() == len(tracks), timeout=2000) + + +def _visible_column_order(view: MusicBrowserList) -> list[str]: + header = view.table.horizontalHeader() + assert header is not None + result: list[str] = [] + for visual_index in range(view.table.columnCount()): + col_key = view._col_key_at(visual_index) + if col_key is not None: + result.append(col_key) + return result + + +def _drag_header_section( + view: MusicBrowserList, + *, + source_visual: int, + target_visual: int, +) -> None: + header = view.table.horizontalHeader() + assert header is not None + viewport = header.viewport() + assert viewport is not None + source_x = header.sectionPosition(source_visual) + (header.sectionSize(source_visual) // 2) + target_x = header.sectionPosition(target_visual) + 5 + center_y = header.height() // 2 + _QTEST.mousePress( + viewport, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + QPoint(source_x, center_y), + delay=10, + ) + _QTEST.mouseMove(viewport, QPoint(target_x, center_y), delay=10) + _QTEST.mouseRelease( + viewport, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + QPoint(target_x, center_y), + delay=10, + ) + + +def _resize_header_section( + view: MusicBrowserList, + *, + visual_index: int, + delta_x: int, +) -> int: + header = view.table.horizontalHeader() + assert header is not None + viewport = header.viewport() + assert viewport is not None + edge_x = header.sectionPosition(visual_index) + header.sectionSize(visual_index) - 1 + center_y = header.height() // 2 + _QTEST.mousePress( + viewport, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + QPoint(edge_x, center_y), + delay=10, + ) + _QTEST.mouseMove(viewport, QPoint(edge_x + delta_x, center_y), delay=10) + _QTEST.mouseRelease( + viewport, + Qt.MouseButton.LeftButton, + Qt.KeyboardModifier.NoModifier, + QPoint(edge_x + delta_x, center_y), + delay=10, + ) + return header.sectionSize(visual_index) + + +def test_column_layout_persists_per_content_type(qtbot): + view = _mount_list(qtbot) + + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + + header = view.table.horizontalHeader() + assert header is not None + assert header.sectionResizeMode(0) == QHeaderView.ResizeMode.Interactive + + header.moveSection(2, 1) + view._on_header_section_moved(2, 2, 1) + view.table.setColumnWidth(0, 260) + qtbot.waitUntil( + lambda: list( + view._settings_service.get_global_settings() + .track_list_columns_by_content.get("music", {}) + )[:3] + == ["Title", "Album", "Artist"], + timeout=2000, + ) + + saved_music = view._settings_service.get_global_settings().track_list_columns_by_content["music"] + assert list(saved_music)[:3] == ["Title", "Album", "Artist"] + assert saved_music["Title"] == 260 + + _load_content(qtbot, view, tracks=_tracks_for_video(), media_type_filter=0x02) + header = view.table.horizontalHeader() + assert header is not None + header.moveSection(5, 4) + view._on_header_section_moved(5, 5, 4) + qtbot.waitUntil( + lambda: list( + view._settings_service.get_global_settings() + .track_list_columns_by_content.get("video", {}) + )[:6] + == [ + "Title", + "Artist", + "Album", + "length", + "size", + "media_type", + ], + timeout=2000, + ) + + saved_video = view._settings_service.get_global_settings().track_list_columns_by_content["video"] + assert list(saved_video)[:6] == [ + "Title", + "Artist", + "Album", + "length", + "size", + "media_type", + ] + + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + view._save_user_widths() + + assert view._user_col_order is not None + assert view._user_col_order[:3] == ["Title", "Album", "Artist"] + assert view._user_col_widths is not None + assert view._user_col_widths["Title"] == 260 + + +def test_album_navigation_preserves_user_column_order(qtbot): + view = _mount_list(qtbot) + tracks = _tracks_for_album_filters() + + _load_content(qtbot, view, tracks=tracks, media_type_filter=0x01) + + header = view.table.horizontalHeader() + assert header is not None + header.moveSection(2, 1) + view._on_header_section_moved(2, 2, 1) + + qtbot.waitUntil( + lambda: list( + view._settings_service.get_global_settings() + .track_list_columns_by_content.get("music", {}) + )[:3] + == ["Title", "Album", "Artist"], + timeout=2000, + ) + + initial_order = _visible_column_order(view) + assert initial_order[:3] == ["Title", "Album", "Artist"] + + view.applyFilter({"filter_key": "Album", "filter_value": "Album B"}) + qtbot.waitUntil(lambda: view.table.rowCount() == 2, timeout=2000) + assert _visible_column_order(view)[:3] == ["Title", "Album", "Artist"] + + view.applyFilter({"filter_key": "Album", "filter_value": "Album A"}) + qtbot.waitUntil(lambda: view.table.rowCount() == 2, timeout=2000) + assert _visible_column_order(view)[:3] == ["Title", "Album", "Artist"] + + +def test_column_width_changes_debounced(qtbot): + """Test that multiple rapid width changes are debounced before saving to settings.""" + view = _mount_list(qtbot) + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + + header = view.table.horizontalHeader() + assert header is not None + + # Simulate rapid drag resizes + original_artist_width = header.sectionSize(1) + + # Simulate multiple resize events (like during a drag) + final_artist_width = original_artist_width + for i in range(10): + final_artist_width = original_artist_width + 10 + i + view.table.setColumnWidth(1, final_artist_width) + view._on_header_section_resized(1, original_artist_width, final_artist_width) + + # Wait for debounce timeout to complete + qtbot.waitUntil( + lambda: "music" + in view._settings_service.get_global_settings().track_list_columns_by_content, + timeout=2000, + ) + + # Verify settings are saved + saved_music = view._settings_service.get_global_settings().track_list_columns_by_content["music"] + assert saved_music["Artist"] == final_artist_width + + +def test_flush_pending_column_changes(qtbot): + """Test that flush_pending_column_changes() immediately saves pending changes.""" + view = _mount_list(qtbot) + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + + header = view.table.horizontalHeader() + assert header is not None + + # Make a column change + header.moveSection(2, 1) + view._on_header_section_moved(2, 2, 1) + + # Don't wait for the debounce timer, instead flush immediately + view.flush_pending_column_changes() + + # Settings should be saved immediately + saved_music = view._settings_service.get_global_settings().track_list_columns_by_content["music"] + assert list(saved_music)[:3] == ["Title", "Album", "Artist"] + + +def test_hideEvent_flushes_pending_changes(qtbot): + """Test that pending column changes are flushed when widget is hidden.""" + view = _mount_list(qtbot) + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + + header = view.table.horizontalHeader() + assert header is not None + + # Make a column change + header.moveSection(2, 1) + view._on_header_section_moved(2, 2, 1) + + # Simulate widget being hidden (should trigger flush) + view.hide() + + # Settings should be saved + saved_music = view._settings_service.get_global_settings().track_list_columns_by_content["music"] + assert list(saved_music)[:3] == ["Title", "Album", "Artist"] + + +def test_human_drag_reorder_persists_to_settings_file_without_force_flush( + qtbot, + monkeypatch, +): + with _RepoTempDir() as tmp_path: + settings_dir = tmp_path / "settings" + settings_path = settings_dir / "settings.json" + monkeypatch.setattr( + settings_persistence, + "default_settings_dir", + lambda: str(settings_dir), + ) + monkeypatch.setattr( + settings_persistence, + "get_settings_path", + lambda: str(settings_path), + ) + + service = RuntimeSettingsService(runtime=SettingsRuntime()) + view = _mount_list(qtbot, settings_service=service) + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + + header = view.table.horizontalHeader() + assert header is not None + header.moveSection(2, 1) + view._on_header_section_moved(2, 2, 1) + + qtbot.waitUntil(settings_path.exists, timeout=2000) + qtbot.waitUntil( + lambda: list( + json.loads(settings_path.read_text(encoding="utf-8")) + .get("track_list_columns_by_content", {}) + .get("music", {}) + )[:3] + == ["Title", "Album", "Artist"], + timeout=2000, + ) + + +def test_human_resize_persists_to_settings_file_without_force_flush( + qtbot, + monkeypatch, +): + with _RepoTempDir() as tmp_path: + settings_dir = tmp_path / "settings" + settings_path = settings_dir / "settings.json" + monkeypatch.setattr( + settings_persistence, + "default_settings_dir", + lambda: str(settings_dir), + ) + monkeypatch.setattr( + settings_persistence, + "get_settings_path", + lambda: str(settings_path), + ) + + service = RuntimeSettingsService(runtime=SettingsRuntime()) + view = _mount_list(qtbot, settings_service=service) + _load_content(qtbot, view, tracks=_tracks_for_music(), media_type_filter=0x01) + + resized_width = _resize_header_section(view, visual_index=0, delta_x=40) + + qtbot.waitUntil(settings_path.exists, timeout=2000) + qtbot.waitUntil( + lambda: ( + json.loads(settings_path.read_text(encoding="utf-8")) + .get("track_list_columns_by_content", {}) + .get("music", {}) + .get("Title") + ) + == resized_width, + timeout=2000, + ) def test_build_new_regular_playlist_marks_payload_as_new_regular_playlist() -> None: diff --git a/tests/test_scrobbling.py b/tests/test_scrobbling.py new file mode 100644 index 0000000..bdfe7e7 --- /dev/null +++ b/tests/test_scrobbling.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from SyncEngine.fingerprint_diff_engine import SyncAction, SyncItem, SyncPlan +from SyncEngine.mapping import MappingFile +from SyncEngine.pc_library import PCTrack +from SyncEngine.scrobbler import ( + IMPORT_SERVICE, + RateLimitInfo, + ScrobbleAborted, + ScrobbleEntry, + ScrobbleResult, + _build_listen_payload, + build_scrobble_entries, + get_latest_import, + scrobble_listenbrainz, + set_latest_import, +) +from SyncEngine.sync_executor import SyncExecutor, _SyncContext + + +def _build_scrobble_context(*, progress_log: list | None = None) -> _SyncContext: + plan = SyncPlan( + to_sync_playcount=[ + SyncItem( + action=SyncAction.SYNC_PLAYCOUNT, + play_count_delta=1, + description="+1 play: Artist - Song", + ) + ] + ) + return _SyncContext( + plan=plan, + mapping=MappingFile(), + progress_callback=(progress_log.append if progress_log is not None else None), + dry_run=False, + write_back_to_pc=False, + _is_cancelled=None, + scrobble_on_sync=True, + listenbrainz_token="token", + listenbrainz_username="TheRealSavi", + ) + + +def test_build_scrobble_entries_use_playback_start_time() -> None: + item = SyncItem( + action=SyncAction.SYNC_PLAYCOUNT, + play_count_delta=2, + pc_track=PCTrack( + path="/tmp/track.mp3", + relative_path="track.mp3", + filename="track.mp3", + extension=".mp3", + mtime=0.0, + size=1234, + artist="Artist", + title="Track", + album="Album", + album_artist="Album Artist", + genre="Rock", + year=None, + track_number=3, + track_total=None, + disc_number=1, + disc_total=None, + duration_ms=240_000, + bitrate=None, + sample_rate=None, + rating=None, + ), + ipod_track={"last_played": 1_700_000_000}, + ) + + entries = build_scrobble_entries([item]) + + assert [entry.timestamp for entry in entries] == [ + 1_700_000_000 - 480, + 1_700_000_000 - 240, + ] + + +def test_execute_scrobble_reports_listenbrainz_errors( + monkeypatch, + tmp_path: Path, +) -> None: + import SyncEngine.scrobbler as scrobbler + + progress_log = [] + ctx = _build_scrobble_context(progress_log=progress_log) + executor = SyncExecutor(tmp_path) + + def fake_scrobble_plays(*args, **kwargs): + return [ScrobbleResult(errors=["HTTP 400: invalid payload"])] + + monkeypatch.setattr(scrobbler, "scrobble_plays", fake_scrobble_plays) + + ok = executor._execute_scrobble(ctx) + + assert ok is False + assert ctx.result.scrobbles_submitted == 0 + assert ctx.result.errors == [ + ("scrobble", "listenbrainz: HTTP 400: invalid payload") + ] + assert progress_log[-1].stage == "scrobble" + assert progress_log[-1].message == ( + "ListenBrainz did not accept any plays from this sync." + ) + + +def test_build_listen_payload_omits_music_service_for_local_collection() -> None: + payload = _build_listen_payload( + ScrobbleEntry( + artist="Artist", + track="Track", + album="Album", + duration_secs=240, + timestamp=1_700_000_000, + ) + ) + + additional_info = payload["track_metadata"]["additional_info"] + assert "music_service_name" not in additional_info + assert additional_info["submission_client"] == "iOpenPod" + assert additional_info["media_player"] == "iPod" + + +def test_latest_import_requests_are_scoped_to_iopenpod(monkeypatch) -> None: + requests: list[tuple[str, str, dict | None, bytes | None]] = [] + + def fake_make_request(method, path, token="", body=None, params=None, **kwargs): + requests.append((method, path, params, body)) + if method == "GET": + return {"latest_import": 123}, RateLimitInfo() + return {"status": "ok"}, RateLimitInfo() + + monkeypatch.setattr("SyncEngine.scrobbler._make_request", fake_make_request) + + assert get_latest_import("TheRealSavi", "token") == 123 + assert set_latest_import(456, "token") is True + + assert requests[0] == ( + "GET", + "/1/latest-import", + {"user_name": "TheRealSavi", "service": IMPORT_SERVICE}, + None, + ) + assert requests[1][0:2] == ("POST", "/1/latest-import") + assert requests[1][2] is None + assert requests[1][3] == b'{"ts": 456, "service": "iopenpod"}' + + +def test_scrobble_listenbrainz_skips_entries_covered_by_latest_import( + monkeypatch, +) -> None: + submitted_payloads: list[list[dict]] = [] + latest_import = 1_700_000_000 + + def fake_get_latest_import( + username, + token="", + service=IMPORT_SERVICE, + **kwargs, + ): + assert username == "TheRealSavi" + assert service == IMPORT_SERVICE + return latest_import + + def fake_set_latest_import(ts, token, service=IMPORT_SERVICE, **kwargs): + assert ts == latest_import + 100 + assert service == IMPORT_SERVICE + return True + + def fake_make_request(method, path, token="", body=None, params=None, **kwargs): + assert method == "POST" + assert path == "/1/submit-listens" + assert body is not None + submitted_payloads.append(json.loads(body.decode("utf-8"))["payload"]) + return {"status": "ok"}, RateLimitInfo(remaining=10, reset_in=0.0) + + monkeypatch.setattr("SyncEngine.scrobbler.get_latest_import", fake_get_latest_import) + monkeypatch.setattr("SyncEngine.scrobbler.set_latest_import", fake_set_latest_import) + monkeypatch.setattr("SyncEngine.scrobbler._make_request", fake_make_request) + + result = scrobble_listenbrainz( + [ + ScrobbleEntry("Artist", "Old", "Album", 240, latest_import), + ScrobbleEntry("Artist", "New", "Album", 240, latest_import + 100), + ], + "token", + listenbrainz_username="TheRealSavi", + ) + + assert result.submitted == 1 + assert result.accepted == 1 + assert result.ignored == 1 + assert len(submitted_payloads) == 1 + assert [listen["track_metadata"]["track_name"] for listen in submitted_payloads[0]] == ["New"] + + +def test_scrobble_listenbrainz_returns_user_gave_up_when_latest_import_aborts( + monkeypatch, +) -> None: + def fake_get_latest_import(*args, **kwargs): + raise ScrobbleAborted("User gave up while connecting to ListenBrainz") + + monkeypatch.setattr("SyncEngine.scrobbler.get_latest_import", fake_get_latest_import) + + result = scrobble_listenbrainz( + [ScrobbleEntry("Artist", "Track", "Album", 240, 1_700_000_100)], + "token", + listenbrainz_username="TheRealSavi", + ) + + assert result.submitted == 0 + assert result.accepted == 0 + assert result.errors == ["User gave up while connecting to ListenBrainz"] + + +def test_scrobble_listenbrainz_reports_latest_import_update_failure( + monkeypatch, +) -> None: + def fake_make_request(method, path, token="", body=None, params=None, **kwargs): + assert method == "POST" + assert path == "/1/submit-listens" + return {"status": "ok"}, RateLimitInfo(remaining=10, reset_in=0.0) + + monkeypatch.setattr("SyncEngine.scrobbler._make_request", fake_make_request) + monkeypatch.setattr("SyncEngine.scrobbler.set_latest_import", lambda *args, **kwargs: False) + + result = scrobble_listenbrainz( + [ScrobbleEntry("Artist", "Track", "Album", 240, 1_700_000_100)], + "token", + ) + + assert result.submitted == 1 + assert result.accepted == 1 + assert result.errors == [ + "Latest-import timestamp could not be updated; future duplicate protection may be affected" + ] + + +def test_write_finalize_scrobbles_before_deleting_playcounts( + monkeypatch, + tmp_path: Path, +) -> None: + import SyncEngine.sync_executor as sync_executor + + order: list[str] = [] + ctx = _build_scrobble_context() + executor = SyncExecutor(tmp_path) + + monkeypatch.setattr(executor, "_write_database", lambda *args, **kwargs: True) + monkeypatch.setattr(executor, "_backpatch_new_tracks", lambda ctx: None) + monkeypatch.setattr(executor.mapping_manager, "save", lambda mapping: None) + monkeypatch.setattr(executor, "_update_podcast_subscriptions", lambda ctx: None) + monkeypatch.setattr(executor, "_clear_gui_cache", lambda ctx: None) + monkeypatch.setattr(executor, "_apply_itunes_protections", lambda ctx, tracks: None) + monkeypatch.setattr(executor, "_build_and_evaluate_playlists", lambda ctx, tracks: ("iPod", [], [])) + monkeypatch.setattr(sync_executor, "read_photo_db", lambda path: None) + monkeypatch.setattr(executor, "_execute_scrobble", lambda ctx: order.append("scrobble") or True) + monkeypatch.setattr(executor, "_delete_playcounts_file", lambda: order.append("delete")) + + executor._execute_write_and_finalize(ctx) + + assert order == ["scrobble", "delete"] diff --git a/tests/test_settings_persistence.py b/tests/test_settings_persistence.py index 8b8a218..a326be7 100644 --- a/tests/test_settings_persistence.py +++ b/tests/test_settings_persistence.py @@ -35,6 +35,11 @@ def test_settings_persistence_round_trip(monkeypatch) -> None: settings = AppSettings( media_folder="C:/Music", + rounded_artwork=True, + sharpen_artwork=False, + track_list_columns_by_content={ + "music": {"Title": 220, "Album": 180, "Artist": 160} + }, window_width=1440, device_write_workers=2, ) @@ -43,5 +48,10 @@ def test_settings_persistence_round_trip(monkeypatch) -> None: loaded = load_app_settings() assert loaded.media_folder == "C:/Music" + assert loaded.rounded_artwork is True + assert loaded.sharpen_artwork is False + assert loaded.track_list_columns_by_content == { + "music": {"Title": 220, "Album": 180, "Artist": 160} + } assert loaded.window_width == 1440 assert loaded.device_write_workers == 2 diff --git a/tests/test_settings_snapshot.py b/tests/test_settings_snapshot.py index 572de8e..9fa3275 100644 --- a/tests/test_settings_snapshot.py +++ b/tests/test_settings_snapshot.py @@ -11,6 +11,11 @@ def test_settings_snapshot_copies_values_and_freezes_lists() -> None: media_folder="C:/Music", theme="light", accent_color="#123456", + rounded_artwork=True, + sharpen_artwork=False, + track_list_columns_by_content={ + "music": {"Title": 240, "Album": 180, "Artist": 160} + }, device_write_workers=2, splitter_sizes=[300, 700], window_width=1440, @@ -22,10 +27,18 @@ def test_settings_snapshot_copies_values_and_freezes_lists() -> None: assert snapshot.media_folder == "C:/Music" assert snapshot.theme == "light" assert snapshot.accent_color == "#123456" + assert snapshot.rounded_artwork is True + assert snapshot.sharpen_artwork is False + assert snapshot.track_list_columns_by_content == { + "music": {"Title": 240, "Album": 180, "Artist": 160} + } assert snapshot.device_write_workers == 2 assert snapshot.splitter_sizes == (300, 700) assert snapshot.window_width == 1440 assert snapshot.window_height == 900 + settings.track_list_columns_by_content["music"]["year"] = 120 + assert "year" not in snapshot.track_list_columns_by_content["music"] + with pytest.raises(FrozenInstanceError): snapshot.theme = "dark" # type: ignore[misc] diff --git a/uv.lock b/uv.lock index 6eb96c2..34bcba4 100644 --- a/uv.lock +++ b/uv.lock @@ -138,7 +138,7 @@ wheels = [ [[package]] name = "iopenpod" -version = "1.0.49" +version = "1.0.51" source = { editable = "." } dependencies = [ { name = "certifi" },