gopro-api 0.0.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gopro_api/__init__.py +1 -0
- gopro_api/api/__init__.py +7 -0
- gopro_api/api/async_gopro.py +93 -0
- gopro_api/api/gopro.py +86 -0
- gopro_api/api/models.py +166 -0
- gopro_api/cli.py +339 -0
- gopro_api/config.py +9 -0
- gopro_api-0.0.6.dist-info/METADATA +261 -0
- gopro_api-0.0.6.dist-info/RECORD +13 -0
- gopro_api-0.0.6.dist-info/WHEEL +5 -0
- gopro_api-0.0.6.dist-info/entry_points.txt +2 -0
- gopro_api-0.0.6.dist-info/licenses/LICENSE +21 -0
- gopro_api-0.0.6.dist-info/top_level.txt +1 -0
gopro_api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Unofficial Python client for the GoPro cloud API (api.gopro.com)."""
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Asynchronous GoPro cloud API client (``aiohttp``)."""
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
|
|
5
|
+
from gopro_api.config import GP_ACCESS_TOKEN
|
|
6
|
+
from gopro_api.api.models import (
|
|
7
|
+
GoProMediaSearchParams,
|
|
8
|
+
GoProMediaDownloadResponse,
|
|
9
|
+
GoProMediaSearchResponse,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AsyncGoProAPI:
|
|
14
|
+
"""Async client for ``https://api.gopro.com`` (Quik / cloud library).
|
|
15
|
+
|
|
16
|
+
Use as an async context manager so an ``aiohttp.ClientSession`` is opened
|
|
17
|
+
and closed around ``search`` and ``download``. Pass ``access_token`` to
|
|
18
|
+
override ``gopro_api.config.GP_ACCESS_TOKEN``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, access_token: str | None = None, timeout: float = 10.0) -> None:
|
|
22
|
+
"""Create an async client.
|
|
23
|
+
|
|
24
|
+
``access_token``: cookie value; defaults to ``GP_ACCESS_TOKEN``.
|
|
25
|
+
``timeout``: total HTTP client timeout in seconds.
|
|
26
|
+
"""
|
|
27
|
+
self.access_token = access_token or GP_ACCESS_TOKEN
|
|
28
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
29
|
+
self._session: aiohttp.ClientSession | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def base_url(self) -> str:
|
|
33
|
+
"""API origin (``https://api.gopro.com``)."""
|
|
34
|
+
return "https://api.gopro.com"
|
|
35
|
+
|
|
36
|
+
def get_headers(self, accept: str) -> dict[str, str]:
|
|
37
|
+
"""Build ``Cookie`` and ``Accept`` headers for an API call."""
|
|
38
|
+
return {
|
|
39
|
+
"Cookie": "gp_access_token=" + self.access_token,
|
|
40
|
+
"Accept": accept,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async def __aenter__(self) -> "AsyncGoProAPI":
|
|
44
|
+
"""Open an ``aiohttp.ClientSession`` for the ``async with`` body."""
|
|
45
|
+
self._session = aiohttp.ClientSession(
|
|
46
|
+
base_url=self.base_url,
|
|
47
|
+
timeout=self._timeout,
|
|
48
|
+
)
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
52
|
+
"""Close the session."""
|
|
53
|
+
if self._session is not None:
|
|
54
|
+
await self._session.close()
|
|
55
|
+
self._session = None
|
|
56
|
+
|
|
57
|
+
def _session_or_raise(self) -> aiohttp.ClientSession:
|
|
58
|
+
"""Return the active session or raise if not inside ``async with``."""
|
|
59
|
+
session = self._session
|
|
60
|
+
if session is None:
|
|
61
|
+
msg = (
|
|
62
|
+
"Use AsyncGoProAPI as an async context manager: "
|
|
63
|
+
"async with AsyncGoProAPI() as api: ..."
|
|
64
|
+
)
|
|
65
|
+
raise RuntimeError(msg)
|
|
66
|
+
return session
|
|
67
|
+
|
|
68
|
+
async def download(self, media_id: str) -> GoProMediaDownloadResponse:
|
|
69
|
+
"""``GET /media/{media_id}/download`` — metadata and CDN URLs for files."""
|
|
70
|
+
headers = self.get_headers("application/vnd.gopro.jk.media+json; version=2.0.0")
|
|
71
|
+
session = self._session_or_raise()
|
|
72
|
+
async with session.get(
|
|
73
|
+
f"/media/{media_id}/download",
|
|
74
|
+
headers=headers,
|
|
75
|
+
) as response:
|
|
76
|
+
response.raise_for_status()
|
|
77
|
+
body = await response.text()
|
|
78
|
+
return GoProMediaDownloadResponse.model_validate_json(body)
|
|
79
|
+
|
|
80
|
+
async def search(self, params: GoProMediaSearchParams) -> GoProMediaSearchResponse:
|
|
81
|
+
"""``GET /media/search`` using ``params.model_dump()`` as query string."""
|
|
82
|
+
headers = self.get_headers(
|
|
83
|
+
"application/vnd.gopro.jk.media.search+json; version=2.0.0",
|
|
84
|
+
)
|
|
85
|
+
session = self._session_or_raise()
|
|
86
|
+
async with session.get(
|
|
87
|
+
"/media/search",
|
|
88
|
+
headers=headers,
|
|
89
|
+
params=params.model_dump(),
|
|
90
|
+
) as response:
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
body = await response.text()
|
|
93
|
+
return GoProMediaSearchResponse.model_validate_json(body)
|
gopro_api/api/gopro.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Synchronous GoPro cloud API client (``requests``)."""
|
|
2
|
+
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
from gopro_api.config import GP_ACCESS_TOKEN
|
|
6
|
+
from gopro_api.api.models import (
|
|
7
|
+
GoProMediaDownloadResponse,
|
|
8
|
+
GoProMediaSearchParams,
|
|
9
|
+
GoProMediaSearchResponse,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GoProAPI:
|
|
14
|
+
"""Synchronous client for ``https://api.gopro.com`` (Quik / cloud library).
|
|
15
|
+
|
|
16
|
+
Use as a context manager so a ``requests.Session`` is created and closed
|
|
17
|
+
around ``search`` and ``download``. Pass ``access_token`` to override
|
|
18
|
+
``gopro_api.config.GP_ACCESS_TOKEN``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, access_token: str | None = None, timeout: float = 10.0) -> None:
|
|
22
|
+
"""Create a sync client.
|
|
23
|
+
|
|
24
|
+
``access_token``: cookie value; defaults to ``GP_ACCESS_TOKEN``.
|
|
25
|
+
``timeout``: per-request timeout in seconds.
|
|
26
|
+
"""
|
|
27
|
+
self.access_token = access_token or GP_ACCESS_TOKEN
|
|
28
|
+
self._timeout = timeout
|
|
29
|
+
self._session: requests.Session | None = None
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def base_url(self) -> str:
|
|
33
|
+
"""API origin (``https://api.gopro.com``)."""
|
|
34
|
+
return "https://api.gopro.com"
|
|
35
|
+
|
|
36
|
+
def get_headers(self, accept: str) -> dict[str, str]:
|
|
37
|
+
"""Build ``Cookie`` and ``Accept`` headers for an API call."""
|
|
38
|
+
return {
|
|
39
|
+
"Cookie": "gp_access_token=" + self.access_token,
|
|
40
|
+
"Accept": accept,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
def __enter__(self) -> "GoProAPI":
|
|
44
|
+
"""Open a ``requests.Session`` for the duration of the ``with`` block."""
|
|
45
|
+
self._session = requests.Session()
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *exc: object) -> None:
|
|
49
|
+
"""Close the session."""
|
|
50
|
+
if self._session is not None:
|
|
51
|
+
self._session.close()
|
|
52
|
+
self._session = None
|
|
53
|
+
|
|
54
|
+
def _session_or_raise(self) -> requests.Session:
|
|
55
|
+
"""Return the active session or raise if used outside a context manager."""
|
|
56
|
+
if self._session is None:
|
|
57
|
+
msg = "Use GoProAPI as a context manager: with GoProAPI() as api: ..."
|
|
58
|
+
raise RuntimeError(msg)
|
|
59
|
+
return self._session
|
|
60
|
+
|
|
61
|
+
def download(self, media_id: str) -> GoProMediaDownloadResponse:
|
|
62
|
+
"""``GET /media/{media_id}/download`` — metadata and CDN URLs for files."""
|
|
63
|
+
headers = self.get_headers("application/vnd.gopro.jk.media+json; version=2.0.0")
|
|
64
|
+
session = self._session_or_raise()
|
|
65
|
+
response = session.get(
|
|
66
|
+
f"{self.base_url}/media/{media_id}/download",
|
|
67
|
+
headers=headers,
|
|
68
|
+
timeout=self._timeout,
|
|
69
|
+
)
|
|
70
|
+
response.raise_for_status()
|
|
71
|
+
return GoProMediaDownloadResponse.model_validate_json(response.text)
|
|
72
|
+
|
|
73
|
+
def search(self, params: GoProMediaSearchParams) -> GoProMediaSearchResponse:
|
|
74
|
+
"""``GET /media/search`` using ``params.model_dump()`` as query string."""
|
|
75
|
+
headers = self.get_headers(
|
|
76
|
+
"application/vnd.gopro.jk.media.search+json; version=2.0.0",
|
|
77
|
+
)
|
|
78
|
+
session = self._session_or_raise()
|
|
79
|
+
response = session.get(
|
|
80
|
+
f"{self.base_url}/media/search",
|
|
81
|
+
headers=headers,
|
|
82
|
+
params=params.model_dump(),
|
|
83
|
+
timeout=self._timeout,
|
|
84
|
+
)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
return GoProMediaSearchResponse.model_validate_json(response.text)
|
gopro_api/api/models.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Pydantic models for GoPro cloud media search and download JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any, List, Optional
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, model_serializer
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_PROCESSING_STATES: List[str] = [
|
|
12
|
+
"rendering",
|
|
13
|
+
"pretranscoding",
|
|
14
|
+
"transcoding",
|
|
15
|
+
"stabilizing",
|
|
16
|
+
"ready",
|
|
17
|
+
"failure",
|
|
18
|
+
]
|
|
19
|
+
DEFAULT_FIELDS: List[str] = ["id", "capturedate"]
|
|
20
|
+
DEFAULT_MEDIA_TYPES: List[str] = [
|
|
21
|
+
"Burst",
|
|
22
|
+
"BurstVideo",
|
|
23
|
+
"Continuous",
|
|
24
|
+
"LoopedVideo",
|
|
25
|
+
"Photo",
|
|
26
|
+
"TimeLapse",
|
|
27
|
+
"TimeLapseVideo",
|
|
28
|
+
"Video",
|
|
29
|
+
"MultiClipEdit",
|
|
30
|
+
"Edit",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CapturedRange(BaseModel):
|
|
35
|
+
"""Capture window for search; serialized to the API ``captured_range`` string."""
|
|
36
|
+
|
|
37
|
+
start: datetime
|
|
38
|
+
end: datetime
|
|
39
|
+
|
|
40
|
+
@model_serializer
|
|
41
|
+
def _serialize_captured_range(self) -> str:
|
|
42
|
+
"""Emit the ``captured_range`` query fragment expected by the API."""
|
|
43
|
+
return (
|
|
44
|
+
f"{self.start.isoformat()}T00:00:00.000Z,"
|
|
45
|
+
f"{self.end.isoformat()}T00:00:00.000Z"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GoProMediaSearchParams(BaseModel):
|
|
50
|
+
"""Query parameters for ``GET /media/search`` (lists as comma-separated values)."""
|
|
51
|
+
|
|
52
|
+
processing_states: List[str] = DEFAULT_PROCESSING_STATES
|
|
53
|
+
fields: List[str] = DEFAULT_FIELDS
|
|
54
|
+
type: List[str] = DEFAULT_MEDIA_TYPES
|
|
55
|
+
captured_range: CapturedRange = CapturedRange(
|
|
56
|
+
start=datetime.min,
|
|
57
|
+
end=datetime.max,
|
|
58
|
+
)
|
|
59
|
+
page: int = 1
|
|
60
|
+
per_page: int = 1
|
|
61
|
+
|
|
62
|
+
@field_serializer("processing_states", "fields", "type")
|
|
63
|
+
def _serialize_csv_lists(self, value: List[str]) -> str:
|
|
64
|
+
"""Join list fields into one comma-separated string for the query."""
|
|
65
|
+
return ",".join(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class GoProMediaSearchItem(BaseModel):
|
|
69
|
+
"""Single item in ``_embedded.media`` from a media search response."""
|
|
70
|
+
|
|
71
|
+
model_config = ConfigDict(extra="allow")
|
|
72
|
+
|
|
73
|
+
id: str
|
|
74
|
+
gopro_user_id: str
|
|
75
|
+
source_gumi: str
|
|
76
|
+
source_mgumi: Optional[str]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class GoProMediaSearchEmbedded(BaseModel):
|
|
80
|
+
"""``_embedded`` object on a media search response."""
|
|
81
|
+
|
|
82
|
+
model_config = ConfigDict(extra="allow")
|
|
83
|
+
|
|
84
|
+
media: List[GoProMediaSearchItem]
|
|
85
|
+
errors: List[Any] = []
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class GoProMediaSearchPages(BaseModel):
|
|
89
|
+
"""Pagination block ``_pages`` on a media search response."""
|
|
90
|
+
|
|
91
|
+
current_page: int
|
|
92
|
+
per_page: int
|
|
93
|
+
total_items: int
|
|
94
|
+
total_pages: int
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GoProMediaSearchResponse(BaseModel):
|
|
98
|
+
"""Top-level JSON body from ``GET /media/search``."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
101
|
+
|
|
102
|
+
embedded: GoProMediaSearchEmbedded = Field(alias="_embedded")
|
|
103
|
+
pages: GoProMediaSearchPages = Field(alias="_pages")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class GoProMediaDownloadFile(BaseModel):
|
|
107
|
+
"""One downloadable file under ``_embedded.files`` from download metadata."""
|
|
108
|
+
|
|
109
|
+
model_config = ConfigDict(extra="allow")
|
|
110
|
+
|
|
111
|
+
url: str
|
|
112
|
+
head: str
|
|
113
|
+
camera_position: str
|
|
114
|
+
item_number: int
|
|
115
|
+
width: int
|
|
116
|
+
height: int
|
|
117
|
+
orientation: int
|
|
118
|
+
available: bool
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class GoProMediaDownloadVariation(BaseModel):
|
|
122
|
+
"""Rendered size / quality variant in ``_embedded.variations``."""
|
|
123
|
+
|
|
124
|
+
model_config = ConfigDict(extra="allow")
|
|
125
|
+
|
|
126
|
+
url: str
|
|
127
|
+
head: str
|
|
128
|
+
width: int
|
|
129
|
+
height: int
|
|
130
|
+
label: str
|
|
131
|
+
type: str
|
|
132
|
+
quality: str
|
|
133
|
+
available: bool
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class GoProMediaDownloadSidecarFile(BaseModel):
|
|
137
|
+
"""Sidecar asset (e.g. zip) in ``_embedded.sidecar_files``."""
|
|
138
|
+
|
|
139
|
+
model_config = ConfigDict(extra="allow")
|
|
140
|
+
|
|
141
|
+
url: str
|
|
142
|
+
head: str
|
|
143
|
+
label: str
|
|
144
|
+
type: str
|
|
145
|
+
fps: int
|
|
146
|
+
available: bool
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class GoProMediaDownloadEmbedded(BaseModel):
|
|
150
|
+
"""``_embedded`` on a media download metadata response."""
|
|
151
|
+
|
|
152
|
+
model_config = ConfigDict(extra="allow")
|
|
153
|
+
|
|
154
|
+
files: List[GoProMediaDownloadFile]
|
|
155
|
+
variations: List[GoProMediaDownloadVariation]
|
|
156
|
+
sprites: List[Any]
|
|
157
|
+
sidecar_files: List[GoProMediaDownloadSidecarFile]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class GoProMediaDownloadResponse(BaseModel):
|
|
161
|
+
"""Top-level JSON body from ``GET /media/{id}/download``."""
|
|
162
|
+
|
|
163
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
164
|
+
|
|
165
|
+
filename: str
|
|
166
|
+
embedded: GoProMediaDownloadEmbedded = Field(alias="_embedded")
|
gopro_api/cli.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Command-line interface for gopro-api."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
from gopro_api.api import GoProAPI
|
|
17
|
+
from gopro_api.api.models import (
|
|
18
|
+
CapturedRange,
|
|
19
|
+
GoProMediaDownloadVariation,
|
|
20
|
+
GoProMediaSearchParams,
|
|
21
|
+
)
|
|
22
|
+
from gopro_api.config import GP_ACCESS_TOKEN
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _version() -> str:
|
|
26
|
+
try:
|
|
27
|
+
return version("gopro-api")
|
|
28
|
+
except PackageNotFoundError:
|
|
29
|
+
return "0.0.0"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _parse_dt(raw: str) -> datetime:
|
|
33
|
+
"""Accept YYYY-MM-DD or ISO datetime."""
|
|
34
|
+
raw = raw.strip()
|
|
35
|
+
if len(raw) == 10 and raw[4] == "-" and raw[7] == "-":
|
|
36
|
+
return datetime.fromisoformat(raw)
|
|
37
|
+
return datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_video_filename(filename: str) -> bool:
|
|
41
|
+
base = filename.rsplit(".", 1)
|
|
42
|
+
return len(base) == 2 and base[1].lower() == "mp4"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _positive_int(raw: str) -> int:
|
|
46
|
+
value = int(raw)
|
|
47
|
+
if value <= 0:
|
|
48
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _select_video_variation(
|
|
53
|
+
variations: list[GoProMediaDownloadVariation],
|
|
54
|
+
*,
|
|
55
|
+
target_height: int | None,
|
|
56
|
+
target_width: int | None,
|
|
57
|
+
) -> GoProMediaDownloadVariation:
|
|
58
|
+
"""Pick one variation: closest to target size, or tallest when no target."""
|
|
59
|
+
if not variations:
|
|
60
|
+
sys.stderr.write("error: API returned no video variations for this media id.\n")
|
|
61
|
+
raise SystemExit(2)
|
|
62
|
+
if target_height is None and target_width is None:
|
|
63
|
+
return max(variations, key=lambda var: var.height)
|
|
64
|
+
|
|
65
|
+
def score(variation: GoProMediaDownloadVariation) -> int:
|
|
66
|
+
delta_h = (
|
|
67
|
+
0 if target_height is None else (variation.height - target_height) ** 2
|
|
68
|
+
)
|
|
69
|
+
delta_w = 0 if target_width is None else (variation.width - target_width) ** 2
|
|
70
|
+
return delta_h + delta_w
|
|
71
|
+
|
|
72
|
+
best = min(score(variation) for variation in variations)
|
|
73
|
+
tied = [variation for variation in variations if score(variation) == best]
|
|
74
|
+
return max(tied, key=lambda var: (var.height, var.width))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _require_token() -> None:
|
|
78
|
+
if not GP_ACCESS_TOKEN:
|
|
79
|
+
sys.stderr.write(
|
|
80
|
+
"error: GP_ACCESS_TOKEN is not set. "
|
|
81
|
+
"Add it to your environment or a .env file.\n",
|
|
82
|
+
)
|
|
83
|
+
raise SystemExit(2)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CliSubcommand(ABC):
|
|
87
|
+
"""One subcommand: its parser arguments and execution."""
|
|
88
|
+
|
|
89
|
+
name: str
|
|
90
|
+
help: str
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
94
|
+
"""Configure the subparser for this command."""
|
|
95
|
+
|
|
96
|
+
@abstractmethod
|
|
97
|
+
def run(self, args: argparse.Namespace) -> None:
|
|
98
|
+
"""Execute after global parse.
|
|
99
|
+
|
|
100
|
+
``args`` includes parent options (for example ``timeout``).
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SearchCommand(CliSubcommand):
|
|
105
|
+
"""``search`` — list media ids in a capture date range."""
|
|
106
|
+
|
|
107
|
+
name = "search"
|
|
108
|
+
help = "List media ids in a capture date range"
|
|
109
|
+
|
|
110
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
111
|
+
parser.add_argument(
|
|
112
|
+
"--start",
|
|
113
|
+
required=True,
|
|
114
|
+
help="Range start: YYYY-MM-DD or ISO datetime",
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--end",
|
|
118
|
+
required=True,
|
|
119
|
+
help=(
|
|
120
|
+
"Range end: YYYY-MM-DD or ISO datetime "
|
|
121
|
+
"(API treats range as in query string)"
|
|
122
|
+
),
|
|
123
|
+
)
|
|
124
|
+
parser.add_argument(
|
|
125
|
+
"--page", type=int, default=1, help="Page number (default: 1)"
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"--per-page",
|
|
129
|
+
type=int,
|
|
130
|
+
default=30,
|
|
131
|
+
metavar="N",
|
|
132
|
+
help="Page size (default: 30)",
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--all-pages",
|
|
136
|
+
action="store_true",
|
|
137
|
+
help="Keep requesting pages until a page returns no media",
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--json",
|
|
141
|
+
action="store_true",
|
|
142
|
+
help="Print full API JSON (with --all-pages: list of page payloads)",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def run(self, args: argparse.Namespace) -> None:
|
|
146
|
+
_require_token()
|
|
147
|
+
start = _parse_dt(args.start)
|
|
148
|
+
end = _parse_dt(args.end)
|
|
149
|
+
per_page = args.per_page
|
|
150
|
+
page = args.page
|
|
151
|
+
|
|
152
|
+
with GoProAPI(timeout=args.timeout) as api:
|
|
153
|
+
if args.all_pages:
|
|
154
|
+
all_pages: list[dict] = []
|
|
155
|
+
while True:
|
|
156
|
+
params = GoProMediaSearchParams(
|
|
157
|
+
captured_range=CapturedRange(start=start, end=end),
|
|
158
|
+
page=page,
|
|
159
|
+
per_page=per_page,
|
|
160
|
+
)
|
|
161
|
+
page_result = api.search(params)
|
|
162
|
+
if not page_result.embedded.media:
|
|
163
|
+
break
|
|
164
|
+
if args.json:
|
|
165
|
+
all_pages.append(
|
|
166
|
+
page_result.model_dump(by_alias=True, mode="json"),
|
|
167
|
+
)
|
|
168
|
+
else:
|
|
169
|
+
for item in page_result.embedded.media:
|
|
170
|
+
print(item.id)
|
|
171
|
+
page += 1
|
|
172
|
+
if args.json:
|
|
173
|
+
print(json.dumps(all_pages, indent=2))
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
params = GoProMediaSearchParams(
|
|
177
|
+
captured_range=CapturedRange(start=start, end=end),
|
|
178
|
+
page=page,
|
|
179
|
+
per_page=per_page,
|
|
180
|
+
)
|
|
181
|
+
page_result = api.search(params)
|
|
182
|
+
if args.json:
|
|
183
|
+
print(
|
|
184
|
+
json.dumps(
|
|
185
|
+
page_result.model_dump(by_alias=True, mode="json"),
|
|
186
|
+
indent=2,
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
else:
|
|
190
|
+
for item in page_result.embedded.media:
|
|
191
|
+
print(item.id)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class InfoCommand(CliSubcommand):
|
|
195
|
+
"""``info`` — show download metadata for one media id."""
|
|
196
|
+
|
|
197
|
+
name = "info"
|
|
198
|
+
help = "Show download metadata (URLs, sizes) for one media id"
|
|
199
|
+
|
|
200
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
201
|
+
parser.add_argument("media_id", help="Media id from search")
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"--json",
|
|
204
|
+
action="store_true",
|
|
205
|
+
help="Print full API JSON",
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
def run(self, args: argparse.Namespace) -> None:
|
|
209
|
+
_require_token()
|
|
210
|
+
with GoProAPI(timeout=args.timeout) as api:
|
|
211
|
+
meta = api.download(args.media_id)
|
|
212
|
+
if args.json:
|
|
213
|
+
print(
|
|
214
|
+
json.dumps(
|
|
215
|
+
meta.model_dump(by_alias=True, mode="json"),
|
|
216
|
+
indent=2,
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
else:
|
|
220
|
+
print(meta.filename)
|
|
221
|
+
|
|
222
|
+
if _is_video_filename(meta.filename):
|
|
223
|
+
media_list = meta.embedded.variations
|
|
224
|
+
else:
|
|
225
|
+
media_list = meta.embedded.files
|
|
226
|
+
|
|
227
|
+
for idx, media_item in enumerate(media_list):
|
|
228
|
+
print(
|
|
229
|
+
f" {idx:>3} {media_item.width}x{media_item.height} "
|
|
230
|
+
f"{media_item.url}",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class PullCommand(CliSubcommand):
|
|
235
|
+
"""``pull`` — download files for one media id."""
|
|
236
|
+
|
|
237
|
+
name = "pull"
|
|
238
|
+
help = "Download files from a media id"
|
|
239
|
+
|
|
240
|
+
def add_arguments(self, parser: argparse.ArgumentParser) -> None:
|
|
241
|
+
parser.add_argument("media_id", help="Media id from search")
|
|
242
|
+
parser.add_argument("destination", help="Path to save the file")
|
|
243
|
+
parser.add_argument(
|
|
244
|
+
"--height",
|
|
245
|
+
type=_positive_int,
|
|
246
|
+
default=None,
|
|
247
|
+
metavar="PX",
|
|
248
|
+
help=(
|
|
249
|
+
"For video: pick the variation whose height is closest to PX "
|
|
250
|
+
"(default: tallest)"
|
|
251
|
+
),
|
|
252
|
+
)
|
|
253
|
+
parser.add_argument(
|
|
254
|
+
"--width",
|
|
255
|
+
type=_positive_int,
|
|
256
|
+
default=None,
|
|
257
|
+
metavar="PX",
|
|
258
|
+
help=(
|
|
259
|
+
"For video: pick the variation whose width is closest to PX "
|
|
260
|
+
"(default: tallest)"
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def run(self, args: argparse.Namespace) -> None:
|
|
265
|
+
_require_token()
|
|
266
|
+
with GoProAPI(timeout=args.timeout) as api:
|
|
267
|
+
meta = api.download(args.media_id)
|
|
268
|
+
|
|
269
|
+
if _is_video_filename(meta.filename):
|
|
270
|
+
chosen = _select_video_variation(
|
|
271
|
+
meta.embedded.variations,
|
|
272
|
+
target_height=args.height,
|
|
273
|
+
target_width=args.width,
|
|
274
|
+
)
|
|
275
|
+
media_list = [chosen]
|
|
276
|
+
else:
|
|
277
|
+
media_list = meta.embedded.files
|
|
278
|
+
|
|
279
|
+
for idx, file_entry in enumerate(media_list):
|
|
280
|
+
os.makedirs(args.destination, exist_ok=True)
|
|
281
|
+
media_name = meta.filename.split(".")[0]
|
|
282
|
+
media_type = meta.filename.split(".")[-1]
|
|
283
|
+
item_number = str(idx).zfill(3)
|
|
284
|
+
media_file_name = f"{media_name}{item_number}.{media_type}"
|
|
285
|
+
dest_path = f"{args.destination}/{media_file_name}"
|
|
286
|
+
with open(dest_path, "wb") as outfile:
|
|
287
|
+
response = requests.get(file_entry.url, timeout=args.timeout)
|
|
288
|
+
response.raise_for_status()
|
|
289
|
+
outfile.write(response.content)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class CliBuilder: # pylint: disable=too-few-public-methods
|
|
293
|
+
"""Assembles the root parser and one subparser per registered command."""
|
|
294
|
+
|
|
295
|
+
def __init__(self, commands: Sequence[CliSubcommand]) -> None:
|
|
296
|
+
self._commands = list(commands)
|
|
297
|
+
|
|
298
|
+
def build(self) -> argparse.ArgumentParser:
|
|
299
|
+
"""Return the root parser with global options and subcommands."""
|
|
300
|
+
parser = argparse.ArgumentParser(
|
|
301
|
+
prog="gopro-api",
|
|
302
|
+
description="CLI for the unofficial GoPro cloud API (api.gopro.com).",
|
|
303
|
+
)
|
|
304
|
+
parser.add_argument(
|
|
305
|
+
"--version",
|
|
306
|
+
action="version",
|
|
307
|
+
version=f"%(prog)s {_version()}",
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--timeout",
|
|
311
|
+
type=float,
|
|
312
|
+
default=60.0,
|
|
313
|
+
help="HTTP timeout in seconds (default: 60)",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
317
|
+
for cmd in self._commands:
|
|
318
|
+
subparser = sub.add_parser(cmd.name, help=cmd.help)
|
|
319
|
+
cmd.add_arguments(subparser)
|
|
320
|
+
subparser.set_defaults(func=cmd.run)
|
|
321
|
+
|
|
322
|
+
return parser
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def main(argv: list[str] | None = None) -> None:
|
|
326
|
+
"""Parse CLI arguments and dispatch to the selected subcommand handler."""
|
|
327
|
+
builder = CliBuilder(
|
|
328
|
+
[
|
|
329
|
+
SearchCommand(),
|
|
330
|
+
InfoCommand(),
|
|
331
|
+
PullCommand(),
|
|
332
|
+
],
|
|
333
|
+
)
|
|
334
|
+
args = builder.build().parse_args(argv)
|
|
335
|
+
args.func(args)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
if __name__ == "__main__":
|
|
339
|
+
main()
|
gopro_api/config.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gopro-api
|
|
3
|
+
Version: 0.0.6
|
|
4
|
+
Summary: Unofficial Python client for the GoPro cloud API (api.gopro.com): sync and async clients, Pydantic models, and a CLI.
|
|
5
|
+
Home-page: https://github.com/himewel/gopro-api
|
|
6
|
+
Author: himewel
|
|
7
|
+
Author-email: welberthime@gmail.com
|
|
8
|
+
License: MIT
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/himewel/gopro-api/issues
|
|
10
|
+
Project-URL: Source, https://github.com/himewel/gopro-api
|
|
11
|
+
Keywords: gopro quik cloud api async aiohttp media gopro-api
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Multimedia :: Graphics :: Capture :: Digital Camera
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: aiohttp~=3.11.14
|
|
28
|
+
Requires-Dist: python-dotenv~=1.0.1
|
|
29
|
+
Requires-Dist: pydantic~=2.10.6
|
|
30
|
+
Requires-Dist: requests~=2.32.3
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build~=1.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: black~=24.10.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pylint~=3.3.0; extra == "dev"
|
|
35
|
+
Dynamic: author
|
|
36
|
+
Dynamic: author-email
|
|
37
|
+
Dynamic: classifier
|
|
38
|
+
Dynamic: description
|
|
39
|
+
Dynamic: description-content-type
|
|
40
|
+
Dynamic: home-page
|
|
41
|
+
Dynamic: keywords
|
|
42
|
+
Dynamic: license
|
|
43
|
+
Dynamic: license-file
|
|
44
|
+
Dynamic: project-url
|
|
45
|
+
Dynamic: provides-extra
|
|
46
|
+
Dynamic: requires-dist
|
|
47
|
+
Dynamic: requires-python
|
|
48
|
+
Dynamic: summary
|
|
49
|
+
|
|
50
|
+
# gopro-api
|
|
51
|
+
|
|
52
|
+
Unofficial Python client for the **GoPro cloud / Quik** HTTP API at [`api.gopro.com`](https://api.gopro.com): **search** your library and **fetch download metadata** (CDN URLs, filenames, variants). Built with **Pydantic** models, plus **sync** (`requests`) and **async** (`aiohttp`) clients and a small **`gopro-api`** CLI.
|
|
53
|
+
|
|
54
|
+
This project is not affiliated with or endorsed by GoPro.
|
|
55
|
+
|
|
56
|
+
## Features
|
|
57
|
+
|
|
58
|
+
- **`GoProAPI`** — synchronous client (`requests`), `with` context manager
|
|
59
|
+
- **`AsyncGoProAPI`** — async client (`aiohttp`), `async with` context manager
|
|
60
|
+
- **Pydantic** request/response types in `gopro_api.api.models`
|
|
61
|
+
- **CLI** — `gopro-api search`, `gopro-api info`, `gopro-api pull`
|
|
62
|
+
- **`GP_ACCESS_TOKEN`** from environment / `.env` (browser cookie value)
|
|
63
|
+
|
|
64
|
+
## Requirements
|
|
65
|
+
|
|
66
|
+
- Python **3.10+**
|
|
67
|
+
- **`GP_ACCESS_TOKEN`** — see [Configuration](#configuration)
|
|
68
|
+
|
|
69
|
+
## Install
|
|
70
|
+
|
|
71
|
+
From the repository root:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
python -m venv .venv
|
|
75
|
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
76
|
+
pip install -r requirements.txt
|
|
77
|
+
pip install -e .
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
From a local wheel (name matches your build):
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pip install ./dist/gopro_api-*-py3-none-any.whl
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Published package on PyPI (distribution name **`gopro-api`**, import **`gopro_api`**):
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install gopro-api
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## CLI
|
|
93
|
+
|
|
94
|
+
After install, **`gopro-api`** is on your `PATH`:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
gopro-api --help
|
|
98
|
+
gopro-api --version
|
|
99
|
+
gopro-api search --start 2026-03-01 --end 2026-03-03 --per-page 30
|
|
100
|
+
gopro-api search --start 2026-03-01 --end 2026-03-03 --all-pages
|
|
101
|
+
gopro-api search --start 2026-03-01 --end 2026-03-03 --json
|
|
102
|
+
gopro-api info MEDIA_ID
|
|
103
|
+
gopro-api info MEDIA_ID --json
|
|
104
|
+
gopro-api pull MEDIA_ID ./downloads
|
|
105
|
+
gopro-api pull MEDIA_ID ./downloads --height 1080
|
|
106
|
+
gopro-api pull MEDIA_ID ./downloads --width 1920 --height 1080
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| Command | Purpose |
|
|
110
|
+
|--------|---------|
|
|
111
|
+
| **`search`** | List media in a capture range. Default: one **id** per line. **`--json`**: full API-shaped response; with **`--all-pages`**, a JSON array of every page. |
|
|
112
|
+
| **`info`** | Show download metadata for one media id (filename + file lines with size and URL), or **`--json`** for the full payload. |
|
|
113
|
+
| **`pull`** | Download asset(s) for a media id into **`destination`** (directory; created if missing). Videos (`.mp4` extension, case-insensitive): one **`variations`** entry — **tallest** by default, or closest to **`--height`** / **`--width`** (sum of squared pixel deltas; ties broken by larger resolution). Photos: uses **`files`** (one request per file). |
|
|
114
|
+
|
|
115
|
+
Global **`--timeout`** (seconds, default **`60`**) applies to API calls and to **`pull`** CDN downloads (`requests.get`).
|
|
116
|
+
|
|
117
|
+
Run without an installed script:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
python -m gopro_api.cli search --start 2026-03-01 --end 2026-03-02
|
|
121
|
+
python -m gopro_api.cli info MEDIA_ID
|
|
122
|
+
python -m gopro_api.cli pull MEDIA_ID ./out
|
|
123
|
+
python -m gopro_api.cli pull MEDIA_ID ./out --height 720
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## Configuration
|
|
127
|
+
|
|
128
|
+
`gopro_api.config` loads **`.env`** from the current working directory and reads **`GP_ACCESS_TOKEN`**.
|
|
129
|
+
|
|
130
|
+
Example `.env`:
|
|
131
|
+
|
|
132
|
+
```env
|
|
133
|
+
GP_ACCESS_TOKEN=your_token_here
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The clients send it as a cookie: `gp_access_token=<value>`. Put **only the token string** in `GP_ACCESS_TOKEN` (not the `gp_access_token=` prefix).
|
|
137
|
+
|
|
138
|
+
You can override the token in code: `GoProAPI(access_token="...")` or `AsyncGoProAPI(access_token="...")`.
|
|
139
|
+
|
|
140
|
+
### Retrieving `gp_access_token` from your browser
|
|
141
|
+
|
|
142
|
+
Sign in to the GoPro web app (e.g. [gopro.com](https://gopro.com) media / Quik). The site sets a cookie **`gp_access_token`**.
|
|
143
|
+
|
|
144
|
+
**Chrome / Edge / Brave**
|
|
145
|
+
|
|
146
|
+
1. Open the site while logged in.
|
|
147
|
+
2. **F12** → **Application** → **Cookies** → choose the origin (often `https://quik.gopro.com` or another `*.gopro.com` host).
|
|
148
|
+
3. Copy the **Value** of **`gp_access_token`**.
|
|
149
|
+
|
|
150
|
+
**Firefox**
|
|
151
|
+
|
|
152
|
+
**F12** → **Storage** → **Cookies** → same idea.
|
|
153
|
+
|
|
154
|
+
**Network panel (Chromium)**
|
|
155
|
+
|
|
156
|
+
1. **Network** → trigger requests to **`api.gopro.com`**.
|
|
157
|
+
2. Pick a request → **Headers** → **Cookie**.
|
|
158
|
+
3. Copy the value after `gp_access_token=` up to the next `;` (or end of string).
|
|
159
|
+
|
|
160
|
+
**Notes**
|
|
161
|
+
|
|
162
|
+
- If the cookie is **HttpOnly**, use the **Network** method.
|
|
163
|
+
- Tokens **expire**; refresh from the browser if you get **401**.
|
|
164
|
+
- Treat the token like a password.
|
|
165
|
+
|
|
166
|
+
**Security:** Do not commit `.env` or tokens. Keep `.env` in `.gitignore`.
|
|
167
|
+
|
|
168
|
+
## Library usage
|
|
169
|
+
|
|
170
|
+
### Async (`AsyncGoProAPI`)
|
|
171
|
+
|
|
172
|
+
```python
|
|
173
|
+
import asyncio
|
|
174
|
+
from datetime import datetime
|
|
175
|
+
|
|
176
|
+
from gopro_api.api import AsyncGoProAPI
|
|
177
|
+
from gopro_api.api.models import CapturedRange, GoProMediaSearchParams
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def main() -> None:
|
|
181
|
+
params = GoProMediaSearchParams(
|
|
182
|
+
captured_range=CapturedRange(
|
|
183
|
+
start=datetime.fromisoformat("2026-03-01"),
|
|
184
|
+
end=datetime.fromisoformat("2026-03-02"),
|
|
185
|
+
),
|
|
186
|
+
per_page=50,
|
|
187
|
+
page=1,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
async with AsyncGoProAPI() as api:
|
|
191
|
+
search = await api.search(params)
|
|
192
|
+
for item in search.embedded.media:
|
|
193
|
+
meta = await api.download(item.id)
|
|
194
|
+
print(meta.filename, len(meta.embedded.files), "files")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
if __name__ == "__main__":
|
|
198
|
+
asyncio.run(main())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Sync (`GoProAPI`)
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from datetime import datetime
|
|
205
|
+
|
|
206
|
+
from gopro_api.api import GoProAPI
|
|
207
|
+
from gopro_api.api.models import CapturedRange, GoProMediaSearchParams
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def main() -> None:
|
|
211
|
+
params = GoProMediaSearchParams(
|
|
212
|
+
captured_range=CapturedRange(
|
|
213
|
+
start=datetime.fromisoformat("2026-03-01"),
|
|
214
|
+
end=datetime.fromisoformat("2026-03-02"),
|
|
215
|
+
),
|
|
216
|
+
per_page=50,
|
|
217
|
+
page=1,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
with GoProAPI() as api:
|
|
221
|
+
search = api.search(params)
|
|
222
|
+
for item in search.embedded.media:
|
|
223
|
+
meta = api.download(item.id)
|
|
224
|
+
print(meta.filename, len(meta.embedded.files), "files")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
main()
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
### Models
|
|
232
|
+
|
|
233
|
+
- **Requests:** `GoProMediaSearchParams`, `CapturedRange`, etc. in **`gopro_api.api.models`**.
|
|
234
|
+
- **Responses:** search and download JSON shapes (including `_embedded` / `_pages` aliases).
|
|
235
|
+
|
|
236
|
+
List fields in search params are serialized to comma-separated strings when you call **`model_dump()`** (used by the HTTP clients).
|
|
237
|
+
|
|
238
|
+
## Project layout
|
|
239
|
+
|
|
240
|
+
| Path | Role |
|
|
241
|
+
|------|------|
|
|
242
|
+
| `gopro_api/api/gopro.py` | `GoProAPI` — sync `search`, `download` |
|
|
243
|
+
| `gopro_api/api/async_gopro.py` | `AsyncGoProAPI` — async `search`, `download` |
|
|
244
|
+
| `gopro_api/api/models.py` | Pydantic request/response models |
|
|
245
|
+
| `gopro_api/api/__init__.py` | Re-exports `GoProAPI`, `AsyncGoProAPI` |
|
|
246
|
+
| `gopro_api/config.py` | `load_dotenv`, `GP_ACCESS_TOKEN` |
|
|
247
|
+
| `gopro_api/cli.py` | `gopro-api` CLI |
|
|
248
|
+
| `setup.py` | Package metadata, dependencies, console entry point |
|
|
249
|
+
|
|
250
|
+
## CI and releases
|
|
251
|
+
|
|
252
|
+
[`.github/workflows/release.yml`](.github/workflows/release.yml):
|
|
253
|
+
|
|
254
|
+
- **Push to `main`** — builds wheel + source `.zip`, uploads **workflow artifacts**.
|
|
255
|
+
- **Push tag `v*`** (e.g. `v0.0.5`) — attaches the same files to a **GitHub Release**.
|
|
256
|
+
|
|
257
|
+
## License
|
|
258
|
+
|
|
259
|
+
[MIT License](LICENSE).
|
|
260
|
+
|
|
261
|
+
GoPro, Quik, and related marks are trademarks of their respective owners.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
gopro_api/__init__.py,sha256=OF04vDQPWRwOxppyTxP6LjWcSlArjBcAh4viis_6yf0,72
|
|
2
|
+
gopro_api/cli.py,sha256=3gMktNwQsCGHSbyseBXJ3m89sgIkLam5jbWI-fOqakU,10891
|
|
3
|
+
gopro_api/config.py,sha256=MIKsKv95nzbISQSbL7MryE04zUiSrbNl6tFtku1hbhI,182
|
|
4
|
+
gopro_api/api/__init__.py,sha256=CHBzJUwHYec4T--JiRp1u-SK4Oxwi9pXA1AnKX2c27E,163
|
|
5
|
+
gopro_api/api/async_gopro.py,sha256=LreKyDhUvQbucjIxihodbS0A7i8ry32aDlEMqx4rJyU,3487
|
|
6
|
+
gopro_api/api/gopro.py,sha256=PsnomdSlSF_tKQWbyyb8K1FVeOxDYdvnZEobQAdx8Ck,3227
|
|
7
|
+
gopro_api/api/models.py,sha256=igLHjcpYwyxrC2U4kIsv21_uQoxiB3KZ1PF5ZPtxy5U,4142
|
|
8
|
+
gopro_api-0.0.6.dist-info/licenses/LICENSE,sha256=o8Gx-7kDb40cl02Zh-vwDIi7cSA72W7-MhSpjf0DazM,1064
|
|
9
|
+
gopro_api-0.0.6.dist-info/METADATA,sha256=qIPNoitm03KK29rtD7euYHXtzL7_YMdANekPd4DGKpc,8776
|
|
10
|
+
gopro_api-0.0.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
gopro_api-0.0.6.dist-info/entry_points.txt,sha256=HA98CKe5NaBbf6oxIGmvrOD8H-nMef7fTMtn9-Xa4aA,49
|
|
12
|
+
gopro_api-0.0.6.dist-info/top_level.txt,sha256=0kYRNrDZ3FffqZmUuq8VG5VB6HKSdSvAtYi6AatvNVY,10
|
|
13
|
+
gopro_api-0.0.6.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 himewel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gopro_api
|