Sync GoPro cloud media to Google Photos Library.
Project description
q2google
Sync media from GoPro cloud into Google Photos for a capture date range with resumable session state.
Requirements
- Python 3.12 or 3.13 (3.14 is excluded until dependent wheels catch up)
GP_ACCESS_TOKENenvironment variable — GoPro cloud access token (required byAsyncGoProClient)- Google OAuth installed app credentials (
client_secret.jsonfrom Google Cloud Console) - A writable path for the user token (
token.jsonby default)
Install
pip install q2google
Or with uv:
uv add q2google
Or with uv:
uv add q2google
Library usage
Minimal example
import asyncio
from datetime import datetime
from gopro_api import AsyncGoProClient
from q2google import (
GoProToPhotosSync,
GooglePhotosClient,
GooglePhotosOAuth,
JsonFileBackend,
)
from q2google.gphotos.api import GooglePhotosAPI
from q2google.gphotos.models import PhotosScopes
async def main() -> None:
oauth = GooglePhotosOAuth(
client_secrets_file="client_secret.json",
scopes=[PhotosScopes.READ_AND_APPEND],
token_file="token.json",
)
async with (
AsyncGoProClient() as gopro,
GooglePhotosAPI(credentials=oauth) as api,
):
photos = GooglePhotosClient(api=api)
backend = JsonFileBackend(root_dir=".q2google_sessions")
syncer = GoProToPhotosSync(
gopro=gopro,
photos=photos,
state_backend=backend,
)
responses = await syncer.sync_date_range(
start_date=datetime(2026, 1, 8),
end_date=datetime(2026, 1, 9),
session_id="my-session",
)
print(f"Created {len(responses)} batch(es).")
asyncio.run(main())
Resuming a session
Pass the same session_id on subsequent runs. GoProToPhotosSync loads the persisted SessionState and skips already-completed items:
responses = await syncer.sync_date_range(
start_date=datetime(2026, 1, 8), # ignored when resuming
end_date=datetime(2026, 1, 9), # ignored when resuming
session_id="my-session", # same key → resumes from checkpoint
)
Custom state backend
Implement SyncStateBackend to persist sessions in any storage layer (database, object store, etc.):
from q2google import SessionState, SyncStateBackend
class RedisBackend:
def load(self, session_id: str) -> SessionState | None:
raw = redis_client.get(session_id)
return SessionState.from_dict(json.loads(raw)) if raw else None
def save(self, state: SessionState) -> None:
redis_client.set(state.session_id, json.dumps(state.to_dict()))
Pass it directly to GoProToPhotosSync(state_backend=RedisBackend()). No other changes required.
Stage completion hook
on_stage_complete is called after each of the three pipeline stages (discovery, transfer, create). Use it to report progress, emit metrics, or trigger side-effects:
from q2google.state.base import SessionState, StageKey
from q2google.photos import MediaItemBatchCreateResponse
async def report(
stage: StageKey,
state: SessionState,
responses: list[MediaItemBatchCreateResponse] | None,
) -> None:
print(f"[{stage}] items={len(state.items)} stage_states={state.stages}")
responses = await syncer.sync_date_range(
start_date=datetime(2026, 1, 8),
end_date=datetime(2026, 1, 9),
session_id="my-session",
on_stage_complete=report,
)
Public API
All public symbols are importable directly from q2google:
| Symbol | Description |
|---|---|
GoProToPhotosSync |
Main orchestrator; runs discovery → transfer → create. |
GooglePhotosClient |
Resumable upload facade (upload_file_path, create_media_items). |
GooglePhotosOAuth |
Load, refresh, or obtain Google OAuth credentials. |
JsonFileBackend |
File-based SyncStateBackend; one JSON per session under a root directory. |
SessionState |
Full persisted session document (to_dict / from_dict for custom stores). |
SyncStateBackend |
Protocol — implement load / save to plug in any storage layer. |
Q2GoogleSettings |
Pydantic settings; batch sizes, timeouts, and paths with env-var overrides. |
get_settings |
Return a singleton Q2GoogleSettings from environment / .env. |
Lower-level symbols in q2google.gphotos:
| Symbol | Description |
|---|---|
GooglePhotosAPI |
Thin aiohttp wrapper for Library v1 — use as async with GooglePhotosAPI(...) as api. |
GooglePhotoLibraryPort |
Protocol matching GooglePhotosAPI; implement for testing or alternative HTTP clients. |
PhotosScopes |
Enum of OAuth scopes (READ_AND_APPEND, READ_ONLY, APPEND_ONLY). |
CLI
The package ships a CLI for one-off or scripted use:
q2google sync \
--start-date 2026-01-08 \
--end-date 2026-01-09 \
--credentials client_secret.json \
--token token.json
Useful options:
| Option | Description |
|---|---|
--state-dir |
JSON session root (default: .q2google_sessions or Q2GOOGLE_STATE_DIR) |
--session-id |
Stable id to resume a run (Q2GOOGLE_SESSION_ID if unset) |
--batch-size |
Files per cycle for new sessions; ignored when resuming (persisted session wins) |
--fail-fast |
Stop on first error after persisting state |
--log-level DEBUG |
Verbose logging |
Library usage
Minimal example
import asyncio
from datetime import datetime
from gopro_api import AsyncGoProClient
from q2google import (
GoProToPhotosSync,
GooglePhotosClient,
GooglePhotosOAuth,
JsonFileBackend,
)
from q2google.gphotos.api import GooglePhotosAPI
from q2google.gphotos.models import PhotosScopes
async def main() -> None:
oauth = GooglePhotosOAuth(
client_secrets_file="client_secret.json",
scopes=[PhotosScopes.READ_AND_APPEND],
token_file="token.json",
)
async with (
AsyncGoProClient() as gopro,
GooglePhotosAPI(credentials=oauth) as api,
):
photos = GooglePhotosClient(api=api)
backend = JsonFileBackend(root_dir=".q2google_sessions")
syncer = GoProToPhotosSync(
gopro=gopro,
photos=photos,
state_backend=backend,
)
responses = await syncer.sync_date_range(
start_date=datetime(2026, 1, 8),
end_date=datetime(2026, 1, 9),
session_id="my-session",
)
print(f"Created {len(responses)} batch(es).")
asyncio.run(main())
Resuming a session
Pass the same session_id on subsequent runs. GoProToPhotosSync loads the persisted SessionState and skips already-completed items:
responses = await syncer.sync_date_range(
start_date=datetime(2026, 1, 8), # ignored when resuming
end_date=datetime(2026, 1, 9), # ignored when resuming
session_id="my-session", # same key → resumes from checkpoint
)
Custom state backend
Implement SyncStateBackend to persist sessions in any storage layer (database, object store, etc.):
from q2google import SessionState, SyncStateBackend
class RedisBackend:
def load(self, session_id: str) -> SessionState | None:
raw = redis_client.get(session_id)
return SessionState.from_dict(json.loads(raw)) if raw else None
def save(self, state: SessionState) -> None:
redis_client.set(state.session_id, json.dumps(state.to_dict()))
Pass it directly to GoProToPhotosSync(state_backend=RedisBackend()). No other changes required.
Stage completion hook
on_stage_complete is called after each of the three pipeline stages (discovery, transfer, create). Use it to report progress, emit metrics, or trigger side-effects:
from q2google.state.base import SessionState, StageKey
from q2google.photos import MediaItemBatchCreateResponse
async def report(
stage: StageKey,
state: SessionState,
responses: list[MediaItemBatchCreateResponse] | None,
) -> None:
print(f"[{stage}] items={len(state.items)} stage_states={state.stages}")
responses = await syncer.sync_date_range(
start_date=datetime(2026, 1, 8),
end_date=datetime(2026, 1, 9),
session_id="my-session",
on_stage_complete=report,
)
Public API
All public symbols are importable directly from q2google:
| Symbol | Description |
|---|---|
GoProToPhotosSync |
Main orchestrator; runs discovery → transfer → create. |
GooglePhotosClient |
Resumable upload facade (upload_file_path, create_media_items). |
GooglePhotosOAuth |
Load, refresh, or obtain Google OAuth credentials. |
JsonFileBackend |
File-based SyncStateBackend; one JSON per session under a root directory. |
SessionState |
Full persisted session document (to_dict / from_dict for custom stores). |
SyncStateBackend |
Protocol — implement load / save to plug in any storage layer. |
Q2GoogleSettings |
Pydantic settings; batch sizes, timeouts, and paths with env-var overrides. |
get_settings |
Return a singleton Q2GoogleSettings from environment / .env. |
Lower-level symbols in q2google.gphotos:
| Symbol | Description |
|---|---|
GooglePhotosAPI |
Thin aiohttp wrapper for Library v1 — use as async with GooglePhotosAPI(...) as api. |
GooglePhotoLibraryPort |
Protocol matching GooglePhotosAPI; implement for testing or alternative HTTP clients. |
PhotosScopes |
Enum of OAuth scopes (READ_AND_APPEND, READ_ONLY, APPEND_ONLY). |
Configuration
All CLI options have environment-variable equivalents. Q2GoogleSettings (Pydantic BaseSettings) loads them with the Q2GOOGLE_ prefix and also reads a .env file in the working directory.
| Variable | Purpose |
|---|---|
GP_ACCESS_TOKEN |
GoPro cloud access token — read by AsyncGoProClient; required for discovery |
Q2GOOGLE_CREDENTIALS_PATH |
Google OAuth client secrets JSON path |
Q2GOOGLE_TOKEN_PATH |
Authorized user token path |
Q2GOOGLE_STATE_DIR |
JSON session state directory |
Q2GOOGLE_SESSION_ID |
Default session id when --session-id is omitted |
Q2GOOGLE_SYNC_BATCH_SIZE |
Transfer batch size for new sessions |
Q2GOOGLE_PHOTOS_LIBRARY_BATCH_SIZE |
Items per batchCreate (1–50) |
Q2GOOGLE_FAIL_FAST |
true / false |
Q2GOOGLE_LOG_LEVEL |
e.g. INFO, DEBUG |
Q2GOOGLE_GOOGLE_PHOTOS_TIMEOUT_SECONDS |
Library API request timeout |
Q2GOOGLE_DOWNLOAD_CHUNK_SIZE_BYTES |
CDN stream chunk size |
See q2google.config.Q2GoogleSettings for the full list and defaults.
Architecture
sync_date_range splits every run into three sequential stages. State is persisted through SyncStateBackend after each stage, so interrupted runs can resume from the last checkpoint.
sequenceDiagram
participant Caller
participant Sync as GoProToPhotosSync
participant GoPro as AsyncGoProClient
participant Photos as GooglePhotosClient
participant Store as SyncStateBackend
Caller->>Sync: sync_date_range(start, end, session_id)
Sync->>Store: load(session_id)
Store-->>Sync: SessionState or new
Note over Sync: discovery
Sync->>GoPro: list_media_items, get_download_url
Sync->>Store: save(state)
Note over Sync: transfer
Sync->>Photos: upload_file_path per item
Sync->>Store: save(state)
Note over Sync: create
Sync->>Photos: create_media_items_from_upload_sessions
Sync->>Store: save(state)
Sync-->>Caller: list of batch create responses
See docs/ARCHITECTURE.md for module layout and extension points.
Development
uv sync
task format # Ruff import fix + format
task lint # Ruff check + format check (no writes)
task test # Pytest with coverage on `q2google`
License
See repository metadata (add a LICENSE file if needed).
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file q2google-0.0.1.tar.gz.
File metadata
- Download URL: q2google-0.0.1.tar.gz
- Upload date:
- Size: 90.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4cd9ef5ff0aebd64b774fa133aa1f52c28dbaf8e9502d53a5cd7e5f38ac15e09
|
|
| MD5 |
c1fef7978610a3281d4e7c8d5f7db9ea
|
|
| BLAKE2b-256 |
ff061f6984bf94d8244e756d699a12cf59a25261a41842046d453b26b1891d4e
|
Provenance
The following attestation bundles were made for q2google-0.0.1.tar.gz:
Publisher:
release.yml on himewel/q2google
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
q2google-0.0.1.tar.gz -
Subject digest:
4cd9ef5ff0aebd64b774fa133aa1f52c28dbaf8e9502d53a5cd7e5f38ac15e09 - Sigstore transparency entry: 1554646702
- Sigstore integration time:
-
Permalink:
himewel/q2google@87d275e4756640eb4063d54150853de16fb1d78c -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/himewel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@87d275e4756640eb4063d54150853de16fb1d78c -
Trigger Event:
push
-
Statement type:
File details
Details for the file q2google-0.0.1-py3-none-any.whl.
File metadata
- Download URL: q2google-0.0.1-py3-none-any.whl
- Upload date:
- Size: 43.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2ebfe833e462955b6695f4fb0da094dc20d3ae6c534616c5c4d7fef5aac46af4
|
|
| MD5 |
b7593229886b4cf8a619e6c92007f9bf
|
|
| BLAKE2b-256 |
fd443535136798e01292bd25a617cdb5526eda13ae3fc249a4c06b203e28d610
|
Provenance
The following attestation bundles were made for q2google-0.0.1-py3-none-any.whl:
Publisher:
release.yml on himewel/q2google
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
q2google-0.0.1-py3-none-any.whl -
Subject digest:
2ebfe833e462955b6695f4fb0da094dc20d3ae6c534616c5c4d7fef5aac46af4 - Sigstore transparency entry: 1554646708
- Sigstore integration time:
-
Permalink:
himewel/q2google@87d275e4756640eb4063d54150853de16fb1d78c -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/himewel
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@87d275e4756640eb4063d54150853de16fb1d78c -
Trigger Event:
push
-
Statement type: