diff --git a/GUI/app.py b/GUI/app.py index bb429ff..330958e 100644 --- a/GUI/app.py +++ b/GUI/app.py @@ -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 @@ -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 @@ -443,6 +451,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 @@ -472,7 +481,15 @@ def onDeviceChanged(self, path: str): thread_pool.clear() from .imgMaker import clear_artwork_api + from .widgets.MBGridViewItem import clear_pixmap_cache 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) @@ -517,6 +534,39 @@ 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 @@ -755,7 +805,9 @@ def _settle_background_device_reads_for_eject(self) -> bool: try: from .imgMaker import clear_artwork_api + from .widgets.MBGridViewItem import clear_pixmap_cache clear_artwork_api() + clear_pixmap_cache() except Exception: logger.debug("Failed to clear artwork cache before eject", exc_info=True) @@ -982,6 +1034,7 @@ def startPCSync(self): pc_folder=self._last_pc_folder, ipod_tracks=ipod_tracks, ipod_path=device_manager.device_path or "", + ipod_hdd=self._get_ipod_hdd_tag(), ), artwork_provider=self._create_back_sync_artwork_provider( device_manager.device_path or "", @@ -1041,6 +1094,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) @@ -1213,9 +1267,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) @@ -1278,6 +1336,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) @@ -1430,6 +1489,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) @@ -1487,7 +1547,9 @@ def _rescanAfterSync(self): # Clear artwork cache — sync may have added/changed album art from .imgMaker import clear_artwork_api + from .widgets.MBGridViewItem import clear_pixmap_cache clear_artwork_api() + clear_pixmap_cache() # Clear UI so the reload starts from a clean slate self.musicBrowser.reloadData() 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 d0681e8..afc4b35 100644 --- a/GUI/widgets/MBGridView.py +++ b/GUI/widgets/MBGridView.py @@ -1,17 +1,18 @@ import difflib import logging -from collections.abc import Hashable +from collections.abc import Hashable, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any 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. @@ -84,10 +85,12 @@ def __init__( *, 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._settings_service = settings_service self._current_category = "Albums" @@ -128,7 +131,10 @@ def loadCategory(self, category: str) -> None: self._set_source_items(items, reset_scroll=True) - def populateGrid(self, items: list[dict[str, Any] | MusicBrowserGridItem]) -> None: + 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: @@ -233,6 +239,9 @@ def _set_source_items( 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: @@ -302,6 +311,7 @@ def _bind_widget( ) -> 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: @@ -344,7 +354,11 @@ def _load_cached_artwork( if cached is None: return _ART_CACHE_UNSET - image, dominant_color, album_colors = cached + 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) def _apply_art_to_widget( @@ -423,8 +437,8 @@ def _load_art_async(self) -> None: ) pool.start(worker) - @staticmethod def _load_art_batch( + self, pairs: list[tuple[Hashable, int]], artworkdb_path: str, artwork_folder: str, @@ -448,12 +462,15 @@ def _load_art_batch( for art_key, link in pairs: if cancellation_token.is_cancelled(): break - result = get_artwork(link, mode="with_colors") - if result is None: + image = get_artwork(link, mode="image_only") + if image is None: results[art_key] = None continue - pil_img, dominant_color, album_colors = result + 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, @@ -512,3 +529,26 @@ def _apply_art_to_visible_widgets(self, artwork_key: Hashable) -> None: def _onItemClicked(self, item_data: dict) -> None: self.item_selected.emit(item_data) + + 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) + + 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 _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 db854f4..280956b 100644 --- a/GUI/widgets/MBGridViewItem.py +++ b/GUI/widgets/MBGridViewItem.py @@ -1,5 +1,7 @@ from collections.abc import Mapping +from collections import OrderedDict from dataclasses import dataclass +import threading from typing import Any from PIL import Image @@ -7,6 +9,37 @@ 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() + + +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() + +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 ( @@ -67,6 +100,7 @@ def __init__(self): 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)) @@ -178,16 +212,29 @@ def _render_placeholder(self) -> None: def _render_image(self, pil_image: Image.Image) -> None: """Render a PIL image into the artwork label.""" - 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, - ) + 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; @@ -308,6 +355,15 @@ def apply_image_result( self._render_model() + 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) 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/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/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 5e48bac..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( @@ -1179,16 +1193,32 @@ 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, self.accent_color, self.font_scale, self.show_art, + self.rounded_artwork, + self.sharpen_artwork, ) self._about_card = _SettingsCard( self.version_row, @@ -1200,6 +1230,8 @@ def _build_general_page(self) -> QScrollArea: "General", "Manage", self._manage_card, + "Device", + self._device_card, "Appearance", self._appearance_card, "About", @@ -1711,9 +1743,18 @@ def _apply_scope_visibility(self) -> None: self._manage_card.setVisible(device_scope) self._set_section_visible("General", "Manage", device_scope) + + # 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) + 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) + 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) @@ -1789,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 = { @@ -1941,6 +1984,21 @@ def load_from_settings(self): if idx >= 0: self.device_write_workers.combo.setCurrentIndex(idx) + # Storage type — per-device tag, not in 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( + bool(session.device_path) + ) + except Exception: + self.storage_type_row.combo.setEnabled(False) + self._apply_scope_visibility() # Connect signals to auto-save (only once) @@ -1976,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) @@ -2192,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", @@ -2317,12 +2379,31 @@ 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: 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, @@ -2342,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() @@ -2354,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: @@ -2369,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 2f1d94c..719bb90 100644 --- a/GUI/widgets/syncReview.py +++ b/GUI/widgets/syncReview.py @@ -10,14 +10,29 @@ from __future__ import annotations -from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QRectF +import html +import logging +import os +import shutil +import time +from typing import TYPE_CHECKING, Any + +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 ( @@ -34,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__) @@ -102,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 @@ -224,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) @@ -257,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)) @@ -481,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)) @@ -555,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) @@ -615,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}; @@ -767,6 +778,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 @@ -910,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() @@ -926,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) @@ -936,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;") @@ -958,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"), @@ -991,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) @@ -1006,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)) @@ -1031,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)) @@ -1047,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 @@ -1060,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) @@ -1088,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 @@ -1196,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) @@ -1302,8 +1314,19 @@ def _set_footer_for_state(self, state: str): self.cancel_btn.setText("Done") self.cancel_btn.setEnabled(True) + def _format_elapsed(self) -> str: + """Return 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 +1339,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 +1370,13 @@ 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("") + self.eta_label.setText(self._format_elapsed()) def show_plan(self, plan: Any): """Display the sync plan as styled category cards.""" @@ -1484,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") @@ -1910,6 +1937,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 = [] @@ -1997,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) @@ -2005,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") @@ -2052,22 +2080,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.""" @@ -2143,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"" @@ -2193,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): @@ -2368,7 +2439,7 @@ def _on_cancel_clicked(self): 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.cancel_btn.setText("Stopping retries…") self.give_up_scrobble_signal.emit() else: # Full cancel @@ -2579,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}; @@ -2592,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)) @@ -2604,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) @@ -2665,7 +2736,7 @@ class PCFolderDialog(QDialog): 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 @@ -2696,7 +2767,7 @@ def __init__(self, parent=None, last_folder: str = ""): def _setup_ui(self): layout = QVBoxLayout(self) - layout.setSpacing((12)) + layout.setSpacing(12) layout.setContentsMargins((20), (16), (20), (16)) # Title @@ -2752,7 +2823,7 @@ def _setup_ui(self): layout.addLayout(folder_layout) - layout.addSpacing((8)) + layout.addSpacing(8) # Buttons btn_row = QHBoxLayout() 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/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/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/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/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 c9cdc46..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 ────────────────────────────────────────── @@ -191,6 +192,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 +220,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 +247,20 @@ 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. + 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 @@ -266,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, @@ -278,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, ) @@ -296,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: @@ -322,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, ) @@ -769,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))) @@ -1632,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...") @@ -1662,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)." ), @@ -1679,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) @@ -1701,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." + ), + ) + 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." ), ) - return + + 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 897400e..a764df2 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): @@ -598,6 +607,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 @@ -692,27 +702,44 @@ def _fp_pc(path: str) -> str | None: 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 + + 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: - fp = None ipod_fingerprint_errors.append(f"{title}: {exc}") - if fp and fp not in pc_fps: - to_export.append((track, ipod_file)) - self.progress.emit( - "backsync_ipod_fingerprint", - idx, - total_ipod, - ( - f"{idx:,}/{total_ipod:,} checked - " - f"{len(to_export):,} missing so far - " - f"{self._short_label(title)}" - ), - ) + 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) @@ -783,6 +810,7 @@ def _fp_pc(path: str) -> str | None: ), ) + _finished_emitted = True self.finished.emit( { "pc_scanned": total_pc, @@ -801,10 +829,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: @@ -1557,6 +1590,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 @@ -1567,6 +1601,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] @@ -1621,6 +1656,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 @@ -1650,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, ) @@ -1687,6 +1724,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/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/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..62631cc 100644 --- a/infrastructure/settings_runtime.py +++ b/infrastructure/settings_runtime.py @@ -57,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" },