mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-04 04:53:58 +03:00
Compare commits
7 Commits
760b6c2f94
...
e5ad786bcf
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ad786bcf | |||
| abd3c2ed62 | |||
| 6d8649ac2d | |||
| 658def3d58 | |||
| 0ab96e4399 | |||
| 0cea4cfcb8 | |||
| 04a7367d19 |
@@ -1,298 +0,0 @@
|
|||||||
name: Build Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
tag:
|
|
||||||
description: "Release tag (e.g., v0.1.0)"
|
|
||||||
required: true
|
|
||||||
type: string
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-windows-package:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: sudo apt update && sudo apt install -y unzip zip curl
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.tag }}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Prepare Windows package
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/windows/bin"
|
|
||||||
cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/windows/"
|
|
||||||
|
|
||||||
# yt-dlp
|
|
||||||
curl -fL --retry 3 -H "User-Agent: github-actions" \
|
|
||||||
-o "$GITHUB_WORKSPACE/dist/windows/bin/yt-dlp.exe" \
|
|
||||||
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe
|
|
||||||
|
|
||||||
# FFmpeg Windows static
|
|
||||||
curl -fL --retry 3 -H "User-Agent: github-actions" \
|
|
||||||
-o "$GITHUB_WORKSPACE/dist/windows/ffmpeg.zip" \
|
|
||||||
https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip
|
|
||||||
unzip -q "$GITHUB_WORKSPACE/dist/windows/ffmpeg.zip" -d "$GITHUB_WORKSPACE/dist/windows/ffmpeg_temp"
|
|
||||||
mv $(find "$GITHUB_WORKSPACE/dist/windows/ffmpeg_temp" -name ffmpeg.exe | head -n 1) "$GITHUB_WORKSPACE/dist/windows/bin/ffmpeg.exe"
|
|
||||||
|
|
||||||
# aria2c Windows static
|
|
||||||
curl -fL --retry 3 -H "User-Agent: github-actions" \
|
|
||||||
-o "$GITHUB_WORKSPACE/dist/windows/aria2c.zip" \
|
|
||||||
https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0-win-64bit-build1.zip
|
|
||||||
unzip "$GITHUB_WORKSPACE/dist/windows/aria2c.zip" -d "$GITHUB_WORKSPACE/dist/windows/"
|
|
||||||
mv "$GITHUB_WORKSPACE/dist/windows/aria2-1.37.0-win-64bit-build1/aria2c.exe" "$GITHUB_WORKSPACE/dist/windows/bin/aria2c.exe"
|
|
||||||
|
|
||||||
rm -rf "$GITHUB_WORKSPACE/dist/windows/ffmpeg_temp" "$GITHUB_WORKSPACE/dist/windows/aria2-1.37.0-win-64bit-build1" "$GITHUB_WORKSPACE/dist/windows/ffmpeg.zip" "$GITHUB_WORKSPACE/dist/windows/aria2c.zip"
|
|
||||||
|
|
||||||
# Create windows archive
|
|
||||||
cd "$GITHUB_WORKSPACE/dist/windows"
|
|
||||||
ZIP_NAME="yt-playlist-windows-${VERSION}.zip"
|
|
||||||
zip -r "$GITHUB_WORKSPACE/$ZIP_NAME" *
|
|
||||||
echo "ZIP_PATH=$GITHUB_WORKSPACE/$ZIP_NAME" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Upload Windows artifact
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: windows-release
|
|
||||||
path: ${{ github.workspace }}/yt-playlist-windows-*.zip
|
|
||||||
|
|
||||||
build-linux-package:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.tag }}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
sudo apt update
|
|
||||||
sudo apt install -y unzip zip curl wget build-essential pkg-config libssl-dev zlib1g-dev
|
|
||||||
|
|
||||||
- name: Prepare workspace
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
|
|
||||||
cp "$GITHUB_WORKSPACE/yt-playlist-main.py" "$GITHUB_WORKSPACE/dist/linux/"
|
|
||||||
|
|
||||||
- name: Download yt-dlp
|
|
||||||
run: |
|
|
||||||
curl -fL --retry 3 -H "User-Agent: github-actions" \
|
|
||||||
-o "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp" \
|
|
||||||
https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_linux
|
|
||||||
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/yt-dlp"
|
|
||||||
|
|
||||||
- name: Download FFmpeg static
|
|
||||||
run: |
|
|
||||||
curl -fL --retry 3 -H "User-Agent: github-actions" \
|
|
||||||
-o "$GITHUB_WORKSPACE/dist/linux/ffmpeg.tar.xz" \
|
|
||||||
https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp"
|
|
||||||
tar -xf "$GITHUB_WORKSPACE/dist/linux/ffmpeg.tar.xz" -C "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp" --strip-components=1
|
|
||||||
mv "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp/ffmpeg" "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
|
|
||||||
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/ffmpeg"
|
|
||||||
|
|
||||||
- name: Restore aria2 cache
|
|
||||||
id: aria2-cache
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: dist/linux/bin/aria2c
|
|
||||||
key: aria2c-${{ runner.os }}-1.37.0
|
|
||||||
|
|
||||||
- name: Build aria2c if not cached
|
|
||||||
if: steps.aria2-cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/linux/bin"
|
|
||||||
|
|
||||||
mkdir -p "$GITHUB_WORKSPACE/dist/linux/aria2c_build"
|
|
||||||
cd "$GITHUB_WORKSPACE"
|
|
||||||
wget https://github.com/aria2/aria2/releases/download/release-1.37.0/aria2-1.37.0.tar.gz
|
|
||||||
tar -xzf aria2-1.37.0.tar.gz
|
|
||||||
cd aria2-1.37.0
|
|
||||||
CFLAGS="-Os -s" LDFLAGS="-static" ./configure \
|
|
||||||
--enable-static --disable-shared \
|
|
||||||
--disable-libaria2 --without-ca-bundle \
|
|
||||||
--without-libnettle --without-libgcrypt \
|
|
||||||
--without-libssh2 --without-libexpat \
|
|
||||||
--without-libxml2 --without-libsqlite3 \
|
|
||||||
--with-openssl
|
|
||||||
make -j"$(nproc)"
|
|
||||||
strip src/aria2c
|
|
||||||
cp src/aria2c "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
|
|
||||||
chmod +x "$GITHUB_WORKSPACE/dist/linux/bin/aria2c"
|
|
||||||
rm -rf "$GITHUB_WORKSPACE/dist/linux/aria2c_build" "$GITHUB_WORKSPACE/aria2-1.37.0" "$GITHUB_WORKSPACE/aria2-1.37.0.tar.gz"
|
|
||||||
|
|
||||||
|
|
||||||
- name: Show cache status and bin contents
|
|
||||||
run: |
|
|
||||||
echo "Cache hit: ${{ steps.aria2-cache.outputs.cache-hit }}"
|
|
||||||
echo "Listing dist/linux/bin:"
|
|
||||||
ls -la dist/linux/bin || true
|
|
||||||
|
|
||||||
- name: Cleanup FFmpeg temp
|
|
||||||
run: rm -rf "$GITHUB_WORKSPACE/dist/linux/ffmpeg_temp" "$GITHUB_WORKSPACE/dist/linux/ffmpeg.tar.xz"
|
|
||||||
|
|
||||||
- name: Archive Linux package
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
|
||||||
cd "$GITHUB_WORKSPACE/dist/linux"
|
|
||||||
TAR_NAME="yt-playlist-linux-${VERSION}.tar.gz"
|
|
||||||
tar -czf "$GITHUB_WORKSPACE/$TAR_NAME" *
|
|
||||||
|
|
||||||
- name: Upload Linux artifact
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: linux-release
|
|
||||||
path: ${{ github.workspace }}/yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
|
|
||||||
|
|
||||||
build-docker-image:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-linux-package]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.tag }}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Set docker image names
|
|
||||||
run: |
|
|
||||||
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}" >> $GITHUB_ENV
|
|
||||||
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Download linux artifact
|
|
||||||
uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: linux-release
|
|
||||||
|
|
||||||
- name: Prepare Docker build context
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/linux-docker
|
|
||||||
cp Dockerfile dist/linux-docker/
|
|
||||||
echo "Copying and extracting Linux artifact..."
|
|
||||||
tar -xzf yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz -C dist/linux-docker/
|
|
||||||
echo "Build context contents:"
|
|
||||||
ls -R dist/linux-docker
|
|
||||||
|
|
||||||
- name: Build Docker image (release)
|
|
||||||
run: docker build dist/linux-docker -t $RELEASE_IMAGE
|
|
||||||
|
|
||||||
- name: Save Docker image as tar (release)
|
|
||||||
run: docker save -o docker-image.tar $RELEASE_IMAGE
|
|
||||||
|
|
||||||
- name: Upload docker-image artifact
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: docker-image
|
|
||||||
path: docker-image.tar
|
|
||||||
|
|
||||||
- name: Build Docker image (latest)
|
|
||||||
run: docker build dist/linux-docker --label build_as_latest=true -t $LATEST_IMAGE
|
|
||||||
|
|
||||||
- name: Save Docker image as tar (latest)
|
|
||||||
run: docker save -o docker-image-latest.tar $LATEST_IMAGE
|
|
||||||
|
|
||||||
- name: Upload docker-image-latest artifact
|
|
||||||
uses: actions/upload-artifact@v6
|
|
||||||
with:
|
|
||||||
name: docker-image-latest
|
|
||||||
path: docker-image-latest.tar
|
|
||||||
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [build-windows-package, build-linux-package, build-docker-image]
|
|
||||||
steps:
|
|
||||||
- uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: windows-release
|
|
||||||
path: windows-release
|
|
||||||
- uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: linux-release
|
|
||||||
path: linux-release
|
|
||||||
- uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: docker-image
|
|
||||||
path: docker-image
|
|
||||||
- uses: actions/download-artifact@v5
|
|
||||||
with:
|
|
||||||
name: docker-image-latest
|
|
||||||
path: docker-image-latest
|
|
||||||
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
VERSION="${{ inputs.tag }}"
|
|
||||||
VERSION="${VERSION#v}"
|
|
||||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
|
||||||
- name: Set docker image names
|
|
||||||
run: |
|
|
||||||
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}" >> $GITHUB_ENV
|
|
||||||
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
|
|
||||||
- name: Login to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
- name: Load and push Docker release image
|
|
||||||
run: |
|
|
||||||
docker load -i docker-image/docker-image.tar
|
|
||||||
docker push $RELEASE_IMAGE
|
|
||||||
- name: Load and push Docker latest image
|
|
||||||
run: |
|
|
||||||
docker load -i docker-image-latest/docker-image-latest.tar
|
|
||||||
docker push $LATEST_IMAGE
|
|
||||||
|
|
||||||
- name: Create GitHub Release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ steps.version.outputs.version }}
|
|
||||||
release_name: "Release ${{ steps.version.outputs.version }}"
|
|
||||||
draft: true
|
|
||||||
- name: Upload Windows release asset
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: windows-release/yt-playlist-windows-${{ steps.version.outputs.version }}.zip
|
|
||||||
asset_name: yt-playlist-windows-${{ steps.version.outputs.version }}.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
- name: Upload Linux release asset
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: linux-release/yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
|
|
||||||
asset_name: yt-playlist-linux-${{ steps.version.outputs.version }}.tar.gz
|
|
||||||
asset_content_type: application/gzip
|
|
||||||
@@ -200,8 +200,8 @@ jobs:
|
|||||||
- name: Set docker image names
|
- name: Set docker image names
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ steps.version.outputs.version }}"
|
VERSION="${{ steps.version.outputs.version }}"
|
||||||
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${VERSION}" >> $GITHUB_ENV
|
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:${VERSION}" >> $GITHUB_ENV
|
||||||
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
|
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:latest" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: |
|
run: |
|
||||||
@@ -248,9 +248,9 @@ jobs:
|
|||||||
- name: Push Docker images
|
- name: Push Docker images
|
||||||
run: |
|
run: |
|
||||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
|
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
|
||||||
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}
|
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:${{ steps.version.outputs.version }}
|
||||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
|
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
|
||||||
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:latest
|
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:latest
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v3
|
uses: softprops/action-gh-release@v3
|
||||||
|
|||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"python.terminal.activateEnvironment": true,
|
||||||
"python.testing.pytestArgs": [
|
"python.testing.pytestArgs": [
|
||||||
"tests"
|
"tests"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
ytpld:
|
ytplst:
|
||||||
image: git.darkzoul.org/dark_zoul/ytpld:latest
|
image: git.darkzoul.org/dark_zoul/ytplst:latest
|
||||||
container_name: ytpld
|
container_name: ytplst
|
||||||
restart: no
|
restart: no
|
||||||
volumes:
|
volumes:
|
||||||
- /path/to/downloads:/app/downloads
|
- /path/to/downloads:/app/downloads
|
||||||
@@ -0,0 +1,781 @@
|
|||||||
|
# YouTube Playlist Sync — Project Conversion Plan
|
||||||
|
|
||||||
|
Repository:
|
||||||
|
|
||||||
|
- [darkzoul5/YoutubePlaylistDownloader](https://github.com/darkzoul5/YoutubePlaylistDownloader?utm_source=chatgpt.com)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Project Direction
|
||||||
|
|
||||||
|
Convert the project from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Single-purpose YouTube playlist downloader
|
||||||
|
```
|
||||||
|
|
||||||
|
into:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Persistent YouTube playlist synchronization client
|
||||||
|
```
|
||||||
|
|
||||||
|
The application becomes state-driven.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Core Product Goals
|
||||||
|
|
||||||
|
## Main Features
|
||||||
|
|
||||||
|
- Sync playlists locally
|
||||||
|
- Download missing items
|
||||||
|
- Remove deleted playlist items
|
||||||
|
- Keep exact playlist ordering
|
||||||
|
- Support audio/video modes
|
||||||
|
- Multiple playlists
|
||||||
|
- Background auto-sync
|
||||||
|
- GUI configuration
|
||||||
|
- Queue management
|
||||||
|
- Logs/history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Explicit Non-Goals (Current Scope)
|
||||||
|
|
||||||
|
Not planned right now:
|
||||||
|
|
||||||
|
- Built-in media playback
|
||||||
|
- Advanced naming templates
|
||||||
|
- Drag-and-drop manual ordering
|
||||||
|
- Private playlist sync
|
||||||
|
- Channel subscriptions
|
||||||
|
- Metadata editing
|
||||||
|
|
||||||
|
Design the architecture so these can be added later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommended Stack
|
||||||
|
|
||||||
|
## Core Language
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
- Native yt-dlp ecosystem
|
||||||
|
- Easier async/background work
|
||||||
|
- Better packaging than many expect
|
||||||
|
- Simpler iteration speed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# GUI
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- PySide6
|
||||||
|
|
||||||
|
Why:
|
||||||
|
|
||||||
|
- Modern Qt ecosystem
|
||||||
|
- Better long-term support
|
||||||
|
- Cleaner than Tkinter
|
||||||
|
- Easier dynamic playlist UI
|
||||||
|
- Good threading support
|
||||||
|
- Better styling
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
- Tkinter for this scale
|
||||||
|
- Electron/Tauri unless you want a web stack
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Downloader Backend
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- yt-dlp Python API
|
||||||
|
|
||||||
|
Install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install yt-dlp
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT:
|
||||||
|
|
||||||
|
- scrape YouTube manually
|
||||||
|
- parse HTML yourself
|
||||||
|
- depend on external unofficial APIs
|
||||||
|
|
||||||
|
Use yt-dlp for:
|
||||||
|
|
||||||
|
- metadata extraction
|
||||||
|
- playlist scanning
|
||||||
|
- downloading
|
||||||
|
- postprocessing
|
||||||
|
|
||||||
|
Use your own app for:
|
||||||
|
|
||||||
|
- sync logic
|
||||||
|
- ordering
|
||||||
|
- deletion
|
||||||
|
- scheduling
|
||||||
|
- state tracking
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Media Processing
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- ffmpeg
|
||||||
|
|
||||||
|
Needed for:
|
||||||
|
|
||||||
|
- audio extraction
|
||||||
|
- remuxing
|
||||||
|
- conversions
|
||||||
|
- thumbnail embedding later
|
||||||
|
|
||||||
|
Recommended approach:
|
||||||
|
|
||||||
|
- auto-detect ffmpeg
|
||||||
|
- optionally bundle with packaged app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- SQLite
|
||||||
|
|
||||||
|
Reason:
|
||||||
|
|
||||||
|
- Zero setup
|
||||||
|
- Local-first architecture
|
||||||
|
- Perfect for sync metadata
|
||||||
|
- Easy migrations
|
||||||
|
- Reliable
|
||||||
|
|
||||||
|
SQLite is extremely important for this project.
|
||||||
|
|
||||||
|
Do NOT rely only on:
|
||||||
|
|
||||||
|
- filenames
|
||||||
|
- folders
|
||||||
|
- JSON
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Background Scheduling
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- APScheduler
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
|
||||||
|
- interval syncs
|
||||||
|
- delayed jobs
|
||||||
|
- retry jobs
|
||||||
|
- startup sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Async/Concurrency
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- asyncio
|
||||||
|
|
||||||
|
Use for:
|
||||||
|
|
||||||
|
- concurrent playlist syncs
|
||||||
|
- GUI-safe task execution
|
||||||
|
- download queue
|
||||||
|
- cancellation
|
||||||
|
- progress updates
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
- loguru
|
||||||
|
|
||||||
|
or:
|
||||||
|
|
||||||
|
- standard logging module
|
||||||
|
|
||||||
|
Need:
|
||||||
|
|
||||||
|
- rotating logs
|
||||||
|
- GUI log panel
|
||||||
|
- error history
|
||||||
|
- debug support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Packaging
|
||||||
|
|
||||||
|
## Recommended
|
||||||
|
|
||||||
|
### During development
|
||||||
|
|
||||||
|
```text
|
||||||
|
venv + pip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release builds
|
||||||
|
|
||||||
|
Choose one:
|
||||||
|
|
||||||
|
| Tool | Notes |
|
||||||
|
| ----------- | ------------------------------- |
|
||||||
|
| Nuitka | Best performance and protection |
|
||||||
|
| PyInstaller | Easier and common |
|
||||||
|
|
||||||
|
Nuitka is probably best long-term.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Major Architectural Changes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 1. Move to State-Based Sync Architecture
|
||||||
|
|
||||||
|
Current downloader logic is likely:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Playlist URL
|
||||||
|
↓
|
||||||
|
Download everything
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Remote Playlist State
|
||||||
|
↓
|
||||||
|
Stored Local State
|
||||||
|
↓
|
||||||
|
Filesystem State
|
||||||
|
↓
|
||||||
|
Diff Engine
|
||||||
|
↓
|
||||||
|
Sync Actions
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the single most important change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2. Introduce Playlist Metadata Database
|
||||||
|
|
||||||
|
Create persistent tracking.
|
||||||
|
|
||||||
|
Suggested tables:
|
||||||
|
|
||||||
|
## playlists
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
url TEXT,
|
||||||
|
path TEXT,
|
||||||
|
mode TEXT,
|
||||||
|
auto_sync INTEGER,
|
||||||
|
sync_interval_minutes INTEGER,
|
||||||
|
last_sync TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## playlist\_items
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE playlist_items (
|
||||||
|
playlist_id TEXT,
|
||||||
|
video_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
playlist_index INTEGER,
|
||||||
|
local_filename TEXT,
|
||||||
|
downloaded INTEGER,
|
||||||
|
last_seen TEXT,
|
||||||
|
PRIMARY KEY (playlist_id, video_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 3. Implement Playlist Scanner Layer
|
||||||
|
|
||||||
|
Create dedicated metadata extraction.
|
||||||
|
|
||||||
|
Suggested structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
core/
|
||||||
|
├── scanner/
|
||||||
|
│ └── playlist_scanner.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
|
||||||
|
- fetch playlist entries
|
||||||
|
- extract video IDs
|
||||||
|
- detect unavailable videos
|
||||||
|
- return normalized playlist state
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
extract_info(download=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
This allows playlist scanning without downloading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 4. Create Diff Engine
|
||||||
|
|
||||||
|
The app should compare:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Remote playlist
|
||||||
|
vs
|
||||||
|
Database state
|
||||||
|
vs
|
||||||
|
Filesystem state
|
||||||
|
```
|
||||||
|
|
||||||
|
Output actions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DOWNLOAD
|
||||||
|
DELETE
|
||||||
|
RENAME
|
||||||
|
REORDER
|
||||||
|
SKIP
|
||||||
|
REPAIR
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested file:
|
||||||
|
|
||||||
|
```text
|
||||||
|
core/sync/diff_engine.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This becomes the heart of the application.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 5. Add Download Queue System
|
||||||
|
|
||||||
|
Multi-playlist sync requires a queue.
|
||||||
|
|
||||||
|
Suggested states:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Queued
|
||||||
|
Downloading
|
||||||
|
Converting
|
||||||
|
Completed
|
||||||
|
Failed
|
||||||
|
Skipped
|
||||||
|
Cancelled
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested structure:
|
||||||
|
|
||||||
|
```text
|
||||||
|
core/download/
|
||||||
|
├── queue_manager.py
|
||||||
|
├── downloader.py
|
||||||
|
└── workers.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 6. Implement Stable File Naming
|
||||||
|
|
||||||
|
Recommended naming:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0001 - Title.ext
|
||||||
|
```
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
- native filesystem sorting
|
||||||
|
- easy reorder support
|
||||||
|
- easy repairs
|
||||||
|
- user friendly
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```python
|
||||||
|
%(playlist_index)04d - %(title)s.%(ext)s
|
||||||
|
```
|
||||||
|
|
||||||
|
through yt-dlp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 7. Implement Safe Reordering
|
||||||
|
|
||||||
|
Playlist ordering changes frequently.
|
||||||
|
|
||||||
|
Never rename directly.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Temporary rename pass
|
||||||
|
↓
|
||||||
|
Final rename pass
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0001.mp3 → temp_a
|
||||||
|
0002.mp3 → temp_b
|
||||||
|
↓
|
||||||
|
temp_a → 0002.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
Avoid collisions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 8. Implement Deletion Strategy
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
Instead of immediate delete:
|
||||||
|
|
||||||
|
```text
|
||||||
|
playlist/.recycle/
|
||||||
|
```
|
||||||
|
|
||||||
|
Move removed files there.
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
- safer
|
||||||
|
- recoverable
|
||||||
|
- easier debugging
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
|
||||||
|
- auto-clean after X days
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 9. Redesign GUI Around Playlists
|
||||||
|
|
||||||
|
Current downloader GUIs are usually task-oriented.
|
||||||
|
|
||||||
|
You should move to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Playlist-oriented UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended sections:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sidebar
|
||||||
|
├── Playlists
|
||||||
|
├── Queue
|
||||||
|
├── History
|
||||||
|
├── Logs
|
||||||
|
└── Settings
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 10. Support Infinite Playlist Entries
|
||||||
|
|
||||||
|
Use dynamic UI generation.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class PlaylistConfig:
|
||||||
|
url: str
|
||||||
|
path: str
|
||||||
|
mode: str
|
||||||
|
auto_sync: bool
|
||||||
|
```
|
||||||
|
|
||||||
|
GUI should render from:
|
||||||
|
|
||||||
|
```python
|
||||||
|
list[PlaylistConfig]
|
||||||
|
```
|
||||||
|
|
||||||
|
Do NOT hardcode playlist pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 11. Add Background Sync
|
||||||
|
|
||||||
|
Start simple.
|
||||||
|
|
||||||
|
## Phase 1
|
||||||
|
|
||||||
|
- Timer-based sync
|
||||||
|
- Tray icon
|
||||||
|
- Run minimized
|
||||||
|
|
||||||
|
## Phase 2
|
||||||
|
|
||||||
|
- Background daemon/service
|
||||||
|
- Headless mode
|
||||||
|
- Autostart support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 12. Add Progress/Event System
|
||||||
|
|
||||||
|
Needed for GUI responsiveness.
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```text
|
||||||
|
event_bus.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Events:
|
||||||
|
|
||||||
|
```text
|
||||||
|
DownloadStarted
|
||||||
|
DownloadProgress
|
||||||
|
SyncStarted
|
||||||
|
SyncFinished
|
||||||
|
FileDeleted
|
||||||
|
PlaylistUpdated
|
||||||
|
```
|
||||||
|
|
||||||
|
This decouples GUI from backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 13. Introduce Config Management
|
||||||
|
|
||||||
|
Recommended:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Only for:
|
||||||
|
|
||||||
|
- app settings
|
||||||
|
- UI preferences
|
||||||
|
- non-relational settings
|
||||||
|
|
||||||
|
Do NOT store sync state in JSON.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Suggested Folder Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── core/
|
||||||
|
│ ├── scanner/
|
||||||
|
│ ├── sync/
|
||||||
|
│ ├── download/
|
||||||
|
│ ├── database/
|
||||||
|
│ ├── scheduler/
|
||||||
|
│ └── events/
|
||||||
|
│
|
||||||
|
├── gui/
|
||||||
|
│ ├── pages/
|
||||||
|
│ ├── widgets/
|
||||||
|
│ ├── dialogs/
|
||||||
|
│ └── models/
|
||||||
|
│
|
||||||
|
├── config/
|
||||||
|
├── logs/
|
||||||
|
├── data/
|
||||||
|
└── main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Suggested Sync Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
Load playlists
|
||||||
|
↓
|
||||||
|
Scheduler triggers sync
|
||||||
|
↓
|
||||||
|
Scanner fetches remote playlist
|
||||||
|
↓
|
||||||
|
Database state loaded
|
||||||
|
↓
|
||||||
|
Filesystem scanned
|
||||||
|
↓
|
||||||
|
Diff engine computes actions
|
||||||
|
↓
|
||||||
|
Queue downloads
|
||||||
|
↓
|
||||||
|
Reorder files
|
||||||
|
↓
|
||||||
|
Move removed files
|
||||||
|
↓
|
||||||
|
Update database
|
||||||
|
↓
|
||||||
|
Emit GUI events
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommended MVP Conversion Order
|
||||||
|
|
||||||
|
## Phase 1 — Backend Foundation
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- SQLite
|
||||||
|
- playlist scanner
|
||||||
|
- diff engine
|
||||||
|
- download wrapper
|
||||||
|
- basic sync logic
|
||||||
|
|
||||||
|
No GUI redesign yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 — Stable Syncing
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- deletion handling
|
||||||
|
- reorder handling
|
||||||
|
- queue system
|
||||||
|
- retry system
|
||||||
|
- logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 3 — GUI Rewrite
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- playlist manager UI
|
||||||
|
- queue page
|
||||||
|
- logs page
|
||||||
|
- settings page
|
||||||
|
- dynamic playlists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 4 — Automation
|
||||||
|
|
||||||
|
Implement:
|
||||||
|
|
||||||
|
- background sync
|
||||||
|
- tray mode
|
||||||
|
- startup sync
|
||||||
|
- periodic sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Important Recommendations
|
||||||
|
|
||||||
|
## Recommendation 1
|
||||||
|
|
||||||
|
Treat:
|
||||||
|
|
||||||
|
```text
|
||||||
|
video_id
|
||||||
|
```
|
||||||
|
|
||||||
|
as the canonical identity.
|
||||||
|
|
||||||
|
Never titles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommendation 2
|
||||||
|
|
||||||
|
Do NOT rely on yt-dlp archive files alone.
|
||||||
|
|
||||||
|
Your own DB should be the source of truth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommendation 3
|
||||||
|
|
||||||
|
Keep download logic isolated.
|
||||||
|
|
||||||
|
yt-dlp should be replaceable internally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommendation 4
|
||||||
|
|
||||||
|
Do not overcomplicate the GUI early.
|
||||||
|
|
||||||
|
Focus on sync correctness first.
|
||||||
|
|
||||||
|
Sync reliability matters more than appearance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommendation 5
|
||||||
|
|
||||||
|
Design everything around interruption recovery.
|
||||||
|
|
||||||
|
The app should survive:
|
||||||
|
|
||||||
|
- crashes
|
||||||
|
- partial downloads
|
||||||
|
- force closes
|
||||||
|
- network failures
|
||||||
|
- playlist changes mid-sync
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Recommendation 6
|
||||||
|
|
||||||
|
Keep the application local-first.
|
||||||
|
|
||||||
|
No account system. No cloud backend. No telemetry.
|
||||||
|
|
||||||
|
That becomes a strong project identity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Final Recommended Identity
|
||||||
|
|
||||||
|
Instead of:
|
||||||
|
|
||||||
|
```text
|
||||||
|
YouTube Downloader GUI
|
||||||
|
```
|
||||||
|
|
||||||
|
Position the project as:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Local-first YouTube playlist synchronization client.
|
||||||
|
```
|
||||||
|
|
||||||
|
That identity is:
|
||||||
|
|
||||||
|
- clearer
|
||||||
|
- more unique
|
||||||
|
- technically stronger
|
||||||
|
- easier to expand later
|
||||||
+25
-6
@@ -3,17 +3,36 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "ytpld"
|
name = "ytplst"
|
||||||
version = "v1.1.1"
|
version = "1.1.1"
|
||||||
description = "YouTube playlist downloader"
|
description = "YouTube playlist Sync Thing"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [ { name = "Dark_Zoul" } ]
|
authors = [ { name = "Dark_Zoul" } ]
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
keywords = ["youtube", "yt-dlp", "playlist", "downloader"]
|
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"yt-dlp>=2026.3.17",
|
||||||
|
]
|
||||||
|
[project.optional-dependencies]
|
||||||
|
gui = [
|
||||||
|
"PySide6"
|
||||||
|
]
|
||||||
|
dev = [
|
||||||
|
"pytest",
|
||||||
|
"ruff",
|
||||||
|
"black"
|
||||||
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
"Home" = "https://git.darkzoul.org/dark_zoul/youtube-playlist-downloader"
|
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
ytplst = "ytplst.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
include = ["*"]
|
include = ["ytplst*"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""
|
||||||
|
App package: backend foundation for playlist sync (no GUI).
|
||||||
|
|
||||||
|
This package is the new, state-driven backend. It is intentionally
|
||||||
|
minimal at this stage and will be filled out iteratively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"core",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Config loader for the new backend (separate from legacy)."""
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: Dict[str, Any] = {
|
||||||
|
"playlists": [],
|
||||||
|
"download_mode": "audio",
|
||||||
|
"max_video_quality": "1080p",
|
||||||
|
"save_path": "./downloads",
|
||||||
|
"yt_dlp_path": "yt-dlp",
|
||||||
|
"ffmpeg_path": "ffmpeg",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Settings:
|
||||||
|
def __init__(self, config_path: Optional[Path] = None) -> None:
|
||||||
|
base_dir = Path("config")
|
||||||
|
base_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.path = (config_path or (base_dir / "yt-playlist-config.json")).resolve()
|
||||||
|
self.data: Dict[str, Any] = dict(DEFAULT_CONFIG)
|
||||||
|
if self.path.exists():
|
||||||
|
try:
|
||||||
|
self.data.update(json.loads(self.path.read_text(encoding="utf-8")))
|
||||||
|
except Exception:
|
||||||
|
# Leave defaults if invalid JSON; validation can be added later.
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlists(self) -> List[Dict[str, Any]]:
|
||||||
|
return list(self.data.get("playlists", []))
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"scanner",
|
||||||
|
"sync",
|
||||||
|
"download",
|
||||||
|
"database",
|
||||||
|
"scheduler",
|
||||||
|
"events",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Database helpers (SQLite)."""
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
PRAGMA journal_mode=WAL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playlists (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
url TEXT,
|
||||||
|
path TEXT,
|
||||||
|
mode TEXT,
|
||||||
|
auto_sync INTEGER,
|
||||||
|
sync_interval_minutes INTEGER,
|
||||||
|
last_sync TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS playlist_items (
|
||||||
|
playlist_id TEXT,
|
||||||
|
video_id TEXT,
|
||||||
|
title TEXT,
|
||||||
|
playlist_index INTEGER,
|
||||||
|
local_filename TEXT,
|
||||||
|
downloaded INTEGER,
|
||||||
|
last_seen TEXT,
|
||||||
|
PRIMARY KEY (playlist_id, video_id)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self.path = db_path
|
||||||
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = sqlite3.connect(self.path)
|
||||||
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
self._migrate()
|
||||||
|
|
||||||
|
def _migrate(self) -> None:
|
||||||
|
with self._conn:
|
||||||
|
self._conn.executescript(SCHEMA)
|
||||||
|
|
||||||
|
def upsert_playlist_items(self, rows: Iterable[tuple]):
|
||||||
|
sql = (
|
||||||
|
"INSERT INTO playlist_items (playlist_id, video_id, title, playlist_index, local_filename, downloaded, last_seen) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, datetime('now')) "
|
||||||
|
"ON CONFLICT(playlist_id, video_id) DO UPDATE SET "
|
||||||
|
"title=excluded.title, playlist_index=excluded.playlist_index, local_filename=excluded.local_filename, "
|
||||||
|
"downloaded=excluded.downloaded, last_seen=datetime('now')"
|
||||||
|
)
|
||||||
|
with self._conn:
|
||||||
|
self._conn.executemany(sql, rows)
|
||||||
|
|
||||||
|
def get_items_index(self, playlist_id: str) -> dict[str, sqlite3.Row]:
|
||||||
|
cur = self._conn.execute(
|
||||||
|
"SELECT * FROM playlist_items WHERE playlist_id = ?",
|
||||||
|
(playlist_id,),
|
||||||
|
)
|
||||||
|
return {row["video_id"]: row for row in cur.fetchall()}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from .queue_manager import DownloadJob, JobState
|
||||||
|
|
||||||
|
|
||||||
|
class Downloader:
|
||||||
|
"""
|
||||||
|
Thin wrapper around yt-dlp usage. For MVP, this is a placeholder
|
||||||
|
where actual download logic will land (audio/video/both).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, yt_dlp_path: Optional[str] = None, ffmpeg_path: Optional[str] = None) -> None:
|
||||||
|
self.yt_dlp_path = yt_dlp_path
|
||||||
|
self.ffmpeg_path = ffmpeg_path
|
||||||
|
|
||||||
|
async def handle_job(self, job: DownloadJob):
|
||||||
|
try:
|
||||||
|
job.state = JobState.DOWNLOADING
|
||||||
|
await self._download(job)
|
||||||
|
job.state = JobState.COMPLETED
|
||||||
|
except Exception as exc: # pragma: no cover - environment dependent
|
||||||
|
job.state = JobState.FAILED
|
||||||
|
job.error = str(exc)
|
||||||
|
|
||||||
|
async def _download(self, job: DownloadJob):
|
||||||
|
# Use yt-dlp Python API, executed in a worker thread
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
def run():
|
||||||
|
import yt_dlp # type: ignore
|
||||||
|
|
||||||
|
outtmpl = str(job.output_path)
|
||||||
|
if job.mode == "audio":
|
||||||
|
ydl_opts = {
|
||||||
|
"format": "bestaudio/best",
|
||||||
|
"outtmpl": outtmpl,
|
||||||
|
"postprocessors": [
|
||||||
|
{
|
||||||
|
"key": "FFmpegExtractAudio",
|
||||||
|
"preferredcodec": "mp3",
|
||||||
|
"preferredquality": "0",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"noplaylist": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
}
|
||||||
|
else: # video
|
||||||
|
ydl_opts = {
|
||||||
|
"format": "bestvideo+bestaudio/best",
|
||||||
|
"merge_output_format": "mp4",
|
||||||
|
"outtmpl": outtmpl,
|
||||||
|
"noplaylist": True,
|
||||||
|
"quiet": True,
|
||||||
|
"no_warnings": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.ffmpeg_path:
|
||||||
|
ydl_opts["ffmpeg_location"] = self.ffmpeg_path
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
|
ydl.download([job.url])
|
||||||
|
|
||||||
|
await asyncio.to_thread(run)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ..models import PlaylistItem
|
||||||
|
|
||||||
|
|
||||||
|
class JobState(str, Enum):
|
||||||
|
QUEUED = "Queued"
|
||||||
|
DOWNLOADING = "Downloading"
|
||||||
|
CONVERTING = "Converting"
|
||||||
|
COMPLETED = "Completed"
|
||||||
|
FAILED = "Failed"
|
||||||
|
SKIPPED = "Skipped"
|
||||||
|
CANCELLED = "Cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DownloadJob:
|
||||||
|
item: PlaylistItem
|
||||||
|
output_path: Optional[Path] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
mode: str = "audio" # audio|video
|
||||||
|
state: JobState = JobState.QUEUED
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QueueManager:
|
||||||
|
def __init__(self, concurrency: int = 2) -> None:
|
||||||
|
self._queue: "asyncio.Queue[DownloadJob]" = asyncio.Queue()
|
||||||
|
self._concurrency = max(1, concurrency)
|
||||||
|
self._workers: list[asyncio.Task[None]] = []
|
||||||
|
self._stopped = asyncio.Event()
|
||||||
|
|
||||||
|
async def start(self, worker_coro):
|
||||||
|
async def runner(idx: int):
|
||||||
|
while not self._stopped.is_set():
|
||||||
|
job = await self._queue.get()
|
||||||
|
try:
|
||||||
|
await worker_coro(job)
|
||||||
|
finally:
|
||||||
|
self._queue.task_done()
|
||||||
|
|
||||||
|
self._workers = [asyncio.create_task(runner(i)) for i in range(self._concurrency)]
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
self._stopped.set()
|
||||||
|
for w in self._workers:
|
||||||
|
w.cancel()
|
||||||
|
self._workers.clear()
|
||||||
|
|
||||||
|
async def enqueue(self, job: DownloadJob):
|
||||||
|
await self._queue.put(job)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .downloader import Downloader
|
||||||
|
from .queue_manager import DownloadJob
|
||||||
|
|
||||||
|
|
||||||
|
async def default_worker(job: DownloadJob):
|
||||||
|
dl = Downloader()
|
||||||
|
await dl.handle_job(job)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Awaitable, Callable, DefaultDict, Dict, List
|
||||||
|
|
||||||
|
|
||||||
|
EventHandler = Callable[[Dict[str, Any]], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
"""Simple async pub/sub event bus used by backend and (later) GUI."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subs: DefaultDict[str, List[EventHandler]] = defaultdict(list)
|
||||||
|
|
||||||
|
def subscribe(self, event_name: str, handler: EventHandler) -> None:
|
||||||
|
self._subs[event_name].append(handler)
|
||||||
|
|
||||||
|
async def publish(self, event_name: str, payload: Dict[str, Any]) -> None:
|
||||||
|
for h in list(self._subs.get(event_name, [])):
|
||||||
|
await h(payload)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadMode(str, Enum):
|
||||||
|
audio = "audio"
|
||||||
|
video = "video"
|
||||||
|
both = "both"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Playlist:
|
||||||
|
id: str
|
||||||
|
name: Optional[str]
|
||||||
|
url: str
|
||||||
|
path: Path
|
||||||
|
mode: DownloadMode = DownloadMode.audio
|
||||||
|
auto_sync: bool = False
|
||||||
|
sync_interval_minutes: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PlaylistItem:
|
||||||
|
playlist_id: str
|
||||||
|
video_id: str
|
||||||
|
title: str
|
||||||
|
playlist_index: int
|
||||||
|
local_filename: Optional[str] = None
|
||||||
|
downloaded: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class SyncActionType(str, Enum):
|
||||||
|
DOWNLOAD = "DOWNLOAD"
|
||||||
|
DELETE = "DELETE"
|
||||||
|
RENAME = "RENAME"
|
||||||
|
REORDER = "REORDER"
|
||||||
|
SKIP = "SKIP"
|
||||||
|
REPAIR = "REPAIR"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SyncAction:
|
||||||
|
type: SyncActionType
|
||||||
|
item: Optional[PlaylistItem] = None
|
||||||
|
from_name: Optional[str] = None
|
||||||
|
to_name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FilesystemEntry:
|
||||||
|
name: str
|
||||||
|
path: Path
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..models import PlaylistItem
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistScanner:
|
||||||
|
"""
|
||||||
|
Fetches remote playlist entries using yt-dlp (no downloads).
|
||||||
|
|
||||||
|
This class intentionally avoids strict dependencies at import time. If
|
||||||
|
yt_dlp is unavailable, call sites should handle the raised ImportError.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def scan(self, playlist_url: str, playlist_id: str) -> List[PlaylistItem]:
|
||||||
|
try:
|
||||||
|
import yt_dlp # type: ignore
|
||||||
|
except Exception as exc: # pragma: no cover - environment dependent
|
||||||
|
raise ImportError("yt_dlp is required to scan playlists") from exc
|
||||||
|
|
||||||
|
ydl_opts = {
|
||||||
|
"extract_flat": True,
|
||||||
|
"skip_download": True,
|
||||||
|
"quiet": True,
|
||||||
|
"dump_single_json": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
|
||||||
|
info = ydl.extract_info(playlist_url, download=False)
|
||||||
|
|
||||||
|
entries = info.get("entries", []) if isinstance(info, dict) else []
|
||||||
|
items: List[PlaylistItem] = []
|
||||||
|
for idx, v in enumerate(entries, start=1):
|
||||||
|
if not v:
|
||||||
|
continue
|
||||||
|
title = v.get("title") or "[Unknown]"
|
||||||
|
if title in ("[Deleted video]", "[Private video]"):
|
||||||
|
continue
|
||||||
|
vid = v.get("id") or ""
|
||||||
|
if not vid:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
PlaylistItem(
|
||||||
|
playlist_id=playlist_id,
|
||||||
|
video_id=vid,
|
||||||
|
title=title,
|
||||||
|
playlist_index=idx,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Awaitable, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class Scheduler:
|
||||||
|
"""
|
||||||
|
Lightweight placeholder for background scheduling. This can later be
|
||||||
|
swapped for APScheduler without changing call sites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._jobs: list[tuple[timedelta, Callable[[], Awaitable[None]]]] = []
|
||||||
|
|
||||||
|
def every(self, interval: timedelta, coro_factory: Callable[[], Awaitable[None]]):
|
||||||
|
self._jobs.append((interval, coro_factory))
|
||||||
|
return self
|
||||||
|
|
||||||
|
# A full implementation will run an event loop and await jobs.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable, List, Mapping, Sequence
|
||||||
|
|
||||||
|
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
|
||||||
|
|
||||||
|
|
||||||
|
class DiffEngine:
|
||||||
|
"""
|
||||||
|
Compares remote playlist items, database state, and filesystem to
|
||||||
|
produce a list of actions. Initial MVP computes DOWNLOAD/RENAME/REORDER
|
||||||
|
based on simple filename scheme "0001 - Title.ext".
|
||||||
|
"""
|
||||||
|
|
||||||
|
def compute_actions(
|
||||||
|
self,
|
||||||
|
remote: Sequence[PlaylistItem],
|
||||||
|
db_index: Mapping[str, PlaylistItem],
|
||||||
|
fs_entries: Iterable[FilesystemEntry],
|
||||||
|
extension: str,
|
||||||
|
) -> List[SyncAction]:
|
||||||
|
actions: List[SyncAction] = []
|
||||||
|
|
||||||
|
desired_names = {
|
||||||
|
item.video_id: f"{item.playlist_index:04d} - {item.title}{extension}"
|
||||||
|
for item in remote
|
||||||
|
}
|
||||||
|
|
||||||
|
fs_by_name = {e.name: e for e in fs_entries}
|
||||||
|
|
||||||
|
for item in remote:
|
||||||
|
desired_name = desired_names[item.video_id]
|
||||||
|
if item.local_filename == desired_name and desired_name in fs_by_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if desired_name in fs_by_name:
|
||||||
|
actions.append(SyncAction(SyncActionType.RENAME, item=item, from_name=item.local_filename, to_name=desired_name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
actions.append(SyncAction(SyncActionType.DOWNLOAD, item=item, to_name=desired_name))
|
||||||
|
|
||||||
|
known_ids = {i.video_id for i in remote}
|
||||||
|
for vid, db_item in db_index.items():
|
||||||
|
if vid not in known_ids and db_item.local_filename:
|
||||||
|
actions.append(SyncAction(SyncActionType.DELETE, item=db_item, from_name=db_item.local_filename))
|
||||||
|
|
||||||
|
return actions
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List
|
||||||
|
|
||||||
|
from ..download.queue_manager import DownloadJob, QueueManager
|
||||||
|
from ..download.workers import default_worker
|
||||||
|
from ..models import SyncAction, SyncActionType
|
||||||
|
from ..sync.reorder import safe_multi_rename
|
||||||
|
|
||||||
|
|
||||||
|
class ActionExecutor:
|
||||||
|
def __init__(self, concurrency: int = 2) -> None:
|
||||||
|
self.concurrency = max(1, concurrency)
|
||||||
|
|
||||||
|
async def execute(self, actions: Iterable[SyncAction], playlist_cfg: dict) -> None:
|
||||||
|
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||||
|
mode = playlist_cfg.get("download_mode", "audio")
|
||||||
|
|
||||||
|
# Prepare roots
|
||||||
|
audio_root = save_path / "audio"
|
||||||
|
video_root = save_path / "video"
|
||||||
|
audio_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
video_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# First, handle renames safely in batch per extension
|
||||||
|
await self._apply_renames(actions, audio_root, video_root)
|
||||||
|
|
||||||
|
# Then, recycle deletions
|
||||||
|
self._apply_deletions(actions, audio_root, video_root)
|
||||||
|
|
||||||
|
# Finally, perform downloads concurrently
|
||||||
|
await self._apply_downloads(actions, mode, audio_root, video_root)
|
||||||
|
|
||||||
|
async def _apply_renames(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path) -> None:
|
||||||
|
audio_renames = []
|
||||||
|
video_renames = []
|
||||||
|
for a in actions:
|
||||||
|
if a.type != SyncActionType.RENAME or not a.from_name or not a.to_name:
|
||||||
|
continue
|
||||||
|
if a.to_name.endswith(".mp3"):
|
||||||
|
audio_renames.append((audio_root / a.from_name, audio_root / a.to_name))
|
||||||
|
elif a.to_name.endswith(".mp4"):
|
||||||
|
video_renames.append((video_root / a.from_name, video_root / a.to_name))
|
||||||
|
|
||||||
|
if audio_renames:
|
||||||
|
safe_multi_rename(audio_renames)
|
||||||
|
if video_renames:
|
||||||
|
safe_multi_rename(video_renames)
|
||||||
|
|
||||||
|
def _apply_deletions(self, actions: Iterable[SyncAction], audio_root: Path, video_root: Path) -> None:
|
||||||
|
recycle_audio = audio_root.parent / ".recycle" / "audio"
|
||||||
|
recycle_video = video_root.parent / ".recycle" / "video"
|
||||||
|
recycle_audio.mkdir(parents=True, exist_ok=True)
|
||||||
|
recycle_video.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
for a in actions:
|
||||||
|
if a.type != SyncActionType.DELETE or not a.from_name:
|
||||||
|
continue
|
||||||
|
if a.from_name.endswith(".mp3"):
|
||||||
|
src = audio_root / a.from_name
|
||||||
|
dst = recycle_audio / a.from_name
|
||||||
|
else:
|
||||||
|
src = video_root / a.from_name
|
||||||
|
dst = recycle_video / a.from_name
|
||||||
|
if src.exists():
|
||||||
|
try:
|
||||||
|
if dst.exists():
|
||||||
|
dst.unlink()
|
||||||
|
shutil.move(str(src), str(dst))
|
||||||
|
except Exception:
|
||||||
|
# fallback to delete if move fails
|
||||||
|
try:
|
||||||
|
src.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _apply_downloads(self, actions: Iterable[SyncAction], mode: str, audio_root: Path, video_root: Path) -> None:
|
||||||
|
queue = QueueManager(concurrency=self.concurrency)
|
||||||
|
|
||||||
|
async def worker(job: DownloadJob):
|
||||||
|
await default_worker(job)
|
||||||
|
|
||||||
|
await queue.start(worker)
|
||||||
|
try:
|
||||||
|
for a in actions:
|
||||||
|
if a.type != SyncActionType.DOWNLOAD or not a.item or not a.to_name:
|
||||||
|
continue
|
||||||
|
is_audio = a.to_name.endswith(".mp3")
|
||||||
|
root = audio_root if is_audio else video_root
|
||||||
|
output_path = root / a.to_name
|
||||||
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
url = f"https://www.youtube.com/watch?v={a.item.video_id}"
|
||||||
|
job = DownloadJob(item=a.item, output_path=output_path, url=url, mode=("audio" if is_audio else "video"))
|
||||||
|
await queue.enqueue(job)
|
||||||
|
finally:
|
||||||
|
await queue._queue.join() # wait for all jobs
|
||||||
|
await queue.stop()
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, List, Sequence
|
||||||
|
|
||||||
|
from ..models import FilesystemEntry
|
||||||
|
|
||||||
|
|
||||||
|
def list_files(root: Path, extensions: Sequence[str]) -> List[FilesystemEntry]:
|
||||||
|
exts = {e.lower() for e in extensions}
|
||||||
|
results: List[FilesystemEntry] = []
|
||||||
|
if not root.exists():
|
||||||
|
return results
|
||||||
|
for p in root.glob("**/*"):
|
||||||
|
if p.is_file() and p.suffix.lower() in exts:
|
||||||
|
results.append(FilesystemEntry(name=p.name, path=p))
|
||||||
|
return results
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
def safe_multi_rename(renames: Iterable[Tuple[Path, Path]]) -> None:
|
||||||
|
"""
|
||||||
|
Apply multiple renames safely using a two-pass strategy to avoid
|
||||||
|
name collisions. Each item is a tuple (src_path, dst_path).
|
||||||
|
"""
|
||||||
|
temp_suffix = ".renametemp"
|
||||||
|
planned = list(renames)
|
||||||
|
existing_dests = {dst for _, dst in planned}
|
||||||
|
|
||||||
|
# Pass 1: move all sources that would collide to temporary names
|
||||||
|
temps: Dict[Path, Path] = {}
|
||||||
|
for src, dst in planned:
|
||||||
|
if not src.exists():
|
||||||
|
continue
|
||||||
|
if src.name == dst.name:
|
||||||
|
continue
|
||||||
|
# If destination exists or another source will become destination, use temp
|
||||||
|
if dst.exists() or dst in existing_dests:
|
||||||
|
tmp = src.with_suffix(src.suffix + temp_suffix)
|
||||||
|
# Ensure unique temp
|
||||||
|
i = 0
|
||||||
|
while tmp.exists():
|
||||||
|
i += 1
|
||||||
|
tmp = src.with_name(src.name + f".{i}" + temp_suffix)
|
||||||
|
src.rename(tmp)
|
||||||
|
temps[tmp] = dst
|
||||||
|
else:
|
||||||
|
# direct rename safe
|
||||||
|
src.rename(dst)
|
||||||
|
|
||||||
|
# Pass 2: move all temp files to their final destinations
|
||||||
|
for tmp, dst in temps.items():
|
||||||
|
if not tmp.exists():
|
||||||
|
continue
|
||||||
|
if dst.exists():
|
||||||
|
dst.unlink()
|
||||||
|
tmp.rename(dst)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from ..database.db import Database
|
||||||
|
from ..models import PlaylistItem
|
||||||
|
from ..scanner.playlist_scanner import PlaylistScanner
|
||||||
|
from ..sync.diff_engine import DiffEngine
|
||||||
|
from ..sync.filesystem import list_files
|
||||||
|
from ..utils.naming import make_filename, sanitize_title
|
||||||
|
from ..utils.yt import extract_playlist_id
|
||||||
|
|
||||||
|
|
||||||
|
class SyncService:
|
||||||
|
def __init__(self, db: Database) -> None:
|
||||||
|
self.db = db
|
||||||
|
self.scanner = PlaylistScanner()
|
||||||
|
self.diff = DiffEngine()
|
||||||
|
|
||||||
|
def _mode_to_extensions(self, mode: str) -> list[str]:
|
||||||
|
if mode == "audio":
|
||||||
|
return [".mp3"]
|
||||||
|
if mode == "video":
|
||||||
|
return [".mp4"]
|
||||||
|
if mode == "both":
|
||||||
|
return [".mp3", ".mp4"]
|
||||||
|
return [".mp3"]
|
||||||
|
|
||||||
|
def sync_from_config(self, playlist_cfg: dict) -> List[dict]:
|
||||||
|
url: str = playlist_cfg.get("url")
|
||||||
|
mode: str = playlist_cfg.get("download_mode", "audio")
|
||||||
|
save_path = Path(playlist_cfg.get("save_path", "./downloads")).resolve()
|
||||||
|
save_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
playlist_id = extract_playlist_id(url) or url
|
||||||
|
items = self.scanner.scan(url, playlist_id)
|
||||||
|
|
||||||
|
sanitized: List[PlaylistItem] = []
|
||||||
|
for it in items:
|
||||||
|
safe_title = sanitize_title(it.title, it.video_id)
|
||||||
|
sanitized.append(
|
||||||
|
PlaylistItem(
|
||||||
|
playlist_id=it.playlist_id,
|
||||||
|
video_id=it.video_id,
|
||||||
|
title=safe_title,
|
||||||
|
playlist_index=it.playlist_index,
|
||||||
|
local_filename=None,
|
||||||
|
downloaded=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
(
|
||||||
|
it.playlist_id,
|
||||||
|
it.video_id,
|
||||||
|
it.title,
|
||||||
|
it.playlist_index,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
for it in sanitized
|
||||||
|
]
|
||||||
|
self.db.upsert_playlist_items(rows)
|
||||||
|
|
||||||
|
db_index_rows = self.db.get_items_index(playlist_id)
|
||||||
|
db_index: dict[str, PlaylistItem] = {}
|
||||||
|
for vid, row in db_index_rows.items():
|
||||||
|
db_index[vid] = PlaylistItem(
|
||||||
|
playlist_id=row["playlist_id"],
|
||||||
|
video_id=row["video_id"],
|
||||||
|
title=row["title"],
|
||||||
|
playlist_index=row["playlist_index"],
|
||||||
|
local_filename=row["local_filename"],
|
||||||
|
downloaded=bool(row["downloaded"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
exts = self._mode_to_extensions(mode)
|
||||||
|
merged_actions = []
|
||||||
|
for ext in exts:
|
||||||
|
mode_dir = "audio" if ext == ".mp3" else "video"
|
||||||
|
fs_root = (save_path / mode_dir)
|
||||||
|
fs_entries = list_files(fs_root, [ext])
|
||||||
|
actions = self.diff.compute_actions(sanitized, db_index, fs_entries, ext)
|
||||||
|
merged_actions.extend(actions)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": a.type,
|
||||||
|
"video_id": a.item.video_id if a.item else None,
|
||||||
|
"from_name": a.from_name,
|
||||||
|
"to_name": a.to_name,
|
||||||
|
}
|
||||||
|
for a in merged_actions
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Utility helpers for naming, parsing, etc."""
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
ILLEGAL_CHARS = '<>:"/\\|?*'
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_title(title: str, fallback: str) -> str:
|
||||||
|
table = str.maketrans({c: "-" for c in ILLEGAL_CHARS})
|
||||||
|
safe = (title or "").translate(table).strip()
|
||||||
|
return safe if safe else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def make_filename(index: int, title: str, ext: str, width: int = 4) -> str:
|
||||||
|
return f"{index:0{width}d} - {title}{ext}"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
|
||||||
|
def extract_playlist_id(url: str) -> str | None:
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
if "list" in qs and qs.get("list"):
|
||||||
|
return qs.get("list", [None])[0]
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
"""
|
||||||
|
Entry point for the new backend (no GUI). For now, this only verifies
|
||||||
|
that configuration and database setup work. Future iterations will wire
|
||||||
|
up scanner, diff engine, queue, and scheduler.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .config.settings import Settings
|
||||||
|
from .core.database.db import Database
|
||||||
|
from .core.sync.service import SyncService
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap(db_path: Path | None = None) -> None:
|
||||||
|
settings = Settings()
|
||||||
|
db = Database((db_path or Path("app/data/app.db")).resolve())
|
||||||
|
service = SyncService(db)
|
||||||
|
|
||||||
|
# Iterate configured playlists and compute actions (no execution yet)
|
||||||
|
for pl in settings.playlists:
|
||||||
|
try:
|
||||||
|
actions = service.sync_from_config(pl)
|
||||||
|
# For now, just print summary for visibility during development
|
||||||
|
print(f"Computed {len(actions)} actions for playlist: {pl.get('url')}")
|
||||||
|
except Exception as exc: # keep bootstrap resilient during early dev
|
||||||
|
print(f"Failed to sync playlist {pl.get('url')}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
bootstrap()
|
||||||
@@ -11,7 +11,7 @@ import sys
|
|||||||
import shutil
|
import shutil
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from src.downloader import PlaylistDownloader
|
from src.old.downloader import PlaylistDownloader
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
# Make imports robust when running the script directly from different working directories.
|
# Make imports robust when running the script directly from different working directories.
|
||||||
# Ensure the repository root is on sys.path so the script can import `src`.
|
# Ensure the repository root is on sys.path so the script can import `src`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from src.manager import PlaylistManager
|
from src.old.manager import PlaylistManager
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import logging
|
|||||||
import subprocess
|
import subprocess
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import src.cli as cli_mod
|
import src.old.cli as cli_mod
|
||||||
|
|
||||||
|
|
||||||
class DummyCompleted(SimpleNamespace):
|
class DummyCompleted(SimpleNamespace):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from src.config import ConfigLoader
|
from src.old.config import ConfigLoader
|
||||||
|
|
||||||
|
|
||||||
def test_config_loader_reads_properties(tmp_path, monkeypatch):
|
def test_config_loader_reads_properties(tmp_path, monkeypatch):
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from src.downloader import PlaylistDownloader
|
from src.old.downloader import PlaylistDownloader
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.downloader import PlaylistDownloader
|
from src.old.downloader import PlaylistDownloader
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import json
|
|||||||
import subprocess
|
import subprocess
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from src.downloader import PlaylistDownloader
|
from src.old.downloader import PlaylistDownloader
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
from src.manager import PlaylistManager
|
from src.old.manager import PlaylistManager
|
||||||
|
|
||||||
|
|
||||||
def test_manager_warns_and_sleeps(monkeypatch, caplog):
|
def test_manager_warns_and_sleeps(monkeypatch, caplog):
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
from src.manager import PlaylistManager
|
from src.old.manager import PlaylistManager
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from src.downloader import PlaylistDownloader
|
from src.old.downloader import PlaylistDownloader
|
||||||
from tests.dummy_config import DummyConfig
|
from tests.dummy_config import DummyConfig
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user