mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-04 04:53:58 +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"
|
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
|
$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"
|
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
|
# Build .exe using PyInstaller
|
||||||
# Use --add-data to include the src folder so internal imports (like 'import cli') work
|
# Use --add-data to include the src folder so internal imports (like 'import cli') work
|
||||||
pip install pyinstaller
|
pip install pyinstaller
|
||||||
@@ -109,43 +104,6 @@ jobs:
|
|||||||
mv "$GITHUB_WORKSPACE/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/"
|
mv "$GITHUB_WORKSPACE/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/"
|
||||||
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
|
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
|
- name: Archive Linux Package
|
||||||
if: matrix.platform == 'linux'
|
if: matrix.platform == 'linux'
|
||||||
run: |
|
run: |
|
||||||
@@ -161,67 +119,9 @@ jobs:
|
|||||||
${{ github.workspace }}/*.zip
|
${{ github.workspace }}/*.zip
|
||||||
${{ github.workspace }}/*.tar.gz
|
${{ 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:
|
release:
|
||||||
needs: [build, docker]
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.event.inputs.tag, 'v')
|
if: startsWith(github.event.inputs.tag, 'v')
|
||||||
steps:
|
steps:
|
||||||
@@ -248,9 +148,9 @@ jobs:
|
|||||||
- name: Push Docker images
|
- name: Push Docker images
|
||||||
run: |
|
run: |
|
||||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
|
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 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
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
@@ -2,10 +2,7 @@ name: Integration tests (minimal)
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
#push:
|
|
||||||
#branches: [ main, Next ]
|
|
||||||
#pull_request:
|
|
||||||
#branches: [ main, Next ]
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
integration:
|
integration:
|
||||||
@@ -13,34 +10,28 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
INTEGRATION_TEST: '1'
|
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'
|
TEST_PLAYLIST_URL: 'https://www.youtube.com/playlist?list=PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Make bundled linux binaries executable (if present)
|
- name: Set up Python
|
||||||
run: |
|
uses: actions/setup-python@v6
|
||||||
set -euo pipefail
|
with:
|
||||||
if [ -d ./bin/linux ]; then
|
python-version: '3.11'
|
||||||
chmod +x ./bin/linux/* || true
|
cache: 'pip'
|
||||||
ls -l ./bin/linux || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Create venv and install project
|
- name: Install ffmpeg
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3 -m venv .venv
|
sudo apt-get update
|
||||||
. .venv/bin/activate
|
sudo apt-get install -y ffmpeg
|
||||||
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
|
|
||||||
|
|
||||||
- name: Run integration script directly
|
- name: Run integration tests
|
||||||
env:
|
|
||||||
YTPL_DEBUG: '1'
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
. .venv/bin/activate
|
python -m pip install -e ".[test]"
|
||||||
python tests/integration_full_workflow_test.py
|
pytest -m integration
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ name: Unit tests
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [ main ]
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -22,20 +24,20 @@ jobs:
|
|||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v6
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
python3 -m venv .venv
|
|
||||||
. .venv/bin/activate
|
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
# Install project (editable) and test deps
|
# Install project (editable) and test deps
|
||||||
python -m pip install -e .[test] || python -m pip install -e .
|
python -m pip install -e ".[test]"
|
||||||
python -m pip install pytest
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
env:
|
|
||||||
PYTHONPATH: ${{ github.workspace }}
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
. .venv/bin/activate
|
|
||||||
pytest -q
|
pytest -q
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ Create/edit `config/yt-playlist-config.json`:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"ffmpeg_path": "./bin/ffmpeg.exe",
|
"ffmpeg_path": "./bin/ffmpeg.exe",
|
||||||
|
"max_parallel_downloads": 2,
|
||||||
|
"retry_max_retries": 2,
|
||||||
|
"retry_delay_seconds": 1.5,
|
||||||
"playlists": [
|
"playlists": [
|
||||||
{
|
{
|
||||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
|
||||||
@@ -46,10 +49,14 @@ Create/edit `config/yt-playlist-config.json`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
|
|
||||||
- `ffmpeg_path`: `./bin/ffmpeg.exe` (Windows) or `./bin/ffmpeg` (Linux)
|
- `ffmpeg_path`: `./bin/ffmpeg.exe` (Windows) or `./bin/ffmpeg` (Linux)
|
||||||
- `download_mode`: `video`
|
- `download_mode`: `video`
|
||||||
- `max_download_quality`: `1080p`
|
- `max_download_quality`: `1080p`
|
||||||
- `save_path`: `./downloads`
|
- `save_path`: `./downloads`
|
||||||
|
- `max_parallel_downloads`: `2`
|
||||||
|
- `retry_max_retries`: `2`
|
||||||
|
- `retry_delay_seconds`: `1.5`
|
||||||
|
|
||||||
`max_download_quality`:
|
`max_download_quality`:
|
||||||
|
|
||||||
@@ -62,24 +69,30 @@ Defaults:
|
|||||||
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
|
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
|
||||||
- `both`: download muxed `.mp4`, extract `.mp3`, keep both files
|
- `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
|
## Run
|
||||||
|
|
||||||
- Compute-only:
|
- Compute-only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m src.app.cli
|
python -m app.cli
|
||||||
```
|
```
|
||||||
|
|
||||||
- Apply actions:
|
- Apply actions:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m src.app.cli --apply
|
python -m app.cli --apply
|
||||||
```
|
```
|
||||||
|
|
||||||
- Single playlist (0-based index):
|
- Single playlist (0-based index):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m src.app.cli --apply --playlist 0
|
python -m app.cli --apply --playlist 0
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data & Layout
|
## Data & Layout
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"ffmpeg_path": "./bin/ffmpeg.exe",
|
"ffmpeg_path": "./bin/ffmpeg.exe",
|
||||||
|
"max_parallel_downloads": 2,
|
||||||
|
"retry_max_retries": 2,
|
||||||
|
"retry_delay_seconds": 1.5,
|
||||||
"playlists": [
|
"playlists": [
|
||||||
{
|
{
|
||||||
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
|
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
|
||||||
|
|||||||
+3
-17
@@ -3,8 +3,6 @@
|
|||||||
## Python-first Desktop Architecture
|
## Python-first Desktop Architecture
|
||||||
|
|
||||||
- **Primary GUI framework**: `PySide6` (Qt for Python).
|
- **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
|
## Core Features to Implement
|
||||||
|
|
||||||
@@ -12,27 +10,15 @@
|
|||||||
2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker).
|
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.
|
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.
|
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"
|
## 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.
|
- [ ] **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**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
|
||||||
|
|
||||||
## Packaging & Distribution (brief)
|
## 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.
|
- 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.
|
- 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?
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Subject Area
|
## 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.
|
- 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.
|
- Targets power users and archivists who need large-scale, repeatable playlist archiving and ongoing synchronization, with GUI interface.
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Users Definition
|
## 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
|
## Functionality Definition
|
||||||
|
|
||||||
@@ -38,13 +38,10 @@ Individuals who need to download a large number of videos or audio files from a
|
|||||||
## Platforms
|
## Platforms
|
||||||
|
|
||||||
- Desktop: Windows (Primary), Linux
|
- Desktop: Windows (Primary), Linux
|
||||||
- Docker
|
|
||||||
- Possible Future: Web App, Android App (via shared FastAPI backend)
|
|
||||||
|
|
||||||
## Architecture & Languages
|
## Architecture & Languages
|
||||||
|
|
||||||
- Core Engine: Python (yt-dlp wrapper)
|
- Core Engine: Python (yt-dlp)
|
||||||
- Backend API: FastAPI (Local localhost-only boundary)
|
|
||||||
- Desktop Frontend: PySide6 (Qt for Python)
|
- Desktop Frontend: PySide6 (Qt for Python)
|
||||||
- Distribution: PyInstaller / Briefcase (Windows .exe, Linux AppImage)
|
- 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"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ytplst"
|
name = "ytpl-sync"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
description = "YouTube playlist Sync Thing"
|
description = "YouTube playlist Sync Thing"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
@@ -13,12 +13,10 @@ keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"yt-dlp>=2026.3.17",
|
"yt-dlp>=2026.3.17",
|
||||||
|
"PySide6",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
gui = [
|
test = [
|
||||||
"PySide6"
|
|
||||||
]
|
|
||||||
dev = [
|
|
||||||
"pytest",
|
"pytest",
|
||||||
"ruff",
|
"ruff",
|
||||||
"black"
|
"black"
|
||||||
@@ -28,11 +26,11 @@ dev = [
|
|||||||
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
|
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ytplst = "ytplst.main:main"
|
ytpl-sync = "app.cli:main"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-dir = {"" = "src"}
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
include = ["ytplst*"]
|
include = ["app*"]
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ testpaths = tests
|
|||||||
# Collect all standardized tests using the conventional pattern
|
# Collect all standardized tests using the conventional pattern
|
||||||
python_files = test_*.py
|
python_files = test_*.py
|
||||||
addopts = -q
|
addopts = -q
|
||||||
|
markers =
|
||||||
|
integration: real network/file download tests (opt-in)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config.settings import Settings
|
from .config.settings import Settings
|
||||||
@@ -12,6 +13,7 @@ from .core.events.event_bus import EventBus
|
|||||||
import re
|
import re
|
||||||
from .core.utils.yt import extract_playlist_id
|
from .core.utils.yt import extract_playlist_id
|
||||||
from .core.utils.deps import DependencyError
|
from .core.utils.deps import DependencyError
|
||||||
|
from .core.utils.logging_setup import configure_logging
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
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("--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("--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("--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)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log"))
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
db = Database(args.db.resolve())
|
db = Database(args.db.resolve())
|
||||||
service = SyncService(db)
|
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
|
counts[a.type.name] = counts.get(a.type.name, 0) + 1
|
||||||
summary = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items()))
|
summary = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items()))
|
||||||
print(f"Playlist {pid}: {len(actions)} actions → {summary}")
|
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:
|
if args.apply and actions:
|
||||||
try:
|
try:
|
||||||
asyncio.run(executor.execute(actions, pl))
|
asyncio.run(executor.execute(actions, pl))
|
||||||
except DependencyError as e:
|
except DependencyError as e:
|
||||||
print(f"ERROR: {e}")
|
print(f"ERROR: {e}")
|
||||||
|
log.error("dependency error: %s", e)
|
||||||
return 2
|
return 2
|
||||||
db.set_playlist_last_sync(pid)
|
db.set_playlist_last_sync(pid)
|
||||||
print(f"Applied actions for {pid}.")
|
print(f"Applied actions for {pid}.")
|
||||||
|
log.info("playlist=%s applied_actions=%s", pid, len(actions))
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ DEFAULT_CONFIG: Dict[str, Any] = {
|
|||||||
"max_download_quality": "1080p",
|
"max_download_quality": "1080p",
|
||||||
"save_path": "./downloads",
|
"save_path": "./downloads",
|
||||||
"ffmpeg_path": _default_ffmpeg_path(),
|
"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"]),
|
"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"]),
|
"save_path": self.data.get("save_path", DEFAULT_CONFIG["save_path"]),
|
||||||
"ffmpeg_path": self.data.get("ffmpeg_path", DEFAULT_CONFIG["ffmpeg_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]] = []
|
results: List[Dict[str, Any]] = []
|
||||||
|
|||||||
@@ -99,3 +99,10 @@ class Database:
|
|||||||
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
|
||||||
(playlist_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):
|
async def handle_job(self, job: DownloadJob):
|
||||||
try:
|
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
|
job.state = JobState.DOWNLOADING
|
||||||
await self._download(job)
|
await self._download(job)
|
||||||
# Optional local audio extraction when requested
|
# Optional local audio extraction when requested
|
||||||
@@ -68,6 +73,11 @@ class Downloader:
|
|||||||
|
|
||||||
def run():
|
def run():
|
||||||
import yt_dlp # type: ignore
|
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:
|
class _QuietLogger:
|
||||||
def debug(self, msg):
|
def debug(self, msg):
|
||||||
@@ -97,6 +107,62 @@ class Downloader:
|
|||||||
"logger": _QuietLogger(),
|
"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]
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
ydl.download([job.url])
|
ydl.download([job.url])
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import asyncio
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
from ..models import PlaylistItem
|
from ..models import PlaylistItem
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ class DownloadJob:
|
|||||||
error: Optional[str] = None
|
error: Optional[str] = None
|
||||||
ffmpeg_path: Optional[str] = None
|
ffmpeg_path: Optional[str] = None
|
||||||
max_download_quality: 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
|
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
|
||||||
keep_video: bool = True
|
keep_video: bool = True
|
||||||
|
|
||||||
@@ -59,3 +62,6 @@ class QueueManager:
|
|||||||
|
|
||||||
async def enqueue(self, job: DownloadJob):
|
async def enqueue(self, job: DownloadJob):
|
||||||
await self._queue.put(job)
|
await self._queue.put(job)
|
||||||
|
|
||||||
|
async def join(self) -> None:
|
||||||
|
await self._queue.join()
|
||||||
|
|||||||
@@ -1,17 +1,37 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
from .downloader import Downloader
|
from .downloader import Downloader
|
||||||
from .queue_manager import DownloadJob, JobState
|
from .queue_manager import DownloadJob, JobState
|
||||||
|
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||||
|
|
||||||
|
|
||||||
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
|
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
dl = Downloader(ffmpeg_path=job.ffmpeg_path)
|
dl = Downloader(ffmpeg_path=job.ffmpeg_path)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
while attempt <= max_retries:
|
while attempt <= max_retries:
|
||||||
await dl.handle_job(job)
|
await dl.handle_job(job)
|
||||||
|
if job.state == JobState.CANCELLED:
|
||||||
|
return
|
||||||
if job.state == JobState.COMPLETED:
|
if job.state == JobState.COMPLETED:
|
||||||
return
|
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
|
attempt += 1
|
||||||
if attempt <= max_retries:
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import List
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
from ..models import PlaylistItem
|
from ..models import PlaylistItem
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ class PlaylistScanner:
|
|||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
pass
|
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:
|
try:
|
||||||
import yt_dlp # type: ignore
|
import yt_dlp # type: ignore
|
||||||
except Exception as exc: # pragma: no cover - environment dependent
|
except Exception as exc: # pragma: no cover - environment dependent
|
||||||
@@ -29,6 +30,15 @@ class PlaylistScanner:
|
|||||||
"dump_single_json": True,
|
"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]
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
info = ydl.extract_info(playlist_url, download=False)
|
info = ydl.extract_info(playlist_url, download=False)
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
|
|||||||
class DiffEngine:
|
class DiffEngine:
|
||||||
"""
|
"""
|
||||||
Compares remote playlist items, database state, and filesystem to
|
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".
|
based on simple filename scheme "0001 - Title.ext".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
+169
-11
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import time
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable, List
|
from typing import Iterable, List
|
||||||
@@ -13,6 +14,7 @@ from ..database.db import Database
|
|||||||
from ..utils.yt import extract_playlist_id
|
from ..utils.yt import extract_playlist_id
|
||||||
from ..events.event_bus import EventBus
|
from ..events.event_bus import EventBus
|
||||||
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
|
||||||
|
from ..utils.rate_limit import is_youtube_rate_limit_error
|
||||||
|
|
||||||
|
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
@@ -21,8 +23,27 @@ class ActionExecutor:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.bus = event_bus
|
self.bus = event_bus
|
||||||
|
|
||||||
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
|
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict, *, cancel_check=None, pause_check=None) -> None:
|
||||||
self._preflight_dependencies(actions, playlist_cfg)
|
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()
|
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||||
mode = playlist_cfg.get("download_mode", "video")
|
mode = playlist_cfg.get("download_mode", "video")
|
||||||
@@ -34,13 +55,53 @@ class ActionExecutor:
|
|||||||
video_root.mkdir(parents=True, exist_ok=True)
|
video_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# First, handle renames safely in batch per extension
|
# 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
|
# 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
|
# 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:
|
def _preflight_dependencies(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -126,14 +187,84 @@ class ActionExecutor:
|
|||||||
if self.bus:
|
if self.bus:
|
||||||
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
|
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
|
||||||
|
|
||||||
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", "")
|
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):
|
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:
|
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 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)
|
await queue.start(worker)
|
||||||
try:
|
try:
|
||||||
@@ -157,6 +288,12 @@ class ActionExecutor:
|
|||||||
temp_video_root.mkdir(parents=True, exist_ok=True)
|
temp_video_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
for a in actions:
|
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:
|
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
|
||||||
continue
|
continue
|
||||||
vid = a.item.video_id
|
vid = a.item.video_id
|
||||||
@@ -223,8 +360,22 @@ class ActionExecutor:
|
|||||||
jobs.append(job)
|
jobs.append(job)
|
||||||
await queue.enqueue(job)
|
await queue.enqueue(job)
|
||||||
finally:
|
finally:
|
||||||
await queue._queue.join() # wait for all jobs
|
join_task = asyncio.create_task(queue.join())
|
||||||
await queue.stop()
|
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
|
# Persist DB updates for completed jobs
|
||||||
for job in locals().get("jobs", []):
|
for job in locals().get("jobs", []):
|
||||||
@@ -241,6 +392,13 @@ class ActionExecutor:
|
|||||||
# Ensure not marked as downloaded if failed
|
# Ensure not marked as downloaded if failed
|
||||||
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
|
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
|
||||||
if self.bus:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import asdict
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
@@ -9,7 +8,7 @@ from ..models import PlaylistItem, SyncAction
|
|||||||
from ..scanner.playlist_scanner import PlaylistScanner
|
from ..scanner.playlist_scanner import PlaylistScanner
|
||||||
from ..sync.diff_engine import DiffEngine
|
from ..sync.diff_engine import DiffEngine
|
||||||
from ..sync.filesystem import list_files
|
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
|
from ..utils.yt import extract_playlist_id
|
||||||
|
|
||||||
|
|
||||||
@@ -26,11 +25,11 @@ class SyncService:
|
|||||||
return [".mp4"]
|
return [".mp4"]
|
||||||
if mode == "both":
|
if mode == "both":
|
||||||
return [".mp3", ".mp4"]
|
return [".mp3", ".mp4"]
|
||||||
return [".mp3"]
|
return [".mp4"]
|
||||||
|
|
||||||
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
|
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
|
||||||
url: str = playlist_cfg.get("url")
|
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 = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||||
save_path.mkdir(parents=True, exist_ok=True)
|
save_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
@@ -45,7 +44,8 @@ class SyncService:
|
|||||||
auto_sync=int(bool(playlist_cfg.get("auto_sync", False))),
|
auto_sync=int(bool(playlist_cfg.get("auto_sync", False))),
|
||||||
sync_interval_minutes=int(playlist_cfg.get("sync_interval_minutes", 0) or 0),
|
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] = []
|
sanitized: List[PlaylistItem] = []
|
||||||
for it in items:
|
for it in items:
|
||||||
@@ -119,19 +119,3 @@ class SyncService:
|
|||||||
merged_actions.extend(actions)
|
merged_actions.extend(actions)
|
||||||
|
|
||||||
return merged_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
|
from __future__ import annotations
|
||||||
|
|
||||||
"""
|
import asyncio
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .config.settings import Settings
|
from .config.settings import Settings
|
||||||
from .core.database.db import Database
|
from .core.database.db import Database
|
||||||
from .core.sync.service import SyncService
|
from .core.sync.service import SyncService
|
||||||
from .core.sync.executor import ActionExecutor
|
from .core.sync.executor import ActionExecutor
|
||||||
from .core.models import SyncActionType
|
|
||||||
from .core.utils.yt import extract_playlist_id
|
from .core.utils.yt import extract_playlist_id
|
||||||
from .core.utils.deps import DependencyError
|
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())
|
summary = ", ".join(f"{k.name}:{v}" for k, v in counts.items())
|
||||||
print(f"Plan → {summary}")
|
print(f"Plan → {summary}")
|
||||||
# Execute
|
# Execute
|
||||||
import asyncio
|
|
||||||
try:
|
try:
|
||||||
asyncio.run(executor.execute(actions, pl))
|
asyncio.run(executor.execute(actions, pl))
|
||||||
except DependencyError as e:
|
except DependencyError as e:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
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():
|
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 json
|
||||||
import os
|
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):
|
def test_settings_creates_root_config_if_missing(tmp_path, monkeypatch):
|
||||||
|
|||||||
Reference in New Issue
Block a user