1
0
mirror of https://github.com/darkzoul5/YoutubePlaylistSync.git synced 2026-07-03 04:23:59 +03:00

20 Commits

Author SHA1 Message Date
dark_zoul d41df72930 update readme 2026-06-19 15:25:20 +03:00
dark_zoul 8ec24d04f6 refactor: consolidate sync entrypoints and trim dead GUI/util code 2026-06-19 15:10:29 +03:00
dark_zoul c1a2227da8 refactor: simplify playlist card management in gui 2026-06-19 14:51:29 +03:00
dark_zoul 789457828c refactor: simplify event routing 2026-06-19 14:49:09 +03:00
dark_zoul a7000d59e1 refactor: remove old unused module 2026-06-19 14:47:40 +03:00
dark_zoul 3c6e7342d2 refactor: centralize tray config handling 2026-06-19 14:45:32 +03:00
dark_zoul afebc35166 refactor: add shared autosave helper 2026-06-19 14:42:27 +03:00
dark_zoul 373a19510e fix: add missing imports for queue target file display 2026-06-19 14:21:49 +03:00
dark_zoul 9a47d87220 fix: remove stale calls to data field 2026-06-19 14:02:52 +03:00
dark_zoul b0e7dac4be fix: invalid import path in settings.py 2026-06-19 13:54:46 +03:00
dark_zoul 9748cbd471 refactor: use path directly instead of filesystemEntry 2026-06-19 13:51:55 +03:00
dark_zoul de39cca57d refactor: split DownloadJob into JobConfig and JobStatus 2026-06-19 13:50:07 +03:00
dark_zoul 2e2c21fc10 refactor: merge diffEngine into SyncService 2026-06-19 13:48:10 +03:00
dark_zoul b6aba1e67e refactor: unified settings for cli and gui 2026-06-19 13:45:45 +03:00
dark_zoul a7f1564581 remove outdated plans 2026-06-18 11:31:06 +03:00
dark_zoul 3b1cdda19d ci(release): verify linux ffmpeg download 2026-06-11 15:57:47 +03:00
darkzoul5 2dc119a2f1 Merge pull request #16 from darkzoul5/chore/refresh-yt-dlp
chore: bump yt-dlp to 2026.6.9
2026-06-11 15:40:45 +03:00
darkzoul5 7d0c7aa1d5 chore: bump yt-dlp to 2026.6.9 2026-06-11 12:38:15 +00:00
dark_zoul 15f2df0cbf ci: use dedicated token for yt-dlp update PRs 2026-06-11 15:37:52 +03:00
dark_zoul 22756f35db ci: switch workflow path filters to paths-ignore 2026-06-11 13:05:51 +03:00
26 changed files with 523 additions and 1194 deletions
+24 -1
View File
@@ -204,7 +204,30 @@ jobs:
run: |
set -euo pipefail
mkdir -p package/bin ffmpeg_tmp
curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz
primary_url="https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz"
curl_common=(
--fail
--location
--retry 5
--retry-all-errors
--retry-delay 2
--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
expected_md5="$(awk '{print $1}' ffmpeg.tar.xz.md5)"
printf '%s *ffmpeg.tar.xz\n' "$expected_md5" | md5sum -c -
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
echo "Downloaded FFmpeg payload is not a valid tar archive" >&2
ls -l ffmpeg.tar.xz >&2 || true
head -c 256 ffmpeg.tar.xz >&2 || true
exit 1
fi
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
chmod +x package/bin/ffmpeg
+11 -1
View File
@@ -2,7 +2,17 @@ name: Lint Python code
on:
push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
jobs:
lint:
@@ -15,4 +25,4 @@ jobs:
run: pip install ruff
- name: Run linter
run: ruff check .
run: ruff check .
+6 -12
View File
@@ -5,21 +5,15 @@ on:
push:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
paths-ignore:
- "assets/**"
- "README.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
+3
View File
@@ -78,6 +78,9 @@ jobs:
if: steps.detect.outputs.needs_update == 'true'
uses: peter-evans/create-pull-request@v8
with:
# Use a non-GITHUB_TOKEN credential so the resulting PR triggers CI workflows.
# Configure secrets.PR_WORKFLOW_TOKEN with contents:write and pull-requests:write.
token: ${{ secrets.PR_WORKFLOW_TOKEN || github.token }}
branch: chore/refresh-yt-dlp
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
title: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
+21 -12
View File
@@ -4,23 +4,23 @@
![Build-Release](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/build-release.yml?style=flat-square&label=Build-Release)
![Unit Tests](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/unit-tests.yml?style=flat-square&label=unit-tests)
A cross-platform tool for downloading and keeping 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/).
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/).
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.
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.
Local-first YouTube playlist synchronization client.
## What's Included
- GUI (PySide6 Essentials) playlist manager + sync runner
- GUI playlist manager and sync runner built with PySide6 Essentials
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
- Safe reordering via two-pass rename, recycle deletions
- Async download queue with simple retry (yt-dlp Python API)
- SQLite metadata (`last_sync`, download state)
- 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
## Requirements
- 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)
- 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).
## Download
@@ -31,7 +31,7 @@ Download the latest release from this repo's Releases page and pick one:
## Configure
Application uses a json config that canbe edited from UI or manually
The application uses a JSON config file that can be edited from the UI or manually.
```json
{
@@ -39,6 +39,14 @@ Application uses a json config that canbe edited from UI or manually
"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",
@@ -60,18 +68,19 @@ Application uses a json config that canbe edited from UI or manually
`download_mode`:
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
- `audio`: download video, extract `.mp3`, delete the video file
- `both`: download video, extract `.mp3`, keep both files
- `audio`: download the video, extract `.mp3`, and delete the video file
- `both`: download the video, extract `.mp3`, and 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
- Run `ytpl-sync.exe` (GUI).
- GUI: run `ytpl-sync-entry.py` or the packaged desktop exe from releases.
## Tray
-24
View File
@@ -1,24 +0,0 @@
# GUI Plan
## Python-first Desktop Architecture
- **Primary GUI framework**: `PySide6` (Qt for Python).
## Core Features to Implement
1. **Dashboard Overview**: List all tracked playlists, their status (Last Sync), and total size.
2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker).
3. **Queue Manager**: Visual progress bars for active downloads, showing speed, ETA, and current video title.
4. **Log Viewer**: Real-time streaming of yt-dlp logs for troubleshooting.
5. **Settings Panel**: Global settings for binary paths (ffmpeg), max parallel jobs, and Docker detection toggle.
## Phase 1 Roadmap: "The Bridge"
- [ ] **PySide6 Skeleton**: Basic window with `QWebEngine` (if hybrid) or native `QWidget` dashboard.
- [ ] **Packaging**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
## Packaging & Distribution (brief)
- Bundle the backend and GUI into one distributable.
- Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX.
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
+6 -6
View File
@@ -11,7 +11,7 @@
- Write useful MP3 metadata for downloaded playlist items without affecting video-only downloads.
- Keep the implementation reliable when optional fields are missing.
- Preserve successful downloads even when metadata embedding partially fails.
- Provide a setting to enable or disable MP3 metadata embedding.
- Provide a per-playlist setting to enable or disable MP3 metadata embedding.
## Required Metadata
@@ -26,7 +26,7 @@
## Configuration Requirement
- Add a setting to turn MP3 metadata embedding on or off.
- Add a per-playlist setting to turn MP3 metadata embedding on or off.
- Default should be explicitly defined during implementation; recommended default is `enabled` for new configs.
- The setting should only affect `.mp3` metadata writing and should not change download selection, extraction, or `.mp4` handling.
@@ -68,7 +68,7 @@
- Ensure the enriched `PlaylistItem` reaches the download job and post-processing stage.
- Keep this propagation in-memory unless restart-safe metadata persistence becomes necessary later.
- Avoid changing unrelated sync behavior for video-only items.
- Carry the MP3 metadata enabled/disabled setting into the post-processing step.
- Carry the per-playlist MP3 metadata enabled/disabled setting into the post-processing step.
### 4. Add an MP3 tag writer
@@ -109,8 +109,8 @@
### 8. Add configuration surface
- Add the new setting to the config model and default config output.
- Expose the setting in the GUI/settings surface if MP3 behavior is already user-configurable there.
- Add the new per-playlist setting to the playlist config model and default config output.
- Expose the setting in the playlist configuration UI, not as a global app setting.
- Keep the naming explicit, for example `write_mp3_metadata` or `embed_mp3_metadata`.
## Error Handling Rules
@@ -137,7 +137,7 @@
## Documentation Updates
- Document that MP3 tags are written only for `.mp3` outputs.
- Document the new setting that enables or disables MP3 metadata embedding.
- Document the new per-playlist setting that enables or disables MP3 metadata embedding.
- Document the field fallback rules, especially artist and album behavior.
- Document that album art comes from the video thumbnail, not playlist artwork.
- Document that some YouTube items will not expose album or genre information.
@@ -1,778 +0,0 @@
# YouTube Playlist Sync — Project Conversion Plan
---
# Project Direction
Convert the project from:
```text
Single-purpose YouTube playlist downloader
```
into:
```text
Persistent YouTube playlist synchronization client
```
The application becomes state-driven.
---
# Core Product Goals
## Main Features
- Sync playlists locally
- Download missing items
- Remove deleted playlist items
- Keep exact playlist ordering
- Support audio/video modes
- Multiple playlists
- Background auto-sync
- GUI configuration
- Queue management
- Logs/history
---
# Explicit Non-Goals (Current Scope)
Not planned right now:
- Built-in media playback
- Advanced naming templates
- Drag-and-drop manual ordering
- Private playlist sync
- Channel subscriptions
- Metadata editing
Design the architecture so these can be added later.
---
# Recommended Stack
## Core Language
- Python 3.12+
Reason:
- Native yt-dlp ecosystem
- Easier async/background work
- Better packaging than many expect
- Simpler iteration speed
---
# GUI
## Recommended
- PySide6
Why:
- Modern Qt ecosystem
- Better long-term support
- Cleaner than Tkinter
- Easier dynamic playlist UI
- Good threading support
- Better styling
Avoid:
- Tkinter for this scale
- Electron/Tauri unless you want a web stack
---
# Downloader Backend
## Recommended
- yt-dlp Python API
Install:
```bash
pip install yt-dlp
```
Do NOT:
- scrape YouTube manually
- parse HTML yourself
- depend on external unofficial APIs
Use yt-dlp for:
- metadata extraction
- playlist scanning
- downloading
- postprocessing
Use your own app for:
- sync logic
- ordering
- deletion
- scheduling
- state tracking
---
# Media Processing
## Recommended
- ffmpeg
Needed for:
- audio extraction
- remuxing
- conversions
- thumbnail embedding later
Recommended approach:
- auto-detect ffmpeg
- optionally bundle with packaged app
---
# Database
## Recommended
- SQLite
Reason:
- Zero setup
- Local-first architecture
- Perfect for sync metadata
- Easy migrations
- Reliable
SQLite is extremely important for this project.
Do NOT rely only on:
- filenames
- folders
- JSON
---
# Background Scheduling
## Recommended
- APScheduler
Use for:
- interval syncs
- delayed jobs
- retry jobs
- startup sync
---
# Async/Concurrency
## Recommended
- asyncio
Use for:
- concurrent playlist syncs
- GUI-safe task execution
- download queue
- cancellation
- progress updates
---
# Logging
## Recommended
- loguru
or:
- standard logging module
Need:
- rotating logs
- GUI log panel
- error history
- debug support
---
# Packaging
## Recommended
### During development
```text
venv + pip
```
### Release builds
Choose one:
| Tool | Notes |
| ----------- | ------------------------------- |
| Nuitka | Best performance and protection |
| PyInstaller | Easier and common |
Nuitka is probably best long-term.
---
# Major Architectural Changes
---
# 1. Move to State-Based Sync Architecture
Current downloader logic is likely:
```text
Playlist URL
Download everything
```
Replace with:
```text
Remote Playlist State
Stored Local State
Filesystem State
Diff Engine
Sync Actions
```
This is the single most important change.
---
# 2. Introduce Playlist Metadata Database
Create persistent tracking.
Suggested tables:
## playlists
```sql
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
name TEXT,
url TEXT,
path TEXT,
mode TEXT,
auto_sync INTEGER,
sync_interval_minutes INTEGER,
last_sync TEXT
);
```
## playlist\_items
```sql
CREATE TABLE playlist_items (
playlist_id TEXT,
video_id TEXT,
title TEXT,
playlist_index INTEGER,
local_filename TEXT,
downloaded INTEGER,
last_seen TEXT,
PRIMARY KEY (playlist_id, video_id)
);
```
---
# 3. Implement Playlist Scanner Layer
Create dedicated metadata extraction.
Suggested structure:
```text
core/
├── scanner/
│ └── playlist_scanner.py
```
Responsibilities:
- fetch playlist entries
- extract video IDs
- detect unavailable videos
- return normalized playlist state
Use:
```python
extract_info(download=False)
```
This allows playlist scanning without downloading.
---
# 4. Create Diff Engine
The app should compare:
```text
Remote playlist
vs
Database state
vs
Filesystem state
```
Output actions:
```text
DOWNLOAD
DELETE
RENAME
REORDER
SKIP
REPAIR
```
Suggested file:
```text
core/sync/diff_engine.py
```
This becomes the heart of the application.
---
# 5. Add Download Queue System
Multi-playlist sync requires a queue.
Suggested states:
```text
Queued
Downloading
Converting
Completed
Failed
Skipped
Cancelled
```
Suggested structure:
```text
core/download/
├── queue_manager.py
├── downloader.py
└── workers.py
```
---
# 6. Implement Stable File Naming
Recommended naming:
```text
0001 - Title.ext
```
Benefits:
- native filesystem sorting
- easy reorder support
- easy repairs
- user friendly
Use:
```python
%(playlist_index)04d - %(title)s.%(ext)s
```
through yt-dlp.
---
# 7. Implement Safe Reordering
Playlist ordering changes frequently.
Never rename directly.
Use:
```text
Temporary rename pass
Final rename pass
```
Example:
```text
0001.mp3 → temp_a
0002.mp3 → temp_b
temp_a → 0002.mp3
```
Avoid collisions.
---
# 8. Implement Deletion Strategy
Recommended:
Instead of immediate delete:
```text
playlist/.recycle/
```
Move removed files there.
Benefits:
- safer
- recoverable
- easier debugging
Optional:
- auto-clean after X days
---
# 9. Redesign GUI Around Playlists
Current downloader GUIs are usually task-oriented.
You should move to:
```text
Playlist-oriented UI
```
Recommended sections:
```text
Sidebar
├── Playlists
├── Queue
├── History
├── Logs
└── Settings
```
---
# 10. Support Infinite Playlist Entries
Use dynamic UI generation.
Example:
```python
class PlaylistConfig:
url: str
path: str
mode: str
auto_sync: bool
```
GUI should render from:
```python
list[PlaylistConfig]
```
Do NOT hardcode playlist pages.
---
# 11. Add Background Sync
Start simple.
## Phase 1
- Timer-based sync
- Tray icon
- Run minimized
## Phase 2
- Background daemon/service
- Headless mode
- Autostart support
---
# 12. Add Progress/Event System
Needed for GUI responsiveness.
Recommended:
```text
event_bus.py
```
Events:
```text
DownloadStarted
DownloadProgress
SyncStarted
SyncFinished
FileDeleted
PlaylistUpdated
```
This decouples GUI from backend.
---
# 13. Introduce Config Management
Recommended:
```text
config.json
```
Only for:
- app settings
- UI preferences
- non-relational settings
Do NOT store sync state in JSON.
---
# Suggested Folder Structure
```text
app/
├── core/
│ ├── scanner/
│ ├── sync/
│ ├── download/
│ ├── database/
│ ├── scheduler/
│ └── events/
├── gui/
│ ├── pages/
│ ├── widgets/
│ ├── dialogs/
│ └── models/
├── config/
├── logs/
├── data/
└── main.py
```
---
# Suggested Sync Flow
```text
Load playlists
Scheduler triggers sync
Scanner fetches remote playlist
Database state loaded
Filesystem scanned
Diff engine computes actions
Queue downloads
Reorder files
Move removed files
Update database
Emit GUI events
```
---
# Recommended MVP Conversion Order
## Phase 1 — Backend Foundation
Implement:
- SQLite
- playlist scanner
- diff engine
- download wrapper
- basic sync logic
No GUI redesign yet.
---
# Phase 2 — Stable Syncing
Implement:
- deletion handling
- reorder handling
- queue system
- retry system
- logs
---
# Phase 3 — GUI Rewrite
Implement:
- playlist manager UI
- queue page
- logs page
- settings page
- dynamic playlists
---
# Phase 4 — Automation
Implement:
- background sync
- tray mode
- startup sync
- periodic sync
---
# Important Recommendations
## Recommendation 1
Treat:
```text
video_id
```
as the canonical identity.
Never titles.
---
# Recommendation 2
Do NOT rely on yt-dlp archive files alone.
Your own DB should be the source of truth.
---
# Recommendation 3
Keep download logic isolated.
yt-dlp should be replaceable internally.
---
# Recommendation 4
Do not overcomplicate the GUI early.
Focus on sync correctness first.
Sync reliability matters more than appearance.
---
# Recommendation 5
Design everything around interruption recovery.
The app should survive:
- crashes
- partial downloads
- force closes
- network failures
- playlist changes mid-sync
---
# Recommendation 6
Keep the application local-first.
No account system. No cloud backend. No telemetry.
That becomes a strong project identity.
---
# Final Recommended Identity
Instead of:
```text
YouTube Downloader GUI
```
Position the project as:
```text
Local-first YouTube playlist synchronization client.
```
That identity is:
- clearer
- more unique
- technically stronger
- easier to expand later
+1 -1
View File
@@ -12,7 +12,7 @@ license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10"
dependencies = [
"yt-dlp>=2026.3.17",
"yt-dlp>=2026.6.9",
"PySide6_Essentials>=6.11.1",
]
[project.optional-dependencies]
+45 -41
View File
@@ -1,18 +1,12 @@
from __future__ import annotations
import argparse
import asyncio
import logging
from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.service import SyncService
from .core.sync.executor import ActionExecutor
from .core.events.event_bus import EventBus
import re
from .core.utils.yt import extract_playlist_id
from .core.utils.deps import DependencyError
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch
from .core.utils.logging_setup import configure_logging
@@ -28,11 +22,8 @@ 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()
executor = ActionExecutor(db, event_bus=bus)
settings, db, service, executor = build_sync_stack(args.db, event_bus=bus)
seen_errors: set[str] = set()
@@ -73,39 +64,52 @@ def main(argv: list[str] | None = None) -> int:
bus.subscribe("RenameApplied", on_rename)
bus.subscribe("FileRecycled", on_recycle)
playlists = settings.playlists
selected_playlists = settings.playlists
if args.playlist is not None:
playlists = [playlists[args.playlist]] if 0 <= args.playlist < len(playlists) else []
selected_playlists = [selected_playlists[args.playlist]] if 0 <= args.playlist < len(selected_playlists) else []
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")
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_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)
return 0
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)
if "yt_dlp" in msg or "yt-dlp" in msg:
print("yt-dlp Python package is required. Install with: pip install -U yt-dlp")
else:
print(f"ERROR: {exc}")
return False
def on_dependency_error(pl: dict, exc: Exception) -> bool:
del pl
print(f"ERROR: {exc}")
log.error("dependency error: %s", exc)
return False
return run_sync_batch(
selected_playlists,
db=db,
service=service,
executor=executor,
apply=bool(args.apply),
on_plan=on_plan,
on_no_actions=on_no_actions,
on_applied=on_applied,
on_import_error=on_import_error,
on_dependency_error=on_dependency_error,
)
if __name__ == "__main__":
+69 -10
View File
@@ -24,11 +24,69 @@ 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:
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()
"""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:
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)
# Ensure there is always a config file at the default path.
@@ -38,13 +96,13 @@ class Settings:
self._load_from_path(self.path)
def _load_from_path(self, path: Path) -> None:
try:
self.data.update(json.loads(path.read_text(encoding="utf-8")))
except Exception:
# Leave defaults if invalid JSON; validation can be added later.
pass
"""Load and merge config from file."""
loaded = load_config(path)
if loaded:
self.data.update(normalize_config(loaded))
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] = {
"playlists": [
@@ -57,10 +115,11 @@ class Settings:
],
"ffmpeg_path": _default_ffmpeg_path(),
}
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
save_config(path, default_payload)
@property
def playlists(self) -> List[Dict[str, Any]]:
"""Get playlists with global defaults merged in."""
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"]),
+7 -7
View File
@@ -21,19 +21,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
mode: str = "audio" # audio|video
state: JobState = JobState.QUEUED
error: Optional[str] = None
ffmpeg_path: Optional[str] = None
max_download_quality: Optional[str] = None
playlist_id: Optional[str] = None
progress_callback: Optional[Callable[[dict[str, Any]], None]] = None
cancel_check: Optional[Callable[[], bool]] = None
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
keep_video: bool = True
# Status fields (mutable during execution)
state: JobState = JobState.QUEUED
error: Optional[str] = None
class QueueManager:
@@ -52,7 +52,7 @@ class QueueManager:
async def start(self, worker_coro):
"""Start the worker tasks that drain the queue."""
async def runner(idx: int):
async def runner():
while not self._stopped.is_set():
job = await self._queue.get()
try:
@@ -60,7 +60,7 @@ class QueueManager:
finally:
self._queue.task_done()
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
self._workers = [asyncio.create_task(runner()) for _ in range(self._concurrency)]
async def stop(self):
"""Cancel all worker tasks and mark the queue as stopped."""
-5
View File
@@ -49,8 +49,3 @@ class SyncAction:
from_name: Optional[str] = None
to_name: Optional[str] = None
@dataclass(frozen=True)
class FilesystemEntry:
name: str
path: Path
-3
View File
@@ -16,9 +16,6 @@ class PlaylistScanner:
still start in environments where yt-dlp is unavailable.
"""
def __init__(self) -> None:
pass
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
"""Return the current remote playlist entries as `PlaylistItem` records."""
try:
-62
View File
@@ -1,62 +0,0 @@
from __future__ import annotations
from typing import Iterable, List, 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
+4 -5
View File
@@ -3,15 +3,14 @@ from __future__ import annotations
from pathlib import Path
from typing import List, Sequence
from ..models import FilesystemEntry
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
def list_files(root: Path, extensions: Sequence[str]) -> List[Path]:
"""List all files in root directory with given extensions."""
exts = {e.lower() for e in extensions}
results: List[FilesystemEntry] = []
results: List[Path] = []
if not root.exists():
return results
for p in root.glob("**/*"):
if p.is_file() and p.suffix.lower() in exts:
results.append(FilesystemEntry(name=p.name, path=p))
results.append(p)
return results
+73
View File
@@ -0,0 +1,73 @@
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
+58 -7
View File
@@ -1,12 +1,11 @@
from __future__ import annotations
from pathlib import Path
from typing import List
from typing import Iterable, List, Mapping, Sequence
from ..database.db import Database
from ..models import PlaylistItem, SyncAction
from ..models import PlaylistItem, SyncAction, SyncActionType
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
@@ -16,14 +15,13 @@ 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 asks the diff engine to
compare the remote state with the local filesystem.
playlist and item metadata in the database, and compares the remote state
with the local filesystem to produce sync actions.
"""
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":
@@ -34,6 +32,59 @@ 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]:
"""Return the sync actions required to bring one playlist in sync.
@@ -128,7 +179,7 @@ class SyncService:
fs = list_files(save_path / "video", [".mp4"])
else:
fs = list_files(save_path, [ext])
actions = self.diff.compute_actions(augmented, db_index, fs, ext)
actions = self._compute_actions(augmented, db_index, fs, ext)
merged_actions.extend(actions)
return merged_actions
+38
View File
@@ -0,0 +1,38 @@
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()
-37
View File
@@ -1,37 +0,0 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
@dataclass(frozen=True)
class AppConfig:
data: Dict[str, Any]
path: Path
def load_config(path: Path) -> AppConfig:
raw = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(raw, dict):
raise ValueError("config root must be a JSON object")
return AppConfig(data=raw, path=path)
def save_config(path: Path, data: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
path.write_text(payload, encoding="utf-8")
def normalize_config(data: Dict[str, Any]) -> Dict[str, Any]:
"""
Ensure basic expected shape for config dict.
Keeps unknown keys intact.
"""
out = dict(data)
pls = out.get("playlists")
if not isinstance(pls, list):
out["playlists"] = []
return out
+16 -22
View File
@@ -5,11 +5,10 @@ import threading
from PySide6 import QtCore, QtGui, QtWidgets
from ..config.settings import Settings
from ..config.settings import Settings, get_tray_config, load_config
from ..core.events.event_bus import EventBus
from .bus_bridge import BusBridge
from .app_icon import load_app_icon
from .config_store import load_config
from .runner import SyncRequest, SyncRunner
from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
@@ -132,12 +131,8 @@ class MainWindow(QtWidgets.QMainWindow):
cfg_path = getattr(self._settings, "path", None)
if cfg_path is None:
return {}
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)
raw = load_config(cfg_path)
return get_tray_config(raw)
except Exception:
return {}
@@ -247,21 +242,20 @@ class MainWindow(QtWidgets.QMainWindow):
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
@QtCore.Slot(str, dict)
def _on_bus_event(self, name: str, payload: dict) -> None:
# Fan out to interested pages.
try:
self._queue_page.on_event(name, payload)
except Exception:
pass
try:
self._logs_page.on_event(name, payload)
except Exception:
pass
try:
self._playlists_page.on_event(name, payload)
except Exception:
pass
# Fan out to any page that exposes on_event().
self._emit_page_event(name, payload)
# Auto-pause on YouTube bot-check/rate-limit surface.
if name == "SyncPaused":
@@ -307,7 +301,7 @@ class MainWindow(QtWidgets.QMainWindow):
@QtCore.Slot(bool, str)
def _on_sync_finished(self, ok: bool, message: str) -> None:
if not ok:
self._logs_page.on_event("SyncError", {"error": message})
self._emit_page_event("SyncError", {"error": message})
self._playlists_page.set_running(False)
# Mark idle so "Sync all" can be started again.
+2 -2
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from PySide6 import QtWidgets
from PySide6 import QtGui, QtWidgets
from ..smooth_scroll import enable_smooth_scrolling
@@ -41,4 +41,4 @@ class LogsPage(QtWidgets.QWidget):
except Exception:
line = f"{name}: {payload}"
self._text.appendPlainText(line)
self._text.moveCursor(self._text.textCursor().End)
self._text.moveCursor(QtGui.QTextCursor.MoveOperation.End)
+58 -79
View File
@@ -6,11 +6,11 @@ from pathlib import Path
from PySide6 import QtCore, QtGui, QtWidgets
from ...config.settings import Settings
from ...config.settings import Settings, load_config, normalize_config, save_config
from ...core.database.db import Database
from ...core.utils.yt import extract_playlist_id
from ..autosave import DebouncedAutosave
from ..smooth_scroll import enable_smooth_scrolling
from ..config_store import load_config, normalize_config, save_config
@dataclass(frozen=True)
@@ -41,11 +41,7 @@ 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._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)
self._autosave = DebouncedAutosave(self, self._autosave_now)
header = QtWidgets.QLabel("Playlists")
header.setObjectName("pageTitle")
@@ -130,18 +126,16 @@ class PlaylistManagerPage(QtWidgets.QWidget):
@QtCore.Slot()
def reload_from_config(self) -> None:
try:
self._suppress_autosave = True
self._settings = Settings()
self._config_path = getattr(self._settings, "path", None)
if self._config_path is None:
raise RuntimeError("Config path not available")
self._config = normalize_config(load_config(self._config_path).data)
rows = self._rows_from_settings()
with self._autosave.suppressed():
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))
rows = self._rows_from_settings()
except Exception as exc:
self._status.setText(f"Failed to load config: {exc}")
return
finally:
self._suppress_autosave = False
# Optional DB metadata (last_sync). If DB is missing/corrupt, keep UI usable.
last_sync_by_id: dict[str, str] = {}
@@ -178,6 +172,19 @@ 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)
@@ -187,11 +194,8 @@ 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 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)
for card in self._iter_cards():
card.set_editing_enabled(not running)
@QtCore.Slot()
def _add_playlist(self) -> None:
@@ -233,13 +237,8 @@ class PlaylistManagerPage(QtWidgets.QWidget):
def _table_to_playlists(self) -> list[dict[str, Any]]:
playlists: list[dict[str, Any]] = []
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if not isinstance(w, _PlaylistCard):
continue
pl = w.to_dict()
playlists.append(pl)
for card in self._iter_cards():
playlists.append(card.to_dict())
return playlists
@QtCore.Slot()
@@ -260,40 +259,28 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._status.setText(f"Failed to save config: {exc}")
def _reindex_cards(self) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
w.set_index(i)
for i, card in enumerate(self._iter_cards()):
card.set_index(i)
def _validate_all(self, *, show_status: bool) -> bool:
ok = True
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
errs = w.validate()
w.set_status("; ".join(errs) if errs else "")
if errs:
ok = False
for card in self._iter_cards():
errs = card.validate()
card.set_status("; ".join(errs) if errs else "")
if errs:
ok = False
if not ok and show_status:
self._status.setText("Fix invalid playlists before saving/syncing.")
return ok
@QtCore.Slot()
def _schedule_autosave(self) -> None:
if self._suppress_autosave:
return
if not self.isEnabled():
return
self._autosave_timer.start()
self._autosave.schedule(enabled=self.isEnabled())
@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
@@ -310,22 +297,25 @@ class PlaylistManagerPage(QtWidgets.QWidget):
pid = payload.get("playlist_id")
total = payload.get("actions_total")
self._sync_state.setText(f"Sync started: {pid} ({total} actions)")
self._set_card_status(str(pid or ""), "running")
self._set_active_card(str(pid or ""), running=True, paused=False)
playlist_id = str(pid or "")
self._set_card_status(playlist_id, "running")
self._set_active_card(playlist_id, running=True, paused=False)
elif name == "SyncSummary":
pid = payload.get("playlist_id")
dur = payload.get("duration_s")
counts = payload.get("counts")
self._sync_state.setText(f"Sync summary: {pid} in {dur}s counts={counts}")
self._set_card_status(str(pid or ""), f"done in {dur}s")
playlist_id = str(pid or "")
self._set_card_status(playlist_id, f"done in {dur}s")
ls = payload.get("last_sync")
if ls:
self._set_card_last_sync(str(pid or ""), str(ls))
self._set_card_last_sync(playlist_id, str(ls))
elif name == "SyncFinished":
pid = payload.get("playlist_id")
self._sync_state.setText(f"Sync finished: {pid}")
self._set_card_status(str(pid or ""), "finished")
self._set_active_card(str(pid or ""), running=False, paused=False)
playlist_id = str(pid or "")
self._set_card_status(playlist_id, "finished")
self._set_active_card(playlist_id, running=False, paused=False)
self.set_running(False)
elif name == "SyncError":
self._sync_state.setText(f"Sync error: {payload.get('error')}")
@@ -382,37 +372,26 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._set_active_card(pid, running=True, paused=True)
def _set_card_progress(self, playlist_id: str, progress: float) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
w.set_progress(progress)
card = self._card_for_playlist_id(playlist_id)
if card is not None:
card.set_progress(progress)
def _set_card_status(self, playlist_id: str, text: str) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard):
if w.playlist_id() == playlist_id:
w.set_status(text)
card = self._card_for_playlist_id(playlist_id)
if card is not None:
card.set_status(text)
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
w.set_last_sync(last_sync)
card = self._card_for_playlist_id(playlist_id)
if card is not None:
card.set_last_sync(last_sync)
def _set_active_card(self, playlist_id: str, *, running: bool, paused: bool) -> None:
for i in range(self._list.count()):
item = self._list.item(i)
w = self._list.itemWidget(item)
if not isinstance(w, _PlaylistCard):
continue
is_active = w.playlist_id() == playlist_id
w.set_active(is_active and running)
if is_active:
w.set_paused(paused)
card = self._card_for_playlist_id(playlist_id)
if card is None:
return
card.set_active(running)
card.set_paused(paused)
class _PlaylistCard(QtWidgets.QFrame):
+28 -9
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from pathlib import Path
from PySide6 import QtCore, QtWidgets
from ..smooth_scroll import enable_smooth_scrolling
@@ -109,6 +111,22 @@ 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:
@@ -131,12 +149,13 @@ 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")
tgt = payload.get("target") or payload.get("filename") or ""
if tgt:
target_item.setText(str(tgt))
target_text = self._target_text(payload, target_text)
if target_text:
target_item.setText(target_text)
elif name == "DownloadProgress":
status_item.setText(str(payload.get("status") or "downloading"))
prog = payload.get("progress")
@@ -155,14 +174,14 @@ class QueuePage(QtWidgets.QWidget):
et = payload.get("eta")
if isinstance(et, (int, float)) and et >= 0:
eta_item.setText(f"{int(et)}s")
fn = payload.get("filename")
if fn:
target_item.setText(str(fn))
target_text = self._target_text(payload, target_text)
if target_text:
target_item.setText(target_text)
elif name == "DownloadCompleted":
status_item.setText("completed")
tgt = payload.get("target") or ""
if tgt:
target_item.setText(str(tgt))
target_text = self._target_text(payload, target_text)
if target_text:
target_item.setText(target_text)
bar = self._table.cellWidget(row, 3)
if bar is None:
bar = QtWidgets.QProgressBar()
+17 -32
View File
@@ -5,7 +5,8 @@ from typing import Any
from PySide6 import QtCore, QtWidgets
from ..config_store import load_config, save_config
from ...config.settings import ensure_tray_config, get_tray_config, load_config, save_config
from ..autosave import DebouncedAutosave
class SettingsPage(QtWidgets.QWidget):
@@ -81,11 +82,7 @@ class SettingsPage(QtWidgets.QWidget):
self._status.setWordWrap(True)
layout.addWidget(self._status)
self._suppress_autosave = False
self._autosave_timer = QtCore.QTimer(self)
self._autosave_timer.setSingleShot(True)
self._autosave_timer.setInterval(600)
self._autosave_timer.timeout.connect(self.save_to_config)
self._autosave = DebouncedAutosave(self, self.save_to_config)
# Autosave on focus-out / change.
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
@@ -107,34 +104,27 @@ class SettingsPage(QtWidgets.QWidget):
self._status.setText("No config loaded yet.")
return
try:
self._suppress_autosave = True
cfg = load_config(self._config_path)
self._config = dict(cfg.data)
with self._autosave.suppressed():
cfg = load_config(self._config_path)
self._config = dict(cfg)
self._ffmpeg_path.setText(str(self._config.get("ffmpeg_path") or ""))
self._max_parallel.setValue(int(self._config.get("max_parallel_downloads") or 2))
self._retry_max.setValue(int(self._config.get("retry_max_retries") or 2))
self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5))
self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
self._ffmpeg_path.setText(str(self._config.get("ffmpeg_path") or ""))
self._max_parallel.setValue(int(self._config.get("max_parallel_downloads") or 2))
self._retry_max.setValue(int(self._config.get("retry_max_retries") or 2))
self._retry_delay.setValue(float(self._config.get("retry_delay_seconds") or 1.5))
self._download_delay.setValue(float(self._config.get("delay_between_downloads_seconds") or 0.0))
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)))
tray = get_tray_config(self._config)
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:
self._status.setText(f"Failed to load settings: {exc}")
finally:
self._suppress_autosave = False
def _schedule_autosave(self) -> None:
if self._suppress_autosave:
return
self._autosave_timer.start()
self._autosave.schedule()
@QtCore.Slot()
def save_to_config(self) -> None:
@@ -149,15 +139,10 @@ class SettingsPage(QtWidgets.QWidget):
data["retry_delay_seconds"] = float(self._retry_delay.value())
data["delay_between_downloads_seconds"] = float(self._download_delay.value())
ui = data.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
tray = ensure_tray_config(data)
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}.")
+36 -38
View File
@@ -7,50 +7,48 @@ Future iterations will wire up scheduler and a GUI.
from __future__ import annotations
import asyncio
from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.service import SyncService
from .core.sync.executor import ActionExecutor
from .core.utils.yt import extract_playlist_id
from .core.utils.deps import DependencyError
from .core.sync.runner import build_sync_stack, format_action_summary, run_sync_batch
def bootstrap(db_path: Path | None = None) -> None:
settings = Settings()
db = Database((db_path or Path("db/app.db")).resolve())
service = SyncService(db)
executor = ActionExecutor(db)
settings, db, service, executor = build_sync_stack(db_path)
# 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')}")
# 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.")
else:
print(f"No actions needed for: {pl.get('url')}")
except Exception as exc: # keep bootstrap resilient during early dev
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
def on_plan(pl: dict, playlist_id: str, actions, counts: dict[str, int]) -> None:
del playlist_id
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
print("Applied actions.")
def on_import_error(pl: dict, exc: Exception) -> bool:
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__":