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 ADDED
@@ -0,0 +1 @@
1
+ """Unofficial Python client for the GoPro cloud API (api.gopro.com)."""
@@ -0,0 +1,7 @@
1
+ """Sync and async HTTP clients for api.gopro.com."""
2
+
3
+ from .gopro import GoProAPI
4
+ from .async_gopro import AsyncGoProAPI
5
+
6
+
7
+ __all__ = ["GoProAPI", "AsyncGoProAPI"]
@@ -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)
@@ -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,9 @@
1
+ """Environment-backed settings (e.g. ``GP_ACCESS_TOKEN`` from ``.env``)."""
2
+
3
+ import os
4
+ from dotenv import load_dotenv
5
+
6
+
7
+ load_dotenv()
8
+
9
+ GP_ACCESS_TOKEN = os.getenv("GP_ACCESS_TOKEN")
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gopro-api = gopro_api.cli:main
@@ -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