🐛 | Code quality fixes
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 1m43s
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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
121
gui.py
121
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,13 +410,16 @@ 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:
|
||||
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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user