feat: add GUI mvp

This commit is contained in:
2026-05-16 22:17:01 +03:00
parent 9c9dd283a6
commit 903389d73c
20 changed files with 1814 additions and 27 deletions
+7
View File
@@ -99,3 +99,10 @@ class Database:
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
(playlist_id,),
)
def get_playlist_last_sync(self, playlist_id: str) -> str | None:
cur = self._conn.execute("SELECT last_sync FROM playlists WHERE id = ?", (playlist_id,))
row = cur.fetchone()
if not row:
return None
return row["last_sync"]
+34 -1
View File
@@ -46,6 +46,11 @@ class Downloader:
async def handle_job(self, job: DownloadJob):
try:
cancel_check = getattr(job, "cancel_check", None)
if callable(cancel_check) and cancel_check():
job.state = JobState.CANCELLED
job.error = "cancelled"
return
job.state = JobState.DOWNLOADING
await self._download(job)
# Optional local audio extraction when requested
@@ -69,6 +74,10 @@ class Downloader:
def run():
import yt_dlp # type: ignore
from pathlib import Path
try:
from yt_dlp.utils import DownloadCancelled # type: ignore
except Exception: # pragma: no cover - optional
DownloadCancelled = Exception # type: ignore[misc,assignment]
class _QuietLogger:
def debug(self, msg):
@@ -99,9 +108,13 @@ class Downloader:
}
progress_cb = getattr(job, "progress_callback", None)
cancel_check = getattr(job, "cancel_check", None)
if progress_cb is not None:
def hook(d):
try:
if callable(cancel_check) and cancel_check():
raise DownloadCancelled("cancelled")
import time
payload = {
"status": d.get("status"),
"downloaded_bytes": d.get("downloaded_bytes"),
@@ -114,7 +127,27 @@ class Downloader:
done = payload.get("downloaded_bytes")
if total and done is not None:
payload["progress"] = float(done) / float(total)
progress_cb(payload)
# Throttle progress events to avoid freezing the GUI event loop.
# Always forward terminal states.
status = str(payload.get("status") or "")
now = time.monotonic()
last_ts = float(getattr(job, "_last_progress_emit_ts", 0.0) or 0.0)
last_pct = float(getattr(job, "_last_progress_emit_pct", -1.0) or -1.0)
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
should_emit = status in {"finished", "error"}
if not should_emit:
if now - last_ts >= 0.25:
should_emit = True
elif pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01):
should_emit = True
if should_emit:
setattr(job, "_last_progress_emit_ts", now)
if pct is not None:
setattr(job, "_last_progress_emit_pct", pct)
progress_cb(payload)
except Exception:
pass
+1
View File
@@ -31,6 +31,7 @@ class DownloadJob:
max_download_quality: Optional[str] = None
playlist_id: Optional[str] = None
progress_callback: Optional[Callable[[dict[str, Any]], None]] = None
cancel_check: Optional[Callable[[], bool]] = None
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
keep_video: bool = True
+9
View File
@@ -4,6 +4,7 @@ import asyncio
import logging
from .downloader import Downloader
from .queue_manager import DownloadJob, JobState
from ..utils.rate_limit import is_youtube_rate_limit_error
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
@@ -12,8 +13,16 @@ async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_second
attempt = 0
while attempt <= max_retries:
await dl.handle_job(job)
if job.state == JobState.CANCELLED:
return
if job.state == JobState.COMPLETED:
return
if is_youtube_rate_limit_error(job.error):
# Do not retry bot-check/rate-limit style errors; caller will pause.
return
if (job.error or "").strip().lower() == "cancelled":
job.state = JobState.CANCELLED
return
attempt += 1
if attempt <= max_retries:
wait = delay_seconds * (2 ** (attempt - 1))
+107 -6
View File
@@ -14,6 +14,7 @@ from ..database.db import Database
from ..utils.yt import extract_playlist_id
from ..events.event_bus import EventBus
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
from ..utils.rate_limit import is_youtube_rate_limit_error
class ActionExecutor:
@@ -22,7 +23,7 @@ class ActionExecutor:
self.db = db
self.bus = event_bus
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict, *, cancel_check=None, pause_check=None) -> None:
actions_list = list(actions)
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
start = time.monotonic()
@@ -40,6 +41,8 @@ class ActionExecutor:
},
)
if not await self._wait_if_paused(pause_check, cancel_check):
return
self._preflight_dependencies(actions_list, playlist_cfg)
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
@@ -52,24 +55,54 @@ class ActionExecutor:
video_root.mkdir(parents=True, exist_ok=True)
# First, handle renames safely in batch per extension
if not await self._wait_if_paused(pause_check, cancel_check):
return
await self._apply_renames(actions_list, audio_root, video_root, playlist_cfg)
# Then, recycle deletions
if not await self._wait_if_paused(pause_check, cancel_check):
return
self._apply_deletions(actions_list, audio_root, video_root, playlist_cfg)
# Finally, perform downloads concurrently
await self._apply_downloads(actions_list, mode, audio_root, video_root, playlist_cfg)
if not await self._wait_if_paused(pause_check, cancel_check):
return
await self._apply_downloads(
actions_list,
mode,
audio_root,
video_root,
playlist_cfg,
cancel_check=cancel_check,
pause_check=pause_check,
)
duration_s = round(time.monotonic() - start, 3)
# Persist last sync timestamp (single source of truth for CLI/GUI/automation).
try:
self.db.set_playlist_last_sync(playlist_id)
last_sync = self.db.get_playlist_last_sync(playlist_id)
except Exception:
last_sync = None
summary = {
"playlist_id": playlist_id,
"duration_s": duration_s,
"counts": dict(counts),
"last_sync": last_sync,
}
if self.bus:
await self.bus.publish("SyncSummary", dict(summary))
await self.bus.publish("SyncFinished", dict(summary))
async def _wait_if_paused(self, pause_check, cancel_check) -> bool:
if not callable(pause_check):
return True
while pause_check():
if callable(cancel_check) and cancel_check():
return False
await asyncio.sleep(0.1)
return True
def _preflight_dependencies(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
"""
Fail fast on core runtime dependencies before doing any filesystem work.
@@ -154,7 +187,17 @@ class ActionExecutor:
if self.bus:
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
async def _apply_downloads(self, actions: Iterable[SyncAction], mode: str, audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
async def _apply_downloads(
self,
actions: Iterable[SyncAction],
mode: str,
audio_root: Path,
video_root: Path,
playlist_cfg: dict,
*,
cancel_check=None,
pause_check=None,
) -> None:
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
loop = asyncio.get_running_loop()
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
@@ -175,8 +218,22 @@ class ActionExecutor:
except Exception:
retry_delay_seconds = 1.5
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
try:
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
except Exception:
delay_between_downloads_seconds = 0.0
rate_limit_pause = asyncio.Event()
rate_limit_emitted = False
async def worker(job: DownloadJob):
nonlocal rate_limit_emitted
job.playlist_id = playlist_id
job.cancel_check = cancel_check
if not await self._wait_if_paused(pause_check, cancel_check):
job.error = "cancelled"
return
if self.bus:
def _progress_cb(info: dict):
@@ -192,6 +249,23 @@ class ActionExecutor:
await self.bus.publish("DownloadStarted", {"playlist_id": playlist_id, "video_id": job.item.video_id, "target": str(job.output_path)})
await default_worker(job, max_retries=retry_max_retries, delay_seconds=retry_delay_seconds)
# If we hit YouTube bot-check / rate-limit, pause the whole playlist sync:
# - stop scheduling/processing more jobs
# - surface a single SyncPaused event
if is_youtube_rate_limit_error(getattr(job, "error", None)):
rate_limit_pause.set()
if self.bus and not rate_limit_emitted:
rate_limit_emitted = True
await self.bus.publish(
"SyncPaused",
{"playlist_id": playlist_id, "video_id": getattr(getattr(job, "item", None), "video_id", None), "reason": "paused due to youtube rate limits"},
)
return
if delay_between_downloads_seconds > 0 and not (callable(cancel_check) and cancel_check()):
# Gentle throttle between jobs to reduce rate limiting.
await asyncio.sleep(delay_between_downloads_seconds)
await queue.start(worker)
try:
jobs: List[DownloadJob] = []
@@ -214,6 +288,12 @@ class ActionExecutor:
temp_video_root.mkdir(parents=True, exist_ok=True)
for a in actions:
if callable(cancel_check) and cancel_check():
break
if not await self._wait_if_paused(pause_check, cancel_check):
break
if rate_limit_pause.is_set():
break
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
continue
vid = a.item.video_id
@@ -280,8 +360,22 @@ class ActionExecutor:
jobs.append(job)
await queue.enqueue(job)
finally:
await queue.join() # wait for all jobs
await queue.stop()
join_task = asyncio.create_task(queue.join())
try:
while not join_task.done():
if callable(cancel_check) and cancel_check():
join_task.cancel()
break
if callable(pause_check) and pause_check():
# Pause requested: stop starting more work and return control.
join_task.cancel()
break
if rate_limit_pause.is_set():
join_task.cancel()
break
await asyncio.sleep(0.1)
finally:
await queue.stop()
# Persist DB updates for completed jobs
for job in locals().get("jobs", []):
@@ -298,6 +392,13 @@ class ActionExecutor:
# Ensure not marked as downloaded if failed
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
if self.bus:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": job.error or "unknown"})
err = job.error or "unknown"
if is_youtube_rate_limit_error(err):
await self.bus.publish(
"DownloadFailed",
{"playlist_id": playlist_id, "video_id": job.item.video_id, "error": "paused due to youtube rate limits"},
)
else:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
except Exception:
pass
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
def is_youtube_rate_limit_error(message: str | None) -> bool:
"""
Best-effort detection of YouTube "bot check" / auth-gated extraction failures.
yt-dlp typically surfaces this as:
- "Sign in to confirm youre not a bot"
- mentions of --cookies / --cookies-from-browser
"""
if not message:
return False
s = str(message).lower()
needles = [
"sign in to confirm",
"you're not a bot",
"youre not a bot",
"--cookies-from-browser",
"--cookies",
]
return any(n in s for n in needles)
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
+40
View File
@@ -0,0 +1,40 @@
from __future__ import annotations
from typing import Any, Dict
from PySide6 import QtCore
from ..core.events.event_bus import EventBus
class BusBridge(QtCore.QObject):
"""
Bridges backend EventBus async events onto the Qt main thread.
Emits `event(name, payload)` as a Qt signal, thread-safe.
"""
event = QtCore.Signal(str, dict)
def __init__(self, bus: EventBus, parent: QtCore.QObject | None = None) -> None:
super().__init__(parent)
self._bus = bus
for name in (
"SyncStarted",
"SyncSummary",
"SyncFinished",
"DownloadStarted",
"DownloadProgress",
"DownloadCompleted",
"DownloadFailed",
"RenameApplied",
"FileRecycled",
):
self._bus.subscribe(name, self._make_handler(name))
def _make_handler(self, name: str):
async def handler(payload: Dict[str, Any]) -> None:
# Ensure delivery on the Qt main thread.
self.event.emit(name, dict(payload))
return handler
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
@dataclass(frozen=True)
class AppConfig:
data: Dict[str, Any]
path: Path
def load_config(path: Path) -> AppConfig:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("config root must be a JSON object")
return AppConfig(data=raw, path=path)
def save_config(path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
path.write_text(payload, encoding="utf-8")
def normalize_config(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Ensure basic expected shape for config dict.
Keeps unknown keys intact.
"""
out = dict(data)
pls = out.get("playlists")
if not isinstance(pls, list):
out["playlists"] = []
return out
+287
View File
@@ -0,0 +1,287 @@
from __future__ import annotations
import sys
import threading
from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings
from ..core.events.event_bus import EventBus
from .bus_bridge import BusBridge
from .runner import SyncRequest, SyncRunner
from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
from .pages.logs import LogsPage
from .pages.settings import SettingsPage
class MainWindow(QtWidgets.QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("ytpl-sync")
self.resize(1100, 700)
self._settings = Settings()
self._bus = EventBus()
self._bridge = BusBridge(self._bus)
self._thread: QtCore.QThread | None = None
self._runner: SyncRunner | None = None
self._cancel_flag: threading.Event | None = None
self._pause_flag: threading.Event | None = None
# Sidebar navigation
self._nav = QtWidgets.QListWidget()
self._nav.setObjectName("sidebar")
self._nav.setFixedWidth(220)
self._nav.setSpacing(2)
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
self._stack = QtWidgets.QStackedWidget()
self._playlists_page = PlaylistManagerPage(self._settings)
self._queue_page = QueuePage()
self._logs_page = LogsPage()
self._settings_page = SettingsPage()
self._pages: list[QtWidgets.QWidget] = [
self._playlists_page,
self._queue_page,
self._logs_page,
self._settings_page,
]
for p in self._pages:
self._stack.addWidget(p)
for label in ("Playlists", "Queue", "Logs", "Settings"):
item = QtWidgets.QListWidgetItem(label)
item.setSizeHint(QtCore.QSize(200, 36))
self._nav.addItem(item)
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
self._nav.setCurrentRow(0)
# Layout
root = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout(root)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self._nav)
layout.addWidget(self._stack, 1)
self.setCentralWidget(root)
self._bridge.event.connect(self._on_bus_event)
self._apply_style()
# Provide Settings page a concrete config path.
cfg_path = getattr(self._settings, "path", None)
if cfg_path is not None:
try:
self._settings_page.set_config_path(cfg_path)
except Exception:
pass
self._playlists_page.cancel_requested.connect(self._cancel_sync)
self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
self._playlists_page.sync_one_requested.connect(self._sync_playlist_index)
self._playlists_page.sync_all_requested.connect(self._sync_all)
self._playlists_page.pause_requested.connect(self._pause_sync)
self._playlists_page.resume_requested.connect(self._resume_sync)
self._refresh_queue_labels()
def _refresh_queue_labels(self) -> None:
try:
from ..core.utils.yt import extract_playlist_id
labels: dict[str, str] = {}
for idx, pl in enumerate(self._settings.playlists, start=1):
url = str(pl.get("url") or "")
pid = extract_playlist_id(url) or url
labels[pid] = str(pl.get("name") or f"Playlist {idx}")
self._queue_page.set_playlist_labels(labels)
except Exception:
pass
@QtCore.Slot(str, dict)
def _on_bus_event(self, name: str, payload: dict) -> None:
# Fan out to interested pages.
try:
self._queue_page.on_event(name, payload)
except Exception:
pass
try:
self._logs_page.on_event(name, payload)
except Exception:
pass
try:
self._playlists_page.on_event(name, payload)
except Exception:
pass
# Auto-pause on YouTube bot-check/rate-limit surface.
if name == "SyncPaused":
self._pause_sync()
def _sync_playlist_index(self, index: int) -> None:
playlists = self._settings.playlists
if index < 0 or index >= len(playlists):
return
cfg = dict(playlists[index])
self._refresh_queue_labels()
self._playlists_page.set_running(True)
# Stop any previous run
if self._thread is not None:
self._thread.quit()
self._thread.wait(2000)
self._thread = None
self._runner = None
self._cancel_flag = None
self._thread = QtCore.QThread()
self._cancel_flag = threading.Event()
self._pause_flag = threading.Event()
self._runner = SyncRunner(self._bus)
self._runner.moveToThread(self._thread)
self._runner.set_request(SyncRequest(playlist_cfg=cfg, apply=True, cancel_flag=self._cancel_flag, pause_flag=self._pause_flag))
self._thread.started.connect(self._runner.run_current)
self._runner.finished.connect(self._on_sync_finished)
self._runner.finished.connect(self._thread.quit)
self._thread.start()
def _sync_all(self) -> None:
# Run playlists sequentially (simple + predictable).
if self._thread is not None:
return
self._sync_queue = list(range(len(self._settings.playlists)))
if not self._sync_queue:
return
self._playlists_page.set_running(True)
self._sync_playlist_index(self._sync_queue.pop(0))
@QtCore.Slot(bool, str)
def _on_sync_finished(self, ok: bool, message: str) -> None:
if not ok:
self._logs_page.on_event("SyncError", {"error": message})
self._playlists_page.set_running(False)
# Mark idle so "Sync all" can be started again.
if self._thread is not None:
try:
self._thread.quit()
self._thread.wait(2000)
except Exception:
pass
self._runner = None
self._cancel_flag = None
self._pause_flag = None
self._thread = None
# Reload config in case playlists/settings changed externally during run.
try:
self._settings = Settings()
self._playlists_page.reload_from_config()
cfg_path = getattr(self._settings, "path", None)
if cfg_path is not None:
self._settings_page.set_config_path(cfg_path)
self._refresh_queue_labels()
except Exception:
pass
# Continue "sync all" chain if active.
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"):
nxt = getattr(self, "_sync_queue").pop(0)
self._sync_playlist_index(nxt)
@QtCore.Slot()
def _cancel_sync(self) -> None:
if self._cancel_flag is not None:
self._cancel_flag.set()
if self._pause_flag is not None:
self._pause_flag.clear()
def _pause_sync(self) -> None:
if self._pause_flag is not None:
self._pause_flag.set()
def _resume_sync(self) -> None:
if self._pause_flag is not None:
self._pause_flag.clear()
def _apply_style(self) -> None:
self.setStyleSheet(
"""
QMainWindow { background: #0f1115; color: #e6e6e6; }
QWidget { font-size: 13px; }
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
QListWidget#sidebar {
background: #0b0d11;
border-right: 1px solid #20242d;
padding: 8px;
}
QListWidget#sidebar::item {
color: #cfd3da;
border-radius: 8px;
padding: 8px 10px;
}
QListWidget#sidebar::item:selected {
background: #1e2633;
color: #ffffff;
}
QTableWidget {
background: #0f1115;
gridline-color: #20242d;
border: 1px solid #20242d;
}
QHeaderView::section {
background: #0b0d11;
color: #cfd3da;
border: 1px solid #20242d;
padding: 6px;
}
QPushButton {
background: #1e2633;
border: 1px solid #2a3140;
padding: 6px 10px;
border-radius: 8px;
color: #e6e6e6;
}
QPushButton:hover { background: #243044; }
QFrame#playlistCard {
background: #0b0d11;
border: 1px solid #20242d;
border-radius: 10px;
padding: 10px;
}
QLineEdit, QComboBox {
background: #0f1115;
border: 1px solid #20242d;
border-radius: 8px;
padding: 6px 8px;
color: #e6e6e6;
}
"""
)
def main() -> int:
app = QtWidgets.QApplication(sys.argv)
app.setApplicationName("ytpl-sync")
app.setOrganizationName("ytpl-sync")
app.setWindowIcon(QtGui.QIcon())
# Avoid Qt warnings when a font with invalid point size is inherited from the environment.
f = app.font()
if f.pointSize() <= 0:
f.setPointSize(10)
app.setFont(f)
w = MainWindow()
w.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import json
from PySide6 import QtWidgets
from ..smooth_scroll import enable_smooth_scrolling
class LogsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Logs")
title.setObjectName("pageTitle")
top = QtWidgets.QHBoxLayout()
top.addWidget(title)
top.addStretch(1)
clear_btn = QtWidgets.QPushButton("Clear")
clear_btn.clicked.connect(self._clear)
top.addWidget(clear_btn)
layout.addLayout(top)
self._text = QtWidgets.QPlainTextEdit()
self._text.setReadOnly(True)
enable_smooth_scrolling(self._text)
layout.addWidget(self._text, 1)
def _clear(self) -> None:
self._text.clear()
def on_event(self, name: str, payload: dict) -> None:
# Avoid flooding the UI with high-frequency progress updates.
if name == "DownloadProgress":
return
# Keep this lightweight: append a single-line JSON entry.
try:
line = json.dumps({"event": name, **payload}, ensure_ascii=False)
except Exception:
line = f"{name}: {payload}"
self._text.appendPlainText(line)
self._text.moveCursor(self._text.textCursor().End)
+624
View File
@@ -0,0 +1,624 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pathlib import Path
from PySide6 import QtCore, QtGui, QtWidgets
from ...config.settings import Settings
from ...core.database.db import Database
from ...core.utils.yt import extract_playlist_id
from ..smooth_scroll import enable_smooth_scrolling
from ..config_store import load_config, normalize_config, save_config
@dataclass(frozen=True)
class PlaylistRow:
name: str
url: str
download_mode: str
max_download_quality: str
save_path: str
class PlaylistManagerPage(QtWidgets.QWidget):
cancel_requested = QtCore.Signal()
sync_one_requested = QtCore.Signal(int)
sync_all_requested = QtCore.Signal()
pause_requested = QtCore.Signal()
resume_requested = QtCore.Signal()
def __init__(
self,
settings: Settings,
*,
parent: QtWidgets.QWidget | None = None,
) -> None:
super().__init__(parent)
self._settings = settings
self._config_path = getattr(settings, "path", None)
self._config: dict[str, Any] = {}
self._download_state_by_pid: dict[str, dict[str, Any]] = {}
self._suppress_autosave = False
self._autosave_timer = QtCore.QTimer(self)
self._autosave_timer.setSingleShot(True)
self._autosave_timer.setInterval(600)
self._autosave_timer.timeout.connect(self._autosave_now)
header = QtWidgets.QLabel("Playlists")
header.setObjectName("pageTitle")
self._list = QtWidgets.QListWidget()
# Selection-based UI is intentionally disabled; actions happen per-card.
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
self._list.setSpacing(8)
self._list.setUniformItemSizes(False)
self._list.setWordWrap(True)
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
enable_smooth_scrolling(self._list)
self._add_btn = QtWidgets.QPushButton("Add")
self._add_btn.clicked.connect(self._add_playlist)
self._save_btn = QtWidgets.QPushButton("Save config")
self._save_btn.clicked.connect(self._save_config)
self._sync_all_btn = QtWidgets.QPushButton("Sync all")
self._sync_all_btn.clicked.connect(self.sync_all_requested.emit)
self._cancel_btn = QtWidgets.QPushButton("Cancel all")
self._cancel_btn.setEnabled(False)
self._cancel_btn.clicked.connect(self._cancel_sync)
self._refresh_btn = QtWidgets.QPushButton("Reload config")
self._refresh_btn.clicked.connect(self.reload_from_config)
self._status = QtWidgets.QLabel("")
self._status.setWordWrap(True)
self._sync_state = QtWidgets.QLabel("")
self._sync_state.setWordWrap(True)
self._sync_state.setStyleSheet("color: #9fb0c6;")
top = QtWidgets.QHBoxLayout()
top.addWidget(header)
top.addStretch(1)
top.addWidget(self._add_btn)
top.addWidget(self._save_btn)
top.addWidget(self._sync_all_btn)
top.addWidget(self._cancel_btn)
top.addWidget(self._refresh_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(top)
layout.addWidget(self._list, 1)
layout.addWidget(self._sync_state)
layout.addWidget(self._status)
self.reload_from_config()
def _rows_from_settings(self) -> list[PlaylistRow]:
rows: list[PlaylistRow] = []
for idx, pl in enumerate(self._settings.playlists, start=1):
name = str(pl.get("name") or f"Playlist {idx}")
rows.append(
PlaylistRow(
name=name,
url=str(pl.get("url") or ""),
download_mode=str(pl.get("download_mode") or ""),
max_download_quality=str(pl.get("max_download_quality") or ""),
save_path=str(pl.get("save_path") or ""),
)
)
return rows
@QtCore.Slot()
def reload_from_config(self) -> None:
try:
self._suppress_autosave = True
self._settings = Settings()
self._config_path = getattr(self._settings, "path", None)
if self._config_path is None:
raise RuntimeError("Config path not available")
self._config = normalize_config(load_config(self._config_path).data)
rows = self._rows_from_settings()
except Exception as exc:
self._status.setText(f"Failed to load config: {exc}")
return
finally:
self._suppress_autosave = False
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
last_sync_by_id: dict[str, str] = {}
try:
db = Database(Path("app/data/app.db").resolve())
for r in rows:
pid = extract_playlist_id(r.url) or r.url
ls = db.get_playlist_last_sync(pid)
if ls:
last_sync_by_id[pid] = str(ls)
except Exception:
last_sync_by_id = {}
self._list.clear()
for idx, r in enumerate(rows):
pid = extract_playlist_id(r.url) or r.url
widget = _PlaylistCard(r, index=idx, last_sync=last_sync_by_id.get(pid))
widget.sync_clicked.connect(self.sync_one_requested.emit)
widget.remove_clicked.connect(self._remove_at_index)
widget.cancel_clicked.connect(lambda _pid: self._cancel_sync())
widget.pause_changed.connect(self._on_pause_changed)
widget.changed.connect(self._schedule_autosave)
item = QtWidgets.QListWidgetItem()
item.setSizeHint(widget.sizeHint())
self._list.addItem(item)
self._list.setItemWidget(item, widget)
cfg_path = getattr(self._settings, "path", None)
self._status.setText(f"Loaded {len(rows)} playlists from {cfg_path}.")
@QtCore.Slot()
def _cancel_sync(self) -> None:
# Actual cancellation is handled by MainWindow; this is UI intent.
self._status.setText("Cancelling…")
self.cancel_requested.emit()
def set_running(self, running: bool) -> None:
self._sync_all_btn.setEnabled(not running)
self._cancel_btn.setEnabled(running)
self._save_btn.setEnabled(not running)
self._add_btn.setEnabled(not running)
self._refresh_btn.setEnabled(not running)
# Keep the list enabled so per-card Pause/Cancel remains clickable.
self._list.setEnabled(True)
# But freeze editing while a sync is running to avoid racey config edits.
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
w.set_editing_enabled(not running)
@QtCore.Slot()
def _add_playlist(self) -> None:
r = PlaylistRow(
name="New Playlist",
url="https://www.youtube.com/playlist?list=",
download_mode="video",
max_download_quality="1080p",
save_path="./downloads",
)
widget = _PlaylistCard(r, index=self._list.count())
widget.sync_clicked.connect(self.sync_one_requested.emit)
widget.remove_clicked.connect(self._remove_at_index)
widget.cancel_clicked.connect(lambda _pid: self._cancel_sync())
widget.pause_changed.connect(self._on_pause_changed)
widget.changed.connect(self._schedule_autosave)
item = QtWidgets.QListWidgetItem()
item.setSizeHint(widget.sizeHint())
self._list.addItem(item)
self._list.setItemWidget(item, widget)
self._schedule_autosave()
@QtCore.Slot()
def _remove_at_index(self, index: int) -> None:
if index < 0 or index >= self._list.count():
return
self._list.takeItem(index)
self._reindex_cards()
self._schedule_autosave()
@QtCore.Slot(bool)
def _on_pause_changed(self, paused: bool) -> None:
if paused:
self.pause_requested.emit()
self._sync_state.setText("Paused")
else:
self.resume_requested.emit()
self._sync_state.setText("Resumed")
def _table_to_playlists(self) -> list[dict[str, Any]]:
playlists: list[dict[str, Any]] = []
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if not isinstance(w, _PlaylistCard):
continue
pl = w.to_dict()
playlists.append(pl)
return playlists
@QtCore.Slot()
def _save_config(self) -> None:
if self._config_path is None:
self._status.setText("No config path loaded.")
return
try:
if not self._validate_all(show_status=True):
return
data = dict(self._config or {})
data["playlists"] = self._table_to_playlists()
save_config(self._config_path, data)
self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.")
# Reload settings to reflect merged defaults
self.reload_from_config()
except Exception as exc:
self._status.setText(f"Failed to save config: {exc}")
def _reindex_cards(self) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
w.set_index(i)
def _validate_all(self, *, show_status: bool) -> bool:
ok = True
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
errs = w.validate()
w.set_status("; ".join(errs) if errs else "")
if errs:
ok = False
if not ok and show_status:
self._status.setText("Fix invalid playlists before saving/syncing.")
return ok
@QtCore.Slot()
def _schedule_autosave(self) -> None:
if self._suppress_autosave:
return
if not self.isEnabled():
return
self._autosave_timer.start()
@QtCore.Slot()
def _autosave_now(self) -> None:
if self._config_path is None:
return
if self._suppress_autosave:
return
if not self._validate_all(show_status=False):
# Don't autosave invalid configs; user sees inline errors.
return
try:
data = dict(self._config or {})
data["playlists"] = self._table_to_playlists()
save_config(self._config_path, data)
self._status.setText(f"Autosaved to {self._config_path}.")
except Exception as exc:
self._status.setText(f"Autosave failed: {exc}")
def on_event(self, name: str, payload: dict) -> None:
if name == "SyncStarted":
pid = payload.get("playlist_id")
total = payload.get("actions_total")
self._sync_state.setText(f"Sync started: {pid} ({total} actions)")
self._set_card_status(str(pid or ""), "running")
self._set_active_card(str(pid or ""), running=True, paused=False)
elif name == "SyncSummary":
pid = payload.get("playlist_id")
dur = payload.get("duration_s")
counts = payload.get("counts")
self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}")
self._set_card_status(str(pid or ""), f"done in {dur}s")
ls = payload.get("last_sync")
if ls:
self._set_card_last_sync(str(pid or ""), str(ls))
elif name == "SyncFinished":
pid = payload.get("playlist_id")
self._sync_state.setText(f"Sync finished: {pid}")
self._set_card_status(str(pid or ""), "finished")
self._set_active_card(str(pid or ""), running=False, paused=False)
self.set_running(False)
elif name == "SyncError":
self._sync_state.setText(f"Sync error: {payload.get('error')}")
self.set_running(False)
# Ensure any card in "pause" mode returns to Sync.
pid = str(payload.get("playlist_id") or "")
if pid:
self._set_active_card(pid, running=False, paused=False)
elif name == "DownloadStarted":
pid = str(payload.get("playlist_id") or "")
vid = str(payload.get("video_id") or "")
if not pid:
return
self._download_state_by_pid[pid] = {"video_id": vid, "progress": 0.0, "status": "started"}
self._set_card_progress(pid, 0.0)
self._set_card_status(pid, f"downloading {vid}".strip())
elif name == "DownloadProgress":
pid = str(payload.get("playlist_id") or "")
vid = str(payload.get("video_id") or "")
prog = payload.get("progress")
if not pid:
return
if isinstance(prog, (int, float)):
p = float(prog)
self._download_state_by_pid.setdefault(pid, {})["progress"] = p
if vid:
self._download_state_by_pid[pid]["video_id"] = vid
self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading")
self._set_card_progress(pid, p)
pct = int(round(max(0.0, min(1.0, p)) * 100))
st = str(payload.get("status") or "downloading")
tail = f"{vid} {pct}%" if vid else f"{pct}%"
self._set_card_status(pid, f"{st} {tail}".strip())
elif name == "DownloadCompleted":
pid = str(payload.get("playlist_id") or "")
if pid:
vid = str(payload.get("video_id") or self._download_state_by_pid.get(pid, {}).get("video_id") or "")
self._set_card_progress(pid, 1.0)
self._download_state_by_pid.pop(pid, None)
self._set_card_status(pid, f"completed {vid}".strip())
elif name == "DownloadFailed":
pid = str(payload.get("playlist_id") or "")
if not pid:
return
vid = str(payload.get("video_id") or self._download_state_by_pid.get(pid, {}).get("video_id") or "")
err = str(payload.get("error") or "").strip()
self._download_state_by_pid.pop(pid, None)
self._set_card_status(pid, f"failed {vid}: {err}" if err else f"failed {vid}".strip())
elif name == "SyncPaused":
pid = str(payload.get("playlist_id") or "")
if not pid:
return
self._set_card_status(pid, str(payload.get("reason") or "paused"))
self._set_active_card(pid, running=True, paused=True)
def _set_card_progress(self, playlist_id: str, progress: float) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
w.set_progress(progress)
def _set_card_status(self, playlist_id: str, text: str) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
if w.playlist_id() == playlist_id:
w.set_status(text)
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
w.set_last_sync(last_sync)
def _set_active_card(self, playlist_id: str, *, running: bool, paused: bool) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if not isinstance(w, _PlaylistCard):
continue
is_active = w.playlist_id() == playlist_id
w.set_active(is_active and running)
if is_active:
w.set_paused(paused)
class _PlaylistCard(QtWidgets.QFrame):
sync_clicked = QtCore.Signal(int)
remove_clicked = QtCore.Signal(int)
cancel_clicked = QtCore.Signal(str)
pause_changed = QtCore.Signal(bool)
changed = QtCore.Signal()
def __init__(self, row: PlaylistRow, *, index: int, last_sync: str | None = None, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
self.setObjectName("playlistCard")
self._index = index
self._active = False
self._paused = False
self._name_value = row.name
self._name_label = QtWidgets.QLabel(self._name_value or "Playlist")
self._name_label.setStyleSheet("font-weight: 600; font-size: 14px;")
self._name_edit = QtWidgets.QLineEdit(self._name_value)
self._name_edit.setMinimumHeight(32)
self._name_edit.editingFinished.connect(self._finish_name_edit)
self._name_stack = QtWidgets.QStackedWidget()
self._name_stack.addWidget(self._name_label)
self._name_stack.addWidget(self._name_edit)
self._name_stack.setCurrentIndex(0)
self._url = QtWidgets.QLineEdit(row.url)
self._mode = QtWidgets.QComboBox()
self._mode.addItems(["video", "audio", "both"])
self._mode.setCurrentText(row.download_mode or "video")
self._quality = QtWidgets.QComboBox()
self._quality.addItems(["1080p", "720p", "480p", "360p"])
self._quality.setEditable(False)
self._quality.setCurrentText(row.max_download_quality or "1080p")
self._save_path = QtWidgets.QLineEdit(row.save_path)
for w in (self._url, self._mode, self._quality, self._save_path):
w.setMinimumHeight(32)
self._url.editingFinished.connect(self.changed.emit)
self._save_path.editingFinished.connect(self.changed.emit)
self._mode.currentIndexChanged.connect(lambda _i: self.changed.emit())
self._quality.currentIndexChanged.connect(lambda _i: self.changed.emit())
self._status = QtWidgets.QLabel("")
self._status.setStyleSheet("color: #9fb0c6;")
self._meta = QtWidgets.QLabel(f"Last sync: {last_sync or 'never'}")
self._meta.setStyleSheet("color: #7f8aa3;")
self._progress = QtWidgets.QProgressBar()
self._progress.setRange(0, 100)
self._progress.setValue(0)
self._progress.setTextVisible(False)
self._progress.setFixedHeight(6)
self._sync_btn = QtWidgets.QPushButton("Sync")
self._sync_btn.clicked.connect(self._on_sync_or_pause_clicked)
self._edit_name_btn = QtWidgets.QToolButton()
self._edit_name_btn.setAutoRaise(True)
self._edit_name_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self._edit_name_btn.setIconSize(QtCore.QSize(16, 16))
self._edit_name_btn.setFixedSize(28, 28)
icon = QtGui.QIcon.fromTheme("document-edit")
if not icon.isNull():
self._edit_name_btn.setIcon(icon)
else:
self._edit_name_btn.setText("")
self._edit_name_btn.clicked.connect(self._toggle_name_edit)
self._remove_btn = QtWidgets.QToolButton()
self._remove_btn.setAutoRaise(True)
self._remove_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self._remove_btn.setIconSize(QtCore.QSize(16, 16))
self._remove_btn.setFixedSize(28, 28)
remove_icon = QtGui.QIcon.fromTheme("edit-delete")
if not remove_icon.isNull():
self._remove_btn.setIcon(remove_icon)
else:
self._remove_btn.setText("X")
self._remove_btn.setToolTip("Remove playlist")
self._remove_btn.clicked.connect(lambda: self.remove_clicked.emit(self._index))
self._cancel_btn = QtWidgets.QToolButton()
self._cancel_btn.setAutoRaise(True)
self._cancel_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
self._cancel_btn.setIconSize(QtCore.QSize(16, 16))
self._cancel_btn.setFixedSize(28, 28)
stop_icon = QtGui.QIcon.fromTheme("process-stop")
if not stop_icon.isNull():
self._cancel_btn.setIcon(stop_icon)
else:
self._cancel_btn.setText("")
self._cancel_btn.setToolTip("Cancel this playlist sync")
self._cancel_btn.setEnabled(False)
self._cancel_btn.clicked.connect(lambda: self.cancel_clicked.emit(self.playlist_id()))
header = QtWidgets.QHBoxLayout()
header.addWidget(self._name_stack, 0)
header.addWidget(self._edit_name_btn, 0)
header.addWidget(self._remove_btn, 0)
header.addWidget(self._cancel_btn, 0)
header.addStretch(1)
header.addWidget(self._sync_btn)
form = QtWidgets.QFormLayout()
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
form.setVerticalSpacing(10)
form.setHorizontalSpacing(12)
form.addRow("URL", self._url)
form.addRow("Mode", self._mode)
form.addRow("Max Quality", self._quality)
form.addRow("Save Path", self._save_path)
outer = QtWidgets.QVBoxLayout()
outer.addLayout(header)
outer.addLayout(form)
outer.addWidget(self._meta)
outer.addWidget(self._progress)
outer.addWidget(self._status)
self.setLayout(outer)
# Give the card a bit more breathing room so controls don't feel cramped.
self.setMinimumHeight(self.sizeHint().height() + 16)
def to_dict(self) -> dict[str, Any]:
name = self._name_value.strip()
url = self._url.text().strip()
mode = self._mode.currentText().strip() or "video"
max_q = self._quality.currentText().strip() or "1080p"
save_path = self._save_path.text().strip() or "./downloads"
pl: dict[str, Any] = {"url": url, "download_mode": mode, "max_download_quality": max_q, "save_path": save_path}
if name:
pl["name"] = name
return pl
def set_status(self, text: str) -> None:
self._status.setText(text)
def set_index(self, index: int) -> None:
self._index = index
def set_active(self, active: bool) -> None:
self._active = bool(active)
self._cancel_btn.setEnabled(self._active)
if not self._active:
self._paused = False
self._sync_btn.setText("Sync")
else:
self._sync_btn.setText("Resume" if self._paused else "Pause")
def set_paused(self, paused: bool) -> None:
self._paused = bool(paused)
if self._active:
self._sync_btn.setText("Resume" if self._paused else "Pause")
def set_editing_enabled(self, enabled: bool) -> None:
# Editing controls only (Sync/Pause/Cancel must remain usable).
self._url.setEnabled(enabled)
self._mode.setEnabled(enabled)
self._quality.setEnabled(enabled)
self._save_path.setEnabled(enabled)
self._edit_name_btn.setEnabled(enabled)
self._remove_btn.setEnabled(enabled)
# Explicitly keep runtime controls enabled even while editing is locked.
self._sync_btn.setEnabled(True)
self._cancel_btn.setEnabled(self._active)
if not enabled and self._name_stack.currentIndex() == 1:
# Force exit name edit if a sync starts mid-edit.
self._finish_name_edit()
def playlist_id(self) -> str:
url = (self._url.text() or "").strip()
return extract_playlist_id(url) or url
def set_progress(self, progress: float) -> None:
pct = max(0, min(100, int(round(progress * 100))))
self._progress.setValue(pct)
def set_last_sync(self, last_sync: str) -> None:
self._meta.setText(f"Last sync: {last_sync or 'never'}")
def validate(self) -> list[str]:
errs: list[str] = []
url = self._url.text().strip()
if not url or not (url.startswith("http://") or url.startswith("https://")):
errs.append("URL required")
mode = self._mode.currentText().strip()
if mode not in {"video", "audio", "both"}:
errs.append("invalid mode")
q = self._quality.currentText().strip().lower()
if not q.endswith("p") or not any(ch.isdigit() for ch in q):
errs.append("invalid quality")
sp = self._save_path.text().strip()
if not sp:
errs.append("save_path required")
return errs
def _toggle_name_edit(self) -> None:
self._name_edit.setText(self._name_value)
self._name_stack.setCurrentIndex(1)
self._edit_name_btn.setVisible(False)
self._name_edit.setFocus()
self._name_edit.selectAll()
def _finish_name_edit(self) -> None:
new_name = self._name_edit.text().strip()
self._name_value = new_name
self._name_label.setText(new_name or "Playlist")
self._name_stack.setCurrentIndex(0)
self._edit_name_btn.setVisible(True)
self.changed.emit()
def _on_sync_or_pause_clicked(self) -> None:
if not self._active:
self.sync_clicked.emit(self._index)
return
self._paused = not self._paused
self._sync_btn.setText("Resume" if self._paused else "Pause")
self.pause_changed.emit(self._paused)
+214
View File
@@ -0,0 +1,214 @@
from __future__ import annotations
from PySide6 import QtCore, QtWidgets
from ..smooth_scroll import enable_smooth_scrolling
class QueuePage(QtWidgets.QWidget):
cancel_sync_requested = QtCore.Signal()
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
self._pending_by_key: dict[tuple[str, str], dict] = {}
self._playlist_labels: dict[str, str] = {}
self._flush_timer = QtCore.QTimer(self)
self._flush_timer.setInterval(150)
self._flush_timer.timeout.connect(self._flush_pending)
self._flush_timer.start()
layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Queue")
title.setObjectName("pageTitle")
top = QtWidgets.QHBoxLayout()
top.addWidget(title)
top.addStretch(1)
clear_btn = QtWidgets.QPushButton("Clear completed")
clear_btn.clicked.connect(self._clear_completed)
cancel_btn = QtWidgets.QPushButton("Cancel all")
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
top.addWidget(clear_btn)
top.addWidget(cancel_btn)
layout.addLayout(top)
self._table = QtWidgets.QTableWidget(0, 7)
self._table.setHorizontalHeaderLabels(["Playlist", "Video ID", "Status", "Progress", "Speed", "ETA", "Target/File"])
self._table.horizontalHeader().setStretchLastSection(True)
self._table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
self._table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
self._table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
self._table.setSortingEnabled(True)
self._table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
enable_smooth_scrolling(self._table)
layout.addWidget(self._table, 1)
self._hint = QtWidgets.QLabel("Waiting for downloads…")
layout.addWidget(self._hint)
def on_event(self, name: str, payload: dict) -> None:
if name not in {"DownloadStarted", "DownloadProgress", "DownloadCompleted", "DownloadFailed"}:
return
vid = str(payload.get("video_id") or "")
if not vid:
return
pid = str(payload.get("playlist_id") or "")
key = (pid, vid)
latest = dict(payload)
latest["_event"] = name
self._pending_by_key[key] = latest
def set_playlist_labels(self, labels: dict[str, str]) -> None:
self._playlist_labels = dict(labels)
# Update any existing rows to reflect new names.
for row in range(self._table.rowCount()):
pl_item = self._table.item(row, 0)
if pl_item is None:
continue
pid = pl_item.data(QtCore.Qt.ItemDataRole.UserRole)
if not pid:
continue
pl_item.setText(self._playlist_labels.get(str(pid), str(pid)))
def _ensure_row(self, key: tuple[str, str]) -> int:
vid_item = self._rows_by_key.get(key)
if vid_item is not None and vid_item.row() >= 0:
return int(vid_item.row())
pid, vid = key
row = self._table.rowCount()
self._table.insertRow(row)
label = self._playlist_labels.get(pid, pid)
pl_item = QtWidgets.QTableWidgetItem(label)
# Keep the real playlist_id even if the displayed label changes.
pl_item.setData(QtCore.Qt.ItemDataRole.UserRole, pid)
pl_item.setToolTip(pid)
self._table.setItem(row, 0, pl_item)
vid_item = QtWidgets.QTableWidgetItem(vid)
self._table.setItem(row, 1, vid_item)
self._table.setItem(row, 2, QtWidgets.QTableWidgetItem("queued"))
self._table.setItem(row, 3, QtWidgets.QTableWidgetItem(""))
self._table.setItem(row, 4, QtWidgets.QTableWidgetItem(""))
self._table.setItem(row, 5, QtWidgets.QTableWidgetItem(""))
self._table.setItem(row, 6, QtWidgets.QTableWidgetItem(""))
self._rows_by_key[key] = vid_item
return row
def _ensure_item(self, row: int, col: int, default: str = "") -> QtWidgets.QTableWidgetItem:
item = self._table.item(row, col)
if item is None:
item = QtWidgets.QTableWidgetItem(default)
self._table.setItem(row, col, item)
return item
@QtCore.Slot()
def _flush_pending(self) -> None:
if not self._pending_by_key:
return
pending = dict(self._pending_by_key)
self._pending_by_key.clear()
sorting_was_enabled = self._table.isSortingEnabled()
if sorting_was_enabled:
self._table.setSortingEnabled(False)
try:
for key, payload in pending.items():
name = str(payload.pop("_event", ""))
row = self._ensure_row(key)
status_item = self._ensure_item(row, 2, "queued")
progress_item = self._ensure_item(row, 3, "")
speed_item = self._ensure_item(row, 4, "")
eta_item = self._ensure_item(row, 5, "")
target_item = self._ensure_item(row, 6, "")
if name == "DownloadStarted":
status_item.setText("started")
tgt = payload.get("target") or payload.get("filename") or ""
if tgt:
target_item.setText(str(tgt))
elif name == "DownloadProgress":
status_item.setText(str(payload.get("status") or "downloading"))
prog = payload.get("progress")
if isinstance(prog, (int, float)):
pct = max(0, min(100, int(round(prog * 100))))
bar = self._table.cellWidget(row, 3)
if bar is None:
bar = QtWidgets.QProgressBar()
bar.setRange(0, 100)
bar.setTextVisible(True)
self._table.setCellWidget(row, 3, bar)
bar.setValue(pct)
sp = payload.get("speed")
if isinstance(sp, (int, float)) and sp > 0:
speed_item.setText(f"{sp/1024/1024:.2f} MiB/s")
et = payload.get("eta")
if isinstance(et, (int, float)) and et >= 0:
eta_item.setText(f"{int(et)}s")
fn = payload.get("filename")
if fn:
target_item.setText(str(fn))
elif name == "DownloadCompleted":
status_item.setText("completed")
tgt = payload.get("target") or ""
if tgt:
target_item.setText(str(tgt))
bar = self._table.cellWidget(row, 3)
if bar is None:
bar = QtWidgets.QProgressBar()
bar.setRange(0, 100)
bar.setTextVisible(True)
self._table.setCellWidget(row, 3, bar)
bar.setValue(100)
speed_item.setText("")
eta_item.setText("")
elif name == "DownloadFailed":
status_item.setText("failed")
self._table.removeCellWidget(row, 3)
progress_item.setText("")
speed_item.setText("")
eta_item.setText("")
err = payload.get("error")
if err:
target_item.setText(str(err))
finally:
if sorting_was_enabled:
self._table.setSortingEnabled(True)
self._hint.setText(f"{len(self._rows_by_key)} job(s) seen.")
def _clear_completed(self) -> None:
to_remove: list[tuple[int, tuple[str, str]]] = []
for key, vid_item in list(self._rows_by_key.items()):
row = int(vid_item.row())
if row < 0:
self._rows_by_key.pop(key, None)
continue
st = self._table.item(row, 2)
if st and st.text() == "completed":
to_remove.append((row, key))
for row, key in sorted(to_remove, key=lambda x: x[0], reverse=True):
self._table.removeRow(row)
self._rows_by_key.pop(key, None)
# Rebuild mapping since row indices/items may have shifted.
rebuilt: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
for r in range(self._table.rowCount()):
pl_item = self._table.item(r, 0)
v_item = self._table.item(r, 1)
if pl_item is None or v_item is None:
continue
pid = pl_item.data(QtCore.Qt.ItemDataRole.UserRole) or pl_item.text()
vid = v_item.text()
rebuilt[(str(pid), str(vid))] = v_item
self._rows_by_key = rebuilt
+125
View File
@@ -0,0 +1,125 @@
from __future__ import annotations
from pathlib import Path
from typing import Any
from PySide6 import QtCore, QtWidgets
from ..config_store import load_config, save_config
class SettingsPage(QtWidgets.QWidget):
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self._config_path: Path | None = None
self._config: dict[str, Any] = {}
layout = QtWidgets.QVBoxLayout(self)
title = QtWidgets.QLabel("Settings")
title.setObjectName("pageTitle")
layout.addWidget(title)
form = QtWidgets.QFormLayout()
self._ffmpeg_path = QtWidgets.QLineEdit()
self._ffmpeg_path.setPlaceholderText("./bin/ffmpeg.exe (Windows) or ./bin/ffmpeg (Linux)")
form.addRow("ffmpeg_path", self._ffmpeg_path)
self._max_parallel = QtWidgets.QSpinBox()
self._max_parallel.setRange(1, 64)
form.addRow("max_parallel_downloads", self._max_parallel)
self._retry_max = QtWidgets.QSpinBox()
self._retry_max.setRange(0, 20)
form.addRow("retry_max_retries", self._retry_max)
self._retry_delay = QtWidgets.QDoubleSpinBox()
self._retry_delay.setRange(0.0, 60.0)
self._retry_delay.setDecimals(2)
self._retry_delay.setSingleStep(0.25)
form.addRow("retry_delay_seconds", self._retry_delay)
self._download_delay = QtWidgets.QDoubleSpinBox()
self._download_delay.setRange(0.0, 600.0)
self._download_delay.setDecimals(2)
self._download_delay.setSingleStep(0.25)
form.addRow("delay_between_downloads_seconds", self._download_delay)
form_box = QtWidgets.QGroupBox("Global defaults")
form_box.setLayout(form)
layout.addWidget(form_box)
btns = QtWidgets.QHBoxLayout()
self._reload_btn = QtWidgets.QPushButton("Reload")
self._reload_btn.clicked.connect(self.reload_from_config)
self._save_btn = QtWidgets.QPushButton("Save")
self._save_btn.clicked.connect(self.save_to_config)
btns.addStretch(1)
btns.addWidget(self._reload_btn)
btns.addWidget(self._save_btn)
layout.addLayout(btns)
self._status = QtWidgets.QLabel("")
self._status.setWordWrap(True)
layout.addWidget(self._status)
self._suppress_autosave = False
self._autosave_timer = QtCore.QTimer(self)
self._autosave_timer.setSingleShot(True)
self._autosave_timer.setInterval(600)
self._autosave_timer.timeout.connect(self.save_to_config)
# Autosave on focus-out / change.
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
self._max_parallel.valueChanged.connect(lambda _v: self._schedule_autosave())
self._retry_max.valueChanged.connect(lambda _v: self._schedule_autosave())
self._retry_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
self._download_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
def set_config_path(self, path: Path) -> None:
self._config_path = path
self.reload_from_config()
@QtCore.Slot()
def reload_from_config(self) -> None:
if self._config_path is None:
self._status.setText("No config loaded yet.")
return
try:
self._suppress_autosave = True
cfg = load_config(self._config_path)
self._config = dict(cfg.data)
self._ffmpeg_path.setText(str(self._config.get("ffmpeg_path") or ""))
self._max_parallel.setValue(int(self._config.get("max_parallel_downloads") or 2))
self._retry_max.setValue(int(self._config.get("retry_max_retries") or 2))
self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5))
self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
self._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc:
self._status.setText(f"Failed to load settings: {exc}")
finally:
self._suppress_autosave = False
def _schedule_autosave(self) -> None:
if self._suppress_autosave:
return
self._autosave_timer.start()
@QtCore.Slot()
def save_to_config(self) -> None:
if self._config_path is None:
self._status.setText("No config loaded yet.")
return
try:
data = dict(self._config or {})
data["ffmpeg_path"] = self._ffmpeg_path.text().strip() or data.get("ffmpeg_path")
data["max_parallel_downloads"] = int(self._max_parallel.value())
data["retry_max_retries"] = int(self._retry_max.value())
data["retry_delay_seconds"] = float(self._retry_delay.value())
data["delay_between_downloads_seconds"] = float(self._download_delay.value())
save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc:
self._status.setText(f"Failed to save settings: {exc}")
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import asyncio
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, Optional
from PySide6 import QtCore
from ..core.database.db import Database
from ..core.sync.executor import ActionExecutor
from ..core.sync.service import SyncService
from ..core.events.event_bus import EventBus
@dataclass(frozen=True)
class SyncRequest:
playlist_cfg: Dict[str, Any]
apply: bool = True
db_path: Path = Path("app/data/app.db")
cancel_flag: threading.Event | None = None
pause_flag: threading.Event | None = None
class SyncRunner(QtCore.QObject):
"""
Runs a sync in the background to keep the UI responsive.
"""
finished = QtCore.Signal(bool, str)
def __init__(self, bus: EventBus, parent: QtCore.QObject | None = None) -> None:
super().__init__(parent)
self._bus = bus
self._req: SyncRequest | None = None
@QtCore.Slot(object)
def set_request(self, req: SyncRequest) -> None:
self._req = req
@QtCore.Slot()
def run_current(self) -> None:
if self._req is None:
self.finished.emit(False, "no request")
return
self.run(self._req)
@QtCore.Slot(object)
def run(self, req: SyncRequest) -> None:
try:
if req.cancel_flag is not None and req.cancel_flag.is_set():
self.finished.emit(False, "cancelled")
return
db = Database(req.db_path.resolve())
service = SyncService(db)
executor = ActionExecutor(db, concurrency=int(req.playlist_cfg.get("max_parallel_downloads", 2) or 2), event_bus=self._bus)
actions = service.sync_from_config(req.playlist_cfg)
if req.cancel_flag is not None and req.cancel_flag.is_set():
self.finished.emit(False, "cancelled")
return
if req.apply and actions:
cancel_check = req.cancel_flag.is_set if req.cancel_flag is not None else None
pause_check = req.pause_flag.is_set if req.pause_flag is not None else None
asyncio.run(executor.execute(actions, req.playlist_cfg, cancel_check=cancel_check, pause_check=pause_check))
if req.cancel_flag is not None and req.cancel_flag.is_set():
self.finished.emit(False, "cancelled")
else:
self.finished.emit(True, "done")
except Exception as exc:
self.finished.emit(False, str(exc))
+60
View File
@@ -0,0 +1,60 @@
from __future__ import annotations
from PySide6 import QtCore, QtWidgets
class _SmoothWheelFilter(QtCore.QObject):
def __init__(self, area: QtWidgets.QAbstractScrollArea, *, duration_ms: int = 140) -> None:
super().__init__(area)
self._area = area
self._duration_ms = max(60, int(duration_ms))
self._anim = QtCore.QPropertyAnimation(area.verticalScrollBar(), b"value", self)
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # noqa: N802
if event.type() != QtCore.QEvent.Type.Wheel:
return super().eventFilter(obj, event)
wheel = event # type: ignore[assignment]
try:
angle_y = wheel.angleDelta().y()
pixel_y = wheel.pixelDelta().y()
except Exception:
return super().eventFilter(obj, event)
dy = pixel_y if pixel_y else angle_y
if dy == 0:
return True
sb = self._area.verticalScrollBar()
start = sb.value()
# Map a wheel "step" to a reasonable pixel delta; keep it snappy but not jarring.
base_step = 80
if pixel_y:
delta = -dy
else:
delta = int(round(-dy / 120.0 * base_step))
target = max(sb.minimum(), min(sb.maximum(), start + delta))
if target == start:
return True
self._anim.stop()
self._anim.setDuration(self._duration_ms)
self._anim.setStartValue(start)
self._anim.setEndValue(target)
self._anim.start()
return True
def enable_smooth_scrolling(widget: QtWidgets.QAbstractScrollArea, *, duration_ms: int = 140) -> None:
"""
Enables animated wheel scrolling for QAbstractScrollArea-derived widgets
(QListWidget, QTableWidget, QPlainTextEdit, etc.).
"""
filt = _SmoothWheelFilter(widget, duration_ms=duration_ms)
widget.viewport().installEventFilter(filt)
# Keep a reference to avoid the filter being GC'd.
widget.setProperty("_smooth_wheel_filter", filt)