diff --git a/.vscode/settings.json b/.vscode/settings.json index a133289..2f11783 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "python.terminal.activateEnvironment": true, "python.testing.pytestArgs": [ "tests" ], diff --git a/src/app/core/models.py b/src/app/core/models.py index 1d9f14d..d515ff0 100644 --- a/src/app/core/models.py +++ b/src/app/core/models.py @@ -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 diff --git a/src/app/core/sync/diff_engine.py b/src/app/core/sync/diff_engine.py index 89474a7..fdd5763 100644 --- a/src/app/core/sync/diff_engine.py +++ b/src/app/core/sync/diff_engine.py @@ -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: diff --git a/src/app/core/sync/filesystem.py b/src/app/core/sync/filesystem.py new file mode 100644 index 0000000..d3c2a54 --- /dev/null +++ b/src/app/core/sync/filesystem.py @@ -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 diff --git a/src/app/core/sync/service.py b/src/app/core/sync/service.py new file mode 100644 index 0000000..4b251d0 --- /dev/null +++ b/src/app/core/sync/service.py @@ -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 + ] diff --git a/src/app/core/utils/__init__.py b/src/app/core/utils/__init__.py new file mode 100644 index 0000000..e79e960 --- /dev/null +++ b/src/app/core/utils/__init__.py @@ -0,0 +1 @@ +"""Utility helpers for naming, parsing, etc.""" diff --git a/src/app/core/utils/naming.py b/src/app/core/utils/naming.py new file mode 100644 index 0000000..a699ebb --- /dev/null +++ b/src/app/core/utils/naming.py @@ -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}" diff --git a/src/app/core/utils/yt.py b/src/app/core/utils/yt.py new file mode 100644 index 0000000..1dc9053 --- /dev/null +++ b/src/app/core/utils/yt.py @@ -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 diff --git a/src/app/main.py b/src/app/main.py index cad5876..0c51dc4 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -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__":