mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-09-18 20:43:54 +03:00
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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]
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
|
"github-actions.workflows.pinned.workflows": []
|
||||||
|
}
|
||||||
+17
-4
@@ -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
@@ -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?
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
# YouTube Playlist Downloader
|
# YouTube Playlist Downloader
|
||||||
|
|
||||||
[](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/release.yml)
|
[](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build.yml)
|
||||||
|
|
||||||
|
[](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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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?
|
||||||
@@ -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*"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
# Collect all standardized tests using the conventional pattern
|
||||||
|
python_files = test_*.py
|
||||||
|
addopts = -q
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""ytplaylist package exports"""
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
__all__ = ["main"]
|
||||||
+46
@@ -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
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
Reference in New Issue
Block a user