from __future__ import annotations import tempfile import zipfile from datetime import datetime from pathlib import Path from typing import Iterable from uuid import uuid4 from .runtime import sanitize_path_component INTERNAL_BUNDLE_NAME_SEPARATOR = "--" def default_bundle_root() -> Path: root = Path(tempfile.gettempdir()) / "musicdl-catalogsync" / "bundles" root.mkdir(parents=True, exist_ok=True) return root def build_single_playlist_bundle_filename( *, platform: str, playlist_id: int, playlist_name: str, ) -> str: safe_platform = sanitize_path_component(str(platform or ""), "unknown") safe_name = sanitize_path_component(str(playlist_name or ""), f"playlist-{int(playlist_id)}") return f"playlist-{safe_platform}-{int(playlist_id)}-{safe_name}.zip" def build_multi_playlist_bundle_filename(*, created_at: datetime | None = None) -> str: now = created_at or datetime.now() return "playlists-export-" + now.strftime("%Y%m%d-%H%M%S") + ".zip" def bundle_download_filename(bundle_path_or_name: str | Path) -> str: filename = Path(bundle_path_or_name).name if INTERNAL_BUNDLE_NAME_SEPARATOR not in filename: return filename return filename.split(INTERNAL_BUNDLE_NAME_SEPARATOR, 1)[1] def resolve_bundle_download_path(bundle_root: Path, bundle_name: str) -> Path | None: normalized_name = str(bundle_name or "").strip() if not normalized_name: return None safe_name = sanitize_path_component(normalized_name, "") if not safe_name or safe_name != normalized_name: return None return Path(bundle_root) / f"{normalized_name}.zip" def create_single_playlist_bundle( *, playlist_dir: Path, bundle_root: Path, platform: str, playlist_id: int, playlist_name: str, ) -> Path: source_dir = Path(playlist_dir) if not source_dir.exists() or not source_dir.is_dir(): raise FileNotFoundError(f"playlist directory not found: {source_dir}") root = Path(bundle_root) root.mkdir(parents=True, exist_ok=True) bundle_path = root / build_single_playlist_bundle_filename( platform=platform, playlist_id=playlist_id, playlist_name=playlist_name, ) _write_zip_from_directories(bundle_path, [(source_dir, source_dir.name)]) return bundle_path def create_multi_playlist_bundle( *, playlist_dirs: Iterable[Path], bundle_root: Path, created_at: datetime | None = None, ) -> Path: resolved_dirs: list[Path] = [] for item in playlist_dirs: playlist_dir = Path(item) if not playlist_dir.exists() or not playlist_dir.is_dir(): raise FileNotFoundError(f"playlist directory not found: {playlist_dir}") resolved_dirs.append(playlist_dir) if not resolved_dirs: raise ValueError("playlist_dirs is required") root = Path(bundle_root) root.mkdir(parents=True, exist_ok=True) friendly_name = build_multi_playlist_bundle_filename(created_at=created_at) unique_storage_name = ( datetime.now().strftime("%Y%m%d%H%M%S%f") + "-" + uuid4().hex[:8] + INTERNAL_BUNDLE_NAME_SEPARATOR + friendly_name ) bundle_path = root / unique_storage_name _write_zip_from_directories( bundle_path, [(playlist_dir, f"playlists/{playlist_dir.name}") for playlist_dir in resolved_dirs], ) return bundle_path def _write_zip_from_directories(bundle_path: Path, directories: list[tuple[Path, str]]) -> None: if bundle_path.exists(): bundle_path.unlink() with zipfile.ZipFile(bundle_path, mode="w", compression=zipfile.ZIP_DEFLATED) as archive: for source_dir, zip_root in directories: for child in sorted(source_dir.rglob("*")): if not child.is_file(): continue relative_path = child.relative_to(source_dir).as_posix() archive.write(child, arcname=f"{zip_root}/{relative_path}")