diff --git a/docs/api/utils.md b/docs/api/utils.md index ba400c9..3e29a14 100644 --- a/docs/api/utils.md +++ b/docs/api/utils.md @@ -6,6 +6,8 @@ Pure helper functions for GoPro media selection, naming, and I/O. ::: gopro_api.utils.select_video_variation +::: gopro_api.utils.extension_from_url + ::: gopro_api.utils.get_file_name ::: gopro_api.utils.pull_assets_for_response diff --git a/gopro_api/utils.py b/gopro_api/utils.py index f3dab11..25665ee 100644 --- a/gopro_api/utils.py +++ b/gopro_api/utils.py @@ -2,6 +2,8 @@ from __future__ import annotations +from urllib.parse import parse_qs, unquote, urlparse + from gopro_api.api.models import ( GoProMediaDownloadFile, GoProMediaDownloadResponse, @@ -15,6 +17,7 @@ "DownloadAsset", "is_video_filename", "select_video_variation", + "extension_from_url", "get_file_name", "pull_assets_for_response", "write_bytes", @@ -76,20 +79,65 @@ def score(variation: GoProMediaDownloadVariation) -> int: return max(tied, key=lambda variation: (variation.height, variation.width)) -def get_file_name(root_name: str, item_number: int) -> str: +def extension_from_url(url: str) -> str | None: + """Return a file extension from a CDN download URL (no leading dot). + + Prefers the ``filename`` in ``response-content-disposition`` when present, + otherwise the URL path suffix. Library filenames can disagree with the real + asset (for example MultiClipEdit rows named ``*.json`` whose baked source is + ``*.mp4``). + + Args: + url: Absolute HTTPS URL, optionally with a query string. + + Returns: + Extension such as ``mp4`` or ``jpg``, or ``None`` if none is found. + """ + parsed = urlparse(url) + disposition_values = parse_qs(parsed.query).get("response-content-disposition") + if disposition_values: + disposition = unquote(disposition_values[0]) + for part in disposition.split(";"): + part = part.strip() + if part.lower().startswith("filename="): + filename = part.split("=", 1)[1].strip().strip("\"'") + _, _, ext = filename.rpartition(".") + if ext and "/" not in ext: + return ext + _, _, ext = parsed.path.rpartition(".") + if not ext or "/" in ext: + return None + return ext + + +def get_file_name( + root_name: str, + item_number: int, + *, + extension: str | None = None, +) -> str: """Build a part filename by inserting a zero-padded index before the extension. Example: ``get_file_name("GX010001.MP4", 2)`` → ``"GX010001002.MP4"``. + When ``extension`` is set it replaces the suffix from ``root_name`` (useful + when the library name is ``.json`` but the CDN asset is ``.mp4``). Args: root_name: Original media filename including extension. item_number: Non-negative part index (three-digit zero padding). + extension: Optional override for the file format (no leading dot). Returns: Derived filename string. """ - media_name, _, file_format = root_name.rpartition(".") - return f"{media_name}{str(item_number).zfill(3)}.{file_format}" + media_name, sep, file_format = root_name.rpartition(".") + if not sep: + media_name = root_name + file_format = "" + fmt = extension if extension is not None else file_format + if not fmt: + return f"{media_name}{str(item_number).zfill(3)}" + return f"{media_name}{str(item_number).zfill(3)}.{fmt}" def pull_assets_for_response( @@ -104,6 +152,9 @@ def pull_assets_for_response( Non-video: returns every file in ``_embedded.files`` in enumeration order (no ``available`` filtering, preserving CLI behaviour for burst sets). + Output extensions come from each asset URL (content-disposition or path), + not from the library filename alone. + Args: result: Parsed download-metadata response for one media id. target_height: Optional preferred video height for variation scoring. @@ -122,9 +173,14 @@ def pull_assets_for_response( target_height=target_height, target_width=target_width, ) - return {get_file_name(name, 0): chosen} - - return {get_file_name(name, idx): f for idx, f in enumerate(result.embedded.files)} + return { + get_file_name(name, 0, extension=extension_from_url(chosen.url)): chosen + } + + return { + get_file_name(name, idx, extension=extension_from_url(f.url)): f + for idx, f in enumerate(result.embedded.files) + } def write_bytes(path: str, data: bytes) -> None: diff --git a/pyproject.toml b/pyproject.toml index 0cd74de..c927331 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "gopro-api" -version = "0.0.10" +version = "0.0.11" description = "Unofficial Python client for the GoPro cloud API (api.gopro.com): sync and async clients, Pydantic models, and a CLI." readme = "README.md" license = { file = "LICENSE" } diff --git a/uv.lock b/uv.lock index dfaf23b..1e18c23 100644 --- a/uv.lock +++ b/uv.lock @@ -494,7 +494,7 @@ wheels = [ [[package]] name = "gopro-api" -version = "0.0.10" +version = "0.0.11" source = { editable = "." } dependencies = [ { name = "aiohttp" },