🎉 | Project added
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 3m36s
All checks were successful
SonarQube Code Quality Scan / SonarQube Scan (push) Successful in 3m36s
This commit is contained in:
25
.gitea/workflows/sonar.yaml
Normal file
25
.gitea/workflows/sonar.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: SonarQube Code Quality Scan
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sonarqube:
|
||||||
|
name: SonarQube Scan
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Clone Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Required for advanced SonarQube features like blame info
|
||||||
|
|
||||||
|
- name: Run SonarQube Scanner
|
||||||
|
uses: sonarsource/sonarqube-scan-action@v4
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }}
|
||||||
|
SONAR_HOST_URL: ${{ secrets.SONARQUBE_HOST_URL }}
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -171,6 +171,9 @@ cython_debug/
|
|||||||
# Ruff stuff:
|
# Ruff stuff:
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
|
||||||
|
# SonarQube
|
||||||
|
.scannerwork/
|
||||||
|
|
||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|||||||
98
config.py
Normal file
98
config.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
"""Tunetti configuration — stored at ~/.config/tunetti/config.json ."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ── Config file location (XDG compliant) ──────────────────────────────────
|
||||||
|
CONFIG_DIR = Path.home() / ".config" / "tunetti"
|
||||||
|
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||||
|
|
||||||
|
# Legacy config in the project directory (for migration).
|
||||||
|
_LEGACY_FILE = Path(__file__).parent / "tunetti_config.json"
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict = {
|
||||||
|
# Playback
|
||||||
|
"volume": 50,
|
||||||
|
"max_history": 5000,
|
||||||
|
"ffplay_args": ["-nodisp", "-autoexit", "-loglevel", "quiet"],
|
||||||
|
# Database
|
||||||
|
"db_path": str(CONFIG_DIR / "music_history.db"),
|
||||||
|
# Discord
|
||||||
|
"discord_client_id": "1510266468388835328",
|
||||||
|
"discord_rpc_enabled": True,
|
||||||
|
# Visualizer
|
||||||
|
"visualizer_enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_legacy() -> None:
|
||||||
|
"""Copy old project-level config and database to the new XDG location."""
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
return # new config already exists — nothing to do
|
||||||
|
if not _LEGACY_FILE.exists():
|
||||||
|
return # nothing to migrate
|
||||||
|
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
try:
|
||||||
|
with open(_LEGACY_FILE) as f:
|
||||||
|
old = json.load(f)
|
||||||
|
|
||||||
|
# Migrate the database file if it lived in the old project dir.
|
||||||
|
old_db_path = Path(old.get("db_path", ""))
|
||||||
|
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)
|
||||||
|
|
||||||
|
save_config({**DEFAULT_CONFIG, **old})
|
||||||
|
# Rename legacy config so we don't re-migrate every launch.
|
||||||
|
_LEGACY_FILE.rename(_LEGACY_FILE.with_suffix(".json.migrated"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> dict:
|
||||||
|
_migrate_legacy()
|
||||||
|
|
||||||
|
if CONFIG_FILE.exists():
|
||||||
|
try:
|
||||||
|
with open(CONFIG_FILE) as f:
|
||||||
|
return {**DEFAULT_CONFIG, **json.load(f)}
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# First launch — create the directory and write defaults.
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_config(dict(DEFAULT_CONFIG))
|
||||||
|
return dict(DEFAULT_CONFIG)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(config: dict) -> None:
|
||||||
|
"""Merge *config* over defaults and persist to disk."""
|
||||||
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
merged = {**DEFAULT_CONFIG, **config}
|
||||||
|
with open(CONFIG_FILE, "w") as f:
|
||||||
|
json.dump(merged, f, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def save_volume(volume: int) -> None:
|
||||||
|
"""Persist current volume to disk and update in-memory SETTINGS."""
|
||||||
|
volume = max(0, min(100, volume))
|
||||||
|
SETTINGS["volume"] = volume
|
||||||
|
save_config(dict(SETTINGS))
|
||||||
|
|
||||||
|
|
||||||
|
def save_setting(key: str, value) -> None:
|
||||||
|
"""Persist a single setting and update the in-memory SETTINGS dict."""
|
||||||
|
SETTINGS[key] = value
|
||||||
|
save_config(dict(SETTINGS))
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(key: str):
|
||||||
|
"""Return a setting value from the in-memory dict."""
|
||||||
|
return SETTINGS.get(key, DEFAULT_CONFIG.get(key))
|
||||||
|
|
||||||
|
|
||||||
|
SETTINGS = load_config()
|
||||||
182
discord_rpc.py
Normal file
182
discord_rpc.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Discord Rich Presence integration for Tunetti."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pypresence import Presence, DiscordNotFound
|
||||||
|
from pypresence.types import ActivityType
|
||||||
|
|
||||||
|
log = logging.getLogger("tunetti.rpc")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_artists(song: Optional[dict]) -> str:
|
||||||
|
"""Extract a comma-separated artist string, handling None / missing keys."""
|
||||||
|
if not song:
|
||||||
|
return ""
|
||||||
|
artists = song.get("artists")
|
||||||
|
if not isinstance(artists, list):
|
||||||
|
return ""
|
||||||
|
names = []
|
||||||
|
for a in artists:
|
||||||
|
if isinstance(a, dict):
|
||||||
|
name = a.get("name", "")
|
||||||
|
if name:
|
||||||
|
names.append(name)
|
||||||
|
return ", ".join(names)
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordRPC:
|
||||||
|
"""Manages Discord Rich Presence for the currently playing song.
|
||||||
|
|
||||||
|
Presence updates are normally batched every ``REFRESH_INTERVAL``
|
||||||
|
seconds, but calling :meth:`update_song` immediately signals the
|
||||||
|
background loop to send the new state right away.
|
||||||
|
"""
|
||||||
|
|
||||||
|
REFRESH_INTERVAL = 15 # seconds between re-sends
|
||||||
|
|
||||||
|
def __init__(self, client_id: str):
|
||||||
|
self._client_id = client_id
|
||||||
|
self._rpc: Optional[Presence] = None
|
||||||
|
self._connected = False
|
||||||
|
self._song: Optional[dict] = None
|
||||||
|
self._start_ts: Optional[int] = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._wake_event = threading.Event()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Connect to Discord and start the background update loop."""
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._wake_event.clear()
|
||||||
|
self._thread = threading.Thread(target=self._run, daemon=True,
|
||||||
|
name="discord-rpc")
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop_event.set()
|
||||||
|
self._wake_event.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=3)
|
||||||
|
self._disconnect()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public update
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def update_song(self, song: Optional[dict],
|
||||||
|
reset_start: bool = True) -> None:
|
||||||
|
"""Set the currently playing song (or None to clear).
|
||||||
|
|
||||||
|
When *reset_start* is ``True`` (default), the start timestamp
|
||||||
|
is set to now so Discord shows a fresh progress bar. Pass
|
||||||
|
``False`` on resume to keep the original start time.
|
||||||
|
|
||||||
|
The presence is sent to Discord *immediately*.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
self._song = song
|
||||||
|
if reset_start:
|
||||||
|
self._start_ts = int(time.time()) if song else None
|
||||||
|
self._wake_event.set()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Clear the presence entirely."""
|
||||||
|
with self._lock:
|
||||||
|
self._song = None
|
||||||
|
self._start_ts = None
|
||||||
|
self._wake_event.set()
|
||||||
|
|
||||||
|
def seek_to(self, position_ms: int) -> None:
|
||||||
|
"""Recalculate start_ts after a seek so Discord's progress bar
|
||||||
|
shows the correct elapsed time from the new position."""
|
||||||
|
with self._lock:
|
||||||
|
if self._start_ts is not None:
|
||||||
|
self._start_ts = int(time.time()) - (position_ms // 1000)
|
||||||
|
self._wake_event.set()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _connect(self) -> bool:
|
||||||
|
try:
|
||||||
|
self._rpc = Presence(self._client_id)
|
||||||
|
self._rpc.connect()
|
||||||
|
self._connected = True
|
||||||
|
log.info("Connected to Discord RPC")
|
||||||
|
return True
|
||||||
|
except DiscordNotFound:
|
||||||
|
log.warning("Discord not running – RPC unavailable")
|
||||||
|
self._connected = False
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("Discord RPC connect failed: %s", exc)
|
||||||
|
self._connected = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _disconnect(self) -> None:
|
||||||
|
if self._rpc:
|
||||||
|
try:
|
||||||
|
self._rpc.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._rpc = None
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def _send_presence(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
song = self._song
|
||||||
|
start = self._start_ts
|
||||||
|
|
||||||
|
if not song:
|
||||||
|
if self._connected and self._rpc is not None:
|
||||||
|
try:
|
||||||
|
self._rpc.clear()
|
||||||
|
except Exception:
|
||||||
|
self._connected = False
|
||||||
|
return
|
||||||
|
|
||||||
|
artists_str = _safe_artists(song)
|
||||||
|
title = song.get("title", "Unknown")
|
||||||
|
thumbnail = song.get("thumbnail") or ""
|
||||||
|
duration = song.get("duration", 0)
|
||||||
|
|
||||||
|
end_ts = (start + duration) if start and duration else None
|
||||||
|
|
||||||
|
if self._rpc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._rpc.update(
|
||||||
|
activity_type=ActivityType.LISTENING,
|
||||||
|
details=title,
|
||||||
|
state=f"by {artists_str}" if artists_str else "YouTube Music",
|
||||||
|
large_image=thumbnail,
|
||||||
|
start=start,
|
||||||
|
end=end_ts,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.debug("RPC update failed: %s", exc)
|
||||||
|
self._connected = False
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if not self._connected:
|
||||||
|
self._connect()
|
||||||
|
if not self._connected:
|
||||||
|
self._stop_event.wait(10)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._send_presence()
|
||||||
|
# Wait for either the refresh interval or an immediate wake-up
|
||||||
|
self._wake_event.wait(self.REFRESH_INTERVAL)
|
||||||
|
self._wake_event.clear()
|
||||||
|
|
||||||
|
self._disconnect()
|
||||||
62
main.py
Normal file
62
main.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tunetti – YouTube Music Player.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python main.py # normal mode
|
||||||
|
TUNETTI_VERBOSE=1 python main.py # full debug output
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
# ── Detect verbose mode from env ────────────────────────────────────────────
|
||||||
|
VERBOSE = os.environ.get("TUNETTI_VERBOSE", "").strip() in ("1", "true", "yes")
|
||||||
|
|
||||||
|
|
||||||
|
_LOG_LEVEL = logging.DEBUG if VERBOSE else logging.INFO
|
||||||
|
_LOG_FORMAT = (
|
||||||
|
"%(asctime)s.%(msecs)03d %(levelname).1s %(name)s %(message)s"
|
||||||
|
if VERBOSE else
|
||||||
|
"%(levelname).1s %(name)s %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=_LOG_LEVEL,
|
||||||
|
format=_LOG_FORMAT,
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
stream=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Suppress noisy Qt FFmpeg warnings — these fire constantly when
|
||||||
|
# YouTube streams end prematurely (expected behaviour with short-lived
|
||||||
|
# streaming tokens). The env-var approach is unreliable across Qt
|
||||||
|
# versions, so we set filter rules *after* QApplication is created.
|
||||||
|
# In verbose mode the filters are omitted so you can see FFmpeg internals.
|
||||||
|
_QT_LOG_RULES = "" if VERBOSE else """\
|
||||||
|
qt.multimedia.ffmpeg.demuxer=false
|
||||||
|
qt.multimedia.ffmpeg.io=false
|
||||||
|
qt.multimedia.ffmpeg.tls=false
|
||||||
|
qt.multimedia.backend=false
|
||||||
|
qt.multimedia=false
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
from gui import run_gui
|
||||||
|
|
||||||
|
# Propagate verbose flag to the player module so yt-dlp also
|
||||||
|
# produces debug output.
|
||||||
|
if VERBOSE:
|
||||||
|
import player as _player_mod
|
||||||
|
_player_mod.VERBOSE = True
|
||||||
|
log = logging.getLogger("tunetti")
|
||||||
|
log.debug("Verbose mode enabled — yt-dlp and Qt FFmpeg logging active")
|
||||||
|
|
||||||
|
run_gui(extra_qt_log_rules=_QT_LOG_RULES)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
225
music_db.py
Normal file
225
music_db.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""SQLite-backed database for song history, favourites, and play stats."""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class MusicDB:
|
||||||
|
"""Thread-safe database layer for Tunetti."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||||
|
self.conn.row_factory = sqlite3.Row
|
||||||
|
self.conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
self._create_tables()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schema
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _create_tables(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.conn.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS songs (
|
||||||
|
video_id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
artists TEXT NOT NULL, -- JSON array of {name, id}
|
||||||
|
album TEXT, -- JSON {name, id} or NULL
|
||||||
|
duration INTEGER NOT NULL DEFAULT 0,
|
||||||
|
thumbnail TEXT,
|
||||||
|
is_favourite INTEGER NOT NULL DEFAULT 0,
|
||||||
|
play_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_played TIMESTAMP,
|
||||||
|
last_played TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS play_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
video_id TEXT NOT NULL REFERENCES songs(video_id),
|
||||||
|
played_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_video
|
||||||
|
ON play_history(video_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_date
|
||||||
|
ON play_history(played_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_songs_fav
|
||||||
|
ON songs(is_favourite);
|
||||||
|
""")
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _upsert_song(self, video_id: str, title: str, artists: str,
|
||||||
|
album: Optional[str], duration: int,
|
||||||
|
thumbnail: Optional[str]) -> None:
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
self.conn.execute("""
|
||||||
|
INSERT INTO songs (video_id, title, artists, album, duration,
|
||||||
|
thumbnail, first_played, last_played)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(video_id) DO UPDATE SET
|
||||||
|
title = excluded.title,
|
||||||
|
artists = excluded.artists,
|
||||||
|
album = excluded.album,
|
||||||
|
duration = excluded.duration,
|
||||||
|
thumbnail = excluded.thumbnail,
|
||||||
|
last_played = excluded.last_played
|
||||||
|
""", (video_id, title, artists, album, duration, thumbnail, now, now))
|
||||||
|
self.conn.execute("""
|
||||||
|
UPDATE songs SET play_count = play_count + 1 WHERE video_id = ?
|
||||||
|
""", (video_id,))
|
||||||
|
|
||||||
|
def _get_song_row(self, video_id: str) -> Optional[sqlite3.Row]:
|
||||||
|
cur = self.conn.execute(
|
||||||
|
"SELECT * FROM songs WHERE video_id = ?", (video_id,))
|
||||||
|
return cur.fetchone()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def record_play(self, video_id: str, title: str,
|
||||||
|
artists: list[dict], album: Optional[dict] = None,
|
||||||
|
duration: int = 0,
|
||||||
|
thumbnail: Optional[str] = None) -> None:
|
||||||
|
"""Record that a song was played (upsert + history row)."""
|
||||||
|
import json
|
||||||
|
artists_json = json.dumps(artists, ensure_ascii=False)
|
||||||
|
album_json = json.dumps(album) if album else None
|
||||||
|
with self._lock:
|
||||||
|
self._upsert_song(video_id, title, artists_json,
|
||||||
|
album_json, duration, thumbnail)
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO play_history (video_id) VALUES (?)", (video_id,))
|
||||||
|
self.conn.commit()
|
||||||
|
|
||||||
|
def toggle_favourite(self, video_id: str) -> bool:
|
||||||
|
"""Toggle the favourite flag for a song. Returns the new state."""
|
||||||
|
with self._lock:
|
||||||
|
row = self._get_song_row(video_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
new_val = 0 if row["is_favourite"] else 1
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE songs SET is_favourite = ? WHERE video_id = ?",
|
||||||
|
(new_val, video_id))
|
||||||
|
self.conn.commit()
|
||||||
|
return bool(new_val)
|
||||||
|
|
||||||
|
def set_favourite(self, video_id: str, value: bool) -> bool:
|
||||||
|
"""Explicitly set favourite state. Returns True if song exists."""
|
||||||
|
with self._lock:
|
||||||
|
row = self._get_song_row(video_id)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
self.conn.execute(
|
||||||
|
"UPDATE songs SET is_favourite = ? WHERE video_id = ?",
|
||||||
|
(1 if value else 0, video_id))
|
||||||
|
self.conn.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_favourites(self) -> list[sqlite3.Row]:
|
||||||
|
"""Return all favourited songs ordered by last played desc."""
|
||||||
|
with self._lock:
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT * FROM songs
|
||||||
|
WHERE is_favourite = 1
|
||||||
|
ORDER BY last_played DESC
|
||||||
|
""")
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
def is_favourite(self, video_id: str) -> bool:
|
||||||
|
"""Check if a song is favourited."""
|
||||||
|
with self._lock:
|
||||||
|
row = self._get_song_row(video_id)
|
||||||
|
return bool(row and row["is_favourite"])
|
||||||
|
|
||||||
|
def get_history(self, limit: int = 50) -> list[sqlite3.Row]:
|
||||||
|
"""Return recent play history with song details.
|
||||||
|
|
||||||
|
Each video_id appears at most once (keeps the most recent play).
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT h.played_at,
|
||||||
|
s.video_id, s.title, s.artists, s.album,
|
||||||
|
s.duration, s.thumbnail, s.is_favourite
|
||||||
|
FROM play_history h
|
||||||
|
JOIN songs s ON s.video_id = h.video_id
|
||||||
|
WHERE h.id IN (
|
||||||
|
SELECT MAX(id) FROM play_history GROUP BY video_id
|
||||||
|
)
|
||||||
|
ORDER BY h.id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
return cur.fetchall()
|
||||||
|
|
||||||
|
def get_stats(self) -> dict:
|
||||||
|
"""Return aggregate listening statistics."""
|
||||||
|
with self._lock:
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT COUNT(DISTINCT video_id) AS unique_songs,
|
||||||
|
SUM(play_count) AS total_plays
|
||||||
|
FROM songs
|
||||||
|
""")
|
||||||
|
total = cur.fetchone()
|
||||||
|
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT COUNT(*) AS fav_count FROM songs WHERE is_favourite = 1
|
||||||
|
""")
|
||||||
|
favs = cur.fetchone()
|
||||||
|
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT video_id, title, artists, thumbnail, play_count
|
||||||
|
FROM songs ORDER BY play_count DESC LIMIT 10
|
||||||
|
""")
|
||||||
|
top = cur.fetchall()
|
||||||
|
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT s.title, s.artists, h.played_at
|
||||||
|
FROM play_history h
|
||||||
|
JOIN songs s ON s.video_id = h.video_id
|
||||||
|
ORDER BY h.played_at DESC LIMIT 5
|
||||||
|
""")
|
||||||
|
recent = cur.fetchall()
|
||||||
|
|
||||||
|
cur = self.conn.execute("""
|
||||||
|
SELECT date(played_at) AS day, COUNT(*) AS plays
|
||||||
|
FROM play_history
|
||||||
|
GROUP BY day ORDER BY day DESC LIMIT 30
|
||||||
|
""")
|
||||||
|
by_day = cur.fetchall()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unique_songs": total["unique_songs"],
|
||||||
|
"total_plays": total["total_plays"],
|
||||||
|
"favourites": favs["fav_count"],
|
||||||
|
"top_played": [
|
||||||
|
{
|
||||||
|
"video_id": r["video_id"],
|
||||||
|
"title": r["title"],
|
||||||
|
"artists": r["artists"],
|
||||||
|
"thumbnail": r["thumbnail"] or "",
|
||||||
|
"play_count": r["play_count"]
|
||||||
|
}
|
||||||
|
for r in top
|
||||||
|
],
|
||||||
|
"recent": [
|
||||||
|
{
|
||||||
|
"title": r["title"],
|
||||||
|
"artists": r["artists"],
|
||||||
|
"played_at": r["played_at"]
|
||||||
|
}
|
||||||
|
for r in recent
|
||||||
|
],
|
||||||
|
"plays_by_day": [
|
||||||
|
{"day": r["day"], "plays": r["plays"]} for r in by_day
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
864
player.py
Normal file
864
player.py
Normal file
@@ -0,0 +1,864 @@
|
|||||||
|
"""Audio player using QMediaPlayer + yt-dlp with queue and local-file playback.
|
||||||
|
|
||||||
|
Instead of streaming YouTube audio URLs directly (which expire within seconds
|
||||||
|
and cause "Connection reset by peer" / "Demuxing failed" errors), we download
|
||||||
|
each song to a temporary file and play from the local filesystem. This is
|
||||||
|
reliable because QMediaPlayer never touches the network directly.
|
||||||
|
|
||||||
|
Architecture
|
||||||
|
------------
|
||||||
|
A single persistent ``DownloadWorker`` lives on a background ``QThread`` for
|
||||||
|
the entire player lifetime. Work is sent via Qt signals — no thread
|
||||||
|
creation/teardown, no signal-disconnect dances, no ``quit()``/``wait()``
|
||||||
|
timeouts. Every download is tagged with a monotonically-increasing
|
||||||
|
``task_id``; stale results (from superseded requests) are simply discarded.
|
||||||
|
|
||||||
|
State machine::
|
||||||
|
|
||||||
|
IDLE ──▶ LOADING ──▶ READY ──▶ PLAYING ──▶ ENDED
|
||||||
|
▲ │ │ │ ▲ │
|
||||||
|
│ ▼ │ │ │ │
|
||||||
|
│ FAILED ───────┘ ▼ │ │
|
||||||
|
└───────────────────────────────── PAUSED │
|
||||||
|
│ ▲ │
|
||||||
|
└──┘ │
|
||||||
|
│
|
||||||
|
(queue empty)─┘
|
||||||
|
|
||||||
|
Set ``TEST_MODE = True`` below to bypass yt-dlp and generate a short test-tone
|
||||||
|
WAV instead — useful for UI development without network access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import tempfile
|
||||||
|
import atexit
|
||||||
|
import math
|
||||||
|
import shutil
|
||||||
|
import threading
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import yt_dlp
|
||||||
|
from PySide6.QtCore import QObject, QThread, Signal, Slot, QUrl
|
||||||
|
from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
|
||||||
|
|
||||||
|
log = logging.getLogger("tunetti.player")
|
||||||
|
|
||||||
|
# ── User-settable flags ────────────────────────────────────────────────────
|
||||||
|
TEST_MODE: bool = False # skip yt-dlp, use local test-tone WAV instead
|
||||||
|
VERBOSE: bool = False # enable yt-dlp debug output
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Temp-file housekeeping
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
_TUNETTI_TMP: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _tmp_dir() -> str:
|
||||||
|
"""Return a scoped temp directory that lives for the process lifetime."""
|
||||||
|
global _TUNETTI_TMP
|
||||||
|
if _TUNETTI_TMP is None:
|
||||||
|
_TUNETTI_TMP = tempfile.mkdtemp(prefix="tunetti_")
|
||||||
|
atexit.register(shutil.rmtree, _TUNETTI_TMP, ignore_errors=True)
|
||||||
|
return _TUNETTI_TMP
|
||||||
|
|
||||||
|
|
||||||
|
def _stale_files(video_id: str) -> list[str]:
|
||||||
|
"""Return paths of existing non-temp files for *video_id*, newest first."""
|
||||||
|
td = _tmp_dir()
|
||||||
|
result = []
|
||||||
|
for fn in os.listdir(td):
|
||||||
|
if fn.startswith(video_id) and ".temp." not in fn:
|
||||||
|
path = os.path.join(td, fn)
|
||||||
|
if os.path.isfile(path) and os.path.getsize(path) > 0:
|
||||||
|
result.append(path)
|
||||||
|
result.sort(key=lambda p: os.path.getmtime(p), reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Test-tone generator (used when TEST_MODE = True)
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _generate_test_wav(path: str, duration_s: int = 15) -> None:
|
||||||
|
"""Write a 440 Hz sine wave to *path* as a 16-bit mono WAV file."""
|
||||||
|
sample_rate = 44100
|
||||||
|
num_samples = sample_rate * duration_s
|
||||||
|
frequency = 440.0
|
||||||
|
amplitude = 0.3
|
||||||
|
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
data_size = num_samples * 2
|
||||||
|
f.write(b"RIFF")
|
||||||
|
f.write(struct.pack("<I", 36 + data_size))
|
||||||
|
f.write(b"WAVE")
|
||||||
|
f.write(b"fmt ")
|
||||||
|
f.write(struct.pack("<I", 16))
|
||||||
|
f.write(struct.pack("<H", 1))
|
||||||
|
f.write(struct.pack("<H", 1))
|
||||||
|
f.write(struct.pack("<I", sample_rate))
|
||||||
|
f.write(struct.pack("<I", sample_rate * 2))
|
||||||
|
f.write(struct.pack("<H", 2))
|
||||||
|
f.write(struct.pack("<H", 16))
|
||||||
|
f.write(b"data")
|
||||||
|
f.write(struct.pack("<I", data_size))
|
||||||
|
for i in range(num_samples):
|
||||||
|
t = i / sample_rate
|
||||||
|
sample = int(amplitude * math.sin(2 * math.pi * frequency * t) * 32767)
|
||||||
|
f.write(struct.pack("<h", sample))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Download worker — lives on a single persistent QThread
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class DownloadWorker(QObject):
|
||||||
|
"""Downloads song audio to temporary files via yt-dlp.
|
||||||
|
|
||||||
|
Lives on a *persistent* background ``QThread`` that is created once
|
||||||
|
when the ``AudioPlayer`` is initialised and never torn down until
|
||||||
|
shutdown. This avoids all the fragile ``quit()``/``wait()``/signal-
|
||||||
|
disconnect races that plagued the previous design.
|
||||||
|
|
||||||
|
Work is requested via :attr:`download_requested` and cancellation is
|
||||||
|
signalled via :attr:`cancel_requested`. Results are delivered on the
|
||||||
|
main thread via :attr:`download_finished`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── Input (main thread → worker thread, queued connection) ──────────
|
||||||
|
download_requested = Signal(int, dict) # task_id, song dict
|
||||||
|
cancel_requested = Signal() # discard current download
|
||||||
|
|
||||||
|
# ── Output (worker thread → main thread, queued connection) ─────────
|
||||||
|
download_succeeded = Signal(int, dict) # task_id, resolved song dict
|
||||||
|
download_failed = Signal(int) # task_id
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QObject] = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._cancelled = False
|
||||||
|
self._task_id: Optional[int] = None
|
||||||
|
|
||||||
|
@Slot(int, dict)
|
||||||
|
def _do_download(self, task_id: int, song: dict) -> None:
|
||||||
|
"""Entry point — invoked on the worker thread."""
|
||||||
|
self._task_id = task_id
|
||||||
|
self._cancelled = False
|
||||||
|
|
||||||
|
video_id = song.get("videoId") or song.get("video_id", "")
|
||||||
|
temp_path: Optional[str] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Keep the stale cleanup at the start of each new download.
|
||||||
|
for stale in _stale_files(video_id):
|
||||||
|
try:
|
||||||
|
os.unlink(stale)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── TEST MODE ────────────────────────────────────────────
|
||||||
|
if TEST_MODE:
|
||||||
|
wav_path = os.path.join(_tmp_dir(), f"{video_id}.wav")
|
||||||
|
_generate_test_wav(wav_path)
|
||||||
|
resolved = dict(song)
|
||||||
|
resolved["_local_path"] = wav_path
|
||||||
|
resolved["duration"] = 15
|
||||||
|
resolved["thumbnail"] = ""
|
||||||
|
resolved["title"] = song.get("title", f"Test {video_id}")
|
||||||
|
with self._lock:
|
||||||
|
if not self._cancelled:
|
||||||
|
self.download_succeeded.emit(task_id, resolved)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Real download ────────────────────────────────────────
|
||||||
|
tmpl = os.path.join(_tmp_dir(), f"{video_id}.%(ext)s")
|
||||||
|
ydl_opts: dict = {
|
||||||
|
"quiet": not VERBOSE,
|
||||||
|
"no_warnings": not VERBOSE,
|
||||||
|
"verbose": VERBOSE,
|
||||||
|
"format": "worstaudio/worst",
|
||||||
|
"outtmpl": tmpl,
|
||||||
|
"noprogress": not VERBOSE,
|
||||||
|
"extract_flat": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||||
|
info = ydl.extract_info(
|
||||||
|
f"https://music.youtube.com/watch?v={video_id}",
|
||||||
|
download=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Construct the expected path from the output template.
|
||||||
|
# The outtmpl is <tmp_dir>/<video_id>.%(ext)s and yt-dlp resolves
|
||||||
|
# %(ext)s from the downloaded format. We avoid relying on
|
||||||
|
# info["requested_downloads"][0]["filepath"] because that key
|
||||||
|
# may be set *before* post-processor fixup renames the file.
|
||||||
|
actual_ext = info.get("ext", "")
|
||||||
|
expected_path = os.path.join(_tmp_dir(), f"{video_id}.{actual_ext}") if actual_ext else ""
|
||||||
|
|
||||||
|
temp_path = self._resolve_temp_path(video_id, expected_path)
|
||||||
|
if not temp_path:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Download produced empty/missing file for {video_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = self._build_resolved_dict(song, info, temp_path)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
if not self._cancelled:
|
||||||
|
self.download_succeeded.emit(task_id, resolved)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Download error for %s: %s", video_id, exc)
|
||||||
|
self._cleanup_path(temp_path)
|
||||||
|
with self._lock:
|
||||||
|
if not self._cancelled:
|
||||||
|
self.download_failed.emit(task_id)
|
||||||
|
|
||||||
|
def _resolve_temp_path(self, video_id: str, expected_path: str) -> Optional[str]:
|
||||||
|
"""Resolve the actual temp file path after a download.
|
||||||
|
|
||||||
|
First checks the *expected_path* (built from the extension yt-dlp reported),
|
||||||
|
then falls back to scanning the temp directory for stale files.
|
||||||
|
"""
|
||||||
|
# Debug: list every file in the temp dir for this video_id
|
||||||
|
td = _tmp_dir()
|
||||||
|
try:
|
||||||
|
all_matches = [
|
||||||
|
(fn, os.path.getsize(os.path.join(td, fn)))
|
||||||
|
for fn in os.listdir(td)
|
||||||
|
if fn.startswith(video_id)
|
||||||
|
]
|
||||||
|
all_matches.sort()
|
||||||
|
log.debug("Temp files for %s: %s", video_id, all_matches)
|
||||||
|
except OSError as exc:
|
||||||
|
log.debug("Temp dir listing failed: %s", exc)
|
||||||
|
|
||||||
|
if expected_path:
|
||||||
|
log.debug("Checking expected path: %s", expected_path)
|
||||||
|
if expected_path and os.path.isfile(expected_path) and os.path.getsize(expected_path) > 0:
|
||||||
|
return expected_path
|
||||||
|
|
||||||
|
# Fallback: scan the temp directory for the most recent valid file.
|
||||||
|
candidates = _stale_files(video_id)
|
||||||
|
log.debug("_stale_files fallback for %s: %s", video_id, candidates)
|
||||||
|
if candidates:
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_resolved_dict(song: dict, info: dict, temp_path: str) -> dict:
|
||||||
|
"""Build the resolved song dict with local path and metadata."""
|
||||||
|
resolved = dict(song)
|
||||||
|
resolved["_local_path"] = temp_path
|
||||||
|
resolved["duration"] = info.get("duration") or song.get("duration", 0)
|
||||||
|
resolved["thumbnail"] = info.get("thumbnail") or song.get("thumbnail", "")
|
||||||
|
resolved["title"] = info.get("title", song.get("title", ""))
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _do_cancel(self) -> None:
|
||||||
|
"""Mark the current download as cancelled."""
|
||||||
|
with self._lock:
|
||||||
|
self._cancelled = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cleanup_path(path: Optional[str]) -> None:
|
||||||
|
if path and os.path.isfile(path):
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Search worker
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class SearchWorker(QObject):
|
||||||
|
"""Runs a ytmusicapi search in a background thread.
|
||||||
|
|
||||||
|
Searches both songs and videos, returning categorised results
|
||||||
|
as a dict with ``"songs"`` and ``"videos"`` keys each containing
|
||||||
|
a list of normalised song dicts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
results_ready = Signal(dict)
|
||||||
|
failed = Signal(str)
|
||||||
|
|
||||||
|
def __init__(self, query: str):
|
||||||
|
super().__init__()
|
||||||
|
self._query = query
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def run(self) -> None:
|
||||||
|
if TEST_MODE:
|
||||||
|
dummy = [
|
||||||
|
{"videoId": f"dummy_{i}", "title": f"Test Song {i}",
|
||||||
|
"artists": [{"name": f"Artist {i}"}],
|
||||||
|
"album": None, "duration_seconds": 15,
|
||||||
|
"duration": "0:15", "thumbnails": [],
|
||||||
|
"videoType": "MUSIC_VIDEO_TYPE_ATV",
|
||||||
|
"resultType": "song"}
|
||||||
|
for i in range(5)
|
||||||
|
]
|
||||||
|
self.results_ready.emit({"songs": dummy, "videos": []})
|
||||||
|
return
|
||||||
|
|
||||||
|
from ytmusicapi import YTMusic
|
||||||
|
try:
|
||||||
|
yt = YTMusic()
|
||||||
|
# Always search both categories so the UI can display them
|
||||||
|
# side by side with a "Videos" and "Songs" header.
|
||||||
|
raw_songs = yt.search(self._query, filter="songs", limit=20)
|
||||||
|
raw_videos = yt.search(self._query, filter="videos", limit=20)
|
||||||
|
|
||||||
|
songs = [
|
||||||
|
_build_song_dict(r) for r in raw_songs if r.get("videoId")
|
||||||
|
]
|
||||||
|
videos = [
|
||||||
|
_build_song_dict(r) for r in raw_videos if r.get("videoId")
|
||||||
|
]
|
||||||
|
self.results_ready.emit({"songs": songs, "videos": videos})
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Search failed: %s", exc)
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Main AudioPlayer
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class AudioPlayer(QObject):
|
||||||
|
"""QObject-based audio player wrapping QMediaPlayer with local-file playback.
|
||||||
|
|
||||||
|
Every song is downloaded to a temporary file before playing. A single
|
||||||
|
persistent ``DownloadWorker`` on a background ``QThread`` handles all
|
||||||
|
downloads — no thread creation/teardown, no signal-disconnect races, no
|
||||||
|
``quit()``/``wait()`` timeouts.
|
||||||
|
|
||||||
|
Signals
|
||||||
|
-------
|
||||||
|
loading(video_id)
|
||||||
|
song_started(song_dict)
|
||||||
|
song_ended(video_id)
|
||||||
|
paused(song_dict)
|
||||||
|
resumed(song_dict)
|
||||||
|
error(dict)
|
||||||
|
position_changed(ms: int)
|
||||||
|
duration_changed(ms: int)
|
||||||
|
playback_state_changed(state: str) — "playing"|"paused"|"stopped"
|
||||||
|
"""
|
||||||
|
|
||||||
|
loading = Signal(str)
|
||||||
|
song_started = Signal(dict)
|
||||||
|
song_ended = Signal(str)
|
||||||
|
paused = Signal(dict)
|
||||||
|
resumed = Signal(dict)
|
||||||
|
error = Signal(dict)
|
||||||
|
position_changed = Signal(int)
|
||||||
|
duration_changed = Signal(int)
|
||||||
|
playback_state_changed = Signal(str)
|
||||||
|
seeked = Signal(int) # position_ms after seek
|
||||||
|
|
||||||
|
def __init__(self, parent: Optional[QObject] = None):
|
||||||
|
super().__init__(parent)
|
||||||
|
|
||||||
|
# ── QMediaPlayer ──────────────────────────────────────────────
|
||||||
|
self._player = QMediaPlayer(self)
|
||||||
|
self._audio_output = QAudioOutput(self)
|
||||||
|
self._player.setAudioOutput(self._audio_output)
|
||||||
|
self._audio_output.setVolume(0.5)
|
||||||
|
|
||||||
|
self._player.mediaStatusChanged.connect(self._on_media_status)
|
||||||
|
self._player.positionChanged.connect(self._on_position)
|
||||||
|
self._player.durationChanged.connect(self._on_duration)
|
||||||
|
self._player.playbackStateChanged.connect(self._on_playback_state)
|
||||||
|
self._player.errorOccurred.connect(self._on_player_error_occurred)
|
||||||
|
|
||||||
|
# ── Queue & state ─────────────────────────────────────────────
|
||||||
|
self._queue: list[dict] = []
|
||||||
|
self._current: Optional[dict] = None
|
||||||
|
self._loop_mode = False
|
||||||
|
|
||||||
|
# ── Download worker (single persistent thread) ────────────────
|
||||||
|
self._next_task_id = 0
|
||||||
|
# The task ID that is "current" — results with other IDs are stale.
|
||||||
|
self._active_task_id: Optional[int] = None
|
||||||
|
# The task ID of the in-progress prefetch (if any).
|
||||||
|
self._prefetch_task_id: Optional[int] = None
|
||||||
|
# Cache of prefetched results: video_id → resolved dict with _local_path.
|
||||||
|
self._prefetch_cache: dict[str, dict] = {}
|
||||||
|
|
||||||
|
self._worker_thread = QThread(self)
|
||||||
|
self._worker = DownloadWorker()
|
||||||
|
self._worker.moveToThread(self._worker_thread)
|
||||||
|
|
||||||
|
# Connect worker signals (cross-thread → queued delivery).
|
||||||
|
self._worker.download_requested.connect(self._worker._do_download)
|
||||||
|
self._worker.cancel_requested.connect(self._worker._do_cancel)
|
||||||
|
self._worker.download_succeeded.connect(self._on_download_succeeded)
|
||||||
|
self._worker.download_failed.connect(self._on_download_failed)
|
||||||
|
self._worker_thread.start()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Public API
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
# ── Volume ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def set_volume(self, percent: int) -> None:
|
||||||
|
self._audio_output.setVolume(max(0, min(100, percent)) / 100.0)
|
||||||
|
|
||||||
|
def volume(self) -> int:
|
||||||
|
return int(self._audio_output.volume() * 100)
|
||||||
|
|
||||||
|
# ── Seek ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def seek(self, ms: int) -> None:
|
||||||
|
self._player.setPosition(max(0, ms))
|
||||||
|
self.seeked.emit(max(0, ms))
|
||||||
|
|
||||||
|
# ── Play / Queue ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def play(self, song: dict) -> None:
|
||||||
|
"""Clear the queue and play *song* immediately."""
|
||||||
|
self._cleanup_temp_files()
|
||||||
|
self._queue.clear()
|
||||||
|
self._prefetch_cache.clear()
|
||||||
|
self._loop_mode = False
|
||||||
|
self._request_download(song)
|
||||||
|
|
||||||
|
def queue_song(self, song: dict) -> None:
|
||||||
|
self._queue.append(song)
|
||||||
|
if self._current is None and self._active_task_id is None:
|
||||||
|
self._pop_and_play()
|
||||||
|
|
||||||
|
def queue_next(self, song: dict) -> None:
|
||||||
|
"""Insert *song* to play immediately after the current one."""
|
||||||
|
self._queue.insert(0, song)
|
||||||
|
|
||||||
|
def queue_list(self, songs: list[dict]) -> None:
|
||||||
|
self._queue.extend(songs)
|
||||||
|
|
||||||
|
def skip(self) -> None:
|
||||||
|
"""Skip to the next song."""
|
||||||
|
# Cancel any in-progress download (the skip is our new intent).
|
||||||
|
self._cancel_active_downloads()
|
||||||
|
self._player.stop()
|
||||||
|
self._advance()
|
||||||
|
|
||||||
|
def stop_playback(self) -> None:
|
||||||
|
"""Soft stop: halt playback, clear queue, cancel downloads."""
|
||||||
|
self._cancel_active_downloads()
|
||||||
|
self._cleanup_temp_files()
|
||||||
|
self._queue.clear()
|
||||||
|
self._prefetch_cache.clear()
|
||||||
|
self._loop_mode = False
|
||||||
|
self._current = None
|
||||||
|
self._player.stop()
|
||||||
|
self.playback_state_changed.emit("stopped")
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _pop_and_play(self) -> None:
|
||||||
|
"""Pop the next song from the queue and start downloading it.
|
||||||
|
|
||||||
|
If it was already prefetched, the download is instant (already cached).
|
||||||
|
"""
|
||||||
|
if not self._queue:
|
||||||
|
return
|
||||||
|
song = self._queue.pop(0)
|
||||||
|
vid = song.get("videoId") or song.get("video_id", "")
|
||||||
|
cached = self._prefetch_cache.pop(vid, None)
|
||||||
|
if cached is not None:
|
||||||
|
# Prefetched — play directly without downloading.
|
||||||
|
self._start_playback(cached)
|
||||||
|
else:
|
||||||
|
self._request_download(song)
|
||||||
|
|
||||||
|
# ── Pause / Resume ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def pause(self) -> None:
|
||||||
|
if self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self._player.pause()
|
||||||
|
|
||||||
|
def resume(self) -> None:
|
||||||
|
if self._player.playbackState() == QMediaPlayer.PlaybackState.PausedState:
|
||||||
|
self._player.play()
|
||||||
|
|
||||||
|
def toggle_pause(self) -> bool:
|
||||||
|
state = self._player.playbackState()
|
||||||
|
if state == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self._player.pause()
|
||||||
|
return True
|
||||||
|
elif state == QMediaPlayer.PlaybackState.PausedState:
|
||||||
|
self._player.play()
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_paused(self) -> bool:
|
||||||
|
return self._player.playbackState() == QMediaPlayer.PlaybackState.PausedState
|
||||||
|
|
||||||
|
def is_playing(self) -> bool:
|
||||||
|
return self._player.playbackState() == QMediaPlayer.PlaybackState.PlayingState
|
||||||
|
|
||||||
|
def is_stopped(self) -> bool:
|
||||||
|
return self._player.playbackState() == QMediaPlayer.PlaybackState.StoppedState
|
||||||
|
|
||||||
|
# ── Queue introspection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_current(self) -> Optional[dict]:
|
||||||
|
return self._current
|
||||||
|
|
||||||
|
def get_queue(self) -> list[dict]:
|
||||||
|
return list(self._queue)
|
||||||
|
|
||||||
|
def get_queue_length(self) -> int:
|
||||||
|
return len(self._queue)
|
||||||
|
|
||||||
|
def get_loop(self) -> bool:
|
||||||
|
return self._loop_mode
|
||||||
|
|
||||||
|
def set_loop(self, enabled: bool) -> None:
|
||||||
|
self._loop_mode = enabled
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Download lifecycle
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _request_download(self, song: dict) -> None:
|
||||||
|
"""Request a download via the persistent worker.
|
||||||
|
|
||||||
|
Cancels any in-progress download and prefetch first, then assigns a
|
||||||
|
fresh task ID and emits the request signal.
|
||||||
|
"""
|
||||||
|
vid = song.get("videoId") or song.get("video_id", "")
|
||||||
|
|
||||||
|
# Guard: don't re-download what's already playing.
|
||||||
|
current_vid = (
|
||||||
|
(self._current or {}).get("videoId")
|
||||||
|
or (self._current or {}).get("video_id", "")
|
||||||
|
)
|
||||||
|
if vid and vid == current_vid:
|
||||||
|
log.debug("Ignoring duplicate download request for %s", vid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fast path: already has a local file (e.g. from prefetch cache).
|
||||||
|
if song.get("_local_path"):
|
||||||
|
self._start_playback(song)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Cancel any in-flight work — this is a new intent.
|
||||||
|
self._cancel_active_downloads()
|
||||||
|
|
||||||
|
task_id = self._next_task_id
|
||||||
|
self._next_task_id += 1
|
||||||
|
self._active_task_id = task_id
|
||||||
|
|
||||||
|
self.loading.emit(vid)
|
||||||
|
self._worker.download_requested.emit(task_id, song)
|
||||||
|
|
||||||
|
def _cancel_active_downloads(self) -> None:
|
||||||
|
"""Tell the worker to discard its current work and forget about it."""
|
||||||
|
if self._active_task_id is not None or self._prefetch_task_id is not None:
|
||||||
|
self._worker.cancel_requested.emit()
|
||||||
|
self._active_task_id = None
|
||||||
|
self._prefetch_task_id = None
|
||||||
|
|
||||||
|
@Slot(int, dict)
|
||||||
|
def _on_download_succeeded(self, task_id: int, song: dict) -> None:
|
||||||
|
"""Called on the main thread when a download succeeds.
|
||||||
|
|
||||||
|
Checks *task_id* against ``_active_task_id`` and ``_prefetch_task_id``
|
||||||
|
to discard stale results from superseded requests.
|
||||||
|
"""
|
||||||
|
log.debug("Download succeeded: task_id=%d, active=%s, prefetch=%s",
|
||||||
|
task_id, self._active_task_id, self._prefetch_task_id)
|
||||||
|
|
||||||
|
# ── Stale check for active download ──────────────────────────
|
||||||
|
if task_id == self._active_task_id:
|
||||||
|
self._active_task_id = None
|
||||||
|
|
||||||
|
local_path = song.get("_local_path")
|
||||||
|
if not local_path or not os.path.isfile(local_path):
|
||||||
|
log.error("Download succeeded but file missing: %s", local_path)
|
||||||
|
self.error.emit({
|
||||||
|
"video_id": song.get("videoId", ""),
|
||||||
|
"error": "Downloaded file went missing",
|
||||||
|
})
|
||||||
|
self._try_next_song()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._start_playback(song)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Stale check for prefetch download ────────────────────────
|
||||||
|
if task_id == self._prefetch_task_id:
|
||||||
|
self._prefetch_task_id = None
|
||||||
|
|
||||||
|
vid = song.get("videoId") or song.get("video_id", "")
|
||||||
|
# Only cache if the song is still in the active queue.
|
||||||
|
if any(
|
||||||
|
(s.get("videoId") or s.get("video_id", "")) == vid
|
||||||
|
for s in self._queue
|
||||||
|
):
|
||||||
|
log.debug("Caching prefetched result for %s (task %d)", vid, task_id)
|
||||||
|
self._prefetch_cache[vid] = song
|
||||||
|
else:
|
||||||
|
log.debug("Prefetched %s no longer in queue, discarding", vid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Otherwise: truly stale — discard ────────────────────────────
|
||||||
|
log.debug("Discarding stale download result for task %d (task_id=%d, active=%s, prefetch=%s)",
|
||||||
|
task_id, task_id, self._active_task_id, self._prefetch_task_id)
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def _on_download_failed(self, task_id: int) -> None:
|
||||||
|
"""Called on the main thread when a download fails."""
|
||||||
|
log.debug("Download failed: task_id=%d, active=%s, prefetch=%s",
|
||||||
|
task_id, self._active_task_id, self._prefetch_task_id)
|
||||||
|
|
||||||
|
if task_id == self._active_task_id:
|
||||||
|
self._active_task_id = None
|
||||||
|
log.warning("Download failed for task %d", task_id)
|
||||||
|
self.error.emit({
|
||||||
|
"video_id": "",
|
||||||
|
"error": "Failed to resolve audio URL",
|
||||||
|
})
|
||||||
|
self._try_next_song()
|
||||||
|
elif task_id == self._prefetch_task_id:
|
||||||
|
self._prefetch_task_id = None
|
||||||
|
log.debug("Prefetch failed for task %d (harmless)", task_id)
|
||||||
|
else:
|
||||||
|
log.debug("Ignoring stale download failure for task %d", task_id)
|
||||||
|
|
||||||
|
# ── Playback ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_playback(self, song: dict) -> None:
|
||||||
|
"""Begin playing a resolved song (already has ``_local_path``).
|
||||||
|
|
||||||
|
Always called on the main thread.
|
||||||
|
"""
|
||||||
|
# Remove the previous song's temp file BEFORE updating _current.
|
||||||
|
self._cleanup_current_temp()
|
||||||
|
|
||||||
|
local_path = song.get("_local_path", "")
|
||||||
|
log.debug("Starting playback: path=%s, exists=%s, size=%s",
|
||||||
|
local_path,
|
||||||
|
os.path.isfile(local_path) if local_path else "N/A",
|
||||||
|
os.path.getsize(local_path) if local_path and os.path.isfile(local_path) else "N/A")
|
||||||
|
|
||||||
|
self._player.stop()
|
||||||
|
self._current = song
|
||||||
|
|
||||||
|
self._player.setSource(QUrl.fromLocalFile(local_path))
|
||||||
|
self._player.play()
|
||||||
|
self.song_started.emit(song)
|
||||||
|
|
||||||
|
# Kick off prefetch for the next queued song.
|
||||||
|
self._start_prefetch()
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# End-of-song handling
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _on_media_status(self, status: QMediaPlayer.MediaStatus) -> None:
|
||||||
|
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||||
|
self._advance()
|
||||||
|
elif status == QMediaPlayer.MediaStatus.InvalidMedia:
|
||||||
|
if self._current is None:
|
||||||
|
return
|
||||||
|
if self._player.playbackState() == QMediaPlayer.PlaybackState.StoppedState:
|
||||||
|
return
|
||||||
|
self.error.emit({
|
||||||
|
"video_id": (self._current or {}).get("videoId", ""),
|
||||||
|
"error": "Invalid media",
|
||||||
|
})
|
||||||
|
# Don't call _advance here — the player will go to stopped state,
|
||||||
|
# and _on_playback_state will handle advancing if appropriate.
|
||||||
|
# But let's advance immediately for invalid media.
|
||||||
|
self._advance()
|
||||||
|
|
||||||
|
def _advance(self) -> None:
|
||||||
|
"""Move to the next song (or stop if the queue is empty).
|
||||||
|
|
||||||
|
This is the single exit point from an actively-playing song.
|
||||||
|
It handles loop re-queuing, song_ended emission, and queue popping.
|
||||||
|
"""
|
||||||
|
if self._current is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
finished_song = self._current
|
||||||
|
finished_vid = finished_song.get("videoId") or finished_song.get(
|
||||||
|
"video_id", ""
|
||||||
|
)
|
||||||
|
self._current = None
|
||||||
|
|
||||||
|
self.song_ended.emit(finished_vid)
|
||||||
|
|
||||||
|
# Re-queue for loop mode.
|
||||||
|
if self._loop_mode:
|
||||||
|
self._queue.insert(0, finished_song)
|
||||||
|
|
||||||
|
self._try_next_song()
|
||||||
|
|
||||||
|
def _try_next_song(self) -> None:
|
||||||
|
"""Attempt to play the next song, or stop if the queue is empty."""
|
||||||
|
if self._queue:
|
||||||
|
self._pop_and_play()
|
||||||
|
else:
|
||||||
|
self.playback_state_changed.emit("stopped")
|
||||||
|
|
||||||
|
# ── Position / duration / state ────────────────────────────────────
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def _on_position(self, ms: int) -> None:
|
||||||
|
self.position_changed.emit(ms)
|
||||||
|
|
||||||
|
@Slot(int)
|
||||||
|
def _on_duration(self, ms: int) -> None:
|
||||||
|
self.duration_changed.emit(ms)
|
||||||
|
|
||||||
|
@Slot(QMediaPlayer.PlaybackState)
|
||||||
|
def _on_playback_state(self, state: QMediaPlayer.PlaybackState) -> None:
|
||||||
|
if state == QMediaPlayer.PlaybackState.PlayingState:
|
||||||
|
self.playback_state_changed.emit("playing")
|
||||||
|
if self._current:
|
||||||
|
self.resumed.emit(self._current)
|
||||||
|
elif state == QMediaPlayer.PlaybackState.PausedState:
|
||||||
|
self.playback_state_changed.emit("paused")
|
||||||
|
if self._current:
|
||||||
|
self.paused.emit(self._current)
|
||||||
|
else:
|
||||||
|
self.playback_state_changed.emit("stopped")
|
||||||
|
|
||||||
|
# ── errorOccurred handler ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Slot(QMediaPlayer.Error, str)
|
||||||
|
def _on_player_error_occurred(
|
||||||
|
self, error: QMediaPlayer.Error, error_string: str
|
||||||
|
) -> None:
|
||||||
|
"""Handle QMediaPlayer error signals."""
|
||||||
|
if error in (QMediaPlayer.Error.NetworkError, QMediaPlayer.Error.ResourceError):
|
||||||
|
vid = (self._current or {}).get("videoId", "")
|
||||||
|
log.warning("Player error (%s): %s — advancing", error, error_string)
|
||||||
|
self.error.emit({
|
||||||
|
"video_id": vid,
|
||||||
|
"error": f"{error_string} (local file issue)",
|
||||||
|
})
|
||||||
|
self._advance()
|
||||||
|
|
||||||
|
# ── Prefetch ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _start_prefetch(self) -> None:
|
||||||
|
"""Request a download of the next queued song (if any).
|
||||||
|
|
||||||
|
Called after playback of the current song starts. Uses the same
|
||||||
|
persistent worker — no separate thread needed.
|
||||||
|
"""
|
||||||
|
if not self._queue:
|
||||||
|
return
|
||||||
|
|
||||||
|
next_song = self._queue[0]
|
||||||
|
vid = next_song.get("videoId") or next_song.get("video_id", "")
|
||||||
|
|
||||||
|
# Already cached or already downloading.
|
||||||
|
if vid in self._prefetch_cache:
|
||||||
|
return
|
||||||
|
if vid == self._prefetch_task_id:
|
||||||
|
return
|
||||||
|
# Don't prefetch if it's already the active download target.
|
||||||
|
if self._active_task_id is not None:
|
||||||
|
# The active download might be for this same song (e.g. if
|
||||||
|
# _request_download was called but is still in flight). Check by
|
||||||
|
# tracking task_id alone is enough — if there's an active download,
|
||||||
|
# the prefetch is redundant anyway.
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = self._next_task_id
|
||||||
|
self._next_task_id += 1
|
||||||
|
self._prefetch_task_id = task_id
|
||||||
|
|
||||||
|
self._worker.download_requested.emit(task_id, next_song)
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
# Temp-file cleanup
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
def _cleanup_current_temp(self) -> None:
|
||||||
|
"""Remove the temp file of the song we're about to replace."""
|
||||||
|
if self._current is None:
|
||||||
|
return
|
||||||
|
lp = self._current.get("_local_path")
|
||||||
|
if lp and os.path.isfile(lp):
|
||||||
|
try:
|
||||||
|
os.unlink(lp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _cleanup_temp_files(self) -> None:
|
||||||
|
"""Remove all temp files we know about (current + prefetch cache)."""
|
||||||
|
if self._current:
|
||||||
|
lp = self._current.get("_local_path")
|
||||||
|
if lp and os.path.isfile(lp):
|
||||||
|
try:
|
||||||
|
os.unlink(lp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
for cached in self._prefetch_cache.values():
|
||||||
|
lp = cached.get("_local_path")
|
||||||
|
if lp and os.path.isfile(lp):
|
||||||
|
try:
|
||||||
|
os.unlink(lp)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ── Shutdown ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"""Clean shutdown — stop playback, quit worker thread, remove temps."""
|
||||||
|
self._player.stop()
|
||||||
|
self._cancel_active_downloads()
|
||||||
|
self._cleanup_temp_files()
|
||||||
|
self._queue.clear()
|
||||||
|
self._prefetch_cache.clear()
|
||||||
|
self._current = None
|
||||||
|
self._loop_mode = False
|
||||||
|
|
||||||
|
# Tear down the persistent worker thread.
|
||||||
|
self._worker_thread.quit()
|
||||||
|
self._worker_thread.wait(3000)
|
||||||
|
# The worker and thread objects are owned by self (parented), so
|
||||||
|
# they'll be cleaned up by Qt when the AudioPlayer is destroyed.
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _best_thumbnail(thumbnails) -> str:
|
||||||
|
if not thumbnails:
|
||||||
|
return ""
|
||||||
|
if isinstance(thumbnails, str):
|
||||||
|
return thumbnails
|
||||||
|
if isinstance(thumbnails, list):
|
||||||
|
best = thumbnails[-1]
|
||||||
|
return best.get("url", "") if isinstance(best, dict) else str(best)
|
||||||
|
return str(thumbnails)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_song_dict(search_result: dict) -> dict:
|
||||||
|
"""Normalise a ytmusicapi result into our standard song dict."""
|
||||||
|
return {
|
||||||
|
"videoId": search_result.get("videoId", ""),
|
||||||
|
"title": search_result.get("title", "Unknown"),
|
||||||
|
"artists": search_result.get("artists") or [],
|
||||||
|
"album": search_result.get("album"),
|
||||||
|
"duration": search_result.get("duration_seconds", 0),
|
||||||
|
"duration_label": search_result.get("duration", "?"),
|
||||||
|
"thumbnail": _best_thumbnail(search_result.get("thumbnails", [])),
|
||||||
|
"videoType": search_result.get("videoType", ""),
|
||||||
|
"resultType": search_result.get("resultType", "song"),
|
||||||
|
}
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
yt-dlp>=2024.0.0
|
||||||
|
ytmusicapi>=1.0.0
|
||||||
|
pypresence>=4.0.0
|
||||||
|
PySide6>=6.5.0
|
||||||
4
sonar-project.properties
Normal file
4
sonar-project.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
sonar.projectKey=Tunetti
|
||||||
|
sonar.projectName=Tunetti
|
||||||
|
sonar.sources=.
|
||||||
|
sonar.language=py
|
||||||
Reference in New Issue
Block a user