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

feat(backend): Implemented executor, safe renames, recycle deletes, and real yt-dlp downloads.

Extended service to compute actions for audio, video, and both.
This commit is contained in:
2026-05-15 14:32:48 +03:00
parent abd3c2ed62
commit e5ad786bcf
5 changed files with 205 additions and 16 deletions
+43 -3
View File
@@ -18,9 +18,49 @@ class Downloader:
async def handle_job(self, job: DownloadJob): async def handle_job(self, job: DownloadJob):
try: try:
job.state = JobState.DOWNLOADING job.state = JobState.DOWNLOADING
# TODO: Implement actual download via yt-dlp Python API or subprocess await self._download(job)
# For now, mark as completed without side effects.
job.state = JobState.COMPLETED job.state = JobState.COMPLETED
except Exception as exc: # pragma: no cover - placeholder except Exception as exc: # pragma: no cover - environment dependent
job.state = JobState.FAILED job.state = JobState.FAILED
job.error = str(exc) 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)
+4 -1
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from pathlib import Path
from typing import Optional from typing import Optional
from ..models import PlaylistItem from ..models import PlaylistItem
@@ -21,7 +22,9 @@ class JobState(str, Enum):
@dataclass @dataclass
class DownloadJob: class DownloadJob:
item: PlaylistItem item: PlaylistItem
output_name: Optional[str] = None output_path: Optional[Path] = None
url: Optional[str] = None
mode: str = "audio" # audio|video
state: JobState = JobState.QUEUED state: JobState = JobState.QUEUED
error: Optional[str] = None error: Optional[str] = None
+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()
+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)
+15 -12
View File
@@ -19,12 +19,14 @@ class SyncService:
self.scanner = PlaylistScanner() self.scanner = PlaylistScanner()
self.diff = DiffEngine() self.diff = DiffEngine()
def _mode_to_extension(self, mode: str) -> str: def _mode_to_extensions(self, mode: str) -> list[str]:
if mode == "audio": if mode == "audio":
return ".mp3" return [".mp3"]
if mode == "video": if mode == "video":
return ".mp4" return [".mp4"]
return ".mp3" # default for MVP if mode == "both":
return [".mp3", ".mp4"]
return [".mp3"]
def sync_from_config(self, playlist_cfg: dict) -> List[dict]: def sync_from_config(self, playlist_cfg: dict) -> List[dict]:
url: str = playlist_cfg.get("url") url: str = playlist_cfg.get("url")
@@ -33,8 +35,6 @@ class SyncService:
save_path.mkdir(parents=True, exist_ok=True) save_path.mkdir(parents=True, exist_ok=True)
playlist_id = extract_playlist_id(url) or url playlist_id = extract_playlist_id(url) or url
ext = self._mode_to_extension(mode)
items = self.scanner.scan(url, playlist_id) items = self.scanner.scan(url, playlist_id)
sanitized: List[PlaylistItem] = [] sanitized: List[PlaylistItem] = []
@@ -76,11 +76,14 @@ class SyncService:
downloaded=bool(row["downloaded"]), downloaded=bool(row["downloaded"]),
) )
mode_dir = "audio" if ext == ".mp3" else "video" exts = self._mode_to_extensions(mode)
fs_root = (save_path / mode_dir) merged_actions = []
fs_entries = list_files(fs_root, [ext]) for ext in exts:
mode_dir = "audio" if ext == ".mp3" else "video"
actions = self.diff.compute_actions(sanitized, db_index, fs_entries, ext) 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 [ return [
{ {
@@ -89,5 +92,5 @@ class SyncService:
"from_name": a.from_name, "from_name": a.from_name,
"to_name": a.to_name, "to_name": a.to_name,
} }
for a in actions for a in merged_actions
] ]