65 Commits
Author SHA1 Message Date
darkzoul5 1d74ec0453 chore: bump yt-dlp to 2026.10.26 (#22) 2026-10-27 14:28:55 +02:00
darkzoul5 1f18b8700c chore: bump yt-dlp to 2026.10.21 (#21) 2026-10-22 15:18:06 +03:00
dark_zoul 1c0ccde877 ci: create release tag only after successful build 2026-10-19 23:53:39 +03:00
dark_zoul 15c7e2b298 ci: handle nested ffmpeg archive layout 2026-10-19 23:51:17 +03:00
dark_zoul c6a98d30a5 ci: reuse test workflow in release build 2026-10-19 23:50:03 +03:00
dark_zoul 15c7c8e514 ci: prevent reusable test workflow deadlock 2026-10-19 23:45:48 +03:00
dark_zoul e1aaf35c1f ci: switch to BtbN n9 ffmpeg builds 2026-10-19 23:41:36 +03:00
dark_zoul 758dcf5080 ci: add combined test workflow 2026-10-19 23:33:34 +03:00
dark_zoul 99bbefce9c chore: bump version to 2.3.0 2026-10-19 23:29:40 +03:00
dark_zoul 02a06a70e4 chore: bump version to 2.2.0 2026-10-19 23:27:24 +03:00
dark_zoul c578e42bf7 fix: load workspace source from app entry point 2026-10-19 23:26:57 +03:00
dark_zoul 9a5deeefed fix: support separate yt-dlp video and audio streams 2026-10-19 23:26:44 +03:00
dark_zoul 389bd1b348 feat: add log entry copy buttons 2026-10-19 23:04:44 +03:00
dark_zoul e65199a403 feat: add clear all queue action 2026-10-19 22:57:48 +03:00
dark_zoul aefef55a2c feat: display logs as visually separate entries 2026-10-19 22:54:33 +03:00
dark_zoul 8904e07597 fix: resolve all current lint errors 2026-10-19 22:46:47 +03:00
dark_zoul 972c8e1dbf fix: resolve app main lint error 2026-10-19 22:21:41 +03:00
dark_zoul b6fb314571 fix: resolve core utility lint errors 2026-10-19 22:17:55 +03:00
dark_zoul b8acce7623 fix: resolve sync executor lint errors 2026-10-19 22:15:43 +03:00
dark_zoul a647685070 fix: resolve scanner lint errors 2026-10-19 22:13:45 +03:00
dark_zoul 3494578c0e refactor: fix lint errors in src/app/core/downloader.py 2026-10-19 22:11:28 +03:00
dark_zoul 5b393c0bcd refactor: fix lint errors with ruff check . --fix 2026-10-19 21:52:30 +03:00
darkzoul5 47f88bc6a4 chore: bump yt-dlp to 2026.8.19 (#20) 2026-08-26 14:13:50 +03:00
dependabot[bot] 835958cd94 chore(deps): bump actions/setup-python from 6 to 7 (#19)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-29 13:31:32 +03:00
darkzoul5 e1fab69598 chore: bump yt-dlp to 2026.7.4 (#18) 2026-07-05 22:47:44 +03:00
dependabot[bot] e97c419b47 chore(deps): bump actions/checkout from 6 to 7 (#17)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [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/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-20 23:23:48 +03:00
dark_zoul a7f1564581 remove outdated plans 2026-06-18 11:31:06 +03:00
dark_zoul 3b1cdda19d ci(release): verify linux ffmpeg download 2026-06-11 15:57:47 +03:00
darkzoul5 2dc119a2f1 Merge pull request #16 from darkzoul5/chore/refresh-yt-dlp
chore: bump yt-dlp to 2026.6.9
2026-06-11 15:40:45 +03:00
darkzoul5 7d0c7aa1d5 chore: bump yt-dlp to 2026.6.9 2026-06-11 12:38:15 +00:00
dark_zoul 15f2df0cbf ci: use dedicated token for yt-dlp update PRs 2026-06-11 15:37:52 +03:00
dark_zoul 22756f35db ci: switch workflow path filters to paths-ignore 2026-06-11 13:05:51 +03:00
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
50 changed files with 1101 additions and 1158 deletions

No files matched your search

+113 -74
View File
@@ -24,71 +24,15 @@ permissions:
contents: write
jobs:
tag:
name: Create tag
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Validate input tag
shell: bash
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
if [[ -z "$TAG" ]]; then
echo "tag is required" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
echo "tag must start with 'v' (got: $TAG)" >&2
exit 1
fi
- name: Create/push tag
shell: bash
env:
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force_tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
if [[ "${FORCE}" != "true" ]]; then
echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2
exit 1
fi
git tag -d "${TAG}" || true
git push origin ":refs/tags/${TAG}" || true
fi
git tag "${TAG}" "${GITHUB_SHA}"
git push origin "${TAG}"
test:
name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: "pip"
- name: Install
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]" || pip install -e .
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}/src
run: pytest
name: Test
uses: ./.github/workflows/test.yml
permissions:
contents: read
build:
name: Build packages
needs: tag
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -97,7 +41,7 @@ jobs:
ffmpeg: [bundled, none]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Derive version
id: version
@@ -107,15 +51,21 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
- uses: actions/setup-python@v6
- name: Write bundled version file
shell: bash
run: |
set -euo pipefail
printf '%s\n' "${{ steps.version.outputs.version }}" > version.txt
- uses: actions/setup-python@v7
with:
python-version: "3.12"
cache: "pip"
cache: "pip"
- name: Install build deps
run: |
python -m pip install --upgrade pip
pip install -e .
pip install -e .
pip install pyinstaller
- name: Build binary (Windows)
@@ -124,14 +74,14 @@ jobs:
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" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
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" --distpath "dist/pyinstaller" --workpath "build/pyinstaller" --specpath "build/pyinstaller" --paths "src" "ytpl-sync-entry.py"
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
@@ -198,9 +148,43 @@ jobs:
run: |
set -euo pipefail
mkdir -p package/bin ffmpeg_tmp
curl -L "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o ffmpeg.tar.xz
archive_name="ffmpeg-n9.0-latest-linux64-gpl-9.0.tar.xz"
release_url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
curl_common=(
--fail
--location
--retry 5
--retry-all-errors
--retry-delay 2
--user-agent "Mozilla/5.0 (GitHub Actions; ytpl-sync release workflow)"
)
curl "${curl_common[@]}" "${release_url}/${archive_name}" -o ffmpeg.tar.xz
curl "${curl_common[@]}" "${release_url}/checksums.sha256" -o checksums.sha256
expected_sha256="$(awk -v archive="$archive_name" '$2 == archive || $2 == "*" archive { print $1; exit }' checksums.sha256)"
if [[ -z "$expected_sha256" ]]; then
echo "Checksum for ${archive_name} was not found" >&2
exit 1
fi
printf '%s ffmpeg.tar.xz\n' "$expected_sha256" | sha256sum -c -
if ! tar -tf ffmpeg.tar.xz >/dev/null 2>&1; then
echo "Downloaded FFmpeg payload is not a valid tar archive" >&2
ls -l ffmpeg.tar.xz >&2 || true
head -c 256 ffmpeg.tar.xz >&2 || true
exit 1
fi
tar -xf ffmpeg.tar.xz -C ffmpeg_tmp --strip-components=1
mv ffmpeg_tmp/ffmpeg package/bin/ffmpeg
ffmpeg_binary="$(find ffmpeg_tmp -type f -name ffmpeg -perm -u+x -print -quit)"
if [[ -z "$ffmpeg_binary" ]]; then
echo "Extracted archive does not contain an executable ffmpeg binary" >&2
find ffmpeg_tmp -maxdepth 3 -type f -print >&2 || true
exit 1
fi
mv "$ffmpeg_binary" package/bin/ffmpeg
chmod +x package/bin/ffmpeg
- name: Archive (Windows)
@@ -239,12 +223,55 @@ jobs:
ytpl-sync-*.zip
ytpl-sync-*.tar.gz
release:
name: Create Release
tag:
name: Create tag
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Validate input tag
shell: bash
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
if [[ -z "$TAG" ]]; then
echo "tag is required" >&2
exit 1
fi
if [[ "$TAG" != v* ]]; then
echo "tag must start with 'v' (got: $TAG)" >&2
exit 1
fi
- name: Create/push tag
shell: bash
env:
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force_tag }}
run: |
set -euo pipefail
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
if [[ "${FORCE}" != "true" ]]; then
echo "Tag ${TAG} already exists. Re-run with force_tag=true to recreate." >&2
exit 1
fi
git tag -d "${TAG}" || true
git push origin ":refs/tags/${TAG}" || true
fi
git tag "${TAG}" "${GITHUB_SHA}"
git push origin "${TAG}"
release:
name: Create Release
needs: tag
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -253,7 +280,7 @@ jobs:
with:
path: artifacts
- name: Generate release notes (since last tag)
- name: Generate release notes
shell: bash
env:
TAG: ${{ inputs.tag }}
@@ -261,10 +288,13 @@ jobs:
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 "### Changes"
echo
if [[ -n "$prev_tag" ]]; then
echo "Compared to \`$prev_tag\`:"
@@ -275,6 +305,15 @@ jobs:
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
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: '3.11'
cache: 'pip'
+5 -5
View File
@@ -1,18 +1,18 @@
name: Lint Python code
name: Lint
on:
push:
pull_request:
workflow_call:
workflow_dispatch:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install Ruff
run: pip install ruff
- name: Run linter
run: ruff check .
run: ruff check .
+34
View File
@@ -0,0 +1,34 @@
name: Test
on:
workflow_call:
workflow_dispatch:
push:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
pull_request:
branches:
- main
paths-ignore:
- "assets/**"
- "README.md"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint
uses: ./.github/workflows/lint_ruff.yml
permissions:
contents: read
unit-tests:
name: Unit tests
uses: ./.github/workflows/unit-tests.yml
permissions:
contents: read
+4 -25
View File
@@ -1,29 +1,8 @@
name: Unit tests
name: Unit Tests
on:
workflow_call:
workflow_dispatch:
push:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
pull_request:
branches:
- main
paths:
- "src/**"
- "tests/**"
- "pyproject.toml"
- "pytest.ini"
- "ytpl-sync-entry.py"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
tests:
@@ -34,10 +13,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v7
with:
python-version: '3.11'
cache: 'pip'
+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@v7
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v7
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
+1
View File
@@ -8,6 +8,7 @@ config/yt-playlist-config.json
*.code-workspace
/bin/*
/db/*
plans
# Byte-compiled / optimized / DLL files
__pycache__/
+6 -4
View File
@@ -1,7 +1,8 @@
# YouTube Playlist Sync
[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.yml/badge.svg)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build-release.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/).
@@ -10,7 +11,7 @@ Local-first YouTube playlist synchronization client.
## What's Included
- GUI (PySide6) playlist manager + sync runner
- 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)
@@ -29,6 +30,7 @@ Download the latest release from this repo's Releases page and pick one:
- `ytpl-sync-windows-{version}.zip` / `ytpl-sync-linux-{version}.tar.gz` (no ffmpeg bundled)
## Configure
Application uses a json config that canbe edited from UI or manually
```json
@@ -72,7 +74,7 @@ Queue / retry:
- Run `ytpl-sync.exe` (GUI).
## Tray
- 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).
+1 -1
View File
@@ -5,7 +5,7 @@
"retry_delay_seconds": 1.5,
"ui": {
"tray": {
"close_to_tray": true,
"close_to_tray": false,
"minimize_to_tray": false,
"start_minimized_to_tray": false
}
-24
View File
@@ -1,24 +0,0 @@
# GUI Plan
## Python-first Desktop Architecture
- **Primary GUI framework**: `PySide6` (Qt for Python).
## Core Features to Implement
1. **Dashboard Overview**: List all tracked playlists, their status (Last Sync), and total size.
2. **Interactive Configuration**: Wizard-style setup for new playlists (URL detection, folder picker).
3. **Queue Manager**: Visual progress bars for active downloads, showing speed, ETA, and current video title.
4. **Log Viewer**: Real-time streaming of yt-dlp logs for troubleshooting.
5. **Settings Panel**: Global settings for binary paths (ffmpeg), max parallel jobs, and Docker detection toggle.
## Phase 1 Roadmap: "The Bridge"
- [ ] **PySide6 Skeleton**: Basic window with `QWebEngine` (if hybrid) or native `QWidget` dashboard.
- [ ] **Packaging**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
## Packaging & Distribution (brief)
- Bundle the backend and GUI into one distributable.
- Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX.
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
+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
@@ -1,781 +0,0 @@
# YouTube Playlist Sync — Project Conversion Plan
Repository:
- [darkzoul5/YoutubePlaylistDownloader](https://github.com/darkzoul5/YoutubePlaylistDownloader?utm_source=chatgpt.com)
---
# Project Direction
Convert the project from:
```text
Single-purpose YouTube playlist downloader
```
into:
```text
Persistent YouTube playlist synchronization client
```
The application becomes state-driven.
---
# Core Product Goals
## Main Features
- Sync playlists locally
- Download missing items
- Remove deleted playlist items
- Keep exact playlist ordering
- Support audio/video modes
- Multiple playlists
- Background auto-sync
- GUI configuration
- Queue management
- Logs/history
---
# Explicit Non-Goals (Current Scope)
Not planned right now:
- Built-in media playback
- Advanced naming templates
- Drag-and-drop manual ordering
- Private playlist sync
- Channel subscriptions
- Metadata editing
Design the architecture so these can be added later.
---
# Recommended Stack
## Core Language
- Python 3.12+
Reason:
- Native yt-dlp ecosystem
- Easier async/background work
- Better packaging than many expect
- Simpler iteration speed
---
# GUI
## Recommended
- PySide6
Why:
- Modern Qt ecosystem
- Better long-term support
- Cleaner than Tkinter
- Easier dynamic playlist UI
- Good threading support
- Better styling
Avoid:
- Tkinter for this scale
- Electron/Tauri unless you want a web stack
---
# Downloader Backend
## Recommended
- yt-dlp Python API
Install:
```bash
pip install yt-dlp
```
Do NOT:
- scrape YouTube manually
- parse HTML yourself
- depend on external unofficial APIs
Use yt-dlp for:
- metadata extraction
- playlist scanning
- downloading
- postprocessing
Use your own app for:
- sync logic
- ordering
- deletion
- scheduling
- state tracking
---
# Media Processing
## Recommended
- ffmpeg
Needed for:
- audio extraction
- remuxing
- conversions
- thumbnail embedding later
Recommended approach:
- auto-detect ffmpeg
- optionally bundle with packaged app
---
# Database
## Recommended
- SQLite
Reason:
- Zero setup
- Local-first architecture
- Perfect for sync metadata
- Easy migrations
- Reliable
SQLite is extremely important for this project.
Do NOT rely only on:
- filenames
- folders
- JSON
---
# Background Scheduling
## Recommended
- APScheduler
Use for:
- interval syncs
- delayed jobs
- retry jobs
- startup sync
---
# Async/Concurrency
## Recommended
- asyncio
Use for:
- concurrent playlist syncs
- GUI-safe task execution
- download queue
- cancellation
- progress updates
---
# Logging
## Recommended
- loguru
or:
- standard logging module
Need:
- rotating logs
- GUI log panel
- error history
- debug support
---
# Packaging
## Recommended
### During development
```text
venv + pip
```
### Release builds
Choose one:
| Tool | Notes |
| ----------- | ------------------------------- |
| Nuitka | Best performance and protection |
| PyInstaller | Easier and common |
Nuitka is probably best long-term.
---
# Major Architectural Changes
---
# 1. Move to State-Based Sync Architecture
Current downloader logic is likely:
```text
Playlist URL
↓
Download everything
```
Replace with:
```text
Remote Playlist State
↓
Stored Local State
↓
Filesystem State
↓
Diff Engine
↓
Sync Actions
```
This is the single most important change.
---
# 2. Introduce Playlist Metadata Database
Create persistent tracking.
Suggested tables:
## playlists
```sql
CREATE TABLE playlists (
id TEXT PRIMARY KEY,
name TEXT,
url TEXT,
path TEXT,
mode TEXT,
auto_sync INTEGER,
sync_interval_minutes INTEGER,
last_sync TEXT
);
```
## playlist\_items
```sql
CREATE TABLE playlist_items (
playlist_id TEXT,
video_id TEXT,
title TEXT,
playlist_index INTEGER,
local_filename TEXT,
downloaded INTEGER,
last_seen TEXT,
PRIMARY KEY (playlist_id, video_id)
);
```
---
# 3. Implement Playlist Scanner Layer
Create dedicated metadata extraction.
Suggested structure:
```text
core/
├── scanner/
│ └── playlist_scanner.py
```
Responsibilities:
- fetch playlist entries
- extract video IDs
- detect unavailable videos
- return normalized playlist state
Use:
```python
extract_info(download=False)
```
This allows playlist scanning without downloading.
---
# 4. Create Diff Engine
The app should compare:
```text
Remote playlist
vs
Database state
vs
Filesystem state
```
Output actions:
```text
DOWNLOAD
DELETE
RENAME
REORDER
SKIP
REPAIR
```
Suggested file:
```text
core/sync/diff_engine.py
```
This becomes the heart of the application.
---
# 5. Add Download Queue System
Multi-playlist sync requires a queue.
Suggested states:
```text
Queued
Downloading
Converting
Completed
Failed
Skipped
Cancelled
```
Suggested structure:
```text
core/download/
├── queue_manager.py
├── downloader.py
└── workers.py
```
---
# 6. Implement Stable File Naming
Recommended naming:
```text
0001 - Title.ext
```
Benefits:
- native filesystem sorting
- easy reorder support
- easy repairs
- user friendly
Use:
```python
%(playlist_index)04d - %(title)s.%(ext)s
```
through yt-dlp.
---
# 7. Implement Safe Reordering
Playlist ordering changes frequently.
Never rename directly.
Use:
```text
Temporary rename pass
↓
Final rename pass
```
Example:
```text
0001.mp3 → temp_a
0002.mp3 → temp_b
↓
temp_a → 0002.mp3
```
Avoid collisions.
---
# 8. Implement Deletion Strategy
Recommended:
Instead of immediate delete:
```text
playlist/.recycle/
```
Move removed files there.
Benefits:
- safer
- recoverable
- easier debugging
Optional:
- auto-clean after X days
---
# 9. Redesign GUI Around Playlists
Current downloader GUIs are usually task-oriented.
You should move to:
```text
Playlist-oriented UI
```
Recommended sections:
```text
Sidebar
├── Playlists
├── Queue
├── History
├── Logs
└── Settings
```
---
# 10. Support Infinite Playlist Entries
Use dynamic UI generation.
Example:
```python
class PlaylistConfig:
url: str
path: str
mode: str
auto_sync: bool
```
GUI should render from:
```python
list[PlaylistConfig]
```
Do NOT hardcode playlist pages.
---
# 11. Add Background Sync
Start simple.
## Phase 1
- Timer-based sync
- Tray icon
- Run minimized
## Phase 2
- Background daemon/service
- Headless mode
- Autostart support
---
# 12. Add Progress/Event System
Needed for GUI responsiveness.
Recommended:
```text
event_bus.py
```
Events:
```text
DownloadStarted
DownloadProgress
SyncStarted
SyncFinished
FileDeleted
PlaylistUpdated
```
This decouples GUI from backend.
---
# 13. Introduce Config Management
Recommended:
```text
config.json
```
Only for:
- app settings
- UI preferences
- non-relational settings
Do NOT store sync state in JSON.
---
# Suggested Folder Structure
```text
app/
├── core/
│ ├── scanner/
│ ├── sync/
│ ├── download/
│ ├── database/
│ ├── scheduler/
│ └── events/
│
├── gui/
│ ├── pages/
│ ├── widgets/
│ ├── dialogs/
│ └── models/
│
├── config/
├── logs/
├── data/
└── main.py
```
---
# Suggested Sync Flow
```text
Load playlists
↓
Scheduler triggers sync
↓
Scanner fetches remote playlist
↓
Database state loaded
↓
Filesystem scanned
↓
Diff engine computes actions
↓
Queue downloads
↓
Reorder files
↓
Move removed files
↓
Update database
↓
Emit GUI events
```
---
# Recommended MVP Conversion Order
## Phase 1 — Backend Foundation
Implement:
- SQLite
- playlist scanner
- diff engine
- download wrapper
- basic sync logic
No GUI redesign yet.
---
# Phase 2 — Stable Syncing
Implement:
- deletion handling
- reorder handling
- queue system
- retry system
- logs
---
# Phase 3 — GUI Rewrite
Implement:
- playlist manager UI
- queue page
- logs page
- settings page
- dynamic playlists
---
# Phase 4 — Automation
Implement:
- background sync
- tray mode
- startup sync
- periodic sync
---
# Important Recommendations
## Recommendation 1
Treat:
```text
video_id
```
as the canonical identity.
Never titles.
---
# Recommendation 2
Do NOT rely on yt-dlp archive files alone.
Your own DB should be the source of truth.
---
# Recommendation 3
Keep download logic isolated.
yt-dlp should be replaceable internally.
---
# Recommendation 4
Do not overcomplicate the GUI early.
Focus on sync correctness first.
Sync reliability matters more than appearance.
---
# Recommendation 5
Design everything around interruption recovery.
The app should survive:
- crashes
- partial downloads
- force closes
- network failures
- playlist changes mid-sync
---
# Recommendation 6
Keep the application local-first.
No account system. No cloud backend. No telemetry.
That becomes a strong project identity.
---
# Final Recommended Identity
Instead of:
```text
YouTube Downloader GUI
```
Position the project as:
```text
Local-first YouTube playlist synchronization client.
```
That identity is:
- clearer
- more unique
- technically stronger
- easier to expand later
+5 -5
View File
@@ -4,16 +4,16 @@ build-backend = "setuptools.build_meta"
[project]
name = "ytpl-sync"
version = "1.1.1"
description = "YouTube playlist Sync Thing"
version = "2.3.0"
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",
"PySide6",
"yt-dlp>=2026.10.26",
"PySide6_Essentials>=6.11.1",
]
[project.optional-dependencies]
test = [
@@ -23,7 +23,7 @@ test = [
]
[project.urls]
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
Home = "https://github.com/darkzoul5/YoutubePlaylistSync"
[project.scripts]
ytpl-sync = "app.cli:main"
+4 -4
View File
@@ -3,17 +3,17 @@ from __future__ import annotations
import argparse
import asyncio
import logging
import re
from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.service import SyncService
from .core.sync.executor import ActionExecutor
from .core.events.event_bus import EventBus
import re
from .core.utils.yt import extract_playlist_id
from .core.sync.executor import ActionExecutor
from .core.sync.service import SyncService
from .core.utils.deps import DependencyError
from .core.utils.logging_setup import configure_logging
from .core.utils.yt import extract_playlist_id
def main(argv: list[str] | None = None) -> int:
+11 -8
View File
@@ -1,9 +1,12 @@
from __future__ import annotations
import json
import logging
import os
from pathlib import Path
from typing import Any, Dict, List
from typing import Any
logger = logging.getLogger(__name__)
def _default_ffmpeg_path() -> str:
@@ -12,7 +15,7 @@ def _default_ffmpeg_path() -> str:
return "./bin/ffmpeg"
DEFAULT_CONFIG: Dict[str, Any] = {
DEFAULT_CONFIG: dict[str, Any] = {
"playlists": [],
"download_mode": "video",
"max_download_quality": "1080p",
@@ -29,7 +32,7 @@ class Settings:
base_dir = Path("config")
base_dir.mkdir(parents=True, exist_ok=True)
self.path = (base_dir / "yt-playlist-config.json").resolve()
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
self.data: dict[str, Any] = dict(DEFAULT_CONFIG)
# Ensure there is always a config file at the default path.
if not self.path.exists():
@@ -40,13 +43,13 @@ class Settings:
def _load_from_path(self, path: Path) -> None:
try:
self.data.update(json.loads(path.read_text(encoding="utf-8")))
except Exception:
except (OSError, TypeError, UnicodeError, ValueError) as exc:
# Leave defaults if invalid JSON; validation can be added later.
pass
logger.debug("Failed to load config %s: %s", path, exc)
def _write_default_config(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
default_payload: Dict[str, Any] = {
default_payload: dict[str, Any] = {
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID",
@@ -60,7 +63,7 @@ class Settings:
path.write_text(json.dumps(default_payload, indent=2) + "\n", encoding="utf-8")
@property
def playlists(self) -> List[Dict[str, Any]]:
def playlists(self) -> list[dict[str, Any]]:
global_defaults = {
"download_mode": self.data.get("download_mode", DEFAULT_CONFIG["download_mode"]),
"max_download_quality": self.data.get("max_download_quality", DEFAULT_CONFIG["max_download_quality"]),
@@ -71,7 +74,7 @@ class Settings:
"retry_delay_seconds": self.data.get("retry_delay_seconds", DEFAULT_CONFIG["retry_delay_seconds"]),
}
results: List[Dict[str, Any]] = []
results: list[dict[str, Any]] = []
for pl in list(self.data.get("playlists", [])):
if not isinstance(pl, dict):
continue
+4 -4
View File
@@ -1,10 +1,10 @@
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
__all__ = [
"scanner",
"sync",
"download",
"database",
"scheduler",
"download",
"events",
"scanner",
"scheduler",
"sync",
]
+17 -2
View File
@@ -1,9 +1,8 @@
from __future__ import annotations
import sqlite3
from collections.abc import Iterable
from pathlib import Path
from typing import Iterable
SCHEMA = """
PRAGMA journal_mode=WAL;
@@ -33,6 +32,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 +47,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 +64,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 +72,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 +83,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 +91,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 +99,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,6 +107,7 @@ 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 = ?",
@@ -101,6 +115,7 @@ class Database:
)
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:
+27 -26
View File
@@ -1,9 +1,11 @@
from __future__ import annotations
from typing import Optional
import logging
from .queue_manager import DownloadJob, JobState
logger = logging.getLogger(__name__)
class Downloader:
"""
@@ -11,7 +13,7 @@ class Downloader:
where actual download logic will land (audio/video/both).
"""
def __init__(self, yt_dlp_path: Optional[str] = None, ffmpeg_path: Optional[str] = None) -> None:
def __init__(self, yt_dlp_path: str | None = None, ffmpeg_path: str | None = None) -> None:
self.yt_dlp_path = yt_dlp_path
self.ffmpeg_path = ffmpeg_path
@@ -34,15 +36,15 @@ class Downloader:
return None
try:
cap = int(digits)
except Exception:
except ValueError:
return None
return cap if cap > 0 else None
cap = parse_height_cap(max_download_quality)
if cap is not None:
#if the requested cap isn't available, we still download the best mp4.
return f"best[ext=mp4][acodec!=none][vcodec!=none][height<={cap}]/best[ext=mp4][height<={cap}]/best[ext=mp4]"
return "best[ext=mp4][acodec!=none][vcodec!=none]/best[ext=mp4]"
# Prefer separate MP4 video and audio streams; yt-dlp merges them with ffmpeg.
return f"bestvideo[ext=mp4][height<={cap}]+bestaudio[ext=m4a]/bestvideo[ext=mp4][height<={cap}]+bestaudio/bestvideo[ext=mp4][height<={cap}]/best[ext=mp4]"
return "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo[ext=mp4]+bestaudio/bestvideo[ext=mp4]/best[ext=mp4]"
async def handle_job(self, job: DownloadJob):
try:
@@ -60,10 +62,10 @@ class Downloader:
if not job.keep_video and job.output_path:
try:
job.output_path.unlink(missing_ok=True)
except Exception:
pass
except OSError as exc:
logger.debug("Failed to remove temporary video file: %s", exc)
job.state = JobState.COMPLETED
except Exception as exc: # pragma: no cover - environment dependent
except Exception as exc: # noqa: BLE001 # pragma: no cover - environment dependent
job.state = JobState.FAILED
job.error = str(exc)
@@ -72,11 +74,12 @@ class Downloader:
import asyncio
def run():
import yt_dlp # type: ignore
from pathlib import Path
import yt_dlp # type: ignore
try:
from yt_dlp.utils import DownloadCancelled # type: ignore
except Exception: # pragma: no cover - optional
except (ImportError, AttributeError): # pragma: no cover - optional
DownloadCancelled = Exception # type: ignore[misc,assignment]
class _QuietLogger:
@@ -94,10 +97,8 @@ class Downloader:
fmt = self.build_format(getattr(job, "max_download_quality", None))
# All modes download a single muxed mp4 when possible.
# This avoids any ffmpeg-driven merging during the download step, satisfying:
# - video: "original file, no processing"
# - audio/both: extraction is done separately after download
# Prefer MP4 video with a separate audio stream; yt-dlp merges them when needed.
# Audio extraction for audio/both modes happens separately after download.
ydl_opts = {
"format": fmt,
"outtmpl": outtmpl,
@@ -137,19 +138,19 @@ class Downloader:
pct = float(payload.get("progress")) if isinstance(payload.get("progress"), (int, float)) else None
should_emit = status in {"finished", "error"}
if not should_emit:
if now - last_ts >= 0.25:
should_emit = True
elif pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01):
should_emit = True
if not should_emit and (
now - last_ts >= 0.25
or pct is not None and (last_pct < 0 or abs(pct - last_pct) >= 0.01)
):
should_emit = True
if should_emit:
setattr(job, "_last_progress_emit_ts", now)
job._last_progress_emit_ts = now
if pct is not None:
setattr(job, "_last_progress_emit_pct", pct)
job._last_progress_emit_pct = pct
progress_cb(payload)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - progress hooks must not abort downloads
logger.debug("Progress callback failed: %s", exc)
ydl_opts["progress_hooks"] = [hook]
@@ -160,8 +161,8 @@ class Downloader:
p = Path(str(ffmpeg_hint))
if p.exists():
ydl_opts["ffmpeg_location"] = str(p)
except Exception:
pass
except (OSError, TypeError, ValueError) as exc:
logger.debug("Invalid ffmpeg path: %s", exc)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
ydl.download([job.url])
+23 -11
View File
@@ -1,10 +1,11 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Optional
from typing import Any
from ..models import PlaylistItem
@@ -22,28 +23,36 @@ class JobState(str, Enum):
@dataclass
class DownloadJob:
item: PlaylistItem
output_path: Optional[Path] = None
url: Optional[str] = None
output_path: Path | None = None
url: str | None = None
mode: str = "audio" # audio|video
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
error: str | None = None
ffmpeg_path: str | None = None
max_download_quality: str | None = None
playlist_id: str | None = None
progress_callback: Callable[[dict[str, Any]], None] | None = None
cancel_check: Callable[[], bool] | None = None
audio_output_path: Path | None = None # when mode=video and we also want mp3
keep_video: bool = True
class QueueManager:
"""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._queue: asyncio.Queue[DownloadJob] = asyncio.Queue()
self._concurrency = max(1, concurrency)
self._workers: list[asyncio.Task[None]] = []
self._stopped = asyncio.Event()
async def start(self, worker_coro):
"""Start the worker tasks that drain the queue."""
async def runner(idx: int):
while not self._stopped.is_set():
job = await self._queue.get()
@@ -55,13 +64,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()
+2 -1
View File
@@ -2,9 +2,10 @@ from __future__ import annotations
import asyncio
import logging
from ..utils.rate_limit import is_youtube_rate_limit_error
from .downloader import Downloader
from .queue_manager import DownloadJob, JobState
from ..utils.rate_limit import is_youtube_rate_limit_error
async def default_worker(job: DownloadJob, *, max_retries: int = 2, delay_seconds: float = 1.5):
+5 -5
View File
@@ -1,21 +1,21 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Awaitable, Callable, DefaultDict, Dict, List
from collections.abc import Awaitable, Callable
from typing import Any
EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
EventHandler = Callable[[dict[str, Any]], Awaitable[None]]
class EventBus:
"""Simple async pub/sub event bus used by backend and (later) GUI."""
def __init__(self) -> None:
self._subs: DefaultDict[str, List[EventHandler]] = defaultdict(list)
self._subs: defaultdict[str, list[EventHandler]] = defaultdict(list)
def subscribe(self, event_name: str, handler: EventHandler) -> None:
self._subs[event_name].append(handler)
async def publish(self, event_name: str, payload: Dict[str, Any]) -> None:
async def publish(self, event_name: str, payload: dict[str, Any]) -> None:
for h in list(self._subs.get(event_name, [])):
await h(payload)
+5 -6
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
class DownloadMode(str, Enum):
@@ -15,7 +14,7 @@ class DownloadMode(str, Enum):
@dataclass(frozen=True)
class Playlist:
id: str
name: Optional[str]
name: str | None
url: str
path: Path
mode: DownloadMode = DownloadMode.audio
@@ -29,7 +28,7 @@ class PlaylistItem:
video_id: str
title: str
playlist_index: int
local_filename: Optional[str] = None
local_filename: str | None = None
downloaded: bool = False
@@ -45,9 +44,9 @@ class SyncActionType(str, Enum):
@dataclass(frozen=True)
class SyncAction:
type: SyncActionType
item: Optional[PlaylistItem] = None
from_name: Optional[str] = None
to_name: Optional[str] = None
item: PlaylistItem | None = None
from_name: str | None = None
to_name: str | None = None
@dataclass(frozen=True)
+13 -8
View File
@@ -1,23 +1,28 @@
from __future__ import annotations
import logging
from pathlib import Path
from typing import List, Optional
from ..models import PlaylistItem
logger = logging.getLogger(__name__)
class PlaylistScanner:
"""
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, *, ffmpeg_path: Optional[str] = None) -> List[PlaylistItem]:
def scan(self, playlist_url: str, playlist_id: str, *, ffmpeg_path: str | None = None) -> list[PlaylistItem]:
"""Return the current remote playlist entries as `PlaylistItem` records."""
try:
import yt_dlp # type: ignore
except Exception as exc: # pragma: no cover - environment dependent
@@ -36,14 +41,14 @@ class PlaylistScanner:
p = Path(str(ffmpeg_path))
if p.exists():
ydl_opts["ffmpeg_location"] = str(p)
except Exception:
pass
except (OSError, TypeError, ValueError) as exc:
logger.debug("Invalid ffmpeg path: %s", exc)
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(playlist_url, download=False)
entries = info.get("entries", []) if isinstance(info, dict) else []
items: List[PlaylistItem] = []
items: list[PlaylistItem] = []
for idx, v in enumerate(entries, start=1):
if not v:
continue
+1 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from datetime import timedelta
from typing import Awaitable, Callable
class Scheduler:
+3 -3
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Iterable, List, Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
@@ -18,8 +18,8 @@ class DiffEngine:
db_index: Mapping[str, PlaylistItem],
fs_entries: Iterable[FilesystemEntry],
extension: str,
) -> List[SyncAction]:
actions: List[SyncAction] = []
) -> list[SyncAction]:
actions: list[SyncAction] = []
desired_names = {
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
+42 -21
View File
@@ -1,29 +1,45 @@
from __future__ import annotations
import asyncio
import time
import logging
import shutil
import time
from collections.abc import Iterable
from pathlib import Path
from typing import Iterable, List
from ..database.db import Database
from ..download.queue_manager import DownloadJob, QueueManager
from ..download.workers import default_worker
from ..events.event_bus import EventBus
from ..models import SyncAction, SyncActionType
from ..sync.reorder import safe_multi_rename
from ..database.db import Database
from ..utils.yt import extract_playlist_id
from ..events.event_bus import EventBus
from ..utils.deps import ensure_ffmpeg_available, ensure_yt_dlp_available
from ..utils.rate_limit import is_youtube_rate_limit_error
from ..utils.yt import extract_playlist_id
logger = logging.getLogger(__name__)
class ActionExecutor:
"""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, *, 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()
@@ -82,7 +98,8 @@ class ActionExecutor:
try:
self.db.set_playlist_last_sync(playlist_id)
last_sync = self.db.get_playlist_last_sync(playlist_id)
except Exception:
except Exception as exc: # noqa: BLE001 - sync timestamp persistence is best effort
logger.debug("Failed to persist playlist sync timestamp: %s", exc)
last_sync = None
summary = {
"playlist_id": playlist_id,
@@ -123,10 +140,11 @@ 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 = []
applied: List[SyncAction] = []
applied: list[SyncAction] = []
for a in actions:
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
continue
@@ -146,12 +164,13 @@ class ActionExecutor:
if a.item and a.to_name:
try:
self.db.update_local_filename(playlist_id, a.item.video_id, a.to_name)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - database update is best effort
logger.debug("Failed to update renamed file in database: %s", exc)
if self.bus:
await self.bus.publish("RenameApplied", {"playlist_id": playlist_id, "video_id": a.item.video_id, "to": a.to_name})
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"
@@ -172,18 +191,19 @@ class ActionExecutor:
if dst.exists():
dst.unlink()
shutil.move(str(src), str(dst))
except Exception:
except OSError as exc:
logger.debug("Failed to recycle file %s: %s", src, exc)
# fallback to delete if move fails
try:
src.unlink()
except Exception:
pass
except OSError as unlink_exc:
logger.debug("Failed to delete unrecycled file %s: %s", src, unlink_exc)
# Update DB to clear file state
if a.item:
try:
self.db.clear_file_state(playlist_id, a.item.video_id)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - database update is best effort
logger.debug("Failed to clear deleted file in database: %s", exc)
if self.bus:
asyncio.create_task(self.bus.publish("FileRecycled", {"playlist_id": playlist_id, "video_id": a.item.video_id, "name": a.from_name}))
@@ -198,12 +218,13 @@ class ActionExecutor:
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", "")
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:
except (TypeError, ValueError):
concurrency = self.concurrency
queue = QueueManager(concurrency=concurrency)
@@ -211,17 +232,17 @@ class ActionExecutor:
retry_delay_cfg = playlist_cfg.get("retry_delay_seconds", 1.5)
try:
retry_max_retries = int(retry_max_cfg) if retry_max_cfg is not None else 2
except Exception:
except (TypeError, ValueError):
retry_max_retries = 2
try:
retry_delay_seconds = float(retry_delay_cfg) if retry_delay_cfg is not None else 1.5
except Exception:
except (TypeError, ValueError):
retry_delay_seconds = 1.5
delay_cfg = playlist_cfg.get("delay_between_downloads_seconds", 0.0)
try:
delay_between_downloads_seconds = float(delay_cfg) if delay_cfg is not None else 0.0
except Exception:
except (TypeError, ValueError):
delay_between_downloads_seconds = 0.0
rate_limit_pause = asyncio.Event()
@@ -268,7 +289,7 @@ class ActionExecutor:
await queue.start(worker)
try:
jobs: List[DownloadJob] = []
jobs: list[DownloadJob] = []
# Collapse 'both' into single video download + local audio extraction
# Build per-video desired outputs
@@ -400,5 +421,5 @@ class ActionExecutor:
)
else:
await self.bus.publish("DownloadFailed", {"playlist_id": playlist_id, "video_id": job.item.video_id, "error": err})
except Exception:
pass
except Exception as exc: # noqa: BLE001 - one failed job must not stop finalization
logger.debug("Failed to persist download result: %s", exc)
+3 -3
View File
@@ -1,14 +1,14 @@
from __future__ import annotations
from collections.abc import Sequence
from pathlib import Path
from typing import List, Sequence
from ..models import FilesystemEntry
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
def list_files(root: Path, extensions: Sequence[str]) -> list[FilesystemEntry]:
exts = {e.lower() for e in extensions}
results: List[FilesystemEntry] = []
results: list[FilesystemEntry] = []
if not root.exists():
return results
for p in root.glob("**/*"):
+3 -3
View File
@@ -1,10 +1,10 @@
from __future__ import annotations
from collections.abc import Iterable
from pathlib import Path
from typing import Dict, Iterable, Tuple
def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
def safe_multi_rename(renames: Iterable[tuple[Path, Path]]) -> None:
"""
Apply multiple renames safely using a two-pass strategy to avoid
name collisions. Each item is a tuple (src_path, dst_path).
@@ -14,7 +14,7 @@ def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
existing_dests = {dst for _, dst in planned}
# Pass 1: move all sources that would collide to temporary names
temps: Dict[Path, Path] = {}
temps: dict[Path, Path] = {}
for src, dst in planned:
if not src.exists():
continue
+17 -5
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
from pathlib import Path
from typing import List
from ..database.db import Database
from ..models import PlaylistItem, SyncAction
@@ -13,6 +12,13 @@ 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()
@@ -27,7 +33,13 @@ class SyncService:
return [".mp3", ".mp4"]
return [".mp4"]
def sync_from_config(self, playlist_cfg: dict) -> List[SyncAction]:
def sync_from_config(self, playlist_cfg: dict) -> list[SyncAction]:
"""Return the sync actions required to bring one playlist in sync.
This method does not apply any changes itself. It normalizes the
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", "video")
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
@@ -47,7 +59,7 @@ class SyncService:
ffmpeg_cfg = playlist_cfg.get("ffmpeg_path")
items = self.scanner.scan(url, playlist_id, ffmpeg_path=str(ffmpeg_cfg) if ffmpeg_cfg is not None else None)
sanitized: List[PlaylistItem] = []
sanitized: list[PlaylistItem] = []
for it in items:
safe_title = sanitize_title(it.title, it.video_id)
sanitized.append(
@@ -87,7 +99,7 @@ class SyncService:
)
# Augment remote items with DB-known filenames/download flags
augmented: List[PlaylistItem] = []
augmented: list[PlaylistItem] = []
for it in sanitized:
known = db_index.get(it.video_id)
if known is None:
@@ -105,7 +117,7 @@ class SyncService:
)
exts = self._mode_to_extensions(mode)
merged_actions: List[SyncAction] = []
merged_actions: list[SyncAction] = []
# Compute per-extension actions against respective roots
for ext in exts:
+8 -9
View File
@@ -6,7 +6,6 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from shutil import which
from typing import Optional, Tuple
class DependencyError(RuntimeError):
@@ -16,20 +15,20 @@ class DependencyError(RuntimeError):
@dataclass(frozen=True)
class DependencyStatus:
yt_dlp_ok: bool
yt_dlp_version: Optional[str]
yt_dlp_version: str | None
ffmpeg_ok: bool
ffmpeg_path: Optional[str]
ffmpeg_path: str | None
ffprobe_ok: bool
ffprobe_path: Optional[str]
ffprobe_path: str | None
def get_yt_dlp_version() -> Optional[str]:
def get_yt_dlp_version() -> str | None:
try:
import yt_dlp # type: ignore
# yt-dlp exposes version in different places depending on install
return getattr(yt_dlp, "__version__", None) or getattr(getattr(yt_dlp, "version", None), "__version__", None)
except Exception:
except (ImportError, AttributeError, TypeError):
return None
@@ -44,7 +43,7 @@ def ensure_yt_dlp_available() -> str:
return get_yt_dlp_version() or "unknown"
def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Optional[str], Optional[str]]:
def _resolve_tool_paths(tool_hint: str | None, exe_name: str) -> tuple[str | None, str | None]:
"""
Returns (resolved_exe_path, resolved_dir_hint).
- If tool_hint is a file path, use it.
@@ -70,7 +69,7 @@ def _resolve_tool_paths(tool_hint: Optional[str], exe_name: str) -> Tuple[Option
return None, None
def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]:
def ensure_ffmpeg_available(ffmpeg_hint: str | None) -> tuple[str, str]:
"""
Ensures ffmpeg is runnable. Returns (ffmpeg_path, ffmpeg_path).
@@ -79,7 +78,7 @@ def ensure_ffmpeg_available(ffmpeg_hint: Optional[str]) -> Tuple[str, str]:
"""
ffmpeg_exe = "ffmpeg.exe" if sys.platform.startswith("win") else "ffmpeg"
ffmpeg_path, ffmpeg_dir = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
ffmpeg_path, _ = _resolve_tool_paths(ffmpeg_hint, ffmpeg_exe)
if not ffmpeg_path:
raise DependencyError("ffmpeg not found. Install ffmpeg or set 'ffmpeg_path' in config.")
+40
View File
@@ -0,0 +1,40 @@
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 (OSError, UnicodeError):
return None
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"
+1 -1
View File
@@ -10,5 +10,5 @@ def extract_playlist_id(url: str) -> str | None:
if "list" in qs and qs.get("list"):
return qs.get("list", [None])[0]
return None
except Exception:
except (TypeError, ValueError):
return None
+7 -4
View File
@@ -1,10 +1,13 @@
from __future__ import annotations
import logging
import sys
from pathlib import Path
from PySide6 import QtGui, QtWidgets
logger = logging.getLogger(__name__)
def _resource_base() -> Path:
# PyInstaller sets sys._MEIPASS to the temp extraction dir.
@@ -31,16 +34,16 @@ def load_app_icon() -> QtGui.QIcon:
icon = QtGui.QIcon(str(p))
if not icon.isNull():
return icon
except Exception:
pass
except Exception as exc: # noqa: BLE001 - icon loading is best effort
logger.debug("Failed to load app icon from %s: %s", p, exc)
# Fallback to a platform theme icon (Linux) or a generic icon.
try:
themed = QtGui.QIcon.fromTheme("applications-multimedia")
if not themed.isNull():
return themed
except Exception:
pass
except Exception as exc: # noqa: BLE001 - themed icon loading is best effort
logger.debug("Failed to load themed app icon: %s", exc)
return QtWidgets.QApplication.style().standardIcon(QtWidgets.QStyle.StandardPixmap.SP_ComputerIcon)
+4 -2
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Any, Dict
from typing import Any
from PySide6 import QtCore
from ..core.events.event_bus import EventBus
@@ -32,7 +34,7 @@ class BusBridge(QtCore.QObject):
self._bus.subscribe(name, self._make_handler(name))
def _make_handler(self, name: str):
async def handler(payload: Dict[str, Any]) -> None:
async def handler(payload: dict[str, Any]) -> None:
# Ensure delivery on the Qt main thread.
self.event.emit(name, dict(payload))
+5 -5
View File
@@ -3,29 +3,29 @@ from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
from typing import Any
@dataclass(frozen=True)
class AppConfig:
data: Dict[str, Any]
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")
raise TypeError("config root must be a JSON object")
return AppConfig(data=raw, path=path)
def save_config(path: Path, data: Dict[str, Any]) -> None:
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]:
def normalize_config(data: dict[str, Any]) -> dict[str, Any]:
"""
Ensure basic expected shape for config dict.
Keeps unknown keys intact.
+181 -58
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import sys
import threading
@@ -7,14 +8,17 @@ 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 .bus_bridge import BusBridge
from .config_store import load_config
from .runner import SyncRequest, SyncRunner
from .pages.about import AboutPage
from .pages.logs import LogsPage
from .pages.playlists import PlaylistManagerPage
from .pages.queue import QueuePage
from .pages.logs import LogsPage
from .pages.settings import SettingsPage
from .runner import SyncRequest, SyncRunner
logger = logging.getLogger(__name__)
class MainWindow(QtWidgets.QMainWindow):
@@ -38,29 +42,37 @@ class MainWindow(QtWidgets.QMainWindow):
# Sidebar navigation
self._nav = QtWidgets.QListWidget()
self._nav.setObjectName("sidebar")
self._nav.setFixedWidth(220)
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"):
item = QtWidgets.QListWidgetItem(label)
item.setSizeHint(QtCore.QSize(200, 36))
self._nav.addItem(item)
for label in ("Playlists", "Queue", "Logs", "Settings", "About"):
self._add_sidebar_item(label)
self._nav.currentRowChanged.connect(self._stack.setCurrentIndex)
self._nav.setCurrentRow(0)
@@ -81,8 +93,8 @@ class MainWindow(QtWidgets.QMainWindow):
if cfg_path is not None:
try:
self._settings_page.set_config_path(cfg_path)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - optional settings-page wiring
logger.debug("Failed to set settings config path: %s", exc)
self._playlists_page.cancel_requested.connect(self._cancel_sync)
self._queue_page.cancel_sync_requested.connect(self._cancel_sync)
@@ -93,6 +105,29 @@ class MainWindow(QtWidgets.QMainWindow):
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).
@@ -106,11 +141,12 @@ class MainWindow(QtWidgets.QMainWindow):
tray = ui.get("tray")
tray = tray if isinstance(tray, dict) else {}
return dict(tray)
except Exception:
except Exception as exc: # noqa: BLE001 - config fallback keeps the UI usable
logger.debug("Failed to load tray configuration: %s", exc)
return {}
def _close_to_tray_enabled(self) -> bool:
return bool(self._tray_config().get("close_to_tray", True))
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))
@@ -126,7 +162,8 @@ class MainWindow(QtWidgets.QMainWindow):
try:
if not QtWidgets.QSystemTrayIcon.isSystemTrayAvailable():
return
except Exception:
except Exception as exc: # noqa: BLE001 - tray support is optional
logger.debug("Failed to check system tray availability: %s", exc)
return
icon = load_app_icon()
@@ -179,27 +216,30 @@ class MainWindow(QtWidgets.QMainWindow):
QtWidgets.QSystemTrayIcon.MessageIcon.Information,
3000,
)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - tray notification is best effort
logger.debug("Failed to show tray notification: %s", exc)
return
if self._tray is not None and not self._close_to_tray_enabled():
# Explicitly quit, because the app may be configured to keep running without windows.
try:
event.accept()
except Exception:
pass
except Exception as exc: # noqa: BLE001 - close event acceptance is best effort
logger.debug("Failed to accept close event: %s", exc)
QtWidgets.QApplication.quit()
return
super().closeEvent(event)
def changeEvent(self, event: QtCore.QEvent) -> None: # type: ignore[override]
try:
if event.type() == QtCore.QEvent.Type.WindowStateChange:
if self._tray is not None and self._minimize_to_tray_enabled():
if bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized):
QtCore.QTimer.singleShot(0, self.hide)
except Exception:
pass
if (
event.type() == QtCore.QEvent.Type.WindowStateChange
and self._tray is not None
and self._minimize_to_tray_enabled()
and bool(self.windowState() & QtCore.Qt.WindowState.WindowMinimized)
):
QtCore.QTimer.singleShot(0, self.hide)
except Exception as exc: # noqa: BLE001 - window state handling is best effort
logger.debug("Failed to handle window state change: %s", exc)
super().changeEvent(event)
def _refresh_queue_labels(self) -> None:
@@ -212,24 +252,24 @@ class MainWindow(QtWidgets.QMainWindow):
pid = extract_playlist_id(url) or url
labels[pid] = str(pl.get("name") or f"Playlist {idx}")
self._queue_page.set_playlist_labels(labels)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - queue labels are best effort
logger.debug("Failed to refresh queue labels: %s", exc)
@QtCore.Slot(str, dict)
def _on_bus_event(self, name: str, payload: dict) -> None:
# Fan out to interested pages.
try:
self._queue_page.on_event(name, payload)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
logger.debug("Queue page event handler failed: %s", exc)
try:
self._logs_page.on_event(name, payload)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
logger.debug("Logs page event handler failed: %s", exc)
try:
self._playlists_page.on_event(name, payload)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - page event fan-out is isolated
logger.debug("Playlists page event handler failed: %s", exc)
# Auto-pause on YouTube bot-check/rate-limit surface.
if name == "SyncPaused":
@@ -283,8 +323,8 @@ class MainWindow(QtWidgets.QMainWindow):
try:
self._thread.quit()
self._thread.wait(2000)
except Exception:
pass
except Exception as exc: # noqa: BLE001 - thread shutdown is best effort
logger.debug("Failed to stop sync thread: %s", exc)
self._runner = None
self._cancel_flag = None
self._pause_flag = None
@@ -298,12 +338,12 @@ class MainWindow(QtWidgets.QMainWindow):
if cfg_path is not None:
self._settings_page.set_config_path(cfg_path)
self._refresh_queue_labels()
except Exception:
pass
except Exception as exc: # noqa: BLE001 - config refresh is best effort
logger.debug("Failed to refresh configuration after sync: %s", exc)
# Continue "sync all" chain if active.
if hasattr(self, "_sync_queue") and getattr(self, "_sync_queue"):
nxt = getattr(self, "_sync_queue").pop(0)
if hasattr(self, "_sync_queue") and self._sync_queue:
nxt = self._sync_queue.pop(0)
self._sync_playlist_index(nxt)
@QtCore.Slot()
@@ -324,13 +364,24 @@ class MainWindow(QtWidgets.QMainWindow):
def _apply_style(self) -> None:
self.setStyleSheet(
"""
QMainWindow { background: #0f1115; color: #e6e6e6; }
QWidget { font-size: 13px; }
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: #0b0d11;
border-right: 1px solid #20242d;
background: #0d1015;
border-right: 1px solid #2a3140;
padding: 8px;
}
QListWidget#sidebar::item {
@@ -339,42 +390,114 @@ class MainWindow(QtWidgets.QMainWindow):
padding: 8px 10px;
}
QListWidget#sidebar::item:selected {
background: #1e2633;
background: #21304a;
color: #ffffff;
}
QTableWidget {
background: #0f1115;
gridline-color: #20242d;
border: 1px solid #20242d;
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;
}
QScrollArea#logsScroll {
background: #11151c;
border: 1px solid #2a3140;
border-radius: 10px;
}
QWidget#logEntries {
background: #11151c;
}
QFrame#logEntry {
background: #171b22;
border: 1px solid #2a3140;
border-radius: 8px;
}
QLabel#logEventName {
color: #8ea7ff;
font-weight: 600;
}
QLabel#logEntryBody {
color: #cfd3da;
}
QScrollBar:vertical {
background: #0f1218;
width: 12px;
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: #0b0d11;
color: #cfd3da;
border: 1px solid #20242d;
background: #171b22;
color: #d7dce4;
border: 1px solid #2a3140;
padding: 6px;
}
QPushButton {
background: #1e2633;
border: 1px solid #2a3140;
background: #1e2631;
border: 1px solid #31405a;
padding: 6px 10px;
border-radius: 8px;
color: #e6e6e6;
color: #d7dce4;
}
QPushButton:hover { background: #243044; }
QPushButton:hover { background: #26344a; }
QPushButton:pressed { background: #1a2433; }
QFrame#playlistCard {
background: #0b0d11;
border: 1px solid #20242d;
border-radius: 10px;
background: #171b22;
border: 1px solid #2a3140;
border-radius: 12px;
padding: 10px;
}
QLineEdit, QComboBox {
background: #0f1115;
border: 1px solid #20242d;
background: #11151c;
border: 1px solid #2a3140;
border-radius: 8px;
padding: 6px 8px;
color: #e6e6e6;
color: #d7dce4;
}
QLineEdit:focus, QComboBox:focus {
border: 1px solid #6c8bff;
}
"""
)
+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
+60 -12
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
import json
from PySide6 import QtWidgets
from PySide6 import QtCore, QtWidgets
from ..smooth_scroll import enable_smooth_scrolling
@@ -10,6 +10,7 @@ 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")
@@ -22,22 +23,69 @@ class LogsPage(QtWidgets.QWidget):
top.addWidget(clear_btn)
layout.addLayout(top)
self._text = QtWidgets.QPlainTextEdit()
self._text.setReadOnly(True)
enable_smooth_scrolling(self._text)
layout.addWidget(self._text, 1)
self._scroll = QtWidgets.QScrollArea()
self._scroll.setObjectName("logsScroll")
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QtWidgets.QFrame.Shape.NoFrame)
self._entries = QtWidgets.QWidget()
self._entries.setObjectName("logEntries")
self._entries_layout = QtWidgets.QVBoxLayout(self._entries)
self._entries_layout.setContentsMargins(2, 2, 2, 2)
self._entries_layout.setSpacing(8)
self._entries_layout.addStretch(1)
self._scroll.setWidget(self._entries)
enable_smooth_scrolling(self._scroll)
layout.addWidget(self._scroll, 1)
def _clear(self) -> None:
self._text.clear()
while self._entries_layout.count() > 1:
item = self._entries_layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
def on_event(self, name: str, payload: dict) -> None:
# Avoid flooding the UI with high-frequency progress updates.
if name == "DownloadProgress":
return
# Keep this lightweight: append a single-line JSON entry.
# Keep the event name in the header and show only its payload here.
try:
line = json.dumps({"event": name, **payload}, ensure_ascii=False)
except Exception:
line = f"{name}: {payload}"
self._text.appendPlainText(line)
self._text.moveCursor(self._text.textCursor().End)
line = json.dumps(payload, ensure_ascii=False)
except Exception: # noqa: BLE001 - fall back to a readable representation
line = str(payload)
entry = QtWidgets.QFrame()
entry.setObjectName("logEntry")
entry_layout = QtWidgets.QVBoxLayout(entry)
entry_layout.setContentsMargins(12, 9, 12, 9)
entry_layout.setSpacing(4)
header_layout = QtWidgets.QHBoxLayout()
header_layout.setContentsMargins(0, 0, 0, 0)
event_label = QtWidgets.QLabel(name)
event_label.setObjectName("logEventName")
event_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
header_layout.addWidget(event_label)
header_layout.addStretch(1)
copy_btn = QtWidgets.QPushButton("Copy")
copy_btn.setToolTip("Copy this log entry")
copy_btn.clicked.connect(lambda: self._copy_entry(f"{name}\n{line}"))
header_layout.addWidget(copy_btn)
entry_layout.addLayout(header_layout)
body_label = QtWidgets.QLabel(line)
body_label.setObjectName("logEntryBody")
body_label.setWordWrap(True)
body_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
body_label.setSizePolicy(QtWidgets.QSizePolicy.Policy.Ignored, QtWidgets.QSizePolicy.Policy.Preferred)
entry_layout.addWidget(body_label)
self._entries_layout.insertWidget(self._entries_layout.count() - 1, entry)
self._scroll.verticalScrollBar().setValue(self._scroll.verticalScrollBar().maximum())
@staticmethod
def _copy_entry(text: str) -> None:
QtWidgets.QApplication.clipboard().setText(text)
+37 -12
View File
@@ -1,16 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from pathlib import Path
from typing import Any
from PySide6 import QtCore, QtGui, QtWidgets
from ...config.settings import Settings
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
from ..smooth_scroll import enable_smooth_scrolling
@dataclass(frozen=True)
@@ -36,6 +36,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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] = {}
@@ -50,6 +51,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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)
@@ -57,6 +59,20 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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)
@@ -121,7 +137,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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:
except Exception as exc: # noqa: BLE001 - show config load failures in the UI
self._status.setText(f"Failed to load config: {exc}")
return
finally:
@@ -136,7 +152,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
ls = db.get_playlist_last_sync(pid)
if ls:
last_sync_by_id[pid] = str(ls)
except Exception:
except Exception: # noqa: BLE001 - database metadata is optional
last_sync_by_id = {}
self._list.clear()
@@ -240,7 +256,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._status.setText(f"Saved {len(data['playlists'])} playlists to {self._config_path}.")
# Reload settings to reflect merged defaults
self.reload_from_config()
except Exception as exc:
except Exception as exc: # noqa: BLE001 - show config save failures in the UI
self._status.setText(f"Failed to save config: {exc}")
def _reindex_cards(self) -> None:
@@ -286,7 +302,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
data["playlists"] = self._table_to_playlists()
save_config(self._config_path, data)
self._status.setText(f"Autosaved to {self._config_path}.")
except Exception as exc:
except Exception as exc: # noqa: BLE001 - show autosave failures in the UI
self._status.setText(f"Autosave failed: {exc}")
def on_event(self, name: str, payload: dict) -> None:
@@ -339,7 +355,7 @@ class PlaylistManagerPage(QtWidgets.QWidget):
self._download_state_by_pid[pid]["video_id"] = vid
self._download_state_by_pid[pid]["status"] = str(payload.get("status") or "downloading")
self._set_card_progress(pid, p)
pct = int(round(max(0.0, min(1.0, p)) * 100))
pct = round(max(0.0, min(1.0, p)) * 100)
st = str(payload.get("status") or "downloading")
tail = f"{vid} {pct}%" if vid else f"{pct}%"
self._set_card_status(pid, f"{st} {tail}".strip())
@@ -376,9 +392,8 @@ class PlaylistManagerPage(QtWidgets.QWidget):
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)
if isinstance(w, _PlaylistCard) and w.playlist_id() == playlist_id:
w.set_status(text)
def _set_card_last_sync(self, playlist_id: str, last_sync: str) -> None:
for i in range(self._list.count()):
@@ -410,6 +425,16 @@ class _PlaylistCard(QtWidgets.QFrame):
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
@@ -578,7 +603,7 @@ class _PlaylistCard(QtWidgets.QFrame):
return extract_playlist_id(url) or url
def set_progress(self, progress: float) -> None:
pct = max(0, min(100, int(round(progress * 100))))
pct = max(0, min(100, round(progress * 100)))
self._progress.setValue(pct)
def set_last_sync(self, last_sync: str) -> None:
@@ -587,7 +612,7 @@ class _PlaylistCard(QtWidgets.QFrame):
def validate(self) -> list[str]:
errs: list[str] = []
url = self._url.text().strip()
if not url or not (url.startswith("http://") or url.startswith("https://")):
if not url or not url.startswith(("http://", "https://")):
errs.append("URL required")
mode = self._mode.currentText().strip()
if mode not in {"video", "audio", "both"}:
+11 -1
View File
@@ -10,6 +10,7 @@ class QueuePage(QtWidgets.QWidget):
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] = {}
@@ -29,9 +30,12 @@ class QueuePage(QtWidgets.QWidget):
top.addStretch(1)
clear_btn = QtWidgets.QPushButton("Clear completed")
clear_btn.clicked.connect(self._clear_completed)
clear_all_btn = QtWidgets.QPushButton("Clear all")
clear_all_btn.clicked.connect(self._clear_all)
cancel_btn = QtWidgets.QPushButton("Cancel all")
cancel_btn.clicked.connect(self.cancel_sync_requested.emit)
top.addWidget(clear_btn)
top.addWidget(clear_all_btn)
top.addWidget(cancel_btn)
layout.addLayout(top)
@@ -140,7 +144,7 @@ class QueuePage(QtWidgets.QWidget):
status_item.setText(str(payload.get("status") or "downloading"))
prog = payload.get("progress")
if isinstance(prog, (int, float)):
pct = max(0, min(100, int(round(prog * 100))))
pct = max(0, min(100, round(prog * 100)))
bar = self._table.cellWidget(row, 3)
if bar is None:
bar = QtWidgets.QProgressBar()
@@ -212,3 +216,9 @@ class QueuePage(QtWidgets.QWidget):
vid = v_item.text()
rebuilt[(str(pid), str(vid))] = v_item
self._rows_by_key = rebuilt
def _clear_all(self) -> None:
self._pending_by_key.clear()
self._rows_by_key.clear()
self._table.setRowCount(0)
self._hint.setText("Waiting for downloads…")
+5 -4
View File
@@ -11,6 +11,7 @@ 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] = {}
@@ -51,7 +52,7 @@ class SettingsPage(QtWidgets.QWidget):
tray_form = QtWidgets.QFormLayout()
self._close_to_tray = QtWidgets.QCheckBox()
self._close_to_tray.setChecked(True)
self._close_to_tray.setChecked(False)
tray_form.addRow("close_to_tray", self._close_to_tray)
self._minimize_to_tray = QtWidgets.QCheckBox()
@@ -120,12 +121,12 @@ class SettingsPage(QtWidgets.QWidget):
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", True)))
self._close_to_tray.setChecked(bool(tray.get("close_to_tray", False)))
self._minimize_to_tray.setChecked(bool(tray.get("minimize_to_tray", False)))
self._start_minimized_to_tray.setChecked(bool(tray.get("start_minimized_to_tray", False)))
self._status.setText(f"Loaded settings from {self._config_path}.")
except Exception as exc:
except Exception as exc: # noqa: BLE001 - show load failures in the UI
self._status.setText(f"Failed to load settings: {exc}")
finally:
self._suppress_autosave = False
@@ -160,5 +161,5 @@ class SettingsPage(QtWidgets.QWidget):
save_config(self._config_path, data)
self._status.setText(f"Saved settings to {self._config_path}.")
except Exception as exc:
except Exception as exc: # noqa: BLE001 - show save failures in the UI
self._status.setText(f"Failed to save settings: {exc}")
+4 -4
View File
@@ -4,19 +4,19 @@ import asyncio
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict
from typing import Any
from PySide6 import QtCore
from ..core.database.db import Database
from ..core.events.event_bus import EventBus
from ..core.sync.executor import ActionExecutor
from ..core.sync.service import SyncService
from ..core.events.event_bus import EventBus
@dataclass(frozen=True)
class SyncRequest:
playlist_cfg: Dict[str, Any]
playlist_cfg: dict[str, Any]
apply: bool = True
db_path: Path = Path("db/app.db")
cancel_flag: threading.Event | None = None
@@ -70,5 +70,5 @@ class SyncRunner(QtCore.QObject):
self.finished.emit(False, "cancelled")
else:
self.finished.emit(True, "done")
except Exception as exc:
except Exception as exc: # noqa: BLE001 - report worker failures through the UI signal
self.finished.emit(False, str(exc))
+3 -3
View File
@@ -11,7 +11,7 @@ class _SmoothWheelFilter(QtCore.QObject):
self._anim = QtCore.QPropertyAnimation(area.verticalScrollBar(), b"value", self)
self._anim.setEasingCurve(QtCore.QEasingCurve.Type.OutCubic)
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # noqa: N802
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
if event.type() != QtCore.QEvent.Type.Wheel:
return super().eventFilter(obj, event)
@@ -19,7 +19,7 @@ class _SmoothWheelFilter(QtCore.QObject):
try:
angle_y = wheel.angleDelta().y()
pixel_y = wheel.pixelDelta().y()
except Exception:
except Exception: # noqa: BLE001 - fall back to native scrolling for unknown event types
return super().eventFilter(obj, event)
dy = pixel_y if pixel_y else angle_y
@@ -34,7 +34,7 @@ class _SmoothWheelFilter(QtCore.QObject):
if pixel_y:
delta = -dy
else:
delta = int(round(-dy / 120.0 * base_step))
delta = round(-dy / 120.0 * base_step)
target = max(sb.minimum(), min(sb.maximum(), start + delta))
if target == start:
+3 -3
View File
@@ -12,10 +12,10 @@ from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.service import SyncService
from .core.sync.executor import ActionExecutor
from .core.utils.yt import extract_playlist_id
from .core.sync.service import SyncService
from .core.utils.deps import DependencyError
from .core.utils.yt import extract_playlist_id
def bootstrap(db_path: Path | None = None) -> None:
@@ -49,7 +49,7 @@ def bootstrap(db_path: Path | None = None) -> None:
print("Applied actions.")
else:
print(f"No actions needed for: {pl.get('url')}")
except Exception as exc: # keep bootstrap resilient during early dev
except Exception as exc: # noqa: BLE001 - keep bootstrap resilient during early dev
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
+2
View File
@@ -6,10 +6,12 @@ from app.core.download.downloader import Downloader
def test_build_format_defaults_to_best_mp4():
fmt = Downloader.build_format(None)
assert "height<=1080" in fmt
assert "bestvideo[ext=mp4][height<=1080]+bestaudio" in fmt
assert fmt.endswith("/best[ext=mp4]")
def test_build_format_applies_height_cap():
fmt = Downloader.build_format("720p")
assert "bestvideo[ext=mp4]" in fmt
assert "height<=720" in fmt
+1 -1
View File
@@ -37,7 +37,7 @@ def test_executor_emits_sync_events(tmp_path):
assert "SyncSummary" in names
assert "SyncFinished" in names
summary = [p for n, p in published if n == "SyncSummary"][0]
summary = next(p for n, p in published if n == "SyncSummary")
assert summary["playlist_id"] == "p"
assert "duration_s" in summary
assert isinstance(summary["counts"], dict)
+1 -2
View File
@@ -5,11 +5,10 @@ import os
import pytest
from app.core.database.db import Database
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+1 -2
View File
@@ -5,11 +5,10 @@ import os
import pytest
from app.core.database.db import Database
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+1 -2
View File
@@ -5,11 +5,10 @@ import os
import pytest
from app.core.database.db import Database
from app.core.sync.executor import ActionExecutor
from app.core.events.event_bus import EventBus
from app.core.sync.executor import ActionExecutor
from app.core.sync.service import SyncService
PLAYLIST_URL = os.getenv("TEST_PLAYLIST_URL")
+7 -1
View File
@@ -1,7 +1,13 @@
from __future__ import annotations
import sys
from pathlib import Path
src_dir = Path(__file__).resolve().parent / "src"
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
from app.gui.main import main
if __name__ == "__main__":
raise SystemExit(main())