diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecd18cf --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e855a99 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/build_v2.yml b/.github/workflows/build_v2.yml new file mode 100644 index 0000000..2fef6cd --- /dev/null +++ b/.github/workflows/build_v2.yml @@ -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 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..eb57fce --- /dev/null +++ b/.github/workflows/integration.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..a25bbd9 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -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 diff --git a/.gitignore b/.gitignore index 319850e..4db30a6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,12 @@ #Custom for this project -yt-playlist-config.json -/bin -/config/ +config/yt-playlist-config.json +/tmp* +/*/tmp* +*.code-workspace +/bin/* + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a133289 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.testing.pytestArgs": [ + "tests" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "github-actions.workflows.pinned.workflows": [] +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c03b547..0997d36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,9 +2,22 @@ FROM python:3.12-alpine WORKDIR /app +# Copy application code (package) and bootstrap COPY yt-playlist-main.py /app/ -COPY ./bin/ffmpeg /app/bin -COPY ./bin/yt-dlp /app/bin -COPY ./bin/aria2c /app/bin +COPY ytpld/ /app/ytpld/ +COPY config/ /app/config/ -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 [""] diff --git a/GUI plan.md b/GUI plan.md new file mode 100644 index 0000000..706c9be --- /dev/null +++ b/GUI plan.md @@ -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? diff --git a/README.md b/README.md index c07376c..a6a555b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # YouTube Playlist Downloader -[![Build Release Packages](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/release.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/release.yml) +[![Build Release](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/build.yml) + +[![Unit tests](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/darkzoul5/YoutubePlaylistDownloader/actions/workflows/unit-tests.yml) + +A cross-platform tool for downloading entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp), [ffmpeg](https://ffmpeg.org/), and [aria2c](https://github.com/aria2/aria2). Includes Gitea CI/CD workflow for packaging and releasing Windows and Linux binaries. - A cross-platform tool for downloading entire YouTube playlists as MP3 or MP4 files, using [yt-dlp](https://github.com/yt-dlp/yt-dlp), [ffmpeg](https://ffmpeg.org/), and [aria2c](https://github.com/aria2/aria2). Includes a GitHub Actions CI/CD workflow for packaging and releasing Windows and Linux binaries. Supports audio, video, or both download modes, music and videos are numbered as they are on your youtube playlist, playlist cleanup, and configurable parallel download options. @@ -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 --- + ## CLI flags + +When running the script locally (for example `python yt-playlist-main.py`), you can pass the following flags: + +- `-c, --config ` — 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 @@ -149,6 +175,27 @@ Run it with: 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 - **No binaries found:** Ensure paths in `yt-playlist-config.json` are correct and binaries are present. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..461dd3e --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/config/yt-playlist-config.example.json b/config/yt-playlist-config.example.json new file mode 100644 index 0000000..cbc5153 --- /dev/null +++ b/config/yt-playlist-config.example.json @@ -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 +} diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..11c76aa --- /dev/null +++ b/docker-entrypoint.sh @@ -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 diff --git a/project plan.md b/project plan.md new file mode 100644 index 0000000..e6e4156 --- /dev/null +++ b/project plan.md @@ -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? \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a49818e --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..7eb673f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +# Collect all standardized tests using the conventional pattern +python_files = test_*.py +addopts = -q diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..bedea11 --- /dev/null +++ b/src/__init__.py @@ -0,0 +1,4 @@ +"""ytplaylist package exports""" +from .cli import main + +__all__ = ["main"] diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..ce4d907 --- /dev/null +++ b/src/cli.py @@ -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() diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..be7ae27 --- /dev/null +++ b/src/config.py @@ -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) diff --git a/src/downloader.py b/src/downloader.py new file mode 100644 index 0000000..50c0a5c --- /dev/null +++ b/src/downloader.py @@ -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") diff --git a/src/manager.py b/src/manager.py new file mode 100644 index 0000000..78ae2d9 --- /dev/null +++ b/src/manager.py @@ -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() diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d6d1267 --- /dev/null +++ b/tests/conftest.py @@ -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() diff --git a/tests/dummy_config.py b/tests/dummy_config.py new file mode 100644 index 0000000..eeaf3f7 --- /dev/null +++ b/tests/dummy_config.py @@ -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 diff --git a/tests/integration_full_workflow_test.py b/tests/integration_full_workflow_test.py new file mode 100644 index 0000000..ab039e6 --- /dev/null +++ b/tests/integration_full_workflow_test.py @@ -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) diff --git a/tests/test_cli_flags.py b/tests/test_cli_flags.py new file mode 100644 index 0000000..2d2fbb7 --- /dev/null +++ b/tests/test_cli_flags.py @@ -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() diff --git a/tests/test_cli_update_and_logging.py b/tests/test_cli_update_and_logging.py new file mode 100644 index 0000000..2d3ff6a --- /dev/null +++ b/tests/test_cli_update_and_logging.py @@ -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 diff --git a/tests/test_config_loader_basic.py b/tests/test_config_loader_basic.py new file mode 100644 index 0000000..4aea84e --- /dev/null +++ b/tests/test_config_loader_basic.py @@ -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 diff --git a/tests/test_download_video_edgecases.py b/tests/test_download_video_edgecases.py new file mode 100644 index 0000000..56ac4fc --- /dev/null +++ b/tests/test_download_video_edgecases.py @@ -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 diff --git a/tests/test_downloader_sanitize_and_path.py b/tests/test_downloader_sanitize_and_path.py new file mode 100644 index 0000000..3189d6d --- /dev/null +++ b/tests/test_downloader_sanitize_and_path.py @@ -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") diff --git a/tests/test_fetch_videos.py b/tests/test_fetch_videos.py new file mode 100644 index 0000000..eb62c45 --- /dev/null +++ b/tests/test_fetch_videos.py @@ -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 diff --git a/tests/test_manager_connection_warning.py b/tests/test_manager_connection_warning.py new file mode 100644 index 0000000..0bb2810 --- /dev/null +++ b/tests/test_manager_connection_warning.py @@ -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) diff --git a/tests/test_playlist_manager_run_behaviour.py b/tests/test_playlist_manager_run_behaviour.py new file mode 100644 index 0000000..2d2fbb7 --- /dev/null +++ b/tests/test_playlist_manager_run_behaviour.py @@ -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() diff --git a/tests/test_renumber_and_cleanup.py b/tests/test_renumber_and_cleanup.py new file mode 100644 index 0000000..d6a3dfc --- /dev/null +++ b/tests/test_renumber_and_cleanup.py @@ -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) diff --git a/yt-playlist-main.py b/yt-playlist-main.py index cdf70de..b3de07b 100644 --- a/yt-playlist-main.py +++ b/yt-playlist-main.py @@ -1,524 +1,17 @@ -import os -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() +from ytplaylist import main if __name__ == "__main__": - cfg = ConfigLoader("yt-playlist-config.json") - if not is_docker(): update_yt_dlp(cfg.yt_dlp_path) #update yt-dpl executable - manager = PlaylistManager(cfg) - manager.run() \ No newline at end of file + # Keep working directory consistent with original script behaviour + import os + import sys + + 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() \ No newline at end of file