diff --git a/plans/GUI plan.md b/plans/GUI plan.md index 8fe8a76..ebf1eb4 100644 --- a/plans/GUI plan.md +++ b/plans/GUI plan.md @@ -3,8 +3,6 @@ ## Python-first Desktop Architecture - **Primary GUI framework**: `PySide6` (Qt for Python). -- **Communication Layer**: A local `FastAPI` backend to separate core logic from the UI. -- **IPC Mechanism**: The GUI spawns the FastAPI server on a random high port (binding to `127.0.0.1` ONLY) and communicates via REST/WebSockets. ## Core Features to Implement @@ -12,27 +10,15 @@ 2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker). 3. **Queue Manager**: Visual progress bars for active downloads, showing speed, ETA, and current video title. 4. **Log Viewer**: Real-time streaming of yt-dlp logs for troubleshooting. -5. **Settings Panel**: Global settings for binary paths (ffmpeg, aria2c), max parallel jobs, and Docker detection toggle. +5. **Settings Panel**: Global settings for binary paths (ffmpeg), max parallel jobs, and Docker detection toggle. ## Phase 1 Roadmap: "The Bridge" -- [ ] **Refactor `src/manager.py`**: Convert CLI-first execution to async-compatible methods for FastAPI consumption. -- [ ] **FastAPI Integration**: Create endpoints for `/playlists`, `/status`, and `/download/start`. - [ ] **PySide6 Skeleton**: Basic window with `QWebEngine` (if hybrid) or native `QWidget` dashboard. - [ ] **Packaging**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`. ## Packaging & Distribution (brief) -- Bundle the backend and GUI into one distributable. The GUI should spawn the local API process (background subprocess) on startup. +- Bundle the backend and GUI into one distributable. - Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX. -- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution. -- Security: bind the local API to `localhost` only, use a short-lived token or IPC for authentication between GUI and backend, and avoid exposing unnecessary ports. - -## Roadmap (GUI → Web → Mobile) - -1. Desktop prototype: `FastAPI` backend + `PySide6` GUI (thin client) with basic playlist add/update/download controls and status streaming. -2. Packaging: create Windows exe/installer and Linux AppImage for the prototype. -3. Web frontend: build a web SPA that consumes the same backend API (hosted or local) — this reuses business logic with minimal change. -4. Android: either a native app or cross-platform UI (Flutter/React Native) that calls the backend API; alternatively host the backend and make a thin mobile client. - -If you want, I can now: scaffold a minimal `FastAPI` backend and `PySide6` desktop starter in this repo, or produce concise packaging steps for Windows and Linux. Which do you prefer? +- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution. \ No newline at end of file diff --git a/plans/sync refactor maybe.md b/plans/sync refactor maybe.md new file mode 100644 index 0000000..7d4a21c --- /dev/null +++ b/plans/sync refactor maybe.md @@ -0,0 +1,121 @@ +You need to separate playlist sync state from download attempts. + +The goal should not be “all 400 downloaded in one run”. It should be: + +All downloadable items eventually reach a final state. Temporary failures are retried later. Permanent failures are recorded clearly. + +Use statuses like: + +queued +downloading +completed + +temporary_failed +rate_limited +verification_required + +unavailable +private +geo_blocked +copyright_blocked +age_restricted +unsupported +failed_permanent + +skipped_by_user +removed_from_playlist + +Main logic: + +1. Fetch playlist metadata +2. Create/update queue items for all playlist videos +3. Download available queued items +4. On normal temporary errors: retry later +5. On YouTube rate-limit / bot check: pause the whole sync +6. On unavailable/private/deleted videos: mark as permanent failure +7. On next sync: retry only retryable items + +Important: do not delete queue records just because download failed. Keep them as sync records. + +For each video, store: + +video_id +playlist_id +playlist_position +wanted_format: audio | video +status +failure_type +failure_message +attempt_count +last_attempt_at +next_retry_at +is_retryable +local_file_path + +Retry behavior: + +temporary_failed -> retry with backoff +rate_limited -> pause playlist/app queue, retry much later +verification_required -> pause until user action +unavailable/private/deleted -> do not retry often +geo/age restricted -> do not retry unless settings/auth changed + +Example backoff: + +attempt 1: retry after 10 minutes +attempt 2: retry after 1 hour +attempt 3: retry after 6 hours +attempt 4: retry after 24 hours +attempt 5+: retry manually or during next scheduled sync + +For the user, show a sync summary: + +Playlist sync partially completed + +Downloaded: 200 +Queued: 0 +Retry later: 80 +Needs attention: 1 +Unavailable: 119 + +The playlist is still tracked. Retryable items will be attempted again in the next sync. + +Best behavior for the 400-item example: + +200 downloaded +50 unavailable/private/deleted -> mark permanent +149 temporary/rate-limited -> retry later +1 bot/verification error -> pause sync and ask user + +Do not cancel the whole sync as “failed”. Mark it as: + +completed_with_issues +paused_needs_attention +partially_synced + +In UI terms, the playlist should have a health/status: + +Synced +Syncing +Partially synced +Paused - needs attention +Error + +The most important rule: + +Never lose the reason why an item did not download. + +That lets your app eventually download everything possible without repeatedly hammering YouTube or confusing the user. + + +## What to change to match the plan + +Fix the destructive UPSERT behavior in src/app/core/database/db.py / SyncService.sync_from_config() so scans update metadata (title/index/last_seen) but do not overwrite existing downloaded/local_filename (and later: status/failure fields). + +Introduce a persistent table (or extend playlist_items) with: +status, failure_type, failure_message, attempt_count, last_attempt_at, next_retry_at, is_retryable, wanted_format, local_file_path. + +Update ActionExecutor / worker layer to write transitions into DB (queued → downloading → completed / temporary_failed / rate_limited / verification_required / failed_permanent). + +Change “next sync” selection to only pick queued or retryable && next_retry_at <= now, not everything each time. +Add summary/health states (partially_synced, paused_needs_attention, etc.) based on counts. \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6f98e20..21f5e8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,11 +13,9 @@ keywords = ["youtube", "yt-dlp", "playlist", "sync"] requires-python = ">=3.10" dependencies = [ "yt-dlp>=2026.3.17", + "PySide6", ] [project.optional-dependencies] -gui = [ - "PySide6" -] test = [ "pytest", "ruff", diff --git a/src/app/core/database/db.py b/src/app/core/database/db.py index 889c95e..2d1d6b5 100644 --- a/src/app/core/database/db.py +++ b/src/app/core/database/db.py @@ -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"] diff --git a/src/app/core/download/downloader.py b/src/app/core/download/downloader.py index 8b6801a..f08c90d 100644 --- a/src/app/core/download/downloader.py +++ b/src/app/core/download/downloader.py @@ -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 diff --git a/src/app/core/download/queue_manager.py b/src/app/core/download/queue_manager.py index 24ebada..eaa90c5 100644 --- a/src/app/core/download/queue_manager.py +++ b/src/app/core/download/queue_manager.py @@ -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 diff --git a/src/app/core/download/workers.py b/src/app/core/download/workers.py index f13adc5..964e72a 100644 --- a/src/app/core/download/workers.py +++ b/src/app/core/download/workers.py @@ -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)) diff --git a/src/app/core/sync/executor.py b/src/app/core/sync/executor.py index b8a60ae..f4e70cd 100644 --- a/src/app/core/sync/executor.py +++ b/src/app/core/sync/executor.py @@ -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 diff --git a/src/app/core/utils/rate_limit.py b/src/app/core/utils/rate_limit.py new file mode 100644 index 0000000..43f2b93 --- /dev/null +++ b/src/app/core/utils/rate_limit.py @@ -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 you’re 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", + "you’re not a bot", + "--cookies-from-browser", + "--cookies", + ] + return any(n in s for n in needles) + diff --git a/src/app/gui/__init__.py b/src/app/gui/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/src/app/gui/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/src/app/gui/bus_bridge.py b/src/app/gui/bus_bridge.py new file mode 100644 index 0000000..b5f6020 --- /dev/null +++ b/src/app/gui/bus_bridge.py @@ -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 + diff --git a/src/app/gui/config_store.py b/src/app/gui/config_store.py new file mode 100644 index 0000000..fa03965 --- /dev/null +++ b/src/app/gui/config_store.py @@ -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 diff --git a/src/app/gui/main.py b/src/app/gui/main.py new file mode 100644 index 0000000..3e9eb12 --- /dev/null +++ b/src/app/gui/main.py @@ -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()) diff --git a/src/app/gui/pages/__init__.py b/src/app/gui/pages/__init__.py new file mode 100644 index 0000000..3ce55a6 --- /dev/null +++ b/src/app/gui/pages/__init__.py @@ -0,0 +1,2 @@ +from __future__ import annotations + diff --git a/src/app/gui/pages/logs.py b/src/app/gui/pages/logs.py new file mode 100644 index 0000000..bcb0ade --- /dev/null +++ b/src/app/gui/pages/logs.py @@ -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) diff --git a/src/app/gui/pages/playlists.py b/src/app/gui/pages/playlists.py new file mode 100644 index 0000000..0e6508d --- /dev/null +++ b/src/app/gui/pages/playlists.py @@ -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) diff --git a/src/app/gui/pages/queue.py b/src/app/gui/pages/queue.py new file mode 100644 index 0000000..f9a71da --- /dev/null +++ b/src/app/gui/pages/queue.py @@ -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 diff --git a/src/app/gui/pages/settings.py b/src/app/gui/pages/settings.py new file mode 100644 index 0000000..61327f9 --- /dev/null +++ b/src/app/gui/pages/settings.py @@ -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}") diff --git a/src/app/gui/runner.py b/src/app/gui/runner.py new file mode 100644 index 0000000..25b7b50 --- /dev/null +++ b/src/app/gui/runner.py @@ -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)) diff --git a/src/app/gui/smooth_scroll.py b/src/app/gui/smooth_scroll.py new file mode 100644 index 0000000..2e23339 --- /dev/null +++ b/src/app/gui/smooth_scroll.py @@ -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) +