mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-03 04:23:59 +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
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:${VERSION}" >> $GITHUB_ENV
|
||||
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytpld:latest" >> $GITHUB_ENV
|
||||
echo "RELEASE_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:${VERSION}" >> $GITHUB_ENV
|
||||
echo "LATEST_IMAGE=ghcr.io/${GITHUB_ACTOR}/ytplst:latest" >> $GITHUB_ENV
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
@@ -248,9 +248,9 @@ jobs:
|
||||
- name: Push Docker images
|
||||
run: |
|
||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image.tar"
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:${{ steps.version.outputs.version }}
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:${{ steps.version.outputs.version }}
|
||||
docker load -i "${{ github.workspace }}/artifacts/docker-images/docker-image-latest.tar"
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytpld:latest
|
||||
docker push ghcr.io/${GITHUB_ACTOR}/ytplst:latest
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v3
|
||||
|
||||
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
ytpld:
|
||||
image: git.darkzoul.org/dark_zoul/ytpld:latest
|
||||
container_name: ytpld
|
||||
ytplst:
|
||||
image: git.darkzoul.org/dark_zoul/ytplst:latest
|
||||
container_name: ytplst
|
||||
restart: no
|
||||
volumes:
|
||||
- /path/to/downloads:/app/downloads
|
||||
@@ -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"
|
||||
|
||||
[project]
|
||||
name = "ytpld"
|
||||
version = "v1.1.1"
|
||||
description = "YouTube playlist downloader"
|
||||
name = "ytplst"
|
||||
version = "1.1.1"
|
||||
description = "YouTube playlist Sync Thing"
|
||||
readme = "README.md"
|
||||
authors = [ { name = "Dark_Zoul" } ]
|
||||
license = { file = "LICENSE" }
|
||||
keywords = ["youtube", "yt-dlp", "playlist", "downloader"]
|
||||
keywords = ["youtube", "yt-dlp", "playlist", "sync"]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"yt-dlp>=2026.3.17",
|
||||
]
|
||||
[project.optional-dependencies]
|
||||
gui = [
|
||||
"PySide6"
|
||||
]
|
||||
dev = [
|
||||
"pytest",
|
||||
"ruff",
|
||||
"black"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Home" = "https://git.darkzoul.org/dark_zoul/youtube-playlist-downloader"
|
||||
Home = "https://github.com/darkzoul5/YoutubePlaylistSyncThing"
|
||||
|
||||
[project.scripts]
|
||||
ytplst = "ytplst.main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
include = ["*"]
|
||||
include = ["ytplst*"]
|
||||
@@ -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 time
|
||||
from pathlib import Path
|
||||
from src.downloader import PlaylistDownloader
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
# Make imports robust when running the script directly from different working directories.
|
||||
# Ensure the repository root is on sys.path so the script can import `src`.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from src.manager import PlaylistManager
|
||||
from src.old.manager import PlaylistManager
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
import src.cli as cli_mod
|
||||
import src.old.cli as cli_mod
|
||||
|
||||
|
||||
class DummyCompleted(SimpleNamespace):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
|
||||
from src.config import ConfigLoader
|
||||
from src.old.config import ConfigLoader
|
||||
|
||||
|
||||
def test_config_loader_reads_properties(tmp_path, monkeypatch):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
from src.downloader import PlaylistDownloader
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.downloader import PlaylistDownloader
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
import subprocess
|
||||
from types import SimpleNamespace
|
||||
|
||||
from src.downloader import PlaylistDownloader
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
from tests.dummy_config import DummyConfig
|
||||
from src.manager import PlaylistManager
|
||||
from src.old.manager import PlaylistManager
|
||||
|
||||
|
||||
def test_manager_warns_and_sleeps(monkeypatch, caplog):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from src.manager import PlaylistManager
|
||||
from src.old.manager import PlaylistManager
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
from src.downloader import PlaylistDownloader
|
||||
from src.old.downloader import PlaylistDownloader
|
||||
from tests.dummy_config import DummyConfig
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user