"""Downloads service — shared validation and routing for artifact downloads.""" import inspect from collections.abc import Awaitable, Callable from pathlib import Path from typing import Any, cast from ..core.client import NotebookLMClient from ..core.errors import ArtifactDownloadError from ._compat import TypedDict from .errors import ServiceError, ValidationError VALID_ARTIFACT_TYPES = ( "audio", "video", "report", "mind_map", "slide_deck", "infographic", "data_table", "quiz", "flashcards", ) VALID_OUTPUT_FORMATS = ("json", "markdown", "html") # Types that support async streaming downloads with progress callbacks STREAMING_TYPES = ("audio", "video", "slide_deck", "infographic") # Types that support output_format (json/markdown/html) INTERACTIVE_TYPES = ("quiz", "flashcards") # Extension map per artifact type (used for default filenames) DEFAULT_EXTENSIONS = { "audio": "m4a", "video": "mp4", "report": "md", "mind_map": "json", "slide_deck": "pdf", "infographic": "png", "data_table": "csv", "quiz": "json", # varies by format "flashcards": "json", # varies by format } # Extension map for output formats (quiz/flashcards) FORMAT_EXTENSIONS = { "json": "json", "markdown": "md", "html": "html", } class DownloadResult(TypedDict): """Result of a download operation.""" artifact_type: str path: str # Directories that are always blocked as download targets, regardless of platform. _BLOCKED_DIRS = { ".ssh", ".gnupg", ".claude", ".config", ".aws", ".kube", } _MISMATCHED_AUDIO_EXTENSIONS = {".mp3", ".wav", ".ogg", ".flac", ".aiff", ".wma"} def validate_audio_extension(output_path: str) -> None: """Reject output extensions that don't match NotebookLM's AAC-in-MP4 audio format. NotebookLM Studio delivers audio as AAC inside an MP4/M4A container. Writing that stream to a `.mp3` (or other incompatible) extension produces a file whose bytes don't match the extension, breaking downstream tools. Raises ValidationError with a helpful message and ffmpeg workaround. """ suffix = Path(output_path).suffix.lower() if suffix in _MISMATCHED_AUDIO_EXTENSIONS: raise ValidationError( f"NotebookLM delivers AAC audio in an MP4 container; " f"cannot honor '{suffix}' suffix.\n" f"Re-run with a .m4a or .mp4 suffix, or transcode with ffmpeg:\n" f" nlm download audio -o raw.m4a\n" f" ffmpeg -i raw.m4a -acodec libmp3lame -q:a 2 podcast.mp3", ) def validate_output_path(output_path: str) -> None: """Validate that output_path is safe and does not escape to sensitive locations. Raises ValidationError if the path resolves to a dangerous location. """ resolved = Path(output_path).expanduser().resolve() # Block writes into sensitive dotfile directories for part in resolved.parts: if part in _BLOCKED_DIRS: raise ValidationError( f"Refusing to write to sensitive directory: {resolved}. " f"Choose a different output path." ) # Block overwriting common sensitive files _sensitive_files = { ".bashrc", ".zshrc", ".profile", ".bash_profile", ".gitconfig", "authorized_keys", "known_hosts", "id_rsa", "id_ed25519", } if resolved.name in _sensitive_files: raise ValidationError( f"Refusing to overwrite sensitive file: {resolved.name}. " f"Choose a different output path." ) def validate_artifact_type(artifact_type: str) -> None: """Validate artifact type. Raises ValidationError if invalid.""" if artifact_type not in VALID_ARTIFACT_TYPES: raise ValidationError( f"Unknown artifact type '{artifact_type}'. " f"Valid types: {', '.join(VALID_ARTIFACT_TYPES)}", ) def validate_output_format(output_format: str) -> None: """Validate output format for interactive types. Raises ValidationError if invalid.""" if output_format not in VALID_OUTPUT_FORMATS: raise ValidationError( f"Invalid output format '{output_format}'. " f"Valid formats: {', '.join(VALID_OUTPUT_FORMATS)}", ) def get_default_extension(artifact_type: str, output_format: str = "json") -> str: """Get default file extension for an artifact type. For interactive types (quiz/flashcards), depends on output_format. """ if artifact_type in INTERACTIVE_TYPES: return FORMAT_EXTENSIONS.get(output_format, "json") return DEFAULT_EXTENSIONS.get(artifact_type, "bin") def download_sync( client: NotebookLMClient, notebook_id: str, artifact_type: str, output_path: str, artifact_id: str | None = None, output_format: str = "json", ) -> DownloadResult: """Download a non-streaming artifact synchronously. For: report, mind_map, data_table, quiz, flashcards. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID artifact_type: Type of artifact output_path: Path to save file artifact_id: Specific artifact ID (optional) output_format: For quiz/flashcards: json|markdown|html Returns: DownloadResult with artifact_type and path Raises: ValidationError: If artifact_type or output_format is invalid ServiceError: If the download fails """ validate_artifact_type(artifact_type) validate_output_path(output_path) if artifact_type == "audio": validate_audio_extension(output_path) if artifact_type in INTERACTIVE_TYPES: validate_output_format(output_format) try: saved_path = _dispatch_sync( client, notebook_id, artifact_type, output_path, artifact_id, output_format, ) except (ValidationError, ServiceError): raise except Exception as e: raise ServiceError( f"Failed to download {artifact_type}: {e}", user_message=f"Download failed for {artifact_type}.", ) from e if not saved_path: raise ServiceError( f"Download returned no path for {artifact_type}", user_message=f"{artifact_type} is not ready or does not exist.", ) return {"artifact_type": artifact_type, "path": saved_path} async def download_async( client: NotebookLMClient, notebook_id: str, artifact_type: str, output_path: str, artifact_id: str | None = None, output_format: str = "json", progress_callback: Callable[[int, int], None] | None = None, slide_deck_format: str = "pdf", ) -> DownloadResult: """Download a streaming artifact asynchronously. For: audio, video, slide_deck, infographic, quiz, flashcards. Args: client: Authenticated NotebookLM client notebook_id: Notebook UUID artifact_type: Type of artifact output_path: Path to save file artifact_id: Specific artifact ID (optional) output_format: For quiz/flashcards: json|markdown|html progress_callback: Called with (current, total) for progress tracking slide_deck_format: For slide_deck only: "pdf" (default) or "pptx" Returns: DownloadResult with artifact_type and path Raises: ValidationError: If artifact_type or output_format is invalid ServiceError: If the download fails """ validate_artifact_type(artifact_type) validate_output_path(output_path) if artifact_type == "audio": validate_audio_extension(output_path) if artifact_type in INTERACTIVE_TYPES: validate_output_format(output_format) try: saved_path = await _dispatch_async( client, notebook_id, artifact_type, output_path, artifact_id, output_format, progress_callback, slide_deck_format=slide_deck_format, ) except (ValidationError, ServiceError): raise except ArtifactDownloadError as e: if artifact_type == "audio" and "still propagating" in e.details: raise ServiceError( f"Failed to download {artifact_type}: {e}", user_message=( "Audio is complete, but its media download URL is still propagating. " "Try again in a few minutes." ), ) from e raise ServiceError( f"Failed to download {artifact_type}: {e}", user_message=f"Download failed for {artifact_type}.", ) from e except Exception as e: raise ServiceError( f"Failed to download {artifact_type}: {e}", user_message=f"Download failed for {artifact_type}.", ) from e if not saved_path: raise ServiceError( f"Download returned no path for {artifact_type}", user_message=f"{artifact_type} is not ready or does not exist.", ) return {"artifact_type": artifact_type, "path": saved_path} def _dispatch_sync( client: NotebookLMClient, notebook_id: str, artifact_type: str, output_path: str, artifact_id: str | None, output_format: str, ) -> str: """Route to the correct synchronous client method.""" if artifact_type == "report": return client.download_report(notebook_id, output_path, artifact_id) elif artifact_type == "mind_map": return client.download_mind_map(notebook_id, output_path, artifact_id) elif artifact_type == "data_table": return client.download_data_table(notebook_id, output_path, artifact_id) else: raise ValidationError( f"Artifact type '{artifact_type}' requires async download. " f"Use download_async() instead.", ) async def _resolve_download_result(result: str | Awaitable[str]) -> str: """Await async download results but also accept synchronous implementations.""" if inspect.isawaitable(result): return await result return result def _get_download_method( client: NotebookLMClient, async_name: str, sync_name: str, ) -> Callable[..., Any]: """Prefer explicit async client aliases when the concrete client class provides them.""" if getattr(type(client), async_name, None) is not None: return cast(Callable[..., Any], getattr(client, async_name)) return cast(Callable[..., Any], getattr(client, sync_name)) async def _dispatch_async( client: NotebookLMClient, notebook_id: str, artifact_type: str, output_path: str, artifact_id: str | None, output_format: str, progress_callback: Callable[[int, int], None] | None, slide_deck_format: str = "pdf", ) -> str: """Route to the correct async client method.""" # Non-streaming types (sync client methods callable from async context) if artifact_type == "report": return await _resolve_download_result( client.download_report(notebook_id, output_path, artifact_id) ) elif artifact_type == "mind_map": return await _resolve_download_result( client.download_mind_map(notebook_id, output_path, artifact_id) ) elif artifact_type == "data_table": return await _resolve_download_result( client.download_data_table(notebook_id, output_path, artifact_id) ) # Streaming types (async client methods) elif artifact_type == "audio": download_audio = _get_download_method(client, "download_audio_async", "download_audio") return await _resolve_download_result( download_audio( notebook_id, output_path, artifact_id, progress_callback=progress_callback, ) ) elif artifact_type == "video": download_video = _get_download_method(client, "download_video_async", "download_video") return await _resolve_download_result( download_video( notebook_id, output_path, artifact_id, progress_callback=progress_callback, ) ) elif artifact_type == "slide_deck": download_slide_deck = _get_download_method( client, "download_slide_deck_async", "download_slide_deck" ) return await _resolve_download_result( download_slide_deck( notebook_id, output_path, artifact_id, progress_callback=progress_callback, file_format=slide_deck_format, ) ) elif artifact_type == "infographic": download_infographic = _get_download_method( client, "download_infographic_async", "download_infographic" ) return await _resolve_download_result( download_infographic( notebook_id, output_path, artifact_id, progress_callback=progress_callback, ) ) elif artifact_type == "quiz": download_quiz = _get_download_method(client, "download_quiz_async", "download_quiz") return await _resolve_download_result( download_quiz( notebook_id, output_path, artifact_id, output_format, ) ) elif artifact_type == "flashcards": download_flashcards = _get_download_method( client, "download_flashcards_async", "download_flashcards" ) return await _resolve_download_result( download_flashcards( notebook_id, output_path, artifact_id, output_format, ) ) else: raise ValidationError( f"Artifact type '{artifact_type}' is not supported for async download.", )