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

31 Commits

Author SHA1 Message Date
dark_zoul e5ad786bcf feat(backend): Implemented executor, safe renames, recycle deletes, and real yt-dlp downloads.
Extended service to compute actions for audio, video, and both.
2026-05-15 14:32:48 +03:00
dark_zoul abd3c2ed62 feat(backend): scaffold state-based sync foundation (no GUI)
Add core scanner, diff engine, SQLite DB, queue, events, scheduler, utils
Wire settings + bootstrap to compute actions;
2026-05-15 11:48:36 +03:00
dark_zoul 6d8649ac2d refactor: move docker files to /docker folder;
remove old buid workflow;
2026-05-15 11:34:43 +03:00
dark_zoul 658def3d58 fix: add missing fields to pyproject.toml 2026-05-15 11:27:09 +03:00
dark_zoul 0ab96e4399 start work on project refactor;
create file structure
move old code to /src/old
2026-05-15 10:52:10 +03:00
dark_zoul 0cea4cfcb8 fix plan 2026-05-15 10:06:59 +03:00
dark_zoul 04a7367d19 chore: move all plans to plans folder; add app conversion plan 2026-05-15 09:52:09 +03:00
darkzoul5 760b6c2f94 ci: add python packages to dependabot 2026-05-15 09:42:05 +03:00
darkzoul5 d7a05c5f52 Merge pull request #8 from darkzoul5/dependabot/github_actions/softprops/action-gh-release-3
Bump softprops/action-gh-release from 2 to 3
2026-05-07 23:15:29 +03:00
dependabot[bot] 93522423ae Bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 17:12:33 +00:00
Mihail Volohov e52dbdcb2e Merge pull request #7 from darkzoul5/dependabot/github_actions/actions/download-artifact-8
Bump actions/download-artifact from 5 to 8
2026-04-15 20:04:51 +03:00
dependabot[bot] 543dd39618 Bump actions/download-artifact from 5 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 17:12:29 +00:00
dark_zoul a841fb4fd8 Update base image to Python 3.13-alpine 2026-04-11 19:42:53 +03:00
Mihail Volohov 2b843ab322 Merge pull request #3 from darkzoul5/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-04-11 18:10:34 +03:00
dependabot[bot] ef57b2e79e 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-04-11 15:09:43 +00:00
Mihail Volohov e8d955ab08 Merge pull request #2 from darkzoul5/dependabot/github_actions/actions/cache-5
Bump actions/cache from 4 to 5
2026-04-11 18:09:12 +03:00
Mihail Volohov c0b919953d Merge pull request #4 from darkzoul5/dependabot/github_actions/actions/setup-python-6
Bump actions/setup-python from 5 to 6
2026-04-11 18:09:00 +03:00
Mihail Volohov 5ab5dcbaf1 Merge pull request #5 from darkzoul5/dependabot/github_actions/docker/login-action-4
Bump docker/login-action from 3 to 4
2026-04-11 18:08:49 +03:00
Mihail Volohov df6b1655cd Merge pull request #6 from darkzoul5/dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 6 to 7
2026-04-11 18:08:11 +03:00
dependabot[bot] e4f0d0c97a Bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 15:06:58 +00:00
dependabot[bot] 22ad7af3e8 Bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 15:06:53 +00:00
dependabot[bot] e53b91f0e6 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-04-11 15:06:50 +00:00
dependabot[bot] da6e939c1d Bump actions/cache from 4 to 5
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-11 15:06:43 +00:00
dark_zoul a5ffa38785 chore: add dependabot configuration for GitHub Actions updates 2026-04-11 18:05:58 +03:00
dark_zoul 40c2839ffc chore: update create-pull-request action to v8 2026-04-11 18:03:35 +03:00
dark_zoul 0c8de9aebb Merge branch 'main' of https://github.com/darkzoul5/YoutubePlaylistDownloader 2026-04-11 18:02:04 +03:00
dark_zoul eeb01117c3 AI: Add dependency updates workflow for automated aria2 version refresh 2026-04-11 18:01:51 +03:00
dark_zoul 25c833435f Add project schedule 2026-04-07 10:38:47 +03:00
dark_zoul 8106fa1f04 Refactor GUI plan formatting 2026-03-20 19:28:30 +02:00
dark_zoul b6afbb33c1 Refine project and GUI plans 2026-03-20 19:25:01 +02:00
dark_zoul 2e1ebe1eea Update README for accuracy in usage instructions. fix typos 2026-03-20 19:12:07 +02:00
56 changed files with 1809 additions and 440 deletions
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: pip
directory: "/"
schedule:
interval: daily
time: "10:00"
groups:
python-packages:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
-298
View File
@@ -1,298 +0,0 @@
name: Build Release
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v0.1.0)"
required: true
type: string
permissions:
contents: write
packages: write
jobs:
build-windows-package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install dependencies
run: sudo apt update && sudo apt install -y unzip zip curl
- name: Get version from tag
id: version
shell: bash
run: |
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Prepare Windows package
run: |
set -e
VERSION="${{ steps.version.outputs.version }}"
mkdir -p "$GITHUB_WORKSPACE/dist/windows/bin"
cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/windows/"
# yt-dlp
curl -fL --retry 3 -H "User-Agent: github-actions" \
-o "$GITHUB_WORKSPACE/dist/windows/bin/yt-dlp.exe" \
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
# FFmpeg Windows static
curl -fL --retry 3 -H "User-Agent: github-actions" \
-o "$GITHUB_WORKSPACE/dist/windows/ffmpeg.zip" \
https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
unzip -q "$GITHUB_WORKSPACE/dist/windows/ffmpeg.zip" -d "$GITHUB_WORKSPACE/dist/windows/ffmpeg_temp"
mv $(find "$GITHUB_WORKSPACE/dist/windows/ffmpeg_temp" -name ffmpeg.exe | head -n 1) "$GITHUB_WORKSPACE/dist/windows/bin/ffmpeg.exe"
# aria2c Windows static
curl -fL --retry 3 -H "User-Agent: github-actions" \
-o "$GITHUB_WORKSPACE/dist/windows/aria2c.zip" \
https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip
unzip "$GITHUB_WORKSPACE/dist/windows/aria2c.zip" -d "$GITHUB_WORKSPACE/dist/windows/"
mv "$GITHUB_WORKSPACE/dist/windows/aria2-1.37.0-win-64bit-build1/aria2c.exe" "$GITHUB_WORKSPACE/dist/windows/bin/aria2c.exe"
rm -rf "$GITHUB_WORKSPACE/dist/windows/ffmpeg_temp" "$GITHUB_WORKSPACE/dist/windows/aria2-1.37.0-win-64bit-build1" "$GITHUB_WORKSPACE/dist/windows/ffmpeg.zip" "$GITHUB_WORKSPACE/dist/windows/aria2c.zip"
# Create windows archive
cd "$GITHUB_WORKSPACE/dist/windows"
ZIP_NAME="yt-playlist-windows-${VERSION}.zip"
zip -r "$GITHUB_WORKSPACE/$ZIP_NAME" *
echo "ZIP_PATH=$GITHUB_WORKSPACE/$ZIP_NAME" >> $GITHUB_ENV
- name: Upload Windows artifact
uses: actions/upload-artifact@v6
with:
name: windows-release
path: ${{ github.workspace }}/yt-playlist-windows-*.zip
build-linux-package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Get version from tag
id: version
shell: bash
run: |
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Install dependencies
run: |
sudo apt update
sudo apt install -y unzip zip curl wget build-essential pkg-config libssl-dev zlib1g-dev
- name: Prepare workspace
run: |
set -e
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/linux/"
- name: Download yt-dlp
run: |
curl -fL --retry 3 -H "User-Agent: github-actions" \
-o "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp" \
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp"
- name: Download FFmpeg static
run: |
curl -fL --retry 3 -H "User-Agent: github-actions" \
-o "$GITHUB_WORKSPACE/dist/linux/ffmpeg.tar.xz" \
https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
mkdir -p "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp"
tar -xf "$GITHUB_WORKSPACE/dist/linux/ffmpeg.tar.xz" -C "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp" --strip-components=1
mv "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
- name: Restore aria2 cache
id: aria2-cache
uses: actions/cache@v4
with:
path: dist/linux/bin/aria2c
key: aria2c-${{ runner.os }}-1.37.0
- name: Build aria2c if not cached
if: steps.aria2-cache.outputs.cache-hit != 'true'
run: |
set -e
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
mkdir -p "$GITHUB_WORKSPACE/dist/linux/aria2c_build"
cd "$GITHUB_WORKSPACE"
wget https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.gz
tar -xzf aria2-1.37.0.tar.gz
cd aria2-1.37.0
CFLAGS="-Os -s" LDFLAGS="-static" ./configure \
--enable-static --disable-shared \
--disable-libaria2 --without-ca-bundle \
--without-libnettle --without-libgcrypt \
--without-libssh2 --without-libexpat \
--without-libxml2 --without-libsqlite3 \
--with-openssl
make -j"$(nproc)"
strip src/aria2c
cp src/aria2c "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
rm -rf "$GITHUB_WORKSPACE/dist/linux/aria2c_build" "$GITHUB_WORKSPACE/aria2-1.37.0" "$GITHUB_WORKSPACE/aria2-1.37.0.tar.gz"
- name: Show cache status and bin contents
run: |
echo "Cache hit: ${{ steps.aria2-cache.outputs.cache-hit }}"
echo "Listing dist/linux/bin:"
ls -la dist/linux/bin || true
- name: Cleanup FFmpeg temp
run: rm -rf "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp" "$GITHUB_WORKSPACE/dist/linux/ffmpeg.tar.xz"
- name: Archive Linux package
run: |
set -e
VERSION="${{ steps.version.outputs.version }}"
cd "$GITHUB_WORKSPACE/dist/linux"
TAR_NAME="yt-playlist-linux-${VERSION}.tar.gz"
tar -czf "$GITHUB_WORKSPACE/$TAR_NAME" *
- name: Upload Linux artifact
uses: actions/upload-artifact@v6
with:
name: linux-release
path: ${{ github.workspace }}/yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
build-docker-image:
runs-on: ubuntu-latest
needs: [build-linux-package]
steps:
- uses: actions/checkout@v5
- name: Get version from tag
id: version
shell: bash
run: |
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set docker image names
run: |
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}" >> $GITHUB_ENV
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
- name: Download linux artifact
uses: actions/download-artifact@v5
with:
name: linux-release
- name: Prepare Docker build context
run: |
mkdir -p dist/linux-docker
cp Dockerfile dist/linux-docker/
echo "Copying and extracting Linux artifact..."
tar -xzf yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz -C dist/linux-docker/
echo "Build context contents:"
ls -R dist/linux-docker
- name: Build Docker image (release)
run: docker build dist/linux-docker -t $RELEASE_IMAGE
- name: Save Docker image as tar (release)
run: docker save -o docker-image.tar $RELEASE_IMAGE
- name: Upload docker-image artifact
uses: actions/upload-artifact@v6
with:
name: docker-image
path: docker-image.tar
- name: Build Docker image (latest)
run: docker build dist/linux-docker --label build_as_latest=true -t $LATEST_IMAGE
- name: Save Docker image as tar (latest)
run: docker save -o docker-image-latest.tar $LATEST_IMAGE
- name: Upload docker-image-latest artifact
uses: actions/upload-artifact@v6
with:
name: docker-image-latest
path: docker-image-latest.tar
release:
runs-on: ubuntu-latest
needs: [build-windows-package, build-linux-package, build-docker-image]
steps:
- uses: actions/download-artifact@v5
with:
name: windows-release
path: windows-release
- uses: actions/download-artifact@v5
with:
name: linux-release
path: linux-release
- uses: actions/download-artifact@v5
with:
name: docker-image
path: docker-image
- uses: actions/download-artifact@v5
with:
name: docker-image-latest
path: docker-image-latest
- name: Get version from tag
id: version
shell: bash
run: |
VERSION="${{ inputs.tag }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Set docker image names
run: |
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}" >> $GITHUB_ENV
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Load and push Docker release image
run: |
docker load -i docker-image/docker-image.tar
docker push $RELEASE_IMAGE
- name: Load and push Docker latest image
run: |
docker load -i docker-image-latest/docker-image-latest.tar
docker push $LATEST_IMAGE
- name: Create GitHub Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ steps.version.outputs.version }}
release_name: "Release ${{ steps.version.outputs.version }}"
draft: true
- name: Upload Windows release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: windows-release/yt-playlist-windows-${{ steps.version.outputs.version }}.zip
asset_name: yt-playlist-windows-${{ steps.version.outputs.version }}.zip
asset_content_type: application/zip
- name: Upload Linux release asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: linux-release/yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
asset_name: yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
asset_content_type: application/gzip
+15 -15
View File
@@ -17,9 +17,9 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install dependencies
@@ -46,7 +46,7 @@ jobs:
artifact_name: linux-release
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get version
id: version
@@ -112,7 +112,7 @@ jobs:
- name: Cache aria2c binary (Linux)
if: matrix.platform == 'linux'
id: aria2-cache
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: ${{ github.workspace }}/dist/linux/bin/aria2c
key: aria2c-linux-1.37.0
@@ -154,7 +154,7 @@ jobs:
cd "$GITHUB_WORKSPACE/dist/linux" && tar -czf "$GITHUB_WORKSPACE/yt-playlist-linux-$VERSION.tar.gz" *
- name: Upload Artifact
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact_name }}
path: |
@@ -165,7 +165,7 @@ jobs:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Get version
id: version
@@ -176,7 +176,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download Linux Artifact
uses: actions/download-artifact@v5
uses: actions/download-artifact@v8
with:
name: linux-release
path: ${{ github.workspace }}/dist/linux-docker
@@ -200,8 +200,8 @@ jobs:
- name: Set docker image names
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${VERSION}" >> $GITHUB_ENV
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:${VERSION}" >> $GITHUB_ENV
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:latest" >> $GITHUB_ENV
- name: Build Docker image
run: |
@@ -215,7 +215,7 @@ jobs:
working-directory: ${{ github.workspace }}/dist/linux-docker
- name: Upload Docker artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
with:
name: docker-images
path: ${{ github.workspace }}/dist/linux-docker/docker-image*.tar
@@ -226,7 +226,7 @@ jobs:
if: startsWith(github.event.inputs.tag, 'v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v8
with:
path: ${{ github.workspace }}/artifacts
@@ -239,7 +239,7 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -248,12 +248,12 @@ jobs:
- name: Push Docker images
run: |
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:${{ steps.version.outputs.version }}
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:latest
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:latest
- name: Create Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.event.inputs.tag }}
draft: true
+64
View File
@@ -0,0 +1,64 @@
name: dependency-updates
on:
workflow_dispatch:
schedule:
- cron: "0 12 * * 1"
permissions:
contents: write
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-bundled-binaries:
name: Refresh pinned build dependencies
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Discover latest aria2 release
id: aria2
shell: bash
run: |
set -euo pipefail
latest_version="$(
git ls-remote --tags --refs https://github.com/aria2/aria2.git 'refs/tags/release-*' \
| awk -F/ '{print $NF}' \
| sed 's/^release-//' \
| sort -V \
| tail -n 1
)"
if [ -z "$latest_version" ]; then
echo "Unable to determine the latest aria2 release." >&2
exit 1
fi
echo "version=$latest_version" >> "$GITHUB_OUTPUT"
- name: Update release workflow pins
shell: bash
env:
ARIA2_VERSION: ${{ steps.aria2.outputs.version }}
run: |
set -euo pipefail
python3 -c 'from pathlib import Path; import os; path = Path(".github/workflows/build_v2.yml"); text = path.read_text(encoding="utf-8"); version = os.environ["ARIA2_VERSION"]; updated = text.replace("release-1.37.0", f"release-{version}").replace("aria2-1.37.0", f"aria2-{version}").replace("aria2c-linux-1.37.0", f"aria2c-linux-{version}"); path.write_text(updated, encoding="utf-8") if updated != text else None; print(f"Updated aria2 references to {version}." if updated != text else "No aria2 references changed.")'
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: refresh aria2 build pins"
title: "Refresh aria2 build pins"
body: |
Automated maintenance update for the bundled aria2 release references used by the release workflow.
branch: dependency-updates/aria2
delete-branch: true
labels: dependencies, maintenance
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Make bundled linux binaries executable (if present)
run: |
+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install Ruff
run: pip install ruff
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Create venv and install project
run: |
+1
View File
@@ -1,4 +1,5 @@
{
"python.terminal.activateEnvironment": true,
"python.testing.pytestArgs": [
"tests"
],
-26
View File
@@ -1,26 +0,0 @@
### Python-first
- Primary GUI framework: `PySide6` (Qt for Python) — native desktop look, cross-platform on Windows and Linux, mature widget set, good documentation.
- Desktop architecture: keep the core downloader logic as a Python package and expose a local HTTP/WebSocket API (e.g., `FastAPI`) that the GUI talks to. The GUI stays a thin client that issues commands and receives status updates.
### Why this approach
- Stay in Python end-to-end for now, minimizing new languages or runtimes.
- A local API boundary lets you reuse the same backend for a future Web frontend (React/Next.js or plain SPA) and for Android (native or Flutter shell that talks to the API or a hosted API).
- `PySide6` provides a polished native desktop UX and easier packaging for Windows/Linux compared with Python mobile toolkits.
### Packaging & Distribution (brief)
- Bundle the backend and GUI into one distributable. The GUI should spawn the local API process (background subprocess) on startup.
- Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX.
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
- Security: bind the local API to `localhost` only, use a short-lived token or IPC for authentication between GUI and backend, and avoid exposing unnecessary ports.
### Roadmap (GUI → Web → Mobile)
1. Desktop prototype: `FastAPI` backend + `PySide6` GUI (thin client) with basic playlist add/update/download controls and status streaming.
2. Packaging: create Windows exe/installer and Linux AppImage for the prototype.
3. Web frontend: build a web SPA that consumes the same backend API (hosted or local) — this reuses business logic with minimal change.
4. Android: either a native app or cross-platform UI (Flutter/React Native) that calls the backend API; alternatively host the backend and make a thin mobile client.
If you want, I can now: scaffold a minimal `FastAPI` backend and `PySide6` desktop starter in this repo, or produce concise packaging steps for Windows and Linux. Which do you prefer?
+9 -22
View File
@@ -3,12 +3,10 @@
[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml/badge.svg)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build_v2.yml)
[![Unit tests](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml)
A cross-platform tool for downloading entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp), [ffmpeg](https://ffmpeg.org/), and [aria2c](https://github.com/aria2/aria2). Includes Gitea CI/CD workflow for packaging and releasing Windows and Linux binaries.
A cross-platform tool for downloading entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp), [ffmpeg](https://ffmpeg.org/), and [aria2](https://github.com/aria2/aria2). Includes Gitea CI/CD workflow for packaging and releasing Windows and Linux binaries.
Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options.
---
## Features
- **Download full YouTube playlists** as high-quality MP3 (audio), MP4 (video), or both.
@@ -21,14 +19,10 @@ Supports audio, video, or both download modes, music and videos are numbered as
- **Cross-platform:** Windows and Linux support.
- **GitHub Actions CI/CD workflow** for automated packaging and release.
---
## Requirements
- Python 3.8+
---
## Installation
### Quick Start
@@ -46,12 +40,12 @@ Supports audio, video, or both download modes, music and videos are numbered as
- Open `yt-playlist-config.json` and adjust paths, playlist URLs, download mode, and quality as needed.
1. **Run the downloader:**
1. **Run ytpld:**
- On Windows:
```sh
python yt-playlist-main.py
yt-playlist-main.exe
```
- On Linux:
@@ -60,8 +54,6 @@ Supports audio, video, or both download modes, music and videos are numbered as
python3 yt-playlist-main.py
```
---
## Usage
### Configuration
@@ -98,9 +90,8 @@ Edit `yt-playlist-config.json` to specify playlists, paths, and options:
### Running
- Just run the script with Python:
- On Windows: `python yt-playlist-main.py`
- On Linux: `python3 yt-playlist-main.py`
- On Windows: `yt-playlist-main.exe`
- On Linux: `python3 yt-playlist-main.py`
- The downloader will:
- Check for required tools and update yt-dlp automatically
@@ -110,18 +101,16 @@ Edit `yt-playlist-config.json` to specify playlists, paths, and options:
- Avoid re-downloading files you've already downloaded
- Offer to clean up files that are no longer in the playlist
---
## CLI flags
When running the script locally (for example `python yt-playlist-main.py`), you can pass the following flags:
When running the script via python (for example `python yt-playlist-main.py`), you can pass the following flags:
- `-c, --config <path>` — Path to a configuration file (relative to the repository `config/` directory by default)
- `-d, --debug` — Show verbose subprocess output (yt-dlp, ffmpeg, aria2c)
- `-p, --prune` — Run with pruning (deleting files not present in playlists)
- `-y, --yes, --non-interactive` — Auto-confirm prompts (used only with `--prune`at the moment)
Examples (local):
Examples:
```powershell
# Run with debug output
@@ -134,8 +123,6 @@ python yt-playlist-main.py --prune --yes
python yt-playlist-main.py --config custom-config.json
```
---
## Docker Usage
You can run YouTube Playlist Downloader using the official Docker image.
@@ -160,7 +147,7 @@ Create a `docker-compose.yml` with the following content (replace the host paths
```yaml
services:
yt-downloader:
image: ghcr.io/dark_zoul/ytpld:latest
image: ghcr.io/dark_zoul5/ytpld:latest
container_name: yt-downloader
restart: no
volumes:
@@ -215,4 +202,4 @@ See [LICENSE](LICENSE).
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- [ffmpeg](https://ffmpeg.org/)
- [aria2c](https://github.com/aria2/aria2)
- [aria2](https://github.com/aria2/aria2)
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.12-alpine
FROM python:3.13-alpine
WORKDIR /app
+3 -3
View File
@@ -1,8 +1,8 @@
version: '3.8'
services:
ytpld:
image: git.darkzoul.org/dark_zoul/ytpld:latest
container_name: ytpld
ytplst:
image: git.darkzoul.org/dark_zoul/ytplst:latest
container_name: ytplst
restart: no
volumes:
- /path/to/downloads:/app/downloads
@@ -1,6 +1,5 @@
#!/bin/sh
# Entry point for the ytplaylist container.
# Converts environment variables into CLI flags and execs the Python CLI.
set -e
+38
View File
@@ -0,0 +1,38 @@
# GUI Plan
## Python-first Desktop Architecture
- **Primary GUI framework**: `PySide6` (Qt for Python).
- **Communication Layer**: A local `FastAPI` backend to separate core logic from the UI.
- **IPC Mechanism**: The GUI spawns the FastAPI server on a random high port (binding to `127.0.0.1` ONLY) and communicates via REST/WebSockets.
## Core Features to Implement
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, aria2c), max parallel jobs, and Docker detection toggle.
## Phase 1 Roadmap: "The Bridge"
- [ ] **Refactor `src/manager.py`**: Convert CLI-first execution to async-compatible methods for FastAPI consumption.
- [ ] **FastAPI Integration**: Create endpoints for `/playlists`, `/status`, and `/download/start`.
- [ ] **PySide6 Skeleton**: Basic window with `QWebEngine` (if hybrid) or native `QWidget` dashboard.
- [ ] **Packaging**: `pyinstaller` configuration to bundle both backend and frontend into a single `.exe`.
## Packaging & Distribution (brief)
- Bundle the backend and GUI into one distributable. The GUI should spawn the local API process (background subprocess) on startup.
- Windows: use `pyinstaller` or `briefcase` to create an executable/installer. Consider creating an MSI or Inno Setup installer for a polished UX.
- Linux: provide AppImage, Snap, or distribution-specific packages (deb/rpm) — AppImage is a good starting point for single-file distribution.
- Security: bind the local API to `localhost` only, use a short-lived token or IPC for authentication between GUI and backend, and avoid exposing unnecessary ports.
## Roadmap (GUI → Web → Mobile)
1. Desktop prototype: `FastAPI` backend + `PySide6` GUI (thin client) with basic playlist add/update/download controls and status streaming.
2. Packaging: create Windows exe/installer and Linux AppImage for the prototype.
3. Web frontend: build a web SPA that consumes the same backend API (hosted or local) — this reuses business logic with minimal change.
4. Android: either a native app or cross-platform UI (Flutter/React Native) that calls the backend API; alternatively host the backend and make a thin mobile client.
If you want, I can now: scaffold a minimal `FastAPI` backend and `PySide6` desktop starter in this repo, or produce concise packaging steps for Windows and Linux. Which do you prefer?
+50
View File
@@ -0,0 +1,50 @@
# Project Plan
## Subject Area
- Tool for downloading and synchronizing YouTube playlists.
- Focuses on batch downloading, format selection (audio and/or video), configurable quality and keeping local copies synced with playlist changes.
- Targets power users and archivists who need large-scale, repeatable playlist archiving and ongoing synchronization, with GUI interface.
## Problem
- Users and power-users who manage large or frequently changing YouTube playlists lack a dependable, configurable tool that:
- correctly detects and downloads new videos while avoiding duplicates,
- and can be configured easily via file or GUI for repeatable workflows.
## Users Definition
Individuals who need to download a large number of videos or audio files from a YouTube playlist and keep it updated
## Functionality Definition
- Multi-format Download:
- Video only (mp4)
- Audio only (mp3)
- Both video and audio (mp3 & mp4)
- Smart Synchronization:
- Archive tracking (prevents re-downloading existing media)
- Playlist Pruning (automatically deletes local files no longer in the YouTube playlist)
- Sequential Renumbering (keeps local files sorted by playlist position)
- Advanced Configuration:
- Per-playlist settings (Quality, paths, archive file)
- Global performance options (Parallel downloads, aria2c threading)
- Path management for yt-dlp, ffmpeg, and aria2c (Docker-aware)
- GUI Integration:
- Real-time status updates via backend API
- Visual configuration editor
- Modern, responsive Qt-based interface
## Platforms
- Desktop: Windows (Primary), Linux
- Docker
- Possible Future: Web App, Android App (via shared FastAPI backend)
## Architecture & Languages
- Core Engine: Python (yt-dlp wrapper)
- Backend API: FastAPI (Local localhost-only boundary)
- Desktop Frontend: PySide6 (Qt for Python)
- Distribution: PyInstaller / Briefcase (Windows .exe, Linux AppImage)
+26
View File
@@ -0,0 +1,26 @@
# Project Schedule
| No. | Task description | Estimated time |
| --- | --- | --- |
| 1 | Define the project requirements, target users, and core features for playlist downloading and synchronization. | 3 hours |
| 2 | Design the overall architecture for the Python package, CLI entry point, and configuration structure. | 4 hours |
| 3 | Implement configuration loading and validation for paths, download modes, quality settings, and external tools. | 5 hours |
| 4 | Build the playlist fetching logic to read playlist entries and detect new, deleted, or private videos. | 5 hours |
| 5 | Implement the downloader core for audio, video, and combined download modes using yt-dlp, ffmpeg, and aria2c. | 8 hours |
| 6 | Add filename sanitization, track renumbering, and output path management to keep downloaded files organized. | 5 hours |
| 7 | Implement playlist update and cleanup logic so the local library stays synchronized with the online playlist. | 5 hours |
| 8 | Create CLI flags for debug mode, pruning, auto-confirmation, and custom config file selection. | 4 hours |
| 9 | Add logging, warnings, and error handling for missing binaries, network issues, and invalid settings. | 4 hours |
| 10 | Prepare Docker support and environment-variable based configuration for containerized usage. | 4 hours |
| 11 | Review the implementation, fix bugs, and refine behavior based on test results and runtime issues. | 6 hours |
| 12 | Choose the GUI framework and define the desktop architecture, create a local backend API. | 4 hours |
| 13 | Build the initial desktop GUI prototype with controls & basic actions. | 6 hours |
| 14 | Connect the GUI to backend functionality for adding playlists, starting downloads, and showing progress updates. | 6 hours |
| 15 | Add GUI settings for download mode, quality, save paths, and external tool configuration. | 5 hours |
| 16 | Prepare GUI packaging and distribution for Windows and Linux. | 5 hours |
| 17 | Write automated tests for config loading, downloader behavior, playlist updates, and cleanup edge cases. | 7 hours |
| 18 | Write user documentation, usage examples, and release instructions for Windows and Linux. | 3 hours |
## Total Time
89 hours
@@ -0,0 +1,781 @@
# 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
-55
View File
@@ -1,55 +0,0 @@
# Project Plan
## Subject Area
- Tool for downloading and synchronizing YouTube playlists.
- Focuses on reliable batch downloading, format selection (audio and/or video), configurable quality and keeping local copies synced with playlist changes.
- Targets power users and archivists who need large-scale, repeatable playlist archiving and ongoing synchronization, with GUI interfaces.
## Problem
- Users and power-users who manage large or frequently changing YouTube playlists lack a dependable, configurable tool that:
- correctly detects and downloads new videos while avoiding duplicates,
- and can be configured easily via file or GUI for repeatable workflows.
## Users Definition
Individuals who need to download a large number of videos or audio files from a YouTube playlist and keep it updated
## Functionality Definition
- Can download:
- Video only
- Audio only
- Both video and audio
- Can update the playlist (download only newly added videos)
- Can delete videos that are no longer in the playlist
- Has configuration for:
- Quality
- Download type (audio, video)
- Save directory
- Use of aria2c
- aria2-related settings
- GUI settings
## GUI
- Has buttons for all features
- Allows adjusting all settings from the GUI
- Modern Design
## Platform
- Desktop application
- Optional:
- Web App
- Android App
## Languages
- Backend
- Python
- Frontend
- qt ?
- Tkinter?
+25 -6
View File
@@ -3,17 +3,36 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "ytpld"
version = "v1.1.1"
description = "YouTube playlist downloader"
name = "ytplst"
version = "1.1.1"
description = "YouTube playlist Sync Thing"
readme = "README.md"
authors = [ { name = "Dark_Zoul" } ]
license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "downloader"]
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
requires-python = ">=3.10"
dependencies = [
"yt-dlp>=2026.3.17",
]
[project.optional-dependencies]
gui = [
"PySide6"
]
dev = [
"pytest",
"ruff",
"black"
]
[project.urls]
"Home" = "https://git.darkzoul.org/dark_zoul/youtube-playlist-downloader"
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
[project.scripts]
ytplst = "ytplst.main:main"
[tool.setuptools]
package-dir = {"" = "src"}
[tool.setuptools.packages.find]
where = ["src"]
include = ["*"]
include = ["ytplst*"]
+10
View File
@@ -0,0 +1,10 @@
"""
App package: backend foundation for playlist sync (no GUI).
This package is the new, state-driven backend. It is intentionally
minimal at this stage and will be filled out iteratively.
"""
__all__ = [
"core",
]
+1
View File
@@ -0,0 +1 @@
"""Config loader for the new backend (separate from legacy)."""
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Dict, List, Optional
DEFAULT_CONFIG: Dict[str, Any] = {
"playlists": [],
"download_mode": "audio",
"max_video_quality": "1080p",
"save_path": "./downloads",
"yt_dlp_path": "yt-dlp",
"ffmpeg_path": "ffmpeg",
}
class Settings:
def __init__(self, config_path: Optional[Path] = None) -> None:
base_dir = Path("config")
base_dir.mkdir(parents=True, exist_ok=True)
self.path = (config_path or (base_dir / "yt-playlist-config.json")).resolve()
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
if self.path.exists():
try:
self.data.update(json.loads(self.path.read_text(encoding="utf-8")))
except Exception:
# Leave defaults if invalid JSON; validation can be added later.
pass
@property
def playlists(self) -> List[Dict[str, Any]]:
return list(self.data.get("playlists", []))
+10
View File
@@ -0,0 +1,10 @@
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
__all__ = [
"scanner",
"sync",
"download",
"database",
"scheduler",
"events",
]
+1
View File
@@ -0,0 +1 @@
"""Database helpers (SQLite)."""
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Iterable
SCHEMA = """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
name TEXT,
url TEXT,
path TEXT,
mode TEXT,
auto_sync INTEGER,
sync_interval_minutes INTEGER,
last_sync TEXT
);
CREATE TABLE IF NOT EXISTS 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)
);
"""
class Database:
def __init__(self, db_path: Path) -> None:
self.path = db_path
self.path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(self.path)
self._conn.row_factory = sqlite3.Row
self._migrate()
def _migrate(self) -> None:
with self._conn:
self._conn.executescript(SCHEMA)
def upsert_playlist_items(self, rows: Iterable[tuple]):
sql = (
"INSERT INTO playlist_items (playlist_id, video_id, title, playlist_index, local_filename, downloaded, last_seen) "
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(playlist_id, video_id) DO UPDATE SET "
"title=excluded.title, playlist_index=excluded.playlist_index, local_filename=excluded.local_filename, "
"downloaded=excluded.downloaded, last_seen=datetime('now')"
)
with self._conn:
self._conn.executemany(sql, rows)
def get_items_index(self, playlist_id: str) -> dict[str, sqlite3.Row]:
cur = self._conn.execute(
"SELECT * FROM playlist_items WHERE playlist_id = ?",
(playlist_id,),
)
return {row["video_id"]: row for row in cur.fetchall()}
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from typing import Optional
from .queue_manager import DownloadJob, JobState
class Downloader:
"""
Thin wrapper around yt-dlp usage. For MVP, this is a placeholder
where actual download logic will land (audio/video/both).
"""
def __init__(self, yt_dlp_path: Optional[str] = None, ffmpeg_path: Optional[str] = None) -> None:
self.yt_dlp_path = yt_dlp_path
self.ffmpeg_path = ffmpeg_path
async def handle_job(self, job: DownloadJob):
try:
job.state = JobState.DOWNLOADING
await self._download(job)
job.state = JobState.COMPLETED
except Exception as exc: # pragma: no cover - environment dependent
job.state = JobState.FAILED
job.error = str(exc)
async def _download(self, job: DownloadJob):
# Use yt-dlp Python API, executed in a worker thread
import asyncio
def run():
import yt_dlp # type: ignore
outtmpl = str(job.output_path)
if job.mode == "audio":
ydl_opts = {
"format": "bestaudio/best",
"outtmpl": outtmpl,
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "mp3",
"preferredquality": "0",
}
],
"noplaylist": True,
"quiet": True,
"no_warnings": True,
}
else: # video
ydl_opts = {
"format": "bestvideo+bestaudio/best",
"merge_output_format": "mp4",
"outtmpl": outtmpl,
"noplaylist": True,
"quiet": True,
"no_warnings": True,
}
if self.ffmpeg_path:
ydl_opts["ffmpeg_location"] = self.ffmpeg_path
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
ydl.download([job.url])
await asyncio.to_thread(run)
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
from ..models import PlaylistItem
class JobState(str, Enum):
QUEUED = "Queued"
DOWNLOADING = "Downloading"
CONVERTING = "Converting"
COMPLETED = "Completed"
FAILED = "Failed"
SKIPPED = "Skipped"
CANCELLED = "Cancelled"
@dataclass
class DownloadJob:
item: PlaylistItem
output_path: Optional[Path] = None
url: Optional[str] = None
mode: str = "audio" # audio|video
state: JobState = JobState.QUEUED
error: Optional[str] = None
class QueueManager:
def __init__(self, concurrency: int = 2) -> None:
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):
async def runner(idx: int):
while not self._stopped.is_set():
job = await self._queue.get()
try:
await worker_coro(job)
finally:
self._queue.task_done()
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
async def stop(self):
self._stopped.set()
for w in self._workers:
w.cancel()
self._workers.clear()
async def enqueue(self, job: DownloadJob):
await self._queue.put(job)
+9
View File
@@ -0,0 +1,9 @@
from __future__ import annotations
from .downloader import Downloader
from .queue_manager import DownloadJob
async def default_worker(job: DownloadJob):
dl = Downloader()
await dl.handle_job(job)
+21
View File
@@ -0,0 +1,21 @@
from __future__ import annotations
from collections import defaultdict
from typing import Any, Awaitable, Callable, DefaultDict, Dict, List
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)
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:
for h in list(self._subs.get(event_name, [])):
await h(payload)
+56
View File
@@ -0,0 +1,56 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Optional
class DownloadMode(str, Enum):
audio = "audio"
video = "video"
both = "both"
@dataclass(frozen=True)
class Playlist:
id: str
name: Optional[str]
url: str
path: Path
mode: DownloadMode = DownloadMode.audio
auto_sync: bool = False
sync_interval_minutes: int = 0
@dataclass(frozen=True)
class PlaylistItem:
playlist_id: str
video_id: str
title: str
playlist_index: int
local_filename: Optional[str] = None
downloaded: bool = False
class SyncActionType(str, Enum):
DOWNLOAD = "DOWNLOAD"
DELETE = "DELETE"
RENAME = "RENAME"
REORDER = "REORDER"
SKIP = "SKIP"
REPAIR = "REPAIR"
@dataclass(frozen=True)
class SyncAction:
type: SyncActionType
item: Optional[PlaylistItem] = None
from_name: Optional[str] = None
to_name: Optional[str] = None
@dataclass(frozen=True)
class FilesystemEntry:
name: str
path: Path
+54
View File
@@ -0,0 +1,54 @@
from __future__ import annotations
from typing import List
from ..models import PlaylistItem
class PlaylistScanner:
"""
Fetches remote playlist entries using yt-dlp (no downloads).
This class intentionally avoids strict dependencies at import time. If
yt_dlp is unavailable, call sites should handle the raised ImportError.
"""
def __init__(self) -> None:
pass
def scan(self, playlist_url: str, playlist_id: str) -> List[PlaylistItem]:
try:
import yt_dlp # type: ignore
except Exception as exc: # pragma: no cover - environment dependent
raise ImportError("yt_dlp is required to scan playlists") from exc
ydl_opts = {
"extract_flat": True,
"skip_download": True,
"quiet": True,
"dump_single_json": True,
}
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] = []
for idx, v in enumerate(entries, start=1):
if not v:
continue
title = v.get("title") or "[Unknown]"
if title in ("[Deleted video]", "[Private video]"):
continue
vid = v.get("id") or ""
if not vid:
continue
items.append(
PlaylistItem(
playlist_id=playlist_id,
video_id=vid,
title=title,
playlist_index=idx,
)
)
return items
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
from datetime import timedelta
from typing import Awaitable, Callable
class Scheduler:
"""
Lightweight placeholder for background scheduling. This can later be
swapped for APScheduler without changing call sites.
"""
def __init__(self) -> None:
self._jobs: list[tuple[timedelta, Callable[[], Awaitable[None]]]] = []
def every(self, interval: timedelta, coro_factory: Callable[[], Awaitable[None]]):
self._jobs.append((interval, coro_factory))
return self
# A full implementation will run an event loop and await jobs.
+47
View File
@@ -0,0 +1,47 @@
from __future__ import annotations
from typing import Iterable, List, Mapping, Sequence
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
class DiffEngine:
"""
Compares remote playlist items, database state, and filesystem to
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/REORDER
based on simple filename scheme "0001 - Title.ext".
"""
def compute_actions(
self,
remote: Sequence[PlaylistItem],
db_index: Mapping[str, PlaylistItem],
fs_entries: Iterable[FilesystemEntry],
extension: str,
) -> List[SyncAction]:
actions: List[SyncAction] = []
desired_names = {
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
for item in remote
}
fs_by_name = {e.name: e for e in fs_entries}
for item in remote:
desired_name = desired_names[item.video_id]
if item.local_filename == desired_name and desired_name in fs_by_name:
continue
if desired_name in fs_by_name:
actions.append(SyncAction(SyncActionType.RENAME, item=item, from_name=item.local_filename, to_name=desired_name))
continue
actions.append(SyncAction(SyncActionType.DOWNLOAD, item=item, to_name=desired_name))
known_ids = {i.video_id for i in remote}
for vid, db_item in db_index.items():
if vid not in known_ids and db_item.local_filename:
actions.append(SyncAction(SyncActionType.DELETE, item=db_item, from_name=db_item.local_filename))
return actions
+100
View File
@@ -0,0 +1,100 @@
from __future__ import annotations
import asyncio
import shutil
from pathlib import Path
from typing import Iterable, List
from ..download.queue_manager import DownloadJob, QueueManager
from ..download.workers import default_worker
from ..models import SyncAction, SyncActionType
from ..sync.reorder import safe_multi_rename
class ActionExecutor:
def __init__(self, concurrency: int = 2) -> None:
self.concurrency = max(1, concurrency)
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
mode = playlist_cfg.get("download_mode", "audio")
# Prepare roots
audio_root = save_path / "audio"
video_root = save_path / "video"
audio_root.mkdir(parents=True, exist_ok=True)
video_root.mkdir(parents=True, exist_ok=True)
# First, handle renames safely in batch per extension
await self._apply_renames(actions, audio_root, video_root)
# Then, recycle deletions
self._apply_deletions(actions, audio_root, video_root)
# Finally, perform downloads concurrently
await self._apply_downloads(actions, mode, audio_root, video_root)
async def _apply_renames(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path) -> None:
audio_renames = []
video_renames = []
for a in actions:
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
continue
if a.to_name.endswith(".mp3"):
audio_renames.append((audio_root / a.from_name, audio_root / a.to_name))
elif a.to_name.endswith(".mp4"):
video_renames.append((video_root / a.from_name, video_root / a.to_name))
if audio_renames:
safe_multi_rename(audio_renames)
if video_renames:
safe_multi_rename(video_renames)
def _apply_deletions(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path) -> None:
recycle_audio = audio_root.parent / ".recycle" / "audio"
recycle_video = video_root.parent / ".recycle" / "video"
recycle_audio.mkdir(parents=True, exist_ok=True)
recycle_video.mkdir(parents=True, exist_ok=True)
for a in actions:
if a.type != SyncActionType.DELETE or not a.from_name:
continue
if a.from_name.endswith(".mp3"):
src = audio_root / a.from_name
dst = recycle_audio / a.from_name
else:
src = video_root / a.from_name
dst = recycle_video / a.from_name
if src.exists():
try:
if dst.exists():
dst.unlink()
shutil.move(str(src), str(dst))
except Exception:
# fallback to delete if move fails
try:
src.unlink()
except Exception:
pass
async def _apply_downloads(self, actions: Iterable[SyncAction], mode: str, audio_root: Path, video_root: Path) -> None:
queue = QueueManager(concurrency=self.concurrency)
async def worker(job: DownloadJob):
await default_worker(job)
await queue.start(worker)
try:
for a in actions:
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
continue
is_audio = a.to_name.endswith(".mp3")
root = audio_root if is_audio else video_root
output_path = root / a.to_name
output_path.parent.mkdir(parents=True, exist_ok=True)
url = f"https://www.youtube.com/watch?v={a.item.video_id}"
job = DownloadJob(item=a.item, output_path=output_path, url=url, mode=("audio" if is_audio else "video"))
await queue.enqueue(job)
finally:
await queue._queue.join() # wait for all jobs
await queue.stop()
+17
View File
@@ -0,0 +1,17 @@
from __future__ import annotations
from pathlib import Path
from typing import Iterable, List, Sequence
from ..models import FilesystemEntry
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
exts = {e.lower() for e in extensions}
results: List[FilesystemEntry] = []
if not root.exists():
return results
for p in root.glob("**/*"):
if p.is_file() and p.suffix.lower() in exts:
results.append(FilesystemEntry(name=p.name, path=p))
return results
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
from pathlib import Path
from typing import Dict, Iterable, Tuple
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).
"""
temp_suffix = ".renametemp"
planned = list(renames)
existing_dests = {dst for _, dst in planned}
# Pass 1: move all sources that would collide to temporary names
temps: Dict[Path, Path] = {}
for src, dst in planned:
if not src.exists():
continue
if src.name == dst.name:
continue
# If destination exists or another source will become destination, use temp
if dst.exists() or dst in existing_dests:
tmp = src.with_suffix(src.suffix + temp_suffix)
# Ensure unique temp
i = 0
while tmp.exists():
i += 1
tmp = src.with_name(src.name + f".{i}" + temp_suffix)
src.rename(tmp)
temps[tmp] = dst
else:
# direct rename safe
src.rename(dst)
# Pass 2: move all temp files to their final destinations
for tmp, dst in temps.items():
if not tmp.exists():
continue
if dst.exists():
dst.unlink()
tmp.rename(dst)
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from typing import List
from ..database.db import Database
from ..models import PlaylistItem
from ..scanner.playlist_scanner import PlaylistScanner
from ..sync.diff_engine import DiffEngine
from ..sync.filesystem import list_files
from ..utils.naming import make_filename, sanitize_title
from ..utils.yt import extract_playlist_id
class SyncService:
def __init__(self, db: Database) -> None:
self.db = db
self.scanner = PlaylistScanner()
self.diff = DiffEngine()
def _mode_to_extensions(self, mode: str) -> list[str]:
if mode == "audio":
return [".mp3"]
if mode == "video":
return [".mp4"]
if mode == "both":
return [".mp3", ".mp4"]
return [".mp3"]
def sync_from_config(self, playlist_cfg: dict) -> List[dict]:
url: str = playlist_cfg.get("url")
mode: str = playlist_cfg.get("download_mode", "audio")
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
save_path.mkdir(parents=True, exist_ok=True)
playlist_id = extract_playlist_id(url) or url
items = self.scanner.scan(url, playlist_id)
sanitized: List[PlaylistItem] = []
for it in items:
safe_title = sanitize_title(it.title, it.video_id)
sanitized.append(
PlaylistItem(
playlist_id=it.playlist_id,
video_id=it.video_id,
title=safe_title,
playlist_index=it.playlist_index,
local_filename=None,
downloaded=False,
)
)
rows = [
(
it.playlist_id,
it.video_id,
it.title,
it.playlist_index,
None,
0,
)
for it in sanitized
]
self.db.upsert_playlist_items(rows)
db_index_rows = self.db.get_items_index(playlist_id)
db_index: dict[str, PlaylistItem] = {}
for vid, row in db_index_rows.items():
db_index[vid] = PlaylistItem(
playlist_id=row["playlist_id"],
video_id=row["video_id"],
title=row["title"],
playlist_index=row["playlist_index"],
local_filename=row["local_filename"],
downloaded=bool(row["downloaded"]),
)
exts = self._mode_to_extensions(mode)
merged_actions = []
for ext in exts:
mode_dir = "audio" if ext == ".mp3" else "video"
fs_root = (save_path / mode_dir)
fs_entries = list_files(fs_root, [ext])
actions = self.diff.compute_actions(sanitized, db_index, fs_entries, ext)
merged_actions.extend(actions)
return [
{
"type": a.type,
"video_id": a.item.video_id if a.item else None,
"from_name": a.from_name,
"to_name": a.to_name,
}
for a in merged_actions
]
+1
View File
@@ -0,0 +1 @@
"""Utility helpers for naming, parsing, etc."""
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from dataclasses import dataclass
ILLEGAL_CHARS = '<>:"/\\|?*'
def sanitize_title(title: str, fallback: str) -> str:
table = str.maketrans({c: "-" for c in ILLEGAL_CHARS})
safe = (title or "").translate(table).strip()
return safe if safe else fallback
def make_filename(index: int, title: str, ext: str, width: int = 4) -> str:
return f"{index:0{width}d} - {title}{ext}"
+14
View File
@@ -0,0 +1,14 @@
from __future__ import annotations
from urllib.parse import parse_qs, urlparse
def extract_playlist_id(url: str) -> str | None:
try:
parsed = urlparse(url)
qs = parse_qs(parsed.query)
if "list" in qs and qs.get("list"):
return qs.get("list", [None])[0]
return None
except Exception:
return None
+32
View File
@@ -0,0 +1,32 @@
from __future__ import annotations
"""
Entry point for the new backend (no GUI). For now, this only verifies
that configuration and database setup work. Future iterations will wire
up scanner, diff engine, queue, and scheduler.
"""
from pathlib import Path
from .config.settings import Settings
from .core.database.db import Database
from .core.sync.service import SyncService
def bootstrap(db_path: Path | None = None) -> None:
settings = Settings()
db = Database((db_path or Path("app/data/app.db")).resolve())
service = SyncService(db)
# Iterate configured playlists and compute actions (no execution yet)
for pl in settings.playlists:
try:
actions = service.sync_from_config(pl)
# For now, just print summary for visibility during development
print(f"Computed {len(actions)} actions for playlist: {pl.get('url')}")
except Exception as exc: # keep bootstrap resilient during early dev
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
if __name__ == "__main__":
bootstrap()
View File
View File
+1 -1
View File
@@ -11,7 +11,7 @@ import sys
import shutil
import time
from pathlib import Path
from src.downloader import PlaylistDownloader
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
# Make imports robust when running the script directly from different working directories.
# Ensure the repository root is on sys.path so the script can import `src`.
+1 -1
View File
@@ -1,5 +1,5 @@
import logging
from src.manager import PlaylistManager
from src.old.manager import PlaylistManager
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -2,7 +2,7 @@ import logging
import subprocess
from types import SimpleNamespace
import src.cli as cli_mod
import src.old.cli as cli_mod
class DummyCompleted(SimpleNamespace):
+1 -1
View File
@@ -1,6 +1,6 @@
import json
from src.config import ConfigLoader
from src.old.config import ConfigLoader
def test_config_loader_reads_properties(tmp_path, monkeypatch):
+1 -1
View File
@@ -1,7 +1,7 @@
import subprocess
import shutil
from src.downloader import PlaylistDownloader
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path
from src.downloader import PlaylistDownloader
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -2,7 +2,7 @@ import json
import subprocess
from types import SimpleNamespace
from src.downloader import PlaylistDownloader
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -1,6 +1,6 @@
import logging
from tests.dummy_config import DummyConfig
from src.manager import PlaylistManager
from src.old.manager import PlaylistManager
def test_manager_warns_and_sleeps(monkeypatch, caplog):
+1 -1
View File
@@ -1,5 +1,5 @@
import logging
from src.manager import PlaylistManager
from src.old.manager import PlaylistManager
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -1,6 +1,6 @@
from pathlib import Path
from src.downloader import PlaylistDownloader
from src.old.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig