Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,22 @@ concurrency:

permissions:
contents: read
pull-requests: write

jobs:
auto-label:
name: Auto-label PR
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: bcoe/conventional-release-labels@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
ignored_types: '["chore"]'
type_labels: '{"feat":"feature","fix":"fix","perf":"performance","refactor":"refactor","docs":"documentation","test":"test","build":"build","ci":"ci","breaking_change":"breaking change"}'

lint-and-build:
name: Lint and build
runs-on: ubuntu-latest
Expand Down
144 changes: 144 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# Architecture

This page describes the internal design of **gopro-api** — how the layers fit together, how data flows from an HTTP request to a typed Python object, and where to look when you want to extend the library.

## High-level overview

```mermaid
flowchart TD
U(["User / Script / CLI"])

U --> GC["GoProClient\n(sync wrapper)"]
U --> AGC["AsyncGoProClient\n(async wrapper)"]

GC -->|delegates to| GA["GoProAPI\n(requests-based)"]
AGC -->|delegates to| AGA["AsyncGoProAPI\n(aiohttp-based)"]

GA -->|HTTP| API(["api.gopro.com"])
AGA -->|HTTP| API
```

## Package layout

| Path | Role |
|------|------|
| `gopro_api/api/gopro.py` | `GoProAPI` — synchronous HTTP client (`requests`) |
| `gopro_api/api/async_gopro.py` | `AsyncGoProAPI` — asynchronous HTTP client (`aiohttp`) |
| `gopro_api/api/models.py` | Pydantic request/response models |
| `gopro_api/api/__init__.py` | Re-exports `GoProAPI`, `AsyncGoProAPI` |
| `gopro_api/client.py` | `GoProClient` / `AsyncGoProClient` — high-level wrappers |
| `gopro_api/config.py` | pydantic-settings `Settings` (`GP_ACCESS_TOKEN`) |
| `gopro_api/exceptions.py` | Custom exception hierarchy |
| `gopro_api/utils.py` | Shared helpers (resolution scoring, etc.) |
| `gopro_api/cli/` | Typer application — `search`, `info`, `pull` commands |

## Layers

### 1. HTTP clients (`gopro_api.api`)

`GoProAPI` and `AsyncGoProAPI` are thin wrappers around HTTP calls. They:

- Inject the `gp_access_token` cookie on every request.
- Validate and deserialise responses into Pydantic models.
- Raise typed exceptions (`gopro_api.exceptions`) on non-2xx responses.

Both are context-managers that handle session lifecycle:

```python
with GoProAPI() as api: # opens a requests.Session
result = api.search(...)

async with AsyncGoProAPI() as api: # opens an aiohttp.ClientSession
result = await api.search(...)
```

### 2. High-level clients (`gopro_api.client`)

`GoProClient` and `AsyncGoProClient` add convenience on top of the raw API:

- **Pagination** — `--all-pages` iteration over `search` results.
- **Asset selection** — picking the best video variation by resolution.
- **File download** — streaming CDN downloads to disk.

### 3. Pydantic models (`gopro_api.api.models`)

All request parameters and API responses are typed with [Pydantic v2](https://docs.pydantic.dev/) models:

- **Requests** — `GoProMediaSearchParams`, `CapturedRange`.
- **Responses** — `GoProMediaSearchResponse`, `GoProMediaDownloadResponse`, and their `_embedded` / `_pages` children (with field aliases for the GoPro API's underscore-prefixed keys).

List fields in request models are serialised to comma-separated strings automatically when calling `model_dump()`.

### 4. Configuration (`gopro_api.config`)

A single `Settings` class (pydantic-settings) reads `GP_ACCESS_TOKEN` from:

1. Environment variables.
2. A `.env` file in the current working directory.

Clients accept an explicit `access_token` parameter that overrides `Settings`.

### 5. CLI (`gopro_api.cli`)

The CLI is built with [Typer](https://typer.tiangolo.com/) and [Rich](https://github.com/Textualize/rich):

- `gopro_api/cli/app.py` — Typer application and shared options.
- `gopro_api/cli/search.py` — `search` command + `SearchPrinter`.
- `gopro_api/cli/info.py` — `info` command + `InfoPrinter`.
- `gopro_api/cli/pull.py` — `pull` command + `PullPrinter`.
- `gopro_api/cli/_common.py` — shared helpers.

Each command delegates to `GoProClient`/`AsyncGoProClient` and passes the result to a dedicated `*Printer` class for Rich-formatted output.

## Data flow — `gopro-api search`

```mermaid
sequenceDiagram
actor User
participant CLI
participant GoProClient
participant GoProAPI
participant GoproCom as api.gopro.com

User->>CLI: gopro-api search --start … --end …
CLI->>GoProClient: search() / search_all()
GoProClient->>GoProAPI: search(GoProMediaSearchParams)
GoProAPI->>GoproCom: GET /media/search?captured_after=…&per_page=…
GoproCom-->>GoProAPI: JSON response
GoProAPI-->>GoProClient: GoProMediaSearchResponse (Pydantic)
GoProClient-->>CLI: response
CLI->>User: Rich table or raw JSON (stdout)
```

## Error handling

```mermaid
classDiagram
Exception <|-- GoProAPIError
GoProAPIError <|-- GoProAuthError
GoProAPIError <|-- GoProNotFoundError

class GoProAPIError {
Base class for all library errors
}
class GoProAuthError {
HTTP 401 — token expired or missing
}
class GoProNotFoundError {
HTTP 404 — media ID does not exist
}
```

| Exception | Raised when |
|-----------|-------------|
| `GoProAPIError` | Base class for all library errors. |
| `GoProAuthError` | The API returns **401 Unauthorized** (token expired or missing). |
| `GoProNotFoundError` | The API returns **404** (media ID does not exist). |

See `gopro_api/exceptions.py` and [API Reference → Exceptions](api/exceptions.md) for the full hierarchy.

## Extending the library

**New API endpoint** — add a method to `GoProAPI` / `AsyncGoProAPI` (and the corresponding Pydantic models), then expose it in `GoProClient` / `AsyncGoProClient`.

**New CLI command** — create a new file under `gopro_api/cli/`, define a Typer command, register it in `app.py`, and add a `*Printer` class for output formatting.
131 changes: 131 additions & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# CLI

`gopro-api` ships with a command-line interface built on [Typer](https://typer.tiangolo.com/) and [Rich](https://github.com/Textualize/rich). After installation it is available as `gopro-api` on your `PATH`, or you can invoke it directly as a module:

```bash
python -m gopro_api.cli <command> [options]
```

## Global options

| Option | Default | Description |
|--------|---------|-------------|
| `--timeout` | `60` | Timeout in seconds for API calls and CDN downloads. |
| `--version` | — | Print the installed version and exit. |
| `--help` | — | Show help text. |

## Commands

### `search`

Search your GoPro cloud library within a capture date range.

```bash
gopro-api search --start 2026-03-01 --end 2026-03-03
```

**Options**

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `--start` | `DATE` | **required** | Range start (inclusive), `YYYY-MM-DD`. |
| `--end` | `DATE` | **required** | Range end (inclusive), `YYYY-MM-DD`. |
| `--per-page` | `INT` | `100` | Items per API page. |
| `--all-pages` | flag | off | Fetch every page automatically. |
| `--json` | flag | off | Print the raw API JSON instead of the tabular view. |

**Default output** — a `# _pages` summary line, then a tab-separated table with columns `id`, `type`, `captured_at`, `filename`, … (other API fields land in an `extra` JSON column).

**`--json`** — pretty-prints the full API-shaped response; combined with `--all-pages` produces a JSON array of every page.

```bash
# Tabular view, all pages
gopro-api search --start 2026-03-01 --end 2026-03-03 --all-pages

# Raw JSON for a single page
gopro-api search --start 2026-03-01 --end 2026-03-03 --per-page 30 --json

# Raw JSON, all pages
gopro-api search --start 2026-03-01 --end 2026-03-03 --json --all-pages
```

---

### `info`

Show download metadata for a single media item.

```bash
gopro-api info MEDIA_ID
```

**Arguments**

| Argument | Description |
|----------|-------------|
| `MEDIA_ID` | The media identifier returned by `search`. |

**Options**

| Option | Description |
|--------|-------------|
| `--json` | Print the full API payload instead of the formatted summary. |

**Default output** — filename on the first line, then one line per downloadable file showing size and CDN URL.

```bash
gopro-api info MEDIA_ID --json
```

---

### `pull`

Download asset(s) for a media item into a local directory.

```bash
gopro-api pull MEDIA_ID ./downloads
```

**Arguments**

| Argument | Description |
|----------|-------------|
| `MEDIA_ID` | The media identifier returned by `search`. |
| `DESTINATION` | Local directory; created if it does not exist. |

**Options**

| Option | Type | Description |
|--------|------|-------------|
| `--height` | `INT` | Target height in pixels (videos). The variation with the smallest squared pixel-delta is chosen; ties go to the larger resolution. |
| `--width` | `INT` | Target width in pixels (videos). Combined with `--height` when both are given. |

**Behaviour by media type**

- **Videos (`.mp4`)** — downloads one `variations` entry: the tallest by default, or the closest to the requested `--height` / `--width`.
- **Photos** — uses the `files` list; one request per file.

```bash
# Default (tallest video variant)
gopro-api pull MEDIA_ID ./downloads

# Closest to 1080 p
gopro-api pull MEDIA_ID ./downloads --height 1080

# Closest to 1920 × 1080
gopro-api pull MEDIA_ID ./downloads --width 1920 --height 1080
```

## Running without an installed entry point

```bash
python -m gopro_api.cli search --start 2026-03-01 --end 2026-03-02
python -m gopro_api.cli info MEDIA_ID
python -m gopro_api.cli pull MEDIA_ID ./out
python -m gopro_api.cli pull MEDIA_ID ./out --height 720
```

## API reference

For the auto-generated docstrings of the CLI internals see [API Reference → CLI](api/cli.md).
67 changes: 67 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Configuration

`gopro-api` uses [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/) to load settings from the environment and from a `.env` file in the current working directory.

## Required settings

| Variable | Description |
|----------|-------------|
| `GP_ACCESS_TOKEN` | Your GoPro cloud access token (the value of the `gp_access_token` browser cookie). |

## `.env` file

Create a `.env` file at the root of your project (or wherever you run the CLI from):

```env
GP_ACCESS_TOKEN=your_token_here
```

!!! warning "Never commit `.env`"
Add `.env` to your `.gitignore`. The token grants full access to your GoPro cloud account.

## Overriding the token in code

Pass the token directly when constructing a client — this takes precedence over the environment:

```python
from gopro_api.api import GoProAPI, AsyncGoProAPI

with GoProAPI(access_token="your_token_here") as api:
...

async with AsyncGoProAPI(access_token="your_token_here") as api:
...
```

## Retrieving `gp_access_token` from your browser

Sign in to [gopro.com](https://gopro.com) or [quik.gopro.com](https://quik.gopro.com). The site sets a cookie named **`gp_access_token`**.

=== "Chrome / Edge / Brave"

1. Open the site while logged in.
2. Press **F12** to open DevTools → **Application** tab → **Cookies** → select the origin (e.g. `https://quik.gopro.com`).
3. Copy the **Value** of `gp_access_token`.

=== "Firefox"

1. Press **F12** → **Storage** tab → **Cookies** → select the origin.
2. Copy the **Value** of `gp_access_token`.

=== "Network panel (Chromium)"

1. Open **DevTools** → **Network** tab.
2. Trigger any request to `api.gopro.com` (e.g. browse your media library).
3. Click on the request → **Headers** → scroll to **Request Headers** → **Cookie**.
4. Copy the value after `gp_access_token=` up to the next `;` (or end of the string).

!!! tip "HttpOnly cookies"
If the cookie is **HttpOnly**, the Application panel will not show its value. Use the **Network panel** method instead.

## Token expiry

Tokens expire. If you receive a **401 Unauthorized** error, return to your browser and copy a fresh token value.

## API reference

See [`gopro_api.config`](api/utils.md) for the `Settings` class that loads these values.
Loading
Loading