1 Commits
Author SHA1 Message Date
darkzoul5 51bc994bc9 chore: bump yt-dlp to 2026.7.4 2026-07-05 10:48:00 +00:00
43 changed files with 269 additions and 380 deletions

No files matched your search

+26 -24
View File
@@ -68,10 +68,23 @@ jobs:
git push origin "${TAG}"
test:
name: Test
uses: ./.github/workflows/test.yml
permissions:
contents: read
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -e .
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}/src
run: pytest
build:
name: Build packages
@@ -100,15 +113,15 @@ jobs:
set -euo pipefail
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
- uses: actions/setup-python@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
cache: "pip"
- name: Install build deps
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -e .
pip install pyinstaller
- name: Build binary (Windows)
@@ -192,8 +205,7 @@ jobs:
set -euo pipefail
mkdir -p package/bin ffmpeg_tmp
archive_name="ffmpeg-n9.0-latest-linux64-gpl-9.0.tar.xz"
release_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
curl_common=(
--fail
--location
@@ -203,15 +215,11 @@ jobs:
--user-agent "Mozilla/5.0 (GitHub Actions; ytpl-sync release workflow)"
)
curl "${curl_common[@]}" "${release_url}/${archive_name}" -o ffmpeg.tar.xz
curl "${curl_common[@]}" "${release_url}/checksums.sha256" -o checksums.sha256
curl "${curl_common[@]}" "$primary_url" -o ffmpeg.tar.xz
curl "${curl_common[@]}" "${primary_url}.md5" -o ffmpeg.tar.xz.md5
expected_sha256="$(awk -v archive="$archive_name" '$2 == archive || $2 == "*" archive { print $1; exit }' checksums.sha256)"
if [[ -z "$expected_sha256" ]]; then
echo "Checksum for ${archive_name} was not found" >&2
exit 1
fi
printf '%s ffmpeg.tar.xz\n' "$expected_sha256" | sha256sum -c -
expected_md5="$(awk '{print $1}' ffmpeg.tar.xz.md5)"
printf '%s *ffmpeg.tar.xz\n' "$expected_md5" | md5sum -c -
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
echo "Downloaded FFmpeg payload is not a valid tar archive" >&2
@@ -221,13 +229,7 @@ jobs:
fi
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
ffmpeg_binary="$(find ffmpeg_tmp -type f -name ffmpeg -perm -u+x -print -quit)"
if [[ -z "$ffmpeg_binary" ]]; then
echo "Extracted archive does not contain an executable ffmpeg binary" >&2
find ffmpeg_tmp -maxdepth 3 -type f -print >&2 || true
exit 1
fi
mv "$ffmpeg_binary" package/bin/ffmpeg
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
chmod +x package/bin/ffmpeg
- name: Archive (Windows)
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
+13 -3
View File
@@ -1,8 +1,18 @@
name: Lint
name: Lint Python code
on:
workflow_call:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
jobs:
lint:
-34
View File
@@ -1,34 +0,0 @@
name: Test
on:
workflow_call:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
uses: ./.github/workflows/lint_ruff.yml
permissions:
contents: read
unit-tests:
name: Unit tests
uses: ./.github/workflows/unit-tests.yml
permissions:
contents: read
+18 -3
View File
@@ -1,8 +1,23 @@
name: Unit Tests
name: Unit tests
on:
workflow_call:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
@@ -16,7 +31,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v7
uses: actions/setup-python@v6
with:
python-version: "3.12"
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ytpl-sync"
version = "2.3.0"
version = "2.1.1"
description = "YouTube playlist Sync"
readme = "README.md"
authors = [ { name = "Dark_Zoul" } ]
@@ -12,7 +12,7 @@ license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10"
dependencies = [
"yt-dlp>=2026.8.19",
"yt-dlp>=2026.7.4",
"PySide6_Essentials>=6.11.1",
]
[project.optional-dependencies]
+4 -4
View File
@@ -3,17 +3,17 @@ from __future__ import annotations
import argparse
import asyncio
import logging
import re
from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.events.event_bus import EventBus
from .core.sync.executor import ActionExecutor
from .core.sync.service import SyncService
from .core.sync.executor import ActionExecutor
from .core.events.event_bus import EventBus
import re
from .core.utils.yt import extract_playlist_id
from .core.utils.deps import DependencyError
from .core.utils.logging_setup import configure_logging
from .core.utils.yt import extract_playlist_id
def main(argv: list[str] | None = None) -> int:
+8 -11
View File
@@ -1,12 +1,9 @@
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
from typing import Any, Dict, List
def _default_ffmpeg_path() -> str:
@@ -15,7 +12,7 @@ def _default_ffmpeg_path() -> str:
return "./bin/ffmpeg"
DEFAULT_CONFIG: dict[str, Any] = {
DEFAULT_CONFIG: Dict[str, Any] = {
"playlists": [],
"download_mode": "video",
"max_download_quality": "1080p",
@@ -32,7 +29,7 @@ class Settings:
base_dir = Path("config")
base_dir.mkdir(parents=True, exist_ok=True)
self.path = (base_dir / "yt-playlist-config.json").resolve()
self.data: dict[str, Any] = dict(DEFAULT_CONFIG)
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
# Ensure there is always a config file at the default path.
if not self.path.exists():
@@ -43,13 +40,13 @@ class Settings:
def _load_from_path(self, path: Path) -> None:
try:
self.data.update(json.loads(path.read_text(encoding="utf-8")))
except (OSError, TypeError, UnicodeError, ValueError) as exc:
except Exception:
# Leave defaults if invalid JSON; validation can be added later.
logger.debug("Failed to load config %s: %s", path, exc)
pass
def _write_default_config(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
default_payload: dict[str, Any] = {
default_payload: Dict[str, Any] = {
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
@@ -63,7 +60,7 @@ class Settings:
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
@property
def playlists(self) -> list[dict[str, Any]]:
def playlists(self) -> List[Dict[str, Any]]:
global_defaults = {
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]),
"max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
@@ -74,7 +71,7 @@ class Settings:
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]),
}
results: list[dict[str, Any]] = []
results: List[Dict[str, Any]] = []
for pl in list(self.data.get("playlists", [])):
if not isinstance(pl, dict):
continue
+4 -4
View File
@@ -1,10 +1,10 @@
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
__all__ = [
"database",
"download",
"events",
"scanner",
"scheduler",
"sync",
"download",
"database",
"scheduler",
"events",
]
+2 -1
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
import sqlite3
from collections.abc import Iterable
from pathlib import Path
from typing import Iterable
SCHEMA = """
PRAGMA journal_mode=WAL;
+26 -27
View File
@@ -1,11 +1,9 @@
from __future__ import annotations
import logging
from typing import Optional
from .queue_manager import DownloadJob, JobState
logger = logging.getLogger(__name__)
class Downloader:
"""
@@ -13,7 +11,7 @@ class Downloader:
where actual download logic will land (audio/video/both).
"""
def __init__(self, yt_dlp_path: str | None = None, ffmpeg_path: str | None = None) -> None:
def __init__(self, yt_dlp_path: Optional[str] = None, ffmpeg_path: Optional[str] = None) -> None:
self.yt_dlp_path = yt_dlp_path
self.ffmpeg_path = ffmpeg_path
@@ -36,15 +34,15 @@ class Downloader:
return None
try:
cap = int(digits)
except ValueError:
except Exception:
return None
return cap if cap > 0 else None
cap = parse_height_cap(max_download_quality)
if cap is not None:
# Prefer separate MP4 video and audio streams; yt-dlp merges them with ffmpeg.
return f"bestvideo[ext=mp4][height<={cap}]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<={cap}]+bestaudio/bestvideo[ext=mp4][height<={cap}]/best[ext=mp4]"
return "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/bestvideo[ext=mp4]/best[ext=mp4]"
#if the requested cap isn't available, we still download the best mp4.
return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]"
return "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]"
async def handle_job(self, job: DownloadJob):
try:
@@ -62,10 +60,10 @@ class Downloader:
if not job.keep_video and job.output_path:
try:
job.output_path.unlink(missing_ok=True)
except OSError as exc:
logger.debug("Failed to remove temporary video file: %s", exc)
except Exception:
pass
job.state = JobState.COMPLETED
except Exception as exc: # noqa: BLE001 # pragma: no cover - environment dependent
except Exception as exc: # pragma: no cover - environment dependent
job.state = JobState.FAILED
job.error = str(exc)
@@ -74,12 +72,11 @@ class Downloader:
import asyncio
def run():
from pathlib import Path
import yt_dlp # type: ignore
from pathlib import Path
try:
from yt_dlp.utils import DownloadCancelled # type: ignore
except (ImportError, AttributeError): # pragma: no cover - optional
except Exception: # pragma: no cover - optional
DownloadCancelled = Exception # type: ignore[misc,assignment]
class _QuietLogger:
@@ -97,8 +94,10 @@ class Downloader:
fmt = self.build_format(getattr(job, "max_download_quality", None))
# Prefer MP4 video with a separate audio stream; yt-dlp merges them when needed.
# Audio extraction for audio/both modes happens separately after download.
# All modes download a single muxed mp4 when possible.
# This avoids any ffmpeg-driven merging during the download step, satisfying:
# - video: "original file, no processing"
# - audio/both: extraction is done separately after download
ydl_opts = {
"format": fmt,
"outtmpl": outtmpl,
@@ -138,19 +137,19 @@ class Downloader:
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
should_emit = status in {"finished", "error"}
if not should_emit and (
now - last_ts >= 0.25
or pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01)
):
should_emit = True
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:
job._last_progress_emit_ts = now
setattr(job, "_last_progress_emit_ts", now)
if pct is not None:
job._last_progress_emit_pct = pct
setattr(job, "_last_progress_emit_pct", pct)
progress_cb(payload)
except Exception as exc: # noqa: BLE001 - progress hooks must not abort downloads
logger.debug("Progress callback failed: %s", exc)
except Exception:
pass
ydl_opts["progress_hooks"] = [hook]
@@ -161,8 +160,8 @@ class Downloader:
p = Path(str(ffmpeg_hint))
if p.exists():
ydl_opts["ffmpeg_location"] = str(p)
except (OSError, TypeError, ValueError) as exc:
logger.debug("Invalid ffmpeg path: %s", exc)
except Exception:
pass
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
ydl.download([job.url])
+11 -12
View File
@@ -1,11 +1,10 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any
from typing import Any, Callable, Optional
from ..models import PlaylistItem
@@ -23,17 +22,17 @@ class JobState(str, Enum):
@dataclass
class DownloadJob:
item: PlaylistItem
output_path: Path | None = None
url: str | None = None
output_path: Optional[Path] = None
url: Optional[str] = None
mode: str = "audio" # audio|video
state: JobState = JobState.QUEUED
error: str | None = None
ffmpeg_path: str | None = None
max_download_quality: str | None = None
playlist_id: str | None = None
progress_callback: Callable[[dict[str, Any]], None] | None = None
cancel_check: Callable[[], bool] | None = None
audio_output_path: Path | None = None # when mode=video and we also want mp3
error: Optional[str] = None
ffmpeg_path: Optional[str] = None
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
@@ -46,7 +45,7 @@ class QueueManager:
"""
def __init__(self, concurrency: int = 2) -> None:
self._queue: asyncio.Queue[DownloadJob] = asyncio.Queue()
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
self._concurrency = max(1, concurrency)
self._workers: list[asyncio.Task[None]] = []
self._stopped = asyncio.Event()
+1 -2
View File
@@ -2,10 +2,9 @@ from __future__ import annotations
import asyncio
import logging
from ..utils.rate_limit import is_youtube_rate_limit_error
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):
+5 -5
View File
@@ -1,21 +1,21 @@
from __future__ import annotations
from collections import defaultdict
from collections.abc import Awaitable, Callable
from typing import Any
from typing import Any, Awaitable, Callable, DefaultDict, Dict, List
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
class EventBus:
"""Simple async pub/sub event bus used by backend and (later) GUI."""
def __init__(self) -> None:
self._subs: defaultdict[str, list[EventHandler]] = defaultdict(list)
self._subs: DefaultDict[str, List[EventHandler]] = defaultdict(list)
def subscribe(self, event_name: str, handler: EventHandler) -> None:
self._subs[event_name].append(handler)
async def publish(self, event_name: str, payload: dict[str, Any]) -> None:
async def publish(self, event_name: str, payload: Dict[str, Any]) -> None:
for h in list(self._subs.get(event_name, [])):
await h(payload)
+6 -5
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
class DownloadMode(str, Enum):
@@ -14,7 +15,7 @@ class DownloadMode(str, Enum):
@dataclass(frozen=True)
class Playlist:
id: str
name: str | None
name: Optional[str]
url: str
path: Path
mode: DownloadMode = DownloadMode.audio
@@ -28,7 +29,7 @@ class PlaylistItem:
video_id: str
title: str
playlist_index: int
local_filename: str | None = None
local_filename: Optional[str] = None
downloaded: bool = False
@@ -44,9 +45,9 @@ class SyncActionType(str, Enum):
@dataclass(frozen=True)
class SyncAction:
type: SyncActionType
item: PlaylistItem | None = None
from_name: str | None = None
to_name: str | None = None
item: Optional[PlaylistItem] = None
from_name: Optional[str] = None
to_name: Optional[str] = None
@dataclass(frozen=True)
+5 -7
View File
@@ -1,12 +1,10 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional
from ..models import PlaylistItem
logger = logging.getLogger(__name__)
class PlaylistScanner:
"""
@@ -21,7 +19,7 @@ class PlaylistScanner:
def __init__(self) -> None:
pass
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: str | None = None) -> list[PlaylistItem]:
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
"""Return the current remote playlist entries as `PlaylistItem` records."""
try:
import yt_dlp # type: ignore
@@ -41,14 +39,14 @@ class PlaylistScanner:
p = Path(str(ffmpeg_path))
if p.exists():
ydl_opts["ffmpeg_location"] = str(p)
except (OSError, TypeError, ValueError) as exc:
logger.debug("Invalid ffmpeg path: %s", exc)
except Exception:
pass
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(playlist_url, download=False)
entries = info.get("entries", []) if isinstance(info, dict) else []
items: list[PlaylistItem] = []
items: List[PlaylistItem] = []
for idx, v in enumerate(entries, start=1):
if not v:
continue
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
from typing import Awaitable, Callable
class Scheduler:
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from typing import Iterable, List, Mapping, Sequence
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
@@ -18,8 +18,8 @@ class DiffEngine:
db_index: Mapping[str, PlaylistItem],
fs_entries: Iterable[FilesystemEntry],
extension: str,
) -> list[SyncAction]:
actions: list[SyncAction] = []
) -> List[SyncAction]:
actions: List[SyncAction] = []
desired_names = {
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
+21 -26
View File
@@ -1,23 +1,20 @@
from __future__ import annotations
import asyncio
import logging
import shutil
import time
from collections.abc import Iterable
import shutil
from pathlib import Path
from typing import Iterable, List
from ..database.db import Database
from ..download.queue_manager import DownloadJob, QueueManager
from ..download.workers import default_worker
from ..events.event_bus import EventBus
from ..models import SyncAction, SyncActionType
from ..sync.reorder import safe_multi_rename
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
from ..utils.yt import extract_playlist_id
logger = logging.getLogger(__name__)
class ActionExecutor:
@@ -98,8 +95,7 @@ class ActionExecutor:
try:
self.db.set_playlist_last_sync(playlist_id)
last_sync = self.db.get_playlist_last_sync(playlist_id)
except Exception as exc: # noqa: BLE001 - sync timestamp persistence is best effort
logger.debug("Failed to persist playlist sync timestamp: %s", exc)
except Exception:
last_sync = None
summary = {
"playlist_id": playlist_id,
@@ -144,7 +140,7 @@ class ActionExecutor:
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
audio_renames = []
video_renames = []
applied: list[SyncAction] = []
applied: List[SyncAction] = []
for a in actions:
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
continue
@@ -164,8 +160,8 @@ class ActionExecutor:
if a.item and a.to_name:
try:
self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name)
except Exception as exc: # noqa: BLE001 - database update is best effort
logger.debug("Failed to update renamed file in database: %s", exc)
except Exception:
pass
if self.bus:
await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name})
@@ -191,19 +187,18 @@ class ActionExecutor:
if dst.exists():
dst.unlink()
shutil.move(str(src), str(dst))
except OSError as exc:
logger.debug("Failed to recycle file %s: %s", src, exc)
except Exception:
# fallback to delete if move fails
try:
src.unlink()
except OSError as unlink_exc:
logger.debug("Failed to delete unrecycled file %s: %s", src, unlink_exc)
except Exception:
pass
# Update DB to clear file state
if a.item:
try:
self.db.clear_file_state(playlist_id, a.item.video_id)
except Exception as exc: # noqa: BLE001 - database update is best effort
logger.debug("Failed to clear deleted file in database: %s", exc)
except Exception:
pass
if self.bus:
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
@@ -224,7 +219,7 @@ class ActionExecutor:
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
try:
concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency
except (TypeError, ValueError):
except Exception:
concurrency = self.concurrency
queue = QueueManager(concurrency=concurrency)
@@ -232,17 +227,17 @@ class ActionExecutor:
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
try:
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
except (TypeError, ValueError):
except Exception:
retry_max_retries = 2
try:
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
except (TypeError, ValueError):
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 (TypeError, ValueError):
except Exception:
delay_between_downloads_seconds = 0.0
rate_limit_pause = asyncio.Event()
@@ -289,7 +284,7 @@ class ActionExecutor:
await queue.start(worker)
try:
jobs: list[DownloadJob] = []
jobs: List[DownloadJob] = []
# Collapse 'both' into single video download + local audio extraction
# Build per-video desired outputs
@@ -421,5 +416,5 @@ class ActionExecutor:
)
else:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
except Exception as exc: # noqa: BLE001 - one failed job must not stop finalization
logger.debug("Failed to persist download result: %s", exc)
except Exception:
pass
+3 -3
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from typing import List, Sequence
from ..models import FilesystemEntry
def list_files(root: Path, extensions: Sequence[str]) -> list[FilesystemEntry]:
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
exts = {e.lower() for e in extensions}
results: list[FilesystemEntry] = []
results: List[FilesystemEntry] = []
if not root.exists():
return results
for p in root.glob("**/*"):
+3 -3
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from typing import Dict, Iterable, Tuple
def safe_multi_rename(renames: Iterable[tuple[Path, Path]]) -> None:
def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
"""
Apply multiple renames safely using a two-pass strategy to avoid
name collisions. Each item is a tuple (src_path, dst_path).
@@ -14,7 +14,7 @@ def safe_multi_rename(renames: Iterable[tuple[Path, Path]]) -> None:
existing_dests = {dst for _, dst in planned}
# Pass 1: move all sources that would collide to temporary names
temps: dict[Path, Path] = {}
temps: Dict[Path, Path] = {}
for src, dst in planned:
if not src.exists():
continue
+5 -4
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
from typing import List
from ..database.db import Database
from ..models import PlaylistItem, SyncAction
@@ -33,7 +34,7 @@ class SyncService:
return [".mp3", ".mp4"]
return [".mp4"]
def sync_from_config(self, playlist_cfg: dict) -> list[SyncAction]:
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
"""Return the sync actions required to bring one playlist in sync.
This method does not apply any changes itself. It normalizes the
@@ -59,7 +60,7 @@ class SyncService:
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path")
items = self.scanner.scan(url, playlist_id, ffmpeg_path=str(ffmpeg_cfg) if ffmpeg_cfg is not None else None)
sanitized: list[PlaylistItem] = []
sanitized: List[PlaylistItem] = []
for it in items:
safe_title = sanitize_title(it.title, it.video_id)
sanitized.append(
@@ -99,7 +100,7 @@ class SyncService:
)
# Augment remote items with DB-known filenames/download flags
augmented: list[PlaylistItem] = []
augmented: List[PlaylistItem] = []
for it in sanitized:
known = db_index.get(it.video_id)
if known is None:
@@ -117,7 +118,7 @@ class SyncService:
)
exts = self._mode_to_extensions(mode)
merged_actions: list[SyncAction] = []
merged_actions: List[SyncAction] = []
# Compute per-extension actions against respective roots
for ext in exts:
+9 -8
View File
@@ -6,6 +6,7 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from shutil import which
from typing import Optional, Tuple
class DependencyError(RuntimeError):
@@ -15,20 +16,20 @@ class DependencyError(RuntimeError):
@dataclass(frozen=True)
class DependencyStatus:
yt_dlp_ok: bool
yt_dlp_version: str | None
yt_dlp_version: Optional[str]
ffmpeg_ok: bool
ffmpeg_path: str | None
ffmpeg_path: Optional[str]
ffprobe_ok: bool
ffprobe_path: str | None
ffprobe_path: Optional[str]
def get_yt_dlp_version() -> str | None:
def get_yt_dlp_version() -> Optional[str]:
try:
import yt_dlp # type: ignore
# yt-dlp exposes version in different places depending on install
return getattr(yt_dlp, "__version__", None) or getattr(getattr(yt_dlp, "version", None), "__version__", None)
except (ImportError, AttributeError, TypeError):
except Exception:
return None
@@ -43,7 +44,7 @@ def ensure_yt_dlp_available() -> str:
return get_yt_dlp_version() or "unknown"
def _resolve_tool_paths(tool_hint: str | None, exe_name: str) -> tuple[str | None, str | None]:
def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Optional[str], Optional[str]]:
"""
Returns (resolved_exe_path, resolved_dir_hint).
- If tool_hint is a file path, use it.
@@ -69,7 +70,7 @@ def _resolve_tool_paths(tool_hint: str | None, exe_name: str) -> tuple[str | Non
return None, None
def ensure_ffmpeg_available(ffmpeg_hint: str | None) -> tuple[str, str]:
def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]:
"""
Ensures ffmpeg is runnable. Returns (ffmpeg_path, ffmpeg_path).
@@ -78,7 +79,7 @@ def ensure_ffmpeg_available(ffmpeg_hint: str | None) -> tuple[str, str]:
"""
ffmpeg_exe = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"
ffmpeg_path, _ = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
ffmpeg_path, ffmpeg_dir = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
if not ffmpeg_path:
raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.")
+2 -4
View File
@@ -17,11 +17,9 @@ def _read_text(path: Path) -> str | None:
if path.exists():
text = path.read_text(encoding="utf-8").strip()
return text or None
except (OSError, UnicodeError):
return None
except Exception:
pass
return None
def get_app_version() -> str:
"""
Returns the packaged app version.
+1 -1
View File
@@ -10,5 +10,5 @@ def extract_playlist_id(url: str) -> str | None:
if "list" in qs and qs.get("list"):
return qs.get("list", [None])[0]
return None
except (TypeError, ValueError):
except Exception:
return None
+4 -7
View File
@@ -1,13 +1,10 @@
from __future__ import annotations
import logging
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
logger = logging.getLogger(__name__)
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
@@ -34,16 +31,16 @@ def load_app_icon() -> QtGui.QIcon:
icon = QtGui.QIcon(str(p))
if not icon.isNull():
return icon
except Exception as exc: # noqa: BLE001 - icon loading is best effort
logger.debug("Failed to load app icon from %s: %s", p, exc)
except Exception:
pass
# Fallback to a platform theme icon (Linux) or a generic icon.
try:
themed = QtGui.QIcon.fromTheme("applications-multimedia")
if not themed.isNull():
return themed
except Exception as exc: # noqa: BLE001 - themed icon loading is best effort
logger.debug("Failed to load themed app icon: %s", exc)
except Exception:
pass
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
+2 -4
View File
@@ -1,7 +1,5 @@
from __future__ import annotations
from typing import Any
from typing import Any, Dict
from PySide6 import QtCore
from ..core.events.event_bus import EventBus
@@ -34,7 +32,7 @@ class BusBridge(QtCore.QObject):
self._bus.subscribe(name, self._make_handler(name))
def _make_handler(self, name: str):
async def handler(payload: dict[str, Any]) -> None:
async def handler(payload: Dict[str, Any]) -> None:
# Ensure delivery on the Qt main thread.
self.event.emit(name, dict(payload))
+5 -5
View File
@@ -3,29 +3,29 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, Dict
@dataclass(frozen=True)
class AppConfig:
data: dict[str, Any]
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 TypeError("config root must be a JSON object")
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:
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]:
def normalize_config(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Ensure basic expected shape for config dict.
Keeps unknown keys intact.
+32 -60
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import logging
import sys
import threading
@@ -8,17 +7,15 @@ from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings
from ..core.events.event_bus import EventBus
from .app_icon import load_app_icon
from .bus_bridge import BusBridge
from .app_icon import load_app_icon
from .config_store import load_config
from .pages.about import AboutPage
from .pages.logs import LogsPage
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
from .runner import SyncRequest, SyncRunner
logger = logging.getLogger(__name__)
from .pages.about import AboutPage
class MainWindow(QtWidgets.QMainWindow):
@@ -93,8 +90,8 @@ class MainWindow(QtWidgets.QMainWindow):
if cfg_path is not None:
try:
self._settings_page.set_config_path(cfg_path)
except Exception as exc: # noqa: BLE001 - optional settings-page wiring
logger.debug("Failed to set settings config path: %s", exc)
except Exception:
pass
self._playlists_page.cancel_requested.connect(self._cancel_sync)
self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
@@ -141,8 +138,7 @@ class MainWindow(QtWidgets.QMainWindow):
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
return dict(tray)
except Exception as exc: # noqa: BLE001 - config fallback keeps the UI usable
logger.debug("Failed to load tray configuration: %s", exc)
except Exception:
return {}
def _close_to_tray_enabled(self) -> bool:
@@ -162,8 +158,7 @@ class MainWindow(QtWidgets.QMainWindow):
try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return
except Exception as exc: # noqa: BLE001 - tray support is optional
logger.debug("Failed to check system tray availability: %s", exc)
except Exception:
return
icon = load_app_icon()
@@ -216,30 +211,27 @@ class MainWindow(QtWidgets.QMainWindow):
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
3000,
)
except Exception as exc: # noqa: BLE001 - tray notification is best effort
logger.debug("Failed to show tray notification: %s", exc)
except Exception:
pass
return
if self._tray is not None and not self._close_to_tray_enabled():
# Explicitly quit, because the app may be configured to keep running without windows.
try:
event.accept()
except Exception as exc: # noqa: BLE001 - close event acceptance is best effort
logger.debug("Failed to accept close event: %s", exc)
except Exception:
pass
QtWidgets.QApplication.quit()
return
super().closeEvent(event)
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
try:
if (
event.type() == QtCore.QEvent.Type.WindowStateChange
and self._tray is not None
and self._minimize_to_tray_enabled()
and bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized)
):
QtCore.QTimer.singleShot(0, self.hide)
except Exception as exc: # noqa: BLE001 - window state handling is best effort
logger.debug("Failed to handle window state change: %s", exc)
if event.type() == QtCore.QEvent.Type.WindowStateChange:
if self._tray is not None and self._minimize_to_tray_enabled():
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
QtCore.QTimer.singleShot(0, self.hide)
except Exception:
pass
super().changeEvent(event)
def _refresh_queue_labels(self) -> None:
@@ -252,24 +244,24 @@ class MainWindow(QtWidgets.QMainWindow):
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 as exc: # noqa: BLE001 - queue labels are best effort
logger.debug("Failed to refresh queue labels: %s", exc)
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 as exc: # noqa: BLE001 - page event fan-out is isolated
logger.debug("Queue page event handler failed: %s", exc)
except Exception:
pass
try:
self._logs_page.on_event(name, payload)
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
logger.debug("Logs page event handler failed: %s", exc)
except Exception:
pass
try:
self._playlists_page.on_event(name, payload)
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
logger.debug("Playlists page event handler failed: %s", exc)
except Exception:
pass
# Auto-pause on YouTube bot-check/rate-limit surface.
if name == "SyncPaused":
@@ -323,8 +315,8 @@ class MainWindow(QtWidgets.QMainWindow):
try:
self._thread.quit()
self._thread.wait(2000)
except Exception as exc: # noqa: BLE001 - thread shutdown is best effort
logger.debug("Failed to stop sync thread: %s", exc)
except Exception:
pass
self._runner = None
self._cancel_flag = None
self._pause_flag = None
@@ -338,12 +330,12 @@ class MainWindow(QtWidgets.QMainWindow):
if cfg_path is not None:
self._settings_page.set_config_path(cfg_path)
self._refresh_queue_labels()
except Exception as exc: # noqa: BLE001 - config refresh is best effort
logger.debug("Failed to refresh configuration after sync: %s", exc)
except Exception:
pass
# Continue "sync all" chain if active.
if hasattr(self, "_sync_queue") and self._sync_queue:
nxt = self._sync_queue.pop(0)
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"):
nxt = getattr(self, "_sync_queue").pop(0)
self._sync_playlist_index(nxt)
@QtCore.Slot()
@@ -408,26 +400,6 @@ class MainWindow(QtWidgets.QMainWindow):
border-radius: 10px;
color: #d7dce4;
}
QScrollArea#logsScroll {
background: #11151c;
border: 1px solid #2a3140;
border-radius: 10px;
}
QWidget#logEntries {
background: #11151c;
}
QFrame#logEntry {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 8px;
}
QLabel#logEventName {
color: #8ea7ff;
font-weight: 600;
}
QLabel#logEntryBody {
color: #cfd3da;
}
QScrollBar:vertical {
background: #0f1218;
width: 12px;
+12 -59
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from PySide6 import QtCore, QtWidgets
from PySide6 import QtWidgets
from ..smooth_scroll import enable_smooth_scrolling
@@ -23,69 +23,22 @@ class LogsPage(QtWidgets.QWidget):
top.addWidget(clear_btn)
layout.addLayout(top)
self._scroll = QtWidgets.QScrollArea()
self._scroll.setObjectName("logsScroll")
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self._entries = QtWidgets.QWidget()
self._entries.setObjectName("logEntries")
self._entries_layout = QtWidgets.QVBoxLayout(self._entries)
self._entries_layout.setContentsMargins(2, 2, 2, 2)
self._entries_layout.setSpacing(8)
self._entries_layout.addStretch(1)
self._scroll.setWidget(self._entries)
enable_smooth_scrolling(self._scroll)
layout.addWidget(self._scroll, 1)
self._text = QtWidgets.QPlainTextEdit()
self._text.setReadOnly(True)
enable_smooth_scrolling(self._text)
layout.addWidget(self._text, 1)
def _clear(self) -> None:
while self._entries_layout.count() > 1:
item = self._entries_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
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 the event name in the header and show only its payload here.
# Keep this lightweight: append a single-line JSON entry.
try:
line = json.dumps(payload, ensure_ascii=False)
except Exception: # noqa: BLE001 - fall back to a readable representation
line = str(payload)
entry = QtWidgets.QFrame()
entry.setObjectName("logEntry")
entry_layout = QtWidgets.QVBoxLayout(entry)
entry_layout.setContentsMargins(12, 9, 12, 9)
entry_layout.setSpacing(4)
header_layout = QtWidgets.QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
event_label = QtWidgets.QLabel(name)
event_label.setObjectName("logEventName")
event_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
header_layout.addWidget(event_label)
header_layout.addStretch(1)
copy_btn = QtWidgets.QPushButton("Copy")
copy_btn.setToolTip("Copy this log entry")
copy_btn.clicked.connect(lambda: self._copy_entry(f"{name}\n{line}"))
header_layout.addWidget(copy_btn)
entry_layout.addLayout(header_layout)
body_label = QtWidgets.QLabel(line)
body_label.setObjectName("logEntryBody")
body_label.setWordWrap(True)
body_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
body_label.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Preferred)
entry_layout.addWidget(body_label)
self._entries_layout.insertWidget(self._entries_layout.count() - 1, entry)
self._scroll.verticalScrollBar().setValue(self._scroll.verticalScrollBar().maximum())
@staticmethod
def _copy_entry(text: str) -> None:
QtWidgets.QApplication.clipboard().setText(text)
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)
+12 -11
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
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 ..config_store import load_config, normalize_config, save_config
from ..smooth_scroll import enable_smooth_scrolling
from ..config_store import load_config, normalize_config, save_config
@dataclass(frozen=True)
@@ -137,7 +137,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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: # noqa: BLE001 - show config load failures in the UI
except Exception as exc:
self._status.setText(f"Failed to load config: {exc}")
return
finally:
@@ -152,7 +152,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
ls = db.get_playlist_last_sync(pid)
if ls:
last_sync_by_id[pid] = str(ls)
except Exception: # noqa: BLE001 - database metadata is optional
except Exception:
last_sync_by_id = {}
self._list.clear()
@@ -256,7 +256,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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: # noqa: BLE001 - show config save failures in the UI
except Exception as exc:
self._status.setText(f"Failed to save config: {exc}")
def _reindex_cards(self) -> None:
@@ -302,7 +302,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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: # noqa: BLE001 - show autosave failures in the UI
except Exception as exc:
self._status.setText(f"Autosave failed: {exc}")
def on_event(self, name: str, payload: dict) -> None:
@@ -355,7 +355,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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 = round(max(0.0, min(1.0, p)) * 100)
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())
@@ -392,8 +392,9 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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_status(text)
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()):
@@ -603,7 +604,7 @@ class _PlaylistCard(QtWidgets.QFrame):
return extract_playlist_id(url) or url
def set_progress(self, progress: float) -> None:
pct = max(0, min(100, round(progress * 100)))
pct = max(0, min(100, int(round(progress * 100))))
self._progress.setValue(pct)
def set_last_sync(self, last_sync: str) -> None:
@@ -612,7 +613,7 @@ class _PlaylistCard(QtWidgets.QFrame):
def validate(self) -> list[str]:
errs: list[str] = []
url = self._url.text().strip()
if not url or not url.startswith(("http://", "https://")):
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"}:
+1 -10
View File
@@ -30,12 +30,9 @@ class QueuePage(QtWidgets.QWidget):
top.addStretch(1)
clear_btn = QtWidgets.QPushButton("Clear completed")
clear_btn.clicked.connect(self._clear_completed)
clear_all_btn = QtWidgets.QPushButton("Clear all")
clear_all_btn.clicked.connect(self._clear_all)
cancel_btn = QtWidgets.QPushButton("Cancel all")
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
top.addWidget(clear_btn)
top.addWidget(clear_all_btn)
top.addWidget(cancel_btn)
layout.addLayout(top)
@@ -144,7 +141,7 @@ class QueuePage(QtWidgets.QWidget):
status_item.setText(str(payload.get("status") or "downloading"))
prog = payload.get("progress")
if isinstance(prog, (int, float)):
pct = max(0, min(100, round(prog * 100)))
pct = max(0, min(100, int(round(prog * 100))))
bar = self._table.cellWidget(row, 3)
if bar is None:
bar = QtWidgets.QProgressBar()
@@ -216,9 +213,3 @@ class QueuePage(QtWidgets.QWidget):
vid = v_item.text()
rebuilt[(str(pid), str(vid))] = v_item
self._rows_by_key = rebuilt
def _clear_all(self) -> None:
self._pending_by_key.clear()
self._rows_by_key.clear()
self._table.setRowCount(0)
self._hint.setText("Waiting for downloads…")
+2 -2
View File
@@ -126,7 +126,7 @@ class SettingsPage(QtWidgets.QWidget):
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
self._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc: # noqa: BLE001 - show load failures in the UI
except Exception as exc:
self._status.setText(f"Failed to load settings: {exc}")
finally:
self._suppress_autosave = False
@@ -161,5 +161,5 @@ class SettingsPage(QtWidgets.QWidget):
save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc: # noqa: BLE001 - show save failures in the UI
except Exception as exc:
self._status.setText(f"Failed to save settings: {exc}")
+4 -4
View File
@@ -4,19 +4,19 @@ import asyncio
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from typing import Any, Dict
from PySide6 import QtCore
from ..core.database.db import Database
from ..core.events.event_bus import EventBus
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]
playlist_cfg: Dict[str, Any]
apply: bool = True
db_path: Path = Path("db/app.db")
cancel_flag: threading.Event | None = None
@@ -70,5 +70,5 @@ class SyncRunner(QtCore.QObject):
self.finished.emit(False, "cancelled")
else:
self.finished.emit(True, "done")
except Exception as exc: # noqa: BLE001 - report worker failures through the UI signal
except Exception as exc:
self.finished.emit(False, str(exc))
+3 -3
View File
@@ -11,7 +11,7 @@ class _SmoothWheelFilter(QtCore.QObject):
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:
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # noqa: N802
if event.type() != QtCore.QEvent.Type.Wheel:
return super().eventFilter(obj, event)
@@ -19,7 +19,7 @@ class _SmoothWheelFilter(QtCore.QObject):
try:
angle_y = wheel.angleDelta().y()
pixel_y = wheel.pixelDelta().y()
except Exception: # noqa: BLE001 - fall back to native scrolling for unknown event types
except Exception:
return super().eventFilter(obj, event)
dy = pixel_y if pixel_y else angle_y
@@ -34,7 +34,7 @@ class _SmoothWheelFilter(QtCore.QObject):
if pixel_y:
delta = -dy
else:
delta = round(-dy / 120.0 * base_step)
delta = int(round(-dy / 120.0 * base_step))
target = max(sb.minimum(), min(sb.maximum(), start + delta))
if target == start:
+3 -3
View File
@@ -12,10 +12,10 @@ from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.executor import ActionExecutor
from .core.sync.service import SyncService
from .core.utils.deps import DependencyError
from .core.sync.executor import ActionExecutor
from .core.utils.yt import extract_playlist_id
from .core.utils.deps import DependencyError
def bootstrap(db_path: Path | None = None) -> None:
@@ -49,7 +49,7 @@ def bootstrap(db_path: Path | None = None) -> None:
print("Applied actions.")
else:
print(f"No actions needed for: {pl.get('url')}")
except Exception as exc: # noqa: BLE001 - keep bootstrap resilient during early dev
except Exception as exc: # keep bootstrap resilient during early dev
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
-2
View File
@@ -6,12 +6,10 @@ from app.core.download.downloader import Downloader
def test_build_format_defaults_to_best_mp4():
fmt = Downloader.build_format(None)
assert "height<=1080" in fmt
assert "bestvideo[ext=mp4][height<=1080]+bestaudio" in fmt
assert fmt.endswith("/best[ext=mp4]")
def test_build_format_applies_height_cap():
fmt = Downloader.build_format("720p")
assert "bestvideo[ext=mp4]" in fmt
assert "height<=720" in fmt
+1 -1
View File
@@ -37,7 +37,7 @@ def test_executor_emits_sync_events(tmp_path):
assert "SyncSummary" in names
assert "SyncFinished" in names
summary = next(p for n, p in published if n == "SyncSummary")
summary = [p for n, p in published if n == "SyncSummary"][0]
assert summary["playlist_id"] == "p"
assert "duration_s" in summary
assert isinstance(summary["counts"], dict)
+2 -1
View File
@@ -5,10 +5,11 @@ import os
import pytest
from app.core.database.db import Database
from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus
from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+2 -1
View File
@@ -5,10 +5,11 @@ import os
import pytest
from app.core.database.db import Database
from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus
from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+2 -1
View File
@@ -5,10 +5,11 @@ import os
import pytest
from app.core.database.db import Database
from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus
from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+1 -7
View File
@@ -1,13 +1,7 @@
from __future__ import annotations
import sys
from pathlib import Path
src_dir = Path(__file__).resolve().parent / "src"
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
from app.gui.main import main
if __name__ == "__main__":
raise SystemExit(main())