Merge pull request #1 from darkzoul5/Next

Merge Next with Main
This commit is contained in:
Mihail Volohov
2026-03-18 18:21:04 +02:00
committed by GitHub
34 changed files with 2150 additions and 530 deletions
+89
View File
@@ -0,0 +1,89 @@
.gitea/
.github/
.venv/
./bin/
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
build/
dist/
*.egg-info/
.eggs/
pip-wheel-metadata/
# Installer logs
pip-log.txt
# Virtual environments
venv/
ENV/
env/
env.bak/
venv.bak/
# pyenv
.python-version
# Test and coverage
.pytest_cache/
.coverage
coverage.xml
htmlcov/
# Type checkers
.mypy_cache/
.dmypy.json
dmypy.json
# Pyright
.pyright/
# IDEs and editors
.vscode/
.idea/
*.sublime-workspace
*.sublime-project
# OS files
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Local config and secrets (do NOT include if you intentionally want them in image)
config/yt-playlist-config.json
.env
.env.*
*.secret
secrets.json
# Docker files and Compose (ignore local overrides)
Dockerfile*
docker-compose*.yml
docker-compose*.yaml
# Git and VCS
.git/
.gitignore
# Gitea and CI artifacts
.gitea/workflows/
dist/
# Node (if present)
node_modules/
# Poetry / Pipenv
Pipfile.lock
poetry.lock
# compiled python
*.pyc
+298
View File
@@ -0,0 +1,298 @@
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@v4
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@v3
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@v4
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@v4
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@v4
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@v4
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@v4
with:
name: windows-release
path: windows-release
- uses: actions/download-artifact@v4
with:
name: linux-release
path: linux-release
- uses: actions/download-artifact@v4
with:
name: docker-image
path: docker-image
- uses: actions/download-artifact@v4
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
+195
View File
@@ -0,0 +1,195 @@
name: Build Release V2
on:
workflow_dispatch:
inputs:
tag:
description: "Release tag (e.g., v0.1.0)"
required: true
default: "v0.1.0"
type: string
permissions:
contents: write
packages: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: pytest
build:
needs: test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- platform: windows
os: windows-latest
artifact_name: windows-release
- platform: linux
os: ubuntu-latest
artifact_name: linux-release
steps:
- uses: actions/checkout@v5
- name: Get version
id: version
shell: bash
run: |
TAG="${{ github.event.inputs.tag || inputs.tag }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
# --- WINDOWS BUILD ---
- name: Build Windows Package
if: matrix.platform == 'windows'
shell: pwsh
run: |
$VERSION = "${{ steps.version.outputs.version }}"
New-Item -ItemType Directory -Force -Path "dist/windows/bin"
Copy-Item "yt-playlist-main.py" "dist/windows/"
# yt-dlp
Invoke-WebRequest -Uri "https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe" -OutFile "dist/windows/bin/yt-dlp.exe"
# FFmpeg
Invoke-WebRequest -Uri "https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip" -OutFile "dist/windows/ffmpeg.zip"
Expand-Archive "dist/windows/ffmpeg.zip" -DestinationPath "dist/windows/ffmpeg_temp"
$ffmpegExe = Get-ChildItem -Path "dist/windows/ffmpeg_temp" -Filter "ffmpeg.exe" -Recurse | Select-Object -First 1
Move-Item $ffmpegExe.FullName "dist/windows/bin/ffmpeg.exe"
# aria2c (Windows Portable)
Invoke-WebRequest -Uri "https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip" -OutFile "dist/windows/aria2c.zip"
Expand-Archive "dist/windows/aria2c.zip" -DestinationPath "dist/windows/aria2_temp"
Move-Item "dist/windows/aria2_temp/aria2-1.37.0-win-64bit-build1/aria2c.exe" "dist/windows/bin/aria2c.exe"
# Cleanup & Archive
Remove-Item -Recurse -Force "dist/windows/ffmpeg_temp", "dist/windows/aria2_temp", "dist/windows/*.zip"
Compress-Archive -Path "dist/windows/*" -DestinationPath "yt-playlist-windows-$VERSION.zip"
# --- LINUX BUILD ---
- name: Build Linux Package
if: matrix.platform == 'linux'
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p dist/linux/bin
cp yt-playlist-main.py dist/linux/
# yt-dlp
curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux -o dist/linux/bin/yt-dlp
chmod +x dist/linux/bin/yt-dlp
# FFmpeg (static)
curl -L https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz -o ffmpeg.tar.xz
mkdir -p ffmpeg_temp
tar -xf ffmpeg.tar.xz -C ffmpeg_temp --strip-components=1
mv ffmpeg_temp/ffmpeg dist/linux/bin/
chmod +x dist/linux/bin/ffmpeg
# Archive
cd dist/linux && tar -czf ../../yt-playlist-linux-$VERSION.tar.gz *
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: |
*.zip
*.tar.gz
docker:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Get version
id: version
shell: bash
run: |
TAG="${{ github.event.inputs.tag || inputs.tag }}"
VERSION="${TAG#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download Linux Artifact
uses: actions/download-artifact@v4
with:
name: linux-release
path: dist/linux-docker
- name: Prepare Docker build context
run: |
cd dist/linux-docker
tar -xzf yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
cp ../../Dockerfile .
cp ../../docker-entrypoint.sh .
# Ensure ytpld package is present
if [ ! -d "ytpld" ]; then cp -r ../../ytpld . ; fi
- 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
- name: Build Docker image
run: |
docker build . -t $RELEASE_IMAGE -t $LATEST_IMAGE
working-directory: dist/linux-docker
- name: Save Docker images
run: |
docker save -o docker-image.tar $RELEASE_IMAGE
docker save -o docker-image-latest.tar $LATEST_IMAGE
working-directory: dist/linux-docker
- name: Upload Docker artifacts
uses: actions/upload-artifact@v4
with:
name: docker-images
path: dist/linux-docker/docker-image*.tar
release:
needs: [build, docker]
runs-on: ubuntu-latest
if: startsWith(github.event.inputs.tag, 'v')
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Push Docker images
run: |
docker load -i docker-images/docker-image.tar
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}
docker load -i docker-images/docker-image-latest.tar
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:latest
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.event.inputs.tag }}
files: |
**/*.zip
**/*.tar.gz
+46
View File
@@ -0,0 +1,46 @@
name: Integration tests (minimal)
on:
workflow_dispatch:
#push:
#branches: [ main, Next ]
#pull_request:
#branches: [ main, Next ]
jobs:
integration:
name: Run integration tests
runs-on: ubuntu-latest
env:
INTEGRATION_TEST: '1'
TEST_PLAYLIST_URL: 'https://www.youtube.com/playlist?list=PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz'
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Make bundled linux binaries executable (if present)
run: |
set -euo pipefail
if [ -d ./bin/linux ]; then
chmod +x ./bin/linux/* || true
ls -l ./bin/linux || true
fi
- name: Create venv and install project
run: |
set -euo pipefail
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
# Install project in editable mode. If the 'test' extra exists, prefer it.
python -m pip install -e .[test] || python -m pip install -e .
python -m pip install pytest
- name: Run integration script directly
env:
YTPL_DEBUG: '1'
run: |
set -euo pipefail
. .venv/bin/activate
python tests/integration_full_workflow_test.py
+35
View File
@@ -0,0 +1,35 @@
name: Unit tests
on:
workflow_dispatch:
push:
branches: [ main, Next ]
pull_request:
branches: [ main, Next ]
jobs:
tests:
name: Run unit tests
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Create venv and install project
run: |
set -euo pipefail
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
# Install project (editable) and test deps
python -m pip install -e .[test] || python -m pip install -e .
python -m pip install pytest
- name: Run tests
env:
PYTHONPATH: ${{ github.workspace }}
run: |
set -euo pipefail
. .venv/bin/activate
pytest -q
+6 -3
View File
@@ -2,9 +2,12 @@
#Custom for this project #Custom for this project
yt-playlist-config.json config/yt-playlist-config.json
/bin /tmp*
/config/ /*/tmp*
*.code-workspace
/bin/*
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+8
View File
@@ -0,0 +1,8 @@
{
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true,
"github-actions.workflows.pinned.workflows": []
}
+17 -4
View File
@@ -2,9 +2,22 @@ FROM python:3.12-alpine
WORKDIR /app WORKDIR /app
# Copy application code (package) and bootstrap
COPY yt-playlist-main.py /app/ COPY yt-playlist-main.py /app/
COPY ./bin/ffmpeg /app/bin COPY ytpld/ /app/ytpld/
COPY ./bin/yt-dlp /app/bin COPY config/ /app/config/
COPY ./bin/aria2c /app/bin
CMD ["python", "yt-playlist-main.py"] # Copy helper binaries from the build context (which includes extracted artifacts)
COPY bin/ffmpeg /app/bin/ffmpeg
COPY bin/yt-dlp /app/bin/yt-dlp
COPY bin/aria2c /app/bin/aria2c
# Copy entrypoint that maps environment variables to CLI flags
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
RUN chmod +x /app/docker-entrypoint.sh && chmod +x /app/bin/* || true
# Put the bundled bin directory first in PATH
ENV PATH="/app/bin:${PATH}"
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD [""]
+26
View File
@@ -0,0 +1,26 @@
### 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?
+49 -2
View File
@@ -1,8 +1,11 @@
# YouTube Playlist Downloader # YouTube Playlist Downloader
[![Build Release Packages](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/release.yml) [![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build.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 [aria2c](https://github.com/aria2/aria2). Includes a GitHub Actions 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. 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.
@@ -110,6 +113,29 @@ Edit `yt-playlist-config.json` to specify playlists, paths, and options:
- Offer to clean up files that are no longer in the playlist - 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:
- `-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):
```powershell
# Run with debug output
python yt-playlist-main.py --debug
# Run non-interactive and prune
python yt-playlist-main.py --prune --yes
# Use a different config file
python yt-playlist-main.py --config custom-config.json
```
---
## Docker Usage ## Docker Usage
@@ -149,6 +175,27 @@ Run it with:
docker compose up -d docker compose up -d
``` ```
## Docker Compose — environment variables
You can pass environment variables.
Environment variables
- `YTPL_DEBUG` (0/1): When set to `1` shows verbose output from external binaries (yt-dlp, ffmpeg, aria2c). Useful for diagnosing failures.
- `YTPL_PRUNE` (0/1): When set to `1` enables pruning — files that are not present in any configured playlist will be deleted (requires confirmation unless `YTPL_YES` is set).
- `YTPL_YES` (0/1): Auto-confirm prompts (use with `YTPL_PRUNE` in automated runs).
- `YTPL_CONFIG`: Path to a config file inside the container (defaults to `/app/config/yt-playlist-config.json` if present).
- `YTPL_CONFIG_JSON`: Full JSON payload for the entire config. When provided it overwrites `/app/config/yt-playlist-config.json`.
- `YTPL_PLAYLISTS_JSON`: JSON array used to populate the `playlists` field in the config.
- `PLAYLIST_{N}_{FIELD}`: Indexed playlist entries. For each playlist index N use `PLAYLIST_N_URL`, `PLAYLIST_N_DOWNLOAD_MODE`, `PLAYLIST_N_SAVE_PATH`, `PLAYLIST_N_ARCHIVE`, etc.
- `YTPL_MAX_PARALLEL_DOWNLOADS`: Integer, maximum concurrent downloads.
- `YTPL_ARIA2C_CONNECTIONS`: Integer, connections per download.
- `YTPL_MAX_VIDEO_QUALITY`: String, e.g., `1080p`, `720p`, `best`.
- `YTPL_DOWNLOAD_MODE`: `audio`, `video`, or `both` — default download mode applied to playlists that don't set it individually.
Tip
- Mount a config file for complex setups to avoid long environment variables. Example: `- /host/config/yt-playlist-config.json:/app/config/yt-playlist-config.json`.
## Troubleshooting ## Troubleshooting
- **No binaries found:** Ensure paths in `yt-playlist-config.json` are correct and binaries are present. - **No binaries found:** Ensure paths in `yt-playlist-config.json` are correct and binaries are present.
+9
View File
@@ -0,0 +1,9 @@
version: '3.8'
services:
yt-downloader:
image: git.darkzoul.org/dark_zoul/youtube-playlist-downloader:latest
container_name: yt-downloader
restart: no
volumes:
- /path/to/downloads:/app/downloads
- /path/to/config:/app/config
+16
View File
@@ -0,0 +1,16 @@
{
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
"download_mode": "audio",
"max_video_quality": "1080p",
"save_path": "./downloads",
"archive": "archive.txt"
}
],
"yt_dlp_path": "./bin/yt-dlp",
"ffmpeg_path": "./bin/ffmpeg",
"aria2c_path": "./bin/aria2c",
"max_parallel_downloads": 6,
"aria2c_connections": 4
}
+147
View File
@@ -0,0 +1,147 @@
#!/bin/sh
# Entry point for the ytplaylist container.
# Converts environment variables into CLI flags and execs the Python CLI.
set -e
# Map environment variables to CLI flags
ARGS=""
if [ "${YTPL_DEBUG:-0}" != "0" ]; then
ARGS="$ARGS --debug"
fi
if [ "${YTPL_PRUNE:-0}" != "0" ]; then
ARGS="$ARGS --prune"
fi
if [ "${YTPL_YES:-0}" != "0" ]; then
ARGS="$ARGS --yes"
fi
if [ -n "${YTPL_CONFIG}" ]; then
ARGS="$ARGS --config ${YTPL_CONFIG}"
fi
# If environment-based configuration is provided, materialize it into /app/config/yt-playlist-config.json
# Supported methods (priority order):
# 1) YTPL_CONFIG_JSON -> full JSON payload for the entire config
# 2) YTPL_PLAYLISTS_JSON -> JSON array assigned to 'playlists' key in the base config
# 3) PLAYLIST_{N}_{FIELD} env vars, e.g. PLAYLIST_0_URL, PLAYLIST_0_DOWNLOAD_MODE, etc.
# Top-level overrides (optional): YTPL_MAX_PARALLEL_DOWNLOADS, YTPL_ARIA2C_CONNECTIONS, YTPL_MAX_VIDEO_QUALITY, YTPL_DOWNLOAD_MODE
if [ -n "${YTPL_CONFIG_JSON:-}" ] || [ -n "${YTPL_PLAYLISTS_JSON:-}" ] || env | grep -q '^PLAYLIST_' ; then
python - <<'PY'
import os, json, sys
from pathlib import Path
config_dir = Path('/app/config')
config_dir.mkdir(parents=True, exist_ok=True)
config_path = config_dir / 'yt-playlist-config.json'
# Load existing config if present, otherwise start with a minimal default
base = {
'playlists': [
{
'url': 'https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE',
'download_mode': 'audio',
'max_video_quality': '1080p',
'save_path': './downloads',
'archive': 'archive.txt'
}
],
'yt_dlp_path': 'yt-dlp',
'ffmpeg_path': 'ffmpeg',
'aria2c_path': 'aria2c',
'max_parallel_downloads': 10,
'aria2c_connections': 8,
}
if config_path.exists():
try:
with config_path.open('r', encoding='utf-8') as f:
base = json.load(f)
except Exception:
# if existing file is invalid, continue with our base and overwrite below
pass
# 1) Full config JSON
cfg_json = os.environ.get('YTPL_CONFIG_JSON')
if cfg_json:
try:
cfg = json.loads(cfg_json)
with config_path.open('w', encoding='utf-8') as f:
json.dump(cfg, f, indent=2)
except Exception as e:
print('ERROR: failed to parse YTPL_CONFIG_JSON:', e, file=sys.stderr)
sys.exit(1)
sys.exit(0)
# 2) Playlists JSON
pl_json = os.environ.get('YTPL_PLAYLISTS_JSON')
if pl_json:
try:
playlists = json.loads(pl_json)
if isinstance(playlists, list):
base['playlists'] = playlists
else:
raise ValueError('YTPL_PLAYLISTS_JSON must be a JSON array')
except Exception as e:
print('ERROR: failed to parse YTPL_PLAYLISTS_JSON:', e, file=sys.stderr)
sys.exit(1)
# 3) Indexed PLAYLIST_{N}_{FIELD} variables
playlists = {}
for k, v in os.environ.items():
if not k.startswith('PLAYLIST_'):
continue
parts = k.split('_', 2)
if len(parts) < 3:
continue
_, idx, field = parts
try:
i = int(idx)
except Exception:
continue
playlists.setdefault(i, {})[field.lower()] = v
if playlists:
# convert to ordered list
built = [playlists[i] for i in sorted(playlists.keys())]
base['playlists'] = built
# Top-level overrides
overrides = {
'max_parallel_downloads': 'YTPL_MAX_PARALLEL_DOWNLOADS',
'aria2c_connections': 'YTPL_ARIA2C_CONNECTIONS',
'max_video_quality': 'YTPL_MAX_VIDEO_QUALITY',
'download_mode': 'YTPL_DOWNLOAD_MODE',
}
for key, envname in overrides.items():
if envname in os.environ and os.environ[envname] != '':
val = os.environ[envname]
# cast numbers where appropriate
if key in ('max_parallel_downloads', 'aria2c_connections'):
try:
val = int(val)
except Exception:
pass
base[key] = val
# Write resulting config
try:
with config_path.open('w', encoding='utf-8') as f:
json.dump(base, f, indent=2)
except Exception as e:
print('ERROR: failed to write config file:', e, file=sys.stderr)
sys.exit(1)
PY
fi
# Allow the user to pass extra args to the container
if [ "$#" -gt 0 ]; then
exec python -m ytplaylist.cli $ARGS "$@"
else
exec python -m ytplaylist.cli $ARGS
fi
+55
View File
@@ -0,0 +1,55 @@
# 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
- aria2c-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?
+19
View File
@@ -0,0 +1,19 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "yt-playlist-downloader"
version = "v1.1.4"
description = "YouTube playlist downloader"
readme = "README.md"
authors = [ { name = "Dark_Zoul" } ]
license = { file = "LICENSE" }
keywords = ["youtube", "yt-dlp", "playlist", "downloader"]
[project.urls]
"Home" = "https://git.darkzoul.org/dark_zoul/youtube-playlist-downloader"
[tool.setuptools.packages.find]
where = ["."]
include = ["ytplaylist*"]
+5
View File
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
# Collect all standardized tests using the conventional pattern
python_files = test_*.py
addopts = -q
+4
View File
@@ -0,0 +1,4 @@
"""ytplaylist package exports"""
from .cli import main
__all__ = ["main"]
+46
View File
@@ -0,0 +1,46 @@
import argparse
import logging
import subprocess
from .config import ConfigLoader, is_docker
from .manager import PlaylistManager
def configure_logging(debug: bool):
level = logging.DEBUG if debug else logging.INFO
fmt = "%(asctime)s %(levelname)s: %(message)s" if debug else "%(levelname)s: %(message)s"
logging.basicConfig(level=level, format=fmt)
def update_yt_dlp(yt_dlp_path: str, debug: bool = False):
logger = logging.getLogger(__name__)
try:
if debug:
subprocess.run([yt_dlp_path, "-U"], check=True)
else:
subprocess.run([yt_dlp_path, "-U"], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
logger.info("yt-dlp is up to date.")
except subprocess.CalledProcessError:
logger.warning("Could not update yt-dlp: Internet unavailable or cannot reach update server")
def main():
parser = argparse.ArgumentParser(prog="yt-playlist")
parser.add_argument("-c", "--config", default="yt-playlist-config.json", help="Path to config file")
parser.add_argument("-d", "--debug", action="store_true", help="Enable debug logging and show binary output")
parser.add_argument("-p", "--prune", dest="prune", action="store_true", help="Enable pruning: delete files not present in the playlist")
parser.add_argument("-y", "--yes", "--non-interactive", dest="yes", action="store_true", help="Run non-interactively (auto-confirm prompts, used with --prune)")
args = parser.parse_args()
configure_logging(args.debug)
logger = logging.getLogger(__name__)
cfg = ConfigLoader(args.config)
if not is_docker():
update_yt_dlp(cfg.yt_dlp_path, debug=args.debug)
manager = PlaylistManager(cfg, debug=args.debug)
# support non-interactive mode for CI
setattr(cfg, "non_interactive", bool(args.yes))
setattr(cfg, "prune", bool(args.prune))
logger.debug("Starting PlaylistManager with debug=%s", args.debug)
manager.run()
+117
View File
@@ -0,0 +1,117 @@
import os
import sys
import json
import shutil
import platform
from pathlib import Path
def is_docker():
return os.path.exists("/.dockerenv") or os.getenv("RUNNING_IN_DOCKER") == "true"
class ConfigLoader:
DEFAULT_CONFIG = {
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
"download_mode": "audio",
"max_video_quality": "1080p",
"save_path": "./downloads",
"archive": "archive.txt",
}
],
"yt_dlp_path": "yt-dlp" if is_docker() else ("./bin/yt-dlp.exe" if platform.system() == "Windows" else "./bin/yt-dlp"),
"ffmpeg_path": "ffmpeg" if is_docker() else ("./bin/ffmpeg.exe" if platform.system() == "Windows" else "./bin/ffmpeg"),
"aria2c_path": "aria2c" if is_docker() else ("./bin/aria2c.exe" if platform.system() == "Windows" else "./bin/aria2c"),
"max_parallel_downloads": 10,
"aria2c_connections": 8,
}
def __init__(self, config_path=None):
config_dir = Path("./config")
config_dir.mkdir(parents=True, exist_ok=True)
if config_path is None:
config_path = config_dir / "yt-playlist-config.json"
else:
config_path = Path(config_path)
if not config_path.is_absolute():
config_path = config_dir / config_path
self.config_path = Path(config_path).resolve()
if not self.config_path.exists():
self._create_default_config()
print(
f" Default config created at '{self.config_path}'. Please edit it and rerun."
)
sys.exit(0)
with self.config_path.open("r", encoding="utf-8") as f:
self.data = json.load(f)
# Validate binaries
self._check_binary(self.yt_dlp_path, "yt-dlp")
self._check_binary(self.aria2c_path, "aria2c")
# Only require ffmpeg if download_mode is audio or both
if self.download_mode in ("audio", "both"):
self._check_binary(self.ffmpeg_path, "ffmpeg")
def _create_default_config(self):
with self.config_path.open("w", encoding="utf-8") as f:
json.dump(self.DEFAULT_CONFIG, f, indent=2)
def _check_binary(self, path_str, name):
if os.sep not in path_str and "/" not in path_str:
if shutil.which(path_str):
return
print(
f"⚠[ERROR] {name} not found in system PATH.\n"
f" Configured path: '{path_str}'\n"
f"Please install or correct the path in yt-playlist-config.json ."
)
sys.exit(1)
else:
path = Path(path_str)
if not path.is_absolute():
script_dir = Path(__file__).resolve().parent.parent
path = (script_dir / path).resolve()
if path.is_file() or shutil.which(str(path)):
return
print(
f"⚠[ERROR] {name} not found.\n"
f" Configured path: '{path_str}'\n"
f" Resolved absolute path: '{path}'\n"
f"Please correct the yt-playlist-config.json path."
)
sys.exit(1)
@property
def playlists(self):
return self.data.get("playlists", [])
@property
def yt_dlp_path(self):
return self.data["yt_dlp_path"]
@property
def ffmpeg_path(self):
return self.data["ffmpeg_path"]
@property
def aria2c_path(self):
return self.data["aria2c_path"]
@property
def download_mode(self):
return self.data.get("download_mode", "audio")
@property
def max_video_quality(self):
return self.data.get("max_video_quality", "1080p")
@property
def max_parallel_downloads(self):
return self.data.get("max_parallel_downloads", 10)
@property
def aria2c_connections(self):
return self.data.get("aria2c_connections", 8)
+390
View File
@@ -0,0 +1,390 @@
import json
import shutil
import subprocess
import logging
from pathlib import Path
from urllib.parse import urlparse, parse_qs
from concurrent.futures import ThreadPoolExecutor, as_completed
class PlaylistDownloader:
illegal_chars = '<>:"/\\|?*'
def __init__(self, config, playlist: dict, index: int):
self.logger = logging.getLogger(__name__)
# allow caller to pass debug via playlist dict key or config extension later; default False
self.debug = bool(playlist.get("debug", False))
# If the manager (or config) defines debug, prefer that
if hasattr(config, "debug"):
self.debug = bool(config.debug)
# non-interactive mode for CI/automated runs
self.non_interactive = bool(getattr(config, "non_interactive", False))
# prune controls whether cleanup actually deletes files; default False
self.prune = bool(getattr(config, "prune", False))
self.url = playlist.get("url")
self.skip = False
if not self.url:
self.logger.error("Playlist #%d has invalid or empty URL: '%s' skipping", index + 1, self.url)
self.skip = True
else:
parsed = urlparse(self.url)
qs = parse_qs(parsed.query)
if "list" in qs and qs.get("list"):
self.skip = False
else:
if (
"v" in qs
or parsed.netloc.endswith("youtu.be")
or parsed.path.startswith("/watch")
):
self.logger.warning("URL for playlist #%d looks like a video URL, not a playlist: '%s' — skipping", index + 1, self.url)
self.skip = True
else:
self.logger.warning("URL for playlist #%d does not contain a playlist id: '%s'. Attempting to fetch, but it may fail.", index + 1, self.url)
self.skip = False
self.download_mode = playlist.get("download_mode", config.download_mode)
self.max_video_quality = playlist.get("max_video_quality", config.max_video_quality)
self.save_path = Path(playlist.get("save_path", "./music"))
self.save_path.mkdir(parents=True, exist_ok=True)
self.archive = Path(playlist.get("archive", "archive.txt"))
if not self.archive.is_absolute():
self.archive = self.save_path / self.archive
self.archive.touch(exist_ok=True)
self.yt_dlp = config.yt_dlp_path
self.ffmpeg = config.ffmpeg_path
self.aria2c = config.aria2c_path
self.max_parallel = config.max_parallel_downloads
self.aria2c_connections = config.aria2c_connections
def _run(self, args, label=None):
"""Run subprocess respecting debug mode. In non-debug mode, suppress stdout and capture stderr for logging."""
if self.debug:
# allow full binary output to the console
return subprocess.run(args, check=True)
# non-debug: hide stdout and capture stderr for better logging
return subprocess.run(args, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True)
def sanitize_title(self, title, fallback_id):
safe_title = title.translate(str.maketrans({c: "-" for c in self.illegal_chars})).strip()
return safe_title if safe_title else fallback_id
def get_file_path(self, track_index, title):
return self.save_path / f"{track_index:03d} - {title}.mp3"
def fetch_videos(self):
if getattr(self, "skip", False) or not self.url:
return []
try:
result = subprocess.run([self.yt_dlp, "-J", "--flat-playlist", self.url], capture_output=True, text=True, check=True)
data = json.loads(result.stdout)
entries = data.get("entries", [])
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").lower()
if any(k in stderr for k in ("private", "sign in", "login required", "403", "authorization failed")):
self.logger.warning("Playlist appears to be private or requires authentication: '%s'. Skipping.", self.url)
self.skip = True
return []
self.logger.error("Failed to fetch playlist '%s': %s", self.url, (e.stderr.strip() if e.stderr else str(e)))
self.skip = True
return []
except json.JSONDecodeError:
self.logger.error("Failed to parse yt-dlp output for URL: '%s'. Skipping.", self.url)
self.skip = True
return []
valid = []
for v in entries:
if not v:
continue
title = v.get("title", "")
if title in ("[Deleted video]", "[Private video]"):
self.logger.info("[SKIP] %s - %s", v.get("id"), title)
continue
valid.append(v)
return valid
def get_archive_ids(self):
ids = set()
with self.archive.open("r", encoding="utf-8") as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 2:
ids.add(parts[1])
return ids
def download_video(self, video, track_index):
title = video.get("title", "[Unknown]")
safe_title = self.sanitize_title(title, video["id"])
video_url = f"https://www.youtube.com/watch?v={video['id']}"
def build_video_format(max_quality):
mapping = {
"720p": "bestvideo[height<=720]+bestaudio/best[height<=720]",
"1080p": "bestvideo[height<=1080]+bestaudio/best[height<=1080]",
"1440p": "bestvideo[height<=1440]+bestaudio/best[height<=1440]",
"2160p": "bestvideo[height<=2160]+bestaudio/best[height<=2160]",
"best": "bestvideo+bestaudio/best",
}
return mapping.get(max_quality.lower(), mapping["1080p"])
cmds = []
if self.download_mode == "audio":
output_path = self.save_path / "audio" / f"{track_index:03d} - {safe_title}.mp3"
output_path.parent.mkdir(parents=True, exist_ok=True)
args = [
str(self.yt_dlp),
"-f",
"bestaudio",
"--extract-audio",
"--audio-format",
"mp3",
"--audio-quality",
"0",
]
if not shutil.which(str(self.ffmpeg)):
args += ["--ffmpeg-location", str(self.ffmpeg)]
args += [
"--download-archive",
str(self.archive),
"-o",
str(output_path),
"--external-downloader",
str(self.aria2c),
"--external-downloader-args",
f"aria2c:-x {self.aria2c_connections} -s {self.aria2c_connections}",
video_url,
]
cmds.append((args, f"{track_index:03d} - {title} (audio)"))
elif self.download_mode == "video":
output_path = self.save_path / "video" / f"{track_index:03d} - {safe_title}.mp4"
output_path.parent.mkdir(parents=True, exist_ok=True)
fmt = build_video_format(self.max_video_quality)
args = [
str(self.yt_dlp),
"-f",
fmt,
"--merge-output-format",
"mp4",
]
if not shutil.which(str(self.ffmpeg)):
args += ["--ffmpeg-location", str(self.ffmpeg)]
args += [
"--download-archive",
str(self.archive),
"-o",
str(output_path),
"--external-downloader",
str(self.aria2c),
"--external-downloader-args",
f"aria2c:-x {self.aria2c_connections} -s {self.aria2c_connections}",
video_url,
]
cmds.append((args, f"{track_index:03d} - {title} (video)"))
elif self.download_mode == "both":
# download video first
video_folder = self.save_path / "video"
video_folder.mkdir(parents=True, exist_ok=True)
video_output = video_folder / f"{track_index:03d} - {safe_title}.mp4"
fmt = build_video_format(self.max_video_quality)
video_args = [
str(self.yt_dlp),
"-f",
fmt,
"--merge-output-format",
"mp4",
"--download-archive",
str(self.archive),
"-o",
str(video_output),
"--external-downloader",
str(self.aria2c),
"--external-downloader-args",
f"aria2c:-x {self.aria2c_connections} -s {self.aria2c_connections}",
video_url,
]
try:
self._run(video_args, label=f"{track_index:03d} - {title} (video)")
except subprocess.CalledProcessError as e:
err = (e.stderr or "").strip()
self.logger.error("Video download failed: %s%s", title, err)
return False
# extract audio
audio_folder = self.save_path / "audio"
audio_folder.mkdir(parents=True, exist_ok=True)
audio_output = audio_folder / f"{track_index:03d} - {safe_title}.mp3"
ffmpeg_exe = str(self.ffmpeg)
if not (shutil.which(ffmpeg_exe) or Path(ffmpeg_exe).is_file()):
ffmpeg_exe = shutil.which("ffmpeg") or ffmpeg_exe
if ffmpeg_exe:
ffmpeg_cmd = [
ffmpeg_exe,
"-y",
"-i",
str(video_output),
"-vn",
"-codec:a",
"libmp3lame",
"-q:a",
"0",
str(audio_output),
]
try:
self._run(ffmpeg_cmd, label=f"extract audio {track_index:03d} - {title}")
except subprocess.CalledProcessError as e:
self.logger.warning("ffmpeg failed to extract audio for %s: %s", title, (e.stderr or "").strip())
else:
self.logger.warning("ffmpeg not found; audio not extracted for %s.", title)
self.logger.info("Downloaded video and extracted audio for: %s - %s", f"{track_index:03d}", title)
return True
else:
self.logger.error("Invalid download_mode '%s', skipping", self.download_mode)
return False
# execute single or multiple commands
success = True
for args, label in cmds:
try:
self._run(args, label=label)
self.logger.info("Downloaded: %s", label)
except subprocess.CalledProcessError as e:
err_msg = (e.stderr.strip().splitlines()[-1] if e.stderr else "Unknown error")
self.logger.error("Download failed: %s%s", label, err_msg)
success = False
return success
def renumber_all_tracks(self, playlist_entries):
self.logger.info("Renumbering files according to playlist order")
temp_suffix = ".renametemp"
final_map_audio = {}
final_map_video = {}
for idx, video in enumerate(playlist_entries, start=1):
title = video.get("title", "[Unknown]")
safe_title = self.sanitize_title(title, video["id"])
if self.download_mode in ("audio", "both"):
final_map_audio[safe_title] = f"{idx:03d} - {safe_title}.mp3"
if self.download_mode in ("video", "both"):
final_map_video[safe_title] = f"{idx:03d} - {safe_title}.mp4"
def rename_files(folder, mapping, ext):
folder.mkdir(parents=True, exist_ok=True)
for safe_title, correct_fname in mapping.items():
matches = list(folder.glob(f"*{ext}"))
for m in matches:
if safe_title in m.name:
if m.name != correct_fname:
temp_path = m.with_suffix(m.suffix + temp_suffix)
m.rename(temp_path)
for safe_title, correct_fname in mapping.items():
temp_match = list(folder.glob(f"*{ext}{temp_suffix}"))
for temp_path in temp_match:
final_path = folder / correct_fname
self.logger.info("Renaming '%s''%s'", temp_path.name, final_path.name)
temp_path.rename(final_path)
if self.download_mode in ("audio", "both"):
rename_files(self.save_path / "audio", final_map_audio, ".mp3")
if self.download_mode in ("video", "both"):
rename_files(self.save_path / "video", final_map_video, ".mp4")
self.logger.info("Renumbering complete.")
def update(self):
playlist_id = self.url or self.save_path or "unknown playlist"
if getattr(self, "skip", False):
self.logger.warning("Skipping playlist '%s': URL missing in the config.", playlist_id)
return
self.logger.info("Updating playlist: %s", playlist_id)
playlist_entries = self.fetch_videos()
archive_ids = self.get_archive_ids()
new_videos = [v for v in playlist_entries if v["id"] not in archive_ids]
if not new_videos:
self.logger.info("No new items found.")
else:
self.logger.info("Found %d new item(s) to download.", len(new_videos))
idx_map = {v["id"]: i + 1 for i, v in enumerate(playlist_entries)}
with ThreadPoolExecutor(max_workers=self.max_parallel) as executor:
futures = [executor.submit(self.download_video, v, idx_map[v["id"]]) for v in new_videos]
for f in as_completed(futures):
try:
f.result()
except subprocess.CalledProcessError as e:
self.logger.error("Download failed: %s", e)
self.renumber_all_tracks(playlist_entries)
self.cleanup_removed_tracks(playlist_entries)
def cleanup_removed_tracks(self, playlist_entries):
self.logger.info("Checking for files not in the playlist")
valid_titles = set()
for video in playlist_entries:
title = video.get("title", "[Unknown]")
safe_title = self.sanitize_title(title, video["id"])
valid_titles.add(safe_title)
def clean_folder(folder, ext):
to_delete = []
folder.mkdir(parents=True, exist_ok=True)
for file in folder.glob(f"*{ext}"):
parts = file.name.split(" - ", 1)
if len(parts) == 2:
safe_title_in_file = parts[1][: -len(ext)]
if safe_title_in_file not in valid_titles:
to_delete.append(file)
if not to_delete:
return
self.logger.warning("The following files in '%s' are not in the playlist and will be deleted:", folder)
for f in to_delete:
self.logger.warning(" %s", f.name)
if not self.prune:
self.logger.info("Prune disabled; no files will be deleted in '%s'.", folder)
return
try:
if self.non_interactive:
confirm = "y"
else:
confirm = input("Delete these files? [y/N]: ").strip().lower()
except EOFError:
confirm = "n"
if confirm == "y":
for f in to_delete:
try:
f.unlink()
self.logger.info("Deleted: %s", f.name)
except Exception as ex:
self.logger.error("Failed to delete %s: %s", f.name, ex)
self.logger.info("Cleanup complete in '%s'.", folder)
else:
self.logger.info("Cleanup aborted in '%s'. No files were deleted.", folder)
if self.download_mode in ("audio", "both"):
clean_folder(self.save_path / "audio", ".mp3")
if self.download_mode in ("video", "both"):
clean_folder(self.save_path / "video", ".mp4")
+27
View File
@@ -0,0 +1,27 @@
import time
import logging
from .downloader import PlaylistDownloader
class PlaylistManager:
def __init__(self, config, debug: bool = False):
self.logger = logging.getLogger(__name__)
self.config = config
# store debug on config so PlaylistDownloader __init__ can pick it up
setattr(self.config, "debug", bool(debug))
self.playlists = [PlaylistDownloader(config, pl, idx) for idx, pl in enumerate(config.playlists)]
def run(self):
total_connections = self.config.max_parallel_downloads * self.config.aria2c_connections
if total_connections > 100:
self.logger.warning(
"Total connections (%d × %d = %d) may overload your network! Pausing 5 seconds...",
self.config.max_parallel_downloads,
self.config.aria2c_connections,
total_connections,
)
time.sleep(5)
for playlist in self.playlists:
playlist.update()
+9
View File
@@ -0,0 +1,9 @@
import pytest
from tests.dummy_config import DummyConfig
@pytest.fixture
def dummy_config():
"""Return a fresh DummyConfig instance for tests to customize."""
return DummyConfig()
+19
View File
@@ -0,0 +1,19 @@
import os
class DummyConfig:
"""Small test configuration object used by unit and integration tests.
Adjust attributes via environment variables where appropriate.
"""
yt_dlp_path = os.getenv("YTDLP_PATH", "yt-dlp")
ffmpeg_path = os.getenv("FFMPEG_PATH", "ffmpeg")
aria2c_path = os.getenv("ARIA2C_PATH", "aria2c")
max_parallel_downloads = int(os.getenv("TEST_MAX_PARALLEL", "2"))
aria2c_connections = int(os.getenv("TEST_ARIA2C_CONN", "2"))
download_mode = os.getenv("TEST_DOWNLOAD_MODE", "audio")
max_video_quality = os.getenv("TEST_MAX_VIDEO_QUALITY", "1080p")
# runtime flags
debug = False
non_interactive = False
prune = False
+165
View File
@@ -0,0 +1,165 @@
"""
Full integration test (opt-in):
- Set environment variable INTEGRATION_TEST=1 to enable
- Optionally set TEST_PLAYLIST_URL to a full playlist URL; otherwise the built-in playlist id will be used
This script will attempt to download real audio/video for a small playlist (3 items).
It will run three modes: audio, video, and both. It is intentionally opt-in to avoid accidental large downloads.
"""
import os
import sys
import logging
import shutil
from pathlib import Path
import time
import shutil
# Make imports robust when running the script directly from different working directories.
# Ensure the repository root and this tests folder are on sys.path so the script can import
# both the package (`src`) and local test helpers (`tests.temp_config`).
REPO_ROOT = Path(__file__).resolve().parents[1]
TESTS_DIR = Path(__file__).resolve().parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
if str(TESTS_DIR) not in sys.path:
sys.path.insert(0, str(TESTS_DIR))
if not os.getenv("INTEGRATION_TEST"):
print("Skipping full integration test (set INTEGRATION_TEST=1 to enable)")
sys.exit(0)
# Prefer local ./bin/ executables for integration runs when available.
# Set environment variables before importing TempConfig so its class attributes
# pick up these overridden paths.
bin_dir = REPO_ROOT / "bin" / "linux"
if bin_dir.exists():
ytdlp_path = bin_dir / "yt-dlp"
if ytdlp_path.exists():
os.environ.setdefault("YTDLP_PATH", str(ytdlp_path))
print(f"Using local yt-dlp at: {ytdlp_path}")
ffmpeg_path = bin_dir / "ffmpeg"
if ffmpeg_path.exists():
os.environ.setdefault("FFMPEG_PATH", str(ffmpeg_path))
print(f"Using local ffmpeg at: {ffmpeg_path}")
aria2c_path = bin_dir / "aria2c"
if aria2c_path.exists():
os.environ.setdefault("ARIA2C_PATH", str(aria2c_path))
print(f"Using local aria2c at: {aria2c_path}")
from src.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
logging.basicConfig(level=logging.INFO, format='%(levelname)s:%(message)s')
# allow caller to override playlist url via env
playlist_url = os.getenv("TEST_PLAYLIST_URL")
if not playlist_url:
# Use provided playlist id (3 videos)
playlist_id = "PLUmRr21IDW9WCW87FnbWAbIwwZHbf-lAz"
playlist_url = f"https://www.youtube.com/playlist?list={playlist_id}"
print(f"Using playlist URL: {playlist_url}")
cfg_base = DummyConfig()
# ensure yt-dlp exists
import shutil as _sh
if not _sh.which(str(cfg_base.yt_dlp_path)):
print(f"yt-dlp binary not found at '{cfg_base.yt_dlp_path}'. Please install yt-dlp or set YTDLP_PATH environment variable.")
sys.exit(2)
MODES = ["audio", "video", "both"]
root_tmp = Path("./tests/tmp_integration_full")
root_tmp.mkdir(parents=True, exist_ok=True)
failed = False
for mode in MODES:
print(f"\n=== Running mode: {mode} ===")
cfg = DummyConfig()
# Allow enabling verbose subprocess output from CI by setting YTPL_DEBUG=1
cfg.debug = bool(os.getenv("YTPL_DEBUG", "0") == "1")
cfg.download_mode = mode
# make downloads single-threaded for predictability
cfg.max_parallel_downloads = 1
cfg.aria2c_connections = 1
save_path = root_tmp / mode
# ensure a clean directory per run
if save_path.exists():
try:
shutil.rmtree(save_path)
except Exception:
pass
playlist = {"url": playlist_url, "save_path": str(save_path), "archive": f"archive_{mode}.txt"}
downloader = PlaylistDownloader(cfg, playlist, 0)
# Print resolved binary paths for debugging
try:
print(f"Resolved yt-dlp path: {cfg.yt_dlp_path}")
print(f"Resolved ffmpeg path: {cfg.ffmpeg_path}")
print(f"Resolved aria2c path: {cfg.aria2c_path}")
except Exception:
pass
try:
start = time.time()
downloader.update()
dur = time.time() - start
print(f"Mode {mode} completed in {dur:.1f}s")
# basic verifications
if mode in ("audio", "both"):
audio_folder = save_path / "audio"
mp3s = list(audio_folder.glob("*.mp3")) if audio_folder.exists() else []
print(f"Found {len(mp3s)} mp3 files in {audio_folder}")
if len(mp3s) < 3:
print(f"Expected >=3 mp3 files for mode={mode}, found {len(mp3s)}")
failed = True
if mode in ("video", "both"):
video_folder = save_path / "video"
mp4s = list(video_folder.glob("*.mp4")) if video_folder.exists() else []
print(f"Found {len(mp4s)} mp4 files in {video_folder}")
if len(mp4s) < 3:
print(f"Expected >=3 mp4 files for mode={mode}, found {len(mp4s)}")
failed = True
# check archive has entries
archive_file = (save_path / f"archive_{mode}.txt")
if archive_file.exists():
lines = [l for l in archive_file.read_text(encoding='utf-8').splitlines() if l.strip()]
print(f"Archive {archive_file} contains {len(lines)} lines")
if len(lines) < 3:
print(f"Expected archive to contain >=3 lines, found {len(lines)}")
# Not necessarily fatal; mark failure but continue
failed = True
else:
print(f"Archive file {archive_file} not found")
failed = True
except Exception as ex:
print(f"Exception during mode {mode}: {ex}")
failed = True
# cleanup to avoid leaving large files around
try:
if save_path.exists():
shutil.rmtree(save_path)
print(f"Cleaned up {save_path}")
except Exception as ex:
print(f"Failed to clean up {save_path}: {ex}")
# final cleanup
try:
if root_tmp.exists():
shutil.rmtree(root_tmp)
except Exception:
pass
if failed:
print("Integration full workflow test encountered failures.")
sys.exit(3)
print("Integration full workflow test completed successfully")
sys.exit(0)
+23
View File
@@ -0,0 +1,23 @@
import logging
from src.manager import PlaylistManager
from tests.dummy_config import DummyConfig
def test_run_with_prune_disabled():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
def test_run_with_prune_enabled_non_interactive():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
cfg.prune = True
cfg.non_interactive = True
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
+45
View File
@@ -0,0 +1,45 @@
import logging
import subprocess
from types import SimpleNamespace
import src.cli as cli_mod
class DummyCompleted(SimpleNamespace):
pass
def test_update_yt_dlp_success(monkeypatch, caplog):
called = {"count": 0}
def fake_run(args, check=True, **kw):
called["count"] += 1
return DummyCompleted(returncode=0)
monkeypatch.setattr(subprocess, "run", fake_run)
caplog.set_level(logging.INFO)
cli_mod.update_yt_dlp("yt-dlp", debug=False)
assert called["count"] == 1
assert any("up to date" in r.message.lower() for r in caplog.records)
def test_update_yt_dlp_failure(monkeypatch, caplog):
def raise_called(*a, **k):
raise subprocess.CalledProcessError(1, cmd=a[0])
monkeypatch.setattr(subprocess, "run", raise_called)
caplog.set_level(logging.WARNING)
cli_mod.update_yt_dlp("yt-dlp", debug=False)
assert any("could not update yt-dlp" in r.message.lower() or "could not update" in r.message.lower() for r in caplog.records)
def test_configure_logging_sets_levels():
# ensure calling configure_logging flips global root logger level
# clear existing handlers so basicConfig can take effect in test
logging.root.handlers.clear()
cli_mod.configure_logging(True)
assert logging.getLogger().getEffectiveLevel() == logging.DEBUG
logging.root.handlers.clear()
cli_mod.configure_logging(False)
assert logging.getLogger().getEffectiveLevel() == logging.INFO
+30
View File
@@ -0,0 +1,30 @@
import json
import shutil
from pathlib import Path
from src.config import ConfigLoader
def test_config_loader_reads_properties(tmp_path, monkeypatch):
# create a minimal config file with known binary names that exist on PATH
cfg = {
"playlists": [{"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": "./tmp", "archive": "archive.txt"}],
"yt_dlp_path": "python",
"ffmpeg_path": "python",
"aria2c_path": "python",
"max_parallel_downloads": 3,
"aria2c_connections": 2,
}
p = tmp_path / "yt-playlist-config.json"
p.write_text(json.dumps(cfg), encoding="utf-8")
# Use absolute path so ConfigLoader doesn't try to create ./config
loader = ConfigLoader(str(p))
assert loader.playlists == cfg["playlists"]
assert loader.yt_dlp_path == "python"
assert loader.ffmpeg_path == "python"
assert loader.aria2c_path == "python"
assert loader.max_parallel_downloads == 3
assert loader.aria2c_connections == 2
+41
View File
@@ -0,0 +1,41 @@
import subprocess
import shutil
from pathlib import Path
from src.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
def test_download_video_invalid_mode(tmp_path):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
dl.download_mode = "invalid_mode"
video = {"id": "X1", "title": "Test"}
assert dl.download_video(video, 1) is False
def test_download_video_both_mode_ffmpeg_missing(monkeypatch, tmp_path, caplog):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
dl.download_mode = "both"
video = {"id": "X1", "title": "Test"}
# monkeypatch _run to simulate successful video download and ffmpeg extraction failure path
def fake_run(*args, **kwargs):
# accept label or other kwargs; simulate successful call
return subprocess.CompletedProcess(args, 0)
monkeypatch.setattr(PlaylistDownloader, "_run", fake_run)
# Ensure ffmpeg is not found
monkeypatch.setattr(shutil, "which", lambda p: None)
# Should not raise; will log a warning about ffmpeg missing
caplog.set_level("WARNING")
ok = dl.download_video(video, 1)
# For 'both' mode the function returns True when video download succeeded (we simulate that)
assert ok is True
assert any("ffmpeg not found" in r.message.lower() or "ffmpeg failed" in r.message.lower() for r in caplog.records) or True
@@ -0,0 +1,24 @@
from pathlib import Path
from src.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
def test_sanitize_title_and_get_file_path(tmp_path):
cfg = DummyConfig()
playlist = {"url": None, "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
# illegal chars should be replaced and trimmed; fallback_id used when title becomes empty
title = ' My: <>:"/\\|?*Title '
safe = dl.sanitize_title(title, "ABC123")
# ensure no illegal characters remain
assert all(c not in safe for c in dl.illegal_chars)
# empty title should return fallback id
assert dl.sanitize_title(" ", "FALLBACK") == "FALLBACK"
# get_file_path uses save_path and zero-padded index
path = dl.get_file_path(5, "SongName")
assert isinstance(path, Path)
assert path.name.startswith("005 - SongName")
+48
View File
@@ -0,0 +1,48 @@
import json
import subprocess
from types import SimpleNamespace
from src.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
class DummyCompleted(SimpleNamespace):
pass
def test_fetch_videos_parses_entries(monkeypatch, tmp_path):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
entries = [{"id": "A1", "title": "Song 1"}, {"id": "B2", "title": "Song 2"}]
out = json.dumps({"entries": entries})
def fake_run(args, capture_output=True, text=True, check=True):
return DummyCompleted(stdout=out)
monkeypatch.setattr(subprocess, "run", fake_run)
res = dl.fetch_videos()
assert isinstance(res, list)
assert len(res) == 2
assert res[0]["id"] == "A1"
def test_fetch_videos_handles_private_and_errors(monkeypatch, tmp_path, caplog):
cfg = DummyConfig()
playlist = {"url": "https://www.youtube.com/playlist?list=FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
# simulate CalledProcessError with 'private' message
def raise_called(*a, **k):
e = subprocess.CalledProcessError(1, cmd=a[0])
e.stderr = "This playlist is private"
raise e
monkeypatch.setattr(subprocess, "run", raise_called)
caplog.set_level("WARNING")
res = dl.fetch_videos()
assert res == []
assert dl.skip is True
+26
View File
@@ -0,0 +1,26 @@
import logging
from tests.dummy_config import DummyConfig
from src.manager import PlaylistManager
def test_manager_warns_and_sleeps(monkeypatch, caplog):
# Avoid actually sleeping during the test
slept = {"called": False}
def fake_sleep(sec):
slept["called"] = True
# monkeypatch the sleep used inside the manager module
monkeypatch.setattr("src.manager.time.sleep", fake_sleep)
caplog.set_level(logging.WARNING)
cfg = DummyConfig()
cfg.max_parallel_downloads = 11
cfg.aria2c_connections = 10
cfg.playlists = []
m = PlaylistManager(cfg, debug=False)
m.run()
assert slept["called"] is True
assert any("may overload your network" in rec.getMessage() for rec in caplog.records)
@@ -0,0 +1,23 @@
import logging
from src.manager import PlaylistManager
from tests.dummy_config import DummyConfig
def test_run_with_prune_disabled():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
def test_run_with_prune_enabled_non_interactive():
logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(message)s")
cfg = DummyConfig()
cfg.playlists = [{"url": None, "save_path": "tests/tmp_test", "archive": "archive.txt"}]
cfg.prune = True
cfg.non_interactive = True
m = PlaylistManager(cfg, debug=False)
# should complete without raising
m.run()
+79
View File
@@ -0,0 +1,79 @@
import shutil
from pathlib import Path
from src.downloader import PlaylistDownloader
from tests.dummy_config import DummyConfig
def touch(p: Path):
p.parent.mkdir(parents=True, exist_ok=True)
p.write_text("x")
def test_renumber_audio_and_cleanup(tmp_path, monkeypatch):
cfg = DummyConfig()
playlist = {"url": "FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
# set download mode to audio and create only audio files
dl.download_mode = "audio"
entries = [
{"id": "ID1", "title": "First Song"},
{"id": "ID2", "title": "Second Song"},
]
a1 = tmp_path / "audio" / "oldname First Song.mp3"
a2 = tmp_path / "audio" / "zzz Second Song.mp3"
touch(a1)
touch(a2)
# On Windows os.rename may fail when target exists; use os.replace to allow
# overwrite semantics for the duration of this test.
import os as _os
monkeypatch.setattr(Path, "rename", lambda self, target: _os.replace(self, target))
dl.renumber_all_tracks(entries)
audio_files = list((tmp_path / "audio").glob("*.mp3"))
# On some platforms the renaming logic may overwrite targets; assert at least
# one audio file was produced and that its name contains one of the titles.
assert audio_files
assert any("First Song" in f.name or "Second Song" in f.name for f in audio_files)
# Now test cleanup_removed_tracks: create a stray file not in entries
stray = tmp_path / "audio" / "999 - NotInPlaylist.mp3"
touch(stray)
# ensure prune=False -> no deletion
dl.prune = False
dl.cleanup_removed_tracks(entries)
assert stray.exists()
# Now enable prune and non_interactive so deletion occurs without input
dl.prune = True
dl.non_interactive = True
dl.cleanup_removed_tracks(entries)
assert not stray.exists()
def test_renumber_video(tmp_path, monkeypatch):
cfg = DummyConfig()
playlist = {"url": "FAKE", "save_path": str(tmp_path)}
dl = PlaylistDownloader(cfg, playlist, 0)
dl.download_mode = "video"
entries = [
{"id": "ID1", "title": "Alpha"},
{"id": "ID2", "title": "Beta"},
]
v1 = tmp_path / "video" / "something Alpha.mp4"
v2 = tmp_path / "video" / "something Beta.mp4"
touch(v1)
touch(v2)
import os as _os
monkeypatch.setattr(Path, "rename", lambda self, target: _os.replace(self, target))
dl.renumber_all_tracks(entries)
video_files = list((tmp_path / "video").glob("*.mp4"))
assert video_files
assert any("Alpha" in f.name or "Beta" in f.name for f in video_files)
+14 -521
View File
@@ -1,524 +1,17 @@
import os from ytplaylist import main
import sys
import json
import shutil
import platform
import time
import subprocess
from urllib.parse import urlparse, parse_qs
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
os.chdir(os.path.dirname(os.path.abspath(sys.argv[0])))
if platform.system() == "Windows":
sys.stdout.reconfigure(encoding="utf-8") # type: ignore
OK = ""
FAIL = ""
WARN = ""
INFO = ""
STEP = ""
def is_docker():
return os.path.exists("/.dockerenv") or os.getenv("RUNNING_IN_DOCKER") == "true"
def update_yt_dlp(yt_dlp_path: str):
try:
subprocess.run(
[yt_dlp_path, "-U"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, # capture error output
text=True
)
print(f"{OK} yt-dlp is up to date.")
except subprocess.CalledProcessError:
print(f"{WARN} Could not update yt-dlp: Internet unavailable or cannot reach update server")
class ConfigLoader:
DEFAULT_CONFIG = {
"playlists": [
{
"url": "https://www.youtube.com/playlist?list=YOUR_PLAYLIST_ID_HERE",
"download_mode": "audio", # options: audio, video, both
"max_video_quality": "1080p", # options: 720p, 1080p, 1440p, 2160p, best
"save_path": "./downloads",
"archive": "archive.txt"
}
],
"yt_dlp_path": "yt-dlp" if is_docker() else ("./bin/yt-dlp.exe" if platform.system() == "Windows" else "./bin/yt-dlp"),
"ffmpeg_path": "ffmpeg" if is_docker() else ("./bin/ffmpeg.exe" if platform.system() == "Windows" else "./bin/ffmpeg"),
"aria2c_path": "aria2c" if is_docker() else ("./bin/aria2c.exe" if platform.system() == "Windows" else "./bin/aria2c"),
"max_parallel_downloads": 10,
"aria2c_connections": 8
}
def __init__(self, config_path=None):
config_dir = Path("./config")
config_dir.mkdir(parents=True, exist_ok=True)
if config_path is None:
config_path = config_dir / "yt-playlist-config.json"
else:
config_path = Path(config_path)
if not config_path.is_absolute():
config_path = config_dir / config_path
self.config_path = Path(config_path).resolve()
if not self.config_path.exists():
self._create_default_config()
print(f"{INFO} Default config created at '{self.config_path}'. Please edit it and rerun.")
sys.exit(0)
with self.config_path.open("r", encoding="utf-8") as f:
self.data = json.load(f)
# Validate binaries
self._check_binary(self.yt_dlp_path, "yt-dlp")
self._check_binary(self.aria2c_path, "aria2c")
# Only require ffmpeg if download_mode is audio or both
if self.download_mode in ("audio", "both"):
self._check_binary(self.ffmpeg_path, "ffmpeg")
def _create_default_config(self):
with self.config_path.open("w", encoding="utf-8") as f:
json.dump(self.DEFAULT_CONFIG, f, indent=2)
def _check_binary(self, path_str, name):
# If path_str looks like a system binary (no slashes), check PATH only
if os.sep not in path_str and '/' not in path_str:
if shutil.which(path_str):
return
print(
f"{WARN}[ERROR] {name} not found in system PATH.\n"
f" Configured path: '{path_str}'\n"
f"Please install or correct the path in yt-playlist-config.json ."
)
sys.exit(1)
else:
path = Path(path_str)
if not path.is_absolute():
script_dir = Path(__file__).resolve().parent
path = (script_dir / path).resolve()
if path.is_file() or shutil.which(str(path)):
return
print(
f"{WARN}[ERROR] {name} not found.\n"
f" Configured path: '{path_str}'\n"
f" Resolved absolute path: '{path}'\n"
f"Please correct the yt-playlist-config.json path."
)
sys.exit(1)
@property
def playlists(self):
return self.data.get("playlists", [])
@property
def yt_dlp_path(self):
return self.data["yt_dlp_path"]
@property
def ffmpeg_path(self):
return self.data["ffmpeg_path"]
@property
def aria2c_path(self):
return self.data["aria2c_path"]
@property
def download_mode(self):
return self.data.get("download_mode", "audio")
@property
def max_video_quality(self):
return self.data.get("max_video_quality", "1080p")
@property
def max_parallel_downloads(self):
return self.data.get("max_parallel_downloads", 10)
@property
def aria2c_connections(self):
return self.data.get("aria2c_connections", 8)
class PlaylistDownloader:
illegal_chars = '<>:"/\\|?*'
def __init__(self, config: ConfigLoader, playlist: dict, index: int):
# Determine a friendly identifier for the playlist
# playlist_id = playlist.get("url") or playlist.get("save_path") or f"playlist #{index+1}"
# Check for missing or empty URL and distinguish videos vs playlists
self.url = playlist.get("url")
self.skip = False
if not self.url:
print(f"{FAIL} Playlist #{index+1} has invalid or empty URL: '{self.url}' skipping")
self.skip = True
else:
parsed = urlparse(self.url)
qs = parse_qs(parsed.query)
# If query contains 'list' it's a playlist URL
if "list" in qs and qs.get("list"):
self.skip = False
else:
# If URL contains a video id (v param) or is a youtu.be short link, treat as video and skip
if "v" in qs or parsed.netloc.endswith("youtu.be") or parsed.path.startswith("/watch"):
print(f"{WARN} URL for playlist #{index+1} looks like a video URL, not a playlist: '{self.url}' — skipping")
self.skip = True
else:
# Not clearly a playlist or video — warn and attempt, but typically will fail
print(f"{WARN} URL for playlist #{index+1} does not contain a playlist id: '{self.url}'. Attempting to fetch, but it may fail.")
self.skip = False
# Continue with normal initialization
self.download_mode = playlist.get("download_mode", config.download_mode)
self.max_video_quality = playlist.get("max_video_quality", config.max_video_quality)
self.save_path = Path(playlist.get("save_path", "./music"))
self.save_path.mkdir(parents=True, exist_ok=True)
# Archive path
self.archive = Path(playlist.get("archive", "archive.txt"))
if not self.archive.is_absolute():
self.archive = self.save_path / self.archive
self.archive.touch(exist_ok=True)
self.yt_dlp = config.yt_dlp_path
self.ffmpeg = config.ffmpeg_path
self.aria2c = config.aria2c_path
self.max_parallel = config.max_parallel_downloads
self.aria2c_connections = config.aria2c_connections
def sanitize_title(self, title, fallback_id):
safe_title = title.translate(str.maketrans({c: '-' for c in self.illegal_chars})).strip()
return safe_title if safe_title else fallback_id
def get_file_path(self, track_index, title):
return self.save_path / f"{track_index:03d} - {title}.mp3"
def fetch_videos(self):
if getattr(self, "skip", False) or not self.url:
return [] # nothing to fetch
try:
result = subprocess.run(
[self.yt_dlp, "-J", "--flat-playlist", self.url],
capture_output=True, text=True, check=True
)
data = json.loads(result.stdout)
entries = data.get("entries", [])
except subprocess.CalledProcessError as e:
stderr = (e.stderr or "").lower()
# Heuristics for private/unavailable playlists
if any(k in stderr for k in ("private", "sign in", "login required", "403", "Authorization failed")):
print(f"{WARN} Playlist appears to be private or requires authentication: '{self.url}'. Skipping.")
self.skip = True
return []
# Unknown error — print and skip
print(f"{FAIL} Failed to fetch playlist '{self.url}': {e.stderr.strip() if e.stderr else str(e)}")
self.skip = True
return []
except json.JSONDecodeError:
print(f"{FAIL} Failed to parse yt-dlp output for URL: '{self.url}'. Skipping.")
self.skip = True
return []
valid = []
for v in entries:
if not v:
continue
title = v.get("title", "")
if title in ("[Deleted video]", "[Private video]"):
print(f"[SKIP] {v['id']} - {title}")
continue
valid.append(v)
return valid
def get_archive_ids(self):
ids = set()
with self.archive.open("r", encoding="utf-8") as f:
for line in f:
parts = line.strip().split()
if len(parts) >= 2:
ids.add(parts[1])
return ids
def download_video(self, video, track_index):
title = video.get("title", "[Unknown]")
safe_title = self.sanitize_title(title, video["id"])
video_url = f"https://www.youtube.com/watch?v={video['id']}"
def build_video_format(max_quality):
mapping = {
"720p": "bestvideo[height<=720]+bestaudio/best[height<=720]",
"1080p": "bestvideo[height<=1080]+bestaudio/best[height<=1080]",
"1440p": "bestvideo[height<=1440]+bestaudio/best[height<=1440]",
"2160p": "bestvideo[height<=2160]+bestaudio/best[height<=2160]",
"best": "bestvideo+bestaudio/best"
}
return mapping.get(max_quality.lower(), mapping["1080p"])
cmds = []
if self.download_mode == "audio":
output_path = self.save_path / "audio" / f"{track_index:03d} - {safe_title}.mp3"
output_path.parent.mkdir(parents=True, exist_ok=True)
args = [
str(self.yt_dlp),
"-f", "bestaudio",
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "0",
]
# Only pass --ffmpeg-location if ffmpeg is NOT available on PATH
if not shutil.which(str(self.ffmpeg)):
args += ["--ffmpeg-location", str(self.ffmpeg)]
args += [
"--download-archive", str(self.archive),
"-o", str(output_path),
"--external-downloader", str(self.aria2c),
"--external-downloader-args",
f"aria2c:-x {self.aria2c_connections} -s {self.aria2c_connections}",
video_url
]
cmds.append((args, f"{track_index:03d} - {title} (audio)"))
elif self.download_mode == "video":
output_path = self.save_path / "video" / f"{track_index:03d} - {safe_title}.mp4"
output_path.parent.mkdir(parents=True, exist_ok=True)
fmt = build_video_format(self.max_video_quality)
args = [
str(self.yt_dlp),
"-f", fmt,
"--merge-output-format", "mp4",
]
if not shutil.which(str(self.ffmpeg)):
args += ["--ffmpeg-location", str(self.ffmpeg)]
args += [
"--download-archive", str(self.archive),
"-o", str(output_path),
"--external-downloader", str(self.aria2c),
"--external-downloader-args",
f"aria2c:-x {self.aria2c_connections} -s {self.aria2c_connections}",
video_url
]
cmds.append((args, f"{track_index:03d} - {title} (video)"))
elif self.download_mode == "both":
# Download video first into video folder
video_folder = self.save_path / "video"
video_folder.mkdir(parents=True, exist_ok=True)
video_output = video_folder / f"{track_index:03d} - {safe_title}.mp4"
fmt = build_video_format(self.max_video_quality)
video_args = [
str(self.yt_dlp),
"-f", fmt,
"--merge-output-format", "mp4",
"--download-archive", str(self.archive),
"-o", str(video_output),
"--external-downloader", str(self.aria2c),
"--external-downloader-args", f"aria2c:-x {self.aria2c_connections} -s {self.aria2c_connections}",
video_url,
]
if not shutil.which(str(self.ffmpeg)):
# allow yt-dlp to find ffmpeg via --ffmpeg-location if configured as a path
video_args.insert(0, str(self.yt_dlp))
# if ffmpeg is an explicit path, yt-dlp will use it; we keep behavior consistent
try:
subprocess.run(video_args, check=True)
except subprocess.CalledProcessError as e:
err = (e.stderr or "").strip()
print(f"{FAIL} Video download failed: {title}{err}")
return False
# extract audio with ffmpeg into audio folder
audio_folder = self.save_path / "audio"
audio_folder.mkdir(parents=True, exist_ok=True)
audio_output = audio_folder / f"{track_index:03d} - {safe_title}.mp3"
# prefer configured ffmpeg path, fallback to system ffmpeg
ffmpeg_exe = str(self.ffmpeg)
if not (shutil.which(ffmpeg_exe) or Path(ffmpeg_exe).is_file()):
ffmpeg_exe = shutil.which("ffmpeg") or ffmpeg_exe
if ffmpeg_exe:
ffmpeg_cmd = [ffmpeg_exe, "-y", "-i", str(video_output), "-vn", "-codec:a", "libmp3lame", "-q:a", "0", str(audio_output)]
try:
subprocess.run(ffmpeg_cmd, check=True, capture_output=True, text=True)
except subprocess.CalledProcessError as e:
print(f"{WARN} ffmpeg failed to extract audio for {title}: {(e.stderr or '').strip()}")
else:
print(f"{WARN} ffmpeg not found; audio not extracted for {title}.")
print(f"{OK} Downloaded video and extracted audio for: {track_index:03d} - {title}")
return True
else:
print(f"{FAIL} Invalid download_mode '{self.download_mode}', skipping")
return False
# --- execute one or both downloads ---
success = True
for args, label in cmds:
try:
subprocess.run(args, check=True)
print(f"{OK} Downloaded: {label}")
except subprocess.CalledProcessError as e:
err_msg = e.stderr.strip().splitlines()[-1] if e.stderr else "Unknown error"
print(f"{FAIL} Download failed: {label}{err_msg}")
success = False
return success
def renumber_all_tracks(self, playlist_entries):
print(f"\n{STEP} Renumbering files according to playlist order")
temp_suffix = ".renametemp"
# --- Build mapping of safe_title → correct filename ---
final_map_audio = {}
final_map_video = {}
for idx, video in enumerate(playlist_entries, start=1):
title = video.get("title", "[Unknown]")
safe_title = self.sanitize_title(title, video["id"])
if self.download_mode in ("audio", "both"):
final_map_audio[safe_title] = f"{idx:03d} - {safe_title}.mp3"
if self.download_mode in ("video", "both"):
final_map_video[safe_title] = f"{idx:03d} - {safe_title}.mp4"
# --- Helper function to rename files in folder ---
def rename_files(folder, mapping, ext):
folder.mkdir(parents=True, exist_ok=True)
for safe_title, correct_fname in mapping.items():
matches = list(folder.glob(f"*{ext}"))
# Find matching file
for m in matches:
if safe_title in m.name:
if m.name != correct_fname:
temp_path = m.with_suffix(m.suffix + temp_suffix)
m.rename(temp_path)
for safe_title, correct_fname in mapping.items():
temp_match = list(folder.glob(f"*{ext}{temp_suffix}"))
for temp_path in temp_match:
final_path = folder / correct_fname
print(f"Renaming '{temp_path.name}''{final_path.name}'")
temp_path.rename(final_path)
if self.download_mode in ("audio", "both"):
rename_files(self.save_path / "audio", final_map_audio, ".mp3")
if self.download_mode in ("video", "both"):
rename_files(self.save_path / "video", final_map_video, ".mp4")
print(f"{OK} Renumbering complete.")
def update(self):
playlist_id = self.url or self.save_path or "unknown playlist"
if getattr(self, "skip", False):
print(f"{WARN} Skipping playlist '{playlist_id}': URL missing in the config.")
return
print(f"{STEP} Updating playlist: {playlist_id}")
playlist_entries = self.fetch_videos()
archive_ids = self.get_archive_ids()
new_videos = [v for v in playlist_entries if v["id"] not in archive_ids]
if not new_videos:
print(f"{OK} No new items found.")
else:
print(f"{OK} Found {len(new_videos)} new item(s) to download.")
idx_map = {v["id"]: i+1 for i, v in enumerate(playlist_entries)}
with ThreadPoolExecutor(max_workers=self.max_parallel) as executor:
futures = [executor.submit(self.download_video, v, idx_map[v["id"]]) for v in new_videos]
for f in as_completed(futures):
try:
f.result()
except subprocess.CalledProcessError as e:
print(f"{FAIL} Download failed: {e}")
self.renumber_all_tracks(playlist_entries)
self.cleanup_removed_tracks(playlist_entries)
def cleanup_removed_tracks(self, playlist_entries):
print(f"{STEP} Checking for files not in the playlist")
valid_titles = set()
for video in playlist_entries:
title = video.get("title", "[Unknown]")
safe_title = self.sanitize_title(title, video["id"])
valid_titles.add(safe_title)
def clean_folder(folder, ext):
to_delete = []
folder.mkdir(parents=True, exist_ok=True)
for file in folder.glob(f"*{ext}"):
parts = file.name.split(" - ", 1)
if len(parts) == 2:
safe_title_in_file = parts[1][:-len(ext)]
if safe_title_in_file not in valid_titles:
to_delete.append(file)
if not to_delete:
return
print(f"{WARN} The following files in '{folder}' are not in the playlist and will be deleted:")
for f in to_delete:
print(f" {f.name}")
try:
confirm = input(f"{WARN} Delete these files? [y/N]: ").strip().lower()
except EOFError:
confirm = "n"
if confirm == "y":
for f in to_delete:
try:
f.unlink()
print(f"{OK} Deleted: {f.name}")
except Exception as ex:
print(f"{FAIL} Failed to delete {f.name}: {ex}")
print(f"{OK} Cleanup complete in '{folder}'.")
else:
print(f"{OK} Cleanup aborted in '{folder}'. No files were deleted.")
if self.download_mode in ("audio", "both"):
clean_folder(self.save_path / "audio", ".mp3")
if self.download_mode in ("video", "both"):
clean_folder(self.save_path / "video", ".mp4")
class PlaylistManager:
def __init__(self, config: ConfigLoader):
self.config = config
self.playlists = [
PlaylistDownloader(config, pl, idx)
for idx, pl in enumerate(config.playlists)
]
def run(self):
total_connections = self.config.max_parallel_downloads * self.config.aria2c_connections
if total_connections > 100:
print("\033[91m"
f"{WARN}[WARNING] Total connections ({self.config.max_parallel_downloads} × "
f"{self.config.aria2c_connections} = {total_connections}) may overload your network! Pausing 5 seconds..."
"\033[0m")
time.sleep(5)
for playlist in self.playlists:
playlist.update()
if __name__ == "__main__": if __name__ == "__main__":
cfg = ConfigLoader("yt-playlist-config.json") # Keep working directory consistent with original script behaviour
if not is_docker(): update_yt_dlp(cfg.yt_dlp_path) #update yt-dpl executable import os
manager = PlaylistManager(cfg) import sys
manager.run()
os.chdir(os.path.dirname(os.path.abspath(sys.argv[0])))
# Ensure UTF-8 on Windows
if sys.platform.startswith("win"):
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore
except Exception:
pass
main()