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:
+3
-17
@@ -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.
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -0,0 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user