1
0
mirror of https://github.com/darkzoul5/YoutubePlaylistSync.git synced 2026-07-03 04:23:59 +03:00

7 Commits

Author SHA1 Message Date
dark_zoul e5ad786bcf feat(backend): Implemented executor, safe renames, recycle deletes, and real yt-dlp downloads.
Extended service to compute actions for audio, video, and both.
2026-05-15 14:32:48 +03:00
dark_zoul abd3c2ed62 feat(backend): scaffold state-based sync foundation (no GUI)
Add core scanner, diff engine, SQLite DB, queue, events, scheduler, utils
Wire settings + bootstrap to compute actions;
2026-05-15 11:48:36 +03:00
dark_zoul 6d8649ac2d refactor: move docker files to /docker folder;
remove old buid workflow;
2026-05-15 11:34:43 +03:00
dark_zoul 658def3d58 fix: add missing fields to pyproject.toml 2026-05-15 11:27:09 +03:00
dark_zoul 0ab96e4399 start work on project refactor;
create file structure
move old code to /src/old
2026-05-15 10:52:10 +03:00
dark_zoul 0cea4cfcb8 fix plan 2026-05-15 10:06:59 +03:00
dark_zoul 04a7367d19 chore: move all plans to plans folder; add app conversion plan 2026-05-15 09:52:09 +03:00
48 changed files with 1591 additions and 321 deletions
-298
View File
@@ -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
+4 -4
View File
@@ -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
+1
View File
@@ -1,4 +1,5 @@
{
"python.terminal.activateEnvironment": true,
"python.testing.pytestArgs": [
"tests"
],
View File
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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*"]
+10
View File
@@ -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",
]
+1
View File
@@ -0,0 +1 @@
"""Config loader for the new backend (separate from legacy)."""
+33
View File
@@ -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", []))
+10
View File
@@ -0,0 +1,10 @@
"""Core backend modules (scanner, sync, download, db, scheduler, events)."""
__all__ = [
"scanner",
"sync",
"download",
"database",
"scheduler",
"events",
]
+1
View File
@@ -0,0 +1 @@
"""Database helpers (SQLite)."""
+63
View File
@@ -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()}
+66
View File
@@ -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)
+57
View File
@@ -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)
+9
View File
@@ -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)
+21
View File
@@ -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)
+56
View File
@@ -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
+54
View File
@@ -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
+20
View File
@@ -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.
+47
View File
@@ -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
+100
View File
@@ -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()
+17
View File
@@ -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
+43
View File
@@ -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)
+96
View File
@@ -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
]
+1
View File
@@ -0,0 +1 @@
"""Utility helpers for naming, parsing, etc."""
+16
View File
@@ -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}"
+14
View File
@@ -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
+32
View File
@@ -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()
View File
View File
+1 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import logging
from src.manager import PlaylistManager
from src.old.manager import PlaylistManager
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
import logging
from src.manager import PlaylistManager
from src.old.manager import PlaylistManager
from tests.dummy_config import DummyConfig
+1 -1
View File
@@ -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