mirror of
https://github.com/darkzoul5/YoutubePlaylistSync.git
synced 2026-07-01 19:47:01 +03:00
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;
This commit is contained in:
Vendored
+1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"python.terminal.activateEnvironment": true,
|
||||
"python.testing.pytestArgs": [
|
||||
"tests"
|
||||
],
|
||||
|
||||
@@ -48,3 +48,9 @@ class SyncAction:
|
||||
item: Optional[PlaylistItem] = None
|
||||
from_name: Optional[str] = None
|
||||
to_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemEntry:
|
||||
name: str
|
||||
path: Path
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, List, Mapping, Sequence
|
||||
|
||||
from ..models import PlaylistItem, SyncAction, SyncActionType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilesystemEntry:
|
||||
name: str
|
||||
path: Path
|
||||
from ..models import FilesystemEntry, PlaylistItem, SyncAction, SyncActionType
|
||||
|
||||
|
||||
class DiffEngine:
|
||||
|
||||
@@ -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,93 @@
|
||||
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_extension(self, mode: str) -> str:
|
||||
if mode == "audio":
|
||||
return ".mp3"
|
||||
if mode == "video":
|
||||
return ".mp4"
|
||||
return ".mp3" # default for MVP
|
||||
|
||||
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
|
||||
ext = self._mode_to_extension(mode)
|
||||
|
||||
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"]),
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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 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
|
||||
+11
-1
@@ -10,12 +10,22 @@ 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())
|
||||
_ = settings, db # silence linters for now
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user