mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-03 04:23:59 +03:00
Compare commits
25 Commits
e7f1dbc1f7
...
235d18ada6
| Author | SHA1 | Date | |
|---|---|---|---|
| 235d18ada6 | |||
| c7ab6d2657 | |||
| aeaf687e92 | |||
| 903389d73c | |||
| 1bb278f3fc | |||
| 9115f207dc | |||
| f32175d963 | |||
| 1628f3fc8a | |||
| 9c9dd283a6 | |||
| f88eaf70a7 | |||
| 49d8dcf012 | |||
| b8fb86902e | |||
| d232137e17 | |||
| 00e3f84f35 | |||
| 262f9a556f | |||
| 7472eaccc7 | |||
| 0436c0b85d | |||
| decc4c675d | |||
| 5649fc17dd | |||
| 8550203411 | |||
| 17c2df3640 | |||
| 7a5db21f47 | |||
| 8cd0c91f29 | |||
| f5f5b710c1 | |||
| 62678cf39e |
@@ -73,12 +73,7 @@ jobs:
|
||||
Expand-Archive "$WORKSPACE/dist/windows/ffmpeg.zip" -DestinationPath "$WORKSPACE/dist/windows/ffmpeg_temp"
|
||||
$ffmpegExe = Get-ChildItem -Path "$WORKSPACE/dist/windows/ffmpeg_temp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1
|
||||
Move-Item $ffmpegExe.FullName "$WORKSPACE/dist/windows/bin/ffmpeg.exe"
|
||||
|
||||
# aria2c (Windows Portable)
|
||||
Invoke-WebRequest -Uri "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip" -OutFile "$WORKSPACE/dist/windows/aria2c.zip"
|
||||
Expand-Archive "$WORKSPACE/dist/windows/aria2c.zip" -DestinationPath "$WORKSPACE/dist/windows/aria2_temp"
|
||||
Move-Item "$WORKSPACE/dist/windows/aria2_temp/aria2-1.37.0-win-64bit-build1/aria2c.exe" "$WORKSPACE/dist/windows/bin/aria2c.exe"
|
||||
|
||||
|
||||
# Build .exe using PyInstaller
|
||||
# Use --add-data to include the src folder so internal imports (like 'import cli') work
|
||||
pip install pyinstaller
|
||||
@@ -109,43 +104,6 @@ jobs:
|
||||
mv "$GITHUB_WORKSPACE/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/"
|
||||
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
|
||||
|
||||
- name: Cache aria2c binary (Linux)
|
||||
if: matrix.platform == 'linux'
|
||||
id: aria2-cache
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ github.workspace }}/dist/linux/bin/aria2c
|
||||
key: aria2c-linux-1.37.0
|
||||
|
||||
- name: Build aria2c (Linux)
|
||||
if: matrix.platform == 'linux' && steps.aria2-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
set -e
|
||||
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev zlib1g-dev wget
|
||||
|
||||
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
|
||||
wget https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.gz
|
||||
tar -xzf aria2-1.37.0.tar.gz
|
||||
cd aria2-1.37.0
|
||||
|
||||
CFLAGS="-Os -s" LDFLAGS="-static" ./configure \
|
||||
--enable-static --disable-shared \
|
||||
--disable-libaria2 --without-ca-bundle \
|
||||
--without-libnettle --without-libgcrypt \
|
||||
--without-libssh2 --without-libexpat \
|
||||
--without-libxml2 --without-libsqlite3 \
|
||||
--with-openssl
|
||||
|
||||
make -j"$(nproc)"
|
||||
strip src/aria2c
|
||||
cp src/aria2c "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
|
||||
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
|
||||
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
rm -rf aria2-1.37.0 aria2-1.37.0.tar.gz
|
||||
|
||||
- name: Archive Linux Package
|
||||
if: matrix.platform == 'linux'
|
||||
run: |
|
||||
@@ -161,67 +119,9 @@ jobs:
|
||||
${{ github.workspace }}/*.zip
|
||||
${{ github.workspace }}/*.tar.gz
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.tag || inputs.tag }}"
|
||||
VERSION="${TAG#v}"
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Linux Artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: linux-release
|
||||
path: ${{ github.workspace }}/dist/linux-docker
|
||||
|
||||
- name: Prepare Docker build context
|
||||
run: |
|
||||
cd "$GITHUB_WORKSPACE/dist/linux-docker"
|
||||
# Extract Linux artifact which now contains bin/, src/, and yt-playlist-main.py
|
||||
tar -xzf "$GITHUB_WORKSPACE/dist/linux-docker/yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz"
|
||||
|
||||
# Copy Docker-specific files
|
||||
cp "$GITHUB_WORKSPACE/Dockerfile" .
|
||||
cp "$GITHUB_WORKSPACE/docker-entrypoint.sh" .
|
||||
|
||||
# Ensure config directory is present (not included in Linux zip by default)
|
||||
if [ ! -d "config" ]; then cp -r "$GITHUB_WORKSPACE/config" . ; fi
|
||||
|
||||
# Debug: List context to verify paths match Dockerfile expectations
|
||||
ls -R
|
||||
|
||||
- name: Set docker image names
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:${VERSION}" >> $GITHUB_ENV
|
||||
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:latest" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build . -t $RELEASE_IMAGE -t $LATEST_IMAGE
|
||||
working-directory: ${{ github.workspace }}/dist/linux-docker
|
||||
|
||||
- name: Save Docker images
|
||||
run: |
|
||||
docker save -o docker-image.tar $RELEASE_IMAGE
|
||||
docker save -o docker-image-latest.tar $LATEST_IMAGE
|
||||
working-directory: ${{ github.workspace }}/dist/linux-docker
|
||||
|
||||
- name: Upload Docker artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: docker-images
|
||||
path: ${{ github.workspace }}/dist/linux-docker/docker-image*.tar
|
||||
|
||||
release:
|
||||
needs: [build, docker]
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.inputs.tag, 'v')
|
||||
steps:
|
||||
@@ -248,9 +148,9 @@ jobs:
|
||||
- name: Push Docker images
|
||||
run: |
|
||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:${{ steps.version.outputs.version }}
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytpl-Sync:${{ steps.version.outputs.version }}
|
||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:latest
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytpl-Sync:latest
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
@@ -2,10 +2,7 @@ name: Integration tests (minimal)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
#push:
|
||||
#branches: [ main, Next ]
|
||||
#pull_request:
|
||||
#branches: [ main, Next ]
|
||||
|
||||
|
||||
jobs:
|
||||
integration:
|
||||
@@ -13,34 +10,28 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
INTEGRATION_TEST: '1'
|
||||
# Used by the integration tests to locate ffmpeg reliably.
|
||||
FFMPEG_PATH: '/usr/bin/ffmpeg'
|
||||
TEST_PLAYLIST_URL: 'https://www.youtube.com/playlist?list=PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz'
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Make bundled linux binaries executable (if present)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -d ./bin/linux ]; then
|
||||
chmod +x ./bin/linux/* || true
|
||||
ls -l ./bin/linux || true
|
||||
fi
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Create venv and install project
|
||||
- name: Install ffmpeg
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
# Install project in editable mode. If the 'test' extra exists, prefer it.
|
||||
python -m pip install -e .[test] || python -m pip install -e .
|
||||
python -m pip install pytest
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg
|
||||
|
||||
- name: Run integration script directly
|
||||
env:
|
||||
YTPL_DEBUG: '1'
|
||||
- name: Run integration tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python tests/integration_full_workflow_test.py
|
||||
python -m pip install -e ".[test]"
|
||||
pytest -m integration
|
||||
|
||||
@@ -3,9 +3,11 @@ name: Unit tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches:
|
||||
- main
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -22,20 +24,20 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Create venv and install project
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Install project
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
# Install project (editable) and test deps
|
||||
python -m pip install -e .[test] || python -m pip install -e .
|
||||
python -m pip install pytest
|
||||
python -m pip install -e ".[test]"
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
pytest -q
|
||||
|
||||
@@ -34,6 +34,9 @@ Create/edit `config/yt-playlist-config.json`:
|
||||
```json
|
||||
{
|
||||
"ffmpeg_path": "./bin/ffmpeg.exe",
|
||||
"max_parallel_downloads": 2,
|
||||
"retry_max_retries": 2,
|
||||
"retry_delay_seconds": 1.5,
|
||||
"playlists": [
|
||||
{
|
||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||
@@ -46,10 +49,14 @@ Create/edit `config/yt-playlist-config.json`:
|
||||
```
|
||||
|
||||
Defaults:
|
||||
|
||||
- `ffmpeg_path`: `./bin/ffmpeg.exe` (Windows) or `./bin/ffmpeg` (Linux)
|
||||
- `download_mode`: `video`
|
||||
- `max_download_quality`: `1080p`
|
||||
- `save_path`: `./downloads`
|
||||
- `max_parallel_downloads`: `2`
|
||||
- `retry_max_retries`: `2`
|
||||
- `retry_delay_seconds`: `1.5`
|
||||
|
||||
`max_download_quality`:
|
||||
|
||||
@@ -62,24 +69,30 @@ Defaults:
|
||||
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
|
||||
- `both`: download muxed `.mp4`, 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.
|
||||
|
||||
## Run
|
||||
|
||||
- Compute-only:
|
||||
|
||||
```bash
|
||||
python -m src.app.cli
|
||||
python -m app.cli
|
||||
```
|
||||
|
||||
- Apply actions:
|
||||
|
||||
```bash
|
||||
python -m src.app.cli --apply
|
||||
python -m app.cli --apply
|
||||
```
|
||||
|
||||
- Single playlist (0-based index):
|
||||
|
||||
```bash
|
||||
python -m src.app.cli --apply --playlist 0
|
||||
python -m app.cli --apply --playlist 0
|
||||
```
|
||||
|
||||
## Data & Layout
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"ffmpeg_path": "./bin/ffmpeg.exe",
|
||||
"max_parallel_downloads": 2,
|
||||
"retry_max_retries": 2,
|
||||
"retry_delay_seconds": 1.5,
|
||||
"playlists": [
|
||||
{
|
||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
|
||||
|
||||
+3
-17
@@ -3,8 +3,6 @@
|
||||
## Python-first Desktop Architecture
|
||||
|
||||
- **Primary GUI framework**: `PySide6` (Qt for Python).
|
||||
- **Communication Layer**: A local `FastAPI` backend to separate core logic from the UI.
|
||||
- **IPC Mechanism**: The GUI spawns the FastAPI server on a random high port (binding to `127.0.0.1` ONLY) and communicates via REST/WebSockets.
|
||||
|
||||
## Core Features to Implement
|
||||
|
||||
@@ -12,27 +10,15 @@
|
||||
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, aria2c), max parallel jobs, and Docker detection toggle.
|
||||
5. **Settings Panel**: Global settings for binary paths (ffmpeg), max parallel jobs, and Docker detection toggle.
|
||||
|
||||
## Phase 1 Roadmap: "The Bridge"
|
||||
|
||||
- [ ] **Refactor `src/manager.py`**: Convert CLI-first execution to async-compatible methods for FastAPI consumption.
|
||||
- [ ] **FastAPI Integration**: Create endpoints for `/playlists`, `/status`, and `/download/start`.
|
||||
- [ ] **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. The GUI should spawn the local API process (background subprocess) on startup.
|
||||
- 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.
|
||||
- Security: bind the local API to `localhost` only, use a short-lived token or IPC for authentication between GUI and backend, and avoid exposing unnecessary ports.
|
||||
|
||||
## Roadmap (GUI → Web → Mobile)
|
||||
|
||||
1. Desktop prototype: `FastAPI` backend + `PySide6` GUI (thin client) with basic playlist add/update/download controls and status streaming.
|
||||
2. Packaging: create Windows exe/installer and Linux AppImage for the prototype.
|
||||
3. Web frontend: build a web SPA that consumes the same backend API (hosted or local) — this reuses business logic with minimal change.
|
||||
4. Android: either a native app or cross-platform UI (Flutter/React Native) that calls the backend API; alternatively host the backend and make a thin mobile client.
|
||||
|
||||
If you want, I can now: scaffold a minimal `FastAPI` backend and `PySide6` desktop starter in this repo, or produce concise packaging steps for Windows and Linux. Which do you prefer?
|
||||
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Subject Area
|
||||
|
||||
- Tool for downloading and synchronizing YouTube playlists.
|
||||
- Tool for downloading and synchronizing local YouTube playlists.
|
||||
- Focuses on batch downloading, format selection (audio and/or video), configurable quality and keeping local copies synced with playlist changes.
|
||||
- Targets power users and archivists who need large-scale, repeatable playlist archiving and ongoing synchronization, with GUI interface.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
## Users Definition
|
||||
|
||||
Individuals who need to download a large number of videos or audio files from a YouTube playlist and keep it updated
|
||||
Individuals who need to have a local youtube playlist synced with a large number of videos or audio files
|
||||
|
||||
## Functionality Definition
|
||||
|
||||
@@ -38,13 +38,10 @@ Individuals who need to download a large number of videos or audio files from a
|
||||
## Platforms
|
||||
|
||||
- Desktop: Windows (Primary), Linux
|
||||
- Docker
|
||||
- Possible Future: Web App, Android App (via shared FastAPI backend)
|
||||
|
||||
## Architecture & Languages
|
||||
|
||||
- Core Engine: Python (yt-dlp wrapper)
|
||||
- Backend API: FastAPI (Local localhost-only boundary)
|
||||
- Core Engine: Python (yt-dlp)
|
||||
- Desktop Frontend: PySide6 (Qt for Python)
|
||||
- Distribution: PyInstaller / Briefcase (Windows .exe, Linux AppImage)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
You need to separate playlist sync state from download attempts.
|
||||
|
||||
The goal should not be “all 400 downloaded in one run”. It should be:
|
||||
|
||||
All downloadable items eventually reach a final state. Temporary failures are retried later. Permanent failures are recorded clearly.
|
||||
|
||||
Use statuses like:
|
||||
|
||||
queued
|
||||
downloading
|
||||
completed
|
||||
|
||||
temporary_failed
|
||||
rate_limited
|
||||
verification_required
|
||||
|
||||
unavailable
|
||||
private
|
||||
geo_blocked
|
||||
copyright_blocked
|
||||
age_restricted
|
||||
unsupported
|
||||
failed_permanent
|
||||
|
||||
skipped_by_user
|
||||
removed_from_playlist
|
||||
|
||||
Main logic:
|
||||
|
||||
1. Fetch playlist metadata
|
||||
2. Create/update queue items for all playlist videos
|
||||
3. Download available queued items
|
||||
4. On normal temporary errors: retry later
|
||||
5. On YouTube rate-limit / bot check: pause the whole sync
|
||||
6. On unavailable/private/deleted videos: mark as permanent failure
|
||||
7. On next sync: retry only retryable items
|
||||
|
||||
Important: do not delete queue records just because download failed. Keep them as sync records.
|
||||
|
||||
For each video, store:
|
||||
|
||||
video_id
|
||||
playlist_id
|
||||
playlist_position
|
||||
wanted_format: audio | video
|
||||
status
|
||||
failure_type
|
||||
failure_message
|
||||
attempt_count
|
||||
last_attempt_at
|
||||
next_retry_at
|
||||
is_retryable
|
||||
local_file_path
|
||||
|
||||
Retry behavior:
|
||||
|
||||
temporary_failed -> retry with backoff
|
||||
rate_limited -> pause playlist/app queue, retry much later
|
||||
verification_required -> pause until user action
|
||||
unavailable/private/deleted -> do not retry often
|
||||
geo/age restricted -> do not retry unless settings/auth changed
|
||||
|
||||
Example backoff:
|
||||
|
||||
attempt 1: retry after 10 minutes
|
||||
attempt 2: retry after 1 hour
|
||||
attempt 3: retry after 6 hours
|
||||
attempt 4: retry after 24 hours
|
||||
attempt 5+: retry manually or during next scheduled sync
|
||||
|
||||
For the user, show a sync summary:
|
||||
|
||||
Playlist sync partially completed
|
||||
|
||||
Downloaded: 200
|
||||
Queued: 0
|
||||
Retry later: 80
|
||||
Needs attention: 1
|
||||
Unavailable: 119
|
||||
|
||||
The playlist is still tracked. Retryable items will be attempted again in the next sync.
|
||||
|
||||
Best behavior for the 400-item example:
|
||||
|
||||
200 downloaded
|
||||
50 unavailable/private/deleted -> mark permanent
|
||||
149 temporary/rate-limited -> retry later
|
||||
1 bot/verification error -> pause sync and ask user
|
||||
|
||||
Do not cancel the whole sync as “failed”. Mark it as:
|
||||
|
||||
completed_with_issues
|
||||
paused_needs_attention
|
||||
partially_synced
|
||||
|
||||
In UI terms, the playlist should have a health/status:
|
||||
|
||||
Synced
|
||||
Syncing
|
||||
Partially synced
|
||||
Paused - needs attention
|
||||
Error
|
||||
|
||||
The most important rule:
|
||||
|
||||
Never lose the reason why an item did not download.
|
||||
|
||||
That lets your app eventually download everything possible without repeatedly hammering YouTube or confusing the user.
|
||||
|
||||
|
||||
## What to change to match the plan
|
||||
|
||||
Fix the destructive UPSERT behavior in src/app/core/database/db.py / SyncService.sync_from_config() so scans update metadata (title/index/last_seen) but do not overwrite existing downloaded/local_filename (and later: status/failure fields).
|
||||
|
||||
Introduce a persistent table (or extend playlist_items) with:
|
||||
status, failure_type, failure_message, attempt_count, last_attempt_at, next_retry_at, is_retryable, wanted_format, local_file_path.
|
||||
|
||||
Update ActionExecutor / worker layer to write transitions into DB (queued → downloading → completed / temporary_failed / rate_limited / verification_required / failed_permanent).
|
||||
|
||||
Change “next sync” selection to only pick queued or retryable && next_retry_at <= now, not everything each time.
|
||||
Add summary/health states (partially_synced, paused_needs_attention, etc.) based on counts.
|
||||
+5
-7
@@ -3,7 +3,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ytplst"
|
||||
name = "ytpl-sync"
|
||||
version = "1.1.1"
|
||||
description = "YouTube playlist Sync Thing"
|
||||
readme = "README.md"
|
||||
@@ -13,12 +13,10 @@ keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"yt-dlp>=2026.3.17",
|
||||
"PySide6",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
gui = [
|
||||
"PySide6"
|
||||
]
|
||||
dev = [
|
||||
test = [
|
||||
"pytest",
|
||||
"ruff",
|
||||
"black"
|
||||
@@ -28,11 +26,11 @@ dev = [
|
||||
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
|
||||
|
||||
[project.scripts]
|
||||
ytplst = "ytplst.main:main"
|
||||
ytpl-sync = "app.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["ytplst*"]
|
||||
include = ["app*"]
|
||||
|
||||
@@ -3,3 +3,5 @@ testpaths = tests
|
||||
# Collect all standardized tests using the conventional pattern
|
||||
python_files = test_*.py
|
||||
addopts = -q
|
||||
markers =
|
||||
integration: real network/file download tests (opt-in)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .config.settings import Settings
|
||||
@@ -12,6 +13,7 @@ from .core.events.event_bus import EventBus
|
||||
import re
|
||||
from .core.utils.yt import extract_playlist_id
|
||||
from .core.utils.deps import DependencyError
|
||||
from .core.utils.logging_setup import configure_logging
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -20,8 +22,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser.add_argument("--db", type=Path, default=Path("app/data/app.db"), help="Path to SQLite database")
|
||||
parser.add_argument("--playlist", type=int, default=None, help="Only run for a specific playlist index (0-based)")
|
||||
parser.add_argument("--verbose", action="store_true", help="Print detailed events (rename/recycle/start)")
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug logging to console + app/data/app.log")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log"))
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
settings = Settings()
|
||||
db = Database(args.db.resolve())
|
||||
service = SyncService(db)
|
||||
@@ -88,14 +94,17 @@ def main(argv: list[str] | None = None) -> int:
|
||||
counts[a.type.name] = counts.get(a.type.name, 0) + 1
|
||||
summary = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items()))
|
||||
print(f"Playlist {pid}: {len(actions)} actions → {summary}")
|
||||
log.info("playlist=%s actions=%s summary=%s", pid, len(actions), summary)
|
||||
if args.apply and actions:
|
||||
try:
|
||||
asyncio.run(executor.execute(actions, pl))
|
||||
except DependencyError as e:
|
||||
print(f"ERROR: {e}")
|
||||
log.error("dependency error: %s", e)
|
||||
return 2
|
||||
db.set_playlist_last_sync(pid)
|
||||
print(f"Applied actions for {pid}.")
|
||||
log.info("playlist=%s applied_actions=%s", pid, len(actions))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
||||
"max_download_quality": "1080p",
|
||||
"save_path": "./downloads",
|
||||
"ffmpeg_path": _default_ffmpeg_path(),
|
||||
"max_parallel_downloads": 2,
|
||||
"retry_max_retries": 2,
|
||||
"retry_delay_seconds": 1.5,
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +66,9 @@ class Settings:
|
||||
"max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
|
||||
"save_path": self.data.get("save_path", DEFAULT_CONFIG["save_path"]),
|
||||
"ffmpeg_path": self.data.get("ffmpeg_path", DEFAULT_CONFIG["ffmpeg_path"]),
|
||||
"max_parallel_downloads": self.data.get("max_parallel_downloads", DEFAULT_CONFIG["max_parallel_downloads"]),
|
||||
"retry_max_retries": self.data.get("retry_max_retries", DEFAULT_CONFIG["retry_max_retries"]),
|
||||
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]),
|
||||
}
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
|
||||
@@ -99,3 +99,10 @@ class Database:
|
||||
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
||||
(playlist_id,),
|
||||
)
|
||||
|
||||
def get_playlist_last_sync(self, playlist_id: str) -> str | None:
|
||||
cur = self._conn.execute("SELECT last_sync FROM playlists WHERE id = ?", (playlist_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return row["last_sync"]
|
||||
|
||||
@@ -46,6 +46,11 @@ class Downloader:
|
||||
|
||||
async def handle_job(self, job: DownloadJob):
|
||||
try:
|
||||
cancel_check = getattr(job, "cancel_check", None)
|
||||
if callable(cancel_check) and cancel_check():
|
||||
job.state = JobState.CANCELLED
|
||||
job.error = "cancelled"
|
||||
return
|
||||
job.state = JobState.DOWNLOADING
|
||||
await self._download(job)
|
||||
# Optional local audio extraction when requested
|
||||
@@ -68,6 +73,11 @@ class Downloader:
|
||||
|
||||
def run():
|
||||
import yt_dlp # type: ignore
|
||||
from pathlib import Path
|
||||
try:
|
||||
from yt_dlp.utils import DownloadCancelled # type: ignore
|
||||
except Exception: # pragma: no cover - optional
|
||||
DownloadCancelled = Exception # type: ignore[misc,assignment]
|
||||
|
||||
class _QuietLogger:
|
||||
def debug(self, msg):
|
||||
@@ -97,6 +107,62 @@ class Downloader:
|
||||
"logger": _QuietLogger(),
|
||||
}
|
||||
|
||||
progress_cb = getattr(job, "progress_callback", None)
|
||||
cancel_check = getattr(job, "cancel_check", None)
|
||||
if progress_cb is not None:
|
||||
def hook(d):
|
||||
try:
|
||||
if callable(cancel_check) and cancel_check():
|
||||
raise DownloadCancelled("cancelled")
|
||||
import time
|
||||
payload = {
|
||||
"status": d.get("status"),
|
||||
"downloaded_bytes": d.get("downloaded_bytes"),
|
||||
"total_bytes": d.get("total_bytes") or d.get("total_bytes_estimate"),
|
||||
"speed": d.get("speed"),
|
||||
"eta": d.get("eta"),
|
||||
"filename": d.get("filename"),
|
||||
}
|
||||
total = payload.get("total_bytes")
|
||||
done = payload.get("downloaded_bytes")
|
||||
if total and done is not None:
|
||||
payload["progress"] = float(done) / float(total)
|
||||
|
||||
# Throttle progress events to avoid freezing the GUI event loop.
|
||||
# Always forward terminal states.
|
||||
status = str(payload.get("status") or "")
|
||||
now = time.monotonic()
|
||||
last_ts = float(getattr(job, "_last_progress_emit_ts", 0.0) or 0.0)
|
||||
last_pct = float(getattr(job, "_last_progress_emit_pct", -1.0) or -1.0)
|
||||
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):
|
||||
should_emit = True
|
||||
|
||||
if should_emit:
|
||||
setattr(job, "_last_progress_emit_ts", now)
|
||||
if pct is not None:
|
||||
setattr(job, "_last_progress_emit_pct", pct)
|
||||
progress_cb(payload)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ydl_opts["progress_hooks"] = [hook]
|
||||
|
||||
# If user provided an ffmpeg path, pass it through to yt-dlp so it doesn't rely on PATH.
|
||||
ffmpeg_hint = getattr(job, "ffmpeg_path", None) or self.ffmpeg_path
|
||||
if ffmpeg_hint:
|
||||
try:
|
||||
p = Path(str(ffmpeg_hint))
|
||||
if p.exists():
|
||||
ydl_opts["ffmpeg_location"] = str(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||
ydl.download([job.url])
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import asyncio
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from ..models import PlaylistItem
|
||||
|
||||
@@ -29,6 +29,9 @@ class DownloadJob:
|
||||
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
|
||||
|
||||
@@ -59,3 +62,6 @@ class QueueManager:
|
||||
|
||||
async def enqueue(self, job: DownloadJob):
|
||||
await self._queue.put(job)
|
||||
|
||||
async def join(self) -> None:
|
||||
await self._queue.join()
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
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):
|
||||
log = logging.getLogger(__name__)
|
||||
dl = Downloader(ffmpeg_path=job.ffmpeg_path)
|
||||
attempt = 0
|
||||
while attempt <= max_retries:
|
||||
await dl.handle_job(job)
|
||||
if job.state == JobState.CANCELLED:
|
||||
return
|
||||
if job.state == JobState.COMPLETED:
|
||||
return
|
||||
if is_youtube_rate_limit_error(job.error):
|
||||
# Do not retry bot-check/rate-limit style errors; caller will pause.
|
||||
return
|
||||
if (job.error or "").strip().lower() == "cancelled":
|
||||
job.state = JobState.CANCELLED
|
||||
return
|
||||
attempt += 1
|
||||
if attempt <= max_retries:
|
||||
await asyncio.sleep(delay_seconds)
|
||||
wait = delay_seconds * (2 ** (attempt - 1))
|
||||
log.warning(
|
||||
"retrying download attempt=%s/%s video_id=%s wait=%.1fs error=%s",
|
||||
attempt,
|
||||
max_retries,
|
||||
getattr(getattr(job, "item", None), "video_id", None),
|
||||
wait,
|
||||
job.error,
|
||||
)
|
||||
await asyncio.sleep(wait)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from ..models import PlaylistItem
|
||||
|
||||
@@ -16,7 +17,7 @@ class PlaylistScanner:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
def scan(self, playlist_url: str, playlist_id: str) -> List[PlaylistItem]:
|
||||
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
|
||||
try:
|
||||
import yt_dlp # type: ignore
|
||||
except Exception as exc: # pragma: no cover - environment dependent
|
||||
@@ -29,6 +30,15 @@ class PlaylistScanner:
|
||||
"dump_single_json": True,
|
||||
}
|
||||
|
||||
# If a local ffmpeg binary is configured, pass it through so yt-dlp doesn't rely on PATH.
|
||||
if ffmpeg_path:
|
||||
try:
|
||||
p = Path(str(ffmpeg_path))
|
||||
if p.exists():
|
||||
ydl_opts["ffmpeg_location"] = str(p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||
info = ydl.extract_info(playlist_url, download=False)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ 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/REORDER
|
||||
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/DELETE
|
||||
based on simple filename scheme "0001 - Title.ext".
|
||||
"""
|
||||
|
||||
|
||||
+169
-11
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List
|
||||
@@ -13,6 +14,7 @@ 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
|
||||
|
||||
|
||||
class ActionExecutor:
|
||||
@@ -21,8 +23,27 @@ class ActionExecutor:
|
||||
self.db = db
|
||||
self.bus = event_bus
|
||||
|
||||
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
|
||||
self._preflight_dependencies(actions, playlist_cfg)
|
||||
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict, *, cancel_check=None, pause_check=None) -> None:
|
||||
actions_list = list(actions)
|
||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||
start = time.monotonic()
|
||||
counts: dict[str, int] = {}
|
||||
for a in actions_list:
|
||||
counts[a.type.name] = counts.get(a.type.name, 0) + 1
|
||||
|
||||
if self.bus:
|
||||
await self.bus.publish(
|
||||
"SyncStarted",
|
||||
{
|
||||
"playlist_id": playlist_id,
|
||||
"actions_total": sum(counts.values()),
|
||||
"counts": dict(counts),
|
||||
},
|
||||
)
|
||||
|
||||
if not await self._wait_if_paused(pause_check, cancel_check):
|
||||
return
|
||||
self._preflight_dependencies(actions_list, playlist_cfg)
|
||||
|
||||
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||
mode = playlist_cfg.get("download_mode", "video")
|
||||
@@ -34,13 +55,53 @@ class ActionExecutor:
|
||||
video_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# First, handle renames safely in batch per extension
|
||||
await self._apply_renames(actions, audio_root, video_root, playlist_cfg)
|
||||
if not await self._wait_if_paused(pause_check, cancel_check):
|
||||
return
|
||||
await self._apply_renames(actions_list, audio_root, video_root, playlist_cfg)
|
||||
|
||||
# Then, recycle deletions
|
||||
self._apply_deletions(actions, audio_root, video_root, playlist_cfg)
|
||||
if not await self._wait_if_paused(pause_check, cancel_check):
|
||||
return
|
||||
self._apply_deletions(actions_list, audio_root, video_root, playlist_cfg)
|
||||
|
||||
# Finally, perform downloads concurrently
|
||||
await self._apply_downloads(actions, mode, audio_root, video_root, playlist_cfg)
|
||||
if not await self._wait_if_paused(pause_check, cancel_check):
|
||||
return
|
||||
await self._apply_downloads(
|
||||
actions_list,
|
||||
mode,
|
||||
audio_root,
|
||||
video_root,
|
||||
playlist_cfg,
|
||||
cancel_check=cancel_check,
|
||||
pause_check=pause_check,
|
||||
)
|
||||
|
||||
duration_s = round(time.monotonic() - start, 3)
|
||||
# Persist last sync timestamp (single source of truth for CLI/GUI/automation).
|
||||
try:
|
||||
self.db.set_playlist_last_sync(playlist_id)
|
||||
last_sync = self.db.get_playlist_last_sync(playlist_id)
|
||||
except Exception:
|
||||
last_sync = None
|
||||
summary = {
|
||||
"playlist_id": playlist_id,
|
||||
"duration_s": duration_s,
|
||||
"counts": dict(counts),
|
||||
"last_sync": last_sync,
|
||||
}
|
||||
if self.bus:
|
||||
await self.bus.publish("SyncSummary", dict(summary))
|
||||
await self.bus.publish("SyncFinished", dict(summary))
|
||||
|
||||
async def _wait_if_paused(self, pause_check, cancel_check) -> bool:
|
||||
if not callable(pause_check):
|
||||
return True
|
||||
while pause_check():
|
||||
if callable(cancel_check) and cancel_check():
|
||||
return False
|
||||
await asyncio.sleep(0.1)
|
||||
return True
|
||||
|
||||
def _preflight_dependencies(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
|
||||
"""
|
||||
@@ -126,14 +187,84 @@ class ActionExecutor:
|
||||
if self.bus:
|
||||
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
|
||||
|
||||
async def _apply_downloads(self, actions: Iterable[SyncAction], mode: str, audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
|
||||
async def _apply_downloads(
|
||||
self,
|
||||
actions: Iterable[SyncAction],
|
||||
mode: str,
|
||||
audio_root: Path,
|
||||
video_root: Path,
|
||||
playlist_cfg: dict,
|
||||
*,
|
||||
cancel_check=None,
|
||||
pause_check=None,
|
||||
) -> None:
|
||||
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
|
||||
queue = QueueManager(concurrency=self.concurrency)
|
||||
loop = asyncio.get_running_loop()
|
||||
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:
|
||||
concurrency = self.concurrency
|
||||
queue = QueueManager(concurrency=concurrency)
|
||||
|
||||
retry_max_cfg = playlist_cfg.get("retry_max_retries", 2)
|
||||
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:
|
||||
retry_max_retries = 2
|
||||
try:
|
||||
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
|
||||
except Exception:
|
||||
retry_delay_seconds = 1.5
|
||||
|
||||
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
|
||||
try:
|
||||
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
|
||||
except Exception:
|
||||
delay_between_downloads_seconds = 0.0
|
||||
|
||||
rate_limit_pause = asyncio.Event()
|
||||
rate_limit_emitted = False
|
||||
|
||||
async def worker(job: DownloadJob):
|
||||
nonlocal rate_limit_emitted
|
||||
job.playlist_id = playlist_id
|
||||
job.cancel_check = cancel_check
|
||||
if not await self._wait_if_paused(pause_check, cancel_check):
|
||||
job.error = "cancelled"
|
||||
return
|
||||
|
||||
if self.bus:
|
||||
def _progress_cb(info: dict):
|
||||
payload = dict(info)
|
||||
payload.setdefault("playlist_id", playlist_id)
|
||||
if job.item:
|
||||
payload.setdefault("video_id", job.item.video_id)
|
||||
loop.call_soon_threadsafe(asyncio.create_task, self.bus.publish("DownloadProgress", payload))
|
||||
|
||||
job.progress_callback = _progress_cb
|
||||
|
||||
if self.bus and job.item:
|
||||
await self.bus.publish("DownloadStarted", {"playlist_id": playlist_id, "video_id": job.item.video_id, "target": str(job.output_path)})
|
||||
await default_worker(job)
|
||||
await default_worker(job, max_retries=retry_max_retries, delay_seconds=retry_delay_seconds)
|
||||
|
||||
# If we hit YouTube bot-check / rate-limit, pause the whole playlist sync:
|
||||
# - stop scheduling/processing more jobs
|
||||
# - surface a single SyncPaused event
|
||||
if is_youtube_rate_limit_error(getattr(job, "error", None)):
|
||||
rate_limit_pause.set()
|
||||
if self.bus and not rate_limit_emitted:
|
||||
rate_limit_emitted = True
|
||||
await self.bus.publish(
|
||||
"SyncPaused",
|
||||
{"playlist_id": playlist_id, "video_id": getattr(getattr(job, "item", None), "video_id", None), "reason": "paused due to youtube rate limits"},
|
||||
)
|
||||
return
|
||||
|
||||
if delay_between_downloads_seconds > 0 and not (callable(cancel_check) and cancel_check()):
|
||||
# Gentle throttle between jobs to reduce rate limiting.
|
||||
await asyncio.sleep(delay_between_downloads_seconds)
|
||||
|
||||
await queue.start(worker)
|
||||
try:
|
||||
@@ -157,6 +288,12 @@ class ActionExecutor:
|
||||
temp_video_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for a in actions:
|
||||
if callable(cancel_check) and cancel_check():
|
||||
break
|
||||
if not await self._wait_if_paused(pause_check, cancel_check):
|
||||
break
|
||||
if rate_limit_pause.is_set():
|
||||
break
|
||||
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
|
||||
continue
|
||||
vid = a.item.video_id
|
||||
@@ -223,8 +360,22 @@ class ActionExecutor:
|
||||
jobs.append(job)
|
||||
await queue.enqueue(job)
|
||||
finally:
|
||||
await queue._queue.join() # wait for all jobs
|
||||
await queue.stop()
|
||||
join_task = asyncio.create_task(queue.join())
|
||||
try:
|
||||
while not join_task.done():
|
||||
if callable(cancel_check) and cancel_check():
|
||||
join_task.cancel()
|
||||
break
|
||||
if callable(pause_check) and pause_check():
|
||||
# Pause requested: stop starting more work and return control.
|
||||
join_task.cancel()
|
||||
break
|
||||
if rate_limit_pause.is_set():
|
||||
join_task.cancel()
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
finally:
|
||||
await queue.stop()
|
||||
|
||||
# Persist DB updates for completed jobs
|
||||
for job in locals().get("jobs", []):
|
||||
@@ -241,6 +392,13 @@ class ActionExecutor:
|
||||
# Ensure not marked as downloaded if failed
|
||||
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
|
||||
if self.bus:
|
||||
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": job.error or "unknown"})
|
||||
err = job.error or "unknown"
|
||||
if is_youtube_rate_limit_error(err):
|
||||
await self.bus.publish(
|
||||
"DownloadFailed",
|
||||
{"playlist_id": playlist_id, "video_id": job.item.video_id, "error": "paused due to youtube rate limits"},
|
||||
)
|
||||
else:
|
||||
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
@@ -9,7 +8,7 @@ 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 make_filename, sanitize_title
|
||||
from ..utils.naming import sanitize_title
|
||||
from ..utils.yt import extract_playlist_id
|
||||
|
||||
|
||||
@@ -26,11 +25,11 @@ class SyncService:
|
||||
return [".mp4"]
|
||||
if mode == "both":
|
||||
return [".mp3", ".mp4"]
|
||||
return [".mp3"]
|
||||
return [".mp4"]
|
||||
|
||||
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
|
||||
url: str = playlist_cfg.get("url")
|
||||
mode: str = playlist_cfg.get("download_mode", "audio")
|
||||
mode: str = playlist_cfg.get("download_mode", "video")
|
||||
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -45,7 +44,8 @@ class SyncService:
|
||||
auto_sync=int(bool(playlist_cfg.get("auto_sync", False))),
|
||||
sync_interval_minutes=int(playlist_cfg.get("sync_interval_minutes", 0) or 0),
|
||||
)
|
||||
items = self.scanner.scan(url, playlist_id)
|
||||
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] = []
|
||||
for it in items:
|
||||
@@ -119,19 +119,3 @@ class SyncService:
|
||||
merged_actions.extend(actions)
|
||||
|
||||
return merged_actions
|
||||
for ext in exts:
|
||||
mode_dir = "audio" if ext == ".mp3" else "video"
|
||||
fs_root = (save_path / mode_dir)
|
||||
fs_entries = list_files(fs_root, [ext])
|
||||
actions = self.diff.compute_actions(sanitized, db_index, fs_entries, ext)
|
||||
merged_actions.extend(actions)
|
||||
|
||||
return [
|
||||
{
|
||||
"type": a.type,
|
||||
"video_id": a.item.video_id if a.item else None,
|
||||
"from_name": a.from_name,
|
||||
"to_name": a.to_name,
|
||||
}
|
||||
for a in merged_actions
|
||||
]
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def configure_logging(*, verbose: bool = False, log_file: Path | None = None) -> None:
|
||||
"""
|
||||
Configure app-wide logging.
|
||||
|
||||
- Console handler always enabled.
|
||||
- Rotating file handler enabled when log_file is provided.
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
|
||||
# Avoid duplicate handlers on repeated calls (tests, re-entrypoints).
|
||||
if getattr(configure_logging, "_configured", False):
|
||||
return
|
||||
|
||||
fmt = logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
|
||||
console = logging.StreamHandler()
|
||||
console.setLevel(logging.DEBUG if verbose else logging.INFO)
|
||||
console.setFormatter(fmt)
|
||||
root.addHandler(console)
|
||||
|
||||
if log_file is not None:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = RotatingFileHandler(str(log_file), maxBytes=2_000_000, backupCount=3, encoding="utf-8")
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(fmt)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
configure_logging._configured = True # type: ignore[attr-defined]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_youtube_rate_limit_error(message: str | None) -> bool:
|
||||
"""
|
||||
Best-effort detection of YouTube "bot check" / auth-gated extraction failures.
|
||||
|
||||
yt-dlp typically surfaces this as:
|
||||
- "Sign in to confirm you’re not a bot"
|
||||
- mentions of --cookies / --cookies-from-browser
|
||||
"""
|
||||
if not message:
|
||||
return False
|
||||
s = str(message).lower()
|
||||
needles = [
|
||||
"sign in to confirm",
|
||||
"you're not a bot",
|
||||
"you’re not a bot",
|
||||
"--cookies-from-browser",
|
||||
"--cookies",
|
||||
]
|
||||
return any(n in s for n in needles)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
from typing import Any, Dict
|
||||
from PySide6 import QtCore
|
||||
|
||||
from ..core.events.event_bus import EventBus
|
||||
|
||||
|
||||
class BusBridge(QtCore.QObject):
|
||||
"""
|
||||
Bridges backend EventBus async events onto the Qt main thread.
|
||||
|
||||
Emits `event(name, payload)` as a Qt signal, thread-safe.
|
||||
"""
|
||||
|
||||
event = QtCore.Signal(str, dict)
|
||||
|
||||
def __init__(self, bus: EventBus, parent: QtCore.QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._bus = bus
|
||||
|
||||
for name in (
|
||||
"SyncStarted",
|
||||
"SyncSummary",
|
||||
"SyncFinished",
|
||||
"DownloadStarted",
|
||||
"DownloadProgress",
|
||||
"DownloadCompleted",
|
||||
"DownloadFailed",
|
||||
"RenameApplied",
|
||||
"FileRecycled",
|
||||
):
|
||||
self._bus.subscribe(name, self._make_handler(name))
|
||||
|
||||
def _make_handler(self, name: str):
|
||||
async def handler(payload: Dict[str, Any]) -> None:
|
||||
# Ensure delivery on the Qt main thread.
|
||||
self.event.emit(name, dict(payload))
|
||||
|
||||
return handler
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
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
|
||||
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from ..config.settings import Settings
|
||||
from ..core.events.event_bus import EventBus
|
||||
from .bus_bridge import BusBridge
|
||||
from .runner import SyncRequest, SyncRunner
|
||||
from .pages.playlists import PlaylistManagerPage
|
||||
from .pages.queue import QueuePage
|
||||
from .pages.logs import LogsPage
|
||||
from .pages.settings import SettingsPage
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("ytpl-sync")
|
||||
self.resize(1100, 700)
|
||||
|
||||
self._settings = Settings()
|
||||
self._bus = EventBus()
|
||||
self._bridge = BusBridge(self._bus)
|
||||
|
||||
self._thread: QtCore.QThread | None = None
|
||||
self._runner: SyncRunner | None = None
|
||||
self._cancel_flag: threading.Event | None = None
|
||||
self._pause_flag: threading.Event | None = None
|
||||
|
||||
# Sidebar navigation
|
||||
self._nav = QtWidgets.QListWidget()
|
||||
self._nav.setObjectName("sidebar")
|
||||
self._nav.setFixedWidth(220)
|
||||
self._nav.setSpacing(2)
|
||||
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
|
||||
|
||||
self._stack = QtWidgets.QStackedWidget()
|
||||
self._playlists_page = PlaylistManagerPage(self._settings)
|
||||
self._queue_page = QueuePage()
|
||||
self._logs_page = LogsPage()
|
||||
self._settings_page = SettingsPage()
|
||||
|
||||
self._pages: list[QtWidgets.QWidget] = [
|
||||
self._playlists_page,
|
||||
self._queue_page,
|
||||
self._logs_page,
|
||||
self._settings_page,
|
||||
]
|
||||
for p in self._pages:
|
||||
self._stack.addWidget(p)
|
||||
|
||||
for label in ("Playlists", "Queue", "Logs", "Settings"):
|
||||
item = QtWidgets.QListWidgetItem(label)
|
||||
item.setSizeHint(QtCore.QSize(200, 36))
|
||||
self._nav.addItem(item)
|
||||
|
||||
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
|
||||
self._nav.setCurrentRow(0)
|
||||
|
||||
# Layout
|
||||
root = QtWidgets.QWidget()
|
||||
layout = QtWidgets.QHBoxLayout(root)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self._nav)
|
||||
layout.addWidget(self._stack, 1)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
self._bridge.event.connect(self._on_bus_event)
|
||||
self._apply_style()
|
||||
|
||||
# Provide Settings page a concrete config path.
|
||||
cfg_path = getattr(self._settings, "path", None)
|
||||
if cfg_path is not None:
|
||||
try:
|
||||
self._settings_page.set_config_path(cfg_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._playlists_page.cancel_requested.connect(self._cancel_sync)
|
||||
self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
|
||||
self._playlists_page.sync_one_requested.connect(self._sync_playlist_index)
|
||||
self._playlists_page.sync_all_requested.connect(self._sync_all)
|
||||
self._playlists_page.pause_requested.connect(self._pause_sync)
|
||||
self._playlists_page.resume_requested.connect(self._resume_sync)
|
||||
|
||||
self._refresh_queue_labels()
|
||||
|
||||
def _refresh_queue_labels(self) -> None:
|
||||
try:
|
||||
from ..core.utils.yt import extract_playlist_id
|
||||
|
||||
labels: dict[str, str] = {}
|
||||
for idx, pl in enumerate(self._settings.playlists, start=1):
|
||||
url = str(pl.get("url") or "")
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
# Auto-pause on YouTube bot-check/rate-limit surface.
|
||||
if name == "SyncPaused":
|
||||
self._pause_sync()
|
||||
|
||||
def _sync_playlist_index(self, index: int) -> None:
|
||||
playlists = self._settings.playlists
|
||||
if index < 0 or index >= len(playlists):
|
||||
return
|
||||
cfg = dict(playlists[index])
|
||||
self._refresh_queue_labels()
|
||||
self._playlists_page.set_running(True)
|
||||
|
||||
# Stop any previous run
|
||||
if self._thread is not None:
|
||||
self._thread.quit()
|
||||
self._thread.wait(2000)
|
||||
self._thread = None
|
||||
self._runner = None
|
||||
self._cancel_flag = None
|
||||
|
||||
self._thread = QtCore.QThread()
|
||||
self._cancel_flag = threading.Event()
|
||||
self._pause_flag = threading.Event()
|
||||
self._runner = SyncRunner(self._bus)
|
||||
self._runner.moveToThread(self._thread)
|
||||
self._runner.set_request(SyncRequest(playlist_cfg=cfg, apply=True, cancel_flag=self._cancel_flag, pause_flag=self._pause_flag))
|
||||
self._thread.started.connect(self._runner.run_current)
|
||||
self._runner.finished.connect(self._on_sync_finished)
|
||||
self._runner.finished.connect(self._thread.quit)
|
||||
self._thread.start()
|
||||
|
||||
def _sync_all(self) -> None:
|
||||
# Run playlists sequentially (simple + predictable).
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._sync_queue = list(range(len(self._settings.playlists)))
|
||||
if not self._sync_queue:
|
||||
return
|
||||
self._playlists_page.set_running(True)
|
||||
self._sync_playlist_index(self._sync_queue.pop(0))
|
||||
|
||||
@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._playlists_page.set_running(False)
|
||||
|
||||
# Mark idle so "Sync all" can be started again.
|
||||
if self._thread is not None:
|
||||
try:
|
||||
self._thread.quit()
|
||||
self._thread.wait(2000)
|
||||
except Exception:
|
||||
pass
|
||||
self._runner = None
|
||||
self._cancel_flag = None
|
||||
self._pause_flag = None
|
||||
self._thread = None
|
||||
|
||||
# Reload config in case playlists/settings changed externally during run.
|
||||
try:
|
||||
self._settings = Settings()
|
||||
self._playlists_page.reload_from_config()
|
||||
cfg_path = getattr(self._settings, "path", None)
|
||||
if cfg_path is not None:
|
||||
self._settings_page.set_config_path(cfg_path)
|
||||
self._refresh_queue_labels()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Continue "sync all" chain if active.
|
||||
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"):
|
||||
nxt = getattr(self, "_sync_queue").pop(0)
|
||||
self._sync_playlist_index(nxt)
|
||||
|
||||
@QtCore.Slot()
|
||||
def _cancel_sync(self) -> None:
|
||||
if self._cancel_flag is not None:
|
||||
self._cancel_flag.set()
|
||||
if self._pause_flag is not None:
|
||||
self._pause_flag.clear()
|
||||
|
||||
def _pause_sync(self) -> None:
|
||||
if self._pause_flag is not None:
|
||||
self._pause_flag.set()
|
||||
|
||||
def _resume_sync(self) -> None:
|
||||
if self._pause_flag is not None:
|
||||
self._pause_flag.clear()
|
||||
|
||||
def _apply_style(self) -> None:
|
||||
self.setStyleSheet(
|
||||
"""
|
||||
QMainWindow { background: #0f1115; color: #e6e6e6; }
|
||||
QWidget { font-size: 13px; }
|
||||
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
|
||||
|
||||
QListWidget#sidebar {
|
||||
background: #0b0d11;
|
||||
border-right: 1px solid #20242d;
|
||||
padding: 8px;
|
||||
}
|
||||
QListWidget#sidebar::item {
|
||||
color: #cfd3da;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
QListWidget#sidebar::item:selected {
|
||||
background: #1e2633;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
QTableWidget {
|
||||
background: #0f1115;
|
||||
gridline-color: #20242d;
|
||||
border: 1px solid #20242d;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #0b0d11;
|
||||
color: #cfd3da;
|
||||
border: 1px solid #20242d;
|
||||
padding: 6px;
|
||||
}
|
||||
QPushButton {
|
||||
background: #1e2633;
|
||||
border: 1px solid #2a3140;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
QPushButton:hover { background: #243044; }
|
||||
|
||||
QFrame#playlistCard {
|
||||
background: #0b0d11;
|
||||
border: 1px solid #20242d;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
QLineEdit, QComboBox {
|
||||
background: #0f1115;
|
||||
border: 1px solid #20242d;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
app.setApplicationName("ytpl-sync")
|
||||
app.setOrganizationName("ytpl-sync")
|
||||
app.setWindowIcon(QtGui.QIcon())
|
||||
|
||||
# Avoid Qt warnings when a font with invalid point size is inherited from the environment.
|
||||
f = app.font()
|
||||
if f.pointSize() <= 0:
|
||||
f.setPointSize(10)
|
||||
app.setFont(f)
|
||||
|
||||
w = MainWindow()
|
||||
w.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from PySide6 import QtWidgets
|
||||
|
||||
from ..smooth_scroll import enable_smooth_scrolling
|
||||
|
||||
|
||||
class LogsPage(QtWidgets.QWidget):
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
title = QtWidgets.QLabel("Logs")
|
||||
title.setObjectName("pageTitle")
|
||||
|
||||
top = QtWidgets.QHBoxLayout()
|
||||
top.addWidget(title)
|
||||
top.addStretch(1)
|
||||
clear_btn = QtWidgets.QPushButton("Clear")
|
||||
clear_btn.clicked.connect(self._clear)
|
||||
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)
|
||||
|
||||
def _clear(self) -> None:
|
||||
self._text.clear()
|
||||
|
||||
def on_event(self, name: str, payload: dict) -> None:
|
||||
# Avoid flooding the UI with high-frequency progress updates.
|
||||
if name == "DownloadProgress":
|
||||
return
|
||||
# Keep this lightweight: append a single-line JSON entry.
|
||||
try:
|
||||
line = json.dumps({"event": name, **payload}, ensure_ascii=False)
|
||||
except Exception:
|
||||
line = f"{name}: {payload}"
|
||||
self._text.appendPlainText(line)
|
||||
self._text.moveCursor(self._text.textCursor().End)
|
||||
@@ -0,0 +1,624 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6 import QtCore, QtGui, QtWidgets
|
||||
|
||||
from ...config.settings import Settings
|
||||
from ...core.database.db import Database
|
||||
from ...core.utils.yt import extract_playlist_id
|
||||
from ..smooth_scroll import enable_smooth_scrolling
|
||||
from ..config_store import load_config, normalize_config, save_config
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlaylistRow:
|
||||
name: str
|
||||
url: str
|
||||
download_mode: str
|
||||
max_download_quality: str
|
||||
save_path: str
|
||||
|
||||
|
||||
class PlaylistManagerPage(QtWidgets.QWidget):
|
||||
cancel_requested = QtCore.Signal()
|
||||
sync_one_requested = QtCore.Signal(int)
|
||||
sync_all_requested = QtCore.Signal()
|
||||
pause_requested = QtCore.Signal()
|
||||
resume_requested = QtCore.Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
*,
|
||||
parent: QtWidgets.QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._settings = settings
|
||||
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)
|
||||
|
||||
header = QtWidgets.QLabel("Playlists")
|
||||
header.setObjectName("pageTitle")
|
||||
|
||||
self._list = QtWidgets.QListWidget()
|
||||
# Selection-based UI is intentionally disabled; actions happen per-card.
|
||||
self._list.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.NoSelection)
|
||||
self._list.setSpacing(8)
|
||||
self._list.setUniformItemSizes(False)
|
||||
self._list.setWordWrap(True)
|
||||
self._list.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
enable_smooth_scrolling(self._list)
|
||||
|
||||
self._add_btn = QtWidgets.QPushButton("Add")
|
||||
self._add_btn.clicked.connect(self._add_playlist)
|
||||
self._save_btn = QtWidgets.QPushButton("Save config")
|
||||
self._save_btn.clicked.connect(self._save_config)
|
||||
|
||||
self._sync_all_btn = QtWidgets.QPushButton("Sync all")
|
||||
self._sync_all_btn.clicked.connect(self.sync_all_requested.emit)
|
||||
|
||||
self._cancel_btn = QtWidgets.QPushButton("Cancel all")
|
||||
self._cancel_btn.setEnabled(False)
|
||||
self._cancel_btn.clicked.connect(self._cancel_sync)
|
||||
|
||||
self._refresh_btn = QtWidgets.QPushButton("Reload config")
|
||||
self._refresh_btn.clicked.connect(self.reload_from_config)
|
||||
|
||||
self._status = QtWidgets.QLabel("")
|
||||
self._status.setWordWrap(True)
|
||||
self._sync_state = QtWidgets.QLabel("")
|
||||
self._sync_state.setWordWrap(True)
|
||||
self._sync_state.setStyleSheet("color: #9fb0c6;")
|
||||
|
||||
top = QtWidgets.QHBoxLayout()
|
||||
top.addWidget(header)
|
||||
top.addStretch(1)
|
||||
top.addWidget(self._add_btn)
|
||||
top.addWidget(self._save_btn)
|
||||
top.addWidget(self._sync_all_btn)
|
||||
top.addWidget(self._cancel_btn)
|
||||
top.addWidget(self._refresh_btn)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
layout.addLayout(top)
|
||||
layout.addWidget(self._list, 1)
|
||||
layout.addWidget(self._sync_state)
|
||||
layout.addWidget(self._status)
|
||||
|
||||
self.reload_from_config()
|
||||
|
||||
def _rows_from_settings(self) -> list[PlaylistRow]:
|
||||
rows: list[PlaylistRow] = []
|
||||
for idx, pl in enumerate(self._settings.playlists, start=1):
|
||||
name = str(pl.get("name") or f"Playlist {idx}")
|
||||
rows.append(
|
||||
PlaylistRow(
|
||||
name=name,
|
||||
url=str(pl.get("url") or ""),
|
||||
download_mode=str(pl.get("download_mode") or ""),
|
||||
max_download_quality=str(pl.get("max_download_quality") or ""),
|
||||
save_path=str(pl.get("save_path") or ""),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
@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()
|
||||
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] = {}
|
||||
try:
|
||||
db = Database(Path("app/data/app.db").resolve())
|
||||
for r in rows:
|
||||
pid = extract_playlist_id(r.url) or r.url
|
||||
ls = db.get_playlist_last_sync(pid)
|
||||
if ls:
|
||||
last_sync_by_id[pid] = str(ls)
|
||||
except Exception:
|
||||
last_sync_by_id = {}
|
||||
|
||||
self._list.clear()
|
||||
for idx, r in enumerate(rows):
|
||||
pid = extract_playlist_id(r.url) or r.url
|
||||
widget = _PlaylistCard(r, index=idx, last_sync=last_sync_by_id.get(pid))
|
||||
widget.sync_clicked.connect(self.sync_one_requested.emit)
|
||||
widget.remove_clicked.connect(self._remove_at_index)
|
||||
widget.cancel_clicked.connect(lambda _pid: self._cancel_sync())
|
||||
widget.pause_changed.connect(self._on_pause_changed)
|
||||
widget.changed.connect(self._schedule_autosave)
|
||||
item = QtWidgets.QListWidgetItem()
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self._list.addItem(item)
|
||||
self._list.setItemWidget(item, widget)
|
||||
|
||||
cfg_path = getattr(self._settings, "path", None)
|
||||
self._status.setText(f"Loaded {len(rows)} playlists from {cfg_path}.")
|
||||
|
||||
@QtCore.Slot()
|
||||
def _cancel_sync(self) -> None:
|
||||
# Actual cancellation is handled by MainWindow; this is UI intent.
|
||||
self._status.setText("Cancelling…")
|
||||
self.cancel_requested.emit()
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._sync_all_btn.setEnabled(not running)
|
||||
self._cancel_btn.setEnabled(running)
|
||||
self._save_btn.setEnabled(not running)
|
||||
self._add_btn.setEnabled(not running)
|
||||
self._refresh_btn.setEnabled(not running)
|
||||
# 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)
|
||||
|
||||
@QtCore.Slot()
|
||||
def _add_playlist(self) -> None:
|
||||
r = PlaylistRow(
|
||||
name="New Playlist",
|
||||
url="https://www.youtube.com/playlist?list=",
|
||||
download_mode="video",
|
||||
max_download_quality="1080p",
|
||||
save_path="./downloads",
|
||||
)
|
||||
widget = _PlaylistCard(r, index=self._list.count())
|
||||
widget.sync_clicked.connect(self.sync_one_requested.emit)
|
||||
widget.remove_clicked.connect(self._remove_at_index)
|
||||
widget.cancel_clicked.connect(lambda _pid: self._cancel_sync())
|
||||
widget.pause_changed.connect(self._on_pause_changed)
|
||||
widget.changed.connect(self._schedule_autosave)
|
||||
item = QtWidgets.QListWidgetItem()
|
||||
item.setSizeHint(widget.sizeHint())
|
||||
self._list.addItem(item)
|
||||
self._list.setItemWidget(item, widget)
|
||||
self._schedule_autosave()
|
||||
|
||||
@QtCore.Slot()
|
||||
def _remove_at_index(self, index: int) -> None:
|
||||
if index < 0 or index >= self._list.count():
|
||||
return
|
||||
self._list.takeItem(index)
|
||||
self._reindex_cards()
|
||||
self._schedule_autosave()
|
||||
|
||||
@QtCore.Slot(bool)
|
||||
def _on_pause_changed(self, paused: bool) -> None:
|
||||
if paused:
|
||||
self.pause_requested.emit()
|
||||
self._sync_state.setText("Paused")
|
||||
else:
|
||||
self.resume_requested.emit()
|
||||
self._sync_state.setText("Resumed")
|
||||
|
||||
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)
|
||||
return playlists
|
||||
|
||||
@QtCore.Slot()
|
||||
def _save_config(self) -> None:
|
||||
if self._config_path is None:
|
||||
self._status.setText("No config path loaded.")
|
||||
return
|
||||
try:
|
||||
if not self._validate_all(show_status=True):
|
||||
return
|
||||
data = dict(self._config or {})
|
||||
data["playlists"] = self._table_to_playlists()
|
||||
save_config(self._config_path, data)
|
||||
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:
|
||||
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)
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
@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
|
||||
try:
|
||||
data = dict(self._config or {})
|
||||
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:
|
||||
self._status.setText(f"Autosave failed: {exc}")
|
||||
|
||||
def on_event(self, name: str, payload: dict) -> None:
|
||||
if name == "SyncStarted":
|
||||
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)
|
||||
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")
|
||||
ls = payload.get("last_sync")
|
||||
if 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}")
|
||||
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')}")
|
||||
self.set_running(False)
|
||||
# Ensure any card in "pause" mode returns to Sync.
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
if pid:
|
||||
self._set_active_card(pid, running=False, paused=False)
|
||||
elif name == "DownloadStarted":
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
vid = str(payload.get("video_id") or "")
|
||||
if not pid:
|
||||
return
|
||||
self._download_state_by_pid[pid] = {"video_id": vid, "progress": 0.0, "status": "started"}
|
||||
self._set_card_progress(pid, 0.0)
|
||||
self._set_card_status(pid, f"downloading {vid}".strip())
|
||||
elif name == "DownloadProgress":
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
vid = str(payload.get("video_id") or "")
|
||||
prog = payload.get("progress")
|
||||
if not pid:
|
||||
return
|
||||
if isinstance(prog, (int, float)):
|
||||
p = float(prog)
|
||||
self._download_state_by_pid.setdefault(pid, {})["progress"] = p
|
||||
if vid:
|
||||
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))
|
||||
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())
|
||||
elif name == "DownloadCompleted":
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
if pid:
|
||||
vid = str(payload.get("video_id") or self._download_state_by_pid.get(pid, {}).get("video_id") or "")
|
||||
self._set_card_progress(pid, 1.0)
|
||||
self._download_state_by_pid.pop(pid, None)
|
||||
self._set_card_status(pid, f"completed {vid}".strip())
|
||||
elif name == "DownloadFailed":
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
if not pid:
|
||||
return
|
||||
vid = str(payload.get("video_id") or self._download_state_by_pid.get(pid, {}).get("video_id") or "")
|
||||
err = str(payload.get("error") or "").strip()
|
||||
self._download_state_by_pid.pop(pid, None)
|
||||
self._set_card_status(pid, f"failed {vid}: {err}" if err else f"failed {vid}".strip())
|
||||
elif name == "SyncPaused":
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
if not pid:
|
||||
return
|
||||
self._set_card_status(pid, str(payload.get("reason") or "paused"))
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class _PlaylistCard(QtWidgets.QFrame):
|
||||
sync_clicked = QtCore.Signal(int)
|
||||
remove_clicked = QtCore.Signal(int)
|
||||
cancel_clicked = QtCore.Signal(str)
|
||||
pause_changed = QtCore.Signal(bool)
|
||||
changed = QtCore.Signal()
|
||||
|
||||
def __init__(self, row: PlaylistRow, *, index: int, last_sync: str | None = None, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setFrameShape(QtWidgets.QFrame.Shape.StyledPanel)
|
||||
self.setObjectName("playlistCard")
|
||||
self._index = index
|
||||
self._active = False
|
||||
self._paused = False
|
||||
|
||||
self._name_value = row.name
|
||||
self._name_label = QtWidgets.QLabel(self._name_value or "Playlist")
|
||||
self._name_label.setStyleSheet("font-weight: 600; font-size: 14px;")
|
||||
|
||||
self._name_edit = QtWidgets.QLineEdit(self._name_value)
|
||||
self._name_edit.setMinimumHeight(32)
|
||||
self._name_edit.editingFinished.connect(self._finish_name_edit)
|
||||
self._name_stack = QtWidgets.QStackedWidget()
|
||||
self._name_stack.addWidget(self._name_label)
|
||||
self._name_stack.addWidget(self._name_edit)
|
||||
self._name_stack.setCurrentIndex(0)
|
||||
|
||||
self._url = QtWidgets.QLineEdit(row.url)
|
||||
|
||||
self._mode = QtWidgets.QComboBox()
|
||||
self._mode.addItems(["video", "audio", "both"])
|
||||
self._mode.setCurrentText(row.download_mode or "video")
|
||||
|
||||
self._quality = QtWidgets.QComboBox()
|
||||
self._quality.addItems(["1080p", "720p", "480p", "360p"])
|
||||
self._quality.setEditable(False)
|
||||
self._quality.setCurrentText(row.max_download_quality or "1080p")
|
||||
|
||||
self._save_path = QtWidgets.QLineEdit(row.save_path)
|
||||
|
||||
for w in (self._url, self._mode, self._quality, self._save_path):
|
||||
w.setMinimumHeight(32)
|
||||
self._url.editingFinished.connect(self.changed.emit)
|
||||
self._save_path.editingFinished.connect(self.changed.emit)
|
||||
self._mode.currentIndexChanged.connect(lambda _i: self.changed.emit())
|
||||
self._quality.currentIndexChanged.connect(lambda _i: self.changed.emit())
|
||||
|
||||
self._status = QtWidgets.QLabel("")
|
||||
self._status.setStyleSheet("color: #9fb0c6;")
|
||||
self._meta = QtWidgets.QLabel(f"Last sync: {last_sync or 'never'}")
|
||||
self._meta.setStyleSheet("color: #7f8aa3;")
|
||||
self._progress = QtWidgets.QProgressBar()
|
||||
self._progress.setRange(0, 100)
|
||||
self._progress.setValue(0)
|
||||
self._progress.setTextVisible(False)
|
||||
self._progress.setFixedHeight(6)
|
||||
self._sync_btn = QtWidgets.QPushButton("Sync")
|
||||
self._sync_btn.clicked.connect(self._on_sync_or_pause_clicked)
|
||||
|
||||
self._edit_name_btn = QtWidgets.QToolButton()
|
||||
self._edit_name_btn.setAutoRaise(True)
|
||||
self._edit_name_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self._edit_name_btn.setIconSize(QtCore.QSize(16, 16))
|
||||
self._edit_name_btn.setFixedSize(28, 28)
|
||||
icon = QtGui.QIcon.fromTheme("document-edit")
|
||||
if not icon.isNull():
|
||||
self._edit_name_btn.setIcon(icon)
|
||||
else:
|
||||
self._edit_name_btn.setText("✎")
|
||||
self._edit_name_btn.clicked.connect(self._toggle_name_edit)
|
||||
|
||||
self._remove_btn = QtWidgets.QToolButton()
|
||||
self._remove_btn.setAutoRaise(True)
|
||||
self._remove_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self._remove_btn.setIconSize(QtCore.QSize(16, 16))
|
||||
self._remove_btn.setFixedSize(28, 28)
|
||||
remove_icon = QtGui.QIcon.fromTheme("edit-delete")
|
||||
if not remove_icon.isNull():
|
||||
self._remove_btn.setIcon(remove_icon)
|
||||
else:
|
||||
self._remove_btn.setText("X")
|
||||
self._remove_btn.setToolTip("Remove playlist")
|
||||
self._remove_btn.clicked.connect(lambda: self.remove_clicked.emit(self._index))
|
||||
|
||||
self._cancel_btn = QtWidgets.QToolButton()
|
||||
self._cancel_btn.setAutoRaise(True)
|
||||
self._cancel_btn.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
|
||||
self._cancel_btn.setIconSize(QtCore.QSize(16, 16))
|
||||
self._cancel_btn.setFixedSize(28, 28)
|
||||
stop_icon = QtGui.QIcon.fromTheme("process-stop")
|
||||
if not stop_icon.isNull():
|
||||
self._cancel_btn.setIcon(stop_icon)
|
||||
else:
|
||||
self._cancel_btn.setText("■")
|
||||
self._cancel_btn.setToolTip("Cancel this playlist sync")
|
||||
self._cancel_btn.setEnabled(False)
|
||||
self._cancel_btn.clicked.connect(lambda: self.cancel_clicked.emit(self.playlist_id()))
|
||||
|
||||
header = QtWidgets.QHBoxLayout()
|
||||
header.addWidget(self._name_stack, 0)
|
||||
header.addWidget(self._edit_name_btn, 0)
|
||||
header.addWidget(self._remove_btn, 0)
|
||||
header.addWidget(self._cancel_btn, 0)
|
||||
header.addStretch(1)
|
||||
header.addWidget(self._sync_btn)
|
||||
|
||||
form = QtWidgets.QFormLayout()
|
||||
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
|
||||
form.setFormAlignment(QtCore.Qt.AlignmentFlag.AlignTop)
|
||||
form.setVerticalSpacing(10)
|
||||
form.setHorizontalSpacing(12)
|
||||
form.addRow("URL", self._url)
|
||||
form.addRow("Mode", self._mode)
|
||||
form.addRow("Max Quality", self._quality)
|
||||
form.addRow("Save Path", self._save_path)
|
||||
|
||||
outer = QtWidgets.QVBoxLayout()
|
||||
outer.addLayout(header)
|
||||
outer.addLayout(form)
|
||||
outer.addWidget(self._meta)
|
||||
outer.addWidget(self._progress)
|
||||
outer.addWidget(self._status)
|
||||
self.setLayout(outer)
|
||||
|
||||
# Give the card a bit more breathing room so controls don't feel cramped.
|
||||
self.setMinimumHeight(self.sizeHint().height() + 16)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
name = self._name_value.strip()
|
||||
url = self._url.text().strip()
|
||||
mode = self._mode.currentText().strip() or "video"
|
||||
max_q = self._quality.currentText().strip() or "1080p"
|
||||
save_path = self._save_path.text().strip() or "./downloads"
|
||||
|
||||
pl: dict[str, Any] = {"url": url, "download_mode": mode, "max_download_quality": max_q, "save_path": save_path}
|
||||
if name:
|
||||
pl["name"] = name
|
||||
return pl
|
||||
|
||||
def set_status(self, text: str) -> None:
|
||||
self._status.setText(text)
|
||||
|
||||
def set_index(self, index: int) -> None:
|
||||
self._index = index
|
||||
|
||||
def set_active(self, active: bool) -> None:
|
||||
self._active = bool(active)
|
||||
self._cancel_btn.setEnabled(self._active)
|
||||
if not self._active:
|
||||
self._paused = False
|
||||
self._sync_btn.setText("Sync")
|
||||
else:
|
||||
self._sync_btn.setText("Resume" if self._paused else "Pause")
|
||||
|
||||
def set_paused(self, paused: bool) -> None:
|
||||
self._paused = bool(paused)
|
||||
if self._active:
|
||||
self._sync_btn.setText("Resume" if self._paused else "Pause")
|
||||
|
||||
def set_editing_enabled(self, enabled: bool) -> None:
|
||||
# Editing controls only (Sync/Pause/Cancel must remain usable).
|
||||
self._url.setEnabled(enabled)
|
||||
self._mode.setEnabled(enabled)
|
||||
self._quality.setEnabled(enabled)
|
||||
self._save_path.setEnabled(enabled)
|
||||
self._edit_name_btn.setEnabled(enabled)
|
||||
self._remove_btn.setEnabled(enabled)
|
||||
# Explicitly keep runtime controls enabled even while editing is locked.
|
||||
self._sync_btn.setEnabled(True)
|
||||
self._cancel_btn.setEnabled(self._active)
|
||||
if not enabled and self._name_stack.currentIndex() == 1:
|
||||
# Force exit name edit if a sync starts mid-edit.
|
||||
self._finish_name_edit()
|
||||
|
||||
def playlist_id(self) -> str:
|
||||
url = (self._url.text() or "").strip()
|
||||
return extract_playlist_id(url) or url
|
||||
|
||||
def set_progress(self, progress: float) -> None:
|
||||
pct = max(0, min(100, int(round(progress * 100))))
|
||||
self._progress.setValue(pct)
|
||||
|
||||
def set_last_sync(self, last_sync: str) -> None:
|
||||
self._meta.setText(f"Last sync: {last_sync or 'never'}")
|
||||
|
||||
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://")):
|
||||
errs.append("URL required")
|
||||
mode = self._mode.currentText().strip()
|
||||
if mode not in {"video", "audio", "both"}:
|
||||
errs.append("invalid mode")
|
||||
q = self._quality.currentText().strip().lower()
|
||||
if not q.endswith("p") or not any(ch.isdigit() for ch in q):
|
||||
errs.append("invalid quality")
|
||||
sp = self._save_path.text().strip()
|
||||
if not sp:
|
||||
errs.append("save_path required")
|
||||
return errs
|
||||
|
||||
def _toggle_name_edit(self) -> None:
|
||||
self._name_edit.setText(self._name_value)
|
||||
self._name_stack.setCurrentIndex(1)
|
||||
self._edit_name_btn.setVisible(False)
|
||||
self._name_edit.setFocus()
|
||||
self._name_edit.selectAll()
|
||||
|
||||
def _finish_name_edit(self) -> None:
|
||||
new_name = self._name_edit.text().strip()
|
||||
self._name_value = new_name
|
||||
self._name_label.setText(new_name or "Playlist")
|
||||
self._name_stack.setCurrentIndex(0)
|
||||
self._edit_name_btn.setVisible(True)
|
||||
self.changed.emit()
|
||||
|
||||
def _on_sync_or_pause_clicked(self) -> None:
|
||||
if not self._active:
|
||||
self.sync_clicked.emit(self._index)
|
||||
return
|
||||
self._paused = not self._paused
|
||||
self._sync_btn.setText("Resume" if self._paused else "Pause")
|
||||
self.pause_changed.emit(self._paused)
|
||||
@@ -0,0 +1,214 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
from ..smooth_scroll import enable_smooth_scrolling
|
||||
|
||||
|
||||
class QueuePage(QtWidgets.QWidget):
|
||||
cancel_sync_requested = QtCore.Signal()
|
||||
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
# Map (playlist_id, video_id) to a stable item; its `.row()` tracks sorting moves.
|
||||
self._rows_by_key: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
|
||||
self._pending_by_key: dict[tuple[str, str], dict] = {}
|
||||
self._playlist_labels: dict[str, str] = {}
|
||||
|
||||
self._flush_timer = QtCore.QTimer(self)
|
||||
self._flush_timer.setInterval(150)
|
||||
self._flush_timer.timeout.connect(self._flush_pending)
|
||||
self._flush_timer.start()
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
title = QtWidgets.QLabel("Queue")
|
||||
title.setObjectName("pageTitle")
|
||||
|
||||
top = QtWidgets.QHBoxLayout()
|
||||
top.addWidget(title)
|
||||
top.addStretch(1)
|
||||
clear_btn = QtWidgets.QPushButton("Clear completed")
|
||||
clear_btn.clicked.connect(self._clear_completed)
|
||||
cancel_btn = QtWidgets.QPushButton("Cancel all")
|
||||
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
|
||||
top.addWidget(clear_btn)
|
||||
top.addWidget(cancel_btn)
|
||||
layout.addLayout(top)
|
||||
|
||||
self._table = QtWidgets.QTableWidget(0, 7)
|
||||
self._table.setHorizontalHeaderLabels(["Playlist", "Video ID", "Status", "Progress", "Speed", "ETA", "Target/File"])
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
self._table.setEditTriggers(QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self._table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._table.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self._table.setSortingEnabled(True)
|
||||
self._table.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollMode.ScrollPerPixel)
|
||||
enable_smooth_scrolling(self._table)
|
||||
layout.addWidget(self._table, 1)
|
||||
|
||||
self._hint = QtWidgets.QLabel("Waiting for downloads…")
|
||||
layout.addWidget(self._hint)
|
||||
|
||||
def on_event(self, name: str, payload: dict) -> None:
|
||||
if name not in {"DownloadStarted", "DownloadProgress", "DownloadCompleted", "DownloadFailed"}:
|
||||
return
|
||||
vid = str(payload.get("video_id") or "")
|
||||
if not vid:
|
||||
return
|
||||
pid = str(payload.get("playlist_id") or "")
|
||||
key = (pid, vid)
|
||||
|
||||
latest = dict(payload)
|
||||
latest["_event"] = name
|
||||
self._pending_by_key[key] = latest
|
||||
|
||||
def set_playlist_labels(self, labels: dict[str, str]) -> None:
|
||||
self._playlist_labels = dict(labels)
|
||||
# Update any existing rows to reflect new names.
|
||||
for row in range(self._table.rowCount()):
|
||||
pl_item = self._table.item(row, 0)
|
||||
if pl_item is None:
|
||||
continue
|
||||
pid = pl_item.data(QtCore.Qt.ItemDataRole.UserRole)
|
||||
if not pid:
|
||||
continue
|
||||
pl_item.setText(self._playlist_labels.get(str(pid), str(pid)))
|
||||
|
||||
def _ensure_row(self, key: tuple[str, str]) -> int:
|
||||
vid_item = self._rows_by_key.get(key)
|
||||
if vid_item is not None and vid_item.row() >= 0:
|
||||
return int(vid_item.row())
|
||||
|
||||
pid, vid = key
|
||||
row = self._table.rowCount()
|
||||
self._table.insertRow(row)
|
||||
|
||||
label = self._playlist_labels.get(pid, pid)
|
||||
pl_item = QtWidgets.QTableWidgetItem(label)
|
||||
# Keep the real playlist_id even if the displayed label changes.
|
||||
pl_item.setData(QtCore.Qt.ItemDataRole.UserRole, pid)
|
||||
pl_item.setToolTip(pid)
|
||||
self._table.setItem(row, 0, pl_item)
|
||||
|
||||
vid_item = QtWidgets.QTableWidgetItem(vid)
|
||||
self._table.setItem(row, 1, vid_item)
|
||||
|
||||
self._table.setItem(row, 2, QtWidgets.QTableWidgetItem("queued"))
|
||||
self._table.setItem(row, 3, QtWidgets.QTableWidgetItem(""))
|
||||
self._table.setItem(row, 4, QtWidgets.QTableWidgetItem(""))
|
||||
self._table.setItem(row, 5, QtWidgets.QTableWidgetItem(""))
|
||||
self._table.setItem(row, 6, QtWidgets.QTableWidgetItem(""))
|
||||
self._rows_by_key[key] = vid_item
|
||||
return row
|
||||
|
||||
def _ensure_item(self, row: int, col: int, default: str = "") -> QtWidgets.QTableWidgetItem:
|
||||
item = self._table.item(row, col)
|
||||
if item is None:
|
||||
item = QtWidgets.QTableWidgetItem(default)
|
||||
self._table.setItem(row, col, item)
|
||||
return item
|
||||
|
||||
@QtCore.Slot()
|
||||
def _flush_pending(self) -> None:
|
||||
if not self._pending_by_key:
|
||||
return
|
||||
|
||||
pending = dict(self._pending_by_key)
|
||||
self._pending_by_key.clear()
|
||||
|
||||
sorting_was_enabled = self._table.isSortingEnabled()
|
||||
if sorting_was_enabled:
|
||||
self._table.setSortingEnabled(False)
|
||||
|
||||
try:
|
||||
for key, payload in pending.items():
|
||||
name = str(payload.pop("_event", ""))
|
||||
row = self._ensure_row(key)
|
||||
|
||||
status_item = self._ensure_item(row, 2, "queued")
|
||||
progress_item = self._ensure_item(row, 3, "")
|
||||
speed_item = self._ensure_item(row, 4, "")
|
||||
eta_item = self._ensure_item(row, 5, "")
|
||||
target_item = self._ensure_item(row, 6, "")
|
||||
|
||||
if name == "DownloadStarted":
|
||||
status_item.setText("started")
|
||||
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))))
|
||||
bar = self._table.cellWidget(row, 3)
|
||||
if bar is None:
|
||||
bar = QtWidgets.QProgressBar()
|
||||
bar.setRange(0, 100)
|
||||
bar.setTextVisible(True)
|
||||
self._table.setCellWidget(row, 3, bar)
|
||||
bar.setValue(pct)
|
||||
sp = payload.get("speed")
|
||||
if isinstance(sp, (int, float)) and sp > 0:
|
||||
speed_item.setText(f"{sp/1024/1024:.2f} MiB/s")
|
||||
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))
|
||||
elif name == "DownloadCompleted":
|
||||
status_item.setText("completed")
|
||||
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()
|
||||
bar.setRange(0, 100)
|
||||
bar.setTextVisible(True)
|
||||
self._table.setCellWidget(row, 3, bar)
|
||||
bar.setValue(100)
|
||||
speed_item.setText("")
|
||||
eta_item.setText("")
|
||||
elif name == "DownloadFailed":
|
||||
status_item.setText("failed")
|
||||
self._table.removeCellWidget(row, 3)
|
||||
progress_item.setText("")
|
||||
speed_item.setText("")
|
||||
eta_item.setText("")
|
||||
err = payload.get("error")
|
||||
if err:
|
||||
target_item.setText(str(err))
|
||||
finally:
|
||||
if sorting_was_enabled:
|
||||
self._table.setSortingEnabled(True)
|
||||
|
||||
self._hint.setText(f"{len(self._rows_by_key)} job(s) seen.")
|
||||
|
||||
def _clear_completed(self) -> None:
|
||||
to_remove: list[tuple[int, tuple[str, str]]] = []
|
||||
for key, vid_item in list(self._rows_by_key.items()):
|
||||
row = int(vid_item.row())
|
||||
if row < 0:
|
||||
self._rows_by_key.pop(key, None)
|
||||
continue
|
||||
st = self._table.item(row, 2)
|
||||
if st and st.text() == "completed":
|
||||
to_remove.append((row, key))
|
||||
|
||||
for row, key in sorted(to_remove, key=lambda x: x[0], reverse=True):
|
||||
self._table.removeRow(row)
|
||||
self._rows_by_key.pop(key, None)
|
||||
|
||||
# Rebuild mapping since row indices/items may have shifted.
|
||||
rebuilt: dict[tuple[str, str], QtWidgets.QTableWidgetItem] = {}
|
||||
for r in range(self._table.rowCount()):
|
||||
pl_item = self._table.item(r, 0)
|
||||
v_item = self._table.item(r, 1)
|
||||
if pl_item is None or v_item is None:
|
||||
continue
|
||||
pid = pl_item.data(QtCore.Qt.ItemDataRole.UserRole) or pl_item.text()
|
||||
vid = v_item.text()
|
||||
rebuilt[(str(pid), str(vid))] = v_item
|
||||
self._rows_by_key = rebuilt
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
from ..config_store import load_config, save_config
|
||||
|
||||
|
||||
class SettingsPage(QtWidgets.QWidget):
|
||||
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._config_path: Path | None = None
|
||||
self._config: dict[str, Any] = {}
|
||||
|
||||
layout = QtWidgets.QVBoxLayout(self)
|
||||
title = QtWidgets.QLabel("Settings")
|
||||
title.setObjectName("pageTitle")
|
||||
layout.addWidget(title)
|
||||
|
||||
form = QtWidgets.QFormLayout()
|
||||
|
||||
self._ffmpeg_path = QtWidgets.QLineEdit()
|
||||
self._ffmpeg_path.setPlaceholderText("./bin/ffmpeg.exe (Windows) or ./bin/ffmpeg (Linux)")
|
||||
form.addRow("ffmpeg_path", self._ffmpeg_path)
|
||||
|
||||
self._max_parallel = QtWidgets.QSpinBox()
|
||||
self._max_parallel.setRange(1, 64)
|
||||
form.addRow("max_parallel_downloads", self._max_parallel)
|
||||
|
||||
self._retry_max = QtWidgets.QSpinBox()
|
||||
self._retry_max.setRange(0, 20)
|
||||
form.addRow("retry_max_retries", self._retry_max)
|
||||
|
||||
self._retry_delay = QtWidgets.QDoubleSpinBox()
|
||||
self._retry_delay.setRange(0.0, 60.0)
|
||||
self._retry_delay.setDecimals(2)
|
||||
self._retry_delay.setSingleStep(0.25)
|
||||
form.addRow("retry_delay_seconds", self._retry_delay)
|
||||
|
||||
self._download_delay = QtWidgets.QDoubleSpinBox()
|
||||
self._download_delay.setRange(0.0, 600.0)
|
||||
self._download_delay.setDecimals(2)
|
||||
self._download_delay.setSingleStep(0.25)
|
||||
form.addRow("delay_between_downloads_seconds", self._download_delay)
|
||||
|
||||
form_box = QtWidgets.QGroupBox("Global defaults")
|
||||
form_box.setLayout(form)
|
||||
layout.addWidget(form_box)
|
||||
|
||||
btns = QtWidgets.QHBoxLayout()
|
||||
self._reload_btn = QtWidgets.QPushButton("Reload")
|
||||
self._reload_btn.clicked.connect(self.reload_from_config)
|
||||
self._save_btn = QtWidgets.QPushButton("Save")
|
||||
self._save_btn.clicked.connect(self.save_to_config)
|
||||
btns.addStretch(1)
|
||||
btns.addWidget(self._reload_btn)
|
||||
btns.addWidget(self._save_btn)
|
||||
layout.addLayout(btns)
|
||||
|
||||
self._status = QtWidgets.QLabel("")
|
||||
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)
|
||||
|
||||
# Autosave on focus-out / change.
|
||||
self._ffmpeg_path.editingFinished.connect(self._schedule_autosave)
|
||||
self._max_parallel.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._retry_max.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._retry_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
self._download_delay.valueChanged.connect(lambda _v: self._schedule_autosave())
|
||||
|
||||
def set_config_path(self, path: Path) -> None:
|
||||
self._config_path = path
|
||||
self.reload_from_config()
|
||||
|
||||
@QtCore.Slot()
|
||||
def reload_from_config(self) -> None:
|
||||
if self._config_path is None:
|
||||
self._status.setText("No config loaded yet.")
|
||||
return
|
||||
try:
|
||||
self._suppress_autosave = True
|
||||
cfg = load_config(self._config_path)
|
||||
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))
|
||||
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._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()
|
||||
|
||||
@QtCore.Slot()
|
||||
def save_to_config(self) -> None:
|
||||
if self._config_path is None:
|
||||
self._status.setText("No config loaded yet.")
|
||||
return
|
||||
try:
|
||||
data = dict(self._config or {})
|
||||
data["ffmpeg_path"] = self._ffmpeg_path.text().strip() or data.get("ffmpeg_path")
|
||||
data["max_parallel_downloads"] = int(self._max_parallel.value())
|
||||
data["retry_max_retries"] = int(self._retry_max.value())
|
||||
data["retry_delay_seconds"] = float(self._retry_delay.value())
|
||||
data["delay_between_downloads_seconds"] = float(self._download_delay.value())
|
||||
save_config(self._config_path, data)
|
||||
self._status.setText(f"Saved settings to {self._config_path}.")
|
||||
except Exception as exc:
|
||||
self._status.setText(f"Failed to save settings: {exc}")
|
||||
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PySide6 import QtCore
|
||||
|
||||
from ..core.database.db import Database
|
||||
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]
|
||||
apply: bool = True
|
||||
db_path: Path = Path("app/data/app.db")
|
||||
cancel_flag: threading.Event | None = None
|
||||
pause_flag: threading.Event | None = None
|
||||
|
||||
|
||||
class SyncRunner(QtCore.QObject):
|
||||
"""
|
||||
Runs a sync in the background to keep the UI responsive.
|
||||
"""
|
||||
|
||||
finished = QtCore.Signal(bool, str)
|
||||
|
||||
def __init__(self, bus: EventBus, parent: QtCore.QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._bus = bus
|
||||
self._req: SyncRequest | None = None
|
||||
|
||||
@QtCore.Slot(object)
|
||||
def set_request(self, req: SyncRequest) -> None:
|
||||
self._req = req
|
||||
|
||||
@QtCore.Slot()
|
||||
def run_current(self) -> None:
|
||||
if self._req is None:
|
||||
self.finished.emit(False, "no request")
|
||||
return
|
||||
self.run(self._req)
|
||||
|
||||
@QtCore.Slot(object)
|
||||
def run(self, req: SyncRequest) -> None:
|
||||
try:
|
||||
if req.cancel_flag is not None and req.cancel_flag.is_set():
|
||||
self.finished.emit(False, "cancelled")
|
||||
return
|
||||
|
||||
db = Database(req.db_path.resolve())
|
||||
service = SyncService(db)
|
||||
executor = ActionExecutor(db, concurrency=int(req.playlist_cfg.get("max_parallel_downloads", 2) or 2), event_bus=self._bus)
|
||||
|
||||
actions = service.sync_from_config(req.playlist_cfg)
|
||||
if req.cancel_flag is not None and req.cancel_flag.is_set():
|
||||
self.finished.emit(False, "cancelled")
|
||||
return
|
||||
if req.apply and actions:
|
||||
cancel_check = req.cancel_flag.is_set if req.cancel_flag is not None else None
|
||||
pause_check = req.pause_flag.is_set if req.pause_flag is not None else None
|
||||
asyncio.run(executor.execute(actions, req.playlist_cfg, cancel_check=cancel_check, pause_check=pause_check))
|
||||
|
||||
if req.cancel_flag is not None and req.cancel_flag.is_set():
|
||||
self.finished.emit(False, "cancelled")
|
||||
else:
|
||||
self.finished.emit(True, "done")
|
||||
except Exception as exc:
|
||||
self.finished.emit(False, str(exc))
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6 import QtCore, QtWidgets
|
||||
|
||||
|
||||
class _SmoothWheelFilter(QtCore.QObject):
|
||||
def __init__(self, area: QtWidgets.QAbstractScrollArea, *, duration_ms: int = 140) -> None:
|
||||
super().__init__(area)
|
||||
self._area = area
|
||||
self._duration_ms = max(60, int(duration_ms))
|
||||
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
|
||||
if event.type() != QtCore.QEvent.Type.Wheel:
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
wheel = event # type: ignore[assignment]
|
||||
try:
|
||||
angle_y = wheel.angleDelta().y()
|
||||
pixel_y = wheel.pixelDelta().y()
|
||||
except Exception:
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
dy = pixel_y if pixel_y else angle_y
|
||||
if dy == 0:
|
||||
return True
|
||||
|
||||
sb = self._area.verticalScrollBar()
|
||||
start = sb.value()
|
||||
|
||||
# Map a wheel "step" to a reasonable pixel delta; keep it snappy but not jarring.
|
||||
base_step = 80
|
||||
if pixel_y:
|
||||
delta = -dy
|
||||
else:
|
||||
delta = int(round(-dy / 120.0 * base_step))
|
||||
|
||||
target = max(sb.minimum(), min(sb.maximum(), start + delta))
|
||||
if target == start:
|
||||
return True
|
||||
|
||||
self._anim.stop()
|
||||
self._anim.setDuration(self._duration_ms)
|
||||
self._anim.setStartValue(start)
|
||||
self._anim.setEndValue(target)
|
||||
self._anim.start()
|
||||
return True
|
||||
|
||||
|
||||
def enable_smooth_scrolling(widget: QtWidgets.QAbstractScrollArea, *, duration_ms: int = 140) -> None:
|
||||
"""
|
||||
Enables animated wheel scrolling for QAbstractScrollArea-derived widgets
|
||||
(QListWidget, QTableWidget, QPlainTextEdit, etc.).
|
||||
"""
|
||||
filt = _SmoothWheelFilter(widget, duration_ms=duration_ms)
|
||||
widget.viewport().installEventFilter(filt)
|
||||
# Keep a reference to avoid the filter being GC'd.
|
||||
widget.setProperty("_smooth_wheel_filter", filt)
|
||||
|
||||
+8
-8
@@ -1,18 +1,19 @@
|
||||
"""
|
||||
Entry point for the backend (no GUI).
|
||||
|
||||
For now, this verifies configuration + database setup and can run a one-off sync.
|
||||
Future iterations will wire up scheduler and a GUI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
"""
|
||||
Entry point for the new backend (no GUI). For now, this only verifies
|
||||
that configuration and database setup work. Future iterations will wire
|
||||
up scanner, diff engine, queue, and scheduler.
|
||||
"""
|
||||
|
||||
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.models import SyncActionType
|
||||
from .core.utils.yt import extract_playlist_id
|
||||
from .core.utils.deps import DependencyError
|
||||
|
||||
@@ -37,7 +38,6 @@ def bootstrap(db_path: Path | None = None) -> None:
|
||||
summary = ", ".join(f"{k.name}:{v}" for k, v in counts.items())
|
||||
print(f"Plan → {summary}")
|
||||
# Execute
|
||||
import asyncio
|
||||
try:
|
||||
asyncio.run(executor.execute(actions, pl))
|
||||
except DependencyError as e:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from src.app.core.download.downloader import Downloader
|
||||
from app.core.download.downloader import Downloader
|
||||
|
||||
|
||||
def test_build_format_defaults_to_best_mp4():
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.download.downloader import Downloader
|
||||
from app.core.download.queue_manager import DownloadJob
|
||||
from app.core.events.event_bus import EventBus
|
||||
from app.core.models import PlaylistItem, SyncAction, SyncActionType
|
||||
from app.core.sync.executor import ActionExecutor
|
||||
|
||||
|
||||
def test_executor_emits_sync_events(tmp_path):
|
||||
published: list[tuple[str, dict]] = []
|
||||
|
||||
class TestBus(EventBus):
|
||||
async def publish(self, event_name: str, payload: dict) -> None: # type: ignore[override]
|
||||
published.append((event_name, dict(payload)))
|
||||
|
||||
class StubDB:
|
||||
def update_local_filename(self, playlist_id: str, video_id: str, filename: str) -> None:
|
||||
return None
|
||||
|
||||
def mark_downloaded(self, playlist_id: str, video_id: str, downloaded: bool) -> None:
|
||||
return None
|
||||
|
||||
bus = TestBus()
|
||||
ex = ActionExecutor(StubDB(), concurrency=1, event_bus=bus) # type: ignore[arg-type]
|
||||
|
||||
item = PlaylistItem(playlist_id="p", video_id="v", title="t", playlist_index=1)
|
||||
actions = [SyncAction(SyncActionType.SKIP, item=item, to_name="0001 - t.mp4")]
|
||||
|
||||
asyncio.run(ex.execute(actions, {"url": "p", "save_path": str(tmp_path)}))
|
||||
|
||||
names = [n for n, _ in published]
|
||||
assert "SyncStarted" in names
|
||||
assert "SyncSummary" in names
|
||||
assert "SyncFinished" in names
|
||||
|
||||
summary = [p for n, p in published if n == "SyncSummary"][0]
|
||||
assert summary["playlist_id"] == "p"
|
||||
assert "duration_s" in summary
|
||||
assert isinstance(summary["counts"], dict)
|
||||
|
||||
|
||||
def test_downloader_progress_hook_calls_callback(tmp_path, monkeypatch):
|
||||
callbacks: list[dict] = []
|
||||
|
||||
class DummyYDL:
|
||||
def __init__(self, opts):
|
||||
self.opts = opts
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def download(self, urls):
|
||||
hooks = self.opts.get("progress_hooks") or []
|
||||
for h in hooks:
|
||||
h({"status": "downloading", "downloaded_bytes": 50, "total_bytes": 100, "speed": 1.0, "eta": 1, "filename": "x"})
|
||||
h({"status": "finished", "downloaded_bytes": 100, "total_bytes": 100, "speed": 1.0, "eta": 0, "filename": "x"})
|
||||
|
||||
dummy = type("yt_dlp", (), {"YoutubeDL": DummyYDL})
|
||||
monkeypatch.setitem(sys.modules, "yt_dlp", dummy)
|
||||
|
||||
ffmpeg = tmp_path / "ffmpeg"
|
||||
ffmpeg.write_text("x", encoding="utf-8")
|
||||
|
||||
job = DownloadJob(
|
||||
item=PlaylistItem(playlist_id="p", video_id="v", title="t", playlist_index=1),
|
||||
url="https://example.invalid",
|
||||
output_path=tmp_path / "out.mp4",
|
||||
ffmpeg_path=str(ffmpeg),
|
||||
)
|
||||
job.progress_callback = lambda payload: callbacks.append(dict(payload))
|
||||
|
||||
dl = Downloader()
|
||||
asyncio.run(dl._download(job)) # type: ignore[attr-defined]
|
||||
|
||||
assert callbacks
|
||||
assert any("progress" in c for c in callbacks)
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.service import SyncService
|
||||
|
||||
|
||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||
|
||||
|
||||
def _require_integration():
|
||||
if not os.getenv("INTEGRATION_TEST"):
|
||||
pytest.skip("Set INTEGRATION_TEST=1 to enable real download tests")
|
||||
if not PLAYLIST_URL:
|
||||
pytest.skip("Set TEST_PLAYLIST_URL to enable real download tests")
|
||||
|
||||
|
||||
def _skip_if_bot_check(errors: list[str]) -> None:
|
||||
msg = "\n".join(errors).lower()
|
||||
if "sign in to confirm you’re not a bot" in msg or "sign in to confirm you're not a bot" in msg:
|
||||
pytest.skip("YouTube bot-check blocked download; provide cookies to yt-dlp to run this test reliably")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_integration_download_audio(tmp_path):
|
||||
_require_integration()
|
||||
if not os.getenv("FFMPEG_PATH"):
|
||||
pytest.skip("Set FFMPEG_PATH to a working ffmpeg binary to enable audio extraction downloads")
|
||||
|
||||
db_path = tmp_path / "app.db"
|
||||
save_path = tmp_path / "downloads"
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {
|
||||
"url": PLAYLIST_URL,
|
||||
"download_mode": "audio",
|
||||
"save_path": str(save_path),
|
||||
# Must be set to a real ffmpeg path for this test to pass.
|
||||
"ffmpeg_path": os.getenv("FFMPEG_PATH"),
|
||||
"max_download_quality": "1080p",
|
||||
}
|
||||
|
||||
db = Database(db_path.resolve())
|
||||
service = SyncService(db)
|
||||
actions = service.sync_from_config(cfg)
|
||||
|
||||
download_actions = [a for a in actions if a.type.name == "DOWNLOAD"]
|
||||
if not download_actions:
|
||||
pytest.skip("No download actions produced (playlist empty or already downloaded?)")
|
||||
|
||||
first_vid = download_actions[0].item.video_id if download_actions[0].item else None
|
||||
assert first_vid
|
||||
# For audio mode there should be a single mp3 target for this video id.
|
||||
subset = [a for a in download_actions if a.item and a.item.video_id == first_vid]
|
||||
subset = [a for a in subset if (a.to_name or "").endswith(".mp3")]
|
||||
assert subset
|
||||
|
||||
errors: list[str] = []
|
||||
bus = EventBus()
|
||||
|
||||
async def on_failed(payload):
|
||||
errors.append(str(payload.get("error", "")))
|
||||
|
||||
bus.subscribe("DownloadFailed", on_failed)
|
||||
|
||||
executor = ActionExecutor(db, concurrency=1, event_bus=bus)
|
||||
import asyncio
|
||||
|
||||
asyncio.run(executor.execute(subset, cfg))
|
||||
|
||||
audio_dir = save_path / "audio"
|
||||
assert audio_dir.exists()
|
||||
if not any(p.suffix.lower() == ".mp3" for p in audio_dir.glob("*.mp3")):
|
||||
_skip_if_bot_check(errors)
|
||||
assert False
|
||||
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.service import SyncService
|
||||
|
||||
|
||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||
|
||||
|
||||
def _require_integration():
|
||||
if not os.getenv("INTEGRATION_TEST"):
|
||||
pytest.skip("Set INTEGRATION_TEST=1 to enable real download tests")
|
||||
if not PLAYLIST_URL:
|
||||
pytest.skip("Set TEST_PLAYLIST_URL to enable real download tests")
|
||||
|
||||
|
||||
def _skip_if_bot_check(errors: list[str]) -> None:
|
||||
msg = "\n".join(errors).lower()
|
||||
if "sign in to confirm you’re not a bot" in msg or "sign in to confirm you're not a bot" in msg:
|
||||
pytest.skip("YouTube bot-check blocked download; provide cookies to yt-dlp to run this test reliably")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_integration_download_both(tmp_path):
|
||||
_require_integration()
|
||||
if not os.getenv("FFMPEG_PATH"):
|
||||
pytest.skip("Set FFMPEG_PATH to a working ffmpeg binary to enable audio extraction downloads")
|
||||
|
||||
db_path = tmp_path / "app.db"
|
||||
save_path = tmp_path / "downloads"
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {
|
||||
"url": PLAYLIST_URL,
|
||||
"download_mode": "both",
|
||||
"save_path": str(save_path),
|
||||
# Must be set to a real ffmpeg path for this test to pass.
|
||||
"ffmpeg_path": os.getenv("FFMPEG_PATH"),
|
||||
"max_download_quality": "1080p",
|
||||
}
|
||||
|
||||
db = Database(db_path.resolve())
|
||||
service = SyncService(db)
|
||||
actions = service.sync_from_config(cfg)
|
||||
|
||||
download_actions = [a for a in actions if a.type.name == "DOWNLOAD"]
|
||||
if not download_actions:
|
||||
pytest.skip("No download actions produced (playlist empty or already downloaded?)")
|
||||
|
||||
first_vid = download_actions[0].item.video_id if download_actions[0].item else None
|
||||
assert first_vid
|
||||
subset = [a for a in download_actions if a.item and a.item.video_id == first_vid]
|
||||
|
||||
mp4 = [a for a in subset if (a.to_name or "").endswith(".mp4")]
|
||||
mp3 = [a for a in subset if (a.to_name or "").endswith(".mp3")]
|
||||
assert mp4 and mp3
|
||||
|
||||
errors: list[str] = []
|
||||
bus = EventBus()
|
||||
|
||||
async def on_failed(payload):
|
||||
errors.append(str(payload.get("error", "")))
|
||||
|
||||
bus.subscribe("DownloadFailed", on_failed)
|
||||
|
||||
executor = ActionExecutor(db, concurrency=1, event_bus=bus)
|
||||
import asyncio
|
||||
|
||||
asyncio.run(executor.execute(mp4 + mp3, cfg))
|
||||
|
||||
audio_dir = save_path / "audio"
|
||||
video_dir = save_path / "video"
|
||||
assert audio_dir.exists() and video_dir.exists()
|
||||
has_mp3 = any(p.suffix.lower() == ".mp3" for p in audio_dir.glob("*.mp3"))
|
||||
has_mp4 = any(p.suffix.lower() == ".mp4" for p in video_dir.glob("*.mp4"))
|
||||
if not (has_mp3 and has_mp4):
|
||||
_skip_if_bot_check(errors)
|
||||
assert False
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.service import SyncService
|
||||
|
||||
|
||||
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
|
||||
|
||||
|
||||
def _require_integration():
|
||||
if not os.getenv("INTEGRATION_TEST"):
|
||||
pytest.skip("Set INTEGRATION_TEST=1 to enable real download tests")
|
||||
if not PLAYLIST_URL:
|
||||
pytest.skip("Set TEST_PLAYLIST_URL to enable real download tests")
|
||||
|
||||
|
||||
def _skip_if_bot_check(errors: list[str]) -> None:
|
||||
msg = "\n".join(errors).lower()
|
||||
if "sign in to confirm you’re not a bot" in msg or "sign in to confirm you're not a bot" in msg:
|
||||
pytest.skip("YouTube bot-check blocked download; provide cookies to yt-dlp to run this test reliably")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_integration_download_video(tmp_path):
|
||||
_require_integration()
|
||||
|
||||
db_path = tmp_path / "app.db"
|
||||
save_path = tmp_path / "downloads"
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
cfg = {
|
||||
"url": PLAYLIST_URL,
|
||||
"download_mode": "video",
|
||||
"save_path": str(save_path),
|
||||
"ffmpeg_path": os.getenv("FFMPEG_PATH"),
|
||||
"max_download_quality": "1080p",
|
||||
}
|
||||
|
||||
db = Database(db_path.resolve())
|
||||
service = SyncService(db)
|
||||
actions = service.sync_from_config(cfg)
|
||||
|
||||
download_actions = [a for a in actions if a.type.name == "DOWNLOAD"]
|
||||
if not download_actions:
|
||||
pytest.skip("No download actions produced (playlist empty or already downloaded?)")
|
||||
|
||||
first_vid = download_actions[0].item.video_id if download_actions[0].item else None
|
||||
assert first_vid
|
||||
subset = [a for a in download_actions if a.item and a.item.video_id == first_vid]
|
||||
subset = [a for a in subset if (a.to_name or "").endswith(".mp4")]
|
||||
assert subset
|
||||
|
||||
errors: list[str] = []
|
||||
bus = EventBus()
|
||||
|
||||
async def on_failed(payload):
|
||||
errors.append(str(payload.get("error", "")))
|
||||
|
||||
bus.subscribe("DownloadFailed", on_failed)
|
||||
|
||||
executor = ActionExecutor(db, concurrency=1, event_bus=bus)
|
||||
import asyncio
|
||||
|
||||
asyncio.run(executor.execute(subset, cfg))
|
||||
|
||||
video_dir = save_path / "video"
|
||||
assert video_dir.exists()
|
||||
if not any(p.suffix.lower() == ".mp4" for p in video_dir.glob("*.mp4")):
|
||||
_skip_if_bot_check(errors)
|
||||
assert False
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.models import PlaylistItem, SyncAction, SyncActionType
|
||||
from app.core.sync.executor import ActionExecutor
|
||||
from app.core.sync.reorder import safe_multi_rename
|
||||
|
||||
|
||||
def test_safe_multi_rename_swaps_files(tmp_path: Path):
|
||||
a = tmp_path / "0001 - A.mp4"
|
||||
b = tmp_path / "0002 - B.mp4"
|
||||
a.write_text("A", encoding="utf-8")
|
||||
b.write_text("B", encoding="utf-8")
|
||||
|
||||
safe_multi_rename([(a, b), (b, a)])
|
||||
|
||||
assert (tmp_path / "0001 - A.mp4").read_text(encoding="utf-8") == "B"
|
||||
assert (tmp_path / "0002 - B.mp4").read_text(encoding="utf-8") == "A"
|
||||
|
||||
|
||||
def test_executor_deletes_to_recycle(tmp_path: Path):
|
||||
class StubDB:
|
||||
def clear_file_state(self, playlist_id: str, video_id: str) -> None:
|
||||
return None
|
||||
|
||||
executor = ActionExecutor(StubDB()) # type: ignore[arg-type]
|
||||
|
||||
save_root = tmp_path / "downloads"
|
||||
audio_root = save_root / "audio"
|
||||
video_root = save_root / "video"
|
||||
audio_root.mkdir(parents=True, exist_ok=True)
|
||||
video_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
victim = audio_root / "0001 - X.mp3"
|
||||
victim.write_text("x", encoding="utf-8")
|
||||
|
||||
item = PlaylistItem(playlist_id="p", video_id="v", title="t", playlist_index=1, local_filename=victim.name, downloaded=True)
|
||||
action = SyncAction(SyncActionType.DELETE, item=item, from_name=victim.name)
|
||||
|
||||
executor._apply_deletions([action], audio_root, video_root, {"url": "p"}) # type: ignore[attr-defined]
|
||||
|
||||
assert not victim.exists()
|
||||
recycled = save_root / ".recycle" / "audio" / victim.name
|
||||
assert recycled.exists()
|
||||
@@ -2,9 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from src.app.config.settings import Settings
|
||||
from app.config.settings import Settings
|
||||
|
||||
|
||||
def test_settings_creates_root_config_if_missing(tmp_path, monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user