Metadata-Version: 2.4
Name: gphotos-suite
Version: 1.0.2
Summary: CLI and typed Python library for the Google Photos Library API, scoped to app-created data.
Author-email: Benjamin THOMAS <bth0mas@free.fr>
License-Expression: MIT
Project-URL: Repository, https://gitlab.com/bth0mas/gphotos-suite
Project-URL: Issues, https://gitlab.com/bth0mas/gphotos-suite/-/issues
Keywords: google-photos,google-photos-api,cli,photos,albums
Classifier: Development Status :: 5 - Production/Stable
Classifier: Environment :: Console
Classifier: Intended Audience :: End Users/Desktop
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Multimedia :: Graphics
Classifier: Topic :: Utilities
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: google-api-python-client
Requires-Dist: google-auth-oauthlib
Provides-Extra: progress
Requires-Dist: tqdm; extra == "progress"
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Dynamic: license-file

# gphotos-suite

**Google Photos from your terminal, and from your code: upload a whole folder as a new album in one command, pipe or import your way to everything else.**

Listing, searching, downloading, renaming: each point of Google Photos' API is a single command, and because every command speaks JSON, they chain together naturally — `gph media list | gph media download - -o ./backup`. Uploads keep track of what already went through, so an interrupted run resumes instead of duplicating half your photos.

Under the hood, `gph` is a thin layer over `gphotos_suite`, the small, fully typed Python library it is built on: one method per API call, lazy authentication (nothing touches the network until you actually ask for something), and credentials you can inject from your own OAuth flow. Neither half is an afterthought — anything `gph` does is available to import.

One caveat worth knowing before you start: both work on app-created data only, meaning the albums and media created through this tool's own OAuth client, not your whole personal library. That restriction is Google's, not mine — broader `photoslibrary.readonly` access was withdrawn from third-party apps in March 2025.

## Requirements

- Python >= 3.10 (set by `google-auth-oauthlib`, which dropped 3.9 in 1.4.0)
- `google-api-python-client`
- `google-auth-oauthlib`
- optional: `tqdm` (progress bars on long listings)
- a Google Cloud OAuth client secrets file (see Configuration below)

## Install

```bash
pip install gphotos-suite
# with progress bars on long listings:
pip install "gphotos-suite[progress]"
```

That puts `gph` on your PATH and makes `gphotos_suite` importable. The
quotes matter in zsh, which would otherwise read the brackets as a
glob pattern.

From a checkout instead, for development:

```bash
pip install -e ".[test,progress]"
# or dependencies only, without installing the package itself:
pip install -r requirements.txt
```

The first API call of the first run opens a browser for OAuth consent
and writes the token file (auth is lazy: merely constructing the
client, or asking for `--help`/`-V`, never triggers it).

## Configuration

Two paths are configurable, each resolved as **explicit argument >
environment variable > default**:

| What | Env var | Default | Also settable via |
|---|---|---|---|
| OAuth client secrets | `GPH_CREDENTIALS` | `gphotos_credentials.json`, relative to the current directory | `GooglePhotos(cred_file=...)` |
| Token file (written on first run) | `GPH_TOKEN` | `token.json`, relative to the current directory | `--token`/`-T` on every command |

`~` is expanded in both, so `GPH_CREDENTIALS=~/secrets/gphotos.json`
works even where the shell wouldn't expand it. The token flag is `-T`,
not `-t`, so `-t` stays free for `--title` on `gph album upload`.

Those variables can also come from a **`.env` file** in the current
directory (or wherever `GPH_ENV_FILE` points):

```
# .env -- gitignored
GPH_CREDENTIALS=~/myVault/etc/secrets/gphotos_credentials.json
GPH_TOKEN=~/myVault/etc/secrets/gphotos_token.json
```

The real environment wins over `.env`, so `GPH_TOKEN=other.json gph …`
still overrides it for a single run. Supported syntax is deliberately
minimal — `KEY=VALUE`, an optional `export ` prefix, quotes, `#`
comment lines — with no interpolation and no extra dependency.

### Handle both files as secrets

They are not equivalent, and neither is disposable:

- **The credentials file** (`GPH_CREDENTIALS`) is what you download
  from the Google Cloud console. It identifies the *application*, and
  its `client_secret` is a real credential: Google's OAuth policies
  require treating it "with extreme care", because anyone holding it
  can use your app's identity to reach user data. Google rotates and
  hashes client secrets for that reason. Never commit it, never
  publish it.
- **The token file** (`GPH_TOKEN`) is written on your first
  authentication and holds a **refresh token**: standing access to your
  Google Photos data until you revoke it. It also embeds a copy of
  `client_id`/`client_secret`, because google-auth's format requires
  them to refresh — which is why the file is self-sufficient, and why
  it is the more dangerous of the two to leak: it carries the
  application's credentials *and* an authorisation to act on your
  account.

The token file is created `0600` (owner-only) and rewritten atomically
after every refresh. Keep it that way: don't copy it into a shared
directory, a backup that others can read, or a repository. Both
default names are covered by this project's `.gitignore`, but that
only protects *this* checkout.

If a token file leaks, revoke the app's access at
[myaccount.google.com/permissions](https://myaccount.google.com/permissions);
if the credentials file leaks, rotate the client secret in the Google
Cloud console.

**Refresh tokens expire after 7 days while the OAuth client is in
"Testing" mode** in the Google Cloud console — you will be asked to
re-authenticate every week. Switching the client to "In production"
stops that; with app-created-only scopes it needs no Google review.
When it happens, `gph` says so plainly rather than showing a
traceback.

## Library use

Fully type-hinted (`py.typed` shipped), snake_case throughout
(`GooglePhotos(token_file=..., cred_file=...)`), one method per API
call:

```python
from gphotos_suite import GooglePhotos

gp = GooglePhotos()                       # no network, no OAuth yet
album = gp.create_album("Holidays 2026")
media = gp.upload_file("beach.jpg", description="Holidays 2026")
gp.add_to_album(album["id"], [media["id"]])

for item in gp.search_media_items(favorites_only=True):
    print(item["filename"], gp.download_media(item)[:4])
```

`examples/shrink_album.py` is the fuller reference: resumable work,
error handling, progress.

**Lazy**: constructing the client performs no network access, no
OAuth, no disk write — authentication happens on the first API call,
or explicitly via `gp.authenticate()`. **Credentials injection**:
`GooglePhotos(credentials=creds)` accepts an existing google.oauth2
credentials object (from your own flow, a stored token, …); injected
credentials are refreshed in memory when possible but never written
to the token file, and if they're unusable the client raises
`CredentialsError` rather than opening a browser behind your back.

Error model: every Library API failure propagates `googleapiclient`'s
`HttpError` as-is (no method returns an `{"error": ...}` dict); the
two raw-HTTP paths raise `UploadError` (stage-1 upload / `batchCreate`
per-item failure) or `DownloadError` (`baseUrl` fetch), and credential
handling raises `CredentialsError` — all subclasses of `GPhotosError`,
all importable from the package root.

## CLI usage

```bash
gph --help
gph --version   # -V/--version also works on every subcommand: gph album upload -V, etc.
gph album --help
gph media --help

gph album list --format csv
gph album content ALBUM_ID > content.json
gph album content --all --format csv
gph album create "New album"
gph album upload ./some_directory
gph album upload ./some_directory --title "A different title"
gph album upload ./some_directory --description "Custom description"
gph album title ALBUM_ID              # prints the album's current details
gph album title ALBUM_ID "New title"
gph album cover ALBUM_ID              # prints the album's current details
gph album cover ALBUM_ID MEDIA_ID
gph album add ALBUM_ID media.json
gph album add ALBUM_ID MEDIA_ID_1 MEDIA_ID_2
gph media upload ./dir --format ndjson | gph album add ALBUM_ID -
gph album rm ALBUM_ID media.json
gph album rm ALBUM_ID MEDIA_ID
gph media list --shared-only
gph media upload photo.jpg ./some_directory another.jpg
gph media upload ./some_directory --description "Custom description"
gph media download items.json -o ./downloaded
gph media list --format ndjson | gph media download - -o ./downloaded
gph album content ALBUM_ID | gph media download - -o ./downloaded
gph album create "New album" | gph album title - "Renamed"
gph media download MEDIA_ID_1 MEDIA_ID_2 -o ./downloaded
gph media search --favorites
gph media search --media-type video --category TRAVEL
gph media search --date 2024-12-25 --date-range 2024-07-01:2024-07-31
gph media get MEDIA_ID
gph media get MEDIA_ID_1 MEDIA_ID_2
gph media get MEDIA_ID --raw          # Google's response, untouched
gph media get ID_1 ID_2 --format ndjson | gph media download - -o ./downloaded
gph media description MEDIA_ID              # prints the media item's current details
gph media description MEDIA_ID "New description"
```

Command names can be abbreviated to any **unambiguous prefix**, the
same way argparse already abbreviates long options (`gph --ver`):

```bash
gph a l              # == gph album list
gph m do items.json  # == gph media download items.json
gph a c ALBUM_ID     # ambiguous (content/cover/create): says so
                     #    instead of guessing
```

An exact name always beats a prefix, and an ambiguous prefix is an
error naming the candidates — never a silent pick. Prefer full names
in scripts: a prefix that is unique today can become ambiguous the
day a command is added.

Every command whose stdout is a *list of records* takes the same
`--format` with the same choices — `text`, `json`, `ndjson` (or its
synonym `jsonl`), `csv`. Only the default differs.

The three browsing commands — `album list`, `media list`,
`media search` — **adapt to where their output goes**: `text` when
you're looking at a terminal, `json` as soon as the output is piped
or redirected. So this reads nicely:

```bash
gph album list
```

and this needs no flag to be machine-readable:

```bash
gph album list | gph album content -
```

`--help` reports whichever default applies in the current context.
Pass `--format` explicitly whenever you want to be sure — in scripts,
that is worth doing.

The pipeline commands (`album content`, `media upload`,
`media download`, `media get`) are always `json`: their output isn't
meant to be read by eye, so there is nothing to adapt.
Commands returning a single object (`album create`, `album title`,
`album cover`, `media description`) always print plain
JSON — a CSV of one record would be pointless.

`title`, `cover`, `add`, `rm` operate on **one album per
call**: the album argument is a raw ID, or a path to a JSON file
containing a single album object (`{"id": ..., "title": ...}`). For
several albums, loop the command in a shell script.

The *media* inputs of `add`/`rm` are flexible, though: raw media IDs,
`.json`/`.ndjson` files (array of media objects/IDs, or a single
object), or `-` to read entries from STDIN — mixable in one call,
deduplicated, order preserved.

`title` and `cover` are get-or-set: the new value is an optional
second argument. Omit it and the command prints the album's current
details instead (via `albums.get`) — `.title` or
`.coverPhotoMediaItemId` on the JSON output.

`cover`'s new value is a media item ID, or a path to a JSON file
containing a single media item object — nothing else. It is *not*
resolved against the album's contents, so listing them
(`gph album content`) is your job when you need to find the right ID.

`content` accepts **one or more** album inputs instead: any mix of
raw IDs, `.json`/`.ndjson` files (a single album object, or an array
of album objects/IDs), or `-` to read entries from STDIN. It requires
at least one input by default — no implicit "everything" — with one
escape hatch: `-a`/`--all` processes every album instead (add
`--shared-only` to restrict that to shared albums); `--all` and
explicit inputs are mutually exclusive.

`gph album upload ./dir` is `gph album create "dir"` + `gph media
upload ./dir` + `gph album add` composed into one command — same
upload logic (see below), then it creates a new album (named after
the directory by default, or `--title`/`-t` to pick a different name)
and attaches every uploaded item to it. It prints the finished album
on stdout, re-fetched *after* the media are attached, so the output
describes a populated album (`mediaItemsCount`, cover) rather than
the empty one `albums.create` returns — pipe it, or redirect it to a
file. Use `gph album create` +
`gph media upload` + `gph album add` directly instead if you need the
pieces separately (e.g. uploading into an already-existing album).

`gph media upload` on its own doesn't create or touch any album — it
just uploads files (mixing filenames and directories, one level deep)
and prints the resulting media items. Pipe its output into `gph
album add` to attach them to an album afterwards:
`gph media upload ./dir --format ndjson | gph album add ALBUM_ID -`.

Both `upload` commands save progress to `--mediafile` (default:
`medias.json`) **after every successful upload**, and reload it on the
next run: on the first upload error the run stops immediately, and
re-running with the same `--mediafile` skips whatever already
succeeded (`--force-reload` to ignore it and re-upload everything).
The file is written atomically and per item, not just at exit, so a
run killed outright — a `kill`, an OOM, a power loss — still resumes
instead of re-uploading (and duplicating) everything. A SIGTERM is
turned into a clean shutdown for the same reason. Already-uploaded files are
tracked by full path rather than bare filename in both commands
(`gph media upload` needs this since it can take several directories
that might contain same-named files; `gph album upload` only ever has
one directory so it wouldn't strictly need it, but uses the same
mechanism since the two commands share this code).

Both `upload` commands also take `--description`/`-d`, applied to
every uploaded item in that run. `gph media upload` defaults to each
file's own name (unchanged default); `gph album upload` defaults
instead to the album's title (the directory name, or `--title`/`-t`
if given) -- not the file names -- since every item in that call
belongs to the same new album. Pass `--description` explicitly on
either command to override its default.

`gph media download` downloads the original bytes of media items (via
`core.download_media()`) to local files, named after each item's
`filename`. Inputs can be full media item objects -- as produced by
`gph media list`/`gph album content`, via a `.json`/`.ndjson` file or
`-` for STDIN (NDJSON style, one object per line) -- or bare media
IDs, resolved through `gph media get`'s underlying `mediaItems.get`
call to obtain a usable `baseUrl`. An ID that fails to resolve (e.g.
invalid) is reported as a failure alongside any download failures,
rather than aborting the whole run. Unlike the upload
commands, there's no `--mediafile`/resume machinery: a repeated or
failed download just re-fetches and overwrites a local file, it
can't create a duplicate item in the real account the way a retried
upload could,
so each item is attempted independently and failures are reported at
the end (exit code 1) rather than stopping the whole run.

`gph media search` filters by media type, favorite status, content
category (`--category`/`--exclude-category`, repeatable, up to 10
each), exact dates (`--date YYYY-MM-DD`, repeatable, up to 5), and
date ranges (`--date-range START:END`, repeatable, up to 5) --
combinable in one call (they're ANDed together; each multi-valued
flag is ORed internally, matching the API's own filter semantics). At
least one filter flag is required — with none, use `gph media list`
instead, which already lists everything unfiltered. This is a
separate command from `gph album content` because the API rejects a
request that sets both `albumId` and `filters` in the same call.

`gph media get` fetches media items by ID and prints **one record per
requested ID**, always as a list, in request order: the media item
itself when it resolved, or its `id` plus an `_error_` key when it
didn't. That last part recovers something the API drops — a raw
per-ID status says *what* went wrong but never *which* ID it was,
only its position in the list. A partial failure exits 1 and names
the failing IDs on stderr; stdout still carries everything that
resolved. Because every record has a top-level `id`, the output pipes
straight into other commands:

```bash
gph media get ID_1 ID_2 --format ndjson | gph media download - -o ./downloaded
```

Add `--raw` to get Google's response untouched instead, asymmetry
included: a single ID returns the media item directly
(`mediaItems.get`), several return `mediaItems.batchGet`'s list of
`{"mediaItem"}`/`{"status"}` wrappers. `--raw` needs a JSON format —
projecting it to `csv` or `text` would defeat its purpose.

Typical use: refresh a stale `baseUrl` (short-lived, roughly an hour)
from an ID alone, e.g. before `gph media download`.

`gph media description` is get-or-set, same as `gph album title`/
`gph album cover`: the new value is an optional second argument, omit
it to print the media item's current details instead (via
`gph media get`'s underlying `mediaItems.get`). Unlike title/cover,
there's only ever this one settable field -- `mediaItems.patch` (the
API this wraps) allows changing `description` and nothing else (not
filename, date, or any other metadata).

## Examples

`gph` exposes only direct Library API operations (with the exception
of `gph album upload`, which is a convenient combo of basic
operations, performed on a very regular basis). 

You can find in `examples/` composite workflows; the directory
currently holds two takes on the same one — duplicating an album with
every photo resized:

- `examples/shrink_album.py` — uses `gphotos-suite` as a library, and
  is the reference for doing so: lazy client, the unified error model,
  and the resumability pattern that keeps a failed run from creating
  duplicate media items on the retry.
  
- `examples/shrink_album.sh` — the same workflow composed out of `gph`
  commands, `gm` and `jq`, and the reference for scripting the CLI.
  Deliberately the simpler version: no resume.

Both were `gph album shrink` until v0.10.0. They need GraphicsMagick
(`gm`); set `GPH_GM` to point at it if it isn't on `$PATH`.

## Testing

```bash
python3 -m venv .venv_test   # once
.venv_test/bin/pip install -e ".[test,progress]"
.venv_test/bin/pytest
```

`.venv_test/` is gitignored and meant to be a standing venv dedicated
to running this suite (re-run the `pip install -e` step whenever
dependencies change — it's a fast no-op otherwise).

Offline only: no test ever calls the real Google Photos API (see
`tests/conftest.py`). Put any throwaway/manual verification script
inside `tests/` too, not in `/tmp` or elsewhere — a real credential
call happened twice already from scripts run outside that directory.

Three levels: the `cli/*.py` tests stub out the whole `GooglePhotos`
client; `tests/test_core.py` fakes Google's own client surface
instead, so `core.py`'s request bodies, pagination and batching are
verified rather than stubbed away; and `tests/test_auth.py` fakes the
credential objects to walk the lazy authentication logic offline.

## Layout

```
src/gphotos_suite/
  core.py          # GooglePhotos wrapper (Library API v1 calls)
  cli_common.py     # shared CLI helpers (arg parsing, I/O, formatting)
  cli/               # one module per `gph album|media <action>` subcommand
examples/            # composite workflows built on the library or the CLI
tests/               # pytest suite (offline only, see Testing above)
```

## Caveats

Limits of what *is* implemented:

- **Uploads are not batched.** `mediaItems.batchCreate` accepts up to
  50 items per call; this package sends one call per file. Correct, but
  slower than it could be on a large upload. The other batch endpoints
  *are* used properly: `albums.batchAddMediaItems`,
  `albums.batchRemoveMediaItems` and `mediaItems.batchGet` all split
  into calls of 50.
- **No retry on HTTP 429.** Being rate-limited stops an upload run;
  `--mediafile` means the next run resumes instead of re-uploading (and
  duplicating) what already succeeded.
- **`baseUrl`s expire after about an hour.** `gph media download` given
  raw media IDs re-resolves each one, so its URLs are always fresh —
  but given a `.json` file produced earlier, the download fails on
  stale URLs. Pass IDs, or regenerate the file.

Not implemented at all:

- `albums.share` / `albums.unshare` — **withdrawn by Google on
  2025-03-31**. `photoslibrary.sharing` was their only authorization
  and it was removed, so both now answer `403 PERMISSION_DENIED`
  unconditionally. `gph album share`/`unshare` and the matching
  library methods existed until v0.12.0 and were removed there; there
  is no replacement, sharing an album is a manual step in the Google
  Photos app. Note that Google's discovery document still advertises
  both methods — only their scope list gives them away.
- `albums.addEnrichment`: a *write-only* feature — the API lets you add
  text/location/map blocks to an album but never modify or delete them.
  (Recreating the album from scratch would work, but breaks the sharing
  link of an album already shared.)
- `sharedAlbums.join/leave/get` — joining or leaving albums shared *by
  others*, outside this package's app-created scope. `get` was
  withdrawn on 2025-03-31 alongside `albums.share`.
- comments, likes and any other engagement data: the Library API
  exposes none, and Takeout doesn't export them either.

## History

This began as a small pile of ad-hoc scripts I had accumulated around
the Google Photos API. Turning them into something packaged, tested and
shareable — the CLI, the library, the test suite, the documentation and
the CI — was done with [Claude Code](https://claude.com/claude-code)
over a series of sessions.
