diff --git a/config.py b/config.py index 9c2d400..a9d445c 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ """Tunetti configuration — stored at ~/.config/tunetti/config.json .""" import json +import shutil from pathlib import Path # ── Config file location (XDG compliant) ────────────────────────────────── @@ -42,7 +43,6 @@ def _migrate_legacy() -> None: if old_db_path.parent == Path(__file__).parent and old_db_path.exists(): new_db_path = CONFIG_DIR / "music_history.db" if not new_db_path.exists(): - import shutil shutil.copy2(str(old_db_path), str(new_db_path)) old["db_path"] = str(new_db_path) diff --git a/discord_rpc.py b/discord_rpc.py index 3973d14..8662e85 100644 --- a/discord_rpc.py +++ b/discord_rpc.py @@ -116,7 +116,7 @@ class DiscordRPC: log.warning("Discord not running – RPC unavailable") self._connected = False return False - except Exception as exc: + except (OSError, RuntimeError, DiscordNotFound) as exc: log.debug("Discord RPC connect failed: %s", exc) self._connected = False return False @@ -125,7 +125,7 @@ class DiscordRPC: if self._rpc: try: self._rpc.close() - except Exception: + except (OSError, RuntimeError): pass self._rpc = None self._connected = False @@ -139,7 +139,7 @@ class DiscordRPC: if self._connected and self._rpc is not None: try: self._rpc.clear() - except Exception: + except (OSError, RuntimeError): self._connected = False return @@ -162,7 +162,7 @@ class DiscordRPC: start=start, end=end_ts, ) - except Exception as exc: + except (OSError, RuntimeError) as exc: log.debug("RPC update failed: %s", exc) self._connected = False diff --git a/gui.py b/gui.py index 4813783..e1922a9 100644 --- a/gui.py +++ b/gui.py @@ -9,7 +9,6 @@ import logging import sys import math from pathlib import Path -from typing import Optional # ═══════════════════════════════════════════════════════════════════════════ # Qt imports are guarded so the module can be imported for its pure helper @@ -169,7 +168,10 @@ def _load_thumbnail(video_id: str, thumb_url: str, nam = _get_thumbnail_nam() reply = nam.get(QNetworkRequest(QUrl(thumb_url))) - def _on_downloaded(): + def _on_downloaded(reply=reply, nam=nam, cache_path=cache_path, + art_label=art_label, size=size, + style_pass=style_pass, style_fail=style_fail, + video_id=video_id): # The label may have been deleted (e.g. search results refreshed) # while the network request was in flight — guard against that. try: @@ -408,14 +410,17 @@ class SongItemWidget(QWidget): # flooding the connection when many items appear at once (e.g. search). # Guard with try/except because the widget may be deleted before the # timer fires (search results refreshed, etc.). + self._deferred_song = song if _cached_thumb_path(self._video_id).exists(): self._load_art(self._art, song) else: - QTimer.singleShot(500, lambda: self._deferred_load_art(song)) + QTimer.singleShot(500, self._load_deferred_art) - def _deferred_load_art(self, song: dict) -> None: + def _load_deferred_art(self) -> None: try: - self._load_art(self._art, song) + song = self._deferred_song + if song: + self._load_art(self._art, song) except RuntimeError: pass # widget was deleted @@ -589,6 +594,10 @@ class AudioVisualizer(QWidget): * Idle "breathing" animation when audio is playing but no buffer arrives. """ + def reset_activity(self) -> None: + """Reset the last activity counter (called on seek).""" + self._last_activity = 0 + # Windows XP marquee loading pattern — sliding 4-bar block _LOADING_PATTERN = [0.55, 0.85, 0.85, 0.55] @@ -1280,7 +1289,8 @@ class PlaybackBar(QWidget): self._progress.setEnabled(False) self._visualizer.set_playing(False) self._visualizer.set_loading(False) - QTimer.singleShot(4000, lambda: self._clear_error()) + # Use a weak-bound method ref so the timer doesn't keep self alive. + QTimer.singleShot(4000, self._clear_error) def _clear_error(self) -> None: """Restore the "Not playing" text after an error.""" @@ -1392,8 +1402,11 @@ class PlaybackBar(QWidget): # to the new audio section instead of decaying through idle. if hasattr(self, "_band_peaks"): self._band_peaks = [0.001] * len(self._band_peaks) - self._visualizer._last_activity = 0 - QTimer.singleShot(200, lambda: setattr(self, "_updating_progress", False)) + self._visualizer.reset_activity() + QTimer.singleShot(200, self._clear_seek_flag) + + def _clear_seek_flag(self) -> None: + self._updating_progress = False def _toggle_mute(self) -> None: if self._player.volume() > 0: @@ -1431,9 +1444,7 @@ class PlaybackBar(QWidget): self._vol_save_timer = QTimer(self) self._vol_save_timer.setSingleShot(True) self._vol_save_timer.setInterval(500) - self._vol_save_timer.timeout.connect( - lambda: save_volume(self._saved_volume) - ) + self._vol_save_timer.timeout.connect(self.flush_volume) self._vol_save_timer.start() # ── Visualizer data feeding ── @@ -1597,7 +1608,7 @@ class PlaybackBar(QWidget): return result - def _flush_volume(self) -> None: + def flush_volume(self) -> None: """Immediately persist the current volume, bypassing the debounce timer.""" if hasattr(self, "_vol_save_timer") and self._vol_save_timer.isActive(): self._vol_save_timer.stop() @@ -1814,6 +1825,7 @@ class SearchPage(QWidget): btn = QPushButton("⋯ Show more") btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setFixedHeight(38) + btn.setProperty("category", category) btn.setStyleSheet(""" QPushButton { border: none; @@ -1829,9 +1841,17 @@ class SearchPage(QWidget): color: #a5b4fc; } """) - btn.clicked.connect(lambda: self._toggle_expand(category)) + btn.clicked.connect(self._on_show_more_clicked) self._results_layout.addWidget(btn) + def _on_show_more_clicked(self) -> None: + btn = self.sender() + if btn is None: + return + category = btn.property("category") + if isinstance(category, str): + self._toggle_expand(category) + def _toggle_expand(self, category: str) -> None: if category == "videos": self._videos_expanded = True @@ -2194,7 +2214,7 @@ class SettingsDialog(QDialog): def _on_visualizer_toggled(self, enabled: bool) -> None: save_setting("visualizer_enabled", enabled) - viz = self._main_window._visualizer + viz = self._main_window.get_visualizer() if enabled: viz.setVisible(True) viz.setFixedHeight(44) @@ -2207,13 +2227,13 @@ class SettingsDialog(QDialog): def _on_discord_toggled(self, enabled: bool) -> None: save_setting("discord_rpc_enabled", enabled) if enabled: - self._main_window.rpc.start() + self._main_window.get_rpc().start() # Re-send current song if one is playing. current = self._main_window.player.get_current() if current: - self._main_window.rpc.update_song(current) + self._main_window.get_rpc().update_song(current) else: - self._main_window.rpc.stop() + self._main_window.get_rpc().stop() # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -2825,36 +2845,8 @@ class TunettiWindow(QMainWindow): # ── Favourites ── def _refresh_favourites(self) -> None: - lst = self._fav_page._list - lst.clear() rows = self.db.get_favourites() - if not rows: - lst.addItem("No favourites yet.") - self._fav_page._all_songs = [] - return - songs_data = [] - for r in rows: - song = { - "videoId": r["video_id"], - "title": r["title"], - "artists": json.loads(r["artists"]) if r["artists"] else [], - "album": json.loads(r["album"]) if r["album"] else None, - "duration": r["duration"], - "duration_label": _fmt_ms(r["duration"] * 1000) if r["duration"] else "?", - "thumbnail": r["thumbnail"] or "", - "_is_fav": True, - } - songs_data.append(song) - widget = SongItemWidget(song, is_fav=True) - widget.play_requested.connect(self._play_video) - widget.fav_toggled.connect(self._on_fav_toggled) - widget.queue_next_requested.connect(self._queue_next) - item = QListWidgetItem(lst) - item.setSizeHint(widget.sizeHint()) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable) - lst.addItem(item) - lst.setItemWidget(item, widget) - self._fav_page._all_songs = songs_data + self._populate_list_page(self._fav_page, rows, default_fav=True) @Slot(str, bool) def _on_fav_toggled(self, video_id: str, new_state: bool) -> None: @@ -2863,15 +2855,27 @@ class TunettiWindow(QMainWindow): # ── History ── def _refresh_history(self) -> None: - lst = self._hist_page._list - lst.clear() rows = self.db.get_history(50) + self._populate_list_page(self._hist_page, rows, default_fav=False) + + def _populate_list_page(self, page, rows, default_fav: bool = False) -> None: + """Populate a QListWidget-based page from DB rows. + + Shared by _refresh_favourites and _refresh_history to avoid + duplicate widget-building code. + """ + lst = page._list + lst.clear() + if not rows: - lst.addItem("No play history yet.") - self._hist_page._all_songs = [] + placeholder = "No favourites yet." if default_fav else "No play history yet." + lst.addItem(placeholder) + page._all_songs = [] return + songs_data = [] for r in rows: + is_fav = default_fav or bool(r["is_favourite"]) song = { "videoId": r["video_id"], "title": r["title"], @@ -2880,10 +2884,10 @@ class TunettiWindow(QMainWindow): "duration": r["duration"], "duration_label": _fmt_ms(r["duration"] * 1000) if r["duration"] else "?", "thumbnail": r["thumbnail"] or "", - "_is_fav": bool(r["is_favourite"]), + "_is_fav": is_fav, } songs_data.append(song) - widget = SongItemWidget(song, is_fav=bool(r["is_favourite"])) + widget = SongItemWidget(song, is_fav=is_fav) widget.play_requested.connect(self._play_video) widget.fav_toggled.connect(self._on_fav_toggled) widget.queue_next_requested.connect(self._queue_next) @@ -2892,7 +2896,7 @@ class TunettiWindow(QMainWindow): item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable) lst.addItem(item) lst.setItemWidget(item, widget) - self._hist_page._all_songs = songs_data + page._all_songs = songs_data # ── Stats ── @@ -2939,6 +2943,17 @@ class TunettiWindow(QMainWindow): except Exception as exc: log.error("_poll_minimized error: %s", exc) + # ── Public accessors (for child dialogs) ── + + def get_visualizer(self) -> AudioVisualizer: + return self._visualizer + + def get_rpc(self) -> DiscordRPC: + return self.rpc + + def get_player(self) -> AudioPlayer: + return self.player + # ── Cleanup ── def resizeEvent(self, event) -> None: @@ -2962,7 +2977,7 @@ class TunettiWindow(QMainWindow): def closeEvent(self, event) -> None: # Flush any pending volume save before closing - self._playback._flush_volume() + self._playback.flush_volume() self.player.shutdown() self.rpc.stop() self.db.close() diff --git a/player.py b/player.py index a32678f..219ede6 100644 --- a/player.py +++ b/player.py @@ -39,6 +39,9 @@ import shutil import threading from typing import Optional +# In this module we use Optional[str] for consistency +# with Qt signal/slot typing conventions. + import yt_dlp # ═══════════════════════════════════════════════════════════════════════════ @@ -75,7 +78,7 @@ VERBOSE: bool = False # enable yt-dlp debug output # Temp-file housekeeping # ═══════════════════════════════════════════════════════════════════════════════ -_TUNETTI_TMP: str | None = None +_TUNETTI_TMP: Optional[str] = None def _tmp_dir() -> str: @@ -166,6 +169,8 @@ class DownloadWorker(QObject): @Slot(int, dict) def _do_download(self, task_id: int, song: dict) -> None: """Entry point — invoked on the worker thread.""" + log.debug("Download starting: task_id=%d, video_id=%s", + task_id, song.get("videoId") or song.get("video_id", "")) self._task_id = task_id self._cancelled = False @@ -232,7 +237,7 @@ class DownloadWorker(QObject): if not self._cancelled: self.download_succeeded.emit(task_id, resolved) - except Exception as exc: + except (OSError, RuntimeError) as exc: log.error("Download error for %s: %s", video_id, exc) self._cleanup_path(temp_path) with self._lock: diff --git a/tests/test_player_helpers.py b/tests/test_player_helpers.py index d63b37f..195c5e9 100644 --- a/tests/test_player_helpers.py +++ b/tests/test_player_helpers.py @@ -4,6 +4,7 @@ import os import struct import math import tempfile +from pathlib import Path import pytest @@ -239,8 +240,9 @@ class TestStaleFiles: td = tempfile.mkdtemp(prefix="tunetti_test_") monkeypatch.setattr("player._TUNETTI_TMP", td) - with open(os.path.join(td, "vid123.mp3"), "w") as _f: - pass # empty; _f unused intentionally + # Create an empty file to verify it's excluded + empty_path = os.path.join(td, "vid123.mp3") + Path(empty_path).touch() result = _stale_files("vid123") assert result == []