q2google 0.0.1__tar.gz

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.
Files changed (34) hide show
  1. q2google-0.0.1/.github/workflows/ci.yml +52 -0
  2. q2google-0.0.1/.github/workflows/release.yml +91 -0
  3. q2google-0.0.1/.gitignore +9 -0
  4. q2google-0.0.1/LICENSE +21 -0
  5. q2google-0.0.1/PKG-INFO +388 -0
  6. q2google-0.0.1/README.md +372 -0
  7. q2google-0.0.1/docs/ARCHITECTURE.md +73 -0
  8. q2google-0.0.1/pyproject.toml +62 -0
  9. q2google-0.0.1/q2google/__init__.py +23 -0
  10. q2google-0.0.1/q2google/__main__.py +9 -0
  11. q2google-0.0.1/q2google/cli/__init__.py +5 -0
  12. q2google-0.0.1/q2google/cli/_app.py +215 -0
  13. q2google-0.0.1/q2google/cli/_formatters.py +101 -0
  14. q2google-0.0.1/q2google/cli/_logging.py +43 -0
  15. q2google-0.0.1/q2google/cli/_printer.py +533 -0
  16. q2google-0.0.1/q2google/cli/_runner.py +116 -0
  17. q2google-0.0.1/q2google/config.py +142 -0
  18. q2google-0.0.1/q2google/gphotos/__init__.py +10 -0
  19. q2google-0.0.1/q2google/gphotos/api.py +219 -0
  20. q2google-0.0.1/q2google/gphotos/auth.py +98 -0
  21. q2google-0.0.1/q2google/gphotos/models.py +219 -0
  22. q2google-0.0.1/q2google/metrics.py +30 -0
  23. q2google-0.0.1/q2google/photos.py +196 -0
  24. q2google-0.0.1/q2google/stages/__init__.py +17 -0
  25. q2google-0.0.1/q2google/stages/common.py +71 -0
  26. q2google-0.0.1/q2google/stages/create.py +130 -0
  27. q2google-0.0.1/q2google/stages/discovery.py +67 -0
  28. q2google-0.0.1/q2google/stages/transfer.py +227 -0
  29. q2google-0.0.1/q2google/state/__init__.py +31 -0
  30. q2google-0.0.1/q2google/state/base.py +299 -0
  31. q2google-0.0.1/q2google/state/local.py +81 -0
  32. q2google-0.0.1/q2google/sync.py +178 -0
  33. q2google-0.0.1/tests/test_sync.py +75 -0
  34. q2google-0.0.1/uv.lock +1049 -0
@@ -0,0 +1,52 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+
7
+ concurrency:
8
+ group: ci-${{ github.workflow }}-${{ github.ref }}
9
+ cancel-in-progress: true
10
+
11
+ permissions:
12
+ contents: read
13
+
14
+ jobs:
15
+ lint-and-build:
16
+ name: Lint and build
17
+ runs-on: ubuntu-latest
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ python-version: ["3.10", "3.11", "3.12"]
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: astral-sh/setup-uv@v5
26
+ with:
27
+ python-version: ${{ matrix.python-version }}
28
+
29
+ - name: Install package and dev tools
30
+ run: uv pip install -e ".[dev]"
31
+
32
+ - name: Lint (pyproject.toml [tool.ruff])
33
+ run: uv run task lint
34
+
35
+ - name: Build wheel and sdist (pyproject.toml [build-system])
36
+ run: uv build
37
+
38
+ # docs:
39
+ # name: Build documentation
40
+ # runs-on: ubuntu-latest
41
+ # steps:
42
+ # - uses: actions/checkout@v4
43
+
44
+ # - uses: astral-sh/setup-uv@v5
45
+ # with:
46
+ # python-version: "3.12"
47
+
48
+ # - name: Install docs dependencies
49
+ # run: uv pip install -e ".[docs]"
50
+
51
+ # - name: Build MkDocs site (--strict)
52
+ # run: uv run mkdocs build --strict
@@ -0,0 +1,91 @@
1
+ name: Release artifacts
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ tags:
8
+ - "v*"
9
+
10
+ permissions:
11
+ contents: write
12
+
13
+ jobs:
14
+ build:
15
+ name: Build Python distributions
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - uses: astral-sh/setup-uv@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Build distributions
25
+ run: uv build
26
+
27
+ - name: Upload workflow artifact (main or v* tag)
28
+ if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
29
+ uses: actions/upload-artifact@v4
30
+ with:
31
+ name: python-dist
32
+ path: dist/*
33
+
34
+ - name: Publish GitHub Release (v* tags only)
35
+ if: startsWith(github.ref, 'refs/tags/v')
36
+ uses: softprops/action-gh-release@v2
37
+ with:
38
+ files: |
39
+ dist/*.whl
40
+ dist/*.tar.gz
41
+ env:
42
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43
+
44
+ publish-pypi:
45
+ name: Publish to PyPI
46
+ needs: build
47
+ if: startsWith(github.ref, 'refs/tags/v')
48
+ runs-on: ubuntu-latest
49
+ environment: pypi
50
+ permissions:
51
+ contents: read
52
+ id-token: write
53
+ steps:
54
+ - name: Download built distributions
55
+ uses: actions/download-artifact@v4
56
+ with:
57
+ name: python-dist
58
+ path: dist
59
+
60
+ - name: Publish package to PyPI
61
+ uses: pypa/gh-action-pypi-publish@release/v1
62
+
63
+ # deploy-docs:
64
+ # name: Deploy docs to GitHub Pages
65
+ # needs: build
66
+ # if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
67
+ # runs-on: ubuntu-latest
68
+ # steps:
69
+ # - uses: actions/checkout@v4
70
+
71
+ # - uses: astral-sh/setup-uv@v5
72
+ # with:
73
+ # python-version: "3.12"
74
+
75
+ # - name: Install docs dependencies
76
+ # run: uv pip install -e ".[docs]"
77
+
78
+ # - name: Fetch gh-pages history for mike
79
+ # run: git fetch origin gh-pages --depth=1 || true
80
+
81
+ # - name: Deploy versioned docs
82
+ # run: |
83
+ # git config user.name github-actions
84
+ # git config user.email github-actions@github.com
85
+ # if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
86
+ # mike deploy --push "$GITHUB_REF_NAME"
87
+ # else
88
+ # # "latest" is an alias in versions.json; deploy version "main" and move the alias (needs --update-aliases).
89
+ # mike deploy --push --update-aliases main latest
90
+ # mike set-default --push latest
91
+ # fi
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ **/__pycache__
3
+ .env
4
+ client_secret.json
5
+ token.json
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .q2google_sessions/
9
+ .coverage
q2google-0.0.1/LICENSE ADDED
@@ -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,388 @@
1
+ Metadata-Version: 2.4
2
+ Name: q2google
3
+ Version: 0.0.1
4
+ Summary: Sync GoPro cloud media to Google Photos Library.
5
+ License-File: LICENSE
6
+ Requires-Python: <3.14,>=3.10
7
+ Requires-Dist: aiofiles~=25.1.0
8
+ Requires-Dist: aiohttp~=3.11.11
9
+ Requires-Dist: google-auth-oauthlib~=1.3.0
10
+ Requires-Dist: gopro-api~=0.0.7
11
+ Requires-Dist: pydantic-settings>=2
12
+ Requires-Dist: pydantic>=2
13
+ Requires-Dist: rich>=13
14
+ Requires-Dist: typer>=0.12
15
+ Description-Content-Type: text/markdown
16
+
17
+ # q2google
18
+
19
+ Sync media from **GoPro cloud** into **Google Photos** for a capture date range with **resumable session state**.
20
+
21
+ ## Requirements
22
+
23
+ - Python **3.12 or 3.13** (3.14 is excluded until dependent wheels catch up)
24
+ - **`GP_ACCESS_TOKEN`** environment variable — GoPro cloud access token (required by `AsyncGoProClient`)
25
+ - Google OAuth **installed app** credentials (`client_secret.json` from Google Cloud Console)
26
+ - A writable path for the user token (`token.json` by default)
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install q2google
32
+ ```
33
+
34
+ Or with [uv](https://docs.astral.sh/uv/):
35
+
36
+ ```bash
37
+ uv add q2google
38
+ ```
39
+
40
+ Or with [uv](https://docs.astral.sh/uv/):
41
+
42
+ ```bash
43
+ uv add q2google
44
+ ```
45
+
46
+ ## Library usage
47
+
48
+ ### Minimal example
49
+
50
+ ```python
51
+ import asyncio
52
+ from datetime import datetime
53
+
54
+ from gopro_api import AsyncGoProClient
55
+
56
+ from q2google import (
57
+ GoProToPhotosSync,
58
+ GooglePhotosClient,
59
+ GooglePhotosOAuth,
60
+ JsonFileBackend,
61
+ )
62
+ from q2google.gphotos.api import GooglePhotosAPI
63
+ from q2google.gphotos.models import PhotosScopes
64
+
65
+
66
+ async def main() -> None:
67
+ oauth = GooglePhotosOAuth(
68
+ client_secrets_file="client_secret.json",
69
+ scopes=[PhotosScopes.READ_AND_APPEND],
70
+ token_file="token.json",
71
+ )
72
+
73
+ async with (
74
+ AsyncGoProClient() as gopro,
75
+ GooglePhotosAPI(credentials=oauth) as api,
76
+ ):
77
+ photos = GooglePhotosClient(api=api)
78
+ backend = JsonFileBackend(root_dir=".q2google_sessions")
79
+
80
+ syncer = GoProToPhotosSync(
81
+ gopro=gopro,
82
+ photos=photos,
83
+ state_backend=backend,
84
+ )
85
+
86
+ responses = await syncer.sync_date_range(
87
+ start_date=datetime(2026, 1, 8),
88
+ end_date=datetime(2026, 1, 9),
89
+ session_id="my-session",
90
+ )
91
+ print(f"Created {len(responses)} batch(es).")
92
+
93
+
94
+ asyncio.run(main())
95
+ ```
96
+
97
+ ### Resuming a session
98
+
99
+ Pass the same `session_id` on subsequent runs. `GoProToPhotosSync` loads the persisted `SessionState` and skips already-completed items:
100
+
101
+ ```python
102
+ responses = await syncer.sync_date_range(
103
+ start_date=datetime(2026, 1, 8), # ignored when resuming
104
+ end_date=datetime(2026, 1, 9), # ignored when resuming
105
+ session_id="my-session", # same key → resumes from checkpoint
106
+ )
107
+ ```
108
+
109
+ ### Custom state backend
110
+
111
+ Implement `SyncStateBackend` to persist sessions in any storage layer (database, object store, etc.):
112
+
113
+ ```python
114
+ from q2google import SessionState, SyncStateBackend
115
+
116
+
117
+ class RedisBackend:
118
+ def load(self, session_id: str) -> SessionState | None:
119
+ raw = redis_client.get(session_id)
120
+ return SessionState.from_dict(json.loads(raw)) if raw else None
121
+
122
+ def save(self, state: SessionState) -> None:
123
+ redis_client.set(state.session_id, json.dumps(state.to_dict()))
124
+ ```
125
+
126
+ Pass it directly to `GoProToPhotosSync(state_backend=RedisBackend())`. No other changes required.
127
+
128
+ ### Stage completion hook
129
+
130
+ `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:
131
+
132
+ ```python
133
+ from q2google.state.base import SessionState, StageKey
134
+ from q2google.photos import MediaItemBatchCreateResponse
135
+
136
+
137
+ async def report(
138
+ stage: StageKey,
139
+ state: SessionState,
140
+ responses: list[MediaItemBatchCreateResponse] | None,
141
+ ) -> None:
142
+ print(f"[{stage}] items={len(state.items)} stage_states={state.stages}")
143
+
144
+
145
+ responses = await syncer.sync_date_range(
146
+ start_date=datetime(2026, 1, 8),
147
+ end_date=datetime(2026, 1, 9),
148
+ session_id="my-session",
149
+ on_stage_complete=report,
150
+ )
151
+ ```
152
+
153
+ ### Public API
154
+
155
+ All public symbols are importable directly from `q2google`:
156
+
157
+ | Symbol | Description |
158
+ |--------|-------------|
159
+ | `GoProToPhotosSync` | Main orchestrator; runs discovery → transfer → create. |
160
+ | `GooglePhotosClient` | Resumable upload facade (`upload_file_path`, `create_media_items`). |
161
+ | `GooglePhotosOAuth` | Load, refresh, or obtain Google OAuth credentials. |
162
+ | `JsonFileBackend` | File-based `SyncStateBackend`; one JSON per session under a root directory. |
163
+ | `SessionState` | Full persisted session document (`to_dict` / `from_dict` for custom stores). |
164
+ | `SyncStateBackend` | Protocol — implement `load` / `save` to plug in any storage layer. |
165
+ | `Q2GoogleSettings` | Pydantic settings; batch sizes, timeouts, and paths with env-var overrides. |
166
+ | `get_settings` | Return a singleton `Q2GoogleSettings` from environment / `.env`. |
167
+
168
+ Lower-level symbols in `q2google.gphotos`:
169
+
170
+ | Symbol | Description |
171
+ |--------|-------------|
172
+ | `GooglePhotosAPI` | Thin `aiohttp` wrapper for Library v1 — use as `async with GooglePhotosAPI(...) as api`. |
173
+ | `GooglePhotoLibraryPort` | Protocol matching `GooglePhotosAPI`; implement for testing or alternative HTTP clients. |
174
+ | `PhotosScopes` | Enum of OAuth scopes (`READ_AND_APPEND`, `READ_ONLY`, `APPEND_ONLY`). |
175
+
176
+ ## CLI
177
+
178
+ The package ships a CLI for one-off or scripted use:
179
+
180
+ ```bash
181
+ q2google sync \
182
+ --start-date 2026-01-08 \
183
+ --end-date 2026-01-09 \
184
+ --credentials client_secret.json \
185
+ --token token.json
186
+ ```
187
+
188
+ Useful options:
189
+
190
+ | Option | Description |
191
+ |--------|-------------|
192
+ | `--state-dir` | JSON session root (default: `.q2google_sessions` or `Q2GOOGLE_STATE_DIR`) |
193
+ | `--session-id` | Stable id to resume a run (`Q2GOOGLE_SESSION_ID` if unset) |
194
+ | `--batch-size` | Files per cycle for **new** sessions; ignored when resuming (persisted session wins) |
195
+ | `--fail-fast` | Stop on first error after persisting state |
196
+ | `--log-level DEBUG` | Verbose logging |
197
+
198
+ ## Library usage
199
+
200
+ ### Minimal example
201
+
202
+ ```python
203
+ import asyncio
204
+ from datetime import datetime
205
+
206
+ from gopro_api import AsyncGoProClient
207
+
208
+ from q2google import (
209
+ GoProToPhotosSync,
210
+ GooglePhotosClient,
211
+ GooglePhotosOAuth,
212
+ JsonFileBackend,
213
+ )
214
+ from q2google.gphotos.api import GooglePhotosAPI
215
+ from q2google.gphotos.models import PhotosScopes
216
+
217
+
218
+ async def main() -> None:
219
+ oauth = GooglePhotosOAuth(
220
+ client_secrets_file="client_secret.json",
221
+ scopes=[PhotosScopes.READ_AND_APPEND],
222
+ token_file="token.json",
223
+ )
224
+
225
+ async with (
226
+ AsyncGoProClient() as gopro,
227
+ GooglePhotosAPI(credentials=oauth) as api,
228
+ ):
229
+ photos = GooglePhotosClient(api=api)
230
+ backend = JsonFileBackend(root_dir=".q2google_sessions")
231
+
232
+ syncer = GoProToPhotosSync(
233
+ gopro=gopro,
234
+ photos=photos,
235
+ state_backend=backend,
236
+ )
237
+
238
+ responses = await syncer.sync_date_range(
239
+ start_date=datetime(2026, 1, 8),
240
+ end_date=datetime(2026, 1, 9),
241
+ session_id="my-session",
242
+ )
243
+ print(f"Created {len(responses)} batch(es).")
244
+
245
+
246
+ asyncio.run(main())
247
+ ```
248
+
249
+ ### Resuming a session
250
+
251
+ Pass the same `session_id` on subsequent runs. `GoProToPhotosSync` loads the persisted `SessionState` and skips already-completed items:
252
+
253
+ ```python
254
+ responses = await syncer.sync_date_range(
255
+ start_date=datetime(2026, 1, 8), # ignored when resuming
256
+ end_date=datetime(2026, 1, 9), # ignored when resuming
257
+ session_id="my-session", # same key → resumes from checkpoint
258
+ )
259
+ ```
260
+
261
+ ### Custom state backend
262
+
263
+ Implement `SyncStateBackend` to persist sessions in any storage layer (database, object store, etc.):
264
+
265
+ ```python
266
+ from q2google import SessionState, SyncStateBackend
267
+
268
+
269
+ class RedisBackend:
270
+ def load(self, session_id: str) -> SessionState | None:
271
+ raw = redis_client.get(session_id)
272
+ return SessionState.from_dict(json.loads(raw)) if raw else None
273
+
274
+ def save(self, state: SessionState) -> None:
275
+ redis_client.set(state.session_id, json.dumps(state.to_dict()))
276
+ ```
277
+
278
+ Pass it directly to `GoProToPhotosSync(state_backend=RedisBackend())`. No other changes required.
279
+
280
+ ### Stage completion hook
281
+
282
+ `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:
283
+
284
+ ```python
285
+ from q2google.state.base import SessionState, StageKey
286
+ from q2google.photos import MediaItemBatchCreateResponse
287
+
288
+
289
+ async def report(
290
+ stage: StageKey,
291
+ state: SessionState,
292
+ responses: list[MediaItemBatchCreateResponse] | None,
293
+ ) -> None:
294
+ print(f"[{stage}] items={len(state.items)} stage_states={state.stages}")
295
+
296
+
297
+ responses = await syncer.sync_date_range(
298
+ start_date=datetime(2026, 1, 8),
299
+ end_date=datetime(2026, 1, 9),
300
+ session_id="my-session",
301
+ on_stage_complete=report,
302
+ )
303
+ ```
304
+
305
+ ### Public API
306
+
307
+ All public symbols are importable directly from `q2google`:
308
+
309
+ | Symbol | Description |
310
+ |--------|-------------|
311
+ | `GoProToPhotosSync` | Main orchestrator; runs discovery → transfer → create. |
312
+ | `GooglePhotosClient` | Resumable upload facade (`upload_file_path`, `create_media_items`). |
313
+ | `GooglePhotosOAuth` | Load, refresh, or obtain Google OAuth credentials. |
314
+ | `JsonFileBackend` | File-based `SyncStateBackend`; one JSON per session under a root directory. |
315
+ | `SessionState` | Full persisted session document (`to_dict` / `from_dict` for custom stores). |
316
+ | `SyncStateBackend` | Protocol — implement `load` / `save` to plug in any storage layer. |
317
+ | `Q2GoogleSettings` | Pydantic settings; batch sizes, timeouts, and paths with env-var overrides. |
318
+ | `get_settings` | Return a singleton `Q2GoogleSettings` from environment / `.env`. |
319
+
320
+ Lower-level symbols in `q2google.gphotos`:
321
+
322
+ | Symbol | Description |
323
+ |--------|-------------|
324
+ | `GooglePhotosAPI` | Thin `aiohttp` wrapper for Library v1 — use as `async with GooglePhotosAPI(...) as api`. |
325
+ | `GooglePhotoLibraryPort` | Protocol matching `GooglePhotosAPI`; implement for testing or alternative HTTP clients. |
326
+ | `PhotosScopes` | Enum of OAuth scopes (`READ_AND_APPEND`, `READ_ONLY`, `APPEND_ONLY`). |
327
+
328
+ ## Configuration
329
+
330
+ 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.
331
+
332
+ | Variable | Purpose |
333
+ |----------|---------|
334
+ | `GP_ACCESS_TOKEN` | **GoPro cloud access token** — read by `AsyncGoProClient`; required for discovery |
335
+ | `Q2GOOGLE_CREDENTIALS_PATH` | Google OAuth client secrets JSON path |
336
+ | `Q2GOOGLE_TOKEN_PATH` | Authorized user token path |
337
+ | `Q2GOOGLE_STATE_DIR` | JSON session state directory |
338
+ | `Q2GOOGLE_SESSION_ID` | Default session id when `--session-id` is omitted |
339
+ | `Q2GOOGLE_SYNC_BATCH_SIZE` | Transfer batch size for **new** sessions |
340
+ | `Q2GOOGLE_PHOTOS_LIBRARY_BATCH_SIZE` | Items per `batchCreate` (1–50) |
341
+ | `Q2GOOGLE_FAIL_FAST` | `true` / `false` |
342
+ | `Q2GOOGLE_LOG_LEVEL` | e.g. `INFO`, `DEBUG` |
343
+ | `Q2GOOGLE_GOOGLE_PHOTOS_TIMEOUT_SECONDS` | Library API request timeout |
344
+ | `Q2GOOGLE_DOWNLOAD_CHUNK_SIZE_BYTES` | CDN stream chunk size |
345
+
346
+ See `q2google.config.Q2GoogleSettings` for the full list and defaults.
347
+
348
+ ## Architecture
349
+
350
+ `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.
351
+
352
+ ```mermaid
353
+ sequenceDiagram
354
+ participant Caller
355
+ participant Sync as GoProToPhotosSync
356
+ participant GoPro as AsyncGoProClient
357
+ participant Photos as GooglePhotosClient
358
+ participant Store as SyncStateBackend
359
+
360
+ Caller->>Sync: sync_date_range(start, end, session_id)
361
+ Sync->>Store: load(session_id)
362
+ Store-->>Sync: SessionState or new
363
+ Note over Sync: discovery
364
+ Sync->>GoPro: list_media_items, get_download_url
365
+ Sync->>Store: save(state)
366
+ Note over Sync: transfer
367
+ Sync->>Photos: upload_file_path per item
368
+ Sync->>Store: save(state)
369
+ Note over Sync: create
370
+ Sync->>Photos: create_media_items_from_upload_sessions
371
+ Sync->>Store: save(state)
372
+ Sync-->>Caller: list of batch create responses
373
+ ```
374
+
375
+ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for module layout and extension points.
376
+
377
+ ## Development
378
+
379
+ ```bash
380
+ uv sync
381
+ task format # Ruff import fix + format
382
+ task lint # Ruff check + format check (no writes)
383
+ task test # Pytest with coverage on `q2google`
384
+ ```
385
+
386
+ ## License
387
+
388
+ See repository metadata (add a `LICENSE` file if needed).