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

88 Commits

Author SHA1 Message Date
darkzoul5 7d0c7aa1d5 chore: bump yt-dlp to 2026.6.9 2026-06-11 12:38:15 +00:00
dark_zoul 15f2df0cbf ci: use dedicated token for yt-dlp update PRs 2026-06-11 15:37:52 +03:00
dark_zoul 22756f35db ci: switch workflow path filters to paths-ignore 2026-06-11 13:05:51 +03:00
dark_zoul 48bcf2c9df add new plan 2026-06-04 23:17:19 +03:00
dark_zoul 5f6df549ab switch to pyside6 essentials instead of full pyside6 2026-06-03 21:49:23 +03:00
dark_zoul d7f3b98be4 change default tray behaiviour to no tray 2026-06-03 21:23:47 +03:00
dark_zoul 7afdb24302 feat: new colour scheme 2026-06-03 21:22:23 +03:00
dark_zoul e8f350805b refactor: simplify about page while keeping look same 2026-06-03 20:25:14 +03:00
dark_zoul df4c7d504b feat: add app version to about page 2026-06-03 18:07:11 +03:00
dark_zoul ac5a98a09c refactor: about page now uses buttons for links 2026-06-03 18:00:56 +03:00
dark_zoul 811ff45dc9 feat: dynamic navbar width hopefully 2026-06-03 17:56:43 +03:00
dark_zoul c658b9a90d refactor: change about page layout 2026-06-03 17:53:24 +03:00
dark_zoul b06ab55f99 change about page formating 2026-06-03 17:46:30 +03:00
dark_zoul de315d07e0 feat: add issues link to abut page 2026-06-03 17:46:12 +03:00
dark_zoul 4dc7d95123 add about page 2026-06-03 17:42:16 +03:00
darkzoul5 42ba6310a3 Merge pull request #13 from darkzoul5/chore/refresh-yt-dlp
chore: bump yt-dlp to 2026.3.17
2026-06-03 17:16:31 +03:00
dark_zoul 0a49676c72 ci: bump gh action version 2026-06-03 17:15:46 +03:00
dark_zoul 8ec894fc1f ci: change name of yt-dlp workflow 2026-06-03 17:14:51 +03:00
darkzoul5 868b419d9c chore: bump yt-dlp to 2026.3.17 2026-06-03 14:10:25 +00:00
dark_zoul 56d3ed7fa2 change yt-dlp to an older version for testing 2026-06-03 17:08:57 +03:00
dark_zoul b741ca1783 ci(yt-dlp): fix labels 2026-06-03 17:04:11 +03:00
dark_zoul f4589cd895 refactor: simplify yt-dlp dependency update workflow 2026-06-03 16:59:26 +03:00
dark_zoul 93c87fcd73 update project name 2026-06-03 16:46:37 +03:00
dark_zoul 1817468ed5 add comments to executor .py 2026-06-03 16:25:21 +03:00
dark_zoul 9f65e6e70d add comments to playlist scanner.py and exector.py 2026-06-03 16:24:58 +03:00
dark_zoul ecc37bb1fa add code comments to queue manager and service.py 2026-06-02 23:09:11 +03:00
dark_zoul 8d291ba5e9 add code comments to db.py 2026-06-02 22:51:33 +03:00
dark_zoul 9597928ffb fix: update pyproject.toml 2026-06-02 22:46:42 +03:00
darkzoul5 bc5ead4d19 readme: update badges 2026-05-23 23:50:34 +03:00
darkzoul5 a6c2da7c75 Update README.md 2026-05-23 23:47:45 +03:00
darkzoul5 9a8a1d8660 Update README.md 2026-05-23 23:47:27 +03:00
dark_zoul 7e142fd9c4 ci: add download count badges to release notes 2026-05-23 23:38:48 +03:00
darkzoul5 981e254346 Merge pull request #12 from darkzoul5/dependabot/github_actions/actions/setup-python-6
chore(deps): bump actions/setup-python from 5 to 6
2026-05-23 23:33:36 +03:00
darkzoul5 fc20c02a09 Merge pull request #11 from darkzoul5/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-05-23 23:33:17 +03:00
dependabot[bot] 20abe8243c chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 17:12:44 +00:00
dependabot[bot] b715802059 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 17:12:40 +00:00
dark_zoul 9ec8974496 fix(build): change asset path to absolute 2026-05-17 14:38:25 +03:00
dark_zoul 410984bc09 feat: add “start minimized to tray” setting 2026-05-17 13:56:33 +03:00
dark_zoul 49fedecd43 feat: add app icon;
feat: add app to tray;
2026-05-17 13:51:15 +03:00
dark_zoul 3291c0c88f readme: add playlist name to config example 2026-05-17 13:38:44 +03:00
dark_zoul b0c531389e readme: update with latest info 2026-05-17 13:36:30 +03:00
dark_zoul b0eaa9d2eb Changed the default SQLite DB location from app/data/ to db/ 2026-05-17 13:27:46 +03:00
dark_zoul 5c6f4b92ef fix: change pyinstaller entrypoint from cli to gui 2026-05-17 13:14:22 +03:00
dark_zoul 6e948f16f2 ci: change default tag input 2026-05-17 13:07:36 +03:00
dark_zoul e9d8c55b1c ci: run tests only on code changes 2026-05-17 12:34:44 +03:00
dark_zoul 9ebed4b92a ci: fix name 2026-05-17 12:32:29 +03:00
dark_zoul 988c938a9e feat: add quality options to include 2160p, 1440p, and a best setting. 2026-05-17 12:30:06 +03:00
dark_zoul a1217a78c3 fix(ci): tighten artifact upload path regex 2026-05-17 12:25:22 +03:00
dark_zoul e2af3f3bfd feat(ci): add commits since last tag to release notes 2026-05-17 12:22:43 +03:00
dark_zoul 45eb29fa4d ci(build): change job order 2026-05-17 12:14:38 +03:00
dark_zoul 945688b0a6 readme: fix badge again 2026-05-17 12:11:54 +03:00
dark_zoul 6bd502580b readme: fix badge 2026-05-17 12:11:20 +03:00
dark_zoul 9d19beec08 lint: fix all ruff errorrs 2026-05-17 12:09:58 +03:00
dark_zoul 65b2d4f89c refactor(build): fully rewrite build workflow + release; remove unused workflows 2026-05-17 12:00:41 +03:00
dark_zoul c046c59fd2 ci(build): remove yt-dlp.exe dependency from build workflow 2026-05-17 11:21:14 +03:00
dark_zoul 235d18ada6 ci(build): remove aria2c dependency from build workflow 2026-05-16 22:24:19 +03:00
dark_zoul c7ab6d2657 ci(build): remove docker related cdoe from build workflow 2026-05-16 22:22:48 +03:00
dark_zoul aeaf687e92 Merge branch 'main' of https://github.com/darkzoul5/YoutubePlaylistDownloader 2026-05-16 22:17:04 +03:00
dark_zoul 903389d73c feat: add GUI mvp 2026-05-16 22:17:01 +03:00
darkzoul5 1bb278f3fc Merge pull request #10 from darkzoul5/dependabot/github_actions/actions/checkout-6
chore(deps): bump actions/checkout from 4 to 6
2026-05-16 20:59:54 +03:00
darkzoul5 9115f207dc Merge pull request #9 from darkzoul5/dependabot/github_actions/actions/setup-python-6
chore(deps): bump actions/setup-python from 5 to 6
2026-05-16 20:59:41 +03:00
dependabot[bot] f32175d963 chore(deps): bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 17:12:41 +00:00
dependabot[bot] 1628f3fc8a chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 17:12:35 +00:00
dark_zoul 9c9dd283a6 ci: integration only on dispatch 2026-05-16 18:54:53 +03:00
dark_zoul f88eaf70a7 fix lint 2026-05-16 18:51:10 +03:00
dark_zoul 49d8dcf012 tests: add skips on youtube bot check 2026-05-16 18:39:33 +03:00
dark_zoul b8fb86902e ci: fix 2026-05-16 18:32:30 +03:00
dark_zoul d232137e17 tests: fix changed module name 2026-05-16 18:27:41 +03:00
dark_zoul 00e3f84f35 ci: fix deps 2026-05-16 18:24:08 +03:00
dark_zoul 262f9a556f fix: fix pyproject.toml 2026-05-16 18:15:42 +03:00
dark_zoul 7472eaccc7 feat(backend): add Download progress; add SyncStarted, SyncFinished, SyncSummary 2026-05-16 18:10:40 +03:00
dark_zoul 0436c0b85d refactor: change project name;
ci: fix integration workflow
2026-05-16 18:08:54 +03:00
dark_zoul decc4c675d feat(backend): add queue system; add retry system 2026-05-16 17:53:37 +03:00
dark_zoul 5649fc17dd feat: add persistent logging to console + rotating file 2026-05-16 17:29:45 +03:00
dark_zoul 8550203411 ci/tests: simplify tests and workflows 2026-05-16 17:17:17 +03:00
dark_zoul 17c2df3640 refactor/ci 2026-05-16 17:10:52 +03:00
dark_zoul 7a5db21f47 ci/tests: fix integration workflow 2026-05-16 17:07:30 +03:00
dark_zoul 8cd0c91f29 ci: enable integration tests 2026-05-16 17:05:03 +03:00
dark_zoul f5f5b710c1 tests: add 3 modes integration tests 2026-05-16 17:00:31 +03:00
dark_zoul 62678cf39e refactor 2026-05-16 16:49:51 +03:00
dark_zoul e7f1dbc1f7 fix 2026-05-16 16:32:28 +03:00
dark_zoul 8e3a7e3920 establish config defaults 2026-05-16 16:22:24 +03:00
dark_zoul 5d4cba3df3 refactor: remove docker related files; 2026-05-16 16:20:37 +03:00
dark_zoul b17913e21b readme: update :) 2026-05-16 16:14:57 +03:00
dark_zoul 98fab7838a refactor: remove old tests 2026-05-16 16:13:50 +03:00
dark_zoul 1928f70928 feat: make sure max_video_quality setting is working 2026-05-16 16:07:52 +03:00
dark_zoul 4cd6255b0f readme: update config section 2026-05-16 15:58:05 +03:00
dark_zoul e8535d335d readme: update confg and dependencies sections 2026-05-16 15:55:30 +03:00
72 changed files with 3740 additions and 1294 deletions
+307
View File
@@ -0,0 +1,307 @@
name: Build & Release
on:
workflow_dispatch:
inputs:
tag:
description: "Tag to create (e.g., v2.0.0)"
required: true
default: "v2.0.0"
type: string
force_tag:
description: "Recreate tag if it already exists"
required: false
default: false
type: boolean
draft:
description: "Create release as draft"
required: false
default: true
type: boolean
permissions:
contents: write
jobs:
tag:
name: Create tag
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate input tag
shell: bash
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
if [[ -z "$TAG" ]]; then
echo "tag is required" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
echo "tag must start with 'v' (got: $TAG)" >&2
exit 1
fi
- name: Create/push tag
shell: bash
env:
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force_tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
if [[ "${FORCE}" != "true" ]]; then
echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2
exit 1
fi
git tag -d "${TAG}" || true
git push origin ":refs/tags/${TAG}" || true
fi
git tag "${TAG}" "${GITHUB_SHA}"
git push origin "${TAG}"
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -e .
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}/src
run: pytest
build:
name: Build packages
needs: tag
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
ffmpeg: [bundled, none]
steps:
- uses: actions/checkout@v6
- name: Derive version
id: version
shell: bash
run: |
TAG="${{ inputs.tag }}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- name: Write bundled version file
shell: bash
run: |
set -euo pipefail
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install build deps
run: |
python -m pip install --upgrade pip
pip install -e .
pip install pyinstaller
- name: Build binary (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$ws = "${{ github.workspace }}"
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "$ws/assets/icon.ico" --add-data "$ws/assets/icon.png;assets" --add-data "$ws/version.txt;." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
- name: Build binary (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
pyinstaller --noconfirm --onefile --noconsole --name "ytpl-sync" --icon "${GITHUB_WORKSPACE}/assets/icon.png" --add-data "${GITHUB_WORKSPACE}/assets/icon.png:assets" --add-data "${GITHUB_WORKSPACE}/version.txt:." --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
- name: Stage package
shell: bash
run: |
set -euo pipefail
OS="${{ runner.os }}"
FFMPEG="${{ matrix.ffmpeg }}"
VERSION="${{ steps.version.outputs.version }}"
PKG_ROOT="$GITHUB_WORKSPACE/package"
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/config" "$PKG_ROOT/bin"
# Binary
if [[ "$OS" == "Windows" ]]; then
cp "$GITHUB_WORKSPACE/dist/pyinstaller/ytpl-sync.exe" "$PKG_ROOT/ytpl-sync.exe"
else
cp "$GITHUB_WORKSPACE/dist/pyinstaller/ytpl-sync" "$PKG_ROOT/ytpl-sync.exe"
chmod +x "$PKG_ROOT/ytpl-sync.exe"
fi
# Config: ship example as the default config
python - <<'PY'
import json
import os
from pathlib import Path
workspace = Path(os.environ["GITHUB_WORKSPACE"])
pkg_root = workspace / "package"
src = workspace / "config" / "yt-playlist-config.example.json"
dst = pkg_root / "config" / "yt-playlist-config.json"
data = json.loads(src.read_text(encoding="utf-8"))
os_name = os.environ["RUNNER_OS"]
ffmpeg_mode = os.environ["FFMPEG_MODE"]
if ffmpeg_mode == "bundled":
data["ffmpeg_path"] = "./bin/ffmpeg.exe" if os_name == "Windows" else "./bin/ffmpeg"
else:
data["ffmpeg_path"] = "ffmpeg"
dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
PY
env:
FFMPEG_MODE: ${{ matrix.ffmpeg }}
- name: Bundle FFmpeg (Windows)
if: runner.os == 'Windows' && matrix.ffmpeg == 'bundled'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force -Path "package/bin" | Out-Null
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "ffmpeg.zip"
Expand-Archive "ffmpeg.zip" -DestinationPath "ffmpeg_tmp"
$ffmpegExe = Get-ChildItem -Path "ffmpeg_tmp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1
if (-not $ffmpegExe) { throw "ffmpeg.exe not found in archive" }
Copy-Item $ffmpegExe.FullName "package/bin/ffmpeg.exe"
Remove-Item -Force "ffmpeg.zip"
Remove-Item -Recurse -Force "ffmpeg_tmp"
- name: Bundle FFmpeg (Linux)
if: runner.os == 'Linux' && matrix.ffmpeg == 'bundled'
shell: bash
run: |
set -euo pipefail
mkdir -p package/bin ffmpeg_tmp
curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
chmod +x package/bin/ffmpeg
- name: Archive (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ steps.version.outputs.version }}"
$variant = "${{ matrix.ffmpeg }}"
if ($variant -eq "bundled") {
$name = "ytpl-sync-windows-$version-ffmpeg.zip"
} else {
$name = "ytpl-sync-windows-$version.zip"
}
Compress-Archive -Path "package/*" -DestinationPath $name
- name: Archive (Linux)
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
version="${{ steps.version.outputs.version }}"
variant="${{ matrix.ffmpeg }}"
if [[ "$variant" == "bundled" ]]; then
name="ytpl-sync-linux-${version}-ffmpeg.tar.gz"
else
name="ytpl-sync-linux-${version}.tar.gz"
fi
tar -C package -czf "$name" .
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: packages-${{ runner.os }}-${{ matrix.ffmpeg }}
path: |
ytpl-sync-*.zip
ytpl-sync-*.tar.gz
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Download artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Generate release notes
shell: bash
env:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
git fetch --tags --force
VERSION="${TAG#v}"
REPO="${GITHUB_REPOSITORY}"
prev_tag="$(git tag --sort=-creatordate | grep -Fxv "$TAG" | head -n 1 || true)"
{
echo "### Changes"
echo
if [[ -n "$prev_tag" ]]; then
echo "Compared to \`$prev_tag\`:"
echo
git log "${prev_tag}..${TAG}" --no-merges --pretty=format:'- %s (%h)' || true
else
echo "First tagged release:"
echo
git log "${TAG}" --no-merges --pretty=format:'- %s (%h)' || true
fi
echo
echo
echo "### Reports"
echo "![Downloads](https://img.shields.io/github/downloads/${REPO}/${TAG}/total?style=flat-square&logo=github&label=Downloads)"
echo "![Linux FFmpeg](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-linux-${VERSION}-ffmpeg.tar.gz?style=flat-square&label=Linux+FFmpeg)"
echo "![Linux](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-linux-${VERSION}.tar.gz?style=flat-square&label=Linux)"
echo "![Windows FFmpeg](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-windows-${VERSION}-ffmpeg.zip?style=flat-square&label=Windows+FFmpeg)"
echo "![Windows](https://img.shields.io/github/downloads/${REPO}/${TAG}/ytpl-sync-windows-${VERSION}.zip?style=flat-square&label=Windows)"
echo
} > release-notes.md
- name: Create release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ inputs.tag }}
draft: ${{ inputs.draft }}
body_path: release-notes.md
files: |
artifacts/**/*.zip
artifacts/**/*.tar.gz
-262
View File
@@ -1,262 +0,0 @@
name: Build Release V2
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v0.1.0)"
required: true
default: "v0.1.0"
type: string
permissions:
contents: write
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: pytest
build:
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: windows
os: windows-latest
artifact_name: windows-release
- platform: linux
os: ubuntu-latest
artifact_name: linux-release
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
# --- WINDOWS BUILD ---
- name: Build Windows Package
if: matrix.platform == 'windows'
shell: pwsh
run: |
$VERSION = "${{ steps.version.outputs.version }}"
$WORKSPACE = "${{ github.workspace }}"
New-Item -ItemType Directory -Force -Path "$WORKSPACE/dist/windows/bin"
# yt-dlp
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -OutFile "$WORKSPACE/dist/windows/bin/yt-dlp.exe"
# FFmpeg
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "$WORKSPACE/dist/windows/ffmpeg.zip"
Expand-Archive "$WORKSPACE/dist/windows/ffmpeg.zip" -DestinationPath "$WORKSPACE/dist/windows/ffmpeg_temp"
$ffmpegExe = Get-ChildItem -Path "$WORKSPACE/dist/windows/ffmpeg_temp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1
Move-Item $ffmpegExe.FullName "$WORKSPACE/dist/windows/bin/ffmpeg.exe"
# aria2c (Windows Portable)
Invoke-WebRequest -Uri "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip" -OutFile "$WORKSPACE/dist/windows/aria2c.zip"
Expand-Archive "$WORKSPACE/dist/windows/aria2c.zip" -DestinationPath "$WORKSPACE/dist/windows/aria2_temp"
Move-Item "$WORKSPACE/dist/windows/aria2_temp/aria2-1.37.0-win-64bit-build1/aria2c.exe" "$WORKSPACE/dist/windows/bin/aria2c.exe"
# Build .exe using PyInstaller
# Use --add-data to include the src folder so internal imports (like 'import cli') work
pip install pyinstaller
pyinstaller --onefile --name "yt-playlist-downloader" --workpath "$WORKSPACE/build" --specpath "$WORKSPACE" --distpath "$WORKSPACE/dist" --add-data "$WORKSPACE/src;src" "$WORKSPACE/yt-playlist-main.py"
Move-Item "$WORKSPACE/dist/yt-playlist-downloader.exe" "$WORKSPACE/dist/windows/yt-playlist-downloader.exe"
# Cleanup & Archive
Remove-Item -Recurse -Force "$WORKSPACE/dist/windows/ffmpeg_temp", "$WORKSPACE/dist/windows/aria2_temp", "$WORKSPACE/dist/windows/*.zip"
Compress-Archive -Path "$WORKSPACE/dist/windows/*" -DestinationPath "$WORKSPACE/yt-playlist-windows-$VERSION.zip"
# --- LINUX BUILD ---
- name: Build Linux Package
if: matrix.platform == 'linux'
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/linux/"
cp -r "$GITHUB_WORKSPACE/src" "$GITHUB_WORKSPACE/dist/linux/"
# yt-dlp
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp"
# FFmpeg (static)
curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o "$GITHUB_WORKSPACE/ffmpeg.tar.xz"
mkdir -p "$GITHUB_WORKSPACE/ffmpeg_temp"
tar -xf "$GITHUB_WORKSPACE/ffmpeg.tar.xz" -C "$GITHUB_WORKSPACE/ffmpeg_temp" --strip-components=1
mv "$GITHUB_WORKSPACE/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
- name: Cache aria2c binary (Linux)
if: matrix.platform == 'linux'
id: aria2-cache
uses: actions/cache@v5
with:
path: ${{ github.workspace }}/dist/linux/bin/aria2c
key: aria2c-linux-1.37.0
- name: Build aria2c (Linux)
if: matrix.platform == 'linux' && steps.aria2-cache.outputs.cache-hit != 'true'
run: |
set -e
sudo apt update && sudo apt install -y build-essential pkg-config libssl-dev zlib1g-dev wget
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
cd "$GITHUB_WORKSPACE"
wget https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.gz
tar -xzf aria2-1.37.0.tar.gz
cd aria2-1.37.0
CFLAGS="-Os -s" LDFLAGS="-static" ./configure \
--enable-static --disable-shared \
--disable-libaria2 --without-ca-bundle \
--without-libnettle --without-libgcrypt \
--without-libssh2 --without-libexpat \
--without-libxml2 --without-libsqlite3 \
--with-openssl
make -j"$(nproc)"
strip src/aria2c
cp src/aria2c "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
cd "$GITHUB_WORKSPACE"
rm -rf aria2-1.37.0 aria2-1.37.0.tar.gz
- name: Archive Linux Package
if: matrix.platform == 'linux'
run: |
VERSION="${{ steps.version.outputs.version }}"
# Archive
cd "$GITHUB_WORKSPACE/dist/linux" && tar -czf "$GITHUB_WORKSPACE/yt-playlist-linux-$VERSION.tar.gz" *
- name: Upload Artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: |
${{ github.workspace }}/*.zip
${{ github.workspace }}/*.tar.gz
docker:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Get version
id: version
shell: bash
run: |
TAG="${{ github.event.inputs.tag || inputs.tag }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download Linux Artifact
uses: actions/download-artifact@v8
with:
name: linux-release
path: ${{ github.workspace }}/dist/linux-docker
- name: Prepare Docker build context
run: |
cd "$GITHUB_WORKSPACE/dist/linux-docker"
# Extract Linux artifact which now contains bin/, src/, and yt-playlist-main.py
tar -xzf "$GITHUB_WORKSPACE/dist/linux-docker/yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz"
# Copy Docker-specific files
cp "$GITHUB_WORKSPACE/Dockerfile" .
cp "$GITHUB_WORKSPACE/docker-entrypoint.sh" .
# Ensure config directory is present (not included in Linux zip by default)
if [ ! -d "config" ]; then cp -r "$GITHUB_WORKSPACE/config" . ; fi
# Debug: List context to verify paths match Dockerfile expectations
ls -R
- name: Set docker image names
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:${VERSION}" >> $GITHUB_ENV
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:latest" >> $GITHUB_ENV
- name: Build Docker image
run: |
docker build . -t $RELEASE_IMAGE -t $LATEST_IMAGE
working-directory: ${{ github.workspace }}/dist/linux-docker
- name: Save Docker images
run: |
docker save -o docker-image.tar $RELEASE_IMAGE
docker save -o docker-image-latest.tar $LATEST_IMAGE
working-directory: ${{ github.workspace }}/dist/linux-docker
- name: Upload Docker artifacts
uses: actions/upload-artifact@v7
with:
name: docker-images
path: ${{ github.workspace }}/dist/linux-docker/docker-image*.tar
release:
needs: [build, docker]
runs-on: ubuntu-latest
if: startsWith(github.event.inputs.tag, 'v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: ${{ github.workspace }}/artifacts
- name: Get version
id: version
shell: bash
run: |
TAG="${{ github.event.inputs.tag || inputs.tag }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push Docker images
run: |
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:${{ steps.version.outputs.version }}
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:latest
- name: Create Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.event.inputs.tag }}
draft: true
files: |
${{ github.workspace }}/artifacts/**/*.zip
${{ github.workspace }}/artifacts/**/*.tar.gz
-64
View File
@@ -1,64 +0,0 @@
name: dependency-updates
on:
workflow_dispatch:
schedule:
- cron: "0 12 * * 1"
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-bundled-binaries:
name: Refresh pinned build dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Discover latest aria2 release
id: aria2
shell: bash
run: |
set -euo pipefail
latest_version="$(
git ls-remote --tags --refs https://github.com/aria2/aria2.git 'refs/tags/release-*' \
| awk -F/ '{print $NF}' \
| sed 's/^release-//' \
| sort -V \
| tail -n 1
)"
if [ -z "$latest_version" ]; then
echo "Unable to determine the latest aria2 release." >&2
exit 1
fi
echo "version=$latest_version" >> "$GITHUB_OUTPUT"
- name: Update release workflow pins
shell: bash
env:
ARIA2_VERSION: ${{ steps.aria2.outputs.version }}
run: |
set -euo pipefail
python3 -c 'from pathlib import Path; import os; path = Path(".github/workflows/build_v2.yml"); text = path.read_text(encoding="utf-8"); version = os.environ["ARIA2_VERSION"]; updated = text.replace("release-1.37.0", f"release-{version}").replace("aria2-1.37.0", f"aria2-{version}").replace("aria2c-linux-1.37.0", f"aria2c-linux-{version}"); path.write_text(updated, encoding="utf-8") if updated != text else None; print(f"Updated aria2 references to {version}." if updated != text else "No aria2 references changed.")'
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: refresh aria2 build pins"
title: "Refresh aria2 build pins"
body: |
Automated maintenance update for the bundled aria2 release references used by the release workflow.
branch: dependency-updates/aria2
delete-branch: true
labels: dependencies, maintenance
+14 -23
View File
@@ -2,10 +2,7 @@ name: Integration tests (minimal)
on:
workflow_dispatch:
#push:
#branches: [ main, Next ]
#pull_request:
#branches: [ main, Next ]
jobs:
integration:
@@ -13,34 +10,28 @@ jobs:
runs-on: ubuntu-latest
env:
INTEGRATION_TEST: '1'
# Used by the integration tests to locate ffmpeg reliably.
FFMPEG_PATH: '/usr/bin/ffmpeg'
TEST_PLAYLIST_URL: 'https://www.youtube.com/playlist?list=PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz'
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Make bundled linux binaries executable (if present)
run: |
set -euo pipefail
if [ -d ./bin/linux ]; then
chmod +x ./bin/linux/* || true
ls -l ./bin/linux || true
fi
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
- name: Create venv and install project
- name: Install ffmpeg
run: |
set -euo pipefail
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
# Install project in editable mode. If the 'test' extra exists, prefer it.
python -m pip install -e .[test] || python -m pip install -e .
python -m pip install pytest
sudo apt-get update
sudo apt-get install -y ffmpeg
- name: Run integration script directly
env:
YTPL_DEBUG: '1'
- name: Run integration tests
run: |
set -euo pipefail
. .venv/bin/activate
python tests/integration_full_workflow_test.py
python -m pip install -e ".[test]"
pytest -m integration
+11 -1
View File
@@ -2,7 +2,17 @@ name: Lint Python code
on:
push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
jobs:
lint:
@@ -15,4 +25,4 @@ jobs:
run: pip install ruff
- name: Run linter
run: ruff check .
run: ruff check .
+18 -10
View File
@@ -3,9 +3,17 @@ name: Unit tests
on:
workflow_dispatch:
push:
branches: [ main ]
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches: [ main ]
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -22,20 +30,20 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v6
- name: Create venv and install project
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.11'
cache: 'pip'
- name: Install project
run: |
set -euo pipefail
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
# Install project (editable) and test deps
python -m pip install -e .[test] || python -m pip install -e .
python -m pip install pytest
python -m pip install -e ".[test]"
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: |
set -euo pipefail
. .venv/bin/activate
pytest -q
+95
View File
@@ -0,0 +1,95 @@
name: update yt-dlp
on:
schedule:
- cron: "0 10 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
concurrency:
group: refresh-yt-dlp-pr
cancel-in-progress: true
jobs:
refresh:
name: Update yt-dlp dependency
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Check and bump yt-dlp
id: detect
shell: bash
run: |
set -euo pipefail
python - <<'PY' >> "$GITHUB_OUTPUT"
from __future__ import annotations
import json
import re
from pathlib import Path
from urllib.request import urlopen
def version_tuple(text: str) -> tuple[int, ...]:
parts = re.findall(r"\d+", text)
return tuple(int(p) for p in parts)
pyproject = Path("pyproject.toml")
text = pyproject.read_text(encoding="utf-8")
dep_match = re.search(r'^\s*"yt-dlp>=(?P<version>[^"]+)"\s*,?\s*$', text, re.MULTILINE)
if not dep_match:
raise SystemExit("Could not find yt-dlp dependency in pyproject.toml")
dep_version = dep_match.group("version")
latest_payload = urlopen("https://pypi.org/pypi/yt-dlp/json", timeout=30)
latest_version = json.load(latest_payload)["info"]["version"]
needs_update = version_tuple(latest_version) > version_tuple(dep_version)
print(f"needs_update={'true' if needs_update else 'false'}")
print(f"latest_yt_dlp={latest_version}")
print(f"current_yt_dlp={dep_version}")
if needs_update:
text = re.sub(
r'(^\s*"yt-dlp>=)[^"]+(")',
rf'\g<1>{latest_version}\2',
text,
flags=re.MULTILINE,
)
pyproject.write_text(text, encoding="utf-8")
PY
- name: Create or update pull request
if: steps.detect.outputs.needs_update == 'true'
uses: peter-evans/create-pull-request@v8
with:
# Use a non-GITHUB_TOKEN credential so the resulting PR triggers CI workflows.
# Configure secrets.PR_WORKFLOW_TOKEN with contents:write and pull-requests:write.
token: ${{ secrets.PR_WORKFLOW_TOKEN || github.token }}
branch: chore/refresh-yt-dlp
commit-message: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
title: "chore: bump yt-dlp to ${{ steps.detect.outputs.latest_yt_dlp }}"
body: |
Automated yt-dlp dependency refresh.
- Current version: `${{ steps.detect.outputs.current_yt_dlp }}`
- Latest version: `${{ steps.detect.outputs.latest_yt_dlp }}`
labels: deps
delete-branch: false
add-paths: |
pyproject.toml
+2 -1
View File
@@ -7,7 +7,8 @@ config/yt-playlist-config.json
/*/tmp*
*.code-workspace
/bin/*
/app/data
/db/*
plans
# Byte-compiled / optimized / DLL files
__pycache__/
+48 -37
View File
@@ -1,83 +1,94 @@
# YouTube Playlist Sync
[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml/badge.svg)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml)
[![Unit tests](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml)
![Release](https://img.shields.io/github/v/release/darkzoul5/YoutubePlaylistSync?style=flat-square&label=Release)
![Build-Release](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/build-release.yml?style=flat-square&label=Build-Release)
![Unit Tests](https://img.shields.io/github/actions/workflow/status/darkzoul5/YoutubePlaylistSync/unit-tests.yml?style=flat-square&label=unit-tests)
A cross-platform tool for downloading and keeping in sync a local copy of entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp) & [ffmpeg](https://ffmpeg.org/).
Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options.
Local-first YouTube playlist synchronization client.
## Whats Included
## What's Included
- GUI (PySide6 Essentials) playlist manager + sync runner
- Scanner (yt-dlp extract-only), diff engine, filesystem scan
- Safe reordering via two-pass rename, recycle deletions
- Async download queue with simple retry (yt-dlp Python API)
- SQLite metadata; DB updates on rename/download/delete; `last_sync`
- Optional event publishing for future GUI/logs
- SQLite metadata (`last_sync`, download state)
## Requirements
- Python 3.10+
- `yt-dlp` (pip)
- `ffmpeg` (only needed for audio extraction / "both" mode)
- If you download a `-ffmpeg` release: no extra dependencies
- If you download a non-ffmpeg release: install `ffmpeg` and ensure it's on PATH (needed for `audio` and `both` modes)
Install:
## Download
```bash
pip install -U yt-dlp
```
Download the latest release from this repo's Releases page and pick one:
- `ytpl-sync-windows-{version}-ffmpeg.zip` / `ytpl-sync-linux-{version}-ffmpeg.tar.gz` (ffmpeg bundled)
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
## Configure
Create/edit `config/yt-playlist-config.json`:
Application uses a json config that canbe edited from UI or manually
```json
{
"ffmpeg_path": "./bin/ffmpeg.exe",
"max_parallel_downloads": 2,
"retry_max_retries": 2,
"retry_delay_seconds": 1.5,
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
"download_mode": "audio",
"save_path": "./downloads"
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads",
"name": "my favorite playlist"
}
],
"ffmpeg_path": "./ffmpeg"
]
}
```
`max_download_quality`:
- Limits yt-dlp download quality (e.g. `"2160p"`, `"1440p"`, `"1080p"`, `"720p"`, `"360p"`). This only affects the downloaded video format selection.
- Use `"best"` for no height cap (highest available).
- If the requested max quality isn't available for a video, the best available quality is chosen.
`download_mode`:
- `video`: download playlist videos as muxed `.mp4` (no ffmpeg processing)
- `audio`: download muxed `.mp4`, extract `.mp3`, delete the `.mp4`
- `both`: download muxed `.mp4`, extract `.mp3`, keep both files
- `video`: download playlist videos as `.mp4` (no ffmpeg required)
- `audio`: download video, extract `.mp3`, delete the video file
- `both`: download video, extract `.mp3`, keep both files
Queue / retry:
- `max_parallel_downloads`: number of concurrent download workers.
- `retry_max_retries`: how many times a failed download job is retried.
- `retry_delay_seconds`: base delay before retry; increases with backoff.
## Run
- Compute-only:
- Run `ytpl-sync.exe` (GUI).
```bash
python -m src.app.cli
```
## Tray
- Apply actions:
```bash
python -m src.app.cli --apply
```
- Single playlist (0-based index):
```bash
python -m src.app.cli --apply --playlist 0
```
- The app supports minimizing to tray on close if the OS provides a system tray; use the tray icon menu to quit.
- Tray behavior settings (Settings page):
- `close_to_tray`: close hides to tray (keeps running).
- `minimize_to_tray`: minimize hides to tray.
- `start_minimized_to_tray`: start hidden in tray.
## Data & Layout
- Database: `app/data/app.db`
- Database: `db/app.db`
- Outputs: `<save_path>/audio` and/or `<save_path>/video`
- Recycle bin: `<save_path>/.recycle/{audio,video}`
## Roadmap (short)
- Scheduler (periodic sync), richer retries/logging
- GUI (PySide6) wired to EventBus
- Enhanced config validation
- UX polish (settings, progress, error messages)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

+16 -5
View File
@@ -1,11 +1,22 @@
{
"ffmpeg_path": "./bin/ffmpeg.exe",
"max_parallel_downloads": 2,
"retry_max_retries": 2,
"retry_delay_seconds": 1.5,
"ui": {
"tray": {
"close_to_tray": false,
"minimize_to_tray": false,
"start_minimized_to_tray": false
}
},
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
"download_mode": "audio",
"max_video_quality": "1080p",
"save_path": "./downloads"
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads",
"name": "my favorite playlist"
}
],
"ffmpeg_path": "./ffmpeg"
]
}
-89
View File
@@ -1,89 +0,0 @@
.gitea/
.github/
.venv/
./bin/
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
build/
dist/
*.egg-info/
.eggs/
pip-wheel-metadata/
# Installer logs
pip-log.txt
# Virtual environments
venv/
ENV/
env/
env.bak/
venv.bak/
# pyenv
.python-version
# Test and coverage
.pytest_cache/
.coverage
coverage.xml
htmlcov/
# Type checkers
.mypy_cache/
.dmypy.json
dmypy.json
# Pyright
.pyright/
# IDEs and editors
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Local config and secrets (do NOT include if you intentionally want them in image)
config/yt-playlist-config.json
.env
.env.*
*.secret
secrets.json
# Docker files and Compose (ignore local overrides)
Dockerfile*
docker-compose*.yml
docker-compose*.yaml
# Git and VCS
.git/
.gitignore
# Gitea and CI artifacts
.gitea/workflows/
dist/
# Node (if present)
node_modules/
# Poetry / Pipenv
Pipfile.lock
poetry.lock
# compiled python
*.pyc
-23
View File
@@ -1,23 +0,0 @@
FROM python:3.13-alpine
WORKDIR /app
# Copy application code (package) and bootstrap
COPY yt-playlist-main.py /app/
COPY src/ /app/
COPY config/ /app/config/
# Copy helper binaries from the build context (which includes extracted artifacts)
COPY bin/ffmpeg /app/bin/ffmpeg
COPY bin/yt-dlp /app/bin/yt-dlp
COPY bin/aria2c /app/bin/aria2c
# Copy entrypoint that maps environment variables to CLI flags
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh && chmod +x /app/bin/* || true
# Put the bundled bin directory first in PATH
ENV PATH="/app/bin:${PATH}"
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD [""]
-9
View File
@@ -1,9 +0,0 @@
version: '3.8'
services:
ytplst:
image: git.darkzoul.org/dark_zoul/ytplst:latest
container_name: ytplst
restart: no
volumes:
- /path/to/downloads:/app/downloads
- /path/to/config:/app/config
-146
View File
@@ -1,146 +0,0 @@
#!/bin/sh
# Entry point for the ytplaylist container.
set -e
# Map environment variables to CLI flags
ARGS=""
if [ "${YTPL_DEBUG:-0}" != "0" ]; then
ARGS="$ARGS --debug"
fi
if [ "${YTPL_PRUNE:-0}" != "0" ]; then
ARGS="$ARGS --prune"
fi
if [ "${YTPL_YES:-0}" != "0" ]; then
ARGS="$ARGS --yes"
fi
if [ -n "${YTPL_CONFIG}" ]; then
ARGS="$ARGS --config ${YTPL_CONFIG}"
fi
# If environment-based configuration is provided, materialize it into /app/config/yt-playlist-config.json
# Supported methods (priority order):
# 1) YTPL_CONFIG_JSON -> full JSON payload for the entire config
# 2) YTPL_PLAYLISTS_JSON -> JSON array assigned to 'playlists' key in the base config
# 3) PLAYLIST_{N}_{FIELD} env vars, e.g. PLAYLIST_0_URL, PLAYLIST_0_DOWNLOAD_MODE, etc.
# Top-level overrides (optional): YTPL_MAX_PARALLEL_DOWNLOADS, YTPL_ARIA2C_CONNECTIONS, YTPL_MAX_VIDEO_QUALITY, YTPL_DOWNLOAD_MODE
if [ -n "${YTPL_CONFIG_JSON:-}" ] || [ -n "${YTPL_PLAYLISTS_JSON:-}" ] || env | grep -q '^PLAYLIST_' ; then
python - <<'PY'
import os, json, sys
from pathlib import Path
config_dir = Path('/app/config')
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / 'yt-playlist-config.json'
# Load existing config if present, otherwise start with a minimal default
base = {
'playlists': [
{
'url': 'https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE',
'download_mode': 'audio',
'max_video_quality': '1080p',
'save_path': './downloads',
'archive': 'archive.txt'
}
],
'yt_dlp_path': 'yt-dlp',
'ffmpeg_path': 'ffmpeg',
'aria2c_path': 'aria2c',
'max_parallel_downloads': 10,
'aria2c_connections': 8,
}
if config_path.exists():
try:
with config_path.open('r', encoding='utf-8') as f:
base = json.load(f)
except Exception:
# if existing file is invalid, continue with our base and overwrite below
pass
# 1) Full config JSON
cfg_json = os.environ.get('YTPL_CONFIG_JSON')
if cfg_json:
try:
cfg = json.loads(cfg_json)
with config_path.open('w', encoding='utf-8') as f:
json.dump(cfg, f, indent=2)
except Exception as e:
print('ERROR: failed to parse YTPL_CONFIG_JSON:', e, file=sys.stderr)
sys.exit(1)
sys.exit(0)
# 2) Playlists JSON
pl_json = os.environ.get('YTPL_PLAYLISTS_JSON')
if pl_json:
try:
playlists = json.loads(pl_json)
if isinstance(playlists, list):
base['playlists'] = playlists
else:
raise ValueError('YTPL_PLAYLISTS_JSON must be a JSON array')
except Exception as e:
print('ERROR: failed to parse YTPL_PLAYLISTS_JSON:', e, file=sys.stderr)
sys.exit(1)
# 3) Indexed PLAYLIST_{N}_{FIELD} variables
playlists = {}
for k, v in os.environ.items():
if not k.startswith('PLAYLIST_'):
continue
parts = k.split('_', 2)
if len(parts) < 3:
continue
_, idx, field = parts
try:
i = int(idx)
except Exception:
continue
playlists.setdefault(i, {})[field.lower()] = v
if playlists:
# convert to ordered list
built = [playlists[i] for i in sorted(playlists.keys())]
base['playlists'] = built
# Top-level overrides
overrides = {
'max_parallel_downloads': 'YTPL_MAX_PARALLEL_DOWNLOADS',
'aria2c_connections': 'YTPL_ARIA2C_CONNECTIONS',
'max_video_quality': 'YTPL_MAX_VIDEO_QUALITY',
'download_mode': 'YTPL_DOWNLOAD_MODE',
}
for key, envname in overrides.items():
if envname in os.environ and os.environ[envname] != '':
val = os.environ[envname]
# cast numbers where appropriate
if key in ('max_parallel_downloads', 'aria2c_connections'):
try:
val = int(val)
except Exception:
pass
base[key] = val
# Write resulting config
try:
with config_path.open('w', encoding='utf-8') as f:
json.dump(base, f, indent=2)
except Exception as e:
print('ERROR: failed to write config file:', e, file=sys.stderr)
sys.exit(1)
PY
fi
# Allow the user to pass extra args to the container
if [ "$#" -gt 0 ]; then
exec python -m ytplaylist.cli $ARGS "$@"
else
exec python -m ytplaylist.cli $ARGS
fi
+3 -17
View File
@@ -3,8 +3,6 @@
## Python-first Desktop Architecture
- **Primary GUI framework**: `PySide6` (Qt for Python).
- **Communication Layer**: A local `FastAPI` backend to separate core logic from the UI.
- **IPC Mechanism**: The GUI spawns the FastAPI server on a random high port (binding to `127.0.0.1` ONLY) and communicates via REST/WebSockets.
## Core Features to Implement
@@ -12,27 +10,15 @@
2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker).
3. **Queue Manager**: Visual progress bars for active downloads, showing speed, ETA, and current video title.
4. **Log Viewer**: Real-time streaming of yt-dlp logs for troubleshooting.
5. **Settings Panel**: Global settings for binary paths (ffmpeg, aria2c), max parallel jobs, and Docker detection toggle.
5. **Settings Panel**: Global settings for binary paths (ffmpeg), max parallel jobs, and Docker detection toggle.
## Phase 1 Roadmap: "The Bridge"
- [ ] **Refactor `src/manager.py`**: Convert CLI-first execution to async-compatible methods for FastAPI consumption.
- [ ] **FastAPI Integration**: Create endpoints for `/playlists`, `/status`, and `/download/start`.
- [ ] **PySide6 Skeleton**: Basic window with `QWebEngine` (if hybrid) or native `QWidget` dashboard.
- [ ] **Packaging**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
## Packaging & Distribution (brief)
- Bundle the backend and GUI into one distributable. The GUI should spawn the local API process (background subprocess) on startup.
- Bundle the backend and GUI into one distributable.
- Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX.
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
- Security: bind the local API to `localhost` only, use a short-lived token or IPC for authentication between GUI and backend, and avoid exposing unnecessary ports.
## Roadmap (GUI → Web → Mobile)
1. Desktop prototype: `FastAPI` backend + `PySide6` GUI (thin client) with basic playlist add/update/download controls and status streaming.
2. Packaging: create Windows exe/installer and Linux AppImage for the prototype.
3. Web frontend: build a web SPA that consumes the same backend API (hosted or local) — this reuses business logic with minimal change.
4. Android: either a native app or cross-platform UI (Flutter/React Native) that calls the backend API; alternatively host the backend and make a thin mobile client.
If you want, I can now: scaffold a minimal `FastAPI` backend and `PySide6` desktop starter in this repo, or produce concise packaging steps for Windows and Linux. Which do you prefer?
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
+161
View File
@@ -0,0 +1,161 @@
# MP3 Metadata Plan
## Subject Area
- Add MP3 tag writing for downloaded YouTube playlist items.
- Scope is limited to `.mp3` outputs produced by `audio` mode and the MP3 side of `both` mode.
- Metadata is sourced from YouTube/yt-dlp and embedded after audio extraction.
## Goal
- Write useful MP3 metadata for downloaded playlist items without affecting video-only downloads.
- Keep the implementation reliable when optional fields are missing.
- Preserve successful downloads even when metadata embedding partially fails.
- Provide a per-playlist setting to enable or disable MP3 metadata embedding.
## Required Metadata
- `title` ← video title
- `artist` ← uploader, fallback to channel
- `album` ← album name if present
- `tracknumber` ← playlist index
- `date` / `year` ← upload date
- `comment` ← source URL
- `genre` ← if available
- `album_art` ← thumbnail
## Configuration Requirement
- Add a per-playlist setting to turn MP3 metadata embedding on or off.
- Default should be explicitly defined during implementation; recommended default is `enabled` for new configs.
- The setting should only affect `.mp3` metadata writing and should not change download selection, extraction, or `.mp4` handling.
## Current Constraints
- The current playlist scan keeps only a minimal item shape: title, video id, and playlist index.
- The scanner uses flat extraction, which is sufficient for diffing but not for full tag data.
- MP3 extraction currently transcodes audio but does not write ID3 metadata.
## Implementation Strategy
- Keep playlist diffing fast by retaining the current flat scan for remote playlist structure.
- Fetch full metadata only for items that are actually going to be downloaded or repaired.
- Write metadata only after MP3 extraction completes successfully.
- Treat metadata embedding as a post-processing step that can fail softly without discarding the MP3.
## Work Breakdown
### 1. Extend the metadata model
- Add optional fields to `PlaylistItem` for:
- uploader
- channel
- album
- upload_date
- genre
- thumbnail_url
- webpage_url
- Keep `artist` as a derived value instead of storing a separate field.
### 2. Fetch full per-video metadata
- Introduce a metadata fetch step for each item selected for download.
- Use yt-dlp per-video extraction to retrieve richer fields than the flat playlist entry provides.
- Prefer canonical values from the video page payload for upload date, uploader/channel, album, genre, thumbnail, and source URL.
### 3. Carry metadata through the download pipeline
- Ensure the enriched `PlaylistItem` reaches the download job and post-processing stage.
- Keep this propagation in-memory unless restart-safe metadata persistence becomes necessary later.
- Avoid changing unrelated sync behavior for video-only items.
- Carry the per-playlist MP3 metadata enabled/disabled setting into the post-processing step.
### 4. Add an MP3 tag writer
- Add `mutagen` as the ID3 writing dependency.
- Implement a focused tagging component that maps `PlaylistItem` metadata into ID3 frames.
- Omit fields when the source value is missing instead of writing placeholders.
### 5. Map fields into ID3 tags
- `title` → video title
- `artist` → uploader, fallback to channel
- `album` → album if present
- `tracknumber` → playlist index
- `date/year` → parsed upload date
- `comment` → canonical source URL
- `genre` → genre if present
### 6. Embed album art
- Download the selected thumbnail for the video after the media download succeeds.
- Attach thumbnail data as embedded cover art when the image type is supported.
- Fail soft if thumbnail retrieval or embedding fails, and keep the MP3 intact.
### 7. Integrate into modes
- `audio` mode:
- download source media
- extract MP3
- write MP3 tags only when the setting is enabled
- delete temporary/source MP4 if configured
- `both` mode:
- download source media
- extract MP3
- write MP3 tags only when the setting is enabled
- keep MP4 unchanged
- `video` mode:
- no MP3 tagging path
### 8. Add configuration surface
- Add the new per-playlist setting to the playlist config model and default config output.
- Expose the setting in the playlist configuration UI, not as a global app setting.
- Keep the naming explicit, for example `write_mp3_metadata` or `embed_mp3_metadata`.
## Error Handling Rules
- If download fails, no tagging runs.
- If extraction fails, no tagging runs.
- If metadata embedding is disabled, skip the tagging step entirely.
- If tagging fails, mark the tag step as failed in logs/events but keep the MP3 file.
- If thumbnail embedding fails, continue with text metadata only.
- Missing `album` or `genre` is normal and should not be treated as an error.
## Testing Plan
- Unit test metadata mapping from yt-dlp info to the internal metadata model.
- Unit test ID3 writing against a temporary MP3 fixture.
- Unit test fallback behavior:
- uploader missing, channel present
- album missing
- genre missing
- thumbnail missing
- Integration test the audio post-processing path with tagging mocked.
- Integration test the both-mode MP3 path with tagging mocked.
## Documentation Updates
- Document that MP3 tags are written only for `.mp3` outputs.
- Document the new per-playlist setting that enables or disables MP3 metadata embedding.
- Document the field fallback rules, especially artist and album behavior.
- Document that album art comes from the video thumbnail, not playlist artwork.
- Document that some YouTube items will not expose album or genre information.
## Dependency Decision
- Recommended library: `mutagen`
- Reason:
- direct ID3 support
- reliable field-level control
- suitable for embedding cover art
- avoids depending on ffmpeg metadata flags for all tag logic
## Delivery Order
- First: add config setting and defaults
- Second: extend metadata model and add full metadata fetch
- Third: add MP3 tag writer and field mapping
- Fourth: add thumbnail embedding
- Fifth: wire tagging into `audio` and `both`
- Sixth: add tests and docs
+3 -6
View File
@@ -2,7 +2,7 @@
## Subject Area
- Tool for downloading and synchronizing YouTube playlists.
- Tool for downloading and synchronizing local YouTube playlists.
- Focuses on batch downloading, format selection (audio and/or video), configurable quality and keeping local copies synced with playlist changes.
- Targets power users and archivists who need large-scale, repeatable playlist archiving and ongoing synchronization, with GUI interface.
@@ -14,7 +14,7 @@
## Users Definition
Individuals who need to download a large number of videos or audio files from a YouTube playlist and keep it updated
Individuals who need to have a local youtube playlist synced with a large number of videos or audio files
## Functionality Definition
@@ -38,13 +38,10 @@ Individuals who need to download a large number of videos or audio files from a
## Platforms
- Desktop: Windows (Primary), Linux
- Docker
- Possible Future: Web App, Android App (via shared FastAPI backend)
## Architecture & Languages
- Core Engine: Python (yt-dlp wrapper)
- Backend API: FastAPI (Local localhost-only boundary)
- Core Engine: Python (yt-dlp)
- Desktop Frontend: PySide6 (Qt for Python)
- Distribution: PyInstaller / Briefcase (Windows .exe, Linux AppImage)
@@ -1,8 +1,5 @@
# YouTube Playlist Sync — Project Conversion Plan
Repository:
- [darkzoul5/YoutubePlaylistDownloader](https://github.com/darkzoul5/YoutubePlaylistDownloader?utm_source=chatgpt.com)
---
+121
View File
@@ -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.
+9 -11
View File
@@ -3,36 +3,34 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ytplst"
version = "1.1.1"
description = "YouTube playlist Sync Thing"
name = "ytpl-sync"
version = "2.1.1"
description = "YouTube playlist Sync"
readme = "README.md"
authors = [ { name = "Dark_Zoul" } ]
license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10"
dependencies = [
"yt-dlp>=2026.3.17",
"yt-dlp>=2026.6.9",
"PySide6_Essentials>=6.11.1",
]
[project.optional-dependencies]
gui = [
"PySide6"
]
dev = [
test = [
"pytest",
"ruff",
"black"
]
[project.urls]
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
Home = "https://github.com/darkzoul5/YoutubePlaylistSync"
[project.scripts]
ytplst = "ytplst.main:main"
ytpl-sync = "app.cli:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
include = ["ytplst*"]
include = ["app*"]
+2
View File
@@ -3,3 +3,5 @@ testpaths = tests
# Collect all standardized tests using the conventional pattern
python_files = test_*.py
addopts = -q
markers =
integration: real network/file download tests (opt-in)
+10 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import asyncio
import logging
from pathlib import Path
from .config.settings import Settings
@@ -12,16 +13,21 @@ from .core.events.event_bus import EventBus
import re
from .core.utils.yt import extract_playlist_id
from .core.utils.deps import DependencyError
from .core.utils.logging_setup import configure_logging
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="YouTube Playlist Sync — compute/apply actions")
parser.add_argument("--apply", action="store_true", help="Apply actions (otherwise compute-only)")
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("db/app.db"), help="Path to SQLite database")
parser.add_argument("--playlist", type=int, default=None, help="Only run for a specific playlist index (0-based)")
parser.add_argument("--verbose", action="store_true", help="Print detailed events (rename/recycle/start)")
parser.add_argument("--debug", action="store_true", help="Enable debug logging to console + app/data/app.log")
args = parser.parse_args(argv)
configure_logging(verbose=bool(args.debug), log_file=Path("app/data/app.log"))
log = logging.getLogger(__name__)
settings = Settings()
db = Database(args.db.resolve())
service = SyncService(db)
@@ -39,7 +45,6 @@ def main(argv: list[str] | None = None) -> int:
print(f"START: {vid}{target}")
async def on_completed(payload):
pid = payload.get("playlist_id")
vid = payload.get("video_id")
target = payload.get("target")
print(f"OK: {vid}{target}")
@@ -88,14 +93,17 @@ def main(argv: list[str] | None = None) -> int:
counts[a.type.name] = counts.get(a.type.name, 0) + 1
summary = ", ".join(f"{k}:{v}" for k, v in sorted(counts.items()))
print(f"Playlist {pid}: {len(actions)} actions → {summary}")
log.info("playlist=%s actions=%s summary=%s", pid, len(actions), summary)
if args.apply and actions:
try:
asyncio.run(executor.execute(actions, pl))
except DependencyError as e:
print(f"ERROR: {e}")
log.error("dependency error: %s", e)
return 2
db.set_playlist_last_sync(pid)
print(f"Applied actions for {pid}.")
log.info("playlist=%s applied_actions=%s", pid, len(actions))
return 0
+48 -13
View File
@@ -1,39 +1,74 @@
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List
def _default_ffmpeg_path() -> str:
if os.name == "nt":
return "./bin/ffmpeg.exe"
return "./bin/ffmpeg"
DEFAULT_CONFIG: Dict[str, Any] = {
"playlists": [],
"download_mode": "audio",
"max_video_quality": "1080p",
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads",
"ffmpeg_path": "ffmpeg",
"ffmpeg_path": _default_ffmpeg_path(),
"max_parallel_downloads": 2,
"retry_max_retries": 2,
"retry_delay_seconds": 1.5,
}
class Settings:
def __init__(self, config_path: Optional[Path] = None) -> None:
def __init__(self) -> None:
base_dir = Path("config")
base_dir.mkdir(parents=True, exist_ok=True)
self.path = (config_path or (base_dir / "yt-playlist-config.json")).resolve()
self.path = (base_dir / "yt-playlist-config.json").resolve()
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
if self.path.exists():
try:
self.data.update(json.loads(self.path.read_text(encoding="utf-8")))
except Exception:
# Leave defaults if invalid JSON; validation can be added later.
pass
# Ensure there is always a config file at the default path.
if not self.path.exists():
self._write_default_config(self.path)
self._load_from_path(self.path)
def _load_from_path(self, path: Path) -> None:
try:
self.data.update(json.loads(path.read_text(encoding="utf-8")))
except Exception:
# Leave defaults if invalid JSON; validation can be added later.
pass
def _write_default_config(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
default_payload: Dict[str, Any] = {
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
"download_mode": "video",
"max_download_quality": "1080p",
"save_path": "./downloads",
}
],
"ffmpeg_path": _default_ffmpeg_path(),
}
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
@property
def playlists(self) -> List[Dict[str, Any]]:
global_defaults = {
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]),
"max_video_quality": self.data.get("max_video_quality", DEFAULT_CONFIG["max_video_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"]),
"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]] = []
+23
View File
@@ -33,6 +33,13 @@ CREATE TABLE IF NOT EXISTS playlist_items (
class Database:
"""Thin SQLite persistence layer for playlists and playlist items.
The database stores the local synchronization state so the sync pipeline
can compare remote playlist data with what has already been downloaded,
renamed, or marked as removed.
"""
def __init__(self, db_path: Path) -> None:
self.path = db_path
self.path.parent.mkdir(parents=True, exist_ok=True)
@@ -41,10 +48,12 @@ class Database:
self._migrate()
def _migrate(self) -> None:
"""Create the schema if this database has not been initialized yet."""
with self._conn:
self._conn.executescript(SCHEMA)
def upsert_playlist_items(self, rows: Iterable[tuple]):
"""Insert or refresh the cached metadata for playlist entries."""
sql = (
"INSERT INTO playlist_items (playlist_id, video_id, title, playlist_index, local_filename, downloaded, last_seen) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
@@ -56,6 +65,7 @@ class Database:
self._conn.executemany(sql, rows)
def get_items_index(self, playlist_id: str) -> dict[str, sqlite3.Row]:
"""Return all cached items for a playlist keyed by video id."""
cur = self._conn.execute(
"SELECT * FROM playlist_items WHERE playlist_id = ?",
(playlist_id,),
@@ -63,6 +73,7 @@ class Database:
return {row["video_id"]: row for row in cur.fetchall()}
def upsert_playlist(self, *, id: str, name: str | None, url: str, path: str, mode: str, auto_sync: int = 0, sync_interval_minutes: int = 0) -> None:
"""Insert or update the playlist configuration row."""
sql = (
"INSERT INTO playlists (id, name, url, path, mode, auto_sync, sync_interval_minutes, last_sync) "
"VALUES (?, ?, ?, ?, ?, ?, ?, NULL) "
@@ -73,6 +84,7 @@ class Database:
self._conn.execute(sql, (id, name, url, path, mode, auto_sync, sync_interval_minutes))
def update_local_filename(self, playlist_id: str, video_id: str, local_filename: str | None) -> None:
"""Record the current filename associated with a playlist item."""
with self._conn:
self._conn.execute(
"UPDATE playlist_items SET local_filename = ?, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
@@ -80,6 +92,7 @@ class Database:
)
def mark_downloaded(self, playlist_id: str, video_id: str, downloaded: bool) -> None:
"""Mark whether a playlist item is present on disk."""
with self._conn:
self._conn.execute(
"UPDATE playlist_items SET downloaded = ?, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
@@ -87,6 +100,7 @@ class Database:
)
def clear_file_state(self, playlist_id: str, video_id: str) -> None:
"""Clear filename and downloaded flags after a deletion or recycle."""
with self._conn:
self._conn.execute(
"UPDATE playlist_items SET local_filename = NULL, downloaded = 0, last_seen = datetime('now') WHERE playlist_id = ? AND video_id = ?",
@@ -94,8 +108,17 @@ class Database:
)
def set_playlist_last_sync(self, playlist_id: str) -> None:
"""Store the timestamp of the most recent successful sync."""
with self._conn:
self._conn.execute(
"UPDATE playlists SET last_sync = datetime('now') WHERE id = ?",
(playlist_id,),
)
def get_playlist_last_sync(self, playlist_id: str) -> str | None:
"""Return the last sync timestamp for a playlist, if any."""
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"]
+98 -1
View File
@@ -15,8 +15,42 @@ class Downloader:
self.yt_dlp_path = yt_dlp_path
self.ffmpeg_path = ffmpeg_path
@staticmethod
def build_format(max_download_quality) -> str:
def parse_height_cap(value) -> int | None:
if value is None:
return 1080
if isinstance(value, int):
return value if value > 0 else None
s = str(value).strip().lower()
if not s:
return 1080
if s in {"best", "max", "auto"}:
return None
if s in {"none", "null"}:
return 1080
digits = "".join(ch for ch in s if ch.isdigit())
if not digits:
return None
try:
cap = int(digits)
except Exception:
return None
return cap if cap > 0 else None
cap = parse_height_cap(max_download_quality)
if cap is not None:
#if the requested cap isn't available, we still download the best mp4.
return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]"
return "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]"
async def handle_job(self, job: DownloadJob):
try:
cancel_check = getattr(job, "cancel_check", None)
if callable(cancel_check) and cancel_check():
job.state = JobState.CANCELLED
job.error = "cancelled"
return
job.state = JobState.DOWNLOADING
await self._download(job)
# Optional local audio extraction when requested
@@ -39,6 +73,11 @@ class Downloader:
def run():
import yt_dlp # type: ignore
from pathlib import Path
try:
from yt_dlp.utils import DownloadCancelled # type: ignore
except Exception: # pragma: no cover - optional
DownloadCancelled = Exception # type: ignore[misc,assignment]
class _QuietLogger:
def debug(self, msg):
@@ -53,12 +92,14 @@ class Downloader:
outtmpl = str(job.output_path)
fmt = self.build_format(getattr(job, "max_download_quality", None))
# All modes download a single muxed mp4 when possible.
# This avoids any ffmpeg-driven merging during the download step, satisfying:
# - video: "original file, no processing"
# - audio/both: extraction is done separately after download
ydl_opts = {
"format": "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]",
"format": fmt,
"outtmpl": outtmpl,
"noplaylist": True,
"quiet": True,
@@ -66,6 +107,62 @@ class Downloader:
"logger": _QuietLogger(),
}
progress_cb = getattr(job, "progress_callback", None)
cancel_check = getattr(job, "cancel_check", None)
if progress_cb is not None:
def hook(d):
try:
if callable(cancel_check) and cancel_check():
raise DownloadCancelled("cancelled")
import time
payload = {
"status": d.get("status"),
"downloaded_bytes": d.get("downloaded_bytes"),
"total_bytes": d.get("total_bytes") or d.get("total_bytes_estimate"),
"speed": d.get("speed"),
"eta": d.get("eta"),
"filename": d.get("filename"),
}
total = payload.get("total_bytes")
done = payload.get("downloaded_bytes")
if total and done is not None:
payload["progress"] = float(done) / float(total)
# Throttle progress events to avoid freezing the GUI event loop.
# Always forward terminal states.
status = str(payload.get("status") or "")
now = time.monotonic()
last_ts = float(getattr(job, "_last_progress_emit_ts", 0.0) or 0.0)
last_pct = float(getattr(job, "_last_progress_emit_pct", -1.0) or -1.0)
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
should_emit = status in {"finished", "error"}
if not should_emit:
if now - last_ts >= 0.25:
should_emit = True
elif pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01):
should_emit = True
if should_emit:
setattr(job, "_last_progress_emit_ts", now)
if pct is not None:
setattr(job, "_last_progress_emit_pct", pct)
progress_cb(payload)
except Exception:
pass
ydl_opts["progress_hooks"] = [hook]
# If user provided an ffmpeg path, pass it through to yt-dlp so it doesn't rely on PATH.
ffmpeg_hint = getattr(job, "ffmpeg_path", None) or self.ffmpeg_path
if ffmpeg_hint:
try:
p = Path(str(ffmpeg_hint))
if p.exists():
ydl_opts["ffmpeg_location"] = str(p)
except Exception:
pass
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
ydl.download([job.url])
+19 -1
View File
@@ -4,7 +4,7 @@ import asyncio
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
from typing import Any, Callable, Optional
from ..models import PlaylistItem
@@ -28,11 +28,22 @@ class DownloadJob:
state: JobState = JobState.QUEUED
error: Optional[str] = None
ffmpeg_path: Optional[str] = None
max_download_quality: Optional[str] = None
playlist_id: Optional[str] = None
progress_callback: Optional[Callable[[dict[str, Any]], None]] = None
cancel_check: Optional[Callable[[], bool]] = None
audio_output_path: Optional[Path] = None # when mode=video and we also want mp3
keep_video: bool = True
class QueueManager:
"""A small asyncio worker pool for download jobs.
Jobs are pushed into a shared queue and processed by a fixed number of
background tasks. This keeps the downloader concurrency bounded without
forcing the caller to manage worker lifetimes directly.
"""
def __init__(self, concurrency: int = 2) -> None:
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
self._concurrency = max(1, concurrency)
@@ -40,6 +51,7 @@ class QueueManager:
self._stopped = asyncio.Event()
async def start(self, worker_coro):
"""Start the worker tasks that drain the queue."""
async def runner(idx: int):
while not self._stopped.is_set():
job = await self._queue.get()
@@ -51,10 +63,16 @@ class QueueManager:
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
async def stop(self):
"""Cancel all worker tasks and mark the queue as stopped."""
self._stopped.set()
for w in self._workers:
w.cancel()
self._workers.clear()
async def enqueue(self, job: DownloadJob):
"""Add a job to the shared queue."""
await self._queue.put(job)
async def join(self) -> None:
"""Block until every queued job has been acknowledged."""
await self._queue.join()
+21 -1
View File
@@ -1,17 +1,37 @@
from __future__ import annotations
import asyncio
import logging
from .downloader import Downloader
from .queue_manager import DownloadJob, JobState
from ..utils.rate_limit import is_youtube_rate_limit_error
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
log = logging.getLogger(__name__)
dl = Downloader(ffmpeg_path=job.ffmpeg_path)
attempt = 0
while attempt <= max_retries:
await dl.handle_job(job)
if job.state == JobState.CANCELLED:
return
if job.state == JobState.COMPLETED:
return
if is_youtube_rate_limit_error(job.error):
# Do not retry bot-check/rate-limit style errors; caller will pause.
return
if (job.error or "").strip().lower() == "cancelled":
job.state = JobState.CANCELLED
return
attempt += 1
if attempt <= max_retries:
await asyncio.sleep(delay_seconds)
wait = delay_seconds * (2 ** (attempt - 1))
log.warning(
"retrying download attempt=%s/%s video_id=%s wait=%.1fs error=%s",
attempt,
max_retries,
getattr(getattr(job, "item", None), "video_id", None),
wait,
job.error,
)
await asyncio.sleep(wait)
+18 -5
View File
@@ -1,22 +1,26 @@
from __future__ import annotations
from typing import List
from pathlib import Path
from typing import List, Optional
from ..models import PlaylistItem
class PlaylistScanner:
"""
Fetches remote playlist entries using yt-dlp (no downloads).
Fetches remote playlist entries using yt-dlp without downloading media.
This class intentionally avoids strict dependencies at import time. If
yt_dlp is unavailable, call sites should handle the raised ImportError.
The scanner is deliberately lightweight: it extracts remote metadata only
and leaves persistence, diffing, and download decisions to higher layers.
Import-time dependency checks are avoided so the rest of the application can
still start in environments where yt-dlp is unavailable.
"""
def __init__(self) -> None:
pass
def scan(self, playlist_url: str, playlist_id: str) -> List[PlaylistItem]:
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
"""Return the current remote playlist entries as `PlaylistItem` records."""
try:
import yt_dlp # type: ignore
except Exception as exc: # pragma: no cover - environment dependent
@@ -29,6 +33,15 @@ class PlaylistScanner:
"dump_single_json": True,
}
# If a local ffmpeg binary is configured, pass it through so yt-dlp doesn't rely on PATH.
if ffmpeg_path:
try:
p = Path(str(ffmpeg_path))
if p.exists():
ydl_opts["ffmpeg_location"] = str(p)
except Exception:
pass
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(playlist_url, download=False)
+1 -1
View File
@@ -8,7 +8,7 @@ from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
class DiffEngine:
"""
Compares remote playlist items, database state, and filesystem to
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/REORDER
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/DELETE
based on simple filename scheme "0001 - Title.ext".
"""
+190 -12
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import time
import shutil
from pathlib import Path
from typing import Iterable, List
@@ -13,19 +14,52 @@ from ..database.db import Database
from ..utils.yt import extract_playlist_id
from ..events.event_bus import EventBus
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
from ..utils.rate_limit import is_youtube_rate_limit_error
class ActionExecutor:
"""Apply sync actions against the filesystem and persist their outcome.
The executor is the imperative half of the sync pipeline: it publishes
lifecycle events, performs safe renames and deletions, coordinates the
download queue, and updates the database after each job completes.
"""
def __init__(self, db: Database, concurrency: int = 2, event_bus: EventBus | None = None) -> None:
self.concurrency = max(1, concurrency)
self.db = db
self.bus = event_bus
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
self._preflight_dependencies(actions, playlist_cfg)
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict, *, cancel_check=None, pause_check=None) -> None:
"""Execute a batch of sync actions for one playlist.
The workflow is intentionally ordered: announce the sync, wait for any
pause state to clear, validate dependencies, perform renames, recycle
deletions, and finally run downloads with bounded concurrency.
"""
actions_list = list(actions)
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
start = time.monotonic()
counts: dict[str, int] = {}
for a in actions_list:
counts[a.type.name] = counts.get(a.type.name, 0) + 1
if self.bus:
await self.bus.publish(
"SyncStarted",
{
"playlist_id": playlist_id,
"actions_total": sum(counts.values()),
"counts": dict(counts),
},
)
if not await self._wait_if_paused(pause_check, cancel_check):
return
self._preflight_dependencies(actions_list, playlist_cfg)
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
mode = playlist_cfg.get("download_mode", "audio")
mode = playlist_cfg.get("download_mode", "video")
# Prepare roots
audio_root = save_path / "audio"
@@ -34,13 +68,53 @@ class ActionExecutor:
video_root.mkdir(parents=True, exist_ok=True)
# First, handle renames safely in batch per extension
await self._apply_renames(actions, audio_root, video_root, playlist_cfg)
if not await self._wait_if_paused(pause_check, cancel_check):
return
await self._apply_renames(actions_list, audio_root, video_root, playlist_cfg)
# Then, recycle deletions
self._apply_deletions(actions, audio_root, video_root, playlist_cfg)
if not await self._wait_if_paused(pause_check, cancel_check):
return
self._apply_deletions(actions_list, audio_root, video_root, playlist_cfg)
# Finally, perform downloads concurrently
await self._apply_downloads(actions, mode, audio_root, video_root, playlist_cfg)
if not await self._wait_if_paused(pause_check, cancel_check):
return
await self._apply_downloads(
actions_list,
mode,
audio_root,
video_root,
playlist_cfg,
cancel_check=cancel_check,
pause_check=pause_check,
)
duration_s = round(time.monotonic() - start, 3)
# Persist last sync timestamp (single source of truth for CLI/GUI/automation).
try:
self.db.set_playlist_last_sync(playlist_id)
last_sync = self.db.get_playlist_last_sync(playlist_id)
except Exception:
last_sync = None
summary = {
"playlist_id": playlist_id,
"duration_s": duration_s,
"counts": dict(counts),
"last_sync": last_sync,
}
if self.bus:
await self.bus.publish("SyncSummary", dict(summary))
await self.bus.publish("SyncFinished", dict(summary))
async def _wait_if_paused(self, pause_check, cancel_check) -> bool:
if not callable(pause_check):
return True
while pause_check():
if callable(cancel_check) and cancel_check():
return False
await asyncio.sleep(0.1)
return True
def _preflight_dependencies(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
"""
@@ -62,6 +136,7 @@ class ActionExecutor:
ensure_ffmpeg_available(str(ffmpeg_hint) if ffmpeg_hint is not None else None)
async def _apply_renames(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
"""Apply all rename actions in batches separated by output type."""
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
audio_renames = []
video_renames = []
@@ -91,6 +166,7 @@ class ActionExecutor:
await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name})
def _apply_deletions(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
"""Recycle or remove files that no longer belong to the playlist."""
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
recycle_audio = audio_root.parent / ".recycle" / "audio"
recycle_video = video_root.parent / ".recycle" / "video"
@@ -126,14 +202,85 @@ class ActionExecutor:
if self.bus:
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
async def _apply_downloads(self, actions: Iterable[SyncAction], mode: str, audio_root: Path, video_root: Path, playlist_cfg: dict) -> None:
async def _apply_downloads(
self,
actions: Iterable[SyncAction],
mode: str,
audio_root: Path,
video_root: Path,
playlist_cfg: dict,
*,
cancel_check=None,
pause_check=None,
) -> None:
"""Queue and run download jobs, then persist their final state."""
playlist_id = extract_playlist_id(playlist_cfg.get("url", "")) or playlist_cfg.get("url", "")
queue = QueueManager(concurrency=self.concurrency)
loop = asyncio.get_running_loop()
concurrency_cfg = playlist_cfg.get("max_parallel_downloads", self.concurrency)
try:
concurrency = int(concurrency_cfg) if concurrency_cfg is not None else self.concurrency
except Exception:
concurrency = self.concurrency
queue = QueueManager(concurrency=concurrency)
retry_max_cfg = playlist_cfg.get("retry_max_retries", 2)
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
try:
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
except Exception:
retry_max_retries = 2
try:
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
except Exception:
retry_delay_seconds = 1.5
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
try:
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
except Exception:
delay_between_downloads_seconds = 0.0
rate_limit_pause = asyncio.Event()
rate_limit_emitted = False
async def worker(job: DownloadJob):
nonlocal rate_limit_emitted
job.playlist_id = playlist_id
job.cancel_check = cancel_check
if not await self._wait_if_paused(pause_check, cancel_check):
job.error = "cancelled"
return
if self.bus:
def _progress_cb(info: dict):
payload = dict(info)
payload.setdefault("playlist_id", playlist_id)
if job.item:
payload.setdefault("video_id", job.item.video_id)
loop.call_soon_threadsafe(asyncio.create_task, self.bus.publish("DownloadProgress", payload))
job.progress_callback = _progress_cb
if self.bus and job.item:
await self.bus.publish("DownloadStarted", {"playlist_id": playlist_id, "video_id": job.item.video_id, "target": str(job.output_path)})
await default_worker(job)
await default_worker(job, max_retries=retry_max_retries, delay_seconds=retry_delay_seconds)
# If we hit YouTube bot-check / rate-limit, pause the whole playlist sync:
# - stop scheduling/processing more jobs
# - surface a single SyncPaused event
if is_youtube_rate_limit_error(getattr(job, "error", None)):
rate_limit_pause.set()
if self.bus and not rate_limit_emitted:
rate_limit_emitted = True
await self.bus.publish(
"SyncPaused",
{"playlist_id": playlist_id, "video_id": getattr(getattr(job, "item", None), "video_id", None), "reason": "paused due to youtube rate limits"},
)
return
if delay_between_downloads_seconds > 0 and not (callable(cancel_check) and cancel_check()):
# Gentle throttle between jobs to reduce rate limiting.
await asyncio.sleep(delay_between_downloads_seconds)
await queue.start(worker)
try:
@@ -152,10 +299,17 @@ class ActionExecutor:
d["video"] = a.to_name
ffmpeg_cfg = str(playlist_cfg.get("ffmpeg_path", "ffmpeg")) if playlist_cfg.get("ffmpeg_path") is not None else None
max_quality_cfg = playlist_cfg.get("max_download_quality")
temp_video_root = video_root / ".tmp"
temp_video_root.mkdir(parents=True, exist_ok=True)
for a in actions:
if callable(cancel_check) and cancel_check():
break
if not await self._wait_if_paused(pause_check, cancel_check):
break
if rate_limit_pause.is_set():
break
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
continue
vid = a.item.video_id
@@ -176,6 +330,7 @@ class ActionExecutor:
url=url,
mode="video",
ffmpeg_path=ffmpeg_cfg,
max_download_quality=max_quality_cfg,
audio_output_path=audio_path,
)
jobs.append(job)
@@ -200,6 +355,7 @@ class ActionExecutor:
url=url,
mode="video",
ffmpeg_path=ffmpeg_cfg,
max_download_quality=max_quality_cfg,
audio_output_path=audio_path,
keep_video=False,
)
@@ -215,12 +371,27 @@ class ActionExecutor:
url=url,
mode="video",
ffmpeg_path=ffmpeg_cfg,
max_download_quality=max_quality_cfg,
)
jobs.append(job)
await queue.enqueue(job)
finally:
await queue._queue.join() # wait for all jobs
await queue.stop()
join_task = asyncio.create_task(queue.join())
try:
while not join_task.done():
if callable(cancel_check) and cancel_check():
join_task.cancel()
break
if callable(pause_check) and pause_check():
# Pause requested: stop starting more work and return control.
join_task.cancel()
break
if rate_limit_pause.is_set():
join_task.cancel()
break
await asyncio.sleep(0.1)
finally:
await queue.stop()
# Persist DB updates for completed jobs
for job in locals().get("jobs", []):
@@ -237,6 +408,13 @@ class ActionExecutor:
# Ensure not marked as downloaded if failed
self.db.mark_downloaded(playlist_id, job.item.video_id, False)
if self.bus:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": job.error or "unknown"})
err = job.error or "unknown"
if is_youtube_rate_limit_error(err):
await self.bus.publish(
"DownloadFailed",
{"playlist_id": playlist_id, "video_id": job.item.video_id, "error": "paused due to youtube rate limits"},
)
else:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
except Exception:
pass
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Sequence
from typing import List, Sequence
from ..models import FilesystemEntry
+18 -21
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from typing import List
@@ -9,11 +8,18 @@ from ..models import PlaylistItem, SyncAction
from ..scanner.playlist_scanner import PlaylistScanner
from ..sync.diff_engine import DiffEngine
from ..sync.filesystem import list_files
from ..utils.naming import make_filename, sanitize_title
from ..utils.naming import sanitize_title
from ..utils.yt import extract_playlist_id
class SyncService:
"""High-level orchestration for a single playlist sync pass.
The service pulls the latest remote playlist snapshot, persists the
playlist and item metadata in the database, and asks the diff engine to
compare the remote state with the local filesystem.
"""
def __init__(self, db: Database) -> None:
self.db = db
self.scanner = PlaylistScanner()
@@ -26,11 +32,17 @@ class SyncService:
return [".mp4"]
if mode == "both":
return [".mp3", ".mp4"]
return [".mp3"]
return [".mp4"]
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
"""Return the sync actions required to bring one playlist in sync.
This method does not apply any changes itself. It normalizes the
configuration, refreshes the playlist/item records in SQLite, and then
computes the actions needed for the configured download mode.
"""
url: str = playlist_cfg.get("url")
mode: str = playlist_cfg.get("download_mode", "audio")
mode: str = playlist_cfg.get("download_mode", "video")
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
save_path.mkdir(parents=True, exist_ok=True)
@@ -45,7 +57,8 @@ class SyncService:
auto_sync=int(bool(playlist_cfg.get("auto_sync", False))),
sync_interval_minutes=int(playlist_cfg.get("sync_interval_minutes", 0) or 0),
)
items = self.scanner.scan(url, playlist_id)
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path")
items = self.scanner.scan(url, playlist_id, ffmpeg_path=str(ffmpeg_cfg) if ffmpeg_cfg is not None else None)
sanitized: List[PlaylistItem] = []
for it in items:
@@ -119,19 +132,3 @@ class SyncService:
merged_actions.extend(actions)
return merged_actions
for ext in exts:
mode_dir = "audio" if ext == ".mp3" else "video"
fs_root = (save_path / mode_dir)
fs_entries = list_files(fs_root, [ext])
actions = self.diff.compute_actions(sanitized, db_index, fs_entries, ext)
merged_actions.extend(actions)
return [
{
"type": a.type,
"video_id": a.item.video_id if a.item else None,
"from_name": a.from_name,
"to_name": a.to_name,
}
for a in merged_actions
]
+37
View File
@@ -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]
-3
View File
@@ -1,8 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
ILLEGAL_CHARS = '<>:"/\\|?*'
+23
View File
@@ -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 youre 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",
"youre not a bot",
"--cookies-from-browser",
"--cookies",
]
return any(n in s for n in needles)
+38
View File
@@ -0,0 +1,38 @@
from __future__ import annotations
import sys
from pathlib import Path
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
base = getattr(sys, "_MEIPASS", None)
if base:
return Path(str(base))
return Path.cwd()
def _read_text(path: Path) -> str | None:
try:
if path.exists():
text = path.read_text(encoding="utf-8").strip()
return text or None
except Exception:
pass
return None
def get_app_version() -> str:
"""
Returns the packaged app version.
In release builds this reads from `version.txt` bundled into the EXE.
"""
candidates = [
Path("version.txt"),
_resource_base() / "version.txt",
]
for candidate in candidates:
text = _read_text(candidate)
if text:
return text
return "dev"
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
base = getattr(sys, "_MEIPASS", None)
if base:
return Path(str(base))
return Path.cwd()
def load_app_icon() -> QtGui.QIcon:
"""
Best-effort app icon loader.
Looks for `assets/icon.png` in the current working directory (dev),
or in the PyInstaller bundle root (packaged).
"""
candidates = [
Path("assets/icon.png"),
_resource_base() / "assets" / "icon.png",
]
for p in candidates:
try:
if p.exists():
icon = QtGui.QIcon(str(p))
if not icon.isNull():
return icon
except Exception:
pass
# Fallback to a platform theme icon (Linux) or a generic icon.
try:
themed = QtGui.QIcon.fromTheme("applications-multimedia")
if not themed.isNull():
return themed
except Exception:
pass
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
+40
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, 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
+500
View File
@@ -0,0 +1,500 @@
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 .app_icon import load_app_icon
from .config_store import load_config
from .runner import SyncRequest, SyncRunner
from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
from .pages.logs import LogsPage
from .pages.settings import SettingsPage
from .pages.about import AboutPage
class MainWindow(QtWidgets.QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("ytpl-sync")
self.resize(1100, 700)
self.setWindowIcon(load_app_icon())
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
self._tray: QtWidgets.QSystemTrayIcon | None = None
self._tray_notified = False
# Sidebar navigation
self._nav = QtWidgets.QListWidget()
self._nav.setObjectName("sidebar")
self._nav.setSpacing(2)
self._nav.setHorizontalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAlwaysOff
)
self._nav.setVerticalScrollBarPolicy(
QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
self._nav.setSelectionMode(QtWidgets.QAbstractItemView.SelectionMode.SingleSelection)
self._nav.model().rowsInserted.connect(self._update_sidebar_width)
self._nav.model().dataChanged.connect(self._update_sidebar_width)
self._nav.model().rowsRemoved.connect(self._update_sidebar_width)
self._stack = QtWidgets.QStackedWidget()
self._playlists_page = PlaylistManagerPage(self._settings)
self._queue_page = QueuePage()
self._logs_page = LogsPage()
self._settings_page = SettingsPage()
self._about_page = AboutPage()
self._pages: list[QtWidgets.QWidget] = [
self._playlists_page,
self._queue_page,
self._logs_page,
self._settings_page,
self._about_page,
]
for p in self._pages:
self._stack.addWidget(p)
for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
self._add_sidebar_item(label)
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()
self._init_tray()
QtCore.QTimer.singleShot(0, self._update_sidebar_width)
def _add_sidebar_item(self, label: str) -> None:
item = QtWidgets.QListWidgetItem(label)
self._nav.addItem(item)
self._update_sidebar_width()
def _update_sidebar_width(self, *_args: object) -> None:
metrics = self._nav.fontMetrics()
max_text_width = 0
for row in range(self._nav.count()):
item = self._nav.item(row)
if item is None:
continue
max_text_width = max(max_text_width, metrics.horizontalAdvance(item.text()))
if max_text_width <= 0:
return
frame = self._nav.frameWidth() * 2
padding = 44
target_width = max_text_width + frame + padding
self._nav.setFixedWidth(max(120, min(220, target_width)))
def _tray_config(self) -> dict:
# Read from disk so toggles apply immediately (no restart required).
try:
cfg_path = getattr(self._settings, "path", None)
if cfg_path is None:
return {}
raw = load_config(cfg_path).data
ui = raw.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
return dict(tray)
except Exception:
return {}
def _close_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("close_to_tray", False))
def _minimize_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("minimize_to_tray", False))
def _start_minimized_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("start_minimized_to_tray", False))
def should_start_minimized_to_tray(self) -> bool:
return self._tray is not None and self._start_minimized_to_tray_enabled()
def _init_tray(self) -> None:
# Tray support is optional and platform-dependent (e.g., some Linux DEs).
try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return
except Exception:
return
icon = load_app_icon()
tray = QtWidgets.QSystemTrayIcon(icon, self)
tray.setToolTip("ytpl-sync")
menu = QtWidgets.QMenu()
act_toggle = menu.addAction("Show/Hide")
act_quit = menu.addAction("Quit")
tray.setContextMenu(menu)
act_toggle.triggered.connect(self._toggle_visible)
act_quit.triggered.connect(self._quit_from_tray)
tray.activated.connect(self._on_tray_activated)
tray.show()
self._tray = tray
def _toggle_visible(self) -> None:
if self.isVisible():
self.hide()
else:
self.show()
self.raise_()
self.activateWindow()
def _quit_from_tray(self) -> None:
# Ensure the closeEvent doesn't just hide the window.
self._tray = None
QtWidgets.QApplication.quit()
def _on_tray_activated(self, reason: QtWidgets.QSystemTrayIcon.ActivationReason) -> None:
if reason in (
QtWidgets.QSystemTrayIcon.ActivationReason.Trigger,
QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick,
):
self._toggle_visible()
def closeEvent(self, event: QtGui.QCloseEvent) -> None: # type: ignore[override]
# If tray is active and configured, close-to-tray.
if self._tray is not None and self._close_to_tray_enabled():
event.ignore()
self.hide()
if not self._tray_notified:
self._tray_notified = True
try:
self._tray.showMessage(
"ytpl-sync",
"Still running in the tray. Use the tray icon menu to quit.",
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
3000,
)
except Exception:
pass
return
if self._tray is not None and not self._close_to_tray_enabled():
# Explicitly quit, because the app may be configured to keep running without windows.
try:
event.accept()
except Exception:
pass
QtWidgets.QApplication.quit()
return
super().closeEvent(event)
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
try:
if event.type() == QtCore.QEvent.Type.WindowStateChange:
if self._tray is not None and self._minimize_to_tray_enabled():
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
QtCore.QTimer.singleShot(0, self.hide)
except Exception:
pass
super().changeEvent(event)
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: #0f1218; color: #d7dce4; }
QWidget { font-size: 13px; color: #d7dce4; }
QWidget#playlistsPage,
QWidget#queuePage,
QWidget#logsPage,
QWidget#settingsPage,
QWidget#aboutPage {
background: #0f1218;
}
QLabel#pageTitle { font-size: 18px; font-weight: 600; padding: 4px 0; }
QLabel#cardTitle { font-size: 15px; font-weight: 600; color: #eef2f8; }
QLabel[muted="true"] { color: #9aa3b2; }
QLabel[link="true"] { color: #6c8bff; }
QLabel[link="true"]:hover { color: #8ea7ff; }
QListWidget#sidebar {
background: #0d1015;
border-right: 1px solid #2a3140;
padding: 8px;
}
QListWidget#sidebar::item {
color: #cfd3da;
border-radius: 8px;
padding: 8px 10px;
}
QListWidget#sidebar::item:selected {
background: #21304a;
color: #ffffff;
}
QTableWidget {
background: #171b22;
gridline-color: #2a3140;
border: 1px solid #2a3140;
}
QTableWidget::item {
padding: 6px 8px;
}
QPlainTextEdit {
background: #11151c;
border: 1px solid #2a3140;
border-radius: 10px;
color: #d7dce4;
}
QScrollBar:vertical {
background: #0f1218;
width: 12px;
margin: 0px;
}
QScrollBar::handle:vertical {
background: #34465f;
min-height: 24px;
border-radius: 6px;
}
QScrollBar::handle:vertical:hover {
background: #456183;
}
QScrollBar::add-line:vertical,
QScrollBar::sub-line:vertical,
QScrollBar::add-page:vertical,
QScrollBar::sub-page:vertical {
background: transparent;
border: none;
}
QGroupBox {
border: 1px solid #2a3140;
border-radius: 12px;
margin-top: 14px;
padding: 12px;
background: #171b22;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #e2e7ef;
background: #171b22;
}
QFrame#aboutCard {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 14px;
}
QHeaderView::section {
background: #171b22;
color: #d7dce4;
border: 1px solid #2a3140;
padding: 6px;
}
QPushButton {
background: #1e2631;
border: 1px solid #31405a;
padding: 6px 10px;
border-radius: 8px;
color: #d7dce4;
}
QPushButton:hover { background: #26344a; }
QPushButton:pressed { background: #1a2433; }
QFrame#playlistCard {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
padding: 10px;
}
QLineEdit, QComboBox {
background: #11151c;
border: 1px solid #2a3140;
border-radius: 8px;
padding: 6px 8px;
color: #d7dce4;
}
QLineEdit:focus, QComboBox:focus {
border: 1px solid #6c8bff;
}
"""
)
def main() -> int:
app = QtWidgets.QApplication(sys.argv)
app.setApplicationName("ytpl-sync")
app.setOrganizationName("ytpl-sync")
app.setWindowIcon(load_app_icon())
app.setQuitOnLastWindowClosed(False)
# 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()
if w.should_start_minimized_to_tray():
w.hide()
else:
w.show()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -0,0 +1,2 @@
from __future__ import annotations
+112
View File
@@ -0,0 +1,112 @@
from __future__ import annotations
from PySide6 import QtCore, QtGui, QtWidgets
from ...core.utils.version import get_app_version
class AboutPage(QtWidgets.QWidget):
REPO_URL = "https://github.com/darkzoul5/YoutubePlaylistSync"
ISSUES_URL = f"{REPO_URL}/issues"
def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super().__init__(parent)
self.setObjectName("aboutPage")
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(14)
title = QtWidgets.QLabel("About")
title.setObjectName("pageTitle")
layout.addWidget(title)
for card in (
self._hero_card(),
self._project_card(),
self._suggestions_card(),
):
layout.addWidget(card)
layout.addStretch(1)
def _card(self, title: str) -> tuple[QtWidgets.QFrame, QtWidgets.QVBoxLayout]:
card = QtWidgets.QFrame()
card.setObjectName("aboutCard")
layout = QtWidgets.QVBoxLayout(card)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(10)
layout.addWidget(self._card_title(title))
return card, layout
def _card_title(self, text: str) -> QtWidgets.QLabel:
label = QtWidgets.QLabel(text)
label.setObjectName("cardTitle")
return label
def _muted_label(self, text: str) -> QtWidgets.QLabel:
label = QtWidgets.QLabel(text)
label.setWordWrap(True)
label.setProperty("muted", True)
return label
def _link_button(self, text: str, url: str) -> QtWidgets.QPushButton:
button = QtWidgets.QPushButton(text)
button.setCursor(QtCore.Qt.CursorShape.PointingHandCursor)
button.clicked.connect(lambda: QtGui.QDesktopServices.openUrl(QtCore.QUrl(url)))
return button
def _action_row(self, text: str, url: str) -> QtWidgets.QWidget:
row = QtWidgets.QWidget()
layout = QtWidgets.QHBoxLayout(row)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
layout.addWidget(self._link_button(text, url))
layout.addStretch(1)
return row
def _hero_card(self) -> QtWidgets.QFrame:
card, layout = self._card("About this project")
layout.insertWidget(
1,
self._muted_label(
"ytpl-sync is a desktop app for keeping local copies of YouTube playlists in sync."
),
)
layout.insertWidget(2, self._muted_label("This is a student project."))
return card
def _project_card(self) -> QtWidgets.QFrame:
card, layout = self._card("Project")
form = QtWidgets.QFormLayout()
form.setLabelAlignment(QtCore.Qt.AlignmentFlag.AlignLeft)
form.setFormAlignment(
QtCore.Qt.AlignmentFlag.AlignTop | QtCore.Qt.AlignmentFlag.AlignLeft
)
form.setHorizontalSpacing(14)
form.setVerticalSpacing(10)
version_text = get_app_version()
version = f"v{version_text}" if version_text != "dev" else version_text
rows = [
("Author", self._muted_label("Dark_Zoul")),
("Version", self._muted_label(version)),
("Repository", self._action_row("Open", self.REPO_URL)),
("Issues", self._action_row("Open", self.ISSUES_URL)),
]
for label, widget in rows:
form.addRow(label, widget)
layout.addLayout(form)
return card
def _suggestions_card(self) -> QtWidgets.QFrame:
card, layout = self._card("Suggestions")
layout.addWidget(
self._muted_label(
"• Keep the app updated regularly so that YouTube extraction stays reliable."
)
)
layout.addStretch(1)
return card
+44
View File
@@ -0,0 +1,44 @@
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)
self.setObjectName("logsPage")
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)
+650
View File
@@ -0,0 +1,650 @@
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.setObjectName("playlistsPage")
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()
self._list.setObjectName("playlistList")
# 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._list.setStyleSheet(
"""
QListWidget#playlistList {
background: #0f1218;
border: none;
}
QListWidget#playlistList::viewport {
background: #0f1218;
}
QListWidget#playlistList::item {
background: transparent;
}
"""
)
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("db/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.setAttribute(QtCore.Qt.WidgetAttribute.WA_StyledBackground, True)
self.setStyleSheet(
"""
QFrame#playlistCard {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
}
"""
)
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(["best", "2160p", "1440p", "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)
+215
View File
@@ -0,0 +1,215 @@
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)
self.setObjectName("queuePage")
# 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
+165
View File
@@ -0,0 +1,165 @@
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.setObjectName("settingsPage")
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)
tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox()
self._close_to_tray.setChecked(False)
tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox()
self._minimize_to_tray.setChecked(False)
tray_form.addRow("minimize_to_tray", self._minimize_to_tray)
self._start_minimized_to_tray = QtWidgets.QCheckBox()
self._start_minimized_to_tray.setChecked(False)
tray_form.addRow("start_minimized_to_tray", self._start_minimized_to_tray)
tray_box = QtWidgets.QGroupBox("Tray behavior")
tray_box.setLayout(tray_form)
layout.addWidget(tray_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())
self._close_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
self._minimize_to_tray.stateChanged.connect(lambda _v: self._schedule_autosave())
self._start_minimized_to_tray.stateChanged.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))
ui = self._config.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
self._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc:
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())
ui = data.get("ui")
ui = ui if isinstance(ui, dict) else {}
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
tray["close_to_tray"] = bool(self._close_to_tray.isChecked())
tray["minimize_to_tray"] = bool(self._minimize_to_tray.isChecked())
tray["start_minimized_to_tray"] = bool(self._start_minimized_to_tray.isChecked())
ui["tray"] = tray
data["ui"] = ui
save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc:
self._status.setText(f"Failed to save settings: {exc}")
+74
View File
@@ -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
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("db/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))
+60
View File
@@ -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)
+9 -9
View File
@@ -1,25 +1,26 @@
"""
Entry point for the backend (no GUI).
For now, this verifies configuration + database setup and can run a one-off sync.
Future iterations will wire up scheduler and a GUI.
"""
from __future__ import annotations
"""
Entry point for the new backend (no GUI). For now, this only verifies
that configuration and database setup work. Future iterations will wire
up scanner, diff engine, queue, and scheduler.
"""
import asyncio
from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.service import SyncService
from .core.sync.executor import ActionExecutor
from .core.models import SyncActionType
from .core.utils.yt import extract_playlist_id
from .core.utils.deps import DependencyError
def bootstrap(db_path: Path | None = None) -> None:
settings = Settings()
db = Database((db_path or Path("app/data/app.db")).resolve())
db = Database((db_path or Path("db/app.db")).resolve())
service = SyncService(db)
executor = ActionExecutor(db)
@@ -37,7 +38,6 @@ def bootstrap(db_path: Path | None = None) -> None:
summary = ", ".join(f"{k.name}:{v}" for k, v in counts.items())
print(f"Plan → {summary}")
# Execute
import asyncio
try:
asyncio.run(executor.execute(actions, pl))
except DependencyError as e:
-9
View File
@@ -1,9 +0,0 @@
import pytest
from tests.dummy_config import DummyConfig
@pytest.fixture
def dummy_config():
"""Return a fresh DummyConfig instance for tests to customize."""
return DummyConfig()
-19
View File
@@ -1,19 +0,0 @@
import os
class DummyConfig:
"""Small test configuration object used by unit and integration tests.
Adjust attributes via environment variables where appropriate.
"""
yt_dlp_path = os.getenv("YTDLP_PATH", "yt-dlp")
ffmpeg_path = os.getenv("FFMPEG_PATH", "ffmpeg")
aria2c_path = os.getenv("ARIA2C_PATH", "aria2c")
max_parallel_downloads = int(os.getenv("TEST_MAX_PARALLEL", "2"))
aria2c_connections = int(os.getenv("TEST_ARIA2C_CONN", "2"))
download_mode = os.getenv("TEST_DOWNLOAD_MODE", "audio")
max_video_quality = os.getenv("TEST_MAX_VIDEO_QUALITY", "1080p")
# runtime flags
debug = False
non_interactive = False
prune = False
-154
View File
@@ -1,154 +0,0 @@
"""
Full integration test (opt-in):
- Set environment variable INTEGRATION_TEST=1 to enable
- Optionally set TEST_PLAYLIST_URL to a full playlist URL; otherwise the built-in playlist id will be used
This script will attempt to download real audio/video for a small playlist (3 items).
It will run three modes: audio, video, and both. It is intentionally opt-in to avoid accidental large downloads.
"""
import os
import sys
import shutil
import time
from pathlib import Path
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
# Make imports robust when running the script directly from different working directories.
# Ensure the repository root is on sys.path so the script can import `src`.
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
if not os.getenv("INTEGRATION_TEST"):
print("Skipping full integration test (set INTEGRATION_TEST=1 to enable)")
sys.exit(0)
# Prefer local ./bin/ executables for integration runs when available.
# Set environment variables before importing TempConfig so its class attributes
# pick up these overridden paths.
bin_dir = REPO_ROOT / "bin" / "linux"
if bin_dir.exists():
ytdlp_path = bin_dir / "yt-dlp"
if ytdlp_path.exists():
os.environ.setdefault("YTDLP_PATH", str(ytdlp_path))
print(f"Using local yt-dlp at: {ytdlp_path}")
ffmpeg_path = bin_dir / "ffmpeg"
if ffmpeg_path.exists():
os.environ.setdefault("FFMPEG_PATH", str(ffmpeg_path))
print(f"Using local ffmpeg at: {ffmpeg_path}")
aria2c_path = bin_dir / "aria2c"
if aria2c_path.exists():
os.environ.setdefault("ARIA2C_PATH", str(aria2c_path))
print(f"Using local aria2c at: {aria2c_path}")
# allow caller to override playlist url via env
playlist_url = os.getenv("TEST_PLAYLIST_URL")
if not playlist_url:
# Use provided playlist id (3 videos)
playlist_id = "PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz"
playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}"
print(f"Using playlist URL: {playlist_url}")
cfg_base = DummyConfig()
# ensure yt-dlp exists
if not shutil.which(str(cfg_base.yt_dlp_path)):
print(f"yt-dlp binary not found at '{cfg_base.yt_dlp_path}'. Please install yt-dlp or set YTDLP_PATH environment variable.")
sys.exit(2)
MODES = ["audio", "video", "both"]
root_tmp = Path("./tests/tmp_integration_full")
root_tmp.mkdir(parents=True, exist_ok=True)
failed = False
for mode in MODES:
print(f"\n=== Running mode: {mode} ===")
cfg = DummyConfig()
# Allow enabling verbose subprocess output from CI by setting YTPL_DEBUG=1
cfg.debug = bool(os.getenv("YTPL_DEBUG", "0") == "1")
cfg.download_mode = mode
# make downloads single-threaded for predictability
cfg.max_parallel_downloads = 1
cfg.aria2c_connections = 1
save_path = root_tmp / mode
# ensure a clean directory per run
if save_path.exists():
try:
shutil.rmtree(save_path)
except Exception:
pass
playlist = {"url": playlist_url, "save_path": str(save_path), "archive": f"archive_{mode}.txt"}
downloader = PlaylistDownloader(cfg, playlist, 0)
# Print resolved binary paths for debugging
try:
print(f"Resolved yt-dlp path: {cfg.yt_dlp_path}")
print(f"Resolved ffmpeg path: {cfg.ffmpeg_path}")
print(f"Resolved aria2c path: {cfg.aria2c_path}")
except Exception:
pass
try:
start = time.time()
downloader.update()
dur = time.time() - start
print(f"Mode {mode} completed in {dur:.1f}s")
# basic verifications
if mode in ("audio", "both"):
audio_folder = save_path / "audio"
mp3s = list(audio_folder.glob("*.mp3")) if audio_folder.exists() else []
print(f"Found {len(mp3s)} mp3 files in {audio_folder}")
if len(mp3s) < 3:
print(f"Expected >=3 mp3 files for mode={mode}, found {len(mp3s)}")
failed = True
if mode in ("video", "both"):
video_folder = save_path / "video"
mp4s = list(video_folder.glob("*.mp4")) if video_folder.exists() else []
print(f"Found {len(mp4s)} mp4 files in {video_folder}")
if len(mp4s) < 3:
print(f"Expected >=3 mp4 files for mode={mode}, found {len(mp4s)}")
failed = True
# check archive has entries
archive_file = (save_path / f"archive_{mode}.txt")
if archive_file.exists():
lines = [line for line in archive_file.read_text(encoding='utf-8').splitlines() if line.strip()]
print(f"Archive {archive_file} contains {len(lines)} lines")
if len(lines) < 3:
print(f"Expected archive to contain >=3 lines, found {len(lines)}")
# Not necessarily fatal; mark failure but continue
failed = True
else:
print(f"Archive file {archive_file} not found")
failed = True
except Exception as ex:
print(f"Exception during mode {mode}: {ex}")
failed = True
# cleanup to avoid leaving large files around
try:
if save_path.exists():
shutil.rmtree(save_path)
print(f"Cleaned up {save_path}")
except Exception as ex:
print(f"Failed to clean up {save_path}: {ex}")
# final cleanup
try:
if root_tmp.exists():
shutil.rmtree(root_tmp)
except Exception:
pass
if failed:
print("Integration full workflow test encountered failures.")
sys.exit(3)
print("Integration full workflow test completed successfully")
sys.exit(0)
-23
View File
@@ -1,23 +0,0 @@
import logging
from src.old.manager import PlaylistManager
from tests.dummy_config import DummyConfig
def test_run_with_prune_disabled():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
def test_run_with_prune_enabled_non_interactive():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
cfg.prune = True
cfg.non_interactive = True
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
-45
View File
@@ -1,45 +0,0 @@
import logging
import subprocess
from types import SimpleNamespace
import src.old.cli as cli_mod
class DummyCompleted(SimpleNamespace):
pass
def test_update_yt_dlp_success(monkeypatch, caplog):
called = {"count": 0}
def fake_run(args, check=True, **kw):
called["count"] += 1
return DummyCompleted(returncode=0)
monkeypatch.setattr(subprocess, "run", fake_run)
caplog.set_level(logging.INFO)
cli_mod.update_yt_dlp("yt-dlp", debug=False)
assert called["count"] == 1
assert any("up to date" in r.message.lower() for r in caplog.records)
def test_update_yt_dlp_failure(monkeypatch, caplog):
def raise_called(*a, **k):
raise subprocess.CalledProcessError(1, cmd=a[0])
monkeypatch.setattr(subprocess, "run", raise_called)
caplog.set_level(logging.WARNING)
cli_mod.update_yt_dlp("yt-dlp", debug=False)
assert any("could not update yt-dlp" in r.message.lower() or "could not update" in r.message.lower() for r in caplog.records)
def test_configure_logging_sets_levels():
# ensure calling configure_logging flips global root logger level
# clear existing handlers so basicConfig can take effect in test
logging.root.handlers.clear()
cli_mod.configure_logging(True)
assert logging.getLogger().getEffectiveLevel() == logging.DEBUG
logging.root.handlers.clear()
cli_mod.configure_logging(False)
assert logging.getLogger().getEffectiveLevel() == logging.INFO
-28
View File
@@ -1,28 +0,0 @@
import json
from src.old.config import ConfigLoader
def test_config_loader_reads_properties(tmp_path, monkeypatch):
# create a minimal config file with known binary names that exist on PATH
cfg = {
"playlists": [{"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": "./tmp", "archive": "archive.txt"}],
"yt_dlp_path": "python",
"ffmpeg_path": "python",
"aria2c_path": "python",
"max_parallel_downloads": 3,
"aria2c_connections": 2,
}
p = tmp_path / "yt-playlist-config.json"
p.write_text(json.dumps(cfg), encoding="utf-8")
# Use absolute path so ConfigLoader doesn't try to create ./config
loader = ConfigLoader(str(p))
assert loader.playlists == cfg["playlists"]
assert loader.yt_dlp_path == "python"
assert loader.ffmpeg_path == "python"
assert loader.aria2c_path == "python"
assert loader.max_parallel_downloads == 3
assert loader.aria2c_connections == 2
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
from app.core.download.downloader import Downloader
def test_build_format_defaults_to_best_mp4():
fmt = Downloader.build_format(None)
assert "height<=1080" in fmt
assert fmt.endswith("/best[ext=mp4]")
def test_build_format_applies_height_cap():
fmt = Downloader.build_format("720p")
assert "height<=720" in fmt
-40
View File
@@ -1,40 +0,0 @@
import subprocess
import shutil
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
def test_download_video_invalid_mode(tmp_path):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
dl.download_mode = "invalid_mode"
video = {"id": "X1", "title": "Test"}
assert dl.download_video(video, 1) is False
def test_download_video_both_mode_ffmpeg_missing(monkeypatch, tmp_path, caplog):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
dl.download_mode = "both"
video = {"id": "X1", "title": "Test"}
# monkeypatch _run to simulate successful video download and ffmpeg extraction failure path
def fake_run(*args, **kwargs):
# accept label or other kwargs; simulate successful call
return subprocess.CompletedProcess(args, 0)
monkeypatch.setattr(PlaylistDownloader, "_run", fake_run)
# Ensure ffmpeg is not found
monkeypatch.setattr(shutil, "which", lambda p: None)
# Should not raise; will log a warning about ffmpeg missing
caplog.set_level("WARNING")
ok = dl.download_video(video, 1)
# For 'both' mode the function returns True when video download succeeded (we simulate that)
assert ok is True
assert any("ffmpeg not found" in r.message.lower() or "ffmpeg failed" in r.message.lower() for r in caplog.records) or True
@@ -1,24 +0,0 @@
from pathlib import Path
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
def test_sanitize_title_and_get_file_path(tmp_path):
cfg = DummyConfig()
playlist = {"url": None, "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
# illegal chars should be replaced and trimmed; fallback_id used when title becomes empty
title = ' My: <>:"/\\|?*Title '
safe = dl.sanitize_title(title, "ABC123")
# ensure no illegal characters remain
assert all(c not in safe for c in dl.illegal_chars)
# empty title should return fallback id
assert dl.sanitize_title(" ", "FALLBACK") == "FALLBACK"
# get_file_path uses save_path and zero-padded index
path = dl.get_file_path(5, "SongName")
assert isinstance(path, Path)
assert path.name.startswith("005 - SongName")
+83
View File
@@ -0,0 +1,83 @@
from __future__ import annotations
import asyncio
import sys
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)
-48
View File
@@ -1,48 +0,0 @@
import json
import subprocess
from types import SimpleNamespace
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
class DummyCompleted(SimpleNamespace):
pass
def test_fetch_videos_parses_entries(monkeypatch, tmp_path):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
entries = [{"id": "A1", "title": "Song 1"}, {"id": "B2", "title": "Song 2"}]
out = json.dumps({"entries": entries})
def fake_run(args, capture_output=True, text=True, check=True):
return DummyCompleted(stdout=out)
monkeypatch.setattr(subprocess, "run", fake_run)
res = dl.fetch_videos()
assert isinstance(res, list)
assert len(res) == 2
assert res[0]["id"] == "A1"
def test_fetch_videos_handles_private_and_errors(monkeypatch, tmp_path, caplog):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
# simulate CalledProcessError with 'private' message
def raise_called(*a, **k):
e = subprocess.CalledProcessError(1, cmd=a[0])
e.stderr = "This playlist is private"
raise e
monkeypatch.setattr(subprocess, "run", raise_called)
caplog.set_level("WARNING")
res = dl.fetch_videos()
assert res == []
assert dl.skip is True
+80
View File
@@ -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 youre 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
+84
View File
@@ -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 youre 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
+76
View File
@@ -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 youre 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
-26
View File
@@ -1,26 +0,0 @@
import logging
from tests.dummy_config import DummyConfig
from src.old.manager import PlaylistManager
def test_manager_warns_and_sleeps(monkeypatch, caplog):
# Avoid actually sleeping during the test
slept = {"called": False}
def fake_sleep(sec):
slept["called"] = True
# monkeypatch the sleep used inside the manager module
monkeypatch.setattr("src.manager.time.sleep", fake_sleep)
caplog.set_level(logging.WARNING)
cfg = DummyConfig()
cfg.max_parallel_downloads = 11
cfg.aria2c_connections = 10
cfg.playlists = []
m = PlaylistManager(cfg, debug=False)
m.run()
assert slept["called"] is True
assert any("may overload your network" in rec.getMessage() for rec in caplog.records)
@@ -1,23 +0,0 @@
import logging
from src.old.manager import PlaylistManager
from tests.dummy_config import DummyConfig
def test_run_with_prune_disabled():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
def test_run_with_prune_enabled_non_interactive():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
cfg.prune = True
cfg.non_interactive = True
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
-78
View File
@@ -1,78 +0,0 @@
from pathlib import Path
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
def touch(p: Path):
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("x")
def test_renumber_audio_and_cleanup(tmp_path, monkeypatch):
cfg = DummyConfig()
playlist = {"url": "FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
# set download mode to audio and create only audio files
dl.download_mode = "audio"
entries = [
{"id": "ID1", "title": "First Song"},
{"id": "ID2", "title": "Second Song"},
]
a1 = tmp_path / "audio" / "oldname First Song.mp3"
a2 = tmp_path / "audio" / "zzz Second Song.mp3"
touch(a1)
touch(a2)
# On Windows os.rename may fail when target exists; use os.replace to allow
# overwrite semantics for the duration of this test.
import os as _os
monkeypatch.setattr(Path, "rename", lambda self, target: _os.replace(self, target))
dl.renumber_all_tracks(entries)
audio_files = list((tmp_path / "audio").glob("*.mp3"))
# On some platforms the renaming logic may overwrite targets; assert at least
# one audio file was produced and that its name contains one of the titles.
assert audio_files
assert any("First Song" in f.name or "Second Song" in f.name for f in audio_files)
# Now test cleanup_removed_tracks: create a stray file not in entries
stray = tmp_path / "audio" / "999 - NotInPlaylist.mp3"
touch(stray)
# ensure prune=False -> no deletion
dl.prune = False
dl.cleanup_removed_tracks(entries)
assert stray.exists()
# Now enable prune and non_interactive so deletion occurs without input
dl.prune = True
dl.non_interactive = True
dl.cleanup_removed_tracks(entries)
assert not stray.exists()
def test_renumber_video(tmp_path, monkeypatch):
cfg = DummyConfig()
playlist = {"url": "FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
dl.download_mode = "video"
entries = [
{"id": "ID1", "title": "Alpha"},
{"id": "ID2", "title": "Beta"},
]
v1 = tmp_path / "video" / "something Alpha.mp4"
v2 = tmp_path / "video" / "something Beta.mp4"
touch(v1)
touch(v2)
import os as _os
monkeypatch.setattr(Path, "rename", lambda self, target: _os.replace(self, target))
dl.renumber_all_tracks(entries)
video_files = list((tmp_path / "video").glob("*.mp4"))
assert video_files
assert any("Alpha" in f.name or "Beta" in f.name for f in video_files)
+45
View File
@@ -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()
+39
View File
@@ -0,0 +1,39 @@
from __future__ import annotations
import json
import os
from app.config.settings import Settings
def test_settings_creates_root_config_if_missing(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cfg_path = tmp_path / "config" / "yt-playlist-config.json"
assert not cfg_path.exists()
settings = Settings()
assert settings.path == cfg_path.resolve()
assert cfg_path.exists()
data = json.loads(cfg_path.read_text(encoding="utf-8"))
assert "playlists" in data
assert data.get("ffmpeg_path") == ("./bin/ffmpeg.exe" if os.name == "nt" else "./bin/ffmpeg")
assert data["playlists"][0].get("download_mode") == "video"
assert data["playlists"][0].get("max_download_quality") == "1080p"
assert data["playlists"][0].get("save_path") == "./downloads"
def test_settings_reads_config_from_default_location(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
cfg_path = tmp_path / "config" / "yt-playlist-config.json"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(json.dumps({"playlists": [{"url": "X"}]}), encoding="utf-8")
settings = Settings()
assert settings.path == cfg_path.resolve()
assert settings.playlists and settings.playlists[0]["url"] == "X"
assert settings.playlists[0]["download_mode"] == "video"
assert settings.playlists[0]["max_download_quality"] == "1080p"
assert settings.playlists[0]["save_path"] == "./downloads"
+7
View File
@@ -0,0 +1,7 @@
from __future__ import annotations
from app.gui.main import main
if __name__ == "__main__":
raise SystemExit(main())