🐛 | Code quality fixes
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m43s

- config.py: move 'import shutil' to module level
- player.py: unify type hints, narrow except, add debug log
- discord_rpc.py: replace broad except Exception with specific types
- gui.py: remove unused import; replace lambda closures with method refs; fix closure scope bug; extract _populate_list_page() to remove duplicate code; add public accessors; make flush_volume public; add AudioVisualizer.reset_activity()
- tests: replace unused _f variable with Path.touch()
This commit is contained in:
2026-06-04 18:26:46 +03:00
parent 82a34a0a55
commit daf9c5739c
5 changed files with 85 additions and 63 deletions

View File

@@ -1,6 +1,7 @@
"""Tunetti configuration — stored at ~/.config/tunetti/config.json .""" """Tunetti configuration — stored at ~/.config/tunetti/config.json ."""
import json import json
import shutil
from pathlib import Path from pathlib import Path
# ── Config file location (XDG compliant) ────────────────────────────────── # ── 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(): if old_db_path.parent == Path(__file__).parent and old_db_path.exists():
new_db_path = CONFIG_DIR / "music_history.db" new_db_path = CONFIG_DIR / "music_history.db"
if not new_db_path.exists(): if not new_db_path.exists():
import shutil
shutil.copy2(str(old_db_path), str(new_db_path)) shutil.copy2(str(old_db_path), str(new_db_path))
old["db_path"] = str(new_db_path) old["db_path"] = str(new_db_path)

View File

@@ -116,7 +116,7 @@ class DiscordRPC:
log.warning("Discord not running RPC unavailable") log.warning("Discord not running RPC unavailable")
self._connected = False self._connected = False
return False return False
except Exception as exc: except (OSError, RuntimeError, DiscordNotFound) as exc:
log.debug("Discord RPC connect failed: %s", exc) log.debug("Discord RPC connect failed: %s", exc)
self._connected = False self._connected = False
return False return False
@@ -125,7 +125,7 @@ class DiscordRPC:
if self._rpc: if self._rpc:
try: try:
self._rpc.close() self._rpc.close()
except Exception: except (OSError, RuntimeError):
pass pass
self._rpc = None self._rpc = None
self._connected = False self._connected = False
@@ -139,7 +139,7 @@ class DiscordRPC:
if self._connected and self._rpc is not None: if self._connected and self._rpc is not None:
try: try:
self._rpc.clear() self._rpc.clear()
except Exception: except (OSError, RuntimeError):
self._connected = False self._connected = False
return return
@@ -162,7 +162,7 @@ class DiscordRPC:
start=start, start=start,
end=end_ts, end=end_ts,
) )
except Exception as exc: except (OSError, RuntimeError) as exc:
log.debug("RPC update failed: %s", exc) log.debug("RPC update failed: %s", exc)
self._connected = False self._connected = False

123
gui.py
View File

@@ -9,7 +9,6 @@ import logging
import sys import sys
import math import math
from pathlib import Path from pathlib import Path
from typing import Optional
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
# Qt imports are guarded so the module can be imported for its pure helper # 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() nam = _get_thumbnail_nam()
reply = nam.get(QNetworkRequest(QUrl(thumb_url))) 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) # The label may have been deleted (e.g. search results refreshed)
# while the network request was in flight — guard against that. # while the network request was in flight — guard against that.
try: try:
@@ -408,14 +410,17 @@ class SongItemWidget(QWidget):
# flooding the connection when many items appear at once (e.g. search). # flooding the connection when many items appear at once (e.g. search).
# Guard with try/except because the widget may be deleted before the # Guard with try/except because the widget may be deleted before the
# timer fires (search results refreshed, etc.). # timer fires (search results refreshed, etc.).
self._deferred_song = song
if _cached_thumb_path(self._video_id).exists(): if _cached_thumb_path(self._video_id).exists():
self._load_art(self._art, song) self._load_art(self._art, song)
else: 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: try:
self._load_art(self._art, song) song = self._deferred_song
if song:
self._load_art(self._art, song)
except RuntimeError: except RuntimeError:
pass # widget was deleted pass # widget was deleted
@@ -589,6 +594,10 @@ class AudioVisualizer(QWidget):
* Idle "breathing" animation when audio is playing but no buffer arrives. * 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 # Windows XP marquee loading pattern — sliding 4-bar block
_LOADING_PATTERN = [0.55, 0.85, 0.85, 0.55] _LOADING_PATTERN = [0.55, 0.85, 0.85, 0.55]
@@ -1280,7 +1289,8 @@ class PlaybackBar(QWidget):
self._progress.setEnabled(False) self._progress.setEnabled(False)
self._visualizer.set_playing(False) self._visualizer.set_playing(False)
self._visualizer.set_loading(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: def _clear_error(self) -> None:
"""Restore the "Not playing" text after an error.""" """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. # to the new audio section instead of decaying through idle.
if hasattr(self, "_band_peaks"): if hasattr(self, "_band_peaks"):
self._band_peaks = [0.001] * len(self._band_peaks) self._band_peaks = [0.001] * len(self._band_peaks)
self._visualizer._last_activity = 0 self._visualizer.reset_activity()
QTimer.singleShot(200, lambda: setattr(self, "_updating_progress", False)) QTimer.singleShot(200, self._clear_seek_flag)
def _clear_seek_flag(self) -> None:
self._updating_progress = False
def _toggle_mute(self) -> None: def _toggle_mute(self) -> None:
if self._player.volume() > 0: if self._player.volume() > 0:
@@ -1431,9 +1444,7 @@ class PlaybackBar(QWidget):
self._vol_save_timer = QTimer(self) self._vol_save_timer = QTimer(self)
self._vol_save_timer.setSingleShot(True) self._vol_save_timer.setSingleShot(True)
self._vol_save_timer.setInterval(500) self._vol_save_timer.setInterval(500)
self._vol_save_timer.timeout.connect( self._vol_save_timer.timeout.connect(self.flush_volume)
lambda: save_volume(self._saved_volume)
)
self._vol_save_timer.start() self._vol_save_timer.start()
# ── Visualizer data feeding ── # ── Visualizer data feeding ──
@@ -1597,7 +1608,7 @@ class PlaybackBar(QWidget):
return result return result
def _flush_volume(self) -> None: def flush_volume(self) -> None:
"""Immediately persist the current volume, bypassing the debounce timer.""" """Immediately persist the current volume, bypassing the debounce timer."""
if hasattr(self, "_vol_save_timer") and self._vol_save_timer.isActive(): if hasattr(self, "_vol_save_timer") and self._vol_save_timer.isActive():
self._vol_save_timer.stop() self._vol_save_timer.stop()
@@ -1814,6 +1825,7 @@ class SearchPage(QWidget):
btn = QPushButton("⋯ Show more") btn = QPushButton("⋯ Show more")
btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.setCursor(Qt.CursorShape.PointingHandCursor)
btn.setFixedHeight(38) btn.setFixedHeight(38)
btn.setProperty("category", category)
btn.setStyleSheet(""" btn.setStyleSheet("""
QPushButton { QPushButton {
border: none; border: none;
@@ -1829,9 +1841,17 @@ class SearchPage(QWidget):
color: #a5b4fc; color: #a5b4fc;
} }
""") """)
btn.clicked.connect(lambda: self._toggle_expand(category)) btn.clicked.connect(self._on_show_more_clicked)
self._results_layout.addWidget(btn) 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: def _toggle_expand(self, category: str) -> None:
if category == "videos": if category == "videos":
self._videos_expanded = True self._videos_expanded = True
@@ -2194,7 +2214,7 @@ class SettingsDialog(QDialog):
def _on_visualizer_toggled(self, enabled: bool) -> None: def _on_visualizer_toggled(self, enabled: bool) -> None:
save_setting("visualizer_enabled", enabled) save_setting("visualizer_enabled", enabled)
viz = self._main_window._visualizer viz = self._main_window.get_visualizer()
if enabled: if enabled:
viz.setVisible(True) viz.setVisible(True)
viz.setFixedHeight(44) viz.setFixedHeight(44)
@@ -2207,13 +2227,13 @@ class SettingsDialog(QDialog):
def _on_discord_toggled(self, enabled: bool) -> None: def _on_discord_toggled(self, enabled: bool) -> None:
save_setting("discord_rpc_enabled", enabled) save_setting("discord_rpc_enabled", enabled)
if enabled: if enabled:
self._main_window.rpc.start() self._main_window.get_rpc().start()
# Re-send current song if one is playing. # Re-send current song if one is playing.
current = self._main_window.player.get_current() current = self._main_window.player.get_current()
if current: if current:
self._main_window.rpc.update_song(current) self._main_window.get_rpc().update_song(current)
else: else:
self._main_window.rpc.stop() self._main_window.get_rpc().stop()
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -2825,36 +2845,8 @@ class TunettiWindow(QMainWindow):
# ── Favourites ── # ── Favourites ──
def _refresh_favourites(self) -> None: def _refresh_favourites(self) -> None:
lst = self._fav_page._list
lst.clear()
rows = self.db.get_favourites() rows = self.db.get_favourites()
if not rows: self._populate_list_page(self._fav_page, rows, default_fav=True)
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
@Slot(str, bool) @Slot(str, bool)
def _on_fav_toggled(self, video_id: str, new_state: bool) -> None: def _on_fav_toggled(self, video_id: str, new_state: bool) -> None:
@@ -2863,15 +2855,27 @@ class TunettiWindow(QMainWindow):
# ── History ── # ── History ──
def _refresh_history(self) -> None: def _refresh_history(self) -> None:
lst = self._hist_page._list
lst.clear()
rows = self.db.get_history(50) 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: if not rows:
lst.addItem("No play history yet.") placeholder = "No favourites yet." if default_fav else "No play history yet."
self._hist_page._all_songs = [] lst.addItem(placeholder)
page._all_songs = []
return return
songs_data = [] songs_data = []
for r in rows: for r in rows:
is_fav = default_fav or bool(r["is_favourite"])
song = { song = {
"videoId": r["video_id"], "videoId": r["video_id"],
"title": r["title"], "title": r["title"],
@@ -2880,10 +2884,10 @@ class TunettiWindow(QMainWindow):
"duration": r["duration"], "duration": r["duration"],
"duration_label": _fmt_ms(r["duration"] * 1000) if r["duration"] else "?", "duration_label": _fmt_ms(r["duration"] * 1000) if r["duration"] else "?",
"thumbnail": r["thumbnail"] or "", "thumbnail": r["thumbnail"] or "",
"_is_fav": bool(r["is_favourite"]), "_is_fav": is_fav,
} }
songs_data.append(song) 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.play_requested.connect(self._play_video)
widget.fav_toggled.connect(self._on_fav_toggled) widget.fav_toggled.connect(self._on_fav_toggled)
widget.queue_next_requested.connect(self._queue_next) widget.queue_next_requested.connect(self._queue_next)
@@ -2892,7 +2896,7 @@ class TunettiWindow(QMainWindow):
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable) item.setFlags(item.flags() | Qt.ItemFlag.ItemIsSelectable)
lst.addItem(item) lst.addItem(item)
lst.setItemWidget(item, widget) lst.setItemWidget(item, widget)
self._hist_page._all_songs = songs_data page._all_songs = songs_data
# ── Stats ── # ── Stats ──
@@ -2939,6 +2943,17 @@ class TunettiWindow(QMainWindow):
except Exception as exc: except Exception as exc:
log.error("_poll_minimized error: %s", 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 ── # ── Cleanup ──
def resizeEvent(self, event) -> None: def resizeEvent(self, event) -> None:
@@ -2962,7 +2977,7 @@ class TunettiWindow(QMainWindow):
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
# Flush any pending volume save before closing # Flush any pending volume save before closing
self._playback._flush_volume() self._playback.flush_volume()
self.player.shutdown() self.player.shutdown()
self.rpc.stop() self.rpc.stop()
self.db.close() self.db.close()

View File

@@ -39,6 +39,9 @@ import shutil
import threading import threading
from typing import Optional from typing import Optional
# In this module we use Optional[str] for consistency
# with Qt signal/slot typing conventions.
import yt_dlp import yt_dlp
# ═══════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════
@@ -75,7 +78,7 @@ VERBOSE: bool = False # enable yt-dlp debug output
# Temp-file housekeeping # Temp-file housekeeping
# ═══════════════════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════════════════
_TUNETTI_TMP: str | None = None _TUNETTI_TMP: Optional[str] = None
def _tmp_dir() -> str: def _tmp_dir() -> str:
@@ -166,6 +169,8 @@ class DownloadWorker(QObject):
@Slot(int, dict) @Slot(int, dict)
def _do_download(self, task_id: int, song: dict) -> None: def _do_download(self, task_id: int, song: dict) -> None:
"""Entry point — invoked on the worker thread.""" """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._task_id = task_id
self._cancelled = False self._cancelled = False
@@ -232,7 +237,7 @@ class DownloadWorker(QObject):
if not self._cancelled: if not self._cancelled:
self.download_succeeded.emit(task_id, resolved) 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) log.error("Download error for %s: %s", video_id, exc)
self._cleanup_path(temp_path) self._cleanup_path(temp_path)
with self._lock: with self._lock:

View File

@@ -4,6 +4,7 @@ import os
import struct import struct
import math import math
import tempfile import tempfile
from pathlib import Path
import pytest import pytest
@@ -239,8 +240,9 @@ class TestStaleFiles:
td = tempfile.mkdtemp(prefix="tunetti_test_") td = tempfile.mkdtemp(prefix="tunetti_test_")
monkeypatch.setattr("player._TUNETTI_TMP", td) monkeypatch.setattr("player._TUNETTI_TMP", td)
with open(os.path.join(td, "vid123.mp3"), "w") as _f: # Create an empty file to verify it's excluded
pass # empty; _f unused intentionally empty_path = os.path.join(td, "vid123.mp3")
Path(empty_path).touch()
result = _stale_files("vid123") result = _stale_files("vid123")
assert result == [] assert result == []