diff --git a/plans/project plan.md b/plans/project plan.md index a9a388d..5d076e6 100644 --- a/plans/project plan.md +++ b/plans/project plan.md @@ -2,7 +2,7 @@ ## Subject Area -- Tool for downloading and synchronizing YouTube playlists. +- Tool for downloading and synchronizing local YouTube playlists. - Focuses on batch downloading, format selection (audio and/or video), configurable quality and keeping local copies synced with playlist changes. - Targets power users and archivists who need large-scale, repeatable playlist archiving and ongoing synchronization, with GUI interface. @@ -14,7 +14,7 @@ ## Users Definition -Individuals who need to download a large number of videos or audio files from a YouTube playlist and keep it updated +Individuals who need to have a local youtube playlist synced with a large number of videos or audio files ## Functionality Definition @@ -38,13 +38,10 @@ Individuals who need to download a large number of videos or audio files from a ## Platforms - Desktop: Windows (Primary), Linux -- Docker -- Possible Future: Web App, Android App (via shared FastAPI backend) ## Architecture & Languages -- Core Engine: Python (yt-dlp wrapper) -- Backend API: FastAPI (Local localhost-only boundary) +- Core Engine: Python (yt-dlp) - Desktop Frontend: PySide6 (Qt for Python) - Distribution: PyInstaller / Briefcase (Windows .exe, Linux AppImage) \ No newline at end of file diff --git a/src/app/cli.py b/src/app/cli.py index c4647e5..4c288c6 100644 --- a/src/app/cli.py +++ b/src/app/cli.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse import asyncio +import logging from pathlib import Path from .config.settings import Settings @@ -12,6 +13,7 @@ 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 def main(argv: list[str] | None = None) -> int: @@ -20,8 +22,12 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--db", type=Path, default=Path("app/data/app.db"), help="Path to SQLite database") parser.add_argument("--playlist", type=int, default=None, help="Only run for a specific playlist index (0-based)") parser.add_argument("--verbose", action="store_true", help="Print detailed events (rename/recycle/start)") + parser.add_argument("--debug", action="store_true", help="Enable debug logging to console + app/data/app.log") args = parser.parse_args(argv) + configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log")) + log = logging.getLogger(__name__) + settings = Settings() db = Database(args.db.resolve()) service = SyncService(db) @@ -88,14 +94,17 @@ def main(argv: list[str] | None = None) -> int: counts[a.type.name] = counts.get(a.type.name, 0) + 1 summary = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items())) print(f"Playlist {pid}: {len(actions)} actions → {summary}") + log.info("playlist=%s actions=%s summary=%s", pid, len(actions), summary) if args.apply and actions: try: asyncio.run(executor.execute(actions, pl)) except DependencyError as e: print(f"ERROR: {e}") + log.error("dependency error: %s", e) return 2 db.set_playlist_last_sync(pid) print(f"Applied actions for {pid}.") + log.info("playlist=%s applied_actions=%s", pid, len(actions)) return 0 diff --git a/src/app/core/download/workers.py b/src/app/core/download/workers.py index 6f3974f..f13adc5 100644 --- a/src/app/core/download/workers.py +++ b/src/app/core/download/workers.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +import logging from .downloader import Downloader from .queue_manager import DownloadJob, JobState async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5): + log = logging.getLogger(__name__) dl = Downloader(ffmpeg_path=job.ffmpeg_path) attempt = 0 while attempt <= max_retries: @@ -14,4 +16,13 @@ async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_second return attempt += 1 if attempt <= max_retries: - await asyncio.sleep(delay_seconds) + wait = delay_seconds * (2 ** (attempt - 1)) + log.warning( + "retrying download attempt=%s/%s video_id=%s wait=%.1fs error=%s", + attempt, + max_retries, + getattr(getattr(job, "item", None), "video_id", None), + wait, + job.error, + ) + await asyncio.sleep(wait) diff --git a/src/app/core/utils/logging_setup.py b/src/app/core/utils/logging_setup.py new file mode 100644 index 0000000..85e3487 --- /dev/null +++ b/src/app/core/utils/logging_setup.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + + +def configure_logging(*, verbose: bool = False, log_file: Path | None = None) -> None: + """ + Configure app-wide logging. + + - Console handler always enabled. + - Rotating file handler enabled when log_file is provided. + """ + root = logging.getLogger() + root.setLevel(logging.DEBUG if verbose else logging.INFO) + + # Avoid duplicate handlers on repeated calls (tests, re-entrypoints). + if getattr(configure_logging, "_configured", False): + return + + fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s") + + console = logging.StreamHandler() + console.setLevel(logging.DEBUG if verbose else logging.INFO) + console.setFormatter(fmt) + root.addHandler(console) + + if log_file is not None: + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = RotatingFileHandler(str(log_file), maxBytes=2_000_000, backupCount=3, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(fmt) + root.addHandler(file_handler) + + configure_logging._configured = True # type: ignore[attr-defined] +