mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-11-05 07:55:12 +02:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6b6f6518d | ||
|
|
1c0ccde877 | ||
|
|
15c7e2b298 | ||
|
|
c6a98d30a5 | ||
|
|
15c7c8e514 | ||
|
|
e1aaf35c1f | ||
|
|
758dcf5080 | ||
|
|
99bbefce9c | ||
|
|
02a06a70e4 | ||
|
|
c578e42bf7 | ||
|
|
9a5deeefed | ||
|
|
389bd1b348 | ||
|
|
e65199a403 | ||
|
|
aefef55a2c | ||
|
|
8904e07597 | ||
|
|
972c8e1dbf | ||
|
|
b6fb314571 | ||
|
|
b8acce7623 | ||
|
|
a647685070 | ||
|
|
3494578c0e | ||
|
|
5b393c0bcd | ||
|
|
47f88bc6a4 | ||
|
|
835958cd94 | ||
|
|
e1fab69598 | ||
|
|
e97c419b47 |
No files matched your search
@@ -24,71 +24,15 @@ permissions:
|
||||
contents: write
|
||||
|
||||
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:
|
||||
name: Unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- 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
|
||||
name: Test
|
||||
uses: ./.github/workflows/test.yml
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
build:
|
||||
name: Build packages
|
||||
needs: tag
|
||||
needs: test
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -97,7 +41,7 @@ jobs:
|
||||
ffmpeg: [bundled, none]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Derive version
|
||||
id: version
|
||||
@@ -113,7 +57,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
@@ -205,7 +149,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
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=(
|
||||
--fail
|
||||
--location
|
||||
@@ -215,11 +160,15 @@ jobs:
|
||||
--user-agent "Mozilla/5.0 (GitHub Actions; ytpl-sync release workflow)"
|
||||
)
|
||||
|
||||
curl "${curl_common[@]}" "$primary_url" -o ffmpeg.tar.xz
|
||||
curl "${curl_common[@]}" "${primary_url}.md5" -o ffmpeg.tar.xz.md5
|
||||
curl "${curl_common[@]}" "${release_url}/${archive_name}" -o ffmpeg.tar.xz
|
||||
curl "${curl_common[@]}" "${release_url}/checksums.sha256" -o checksums.sha256
|
||||
|
||||
expected_md5="$(awk '{print $1}' ffmpeg.tar.xz.md5)"
|
||||
printf '%s *ffmpeg.tar.xz\n' "$expected_md5" | md5sum -c -
|
||||
expected_sha256="$(awk -v archive="$archive_name" '$2 == archive || $2 == "*" archive { print $1; exit }' checksums.sha256)"
|
||||
if [[ -z "$expected_sha256" ]]; then
|
||||
echo "Checksum for ${archive_name} was not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s ffmpeg.tar.xz\n' "$expected_sha256" | sha256sum -c -
|
||||
|
||||
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
|
||||
echo "Downloaded FFmpeg payload is not a valid tar archive" >&2
|
||||
@@ -229,7 +178,13 @@ jobs:
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
- name: Archive (Windows)
|
||||
@@ -268,12 +223,55 @@ jobs:
|
||||
ytpl-sync-*.zip
|
||||
ytpl-sync-*.tar.gz
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
tag:
|
||||
name: Create tag
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -1,25 +1,15 @@
|
||||
name: Lint Python code
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "assets/**"
|
||||
- "README.md"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "assets/**"
|
||||
- "README.md"
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Ruff
|
||||
run: pip install ruff
|
||||
|
||||
@@ -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
|
||||
@@ -1,23 +1,8 @@
|
||||
name: Unit tests
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "assets/**"
|
||||
- "README.md"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "assets/**"
|
||||
- "README.md"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
@@ -28,10 +13,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
@@ -19,12 +19,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -4,23 +4,23 @@
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
- Safe reordering via two-pass rename and recycle deletions
|
||||
- Async download queue with retry support (yt-dlp Python API)
|
||||
- SQLite metadata for `last_sync` and download state
|
||||
- Safe reordering via two-pass rename, recycle deletions
|
||||
- Async download queue with simple retry (yt-dlp Python API)
|
||||
- SQLite metadata (`last_sync`, download state)
|
||||
|
||||
## Requirements
|
||||
|
||||
- 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 `-ffmpeg` release: no extra dependencies
|
||||
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it's on PATH (needed for `audio` and `both` modes)
|
||||
|
||||
## Download
|
||||
|
||||
@@ -31,7 +31,7 @@ Download the latest release from this repo's Releases page and pick one:
|
||||
|
||||
## 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
|
||||
{
|
||||
@@ -39,14 +39,6 @@ The application uses a JSON config file that can be edited from the UI or manual
|
||||
"max_parallel_downloads": 2,
|
||||
"retry_max_retries": 2,
|
||||
"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": [
|
||||
{
|
||||
"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`:
|
||||
|
||||
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
|
||||
- `audio`: download the video, extract `.mp3`, and delete the video file
|
||||
- `both`: download the video, extract `.mp3`, and keep both files
|
||||
- `audio`: download video, extract `.mp3`, delete the video file
|
||||
- `both`: download video, extract `.mp3`, keep both files
|
||||
|
||||
Queue / retry:
|
||||
|
||||
- `max_parallel_downloads`: number of concurrent download workers.
|
||||
- `retry_max_retries`: how many times a failed download job is retried.
|
||||
- `retry_delay_seconds`: base delay before retry; increases with backoff.
|
||||
- `delay_between_downloads_seconds`: optional delay between download jobs.
|
||||
|
||||
## Run
|
||||
|
||||
- GUI: run `ytpl-sync-entry.py` or the packaged desktop exe from releases.
|
||||
- Run `ytpl-sync.exe` (GUI).
|
||||
|
||||
## Tray
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ytpl-sync"
|
||||
version = "2.1.1"
|
||||
version = "2.3.0"
|
||||
description = "YouTube playlist Sync"
|
||||
readme = "README.md"
|
||||
authors = [ { name = "Dark_Zoul" } ]
|
||||
@@ -12,7 +12,7 @@ license = { file = "LICENSE" }
|
||||
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"yt-dlp>=2026.6.9",
|
||||
"yt-dlp>=2026.10.21",
|
||||
"PySide6_Essentials>=6.11.1",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
|
||||
+40
-44
@@ -1,13 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .config.settings import Settings
|
||||
from .core.database.db import Database
|
||||
from .core.events.event_bus import EventBus
|
||||
import re
|
||||
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch
|
||||
from .core.sync.executor import ActionExecutor
|
||||
from .core.sync.service import SyncService
|
||||
from .core.utils.deps import DependencyError
|
||||
from .core.utils.logging_setup import configure_logging
|
||||
from .core.utils.yt import extract_playlist_id
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -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"))
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
settings = Settings()
|
||||
db = Database(args.db.resolve())
|
||||
service = SyncService(db)
|
||||
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()
|
||||
|
||||
@@ -64,52 +73,39 @@ def main(argv: list[str] | None = None) -> int:
|
||||
bus.subscribe("RenameApplied", on_rename)
|
||||
bus.subscribe("FileRecycled", on_recycle)
|
||||
|
||||
selected_playlists = settings.playlists
|
||||
playlists = settings.playlists
|
||||
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:
|
||||
summary = format_action_summary(counts)
|
||||
print(f"Playlist {playlist_id}: {len(actions)} actions → {summary}")
|
||||
log.info("playlist=%s actions=%s summary=%s", playlist_id, len(actions), summary)
|
||||
|
||||
def on_no_actions(pl: dict, playlist_id: str) -> None:
|
||||
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)
|
||||
for pl in playlists:
|
||||
url = pl.get("url")
|
||||
pid = extract_playlist_id(url) or (url or "")
|
||||
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")
|
||||
else:
|
||||
print(f"ERROR: {exc}")
|
||||
return False
|
||||
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_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,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+16
-72
@@ -1,9 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _default_ffmpeg_path() -> str:
|
||||
@@ -12,7 +15,7 @@ def _default_ffmpeg_path() -> str:
|
||||
return "./bin/ffmpeg"
|
||||
|
||||
|
||||
DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
DEFAULT_CONFIG: dict[str, Any] = {
|
||||
"playlists": [],
|
||||
"download_mode": "video",
|
||||
"max_download_quality": "1080p",
|
||||
@@ -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:
|
||||
"""Unified configuration loader that combines file I/O and playlist merging."""
|
||||
|
||||
def __init__(self, config_path: Path | None = None) -> None:
|
||||
if config_path is None:
|
||||
def __init__(self) -> None:
|
||||
base_dir = Path("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)
|
||||
self.data: dict[str, Any] = dict(DEFAULT_CONFIG)
|
||||
|
||||
# Ensure there is always a config file at the default path.
|
||||
if not self.path.exists():
|
||||
@@ -96,15 +41,15 @@ class Settings:
|
||||
self._load_from_path(self.path)
|
||||
|
||||
def _load_from_path(self, path: Path) -> None:
|
||||
"""Load and merge config from file."""
|
||||
loaded = load_config(path)
|
||||
if loaded:
|
||||
self.data.update(normalize_config(loaded))
|
||||
try:
|
||||
self.data.update(json.loads(path.read_text(encoding="utf-8")))
|
||||
except (OSError, TypeError, UnicodeError, ValueError) as exc:
|
||||
# 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:
|
||||
"""Write a default config file."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
default_payload: Dict[str, Any] = {
|
||||
default_payload: dict[str, Any] = {
|
||||
"playlists": [
|
||||
{
|
||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||
@@ -115,11 +60,10 @@ class Settings:
|
||||
],
|
||||
"ffmpeg_path": _default_ffmpeg_path(),
|
||||
}
|
||||
save_config(path, default_payload)
|
||||
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
@property
|
||||
def playlists(self) -> List[Dict[str, Any]]:
|
||||
"""Get playlists with global defaults merged in."""
|
||||
def playlists(self) -> list[dict[str, Any]]:
|
||||
global_defaults = {
|
||||
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]),
|
||||
"max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
|
||||
@@ -130,7 +74,7 @@ class Settings:
|
||||
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]),
|
||||
}
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
results: list[dict[str, Any]] = []
|
||||
for pl in list(self.data.get("playlists", [])):
|
||||
if not isinstance(pl, dict):
|
||||
continue
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
|
||||
|
||||
__all__ = [
|
||||
"scanner",
|
||||
"sync",
|
||||
"download",
|
||||
"database",
|
||||
"scheduler",
|
||||
"download",
|
||||
"events",
|
||||
"scanner",
|
||||
"scheduler",
|
||||
"sync",
|
||||
]
|
||||
@@ -1,9 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
from .queue_manager import DownloadJob, JobState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""
|
||||
@@ -11,7 +13,7 @@ class Downloader:
|
||||
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.ffmpeg_path = ffmpeg_path
|
||||
|
||||
@@ -34,15 +36,15 @@ class Downloader:
|
||||
return None
|
||||
try:
|
||||
cap = int(digits)
|
||||
except Exception:
|
||||
except ValueError:
|
||||
return None
|
||||
return cap if cap > 0 else None
|
||||
|
||||
cap = parse_height_cap(max_download_quality)
|
||||
if cap is not None:
|
||||
#if the requested cap isn't available, we still download the best mp4.
|
||||
return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]"
|
||||
return "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]"
|
||||
# Prefer separate MP4 video and audio streams; yt-dlp merges them with ffmpeg.
|
||||
return f"bestvideo[ext=mp4][height<={cap}]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<={cap}]+bestaudio/bestvideo[ext=mp4][height<={cap}]/best[ext=mp4]"
|
||||
return "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/bestvideo[ext=mp4]/best[ext=mp4]"
|
||||
|
||||
async def handle_job(self, job: DownloadJob):
|
||||
try:
|
||||
@@ -60,10 +62,10 @@ class Downloader:
|
||||
if not job.keep_video and job.output_path:
|
||||
try:
|
||||
job.output_path.unlink(missing_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug("Failed to remove temporary video file: %s", exc)
|
||||
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.error = str(exc)
|
||||
|
||||
@@ -72,11 +74,12 @@ class Downloader:
|
||||
import asyncio
|
||||
|
||||
def run():
|
||||
import yt_dlp # type: ignore
|
||||
from pathlib import Path
|
||||
|
||||
import yt_dlp # type: ignore
|
||||
try:
|
||||
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]
|
||||
|
||||
class _QuietLogger:
|
||||
@@ -94,10 +97,8 @@ class Downloader:
|
||||
|
||||
fmt = self.build_format(getattr(job, "max_download_quality", None))
|
||||
|
||||
# All modes download a single muxed mp4 when possible.
|
||||
# This avoids any ffmpeg-driven merging during the download step, satisfying:
|
||||
# - video: "original file, no processing"
|
||||
# - audio/both: extraction is done separately after download
|
||||
# Prefer MP4 video with a separate audio stream; yt-dlp merges them when needed.
|
||||
# Audio extraction for audio/both modes happens separately after download.
|
||||
ydl_opts = {
|
||||
"format": fmt,
|
||||
"outtmpl": outtmpl,
|
||||
@@ -137,19 +138,19 @@ class Downloader:
|
||||
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
|
||||
|
||||
should_emit = status in {"finished", "error"}
|
||||
if not should_emit:
|
||||
if now - last_ts >= 0.25:
|
||||
should_emit = True
|
||||
elif pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01):
|
||||
if not should_emit and (
|
||||
now - last_ts >= 0.25
|
||||
or pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01)
|
||||
):
|
||||
should_emit = True
|
||||
|
||||
if should_emit:
|
||||
setattr(job, "_last_progress_emit_ts", now)
|
||||
job._last_progress_emit_ts = now
|
||||
if pct is not None:
|
||||
setattr(job, "_last_progress_emit_pct", pct)
|
||||
job._last_progress_emit_pct = pct
|
||||
progress_cb(payload)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - progress hooks must not abort downloads
|
||||
logger.debug("Progress callback failed: %s", exc)
|
||||
|
||||
ydl_opts["progress_hooks"] = [hook]
|
||||
|
||||
@@ -160,8 +161,8 @@ class Downloader:
|
||||
p = Path(str(ffmpeg_hint))
|
||||
if p.exists():
|
||||
ydl_opts["ffmpeg_location"] = str(p)
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.debug("Invalid ffmpeg path: %s", exc)
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||
ydl.download([job.url])
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any
|
||||
|
||||
from ..models import PlaylistItem
|
||||
|
||||
@@ -21,19 +22,19 @@ class JobState(str, Enum):
|
||||
|
||||
@dataclass
|
||||
class DownloadJob:
|
||||
"""Configuration and status for a single download job."""
|
||||
item: PlaylistItem
|
||||
output_path: Optional[Path] = None
|
||||
url: Optional[str] = None
|
||||
output_path: Path | None = None
|
||||
url: str | None = None
|
||||
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
|
||||
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:
|
||||
@@ -45,14 +46,14 @@ class QueueManager:
|
||||
"""
|
||||
|
||||
def __init__(self, concurrency: int = 2) -> None:
|
||||
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
|
||||
self._queue: asyncio.Queue[DownloadJob] = asyncio.Queue()
|
||||
self._concurrency = max(1, concurrency)
|
||||
self._workers: list[asyncio.Task[None]] = []
|
||||
self._stopped = asyncio.Event()
|
||||
|
||||
async def start(self, worker_coro):
|
||||
"""Start the worker tasks that drain the queue."""
|
||||
async def runner():
|
||||
async def runner(idx: int):
|
||||
while not self._stopped.is_set():
|
||||
job = await self._queue.get()
|
||||
try:
|
||||
@@ -60,7 +61,7 @@ class QueueManager:
|
||||
finally:
|
||||
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):
|
||||
"""Cancel all worker tasks and mark the queue as stopped."""
|
||||
|
||||
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||
from .downloader import Downloader
|
||||
from .queue_manager import DownloadJob, JobState
|
||||
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||
|
||||
|
||||
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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:
|
||||
"""Simple async pub/sub event bus used by backend and (later) GUI."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subs: DefaultDict[str, List[EventHandler]] = defaultdict(list)
|
||||
self._subs: defaultdict[str, list[EventHandler]] = defaultdict(list)
|
||||
|
||||
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||
self._subs[event_name].append(handler)
|
||||
|
||||
async def publish(self, event_name: str, payload: Dict[str, Any]) -> None:
|
||||
async def publish(self, event_name: str, payload: dict[str, Any]) -> None:
|
||||
for h in list(self._subs.get(event_name, [])):
|
||||
await h(payload)
|
||||
+10
-6
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class DownloadMode(str, Enum):
|
||||
@@ -15,7 +14,7 @@ class DownloadMode(str, Enum):
|
||||
@dataclass(frozen=True)
|
||||
class Playlist:
|
||||
id: str
|
||||
name: Optional[str]
|
||||
name: str | None
|
||||
url: str
|
||||
path: Path
|
||||
mode: DownloadMode = DownloadMode.audio
|
||||
@@ -29,7 +28,7 @@ class PlaylistItem:
|
||||
video_id: str
|
||||
title: str
|
||||
playlist_index: int
|
||||
local_filename: Optional[str] = None
|
||||
local_filename: str | None = None
|
||||
downloaded: bool = False
|
||||
|
||||
|
||||
@@ -45,7 +44,12 @@ class SyncActionType(str, Enum):
|
||||
@dataclass(frozen=True)
|
||||
class SyncAction:
|
||||
type: SyncActionType
|
||||
item: Optional[PlaylistItem] = None
|
||||
from_name: Optional[str] = None
|
||||
to_name: Optional[str] = None
|
||||
item: PlaylistItem | None = None
|
||||
from_name: str | None = None
|
||||
to_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemEntry:
|
||||
name: str
|
||||
path: Path
|
||||
@@ -1,10 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from ..models import PlaylistItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlaylistScanner:
|
||||
"""
|
||||
@@ -16,7 +18,10 @@ class PlaylistScanner:
|
||||
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."""
|
||||
try:
|
||||
import yt_dlp # type: ignore
|
||||
@@ -36,14 +41,14 @@ class PlaylistScanner:
|
||||
p = Path(str(ffmpeg_path))
|
||||
if p.exists():
|
||||
ydl_opts["ffmpeg_location"] = str(p)
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
logger.debug("Invalid ffmpeg path: %s", exc)
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||
info = ydl.extract_info(playlist_url, download=False)
|
||||
|
||||
entries = info.get("entries", []) if isinstance(info, dict) else []
|
||||
items: List[PlaylistItem] = []
|
||||
items: list[PlaylistItem] = []
|
||||
for idx, v in enumerate(entries, start=1):
|
||||
if not v:
|
||||
continue
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import timedelta
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
|
||||
class Scheduler:
|
||||
|
||||
@@ -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
|
||||
@@ -1,20 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import logging
|
||||
import shutil
|
||||
import time
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
|
||||
from ..database.db import Database
|
||||
from ..download.queue_manager import DownloadJob, QueueManager
|
||||
from ..download.workers import default_worker
|
||||
from ..events.event_bus import EventBus
|
||||
from ..models import SyncAction, SyncActionType
|
||||
from ..sync.reorder import safe_multi_rename
|
||||
from ..database.db import Database
|
||||
from ..utils.yt import extract_playlist_id
|
||||
from ..events.event_bus import EventBus
|
||||
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
||||
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||
from ..utils.yt import extract_playlist_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
@@ -95,7 +98,8 @@ class ActionExecutor:
|
||||
try:
|
||||
self.db.set_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
|
||||
summary = {
|
||||
"playlist_id": playlist_id,
|
||||
@@ -140,7 +144,7 @@ class ActionExecutor:
|
||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||
audio_renames = []
|
||||
video_renames = []
|
||||
applied: List[SyncAction] = []
|
||||
applied: list[SyncAction] = []
|
||||
for a in actions:
|
||||
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
|
||||
continue
|
||||
@@ -160,8 +164,8 @@ class ActionExecutor:
|
||||
if a.item and a.to_name:
|
||||
try:
|
||||
self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - database update is best effort
|
||||
logger.debug("Failed to update renamed file in database: %s", exc)
|
||||
if self.bus:
|
||||
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():
|
||||
dst.unlink()
|
||||
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
|
||||
try:
|
||||
src.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
except OSError as unlink_exc:
|
||||
logger.debug("Failed to delete unrecycled file %s: %s", src, unlink_exc)
|
||||
# Update DB to clear file state
|
||||
if a.item:
|
||||
try:
|
||||
self.db.clear_file_state(playlist_id, a.item.video_id)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - database update is best effort
|
||||
logger.debug("Failed to clear deleted file in database: %s", exc)
|
||||
if self.bus:
|
||||
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)
|
||||
try:
|
||||
concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency
|
||||
except Exception:
|
||||
except (TypeError, ValueError):
|
||||
concurrency = self.concurrency
|
||||
queue = QueueManager(concurrency=concurrency)
|
||||
|
||||
@@ -227,17 +232,17 @@ class ActionExecutor:
|
||||
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
|
||||
try:
|
||||
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
|
||||
except Exception:
|
||||
except (TypeError, ValueError):
|
||||
retry_max_retries = 2
|
||||
try:
|
||||
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
|
||||
|
||||
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
|
||||
try:
|
||||
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
|
||||
except Exception:
|
||||
except (TypeError, ValueError):
|
||||
delay_between_downloads_seconds = 0.0
|
||||
|
||||
rate_limit_pause = asyncio.Event()
|
||||
@@ -284,7 +289,7 @@ class ActionExecutor:
|
||||
|
||||
await queue.start(worker)
|
||||
try:
|
||||
jobs: List[DownloadJob] = []
|
||||
jobs: list[DownloadJob] = []
|
||||
|
||||
# Collapse 'both' into single video download + local audio extraction
|
||||
# Build per-video desired outputs
|
||||
@@ -416,5 +421,5 @@ class ActionExecutor:
|
||||
)
|
||||
else:
|
||||
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - one failed job must not stop finalization
|
||||
logger.debug("Failed to persist download result: %s", exc)
|
||||
@@ -1,16 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import List, Sequence
|
||||
|
||||
from ..models import FilesystemEntry
|
||||
|
||||
|
||||
def list_files(root: Path, extensions: Sequence[str]) -> List[Path]:
|
||||
"""List all files in root directory with given extensions."""
|
||||
def list_files(root: Path, extensions: Sequence[str]) -> list[FilesystemEntry]:
|
||||
exts = {e.lower() for e in extensions}
|
||||
results: List[Path] = []
|
||||
results: list[FilesystemEntry] = []
|
||||
if not root.exists():
|
||||
return results
|
||||
for p in root.glob("**/*"):
|
||||
if p.is_file() and p.suffix.lower() in exts:
|
||||
results.append(p)
|
||||
results.append(FilesystemEntry(name=p.name, path=p))
|
||||
return results
|
||||
@@ -1,10 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Tuple
|
||||
|
||||
|
||||
def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
|
||||
def safe_multi_rename(renames: Iterable[tuple[Path, Path]]) -> None:
|
||||
"""
|
||||
Apply multiple renames safely using a two-pass strategy to avoid
|
||||
name collisions. Each item is a tuple (src_path, dst_path).
|
||||
@@ -14,7 +14,7 @@ def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
|
||||
existing_dests = {dst for _, dst in planned}
|
||||
|
||||
# Pass 1: move all sources that would collide to temporary names
|
||||
temps: Dict[Path, Path] = {}
|
||||
temps: dict[Path, Path] = {}
|
||||
for src, dst in planned:
|
||||
if not src.exists():
|
||||
continue
|
||||
|
||||
@@ -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
|
||||
@@ -1,11 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Mapping, Sequence
|
||||
|
||||
from ..database.db import Database
|
||||
from ..models import PlaylistItem, SyncAction, SyncActionType
|
||||
from ..models import PlaylistItem, SyncAction
|
||||
from ..scanner.playlist_scanner import PlaylistScanner
|
||||
from ..sync.diff_engine import DiffEngine
|
||||
from ..sync.filesystem import list_files
|
||||
from ..utils.naming import sanitize_title
|
||||
from ..utils.yt import extract_playlist_id
|
||||
@@ -15,13 +15,14 @@ class SyncService:
|
||||
"""High-level orchestration for a single playlist sync pass.
|
||||
|
||||
The service pulls the latest remote playlist snapshot, persists the
|
||||
playlist and item metadata in the database, and compares the remote state
|
||||
with the local filesystem to produce sync actions.
|
||||
playlist and item metadata in the database, and asks the diff engine to
|
||||
compare the remote state with the local filesystem.
|
||||
"""
|
||||
|
||||
def __init__(self, db: Database) -> None:
|
||||
self.db = db
|
||||
self.scanner = PlaylistScanner()
|
||||
self.diff = DiffEngine()
|
||||
|
||||
def _mode_to_extensions(self, mode: str) -> list[str]:
|
||||
if mode == "audio":
|
||||
@@ -32,60 +33,7 @@ class SyncService:
|
||||
return [".mp3", ".mp4"]
|
||||
return [".mp4"]
|
||||
|
||||
def _compute_actions(
|
||||
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]:
|
||||
def sync_from_config(self, playlist_cfg: dict) -> list[SyncAction]:
|
||||
"""Return the sync actions required to bring one playlist in sync.
|
||||
|
||||
This method does not apply any changes itself. It normalizes the
|
||||
@@ -111,7 +59,7 @@ class SyncService:
|
||||
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path")
|
||||
items = self.scanner.scan(url, playlist_id, ffmpeg_path=str(ffmpeg_cfg) if ffmpeg_cfg is not None else None)
|
||||
|
||||
sanitized: List[PlaylistItem] = []
|
||||
sanitized: list[PlaylistItem] = []
|
||||
for it in items:
|
||||
safe_title = sanitize_title(it.title, it.video_id)
|
||||
sanitized.append(
|
||||
@@ -151,7 +99,7 @@ class SyncService:
|
||||
)
|
||||
|
||||
# Augment remote items with DB-known filenames/download flags
|
||||
augmented: List[PlaylistItem] = []
|
||||
augmented: list[PlaylistItem] = []
|
||||
for it in sanitized:
|
||||
known = db_index.get(it.video_id)
|
||||
if known is None:
|
||||
@@ -169,7 +117,7 @@ class SyncService:
|
||||
)
|
||||
|
||||
exts = self._mode_to_extensions(mode)
|
||||
merged_actions: List[SyncAction] = []
|
||||
merged_actions: list[SyncAction] = []
|
||||
|
||||
# Compute per-extension actions against respective roots
|
||||
for ext in exts:
|
||||
@@ -179,7 +127,7 @@ class SyncService:
|
||||
fs = list_files(save_path / "video", [".mp4"])
|
||||
else:
|
||||
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)
|
||||
|
||||
return merged_actions
|
||||
@@ -6,7 +6,6 @@ import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from shutil import which
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
class DependencyError(RuntimeError):
|
||||
@@ -16,20 +15,20 @@ class DependencyError(RuntimeError):
|
||||
@dataclass(frozen=True)
|
||||
class DependencyStatus:
|
||||
yt_dlp_ok: bool
|
||||
yt_dlp_version: Optional[str]
|
||||
yt_dlp_version: str | None
|
||||
ffmpeg_ok: bool
|
||||
ffmpeg_path: Optional[str]
|
||||
ffmpeg_path: str | None
|
||||
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:
|
||||
import yt_dlp # type: ignore
|
||||
|
||||
# yt-dlp exposes version in different places depending on install
|
||||
return getattr(yt_dlp, "__version__", None) or getattr(getattr(yt_dlp, "version", None), "__version__", None)
|
||||
except Exception:
|
||||
except (ImportError, AttributeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -44,7 +43,7 @@ def ensure_yt_dlp_available() -> str:
|
||||
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).
|
||||
- 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
|
||||
|
||||
|
||||
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).
|
||||
|
||||
@@ -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_path, ffmpeg_dir = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
|
||||
ffmpeg_path, _ = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
|
||||
if not ffmpeg_path:
|
||||
raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.")
|
||||
|
||||
|
||||
@@ -17,9 +17,11 @@ def _read_text(path: Path) -> str | None:
|
||||
if path.exists():
|
||||
text = path.read_text(encoding="utf-8").strip()
|
||||
return text or None
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, UnicodeError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_app_version() -> str:
|
||||
"""
|
||||
Returns the packaged app version.
|
||||
|
||||
@@ -10,5 +10,5 @@ def extract_playlist_id(url: str) -> str | None:
|
||||
if "list" in qs and qs.get("list"):
|
||||
return qs.get("list", [None])[0]
|
||||
return None
|
||||
except Exception:
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
@@ -1,10 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _resource_base() -> Path:
|
||||
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
|
||||
@@ -31,16 +34,16 @@ def load_app_icon() -> QtGui.QIcon:
|
||||
icon = QtGui.QIcon(str(p))
|
||||
if not icon.isNull():
|
||||
return icon
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - icon loading is best effort
|
||||
logger.debug("Failed to load app icon from %s: %s", p, exc)
|
||||
|
||||
# Fallback to a platform theme icon (Linux) or a generic icon.
|
||||
try:
|
||||
themed = QtGui.QIcon.fromTheme("applications-multimedia")
|
||||
if not themed.isNull():
|
||||
return themed
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - themed icon loading is best effort
|
||||
logger.debug("Failed to load themed app icon: %s", exc)
|
||||
|
||||
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
|
||||
|
||||
@@ -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()
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any, Dict
|
||||
|
||||
from typing import Any
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from ..core.events.event_bus import EventBus
|
||||
@@ -32,7 +34,7 @@ class BusBridge(QtCore.QObject):
|
||||
self._bus.subscribe(name, self._make_handler(name))
|
||||
|
||||
def _make_handler(self, name: str):
|
||||
async def handler(payload: Dict[str, Any]) -> None:
|
||||
async def handler(payload: dict[str, Any]) -> None:
|
||||
# Ensure delivery on the Qt main thread.
|
||||
self.event.emit(name, dict(payload))
|
||||
|
||||
|
||||
@@ -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
|
||||
+75
-41
@@ -1,20 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import threading
|
||||
|
||||
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 .bus_bridge import BusBridge
|
||||
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.queue import QueuePage
|
||||
from .pages.logs import LogsPage
|
||||
from .pages.settings import SettingsPage
|
||||
from .pages.about import AboutPage
|
||||
from .runner import SyncRequest, SyncRunner
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
@@ -89,8 +93,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
if cfg_path is not None:
|
||||
try:
|
||||
self._settings_page.set_config_path(cfg_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - optional settings-page wiring
|
||||
logger.debug("Failed to set settings config path: %s", exc)
|
||||
|
||||
self._playlists_page.cancel_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)
|
||||
if cfg_path is None:
|
||||
return {}
|
||||
raw = load_config(cfg_path)
|
||||
return get_tray_config(raw)
|
||||
except Exception:
|
||||
raw = load_config(cfg_path).data
|
||||
ui = raw.get("ui")
|
||||
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 {}
|
||||
|
||||
def _close_to_tray_enabled(self) -> bool:
|
||||
@@ -153,7 +162,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
try:
|
||||
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
|
||||
return
|
||||
except Exception:
|
||||
except Exception as exc: # noqa: BLE001 - tray support is optional
|
||||
logger.debug("Failed to check system tray availability: %s", exc)
|
||||
return
|
||||
|
||||
icon = load_app_icon()
|
||||
@@ -206,27 +216,30 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
|
||||
3000,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - tray notification is best effort
|
||||
logger.debug("Failed to show tray notification: %s", exc)
|
||||
return
|
||||
if self._tray is not None and not self._close_to_tray_enabled():
|
||||
# Explicitly quit, because the app may be configured to keep running without windows.
|
||||
try:
|
||||
event.accept()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - close event acceptance is best effort
|
||||
logger.debug("Failed to accept close event: %s", exc)
|
||||
QtWidgets.QApplication.quit()
|
||||
return
|
||||
super().closeEvent(event)
|
||||
|
||||
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
|
||||
try:
|
||||
if event.type() == QtCore.QEvent.Type.WindowStateChange:
|
||||
if self._tray is not None and self._minimize_to_tray_enabled():
|
||||
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
|
||||
if (
|
||||
event.type() == QtCore.QEvent.Type.WindowStateChange
|
||||
and self._tray is not None
|
||||
and self._minimize_to_tray_enabled()
|
||||
and bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized)
|
||||
):
|
||||
QtCore.QTimer.singleShot(0, self.hide)
|
||||
except Exception:
|
||||
pass
|
||||
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)
|
||||
|
||||
def _refresh_queue_labels(self) -> None:
|
||||
@@ -239,23 +252,24 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
pid = extract_playlist_id(url) or url
|
||||
labels[pid] = str(pl.get("name") or f"Playlist {idx}")
|
||||
self._queue_page.set_playlist_labels(labels)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
except Exception as exc: # noqa: BLE001 - queue labels are best effort
|
||||
logger.debug("Failed to refresh queue labels: %s", exc)
|
||||
|
||||
@QtCore.Slot(str, dict)
|
||||
def _on_bus_event(self, name: str, payload: dict) -> None:
|
||||
# Fan out to any page that exposes on_event().
|
||||
self._emit_page_event(name, payload)
|
||||
# Fan out to interested pages.
|
||||
try:
|
||||
self._queue_page.on_event(name, payload)
|
||||
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
|
||||
logger.debug("Queue page event handler failed: %s", exc)
|
||||
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.
|
||||
if name == "SyncPaused":
|
||||
@@ -301,7 +315,7 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
@QtCore.Slot(bool, str)
|
||||
def _on_sync_finished(self, ok: bool, message: str) -> None:
|
||||
if not ok:
|
||||
self._emit_page_event("SyncError", {"error": message})
|
||||
self._logs_page.on_event("SyncError", {"error": message})
|
||||
self._playlists_page.set_running(False)
|
||||
|
||||
# Mark idle so "Sync all" can be started again.
|
||||
@@ -309,8 +323,8 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
try:
|
||||
self._thread.quit()
|
||||
self._thread.wait(2000)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - thread shutdown is best effort
|
||||
logger.debug("Failed to stop sync thread: %s", exc)
|
||||
self._runner = None
|
||||
self._cancel_flag = None
|
||||
self._pause_flag = None
|
||||
@@ -324,12 +338,12 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
if cfg_path is not None:
|
||||
self._settings_page.set_config_path(cfg_path)
|
||||
self._refresh_queue_labels()
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001 - config refresh is best effort
|
||||
logger.debug("Failed to refresh configuration after sync: %s", exc)
|
||||
|
||||
# Continue "sync all" chain if active.
|
||||
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"):
|
||||
nxt = getattr(self, "_sync_queue").pop(0)
|
||||
if hasattr(self, "_sync_queue") and self._sync_queue:
|
||||
nxt = self._sync_queue.pop(0)
|
||||
self._sync_playlist_index(nxt)
|
||||
|
||||
@QtCore.Slot()
|
||||
@@ -394,6 +408,26 @@ class MainWindow(QtWidgets.QMainWindow):
|
||||
border-radius: 10px;
|
||||
color: #d7dce4;
|
||||
}
|
||||
QScrollArea#logsScroll {
|
||||
background: #11151c;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 10px;
|
||||
}
|
||||
QWidget#logEntries {
|
||||
background: #11151c;
|
||||
}
|
||||
QFrame#logEntry {
|
||||
background: #171b22;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QLabel#logEventName {
|
||||
color: #8ea7ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel#logEntryBody {
|
||||
color: #cfd3da;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #0f1218;
|
||||
width: 12px;
|
||||
|
||||
+59
-12
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PySide6 import QtGui, QtWidgets
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
from ..smooth_scroll import enable_smooth_scrolling
|
||||
|
||||
@@ -23,22 +23,69 @@ class LogsPage(QtWidgets.QWidget):
|
||||
top.addWidget(clear_btn)
|
||||
layout.addLayout(top)
|
||||
|
||||
self._text = QtWidgets.QPlainTextEdit()
|
||||
self._text.setReadOnly(True)
|
||||
enable_smooth_scrolling(self._text)
|
||||
layout.addWidget(self._text, 1)
|
||||
self._scroll = QtWidgets.QScrollArea()
|
||||
self._scroll.setObjectName("logsScroll")
|
||||
self._scroll.setWidgetResizable(True)
|
||||
self._scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
|
||||
|
||||
self._entries = QtWidgets.QWidget()
|
||||
self._entries.setObjectName("logEntries")
|
||||
self._entries_layout = QtWidgets.QVBoxLayout(self._entries)
|
||||
self._entries_layout.setContentsMargins(2, 2, 2, 2)
|
||||
self._entries_layout.setSpacing(8)
|
||||
self._entries_layout.addStretch(1)
|
||||
self._scroll.setWidget(self._entries)
|
||||
enable_smooth_scrolling(self._scroll)
|
||||
layout.addWidget(self._scroll, 1)
|
||||
|
||||
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:
|
||||
# Avoid flooding the UI with high-frequency progress updates.
|
||||
if name == "DownloadProgress":
|
||||
return
|
||||
# Keep this lightweight: append a single-line JSON entry.
|
||||
# Keep the event name in the header and show only its payload here.
|
||||
try:
|
||||
line = json.dumps({"event": name, **payload}, ensure_ascii=False)
|
||||
except Exception:
|
||||
line = f"{name}: {payload}"
|
||||
self._text.appendPlainText(line)
|
||||
self._text.moveCursor(QtGui.QTextCursor.MoveOperation.End)
|
||||
line = json.dumps(payload, ensure_ascii=False)
|
||||
except Exception: # noqa: BLE001 - fall back to a readable representation
|
||||
line = str(payload)
|
||||
entry = QtWidgets.QFrame()
|
||||
entry.setObjectName("logEntry")
|
||||
|
||||
entry_layout = QtWidgets.QVBoxLayout(entry)
|
||||
entry_layout.setContentsMargins(12, 9, 12, 9)
|
||||
entry_layout.setSpacing(4)
|
||||
|
||||
header_layout = QtWidgets.QHBoxLayout()
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
event_label = QtWidgets.QLabel(name)
|
||||
event_label.setObjectName("logEventName")
|
||||
event_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
header_layout.addWidget(event_label)
|
||||
header_layout.addStretch(1)
|
||||
|
||||
copy_btn = QtWidgets.QPushButton("Copy")
|
||||
copy_btn.setToolTip("Copy this log entry")
|
||||
copy_btn.clicked.connect(lambda: self._copy_entry(f"{name}\n{line}"))
|
||||
header_layout.addWidget(copy_btn)
|
||||
entry_layout.addLayout(header_layout)
|
||||
|
||||
body_label = QtWidgets.QLabel(line)
|
||||
body_label.setObjectName("logEntryBody")
|
||||
body_label.setWordWrap(True)
|
||||
body_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||
body_label.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Preferred)
|
||||
entry_layout.addWidget(body_label)
|
||||
|
||||
self._entries_layout.insertWidget(self._entries_layout.count() - 1, entry)
|
||||
self._scroll.verticalScrollBar().setValue(self._scroll.verticalScrollBar().maximum())
|
||||
|
||||
@staticmethod
|
||||
def _copy_entry(text: str) -> None:
|
||||
QtWidgets.QApplication.clipboard().setText(text)
|
||||
@@ -1,15 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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.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
|
||||
|
||||
|
||||
@@ -41,7 +41,11 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
self._config_path = getattr(settings, "path", None)
|
||||
self._config: 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.setObjectName("pageTitle")
|
||||
@@ -126,16 +130,18 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
@QtCore.Slot()
|
||||
def reload_from_config(self) -> None:
|
||||
try:
|
||||
with self._autosave.suppressed():
|
||||
self._suppress_autosave = True
|
||||
self._settings = Settings()
|
||||
self._config_path = getattr(self._settings, "path", None)
|
||||
if self._config_path is None:
|
||||
raise RuntimeError("Config path not available")
|
||||
self._config = normalize_config(load_config(self._config_path))
|
||||
self._config = normalize_config(load_config(self._config_path).data)
|
||||
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}")
|
||||
return
|
||||
finally:
|
||||
self._suppress_autosave = False
|
||||
|
||||
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
|
||||
last_sync_by_id: dict[str, str] = {}
|
||||
@@ -146,7 +152,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
ls = db.get_playlist_last_sync(pid)
|
||||
if ls:
|
||||
last_sync_by_id[pid] = str(ls)
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001 - database metadata is optional
|
||||
last_sync_by_id = {}
|
||||
|
||||
self._list.clear()
|
||||
@@ -172,19 +178,6 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
self._status.setText("Cancelling…")
|
||||
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:
|
||||
self._sync_all_btn.setEnabled(not 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.
|
||||
self._list.setEnabled(True)
|
||||
# But freeze editing while a sync is running to avoid racey config edits.
|
||||
for card in self._iter_cards():
|
||||
card.set_editing_enabled(not running)
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if isinstance(w, _PlaylistCard):
|
||||
w.set_editing_enabled(not running)
|
||||
|
||||
@QtCore.Slot()
|
||||
def _add_playlist(self) -> None:
|
||||
@@ -237,8 +233,13 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
|
||||
def _table_to_playlists(self) -> list[dict[str, Any]]:
|
||||
playlists: list[dict[str, Any]] = []
|
||||
for card in self._iter_cards():
|
||||
playlists.append(card.to_dict())
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if not isinstance(w, _PlaylistCard):
|
||||
continue
|
||||
pl = w.to_dict()
|
||||
playlists.append(pl)
|
||||
return playlists
|
||||
|
||||
@QtCore.Slot()
|
||||
@@ -255,18 +256,24 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.")
|
||||
# Reload settings to reflect merged defaults
|
||||
self.reload_from_config()
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - show config save failures in the UI
|
||||
self._status.setText(f"Failed to save config: {exc}")
|
||||
|
||||
def _reindex_cards(self) -> None:
|
||||
for i, card in enumerate(self._iter_cards()):
|
||||
card.set_index(i)
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if isinstance(w, _PlaylistCard):
|
||||
w.set_index(i)
|
||||
|
||||
def _validate_all(self, *, show_status: bool) -> bool:
|
||||
ok = True
|
||||
for card in self._iter_cards():
|
||||
errs = card.validate()
|
||||
card.set_status("; ".join(errs) if errs else "")
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if isinstance(w, _PlaylistCard):
|
||||
errs = w.validate()
|
||||
w.set_status("; ".join(errs) if errs else "")
|
||||
if errs:
|
||||
ok = False
|
||||
if not ok and show_status:
|
||||
@@ -275,12 +282,18 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
|
||||
@QtCore.Slot()
|
||||
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()
|
||||
def _autosave_now(self) -> None:
|
||||
if self._config_path is None:
|
||||
return
|
||||
if self._suppress_autosave:
|
||||
return
|
||||
if not self._validate_all(show_status=False):
|
||||
# Don't autosave invalid configs; user sees inline errors.
|
||||
return
|
||||
@@ -289,7 +302,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
data["playlists"] = self._table_to_playlists()
|
||||
save_config(self._config_path, data)
|
||||
self._status.setText(f"Autosaved to {self._config_path}.")
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - show autosave failures in the UI
|
||||
self._status.setText(f"Autosave failed: {exc}")
|
||||
|
||||
def on_event(self, name: str, payload: dict) -> None:
|
||||
@@ -297,25 +310,22 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
pid = payload.get("playlist_id")
|
||||
total = payload.get("actions_total")
|
||||
self._sync_state.setText(f"Sync started: {pid} ({total} actions)")
|
||||
playlist_id = str(pid or "")
|
||||
self._set_card_status(playlist_id, "running")
|
||||
self._set_active_card(playlist_id, running=True, paused=False)
|
||||
self._set_card_status(str(pid or ""), "running")
|
||||
self._set_active_card(str(pid or ""), running=True, paused=False)
|
||||
elif name == "SyncSummary":
|
||||
pid = payload.get("playlist_id")
|
||||
dur = payload.get("duration_s")
|
||||
counts = payload.get("counts")
|
||||
self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}")
|
||||
playlist_id = str(pid or "")
|
||||
self._set_card_status(playlist_id, f"done in {dur}s")
|
||||
self._set_card_status(str(pid or ""), f"done in {dur}s")
|
||||
ls = payload.get("last_sync")
|
||||
if ls:
|
||||
self._set_card_last_sync(playlist_id, str(ls))
|
||||
self._set_card_last_sync(str(pid or ""), str(ls))
|
||||
elif name == "SyncFinished":
|
||||
pid = payload.get("playlist_id")
|
||||
self._sync_state.setText(f"Sync finished: {pid}")
|
||||
playlist_id = str(pid or "")
|
||||
self._set_card_status(playlist_id, "finished")
|
||||
self._set_active_card(playlist_id, running=False, paused=False)
|
||||
self._set_card_status(str(pid or ""), "finished")
|
||||
self._set_active_card(str(pid or ""), running=False, paused=False)
|
||||
self.set_running(False)
|
||||
elif name == "SyncError":
|
||||
self._sync_state.setText(f"Sync error: {payload.get('error')}")
|
||||
@@ -345,7 +355,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
self._download_state_by_pid[pid]["video_id"] = vid
|
||||
self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading")
|
||||
self._set_card_progress(pid, p)
|
||||
pct = 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")
|
||||
tail = f"{vid} {pct}%" if vid else f"{pct}%"
|
||||
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)
|
||||
|
||||
def _set_card_progress(self, playlist_id: str, progress: float) -> None:
|
||||
card = self._card_for_playlist_id(playlist_id)
|
||||
if card is not None:
|
||||
card.set_progress(progress)
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
|
||||
w.set_progress(progress)
|
||||
|
||||
def _set_card_status(self, playlist_id: str, text: str) -> None:
|
||||
card = self._card_for_playlist_id(playlist_id)
|
||||
if card is not None:
|
||||
card.set_status(text)
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
|
||||
w.set_status(text)
|
||||
|
||||
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
|
||||
card = self._card_for_playlist_id(playlist_id)
|
||||
if card is not None:
|
||||
card.set_last_sync(last_sync)
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
|
||||
w.set_last_sync(last_sync)
|
||||
|
||||
def _set_active_card(self, playlist_id: str, *, running: bool, paused: bool) -> None:
|
||||
card = self._card_for_playlist_id(playlist_id)
|
||||
if card is None:
|
||||
return
|
||||
card.set_active(running)
|
||||
card.set_paused(paused)
|
||||
for i in range(self._list.count()):
|
||||
item = self._list.item(i)
|
||||
w = self._list.itemWidget(item)
|
||||
if not isinstance(w, _PlaylistCard):
|
||||
continue
|
||||
is_active = w.playlist_id() == playlist_id
|
||||
w.set_active(is_active and running)
|
||||
if is_active:
|
||||
w.set_paused(paused)
|
||||
|
||||
|
||||
class _PlaylistCard(QtWidgets.QFrame):
|
||||
@@ -583,7 +603,7 @@ class _PlaylistCard(QtWidgets.QFrame):
|
||||
return extract_playlist_id(url) or url
|
||||
|
||||
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)
|
||||
|
||||
def set_last_sync(self, last_sync: str) -> None:
|
||||
@@ -592,7 +612,7 @@ class _PlaylistCard(QtWidgets.QFrame):
|
||||
def validate(self) -> list[str]:
|
||||
errs: list[str] = []
|
||||
url = self._url.text().strip()
|
||||
if not url or not (url.startswith("http://") or url.startswith("https://")):
|
||||
if not url or not url.startswith(("http://", "https://")):
|
||||
errs.append("URL required")
|
||||
mode = self._mode.currentText().strip()
|
||||
if mode not in {"video", "audio", "both"}:
|
||||
|
||||
+19
-29
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
from ..smooth_scroll import enable_smooth_scrolling
|
||||
@@ -32,9 +30,12 @@ class QueuePage(QtWidgets.QWidget):
|
||||
top.addStretch(1)
|
||||
clear_btn = QtWidgets.QPushButton("Clear completed")
|
||||
clear_btn.clicked.connect(self._clear_completed)
|
||||
clear_all_btn = QtWidgets.QPushButton("Clear all")
|
||||
clear_all_btn.clicked.connect(self._clear_all)
|
||||
cancel_btn = QtWidgets.QPushButton("Cancel all")
|
||||
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
|
||||
top.addWidget(clear_btn)
|
||||
top.addWidget(clear_all_btn)
|
||||
top.addWidget(cancel_btn)
|
||||
layout.addLayout(top)
|
||||
|
||||
@@ -111,22 +112,6 @@ class QueuePage(QtWidgets.QWidget):
|
||||
self._table.setItem(row, col, 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()
|
||||
def _flush_pending(self) -> None:
|
||||
if not self._pending_by_key:
|
||||
@@ -149,18 +134,17 @@ class QueuePage(QtWidgets.QWidget):
|
||||
speed_item = self._ensure_item(row, 4, "")
|
||||
eta_item = self._ensure_item(row, 5, "")
|
||||
target_item = self._ensure_item(row, 6, "")
|
||||
target_text = target_item.text()
|
||||
|
||||
if name == "DownloadStarted":
|
||||
status_item.setText("started")
|
||||
target_text = self._target_text(payload, target_text)
|
||||
if target_text:
|
||||
target_item.setText(target_text)
|
||||
tgt = payload.get("target") or payload.get("filename") or ""
|
||||
if tgt:
|
||||
target_item.setText(str(tgt))
|
||||
elif name == "DownloadProgress":
|
||||
status_item.setText(str(payload.get("status") or "downloading"))
|
||||
prog = payload.get("progress")
|
||||
if isinstance(prog, (int, float)):
|
||||
pct = max(0, min(100, int(round(prog * 100))))
|
||||
pct = max(0, min(100, round(prog * 100)))
|
||||
bar = self._table.cellWidget(row, 3)
|
||||
if bar is None:
|
||||
bar = QtWidgets.QProgressBar()
|
||||
@@ -174,14 +158,14 @@ class QueuePage(QtWidgets.QWidget):
|
||||
et = payload.get("eta")
|
||||
if isinstance(et, (int, float)) and et >= 0:
|
||||
eta_item.setText(f"{int(et)}s")
|
||||
target_text = self._target_text(payload, target_text)
|
||||
if target_text:
|
||||
target_item.setText(target_text)
|
||||
fn = payload.get("filename")
|
||||
if fn:
|
||||
target_item.setText(str(fn))
|
||||
elif name == "DownloadCompleted":
|
||||
status_item.setText("completed")
|
||||
target_text = self._target_text(payload, target_text)
|
||||
if target_text:
|
||||
target_item.setText(target_text)
|
||||
tgt = payload.get("target") or ""
|
||||
if tgt:
|
||||
target_item.setText(str(tgt))
|
||||
bar = self._table.cellWidget(row, 3)
|
||||
if bar is None:
|
||||
bar = QtWidgets.QProgressBar()
|
||||
@@ -232,3 +216,9 @@ class QueuePage(QtWidgets.QWidget):
|
||||
vid = v_item.text()
|
||||
rebuilt[(str(pid), str(vid))] = v_item
|
||||
self._rows_by_key = rebuilt
|
||||
|
||||
def _clear_all(self) -> None:
|
||||
self._pending_by_key.clear()
|
||||
self._rows_by_key.clear()
|
||||
self._table.setRowCount(0)
|
||||
self._hint.setText("Waiting for downloads…")
|
||||
@@ -5,8 +5,7 @@ from typing import Any
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
from ...config.settings import ensure_tray_config, get_tray_config, load_config, save_config
|
||||
from ..autosave import DebouncedAutosave
|
||||
from ..config_store import load_config, save_config
|
||||
|
||||
|
||||
class SettingsPage(QtWidgets.QWidget):
|
||||
@@ -82,7 +81,11 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
self._status.setWordWrap(True)
|
||||
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.
|
||||
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
|
||||
@@ -104,9 +107,9 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
self._status.setText("No config loaded yet.")
|
||||
return
|
||||
try:
|
||||
with self._autosave.suppressed():
|
||||
self._suppress_autosave = True
|
||||
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._max_parallel.setValue(int(self._config.get("max_parallel_downloads") or 2))
|
||||
@@ -114,17 +117,24 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
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))
|
||||
|
||||
tray = get_tray_config(self._config)
|
||||
ui = self._config.get("ui")
|
||||
ui = ui if isinstance(ui, dict) else {}
|
||||
tray = ui.get("tray")
|
||||
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}.")
|
||||
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}")
|
||||
finally:
|
||||
self._suppress_autosave = False
|
||||
|
||||
def _schedule_autosave(self) -> None:
|
||||
self._autosave.schedule()
|
||||
if self._suppress_autosave:
|
||||
return
|
||||
self._autosave_timer.start()
|
||||
|
||||
@QtCore.Slot()
|
||||
def save_to_config(self) -> None:
|
||||
@@ -139,12 +149,17 @@ class SettingsPage(QtWidgets.QWidget):
|
||||
data["retry_delay_seconds"] = float(self._retry_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["minimize_to_tray"] = bool(self._minimize_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)
|
||||
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}")
|
||||
@@ -4,19 +4,19 @@ import asyncio
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
from typing import Any
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from ..core.database.db import Database
|
||||
from ..core.events.event_bus import EventBus
|
||||
from ..core.sync.executor import ActionExecutor
|
||||
from ..core.sync.service import SyncService
|
||||
from ..core.events.event_bus import EventBus
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncRequest:
|
||||
playlist_cfg: Dict[str, Any]
|
||||
playlist_cfg: dict[str, Any]
|
||||
apply: bool = True
|
||||
db_path: Path = Path("db/app.db")
|
||||
cancel_flag: threading.Event | None = None
|
||||
@@ -70,5 +70,5 @@ class SyncRunner(QtCore.QObject):
|
||||
self.finished.emit(False, "cancelled")
|
||||
else:
|
||||
self.finished.emit(True, "done")
|
||||
except Exception as exc:
|
||||
except Exception as exc: # noqa: BLE001 - report worker failures through the UI signal
|
||||
self.finished.emit(False, str(exc))
|
||||
@@ -11,7 +11,7 @@ class _SmoothWheelFilter(QtCore.QObject):
|
||||
self._anim = QtCore.QPropertyAnimation(area.verticalScrollBar(), b"value", self)
|
||||
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
|
||||
|
||||
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # noqa: N802
|
||||
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
|
||||
if event.type() != QtCore.QEvent.Type.Wheel:
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
@@ -19,7 +19,7 @@ class _SmoothWheelFilter(QtCore.QObject):
|
||||
try:
|
||||
angle_y = wheel.angleDelta().y()
|
||||
pixel_y = wheel.pixelDelta().y()
|
||||
except Exception:
|
||||
except Exception: # noqa: BLE001 - fall back to native scrolling for unknown event types
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
dy = pixel_y if pixel_y else angle_y
|
||||
@@ -34,7 +34,7 @@ class _SmoothWheelFilter(QtCore.QObject):
|
||||
if pixel_y:
|
||||
delta = -dy
|
||||
else:
|
||||
delta = int(round(-dy / 120.0 * base_step))
|
||||
delta = round(-dy / 120.0 * base_step)
|
||||
|
||||
target = max(sb.minimum(), min(sb.maximum(), start + delta))
|
||||
if target == start:
|
||||
|
||||
+35
-33
@@ -7,48 +7,50 @@ Future iterations will wire up scheduler and a GUI.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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:
|
||||
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:
|
||||
del playlist_id
|
||||
# Iterate configured playlists and compute actions (no execution yet)
|
||||
for pl in settings.playlists:
|
||||
try:
|
||||
actions = service.sync_from_config(pl)
|
||||
# Apply actions now
|
||||
if actions:
|
||||
print(f"Applying {len(actions)} actions for: {pl.get('url')}")
|
||||
print(f"Plan → {format_action_summary(counts)}")
|
||||
|
||||
def on_no_actions(pl: dict, playlist_id: str) -> None:
|
||||
del playlist_id
|
||||
print(f"No actions needed for: {pl.get('url')}")
|
||||
|
||||
def on_applied(pl: dict, playlist_id: str) -> None:
|
||||
del pl, playlist_id
|
||||
# Summarize before applying
|
||||
counts = {}
|
||||
for a in actions:
|
||||
counts[a.type] = counts.get(a.type, 0) + 1
|
||||
summary = ", ".join(f"{k.name}:{v}" for k, v in counts.items())
|
||||
print(f"Plan → {summary}")
|
||||
# Execute
|
||||
try:
|
||||
asyncio.run(executor.execute(actions, pl))
|
||||
except DependencyError as e:
|
||||
print(f"ERROR: {e}")
|
||||
continue
|
||||
# Post summary (no DB readback yet)
|
||||
pid = extract_playlist_id(pl.get('url', '')) or pl.get('url', '')
|
||||
db.set_playlist_last_sync(pid)
|
||||
print("Applied actions.")
|
||||
|
||||
def on_import_error(pl: dict, exc: Exception) -> bool:
|
||||
else:
|
||||
print(f"No actions needed for: {pl.get('url')}")
|
||||
except Exception as exc: # noqa: BLE001 - keep bootstrap resilient during early dev
|
||||
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
|
||||
return True
|
||||
|
||||
def on_dependency_error(pl: dict, exc: Exception) -> bool:
|
||||
del pl
|
||||
print(f"ERROR: {exc}")
|
||||
return True
|
||||
|
||||
run_sync_batch(
|
||||
settings.playlists,
|
||||
db=db,
|
||||
service=service,
|
||||
executor=executor,
|
||||
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__":
|
||||
|
||||
@@ -6,10 +6,12 @@ from app.core.download.downloader import Downloader
|
||||
def test_build_format_defaults_to_best_mp4():
|
||||
fmt = Downloader.build_format(None)
|
||||
assert "height<=1080" in fmt
|
||||
assert "bestvideo[ext=mp4][height<=1080]+bestaudio" in fmt
|
||||
assert fmt.endswith("/best[ext=mp4]")
|
||||
|
||||
|
||||
def test_build_format_applies_height_cap():
|
||||
fmt = Downloader.build_format("720p")
|
||||
assert "bestvideo[ext=mp4]" in fmt
|
||||
assert "height<=720" in fmt
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_executor_emits_sync_events(tmp_path):
|
||||
assert "SyncSummary" 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 "duration_s" in summary
|
||||
assert isinstance(summary["counts"], dict)
|
||||
|
||||
@@ -5,11 +5,10 @@ import os
|
||||
import pytest
|
||||
|
||||
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.sync.executor import ActionExecutor
|
||||
from app.core.sync.service import SyncService
|
||||
|
||||
|
||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ import os
|
||||
import pytest
|
||||
|
||||
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.sync.executor import ActionExecutor
|
||||
from app.core.sync.service import SyncService
|
||||
|
||||
|
||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@ import os
|
||||
import pytest
|
||||
|
||||
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.sync.executor import ActionExecutor
|
||||
from app.core.sync.service import SyncService
|
||||
|
||||
|
||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||
|
||||
|
||||
|
||||
+7
-1
@@ -1,7 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
src_dir = Path(__file__).resolve().parent / "src"
|
||||
if str(src_dir) not in sys.path:
|
||||
sys.path.insert(0, str(src_dir))
|
||||
|
||||
from app.gui.main import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user