mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-09-18 20:43:54 +03:00
feat: add GUI mvp
This commit is contained in:
+3
-17
@@ -3,8 +3,6 @@
|
|||||||
## Python-first Desktop Architecture
|
## Python-first Desktop Architecture
|
||||||
|
|
||||||
- **Primary GUI framework**: `PySide6` (Qt for Python).
|
- **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
|
## Core Features to Implement
|
||||||
|
|
||||||
@@ -12,27 +10,15 @@
|
|||||||
2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker).
|
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.
|
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.
|
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"
|
## 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.
|
- [ ] **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**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
|
||||||
|
|
||||||
## Packaging & Distribution (brief)
|
## 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.
|
- 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.
|
- 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?
|
|
||||||
@@ -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"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yt-dlp>=2026.3.17",
|
"yt-dlp>=2026.3.17",
|
||||||
|
"PySide6",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
gui = [
|
|
||||||
"PySide6"
|
|
||||||
]
|
|
||||||
test = [
|
test = [
|
||||||
"pytest",
|
"pytest",
|
||||||
"ruff",
|
"ruff",
|
||||||
|
|||||||
@@ -99,3 +99,10 @@ class Database:
|
|||||||
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
||||||
(playlist_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):
|
async def handle_job(self, job: DownloadJob):
|
||||||
try:
|
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
|
job.state = JobState.DOWNLOADING
|
||||||
await self._download(job)
|
await self._download(job)
|
||||||
# Optional local audio extraction when requested
|
# Optional local audio extraction when requested
|
||||||
@@ -69,6 +74,10 @@ class Downloader:
|
|||||||
def run():
|
def run():
|
||||||
import yt_dlp # type: ignore
|
import yt_dlp # type: ignore
|
||||||
from pathlib import Path
|
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:
|
class _QuietLogger:
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
@@ -99,9 +108,13 @@ class Downloader:
|
|||||||
}
|
}
|
||||||
|
|
||||||
progress_cb = getattr(job, "progress_callback", None)
|
progress_cb = getattr(job, "progress_callback", None)
|
||||||
|
cancel_check = getattr(job, "cancel_check", None)
|
||||||
if progress_cb is not None:
|
if progress_cb is not None:
|
||||||
def hook(d):
|
def hook(d):
|
||||||
try:
|
try:
|
||||||
|
if callable(cancel_check) and cancel_check():
|
||||||
|
raise DownloadCancelled("cancelled")
|
||||||
|
import time
|
||||||
payload = {
|
payload = {
|
||||||
"status": d.get("status"),
|
"status": d.get("status"),
|
||||||
"downloaded_bytes": d.get("downloaded_bytes"),
|
"downloaded_bytes": d.get("downloaded_bytes"),
|
||||||
@@ -114,7 +127,27 @@ class Downloader:
|
|||||||
done = payload.get("downloaded_bytes")
|
done = payload.get("downloaded_bytes")
|
||||||
if total and done is not None:
|
if total and done is not None:
|
||||||
payload["progress"] = float(done) / float(total)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class DownloadJob:
|
|||||||
max_download_quality: Optional[str] = None
|
max_download_quality: Optional[str] = None
|
||||||
playlist_id: Optional[str] = None
|
playlist_id: Optional[str] = None
|
||||||
progress_callback: Optional[Callable[[dict[str, Any]], None]] = 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
|
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
|
||||||
keep_video: bool = True
|
keep_video: bool = True
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from .downloader import Downloader
|
from .downloader import Downloader
|
||||||
from .queue_manager import DownloadJob, JobState
|
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):
|
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
|
attempt = 0
|
||||||
while attempt <= max_retries:
|
while attempt <= max_retries:
|
||||||
await dl.handle_job(job)
|
await dl.handle_job(job)
|
||||||
|
if job.state == JobState.CANCELLED:
|
||||||
|
return
|
||||||
if job.state == JobState.COMPLETED:
|
if job.state == JobState.COMPLETED:
|
||||||
return
|
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
|
attempt += 1
|
||||||
if attempt <= max_retries:
|
if attempt <= max_retries:
|
||||||
wait = delay_seconds * (2 ** (attempt - 1))
|
wait = delay_seconds * (2 ** (attempt - 1))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..database.db import Database
|
|||||||
from ..utils.yt import extract_playlist_id
|
from ..utils.yt import extract_playlist_id
|
||||||
from ..events.event_bus import EventBus
|
from ..events.event_bus import EventBus
|
||||||
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
||||||
|
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||||
|
|
||||||
|
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
@@ -22,7 +23,7 @@ class ActionExecutor:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.bus = event_bus
|
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)
|
actions_list = list(actions)
|
||||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||||
start = time.monotonic()
|
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)
|
self._preflight_dependencies(actions_list, playlist_cfg)
|
||||||
|
|
||||||
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||||
@@ -52,24 +55,54 @@ class ActionExecutor:
|
|||||||
video_root.mkdir(parents=True, exist_ok=True)
|
video_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# First, handle renames safely in batch per extension
|
# 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)
|
await self._apply_renames(actions_list, audio_root, video_root, playlist_cfg)
|
||||||
|
|
||||||
# Then, recycle deletions
|
# 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)
|
self._apply_deletions(actions_list, audio_root, video_root, playlist_cfg)
|
||||||
|
|
||||||
# Finally, perform downloads concurrently
|
# 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)
|
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 = {
|
summary = {
|
||||||
"playlist_id": playlist_id,
|
"playlist_id": playlist_id,
|
||||||
"duration_s": duration_s,
|
"duration_s": duration_s,
|
||||||
"counts": dict(counts),
|
"counts": dict(counts),
|
||||||
|
"last_sync": last_sync,
|
||||||
}
|
}
|
||||||
if self.bus:
|
if self.bus:
|
||||||
await self.bus.publish("SyncSummary", dict(summary))
|
await self.bus.publish("SyncSummary", dict(summary))
|
||||||
await self.bus.publish("SyncFinished", 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:
|
def _preflight_dependencies(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Fail fast on core runtime dependencies before doing any filesystem work.
|
Fail fast on core runtime dependencies before doing any filesystem work.
|
||||||
@@ -154,7 +187,17 @@ class ActionExecutor:
|
|||||||
if self.bus:
|
if self.bus:
|
||||||
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
|
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", "")
|
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
|
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
|
||||||
@@ -175,8 +218,22 @@ class ActionExecutor:
|
|||||||
except Exception:
|
except Exception:
|
||||||
retry_delay_seconds = 1.5
|
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):
|
async def worker(job: DownloadJob):
|
||||||
|
nonlocal rate_limit_emitted
|
||||||
job.playlist_id = playlist_id
|
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:
|
if self.bus:
|
||||||
def _progress_cb(info: dict):
|
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 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)
|
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)
|
await queue.start(worker)
|
||||||
try:
|
try:
|
||||||
jobs: List[DownloadJob] = []
|
jobs: List[DownloadJob] = []
|
||||||
@@ -214,6 +288,12 @@ class ActionExecutor:
|
|||||||
temp_video_root.mkdir(parents=True, exist_ok=True)
|
temp_video_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for a in actions:
|
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:
|
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
|
||||||
continue
|
continue
|
||||||
vid = a.item.video_id
|
vid = a.item.video_id
|
||||||
@@ -280,8 +360,22 @@ class ActionExecutor:
|
|||||||
jobs.append(job)
|
jobs.append(job)
|
||||||
await queue.enqueue(job)
|
await queue.enqueue(job)
|
||||||
finally:
|
finally:
|
||||||
await queue.join() # wait for all jobs
|
join_task = asyncio.create_task(queue.join())
|
||||||
await queue.stop()
|
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
|
# Persist DB updates for completed jobs
|
||||||
for job in locals().get("jobs", []):
|
for job in locals().get("jobs", []):
|
||||||
@@ -298,6 +392,13 @@ class ActionExecutor:
|
|||||||
# Ensure not marked as downloaded if failed
|
# Ensure not marked as downloaded if failed
|
||||||
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
|
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
|
||||||
if self.bus:
|
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:
|
except Exception:
|
||||||
pass
|
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