Author SHA1 Message Date
darkzoul5 c6b6f6518d chore: bump yt-dlp to 2026.10.21 2026-10-22 10:17:59 +00:00
dark_zoul 1c0ccde877 ci: create release tag only after successful build 2026-10-19 23:53:39 +03:00
dark_zoul 15c7e2b298 ci: handle nested ffmpeg archive layout 2026-10-19 23:51:17 +03:00
dark_zoul c6a98d30a5 ci: reuse test workflow in release build 2026-10-19 23:50:03 +03:00
dark_zoul 15c7c8e514 ci: prevent reusable test workflow deadlock 2026-10-19 23:45:48 +03:00
dark_zoul e1aaf35c1f ci: switch to BtbN n9 ffmpeg builds 2026-10-19 23:41:36 +03:00
dark_zoul 758dcf5080 ci: add combined test workflow 2026-10-19 23:33:34 +03:00
dark_zoul 99bbefce9c chore: bump version to 2.3.0 2026-10-19 23:29:40 +03:00
dark_zoul 02a06a70e4 chore: bump version to 2.2.0 2026-10-19 23:27:24 +03:00
dark_zoul c578e42bf7 fix: load workspace source from app entry point 2026-10-19 23:26:57 +03:00
dark_zoul 9a5deeefed fix: support separate yt-dlp video and audio streams 2026-10-19 23:26:44 +03:00
dark_zoul 389bd1b348 feat: add log entry copy buttons 2026-10-19 23:04:44 +03:00
dark_zoul e65199a403 feat: add clear all queue action 2026-10-19 22:57:48 +03:00
dark_zoul aefef55a2c feat: display logs as visually separate entries 2026-10-19 22:54:33 +03:00
dark_zoul 8904e07597 fix: resolve all current lint errors 2026-10-19 22:46:47 +03:00
dark_zoul 972c8e1dbf fix: resolve app main lint error 2026-10-19 22:21:41 +03:00
dark_zoul b6fb314571 fix: resolve core utility lint errors 2026-10-19 22:17:55 +03:00
dark_zoul b8acce7623 fix: resolve sync executor lint errors 2026-10-19 22:15:43 +03:00
dark_zoul a647685070 fix: resolve scanner lint errors 2026-10-19 22:13:45 +03:00
dark_zoul 3494578c0e refactor: fix lint errors in src/app/core/downloader.py 2026-10-19 22:11:28 +03:00
dark_zoul 5b393c0bcd refactor: fix lint errors with ruff check . --fix 2026-10-19 21:52:30 +03:00
darkzoul5 47f88bc6a4 chore: bump yt-dlp to 2026.8.19 (#20) 2026-08-26 14:13:50 +03:00
dependabot[bot] 835958cd94 chore(deps): bump actions/setup-python from 6 to 7 (#19)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 13:31:32 +03:00
darkzoul5 e1fab69598 chore: bump yt-dlp to 2026.7.4 (#18) 2026-07-05 22:47:44 +03:00
dependabot[bot] e97c419b47 chore(deps): bump actions/checkout from 6 to 7 (#17)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 23:23:48 +03:00
46 changed files with 768 additions and 758 deletions

No files matched your search

+72 -74
View File
@@ -24,71 +24,15 @@ permissions:
contents: write contents: write
jobs: jobs:
tag:
name: Create tag
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate input tag
shell: bash
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
if [[ -z "$TAG" ]]; then
echo "tag is required" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
echo "tag must start with 'v' (got: $TAG)" >&2
exit 1
fi
- name: Create/push tag
shell: bash
env:
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force_tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
if [[ "${FORCE}" != "true" ]]; then
echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2
exit 1
fi
git tag -d "${TAG}" || true
git push origin ":refs/tags/${TAG}" || true
fi
git tag "${TAG}" "${GITHUB_SHA}"
git push origin "${TAG}"
test: test:
name: Unit tests name: Test
runs-on: ubuntu-latest uses: ./.github/workflows/test.yml
steps: permissions:
- uses: actions/checkout@v6 contents: read
- 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: build:
name: Build packages name: Build packages
needs: tag needs: test
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
@@ -97,7 +41,7 @@ jobs:
ffmpeg: [bundled, none] ffmpeg: [bundled, none]
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Derive version - name: Derive version
id: version id: version
@@ -113,15 +57,15 @@ jobs:
set -euo pipefail set -euo pipefail
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
- uses: actions/setup-python@v6 - uses: actions/setup-python@v7
with: with:
python-version: "3.12" python-version: "3.12"
cache: "pip" cache: "pip"
- name: Install build deps - name: Install build deps
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -e . pip install -e .
pip install pyinstaller pip install pyinstaller
- name: Build binary (Windows) - name: Build binary (Windows)
@@ -205,7 +149,8 @@ jobs:
set -euo pipefail set -euo pipefail
mkdir -p package/bin ffmpeg_tmp mkdir -p package/bin ffmpeg_tmp
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" archive_name="ffmpeg-n9.0-latest-linux64-gpl-9.0.tar.xz"
release_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
curl_common=( curl_common=(
--fail --fail
--location --location
@@ -215,11 +160,15 @@ jobs:
--user-agent "Mozilla/5.0 (GitHub Actions; ytpl-sync release workflow)" --user-agent "Mozilla/5.0 (GitHub Actions; ytpl-sync release workflow)"
) )
curl "${curl_common[@]}" "$primary_url" -o ffmpeg.tar.xz curl "${curl_common[@]}" "${release_url}/${archive_name}" -o ffmpeg.tar.xz
curl "${curl_common[@]}" "${primary_url}.md5" -o ffmpeg.tar.xz.md5 curl "${curl_common[@]}" "${release_url}/checksums.sha256" -o checksums.sha256
expected_md5="$(awk '{print $1}' ffmpeg.tar.xz.md5)" expected_sha256="$(awk -v archive="$archive_name" '$2 == archive || $2 == "*" archive { print $1; exit }' checksums.sha256)"
printf '%s *ffmpeg.tar.xz\n' "$expected_md5" | md5sum -c - 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 -
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
echo "Downloaded FFmpeg payload is not a valid tar archive" >&2 echo "Downloaded FFmpeg payload is not a valid tar archive" >&2
@@ -229,7 +178,13 @@ jobs:
fi fi
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1 tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg 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
chmod +x package/bin/ffmpeg chmod +x package/bin/ffmpeg
- name: Archive (Windows) - name: Archive (Windows)
@@ -268,12 +223,55 @@ jobs:
ytpl-sync-*.zip ytpl-sync-*.zip
ytpl-sync-*.tar.gz ytpl-sync-*.tar.gz
release: tag:
name: Create Release name: Create tag
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Validate input tag
shell: bash
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
if [[ -z "$TAG" ]]; then
echo "tag is required" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
echo "tag must start with 'v' (got: $TAG)" >&2
exit 1
fi
- name: Create/push tag
shell: bash
env:
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force_tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
if [[ "${FORCE}" != "true" ]]; then
echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2
exit 1
fi
git tag -d "${TAG}" || true
git push origin ":refs/tags/${TAG}" || true
fi
git tag "${TAG}" "${GITHUB_SHA}"
git push origin "${TAG}"
release:
name: Create Release
needs: tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: '3.11' python-version: '3.11'
cache: 'pip' cache: 'pip'
+4 -14
View File
@@ -1,25 +1,15 @@
name: Lint Python code name: Lint
on: on:
push: workflow_call:
branches: workflow_dispatch:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
jobs: jobs:
lint: lint:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install Ruff - name: Install Ruff
run: pip install ruff run: pip install ruff
+34
View File
@@ -0,0 +1,34 @@
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
+4 -19
View File
@@ -1,23 +1,8 @@
name: Unit tests name: Unit Tests
on: on:
workflow_call:
workflow_dispatch: 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: jobs:
tests: tests:
@@ -28,10 +13,10 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: '3.11' python-version: '3.11'
cache: 'pip' cache: 'pip'
+2 -2
View File
@@ -19,12 +19,12 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v6 uses: actions/setup-python@v7
with: with:
python-version: "3.12" python-version: "3.12"
+12 -21
View File
@@ -4,23 +4,23 @@
![Build-Release](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/build-release.yml?style=flat-square&label=Build-Release) ![Build-Release](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/build-release.yml?style=flat-square&label=Build-Release)
![Unit Tests](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/unit-tests.yml?style=flat-square&label=unit-tests) ![Unit Tests](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/unit-tests.yml?style=flat-square&label=unit-tests)
A cross-platform tool for downloading and keeping a local copy of YouTube playlists in sync as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp) and [ffmpeg](https://ffmpeg.org/). A cross-platform tool for downloading and keeping in sync a local copy of entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp) & [ffmpeg](https://ffmpeg.org/).
It supports audio, video, or both download modes, keeps files numbered to match the playlist order, handles playlist cleanup, and exposes configurable parallel download options. Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options.
Local-first YouTube playlist synchronization client. Local-first YouTube playlist synchronization client.
## What's Included ## What's Included
- GUI playlist manager and sync runner built with PySide6 Essentials - GUI (PySide6 Essentials) playlist manager + sync runner
- Scanner (yt-dlp extract-only), diff engine, filesystem scan - Scanner (yt-dlp extract-only), diff engine, filesystem scan
- Safe reordering via two-pass rename and recycle deletions - Safe reordering via two-pass rename, recycle deletions
- Async download queue with retry support (yt-dlp Python API) - Async download queue with simple retry (yt-dlp Python API)
- SQLite metadata for `last_sync` and download state - SQLite metadata (`last_sync`, download state)
## Requirements ## Requirements
- If you download a `-ffmpeg` release: no extra dependencies. - If you download a `-ffmpeg` release: no extra dependencies
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it is on PATH (needed for `audio` and `both` modes). - If you download a non-ffmpeg release: install `ffmpeg` and ensure it's on PATH (needed for `audio` and `both` modes)
## Download ## Download
@@ -31,7 +31,7 @@ Download the latest release from this repo's Releases page and pick one:
## Configure ## Configure
The application uses a JSON config file that can be edited from the UI or manually. Application uses a json config that canbe edited from UI or manually
```json ```json
{ {
@@ -39,14 +39,6 @@ The application uses a JSON config file that can be edited from the UI or manual
"max_parallel_downloads": 2, "max_parallel_downloads": 2,
"retry_max_retries": 2, "retry_max_retries": 2,
"retry_delay_seconds": 1.5, "retry_delay_seconds": 1.5,
"delay_between_downloads_seconds": 0.0,
"ui": {
"tray": {
"close_to_tray": false,
"minimize_to_tray": false,
"start_minimized_to_tray": false
}
},
"playlists": [ "playlists": [
{ {
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID", "url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
@@ -68,19 +60,18 @@ The application uses a JSON config file that can be edited from the UI or manual
`download_mode`: `download_mode`:
- `video`: download playlist videos as `.mp4` (no ffmpeg required) - `video`: download playlist videos as `.mp4` (no ffmpeg required)
- `audio`: download the video, extract `.mp3`, and delete the video file - `audio`: download video, extract `.mp3`, delete the video file
- `both`: download the video, extract `.mp3`, and keep both files - `both`: download video, extract `.mp3`, keep both files
Queue / retry: Queue / retry:
- `max_parallel_downloads`: number of concurrent download workers. - `max_parallel_downloads`: number of concurrent download workers.
- `retry_max_retries`: how many times a failed download job is retried. - `retry_max_retries`: how many times a failed download job is retried.
- `retry_delay_seconds`: base delay before retry; increases with backoff. - `retry_delay_seconds`: base delay before retry; increases with backoff.
- `delay_between_downloads_seconds`: optional delay between download jobs.
## Run ## Run
- GUI: run `ytpl-sync-entry.py` or the packaged desktop exe from releases. - Run `ytpl-sync.exe` (GUI).
## Tray ## Tray
+2 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "ytpl-sync" name = "ytpl-sync"
version = "2.1.1" version = "2.3.0"
description = "YouTube playlist Sync" description = "YouTube playlist Sync"
readme = "README.md" readme = "README.md"
authors = [ { name = "Dark_Zoul" } ] authors = [ { name = "Dark_Zoul" } ]
@@ -12,7 +12,7 @@ license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "sync"] keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"yt-dlp>=2026.6.9", "yt-dlp>=2026.10.21",
"PySide6_Essentials>=6.11.1", "PySide6_Essentials>=6.11.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+42 -46
View File
@@ -1,13 +1,19 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import asyncio
import logging import logging
import re
from pathlib import Path from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.events.event_bus import EventBus from .core.events.event_bus import EventBus
import re from .core.sync.executor import ActionExecutor
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch from .core.sync.service import SyncService
from .core.utils.deps import DependencyError
from .core.utils.logging_setup import configure_logging from .core.utils.logging_setup import configure_logging
from .core.utils.yt import extract_playlist_id
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
@@ -22,8 +28,11 @@ def main(argv: list[str] | None = None) -> int:
configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log")) configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log"))
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
settings = Settings()
db = Database(args.db.resolve())
service = SyncService(db)
bus = EventBus() bus = EventBus()
settings, db, service, executor = build_sync_stack(args.db, event_bus=bus) executor = ActionExecutor(db, event_bus=bus)
seen_errors: set[str] = set() seen_errors: set[str] = set()
@@ -64,52 +73,39 @@ def main(argv: list[str] | None = None) -> int:
bus.subscribe("RenameApplied", on_rename) bus.subscribe("RenameApplied", on_rename)
bus.subscribe("FileRecycled", on_recycle) bus.subscribe("FileRecycled", on_recycle)
selected_playlists = settings.playlists playlists = settings.playlists
if args.playlist is not None: if args.playlist is not None:
selected_playlists = [selected_playlists[args.playlist]] if 0 <= args.playlist < len(selected_playlists) else [] playlists = [playlists[args.playlist]] if 0 <= args.playlist < len(playlists) else []
def on_plan(pl: dict, playlist_id: str, actions, counts: dict[str, int]) -> None: for pl in playlists:
summary = format_action_summary(counts) url = pl.get("url")
print(f"Playlist {playlist_id}: {len(actions)} actions → {summary}") pid = extract_playlist_id(url) or (url or "")
log.info("playlist=%s actions=%s summary=%s", playlist_id, len(actions), summary) try:
actions = service.sync_from_config(pl)
except ImportError as e:
msg = str(e)
if "yt_dlp" in msg or "yt-dlp" in msg:
print("yt-dlp Python package is required. Install with: pip install -U yt-dlp")
return 2
raise
counts: dict[str, int] = {}
for a in actions:
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))
def on_no_actions(pl: dict, playlist_id: str) -> None: return 0
del pl
print(f"Playlist {playlist_id}: 0 actions →")
log.info("playlist=%s actions=0 summary=", playlist_id)
def on_applied(pl: dict, playlist_id: str) -> None:
del pl
print(f"Applied actions for {playlist_id}.")
log.info("playlist=%s applied_actions=done", playlist_id)
def on_import_error(pl: dict, exc: Exception) -> bool:
del pl
msg = str(exc)
if "yt_dlp" in msg or "yt-dlp" in msg:
print("yt-dlp Python package is required. Install with: pip install -U yt-dlp")
else:
print(f"ERROR: {exc}")
return False
def on_dependency_error(pl: dict, exc: Exception) -> bool:
del pl
print(f"ERROR: {exc}")
log.error("dependency error: %s", exc)
return False
return run_sync_batch(
selected_playlists,
db=db,
service=service,
executor=executor,
apply=bool(args.apply),
on_plan=on_plan,
on_no_actions=on_no_actions,
on_applied=on_applied,
on_import_error=on_import_error,
on_dependency_error=on_dependency_error,
)
if __name__ == "__main__": if __name__ == "__main__":
+19 -75
View File
@@ -1,9 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import os import os
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any
logger = logging.getLogger(__name__)
def _default_ffmpeg_path() -> str: def _default_ffmpeg_path() -> str:
@@ -12,7 +15,7 @@ def _default_ffmpeg_path() -> str:
return "./bin/ffmpeg" return "./bin/ffmpeg"
DEFAULT_CONFIG: Dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"playlists": [], "playlists": [],
"download_mode": "video", "download_mode": "video",
"max_download_quality": "1080p", "max_download_quality": "1080p",
@@ -24,70 +27,12 @@ DEFAULT_CONFIG: Dict[str, Any] = {
} }
def load_config(path: Path) -> Dict[str, Any]:
"""Load configuration from a JSON file."""
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("config root must be a JSON object")
return raw
except Exception:
# Return empty dict if file doesn't exist or is invalid
return {}
def save_config(path: Path, data: Dict[str, Any]) -> None:
"""Save configuration to a JSON file."""
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
def get_tray_config(data: Dict[str, Any]) -> Dict[str, Any]:
"""Return the tray config as a safe dict copy."""
ui = data.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
return dict(tray)
def ensure_tray_config(data: Dict[str, Any]) -> Dict[str, Any]:
"""Ensure the nested ui.tray dict exists and return it for mutation."""
ui = data.get("ui")
if not isinstance(ui, dict):
ui = {}
data["ui"] = ui
tray = ui.get("tray")
if not isinstance(tray, dict):
tray = {}
ui["tray"] = tray
return tray
class Settings: class Settings:
"""Unified configuration loader that combines file I/O and playlist merging.""" def __init__(self) -> None:
base_dir = Path("config")
def __init__(self, config_path: Path | None = None) -> None: base_dir.mkdir(parents=True, exist_ok=True)
if config_path is None: self.path = (base_dir / "yt-playlist-config.json").resolve()
base_dir = Path("config") self.data: dict[str, Any] = dict(DEFAULT_CONFIG)
base_dir.mkdir(parents=True, exist_ok=True)
self.path = (base_dir / "yt-playlist-config.json").resolve()
else:
self.path = config_path.resolve()
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
# Ensure there is always a config file at the default path. # Ensure there is always a config file at the default path.
if not self.path.exists(): if not self.path.exists():
@@ -96,15 +41,15 @@ class Settings:
self._load_from_path(self.path) self._load_from_path(self.path)
def _load_from_path(self, path: Path) -> None: def _load_from_path(self, path: Path) -> None:
"""Load and merge config from file.""" try:
loaded = load_config(path) self.data.update(json.loads(path.read_text(encoding="utf-8")))
if loaded: except (OSError, TypeError, UnicodeError, ValueError) as exc:
self.data.update(normalize_config(loaded)) # Leave defaults if invalid JSON; validation can be added later.
logger.debug("Failed to load config %s: %s", path, exc)
def _write_default_config(self, path: Path) -> None: def _write_default_config(self, path: Path) -> None:
"""Write a default config file."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
default_payload: Dict[str, Any] = { default_payload: dict[str, Any] = {
"playlists": [ "playlists": [
{ {
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID", "url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
@@ -115,11 +60,10 @@ class Settings:
], ],
"ffmpeg_path": _default_ffmpeg_path(), "ffmpeg_path": _default_ffmpeg_path(),
} }
save_config(path, default_payload) path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
@property @property
def playlists(self) -> List[Dict[str, Any]]: def playlists(self) -> list[dict[str, Any]]:
"""Get playlists with global defaults merged in."""
global_defaults = { global_defaults = {
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]), "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"]), "max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
@@ -130,7 +74,7 @@ class Settings:
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]), "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", [])): for pl in list(self.data.get("playlists", [])):
if not isinstance(pl, dict): if not isinstance(pl, dict):
continue continue
+4 -4
View File
@@ -1,10 +1,10 @@
"""Core backend modules (scanner, sync, download, db, scheduler, events).""" """Core backend modules (scanner, sync, download, db, scheduler, events)."""
__all__ = [ __all__ = [
"scanner",
"sync",
"download",
"database", "database",
"scheduler", "download",
"events", "events",
"scanner",
"scheduler",
"sync",
] ]
+1 -2
View File
@@ -1,9 +1,8 @@
from __future__ import annotations from __future__ import annotations
import sqlite3 import sqlite3
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from typing import Iterable
SCHEMA = """ SCHEMA = """
PRAGMA journal_mode=WAL; PRAGMA journal_mode=WAL;
+27 -26
View File
@@ -1,9 +1,11 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional import logging
from .queue_manager import DownloadJob, JobState from .queue_manager import DownloadJob, JobState
logger = logging.getLogger(__name__)
class Downloader: class Downloader:
""" """
@@ -11,7 +13,7 @@ class Downloader:
where actual download logic will land (audio/video/both). where actual download logic will land (audio/video/both).
""" """
def __init__(self, yt_dlp_path: Optional[str] = None, ffmpeg_path: Optional[str] = None) -> None: def __init__(self, yt_dlp_path: str | None = None, ffmpeg_path: str | None = None) -> None:
self.yt_dlp_path = yt_dlp_path self.yt_dlp_path = yt_dlp_path
self.ffmpeg_path = ffmpeg_path self.ffmpeg_path = ffmpeg_path
@@ -34,15 +36,15 @@ class Downloader:
return None return None
try: try:
cap = int(digits) cap = int(digits)
except Exception: except ValueError:
return None return None
return cap if cap > 0 else None return cap if cap > 0 else None
cap = parse_height_cap(max_download_quality) cap = parse_height_cap(max_download_quality)
if cap is not None: if cap is not None:
#if the requested cap isn't available, we still download the best mp4. # Prefer separate MP4 video and audio streams; yt-dlp merges them with ffmpeg.
return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]" 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 "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]" return "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/bestvideo[ext=mp4]/best[ext=mp4]"
async def handle_job(self, job: DownloadJob): async def handle_job(self, job: DownloadJob):
try: try:
@@ -60,10 +62,10 @@ class Downloader:
if not job.keep_video and job.output_path: if not job.keep_video and job.output_path:
try: try:
job.output_path.unlink(missing_ok=True) job.output_path.unlink(missing_ok=True)
except Exception: except OSError as exc:
pass logger.debug("Failed to remove temporary video file: %s", exc)
job.state = JobState.COMPLETED job.state = JobState.COMPLETED
except Exception as exc: # pragma: no cover - environment dependent except Exception as exc: # noqa: BLE001 # pragma: no cover - environment dependent
job.state = JobState.FAILED job.state = JobState.FAILED
job.error = str(exc) job.error = str(exc)
@@ -72,11 +74,12 @@ class Downloader:
import asyncio import asyncio
def run(): def run():
import yt_dlp # type: ignore
from pathlib import Path from pathlib import Path
import yt_dlp # type: ignore
try: try:
from yt_dlp.utils import DownloadCancelled # type: ignore from yt_dlp.utils import DownloadCancelled # type: ignore
except Exception: # pragma: no cover - optional except (ImportError, AttributeError): # pragma: no cover - optional
DownloadCancelled = Exception # type: ignore[misc,assignment] DownloadCancelled = Exception # type: ignore[misc,assignment]
class _QuietLogger: class _QuietLogger:
@@ -94,10 +97,8 @@ class Downloader:
fmt = self.build_format(getattr(job, "max_download_quality", None)) fmt = self.build_format(getattr(job, "max_download_quality", None))
# All modes download a single muxed mp4 when possible. # Prefer MP4 video with a separate audio stream; yt-dlp merges them when needed.
# This avoids any ffmpeg-driven merging during the download step, satisfying: # Audio extraction for audio/both modes happens separately after download.
# - video: "original file, no processing"
# - audio/both: extraction is done separately after download
ydl_opts = { ydl_opts = {
"format": fmt, "format": fmt,
"outtmpl": outtmpl, "outtmpl": outtmpl,
@@ -137,19 +138,19 @@ class Downloader:
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
should_emit = status in {"finished", "error"} should_emit = status in {"finished", "error"}
if not should_emit: if not should_emit and (
if now - last_ts >= 0.25: now - last_ts >= 0.25
should_emit = True or pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01)
elif pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01): ):
should_emit = True should_emit = True
if should_emit: if should_emit:
setattr(job, "_last_progress_emit_ts", now) job._last_progress_emit_ts = now
if pct is not None: if pct is not None:
setattr(job, "_last_progress_emit_pct", pct) job._last_progress_emit_pct = pct
progress_cb(payload) progress_cb(payload)
except Exception: except Exception as exc: # noqa: BLE001 - progress hooks must not abort downloads
pass logger.debug("Progress callback failed: %s", exc)
ydl_opts["progress_hooks"] = [hook] ydl_opts["progress_hooks"] = [hook]
@@ -160,8 +161,8 @@ class Downloader:
p = Path(str(ffmpeg_hint)) p = Path(str(ffmpeg_hint))
if p.exists(): if p.exists():
ydl_opts["ffmpeg_location"] = str(p) ydl_opts["ffmpeg_location"] = str(p)
except Exception: except (OSError, TypeError, ValueError) as exc:
pass logger.debug("Invalid ffmpeg path: %s", exc)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined] with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
ydl.download([job.url]) ydl.download([job.url])
+15 -14
View File
@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Optional from typing import Any
from ..models import PlaylistItem from ..models import PlaylistItem
@@ -21,19 +22,19 @@ class JobState(str, Enum):
@dataclass @dataclass
class DownloadJob: class DownloadJob:
"""Configuration and status for a single download job."""
item: PlaylistItem item: PlaylistItem
output_path: Optional[Path] = None output_path: Path | None = None
url: Optional[str] = None url: str | None = None
mode: str = "audio" # audio|video mode: str = "audio" # audio|video
ffmpeg_path: Optional[str] = None
max_download_quality: Optional[str] = None
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
keep_video: bool = True
# Status fields (mutable during execution)
state: JobState = JobState.QUEUED state: JobState = JobState.QUEUED
error: Optional[str] = None 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
keep_video: bool = True
class QueueManager: class QueueManager:
@@ -45,14 +46,14 @@ class QueueManager:
""" """
def __init__(self, concurrency: int = 2) -> None: 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._concurrency = max(1, concurrency)
self._workers: list[asyncio.Task[None]] = [] self._workers: list[asyncio.Task[None]] = []
self._stopped = asyncio.Event() self._stopped = asyncio.Event()
async def start(self, worker_coro): async def start(self, worker_coro):
"""Start the worker tasks that drain the queue.""" """Start the worker tasks that drain the queue."""
async def runner(): async def runner(idx: int):
while not self._stopped.is_set(): while not self._stopped.is_set():
job = await self._queue.get() job = await self._queue.get()
try: try:
@@ -60,7 +61,7 @@ class QueueManager:
finally: finally:
self._queue.task_done() self._queue.task_done()
self._workers = [asyncio.create_task(runner()) for _ in range(self._concurrency)] self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
async def stop(self): async def stop(self):
"""Cancel all worker tasks and mark the queue as stopped.""" """Cancel all worker tasks and mark the queue as stopped."""
+2 -1
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from ..utils.rate_limit import is_youtube_rate_limit_error
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):
+5 -5
View File
@@ -1,21 +1,21 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from typing import Any, Awaitable, Callable, DefaultDict, Dict, List from collections.abc import Awaitable, Callable
from typing import Any
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
class EventBus: class EventBus:
"""Simple async pub/sub event bus used by backend and (later) GUI.""" """Simple async pub/sub event bus used by backend and (later) GUI."""
def __init__(self) -> None: 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: def subscribe(self, event_name: str, handler: EventHandler) -> None:
self._subs[event_name].append(handler) 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, [])): for h in list(self._subs.get(event_name, [])):
await h(payload) await h(payload)
+10 -6
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Optional
class DownloadMode(str, Enum): class DownloadMode(str, Enum):
@@ -15,7 +14,7 @@ class DownloadMode(str, Enum):
@dataclass(frozen=True) @dataclass(frozen=True)
class Playlist: class Playlist:
id: str id: str
name: Optional[str] name: str | None
url: str url: str
path: Path path: Path
mode: DownloadMode = DownloadMode.audio mode: DownloadMode = DownloadMode.audio
@@ -29,7 +28,7 @@ class PlaylistItem:
video_id: str video_id: str
title: str title: str
playlist_index: int playlist_index: int
local_filename: Optional[str] = None local_filename: str | None = None
downloaded: bool = False downloaded: bool = False
@@ -45,7 +44,12 @@ class SyncActionType(str, Enum):
@dataclass(frozen=True) @dataclass(frozen=True)
class SyncAction: class SyncAction:
type: SyncActionType type: SyncActionType
item: Optional[PlaylistItem] = None item: PlaylistItem | None = None
from_name: Optional[str] = None from_name: str | None = None
to_name: Optional[str] = None to_name: str | None = None
@dataclass(frozen=True)
class FilesystemEntry:
name: str
path: Path
+10 -5
View File
@@ -1,10 +1,12 @@
from __future__ import annotations from __future__ import annotations
import logging
from pathlib import Path from pathlib import Path
from typing import List, Optional
from ..models import PlaylistItem from ..models import PlaylistItem
logger = logging.getLogger(__name__)
class PlaylistScanner: class PlaylistScanner:
""" """
@@ -16,7 +18,10 @@ class PlaylistScanner:
still start in environments where yt-dlp is unavailable. still start in environments where yt-dlp is unavailable.
""" """
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]: def __init__(self) -> None:
pass
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: str | None = None) -> list[PlaylistItem]:
"""Return the current remote playlist entries as `PlaylistItem` records.""" """Return the current remote playlist entries as `PlaylistItem` records."""
try: try:
import yt_dlp # type: ignore import yt_dlp # type: ignore
@@ -36,14 +41,14 @@ class PlaylistScanner:
p = Path(str(ffmpeg_path)) p = Path(str(ffmpeg_path))
if p.exists(): if p.exists():
ydl_opts["ffmpeg_location"] = str(p) ydl_opts["ffmpeg_location"] = str(p)
except Exception: except (OSError, TypeError, ValueError) as exc:
pass logger.debug("Invalid ffmpeg path: %s", exc)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined] with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(playlist_url, download=False) info = ydl.extract_info(playlist_url, download=False)
entries = info.get("entries", []) if isinstance(info, dict) else [] entries = info.get("entries", []) if isinstance(info, dict) else []
items: List[PlaylistItem] = [] items: list[PlaylistItem] = []
for idx, v in enumerate(entries, start=1): for idx, v in enumerate(entries, start=1):
if not v: if not v:
continue continue
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta from datetime import timedelta
from typing import Awaitable, Callable
class Scheduler: class Scheduler:
+62
View File
@@ -0,0 +1,62 @@
from __future__ import annotations
from collections.abc import Iterable, Mapping, Sequence
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
class DiffEngine:
"""
Compares remote playlist items, database state, and filesystem to
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/DELETE
based on simple filename scheme "0001 - Title.ext".
"""
def compute_actions(
self,
remote: Sequence[PlaylistItem],
db_index: Mapping[str, PlaylistItem],
fs_entries: Iterable[FilesystemEntry],
extension: str,
) -> list[SyncAction]:
actions: list[SyncAction] = []
desired_names = {
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
for item in remote
}
fs_by_name = {e.name: e for e in fs_entries}
for item in remote:
desired_name = desired_names[item.video_id]
# If DB knows the current local filename and it already matches and exists -> nothing to do
if item.local_filename == desired_name and desired_name in fs_by_name:
continue
# If DB knows a different current filename and it exists -> plan a rename
if item.local_filename and item.local_filename in fs_by_name and item.local_filename != desired_name:
actions.append(
SyncAction(
SyncActionType.RENAME,
item=item,
from_name=item.local_filename,
to_name=desired_name,
)
)
continue
# If the desired file already exists on disk but DB doesn't reflect it -> skip (already correct)
if desired_name in fs_by_name:
actions.append(SyncAction(SyncActionType.SKIP, item=item, to_name=desired_name))
continue
# Otherwise, we need to download
actions.append(SyncAction(SyncActionType.DOWNLOAD, item=item, to_name=desired_name))
known_ids = {i.video_id for i in remote}
for vid, db_item in db_index.items():
if vid not in known_ids and db_item.local_filename:
actions.append(SyncAction(SyncActionType.DELETE, item=db_item, from_name=db_item.local_filename))
return actions
+26 -21
View File
@@ -1,20 +1,23 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import time import logging
import shutil import shutil
import time
from collections.abc import Iterable
from pathlib import Path from pathlib import Path
from typing import Iterable, List
from ..database.db import Database
from ..download.queue_manager import DownloadJob, QueueManager from ..download.queue_manager import DownloadJob, QueueManager
from ..download.workers import default_worker from ..download.workers import default_worker
from ..events.event_bus import EventBus
from ..models import SyncAction, SyncActionType from ..models import SyncAction, SyncActionType
from ..sync.reorder import safe_multi_rename 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.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
from ..utils.rate_limit import is_youtube_rate_limit_error from ..utils.rate_limit import is_youtube_rate_limit_error
from ..utils.yt import extract_playlist_id
logger = logging.getLogger(__name__)
class ActionExecutor: class ActionExecutor:
@@ -95,7 +98,8 @@ class ActionExecutor:
try: try:
self.db.set_playlist_last_sync(playlist_id) self.db.set_playlist_last_sync(playlist_id)
last_sync = self.db.get_playlist_last_sync(playlist_id) last_sync = self.db.get_playlist_last_sync(playlist_id)
except Exception: except Exception as exc: # noqa: BLE001 - sync timestamp persistence is best effort
logger.debug("Failed to persist playlist sync timestamp: %s", exc)
last_sync = None last_sync = None
summary = { summary = {
"playlist_id": playlist_id, "playlist_id": playlist_id,
@@ -140,7 +144,7 @@ class ActionExecutor:
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", "")
audio_renames = [] audio_renames = []
video_renames = [] video_renames = []
applied: List[SyncAction] = [] applied: list[SyncAction] = []
for a in actions: for a in actions:
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name: if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
continue continue
@@ -160,8 +164,8 @@ class ActionExecutor:
if a.item and a.to_name: if a.item and a.to_name:
try: try:
self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name) self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name)
except Exception: except Exception as exc: # noqa: BLE001 - database update is best effort
pass logger.debug("Failed to update renamed file in database: %s", exc)
if self.bus: if self.bus:
await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name}) await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name})
@@ -187,18 +191,19 @@ class ActionExecutor:
if dst.exists(): if dst.exists():
dst.unlink() dst.unlink()
shutil.move(str(src), str(dst)) shutil.move(str(src), str(dst))
except Exception: except OSError as exc:
logger.debug("Failed to recycle file %s: %s", src, exc)
# fallback to delete if move fails # fallback to delete if move fails
try: try:
src.unlink() src.unlink()
except Exception: except OSError as unlink_exc:
pass logger.debug("Failed to delete unrecycled file %s: %s", src, unlink_exc)
# Update DB to clear file state # Update DB to clear file state
if a.item: if a.item:
try: try:
self.db.clear_file_state(playlist_id, a.item.video_id) self.db.clear_file_state(playlist_id, a.item.video_id)
except Exception: except Exception as exc: # noqa: BLE001 - database update is best effort
pass logger.debug("Failed to clear deleted file in database: %s", exc)
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}))
@@ -219,7 +224,7 @@ class ActionExecutor:
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency) concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
try: try:
concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency
except Exception: except (TypeError, ValueError):
concurrency = self.concurrency concurrency = self.concurrency
queue = QueueManager(concurrency=concurrency) queue = QueueManager(concurrency=concurrency)
@@ -227,17 +232,17 @@ class ActionExecutor:
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5) retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
try: try:
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2 retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
except Exception: except (TypeError, ValueError):
retry_max_retries = 2 retry_max_retries = 2
try: try:
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5 retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
except Exception: except (TypeError, ValueError):
retry_delay_seconds = 1.5 retry_delay_seconds = 1.5
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0) delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
try: try:
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0 delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
except Exception: except (TypeError, ValueError):
delay_between_downloads_seconds = 0.0 delay_between_downloads_seconds = 0.0
rate_limit_pause = asyncio.Event() rate_limit_pause = asyncio.Event()
@@ -284,7 +289,7 @@ class ActionExecutor:
await queue.start(worker) await queue.start(worker)
try: try:
jobs: List[DownloadJob] = [] jobs: list[DownloadJob] = []
# Collapse 'both' into single video download + local audio extraction # Collapse 'both' into single video download + local audio extraction
# Build per-video desired outputs # Build per-video desired outputs
@@ -416,5 +421,5 @@ class ActionExecutor:
) )
else: else:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err}) await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
except Exception: except Exception as exc: # noqa: BLE001 - one failed job must not stop finalization
pass logger.debug("Failed to persist download result: %s", exc)
+6 -5
View File
@@ -1,16 +1,17 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from typing import List, Sequence
from ..models import FilesystemEntry
def list_files(root: Path, extensions: Sequence[str]) -> List[Path]: def list_files(root: Path, extensions: Sequence[str]) -> list[FilesystemEntry]:
"""List all files in root directory with given extensions."""
exts = {e.lower() for e in extensions} exts = {e.lower() for e in extensions}
results: List[Path] = [] results: list[FilesystemEntry] = []
if not root.exists(): if not root.exists():
return results return results
for p in root.glob("**/*"): for p in root.glob("**/*"):
if p.is_file() and p.suffix.lower() in exts: if p.is_file() and p.suffix.lower() in exts:
results.append(p) results.append(FilesystemEntry(name=p.name, path=p))
return results return results
+3 -3
View File
@@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path 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 Apply multiple renames safely using a two-pass strategy to avoid
name collisions. Each item is a tuple (src_path, dst_path). 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} existing_dests = {dst for _, dst in planned}
# Pass 1: move all sources that would collide to temporary names # Pass 1: move all sources that would collide to temporary names
temps: Dict[Path, Path] = {} temps: dict[Path, Path] = {}
for src, dst in planned: for src, dst in planned:
if not src.exists(): if not src.exists():
continue continue
-73
View File
@@ -1,73 +0,0 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, Callable, Sequence
from ..database.db import Database
from ..events.event_bus import EventBus
from ..models import SyncAction
from ..utils.deps import DependencyError
from ..utils.yt import extract_playlist_id
from .executor import ActionExecutor
from .service import SyncService
from ...config.settings import Settings
def build_sync_stack(db_path: Path | None = None, *, event_bus: EventBus | None = None) -> tuple[Settings, Database, SyncService, ActionExecutor]:
settings = Settings()
db = Database((db_path or Path("db/app.db")).resolve())
service = SyncService(db)
executor = ActionExecutor(db, event_bus=event_bus)
return settings, db, service, executor
def format_action_summary(counts: dict[str, int]) -> str:
return ", ".join(f"{name}:{count}" for name, count in sorted(counts.items()))
def run_sync_batch(
playlists: Sequence[dict[str, Any]],
*,
db: Database,
service: SyncService,
executor: ActionExecutor,
apply: bool,
on_plan: Callable[[dict[str, Any], str, list[SyncAction], dict[str, int]], None] | None = None,
on_no_actions: Callable[[dict[str, Any], str], None] | None = None,
on_applied: Callable[[dict[str, Any], str], None] | None = None,
on_import_error: Callable[[dict[str, Any], Exception], bool] | None = None,
on_dependency_error: Callable[[dict[str, Any], Exception], bool] | None = None,
) -> int:
for playlist_cfg in playlists:
playlist_url = str(playlist_cfg.get("url") or "")
playlist_id = extract_playlist_id(playlist_url) or playlist_url
try:
actions = service.sync_from_config(playlist_cfg)
except ImportError as exc:
if on_import_error is not None and on_import_error(playlist_cfg, exc):
continue
return 2
counts: dict[str, int] = {}
for action in actions:
counts[action.type.name] = counts.get(action.type.name, 0) + 1
if on_plan is not None:
on_plan(playlist_cfg, playlist_id, actions, counts)
if apply and actions:
try:
asyncio.run(executor.execute(actions, playlist_cfg))
except DependencyError as exc:
if on_dependency_error is not None and on_dependency_error(playlist_cfg, exc):
continue
return 2
db.set_playlist_last_sync(playlist_id)
if on_applied is not None:
on_applied(playlist_cfg, playlist_id)
elif on_no_actions is not None:
on_no_actions(playlist_cfg, playlist_id)
return 0
+10 -62
View File
@@ -1,11 +1,11 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Iterable, List, Mapping, Sequence
from ..database.db import Database from ..database.db import Database
from ..models import PlaylistItem, SyncAction, SyncActionType from ..models import PlaylistItem, SyncAction
from ..scanner.playlist_scanner import PlaylistScanner from ..scanner.playlist_scanner import PlaylistScanner
from ..sync.diff_engine import DiffEngine
from ..sync.filesystem import list_files from ..sync.filesystem import list_files
from ..utils.naming import sanitize_title from ..utils.naming import sanitize_title
from ..utils.yt import extract_playlist_id from ..utils.yt import extract_playlist_id
@@ -15,13 +15,14 @@ class SyncService:
"""High-level orchestration for a single playlist sync pass. """High-level orchestration for a single playlist sync pass.
The service pulls the latest remote playlist snapshot, persists the The service pulls the latest remote playlist snapshot, persists the
playlist and item metadata in the database, and compares the remote state playlist and item metadata in the database, and asks the diff engine to
with the local filesystem to produce sync actions. compare the remote state with the local filesystem.
""" """
def __init__(self, db: Database) -> None: def __init__(self, db: Database) -> None:
self.db = db self.db = db
self.scanner = PlaylistScanner() self.scanner = PlaylistScanner()
self.diff = DiffEngine()
def _mode_to_extensions(self, mode: str) -> list[str]: def _mode_to_extensions(self, mode: str) -> list[str]:
if mode == "audio": if mode == "audio":
@@ -32,60 +33,7 @@ class SyncService:
return [".mp3", ".mp4"] return [".mp3", ".mp4"]
return [".mp4"] return [".mp4"]
def _compute_actions( def sync_from_config(self, playlist_cfg: dict) -> list[SyncAction]:
self,
remote: Sequence[PlaylistItem],
db_index: Mapping[str, PlaylistItem],
fs_entries: Iterable[Path],
extension: str,
) -> List[SyncAction]:
"""Compare remote items, database state, and filesystem to produce actions.
Computes DOWNLOAD/RENAME/DELETE based on the filename scheme "0001 - Title.ext".
"""
actions: List[SyncAction] = []
desired_names = {
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
for item in remote
}
fs_by_name = {p.name: p for p in fs_entries}
for item in remote:
desired_name = desired_names[item.video_id]
# If DB knows the current local filename and it already matches and exists -> nothing to do
if item.local_filename == desired_name and desired_name in fs_by_name:
continue
# If DB knows a different current filename and it exists -> plan a rename
if item.local_filename and item.local_filename in fs_by_name and item.local_filename != desired_name:
actions.append(
SyncAction(
SyncActionType.RENAME,
item=item,
from_name=item.local_filename,
to_name=desired_name,
)
)
continue
# If the desired file already exists on disk but DB doesn't reflect it -> skip (already correct)
if desired_name in fs_by_name:
actions.append(SyncAction(SyncActionType.SKIP, item=item, to_name=desired_name))
continue
# Otherwise, we need to download
actions.append(SyncAction(SyncActionType.DOWNLOAD, item=item, to_name=desired_name))
known_ids = {i.video_id for i in remote}
for vid, db_item in db_index.items():
if vid not in known_ids and db_item.local_filename:
actions.append(SyncAction(SyncActionType.DELETE, item=db_item, from_name=db_item.local_filename))
return actions
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
"""Return the sync actions required to bring one playlist in sync. """Return the sync actions required to bring one playlist in sync.
This method does not apply any changes itself. It normalizes the This method does not apply any changes itself. It normalizes the
@@ -111,7 +59,7 @@ class SyncService:
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path") 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) 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: for it in items:
safe_title = sanitize_title(it.title, it.video_id) safe_title = sanitize_title(it.title, it.video_id)
sanitized.append( sanitized.append(
@@ -151,7 +99,7 @@ class SyncService:
) )
# Augment remote items with DB-known filenames/download flags # Augment remote items with DB-known filenames/download flags
augmented: List[PlaylistItem] = [] augmented: list[PlaylistItem] = []
for it in sanitized: for it in sanitized:
known = db_index.get(it.video_id) known = db_index.get(it.video_id)
if known is None: if known is None:
@@ -169,7 +117,7 @@ class SyncService:
) )
exts = self._mode_to_extensions(mode) exts = self._mode_to_extensions(mode)
merged_actions: List[SyncAction] = [] merged_actions: list[SyncAction] = []
# Compute per-extension actions against respective roots # Compute per-extension actions against respective roots
for ext in exts: for ext in exts:
@@ -179,7 +127,7 @@ class SyncService:
fs = list_files(save_path / "video", [".mp4"]) fs = list_files(save_path / "video", [".mp4"])
else: else:
fs = list_files(save_path, [ext]) fs = list_files(save_path, [ext])
actions = self._compute_actions(augmented, db_index, fs, ext) actions = self.diff.compute_actions(augmented, db_index, fs, ext)
merged_actions.extend(actions) merged_actions.extend(actions)
return merged_actions return merged_actions
+8 -9
View File
@@ -6,7 +6,6 @@ import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from shutil import which from shutil import which
from typing import Optional, Tuple
class DependencyError(RuntimeError): class DependencyError(RuntimeError):
@@ -16,20 +15,20 @@ class DependencyError(RuntimeError):
@dataclass(frozen=True) @dataclass(frozen=True)
class DependencyStatus: class DependencyStatus:
yt_dlp_ok: bool yt_dlp_ok: bool
yt_dlp_version: Optional[str] yt_dlp_version: str | None
ffmpeg_ok: bool ffmpeg_ok: bool
ffmpeg_path: Optional[str] ffmpeg_path: str | None
ffprobe_ok: bool ffprobe_ok: bool
ffprobe_path: Optional[str] ffprobe_path: str | None
def get_yt_dlp_version() -> Optional[str]: def get_yt_dlp_version() -> str | None:
try: try:
import yt_dlp # type: ignore import yt_dlp # type: ignore
# yt-dlp exposes version in different places depending on install # 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) return getattr(yt_dlp, "__version__", None) or getattr(getattr(yt_dlp, "version", None), "__version__", None)
except Exception: except (ImportError, AttributeError, TypeError):
return None return None
@@ -44,7 +43,7 @@ def ensure_yt_dlp_available() -> str:
return get_yt_dlp_version() or "unknown" return get_yt_dlp_version() or "unknown"
def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Optional[str], Optional[str]]: def _resolve_tool_paths(tool_hint: str | None, exe_name: str) -> tuple[str | None, str | None]:
""" """
Returns (resolved_exe_path, resolved_dir_hint). Returns (resolved_exe_path, resolved_dir_hint).
- If tool_hint is a file path, use it. - If tool_hint is a file path, use it.
@@ -70,7 +69,7 @@ def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Option
return None, None return None, None
def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]: def ensure_ffmpeg_available(ffmpeg_hint: str | None) -> tuple[str, str]:
""" """
Ensures ffmpeg is runnable. Returns (ffmpeg_path, ffmpeg_path). Ensures ffmpeg is runnable. Returns (ffmpeg_path, ffmpeg_path).
@@ -79,7 +78,7 @@ def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]:
""" """
ffmpeg_exe = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg" ffmpeg_exe = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"
ffmpeg_path, ffmpeg_dir = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe) ffmpeg_path, _ = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
if not ffmpeg_path: if not ffmpeg_path:
raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.") raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.")
+4 -2
View File
@@ -17,9 +17,11 @@ def _read_text(path: Path) -> str | None:
if path.exists(): if path.exists():
text = path.read_text(encoding="utf-8").strip() text = path.read_text(encoding="utf-8").strip()
return text or None return text or None
except Exception: except (OSError, UnicodeError):
pass return None
return None return None
def get_app_version() -> str: def get_app_version() -> str:
""" """
Returns the packaged app version. 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"): if "list" in qs and qs.get("list"):
return qs.get("list", [None])[0] return qs.get("list", [None])[0]
return None return None
except Exception: except (TypeError, ValueError):
return None return None
+7 -4
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import logging
import sys import sys
from pathlib import Path from pathlib import Path
from PySide6 import QtGui, QtWidgets from PySide6 import QtGui, QtWidgets
logger = logging.getLogger(__name__)
def _resource_base() -> Path: def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir. # PyInstaller sets sys._MEIPASS to the temp extraction dir.
@@ -31,16 +34,16 @@ def load_app_icon() -> QtGui.QIcon:
icon = QtGui.QIcon(str(p)) icon = QtGui.QIcon(str(p))
if not icon.isNull(): if not icon.isNull():
return icon return icon
except Exception: except Exception as exc: # noqa: BLE001 - icon loading is best effort
pass logger.debug("Failed to load app icon from %s: %s", p, exc)
# Fallback to a platform theme icon (Linux) or a generic icon. # Fallback to a platform theme icon (Linux) or a generic icon.
try: try:
themed = QtGui.QIcon.fromTheme("applications-multimedia") themed = QtGui.QIcon.fromTheme("applications-multimedia")
if not themed.isNull(): if not themed.isNull():
return themed return themed
except Exception: except Exception as exc: # noqa: BLE001 - themed icon loading is best effort
pass logger.debug("Failed to load themed app icon: %s", exc)
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon) return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
-38
View File
@@ -1,38 +0,0 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Callable, Iterator
from PySide6 import QtCore
class DebouncedAutosave(QtCore.QObject):
"""Small helper for debounced autosave flows in Qt widgets."""
def __init__(self, parent: QtCore.QObject, callback: Callable[[], None], interval_ms: int = 600) -> None:
super().__init__(parent)
self._suppressed = False
self._timer = QtCore.QTimer(self)
self._timer.setSingleShot(True)
self._timer.setInterval(interval_ms)
self._timer.timeout.connect(callback)
@contextmanager
def suppressed(self) -> Iterator[None]:
previous = self._suppressed
self._suppressed = True
try:
yield
finally:
self._suppressed = previous
def set_suppressed(self, suppressed: bool) -> None:
self._suppressed = bool(suppressed)
def schedule(self, *, enabled: bool = True) -> None:
if self._suppressed or not enabled:
return
self._timer.start()
def stop(self) -> None:
self._timer.stop()
+4 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict
from typing import Any
from PySide6 import QtCore from PySide6 import QtCore
from ..core.events.event_bus import EventBus from ..core.events.event_bus import EventBus
@@ -32,7 +34,7 @@ class BusBridge(QtCore.QObject):
self._bus.subscribe(name, self._make_handler(name)) self._bus.subscribe(name, self._make_handler(name))
def _make_handler(self, name: str): 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. # Ensure delivery on the Qt main thread.
self.event.emit(name, dict(payload)) self.event.emit(name, dict(payload))
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
@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 TypeError("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
+76 -42
View File
@@ -1,20 +1,24 @@
from __future__ import annotations from __future__ import annotations
import logging
import sys import sys
import threading import threading
from PySide6 import QtCore, QtGui, QtWidgets from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings, get_tray_config, load_config from ..config.settings import Settings
from ..core.events.event_bus import EventBus from ..core.events.event_bus import EventBus
from .bus_bridge import BusBridge
from .app_icon import load_app_icon from .app_icon import load_app_icon
from .runner import SyncRequest, SyncRunner from .bus_bridge import BusBridge
from .config_store import load_config
from .pages.about import AboutPage
from .pages.logs import LogsPage
from .pages.playlists import PlaylistManagerPage from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage from .pages.queue import QueuePage
from .pages.logs import LogsPage
from .pages.settings import SettingsPage from .pages.settings import SettingsPage
from .pages.about import AboutPage from .runner import SyncRequest, SyncRunner
logger = logging.getLogger(__name__)
class MainWindow(QtWidgets.QMainWindow): class MainWindow(QtWidgets.QMainWindow):
@@ -89,8 +93,8 @@ class MainWindow(QtWidgets.QMainWindow):
if cfg_path is not None: if cfg_path is not None:
try: try:
self._settings_page.set_config_path(cfg_path) self._settings_page.set_config_path(cfg_path)
except Exception: except Exception as exc: # noqa: BLE001 - optional settings-page wiring
pass logger.debug("Failed to set settings config path: %s", exc)
self._playlists_page.cancel_requested.connect(self._cancel_sync) self._playlists_page.cancel_requested.connect(self._cancel_sync)
self._queue_page.cancel_sync_requested.connect(self._cancel_sync) self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
@@ -131,9 +135,14 @@ class MainWindow(QtWidgets.QMainWindow):
cfg_path = getattr(self._settings, "path", None) cfg_path = getattr(self._settings, "path", None)
if cfg_path is None: if cfg_path is None:
return {} return {}
raw = load_config(cfg_path) raw = load_config(cfg_path).data
return get_tray_config(raw) ui = raw.get("ui")
except Exception: ui = ui if isinstance(ui, dict) else {}
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)
return {} return {}
def _close_to_tray_enabled(self) -> bool: def _close_to_tray_enabled(self) -> bool:
@@ -153,7 +162,8 @@ class MainWindow(QtWidgets.QMainWindow):
try: try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable(): if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return return
except Exception: except Exception as exc: # noqa: BLE001 - tray support is optional
logger.debug("Failed to check system tray availability: %s", exc)
return return
icon = load_app_icon() icon = load_app_icon()
@@ -206,27 +216,30 @@ class MainWindow(QtWidgets.QMainWindow):
QtWidgets.QSystemTrayIcon.MessageIcon.Information, QtWidgets.QSystemTrayIcon.MessageIcon.Information,
3000, 3000,
) )
except Exception: except Exception as exc: # noqa: BLE001 - tray notification is best effort
pass logger.debug("Failed to show tray notification: %s", exc)
return return
if self._tray is not None and not self._close_to_tray_enabled(): 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. # Explicitly quit, because the app may be configured to keep running without windows.
try: try:
event.accept() event.accept()
except Exception: except Exception as exc: # noqa: BLE001 - close event acceptance is best effort
pass logger.debug("Failed to accept close event: %s", exc)
QtWidgets.QApplication.quit() QtWidgets.QApplication.quit()
return return
super().closeEvent(event) super().closeEvent(event)
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override] def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
try: try:
if event.type() == QtCore.QEvent.Type.WindowStateChange: if (
if self._tray is not None and self._minimize_to_tray_enabled(): event.type() == QtCore.QEvent.Type.WindowStateChange
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized): and self._tray is not None
QtCore.QTimer.singleShot(0, self.hide) and self._minimize_to_tray_enabled()
except Exception: and bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized)
pass ):
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)
super().changeEvent(event) super().changeEvent(event)
def _refresh_queue_labels(self) -> None: def _refresh_queue_labels(self) -> None:
@@ -239,23 +252,24 @@ class MainWindow(QtWidgets.QMainWindow):
pid = extract_playlist_id(url) or url pid = extract_playlist_id(url) or url
labels[pid] = str(pl.get("name") or f"Playlist {idx}") labels[pid] = str(pl.get("name") or f"Playlist {idx}")
self._queue_page.set_playlist_labels(labels) self._queue_page.set_playlist_labels(labels)
except Exception: except Exception as exc: # noqa: BLE001 - queue labels are best effort
pass logger.debug("Failed to refresh queue labels: %s", exc)
def _emit_page_event(self, name: str, payload: dict) -> None:
for page in self._pages:
handler = getattr(page, "on_event", None)
if not callable(handler):
continue
try:
handler(name, payload)
except Exception:
pass
@QtCore.Slot(str, dict) @QtCore.Slot(str, dict)
def _on_bus_event(self, name: str, payload: dict) -> None: def _on_bus_event(self, name: str, payload: dict) -> None:
# Fan out to any page that exposes on_event(). # Fan out to interested pages.
self._emit_page_event(name, payload) 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)
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)
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)
# Auto-pause on YouTube bot-check/rate-limit surface. # Auto-pause on YouTube bot-check/rate-limit surface.
if name == "SyncPaused": if name == "SyncPaused":
@@ -301,7 +315,7 @@ class MainWindow(QtWidgets.QMainWindow):
@QtCore.Slot(bool, str) @QtCore.Slot(bool, str)
def _on_sync_finished(self, ok: bool, message: str) -> None: def _on_sync_finished(self, ok: bool, message: str) -> None:
if not ok: if not ok:
self._emit_page_event("SyncError", {"error": message}) self._logs_page.on_event("SyncError", {"error": message})
self._playlists_page.set_running(False) self._playlists_page.set_running(False)
# Mark idle so "Sync all" can be started again. # Mark idle so "Sync all" can be started again.
@@ -309,8 +323,8 @@ class MainWindow(QtWidgets.QMainWindow):
try: try:
self._thread.quit() self._thread.quit()
self._thread.wait(2000) self._thread.wait(2000)
except Exception: except Exception as exc: # noqa: BLE001 - thread shutdown is best effort
pass logger.debug("Failed to stop sync thread: %s", exc)
self._runner = None self._runner = None
self._cancel_flag = None self._cancel_flag = None
self._pause_flag = None self._pause_flag = None
@@ -324,12 +338,12 @@ class MainWindow(QtWidgets.QMainWindow):
if cfg_path is not None: if cfg_path is not None:
self._settings_page.set_config_path(cfg_path) self._settings_page.set_config_path(cfg_path)
self._refresh_queue_labels() self._refresh_queue_labels()
except Exception: except Exception as exc: # noqa: BLE001 - config refresh is best effort
pass logger.debug("Failed to refresh configuration after sync: %s", exc)
# Continue "sync all" chain if active. # Continue "sync all" chain if active.
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"): if hasattr(self, "_sync_queue") and self._sync_queue:
nxt = getattr(self, "_sync_queue").pop(0) nxt = self._sync_queue.pop(0)
self._sync_playlist_index(nxt) self._sync_playlist_index(nxt)
@QtCore.Slot() @QtCore.Slot()
@@ -394,6 +408,26 @@ class MainWindow(QtWidgets.QMainWindow):
border-radius: 10px; border-radius: 10px;
color: #d7dce4; 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 { QScrollBar:vertical {
background: #0f1218; background: #0f1218;
width: 12px; width: 12px;
+59 -12
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json import json
from PySide6 import QtGui, QtWidgets from PySide6 import QtCore, QtWidgets
from ..smooth_scroll import enable_smooth_scrolling from ..smooth_scroll import enable_smooth_scrolling
@@ -23,22 +23,69 @@ class LogsPage(QtWidgets.QWidget):
top.addWidget(clear_btn) top.addWidget(clear_btn)
layout.addLayout(top) layout.addLayout(top)
self._text = QtWidgets.QPlainTextEdit() self._scroll = QtWidgets.QScrollArea()
self._text.setReadOnly(True) self._scroll.setObjectName("logsScroll")
enable_smooth_scrolling(self._text) self._scroll.setWidgetResizable(True)
layout.addWidget(self._text, 1) 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)
def _clear(self) -> None: def _clear(self) -> None:
self._text.clear() while self._entries_layout.count() > 1:
item = self._entries_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def on_event(self, name: str, payload: dict) -> None: def on_event(self, name: str, payload: dict) -> None:
# Avoid flooding the UI with high-frequency progress updates. # Avoid flooding the UI with high-frequency progress updates.
if name == "DownloadProgress": if name == "DownloadProgress":
return return
# Keep this lightweight: append a single-line JSON entry. # Keep the event name in the header and show only its payload here.
try: try:
line = json.dumps({"event": name, **payload}, ensure_ascii=False) line = json.dumps(payload, ensure_ascii=False)
except Exception: except Exception: # noqa: BLE001 - fall back to a readable representation
line = f"{name}: {payload}" line = str(payload)
self._text.appendPlainText(line) entry = QtWidgets.QFrame()
self._text.moveCursor(QtGui.QTextCursor.MoveOperation.End) 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)
+86 -66
View File
@@ -1,15 +1,15 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from pathlib import Path from pathlib import Path
from typing import Any
from PySide6 import QtCore, QtGui, QtWidgets from PySide6 import QtCore, QtGui, QtWidgets
from ...config.settings import Settings, load_config, normalize_config, save_config from ...config.settings import Settings
from ...core.database.db import Database from ...core.database.db import Database
from ...core.utils.yt import extract_playlist_id from ...core.utils.yt import extract_playlist_id
from ..autosave import DebouncedAutosave from ..config_store import load_config, normalize_config, save_config
from ..smooth_scroll import enable_smooth_scrolling from ..smooth_scroll import enable_smooth_scrolling
@@ -41,7 +41,11 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._config_path = getattr(settings, "path", None) self._config_path = getattr(settings, "path", None)
self._config: dict[str, Any] = {} self._config: dict[str, Any] = {}
self._download_state_by_pid: dict[str, dict[str, Any]] = {} self._download_state_by_pid: dict[str, dict[str, Any]] = {}
self._autosave = DebouncedAutosave(self, self._autosave_now) 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 = QtWidgets.QLabel("Playlists")
header.setObjectName("pageTitle") header.setObjectName("pageTitle")
@@ -126,16 +130,18 @@ class PlaylistManagerPage(QtWidgets.QWidget):
@QtCore.Slot() @QtCore.Slot()
def reload_from_config(self) -> None: def reload_from_config(self) -> None:
try: try:
with self._autosave.suppressed(): self._suppress_autosave = True
self._settings = Settings() self._settings = Settings()
self._config_path = getattr(self._settings, "path", None) self._config_path = getattr(self._settings, "path", None)
if self._config_path is None: if self._config_path is None:
raise RuntimeError("Config path not available") raise RuntimeError("Config path not available")
self._config = normalize_config(load_config(self._config_path)) self._config = normalize_config(load_config(self._config_path).data)
rows = self._rows_from_settings() rows = self._rows_from_settings()
except Exception as exc: except Exception as exc: # noqa: BLE001 - show config load failures in the UI
self._status.setText(f"Failed to load config: {exc}") self._status.setText(f"Failed to load config: {exc}")
return return
finally:
self._suppress_autosave = False
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable. # Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
last_sync_by_id: dict[str, str] = {} last_sync_by_id: dict[str, str] = {}
@@ -146,7 +152,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
ls = db.get_playlist_last_sync(pid) ls = db.get_playlist_last_sync(pid)
if ls: if ls:
last_sync_by_id[pid] = str(ls) last_sync_by_id[pid] = str(ls)
except Exception: except Exception: # noqa: BLE001 - database metadata is optional
last_sync_by_id = {} last_sync_by_id = {}
self._list.clear() self._list.clear()
@@ -172,19 +178,6 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._status.setText("Cancelling…") self._status.setText("Cancelling…")
self.cancel_requested.emit() self.cancel_requested.emit()
def _iter_cards(self):
for i in range(self._list.count()):
item = self._list.item(i)
widget = self._list.itemWidget(item)
if isinstance(widget, _PlaylistCard):
yield widget
def _card_for_playlist_id(self, playlist_id: str) -> _PlaylistCard | None:
for card in self._iter_cards():
if card.playlist_id() == playlist_id:
return card
return None
def set_running(self, running: bool) -> None: def set_running(self, running: bool) -> None:
self._sync_all_btn.setEnabled(not running) self._sync_all_btn.setEnabled(not running)
self._cancel_btn.setEnabled(running) self._cancel_btn.setEnabled(running)
@@ -194,8 +187,11 @@ class PlaylistManagerPage(QtWidgets.QWidget):
# Keep the list enabled so per-card Pause/Cancel remains clickable. # Keep the list enabled so per-card Pause/Cancel remains clickable.
self._list.setEnabled(True) self._list.setEnabled(True)
# But freeze editing while a sync is running to avoid racey config edits. # But freeze editing while a sync is running to avoid racey config edits.
for card in self._iter_cards(): for i in range(self._list.count()):
card.set_editing_enabled(not running) item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
w.set_editing_enabled(not running)
@QtCore.Slot() @QtCore.Slot()
def _add_playlist(self) -> None: def _add_playlist(self) -> None:
@@ -237,8 +233,13 @@ class PlaylistManagerPage(QtWidgets.QWidget):
def _table_to_playlists(self) -> list[dict[str, Any]]: def _table_to_playlists(self) -> list[dict[str, Any]]:
playlists: list[dict[str, Any]] = [] playlists: list[dict[str, Any]] = []
for card in self._iter_cards(): for i in range(self._list.count()):
playlists.append(card.to_dict()) 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 return playlists
@QtCore.Slot() @QtCore.Slot()
@@ -255,32 +256,44 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.") self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.")
# Reload settings to reflect merged defaults # Reload settings to reflect merged defaults
self.reload_from_config() self.reload_from_config()
except Exception as exc: except Exception as exc: # noqa: BLE001 - show config save failures in the UI
self._status.setText(f"Failed to save config: {exc}") self._status.setText(f"Failed to save config: {exc}")
def _reindex_cards(self) -> None: def _reindex_cards(self) -> None:
for i, card in enumerate(self._iter_cards()): for i in range(self._list.count()):
card.set_index(i) 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: def _validate_all(self, *, show_status: bool) -> bool:
ok = True ok = True
for card in self._iter_cards(): for i in range(self._list.count()):
errs = card.validate() item = self._list.item(i)
card.set_status("; ".join(errs) if errs else "") w = self._list.itemWidget(item)
if errs: if isinstance(w, _PlaylistCard):
ok = False errs = w.validate()
w.set_status("; ".join(errs) if errs else "")
if errs:
ok = False
if not ok and show_status: if not ok and show_status:
self._status.setText("Fix invalid playlists before saving/syncing.") self._status.setText("Fix invalid playlists before saving/syncing.")
return ok return ok
@QtCore.Slot() @QtCore.Slot()
def _schedule_autosave(self) -> None: def _schedule_autosave(self) -> None:
self._autosave.schedule(enabled=self.isEnabled()) if self._suppress_autosave:
return
if not self.isEnabled():
return
self._autosave_timer.start()
@QtCore.Slot() @QtCore.Slot()
def _autosave_now(self) -> None: def _autosave_now(self) -> None:
if self._config_path is None: if self._config_path is None:
return return
if self._suppress_autosave:
return
if not self._validate_all(show_status=False): if not self._validate_all(show_status=False):
# Don't autosave invalid configs; user sees inline errors. # Don't autosave invalid configs; user sees inline errors.
return return
@@ -289,7 +302,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
data["playlists"] = self._table_to_playlists() data["playlists"] = self._table_to_playlists()
save_config(self._config_path, data) save_config(self._config_path, data)
self._status.setText(f"Autosaved to {self._config_path}.") self._status.setText(f"Autosaved to {self._config_path}.")
except Exception as exc: except Exception as exc: # noqa: BLE001 - show autosave failures in the UI
self._status.setText(f"Autosave failed: {exc}") self._status.setText(f"Autosave failed: {exc}")
def on_event(self, name: str, payload: dict) -> None: def on_event(self, name: str, payload: dict) -> None:
@@ -297,25 +310,22 @@ class PlaylistManagerPage(QtWidgets.QWidget):
pid = payload.get("playlist_id") pid = payload.get("playlist_id")
total = payload.get("actions_total") total = payload.get("actions_total")
self._sync_state.setText(f"Sync started: {pid} ({total} actions)") self._sync_state.setText(f"Sync started: {pid} ({total} actions)")
playlist_id = str(pid or "") self._set_card_status(str(pid or ""), "running")
self._set_card_status(playlist_id, "running") self._set_active_card(str(pid or ""), running=True, paused=False)
self._set_active_card(playlist_id, running=True, paused=False)
elif name == "SyncSummary": elif name == "SyncSummary":
pid = payload.get("playlist_id") pid = payload.get("playlist_id")
dur = payload.get("duration_s") dur = payload.get("duration_s")
counts = payload.get("counts") counts = payload.get("counts")
self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}") self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}")
playlist_id = str(pid or "") self._set_card_status(str(pid or ""), f"done in {dur}s")
self._set_card_status(playlist_id, f"done in {dur}s")
ls = payload.get("last_sync") ls = payload.get("last_sync")
if ls: if ls:
self._set_card_last_sync(playlist_id, str(ls)) self._set_card_last_sync(str(pid or ""), str(ls))
elif name == "SyncFinished": elif name == "SyncFinished":
pid = payload.get("playlist_id") pid = payload.get("playlist_id")
self._sync_state.setText(f"Sync finished: {pid}") self._sync_state.setText(f"Sync finished: {pid}")
playlist_id = str(pid or "") self._set_card_status(str(pid or ""), "finished")
self._set_card_status(playlist_id, "finished") self._set_active_card(str(pid or ""), running=False, paused=False)
self._set_active_card(playlist_id, running=False, paused=False)
self.set_running(False) self.set_running(False)
elif name == "SyncError": elif name == "SyncError":
self._sync_state.setText(f"Sync error: {payload.get('error')}") self._sync_state.setText(f"Sync error: {payload.get('error')}")
@@ -345,7 +355,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._download_state_by_pid[pid]["video_id"] = vid self._download_state_by_pid[pid]["video_id"] = vid
self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading") self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading")
self._set_card_progress(pid, p) self._set_card_progress(pid, p)
pct = int(round(max(0.0, min(1.0, p)) * 100)) pct = round(max(0.0, min(1.0, p)) * 100)
st = str(payload.get("status") or "downloading") st = str(payload.get("status") or "downloading")
tail = f"{vid} {pct}%" if vid else f"{pct}%" tail = f"{vid} {pct}%" if vid else f"{pct}%"
self._set_card_status(pid, f"{st} {tail}".strip()) self._set_card_status(pid, f"{st} {tail}".strip())
@@ -372,26 +382,36 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._set_active_card(pid, running=True, paused=True) self._set_active_card(pid, running=True, paused=True)
def _set_card_progress(self, playlist_id: str, progress: float) -> None: def _set_card_progress(self, playlist_id: str, progress: float) -> None:
card = self._card_for_playlist_id(playlist_id) for i in range(self._list.count()):
if card is not None: item = self._list.item(i)
card.set_progress(progress) 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: def _set_card_status(self, playlist_id: str, text: str) -> None:
card = self._card_for_playlist_id(playlist_id) for i in range(self._list.count()):
if card is not None: item = self._list.item(i)
card.set_status(text) w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
w.set_status(text)
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None: def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
card = self._card_for_playlist_id(playlist_id) for i in range(self._list.count()):
if card is not None: item = self._list.item(i)
card.set_last_sync(last_sync) 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: def _set_active_card(self, playlist_id: str, *, running: bool, paused: bool) -> None:
card = self._card_for_playlist_id(playlist_id) for i in range(self._list.count()):
if card is None: item = self._list.item(i)
return w = self._list.itemWidget(item)
card.set_active(running) if not isinstance(w, _PlaylistCard):
card.set_paused(paused) 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): class _PlaylistCard(QtWidgets.QFrame):
@@ -583,7 +603,7 @@ class _PlaylistCard(QtWidgets.QFrame):
return extract_playlist_id(url) or url return extract_playlist_id(url) or url
def set_progress(self, progress: float) -> None: def set_progress(self, progress: float) -> None:
pct = max(0, min(100, int(round(progress * 100)))) pct = max(0, min(100, round(progress * 100)))
self._progress.setValue(pct) self._progress.setValue(pct)
def set_last_sync(self, last_sync: str) -> None: def set_last_sync(self, last_sync: str) -> None:
@@ -592,7 +612,7 @@ class _PlaylistCard(QtWidgets.QFrame):
def validate(self) -> list[str]: def validate(self) -> list[str]:
errs: list[str] = [] errs: list[str] = []
url = self._url.text().strip() url = self._url.text().strip()
if not url or not (url.startswith("http://") or url.startswith("https://")): if not url or not url.startswith(("http://", "https://")):
errs.append("URL required") errs.append("URL required")
mode = self._mode.currentText().strip() mode = self._mode.currentText().strip()
if mode not in {"video", "audio", "both"}: if mode not in {"video", "audio", "both"}:
+19 -29
View File
@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
from ..smooth_scroll import enable_smooth_scrolling from ..smooth_scroll import enable_smooth_scrolling
@@ -32,9 +30,12 @@ class QueuePage(QtWidgets.QWidget):
top.addStretch(1) top.addStretch(1)
clear_btn = QtWidgets.QPushButton("Clear completed") clear_btn = QtWidgets.QPushButton("Clear completed")
clear_btn.clicked.connect(self._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 = QtWidgets.QPushButton("Cancel all")
cancel_btn.clicked.connect(self.cancel_sync_requested.emit) cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
top.addWidget(clear_btn) top.addWidget(clear_btn)
top.addWidget(clear_all_btn)
top.addWidget(cancel_btn) top.addWidget(cancel_btn)
layout.addLayout(top) layout.addLayout(top)
@@ -111,22 +112,6 @@ class QueuePage(QtWidgets.QWidget):
self._table.setItem(row, col, item) self._table.setItem(row, col, item)
return item return item
def _target_text(self, payload: dict, current: str = "") -> str:
value = (
payload.get("target")
or payload.get("filename")
or payload.get("output_path")
or payload.get("path")
or payload.get("to")
or current
)
if not value:
return current
try:
return Path(str(value)).name or str(value)
except Exception:
return str(value)
@QtCore.Slot() @QtCore.Slot()
def _flush_pending(self) -> None: def _flush_pending(self) -> None:
if not self._pending_by_key: if not self._pending_by_key:
@@ -149,18 +134,17 @@ class QueuePage(QtWidgets.QWidget):
speed_item = self._ensure_item(row, 4, "") speed_item = self._ensure_item(row, 4, "")
eta_item = self._ensure_item(row, 5, "") eta_item = self._ensure_item(row, 5, "")
target_item = self._ensure_item(row, 6, "") target_item = self._ensure_item(row, 6, "")
target_text = target_item.text()
if name == "DownloadStarted": if name == "DownloadStarted":
status_item.setText("started") status_item.setText("started")
target_text = self._target_text(payload, target_text) tgt = payload.get("target") or payload.get("filename") or ""
if target_text: if tgt:
target_item.setText(target_text) target_item.setText(str(tgt))
elif name == "DownloadProgress": elif name == "DownloadProgress":
status_item.setText(str(payload.get("status") or "downloading")) status_item.setText(str(payload.get("status") or "downloading"))
prog = payload.get("progress") prog = payload.get("progress")
if isinstance(prog, (int, float)): if isinstance(prog, (int, float)):
pct = max(0, min(100, int(round(prog * 100)))) pct = max(0, min(100, round(prog * 100)))
bar = self._table.cellWidget(row, 3) bar = self._table.cellWidget(row, 3)
if bar is None: if bar is None:
bar = QtWidgets.QProgressBar() bar = QtWidgets.QProgressBar()
@@ -174,14 +158,14 @@ class QueuePage(QtWidgets.QWidget):
et = payload.get("eta") et = payload.get("eta")
if isinstance(et, (int, float)) and et >= 0: if isinstance(et, (int, float)) and et >= 0:
eta_item.setText(f"{int(et)}s") eta_item.setText(f"{int(et)}s")
target_text = self._target_text(payload, target_text) fn = payload.get("filename")
if target_text: if fn:
target_item.setText(target_text) target_item.setText(str(fn))
elif name == "DownloadCompleted": elif name == "DownloadCompleted":
status_item.setText("completed") status_item.setText("completed")
target_text = self._target_text(payload, target_text) tgt = payload.get("target") or ""
if target_text: if tgt:
target_item.setText(target_text) target_item.setText(str(tgt))
bar = self._table.cellWidget(row, 3) bar = self._table.cellWidget(row, 3)
if bar is None: if bar is None:
bar = QtWidgets.QProgressBar() bar = QtWidgets.QProgressBar()
@@ -232,3 +216,9 @@ class QueuePage(QtWidgets.QWidget):
vid = v_item.text() vid = v_item.text()
rebuilt[(str(pid), str(vid))] = v_item rebuilt[(str(pid), str(vid))] = v_item
self._rows_by_key = rebuilt 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…")
+34 -19
View File
@@ -5,8 +5,7 @@ from typing import Any
from PySide6 import QtCore, QtWidgets from PySide6 import QtCore, QtWidgets
from ...config.settings import ensure_tray_config, get_tray_config, load_config, save_config from ..config_store import load_config, save_config
from ..autosave import DebouncedAutosave
class SettingsPage(QtWidgets.QWidget): class SettingsPage(QtWidgets.QWidget):
@@ -82,7 +81,11 @@ class SettingsPage(QtWidgets.QWidget):
self._status.setWordWrap(True) self._status.setWordWrap(True)
layout.addWidget(self._status) layout.addWidget(self._status)
self._autosave = DebouncedAutosave(self, self.save_to_config) 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. # Autosave on focus-out / change.
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave) self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
@@ -104,27 +107,34 @@ class SettingsPage(QtWidgets.QWidget):
self._status.setText("No config loaded yet.") self._status.setText("No config loaded yet.")
return return
try: try:
with self._autosave.suppressed(): self._suppress_autosave = True
cfg = load_config(self._config_path) cfg = load_config(self._config_path)
self._config = dict(cfg) self._config = dict(cfg.data)
self._ffmpeg_path.setText(str(self._config.get("ffmpeg_path") or "")) 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._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_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._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._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
tray = get_tray_config(self._config) ui = self._config.get("ui")
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False))) ui = ui if isinstance(ui, dict) else {}
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False))) tray = ui.get("tray")
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False))) tray = tray if isinstance(tray, dict) else {}
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
self._status.setText(f"Loaded settings from {self._config_path}.") self._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc: except Exception as exc: # noqa: BLE001 - show load failures in the UI
self._status.setText(f"Failed to load settings: {exc}") self._status.setText(f"Failed to load settings: {exc}")
finally:
self._suppress_autosave = False
def _schedule_autosave(self) -> None: def _schedule_autosave(self) -> None:
self._autosave.schedule() if self._suppress_autosave:
return
self._autosave_timer.start()
@QtCore.Slot() @QtCore.Slot()
def save_to_config(self) -> None: def save_to_config(self) -> None:
@@ -139,12 +149,17 @@ class SettingsPage(QtWidgets.QWidget):
data["retry_delay_seconds"] = float(self._retry_delay.value()) data["retry_delay_seconds"] = float(self._retry_delay.value())
data["delay_between_downloads_seconds"] = float(self._download_delay.value()) data["delay_between_downloads_seconds"] = float(self._download_delay.value())
tray = ensure_tray_config(data) ui = data.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
tray["close_to_tray"] = bool(self._close_to_tray.isChecked()) tray["close_to_tray"] = bool(self._close_to_tray.isChecked())
tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked()) tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked())
tray["start_minimized_to_tray"] = bool(self._start_minimized_to_tray.isChecked()) tray["start_minimized_to_tray"] = bool(self._start_minimized_to_tray.isChecked())
ui["tray"] = tray
data["ui"] = ui
save_config(self._config_path, data) save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.") self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc: except Exception as exc: # noqa: BLE001 - show save failures in the UI
self._status.setText(f"Failed to save settings: {exc}") self._status.setText(f"Failed to save settings: {exc}")
+4 -4
View File
@@ -4,19 +4,19 @@ import asyncio
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Dict from typing import Any
from PySide6 import QtCore from PySide6 import QtCore
from ..core.database.db import Database from ..core.database.db import Database
from ..core.events.event_bus import EventBus
from ..core.sync.executor import ActionExecutor from ..core.sync.executor import ActionExecutor
from ..core.sync.service import SyncService from ..core.sync.service import SyncService
from ..core.events.event_bus import EventBus
@dataclass(frozen=True) @dataclass(frozen=True)
class SyncRequest: class SyncRequest:
playlist_cfg: Dict[str, Any] playlist_cfg: dict[str, Any]
apply: bool = True apply: bool = True
db_path: Path = Path("db/app.db") db_path: Path = Path("db/app.db")
cancel_flag: threading.Event | None = None cancel_flag: threading.Event | None = None
@@ -70,5 +70,5 @@ class SyncRunner(QtCore.QObject):
self.finished.emit(False, "cancelled") self.finished.emit(False, "cancelled")
else: else:
self.finished.emit(True, "done") self.finished.emit(True, "done")
except Exception as exc: except Exception as exc: # noqa: BLE001 - report worker failures through the UI signal
self.finished.emit(False, str(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 = QtCore.QPropertyAnimation(area.verticalScrollBar(), b"value", self)
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic) self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # noqa: N802 def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
if event.type() != QtCore.QEvent.Type.Wheel: if event.type() != QtCore.QEvent.Type.Wheel:
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -19,7 +19,7 @@ class _SmoothWheelFilter(QtCore.QObject):
try: try:
angle_y = wheel.angleDelta().y() angle_y = wheel.angleDelta().y()
pixel_y = wheel.pixelDelta().y() pixel_y = wheel.pixelDelta().y()
except Exception: except Exception: # noqa: BLE001 - fall back to native scrolling for unknown event types
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
dy = pixel_y if pixel_y else angle_y dy = pixel_y if pixel_y else angle_y
@@ -34,7 +34,7 @@ class _SmoothWheelFilter(QtCore.QObject):
if pixel_y: if pixel_y:
delta = -dy delta = -dy
else: else:
delta = int(round(-dy / 120.0 * base_step)) delta = round(-dy / 120.0 * base_step)
target = max(sb.minimum(), min(sb.maximum(), start + delta)) target = max(sb.minimum(), min(sb.maximum(), start + delta))
if target == start: if target == start:
+38 -36
View File
@@ -7,48 +7,50 @@ Future iterations will wire up scheduler and a GUI.
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch 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.utils.yt import extract_playlist_id
def bootstrap(db_path: Path | None = None) -> None: def bootstrap(db_path: Path | None = None) -> None:
settings, db, service, executor = build_sync_stack(db_path) settings = Settings()
db = Database((db_path or Path("db/app.db")).resolve())
service = SyncService(db)
executor = ActionExecutor(db)
def on_plan(pl: dict, playlist_id: str, actions, counts: dict[str, int]) -> None: # Iterate configured playlists and compute actions (no execution yet)
del playlist_id for pl in settings.playlists:
print(f"Applying {len(actions)} actions for: {pl.get('url')}") try:
print(f"Plan → {format_action_summary(counts)}") actions = service.sync_from_config(pl)
# Apply actions now
def on_no_actions(pl: dict, playlist_id: str) -> None: if actions:
del playlist_id print(f"Applying {len(actions)} actions for: {pl.get('url')}")
print(f"No actions needed for: {pl.get('url')}") # Summarize before applying
counts = {}
def on_applied(pl: dict, playlist_id: str) -> None: for a in actions:
del pl, playlist_id counts[a.type] = counts.get(a.type, 0) + 1
print("Applied actions.") summary = ", ".join(f"{k.name}:{v}" for k, v in counts.items())
print(f"Plan → {summary}")
def on_import_error(pl: dict, exc: Exception) -> bool: # Execute
print(f"Failed to sync playlist {pl.get('url')}: {exc}") try:
return True asyncio.run(executor.execute(actions, pl))
except DependencyError as e:
def on_dependency_error(pl: dict, exc: Exception) -> bool: print(f"ERROR: {e}")
del pl continue
print(f"ERROR: {exc}") # Post summary (no DB readback yet)
return True pid = extract_playlist_id(pl.get('url', '')) or pl.get('url', '')
db.set_playlist_last_sync(pid)
run_sync_batch( print("Applied actions.")
settings.playlists, else:
db=db, print(f"No actions needed for: {pl.get('url')}")
service=service, except Exception as exc: # noqa: BLE001 - keep bootstrap resilient during early dev
executor=executor, print(f"Failed to sync playlist {pl.get('url')}: {exc}")
apply=True,
on_plan=on_plan,
on_no_actions=on_no_actions,
on_applied=on_applied,
on_import_error=on_import_error,
on_dependency_error=on_dependency_error,
)
if __name__ == "__main__": if __name__ == "__main__":
+2
View File
@@ -6,10 +6,12 @@ from app.core.download.downloader import Downloader
def test_build_format_defaults_to_best_mp4(): def test_build_format_defaults_to_best_mp4():
fmt = Downloader.build_format(None) fmt = Downloader.build_format(None)
assert "height<=1080" in fmt assert "height<=1080" in fmt
assert "bestvideo[ext=mp4][height<=1080]+bestaudio" in fmt
assert fmt.endswith("/best[ext=mp4]") assert fmt.endswith("/best[ext=mp4]")
def test_build_format_applies_height_cap(): def test_build_format_applies_height_cap():
fmt = Downloader.build_format("720p") fmt = Downloader.build_format("720p")
assert "bestvideo[ext=mp4]" in fmt
assert "height<=720" 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 "SyncSummary" in names
assert "SyncFinished" in names assert "SyncFinished" in names
summary = [p for n, p in published if n == "SyncSummary"][0] summary = next(p for n, p in published if n == "SyncSummary")
assert summary["playlist_id"] == "p" assert summary["playlist_id"] == "p"
assert "duration_s" in summary assert "duration_s" in summary
assert isinstance(summary["counts"], dict) assert isinstance(summary["counts"], dict)
+1 -2
View File
@@ -5,11 +5,10 @@ import os
import pytest import pytest
from app.core.database.db import Database from app.core.database.db import Database
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.sync.service import SyncService from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL") PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+1 -2
View File
@@ -5,11 +5,10 @@ import os
import pytest import pytest
from app.core.database.db import Database from app.core.database.db import Database
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.sync.service import SyncService from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL") PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+1 -2
View File
@@ -5,11 +5,10 @@ import os
import pytest import pytest
from app.core.database.db import Database from app.core.database.db import Database
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.sync.service import SyncService from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL") PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+7 -1
View File
@@ -1,7 +1,13 @@
from __future__ import annotations 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 from app.gui.main import main
if __name__ == "__main__": if __name__ == "__main__":
raise SystemExit(main()) raise SystemExit(main())