1
0
mirror of https://github.com/darkzoul5/YoutubePlaylistSync.git synced 2026-07-03 04:23:59 +03:00

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
+3 -17
View File
@@ -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.
+121
View File
@@ -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.
+1 -3
View File
@@ -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",
+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)