Move media from Drive to a Cloud Storage bucket with stored thumbnails

This commit is contained in:
Ian Gulliver
2026-08-20 11:47:42 -07:00
parent 52668a6a09
commit 75e37bc02c
11 changed files with 390 additions and 175 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ To run against real community data instead, see [docs/dev.md](docs/dev.md).
- `internal/auth` — Google sign-in and session cookies
- `internal/data` — tabular data sources: sample CSVs and Google Sheets
- `internal/directory` — the directory app: model load, handlers, self-service edits
- `internal/blob` — media from Google Drive, held in memory with thumbnails
- `internal/blob` — media from Cloud Storage, held in memory with stored thumbnails
- `internal/geocode` — address → coordinates for the map
- `web/` — page templates and static assets (frameworkless JavaScript)
- `sampledata/` — the fictional community served by default
+11 -9
View File
@@ -1,6 +1,6 @@
# Data model
The entities the directory serves, and how they are assembled. Structured data lives in one Google Sheet; blobs (photos, audio) are files beside it in the community shared drive, discovered by naming convention. Access rides on drive membership: the service account is a content manager of the drive, never granted through project IAM. The organized model is held in memory — nothing computed is ever written back to the sheet.
The entities the directory serves, and how they are assembled. Structured data lives in one Google Sheet in the community shared drive, reached through drive membership rather than project IAM; blobs (photos, audio) are objects in the media bucket, reached through project IAM. Each has exactly one home. The organized model is held in memory — nothing computed is ever written back to the sheet.
## Spreadsheet layout
@@ -18,7 +18,7 @@ School structure lives nowhere in the sheet: membership and the classroom and cr
1. Read the raw import.
2. Transform each import row into canonical person and household records (explosion, below). The Name to Email mapping applies during this step, since it fixes the key everything else uses.
3. Apply the Overrides tab onto the canonical records by email: unflagged rows patch existing records, flagged rows create new ones.
4. List the media drive and attach photo and pronunciation blobs to records by filename convention.
4. List the media bucket and attach photo and pronunciation blobs to records by object-name convention.
5. Hold the organized result in memory; the server refuses to start if the load fails, and the model reloads periodically.
Because Overrides rows are authored post-transform, their values skip import normalization — so canonical-value validation runs on every layer, not just the import.
@@ -53,7 +53,7 @@ The explosion turns each import row into one student record plus up to four adul
Canonical model columns, keyed by lowercased email, one row per person. Three kinds of content share the tab, distinguished only by authorship and one flag:
- **Corrections** (hand): fix anything the import gets wrong — swapped name fields, bad phone numbers — and carry person flags with no import source, like room-parent assignments and the new-to-Helios marker.
- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media drive and never touch the sheet.
- **Self-service text** (app): facts, pronouns, preferred name, phone, address — the latter two hideable via the `-` clear — and the Opted Out flag. Every self-service edit warns that it doesn't affect the values shown in Veracross. The app writes these cells directly; moderating a contribution is the same act as any other correction. Photo and pronunciation uploads go straight to the media bucket and never touch the sheet.
- **Additions** (hand, flagged): people with no import row at all. The flag inverts the source expectation.
Cell semantics are sparse: an empty cell contributes nothing, `-` clears the underlying value. An addition is just an override applied to an empty base record, so the merge logic is uniform; the flag selects the validation instead:
@@ -69,11 +69,13 @@ Family-level fields (address, family photo caption, family phone) ride on a pare
**Opted Out** removes the person entirely at load: their record, their membership in families and parent-contact lists, and any room-parent assignment all vanish from the model. Because viewing the directory requires being in it, opting out also locks the person out — they get a permissions error until the school clears the flag. People set it from their own page, parents set it for their kids (each with a confirmation spelling out the consequences), or an admin sets the cell by hand.
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; the drive archive is their history.
Every change to Overrides appends a Change Log row: timestamp, actor, the row's email, then the previous value of each column that changed — `-` marking a previously empty cell, untouched columns left blank. Media uploads are not logged here; bucket object versions are their history.
## Media blobs
Photos and pronunciation recordings are files in the media shared drive, named by convention: `<email local part>-photo` and `<email local part>-pronunciation` for people, `<family key hash>-photo` and `<family key hash>-pronunciation` for families. Presence means existence — no sheet cell records a filename — and freshness comes from the file's modified time. Uploads replace the file and archive the previous version; superseded versions stay in an archive folder.
Photos and pronunciation recordings are objects in the media bucket `gs://heliosian-media`, under a `people/` or `families/` prefix and named by convention: `<email local part>-photo` and `<email local part>-pronunciation` for people, `<family key hash>-photo` and `<family key hash>-pronunciation` for families, each keeping its source extension. Presence means existence — no sheet cell records a filename — and freshness comes from the object generation.
Every image is stored with a `-thumb.jpg` sibling written at the same time, so startup loads thumbnails instead of computing them; an image object with no stored thumbnail is fatal. Uploads overwrite the object and its thumbnail, and bucket versioning retains the superseded generations. An upload arriving in a different format also deletes the previous extension's object, so a key never resolves to two files.
## Person
@@ -84,8 +86,8 @@ One record per person, all roles in one shape:
- **Roles**: student, parent, and staff booleans, derived from sourcing; combinations are valid. Display strings derive from the flags.
- **New to Helios**: override-carried flag marking people who just joined the community; drives the matching filter toggle.
- **Pronouns**: optional; a curated list plus a freeform escape hatch.
- **Pronunciation**: optional audio recording of the person's name, from the media drive.
- **Photo**: official portrait or personal upload, from the media drive; people may opt for an illustrated avatar instead.
- **Pronunciation**: optional audio recording of the person's name, from the media bucket.
- **Photo**: official portrait or personal upload, from the media bucket; people may opt for an illustrated avatar instead.
- **Facts**: optional about-me text — first-person blurbs for students, professional bios for staff.
- **Student fields**: grade, classroom, and crew from the homeroom split; parent contact emails derive from the student's household adults.
- **Staff fields**: job title, department, grade band, and classroom/crew assignment for teaching staff.
@@ -98,8 +100,8 @@ A household groups adults and kids; a student belongs to one household normally,
- **Key**: a hash of the sorted emails of every member — students and adults alike — so identity is order-insensitive and derives from nothing but membership. Any membership change (new student, student leaves, parent change) produces a new key, deliberately: the family's URL and photo association reset along with its composition.
- **Members**: the adults in the household and the students whose rows name it.
- **Photo and caption**: the family photo from the media drive plus a who's-who description naming everyone in it.
- **Pronunciation**: optional audio recording of the family name, from the media drive.
- **Photo and caption**: the family photo from the media bucket plus a who's-who description naming everyone in it.
- **Pronunciation**: optional audio recording of the family name, from the media bucket.
- **Address**: as much as the family chooses to share — full postal address or just city and state, seeded from the import and updatable via self-service.
- **Phone**: optional household phone.
+9 -2
View File
@@ -32,7 +32,7 @@ The service was created once with:
Each flag is load-bearing:
- `--service-account` — the runtime identity. Application-default credentials inside the container resolve to `directory@` through the metadata server; there is no key file anywhere in the system.
- `--min-instances 1` — startup preloads every media file from Drive before listening (about two and a half minutes all told); far too slow for scale-to-zero, and close enough to Cloud Run's four-minute startup probe window to watch as the media set grows.
- `--min-instances 1` — startup preloads every media object before listening (well under a minute); still far too slow for scale-to-zero.
- `--max-instances 1` — the directory model and blob store live in per-instance memory with no cross-instance coherency; a self-service edit refreshes only the instance that handled it, so a second instance would serve stale data.
- `--memory 4Gi` — the blob store holds all media and thumbnails in RAM, and the preload peaks well above the steady-state footprint (a roughly 600 MB blob store OOMed a 2 GiB instance during preload). The startup log line `blob store: … MB in memory` reports the steady state; resize when boots start failing or the footprint approaches half the limit.
- `--no-cpu-throttling` — the directory model and blob store refresh on five-minute tickers between requests; default throttling would starve them.
@@ -53,11 +53,18 @@ Secret Manager secrets, delivered as environment variables per `--set-secrets` a
- `heliosian-geocoding-key` — the Geocoding API server key, mirrored locally as `creds/geocoding.key`
- `heliosian-maps-browser-key` — the Maps JavaScript browser key, mirrored locally as `creds/maps.key`
## Media storage
Photos and pronunciation recordings live in `gs://heliosian-media` (us-west1, uniform access, public access prevented, object versioning on), the single source of truth for media — see `docs/data.md` for the naming convention and stored thumbnails. It sits in the same region as the service, so the whole set preloads into memory in about half a minute with no per-request throttle to work around, and versioning carries the upload history that used to need an archive folder.
Because the bucket is private and every read goes through the app's own sign-in gate, no object is ever publicly readable; the service reads and writes it as `directory@`.
## IAM
`directory@heliosian.iam.gserviceaccount.com` is both the data identity and the runtime identity:
- Data access: content manager on the community shared drive — which covers editing the `Directory` sheet inside it (self-service edits write cells and append to the Change Log tab) and managing media (uploads create files and archive old versions). Shared in Drive directly, never through project IAM.
- Data access: content manager on the community shared drive, which covers editing the `Directory` sheet inside it (self-service edits write cells and append to the Change Log tab). Shared in Drive directly, never through project IAM.
- Media: `roles/storage.objectAdmin` on `gs://heliosian-media`, granted on the bucket.
- Runtime: the Cloud Run service runs as it, and it holds Secret Manager Secret Accessor on each secret individually.
- Humans: `roles/iam.serviceAccountTokenCreator` on this account enables the local impersonation that real-data development uses (`docs/dev.md`).
+4 -3
View File
@@ -16,14 +16,14 @@ In sample mode `tools/screenshot` captures pages directly, no session needed (se
DIRECTORY_SHEET=<spreadsheet id> go run .
serves from the production spreadsheet and media drive (see `docs/data.md`) and turns on the full stack. The model loads at startup — the server refuses to start if the load fails — and reloads every five minutes. Real data never leaves the process: nothing is written to disk. Requirements:
serves from the production spreadsheet and media bucket (see `docs/data.md`) and turns on the full stack. The model loads at startup — the server refuses to start if the load fails — and reloads every five minutes. Real data never leaves the process: nothing is written to disk. Requirements:
- **Sign-in** — everything sits behind Google sign-in restricted to the school's Workspace domain (API paths get a 401 instead of the login page). The OAuth web client is read from `creds/oauth-client.json` or `GOOGLE_CLIENT_ID`; its authorized JavaScript origins must include `http://localhost:8080`. The server issues its own HMAC-signed session cookie; set `SESSION_KEY` to keep sessions valid across restarts.
- **Data access** — Google credentials come from application-default credentials impersonating the data service account, set up once per machine:
gcloud auth application-default login --impersonate-service-account=directory@heliosian.iam.gserviceaccount.com
This requires `roles/iam.serviceAccountTokenCreator` on that account. The community shared drive — which holds the `Directory` sheet and all media — is shared with `directory@` as content manager; local processes then act as exactly the identity production runs as, with no key file anywhere (the org forbids creating one).
This requires `roles/iam.serviceAccountTokenCreator` on that account. `directory@` reaches the `Directory` sheet as a content manager of the community shared drive, and the media bucket through project IAM; local processes then act as exactly the identity production runs as, with no key file anywhere (the org forbids creating one).
- **Maps** — a server key for the Geocoding API (`creds/geocoding.key` or `GOOGLE_MAPS_SERVER_KEY`; never rendered into pages, restrict by server IP or leave unrestricted for dev) and a browser key for the Maps JavaScript API (`creds/maps.key` or `GOOGLE_MAPS_BROWSER_KEY`; rendered into pages, restrict by HTTP referer). Geocoding results are cached in memory per address.
To capture authenticated real-data pages, launch the capture browser (`go run ./tools/capturebrowser`), sign in to the local server there once, and use `tools/browse` or `tools/screenshot -remote` — the session cookie lives in the capture profile.
@@ -42,7 +42,7 @@ No Node, no Docker, and no cloud credentials are needed for local development. R
## Tools
Each runs as `go run ./tools/<name>`. The sheet and drive tools authenticate with the same impersonated application-default credentials as the server (see Real data).
Each runs as `go run ./tools/<name>`. The sheet, drive, and bucket tools authenticate with the same impersonated application-default credentials as the server (see Real data).
- `screenshot`, `capturebrowser`, `browse` — page capture and browser driving; see `docs/screenshots.md`
- `startserver` — launch the app detached, wait for it to listen, print the pid, log path, and a minted session cookie (needs `SESSION_KEY` and `DIRECTORY_SHEET`)
@@ -55,4 +55,5 @@ Each runs as `go run ./tools/<name>`. The sheet and drive tools authenticate wit
- `createtabs` — create the directory sheet's local-layer tabs with their header rows
- `setcell` — set one cell in a tab by key column, appending the row if missing
- `probeblob` — time the download of a few drive media files
- `migratemedia` — copy the drive media folders into the media bucket, generating thumbnails; retired once the bucket is the only store
- `splash` — regenerate the iOS splash battery from the captured original page; see `docs/pwa.md`
+1 -1
View File
@@ -62,4 +62,4 @@ A copyable contact table for party planning and outreach: full name, email, role
- Favorites/bookmarks mark people and feed the email list's bookmark tab.
- Photos lazy-load; full-size view on click where the photo is the subject (family pages).
- All data is community-only, behind sign-in; opt-out removes a person on request.
- Self-service: viewing your own record, your kids', or your family page shows inline edit affordances — photo upload, pronunciation recording or upload, About Me text, preferred name, phone, address — plus opt-out for yourself or your kids. Media uploads replace the Drive file and archive the previous version; sheet-backed edits write the Overrides tab and append a Change Log row (see `docs/data.md`).
- Self-service: viewing your own record, your kids', or your family page shows inline edit affordances — photo upload, pronunciation recording or upload, About Me text, preferred name, phone, address — plus opt-out for yourself or your kids. Media uploads overwrite the bucket object, which retains the previous version; sheet-backed edits write the Overrides tab and append a Change Log row (see `docs/data.md`).
+1 -1
View File
@@ -23,5 +23,5 @@ More apps follow over time, all served by the same binary.
## Data
- Structured data comes from Google Sheets. The system of record is Veracross, and a direct integration replaces Sheets when an API becomes available.
- Blobs (photos and other uploads) live in Google Drive.
- Blobs (photos and other uploads) live in Cloud Storage.
- Existing data is imported from the current platforms.
+22
View File
@@ -11,12 +11,24 @@ require (
)
require (
cel.dev/expr v0.25.2 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.23.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.11.0 // indirect
cloud.google.com/go/monitoring v1.29.0 // indirect
cloud.google.com/go/storage v1.65.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -27,16 +39,26 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect
github.com/googleapis/gax-go/v2 v2.23.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea // indirect
google.golang.org/grpc v1.83.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
+35
View File
@@ -1,9 +1,25 @@
cel.dev/expr v0.25.2 h1:K6j46C81hXtZQfuX60cVWQFBJahKSE2gfRbNuvr5bFs=
cel.dev/expr v0.25.2/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.23.0 h1:6Gg1CMgpgubRG7DGz5Vf1pcoNo8RfiRiRAPS4crTp54=
cloud.google.com/go/auth v0.23.0/go.mod h1:4DhBRcqvtljQN3dJ57qtqbib5ZGCYE5f2crfiiC2EM0=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.11.0 h1:KieQ9Pb+LLPak1O3Rv3GgCxhnmkYf7Xyh0P5HfF1jFM=
cloud.google.com/go/iam v1.11.0/go.mod h1:KP+nKGugNJW4LcLx1uEZcq1ok5sQHFaQehQNl4QDgV4=
cloud.google.com/go/monitoring v1.29.0 h1:AHhDsFaSax1/4k+qlIDX/SDGe6hggnfXJ9dkgD9qBPY=
cloud.google.com/go/monitoring v1.29.0/go.mod h1:72NOVjJXHY/HBfoLT0+qlCZBT059+9VXLeAnL2PeeVM=
cloud.google.com/go/storage v1.65.0 h1:McbFt5j+hTNx+dkFuzq7teakIKcpqGp/cJZRxMyfvAc=
cloud.google.com/go/storage v1.65.0/go.mod h1:UsS9OgFg/XHOSYakQ8ZtLWWeyGkk1WnmD/GsGfN0BHM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 h1:l7+6kwRMJNwdCvYdDl7Eax+wzEYHSnNY7zrrfbhDdTA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0 h1:jLdiS1vO+XJFyDSWRHBx56r4s/NNtcl5J6KyCcWUX/w=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.57.0/go.mod h1:8lmpHY+1VRoteiOwyrQMDt1YGXOrFKCz+1wJW7n3ODY=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0 h1:RoO5+d7uCmDqovLrHCr2/BuViUXvdcrNxyNM1pN9dDQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.57.0/go.mod h1:YqwkQPrWSC7+byyc1VlKbWLBF5JsW5IoL6xUkemYSXk=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260714215040-dc233986426f h1:0Z1zcSLEmnj2c2CmJYBqewtS6pxhB39bNWUSEUAWjgk=
@@ -12,10 +28,19 @@ github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 h1:KZaTBSyshWX3MP5jukJcNSuXDQTO+rNpt0J564dX/eg=
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -45,16 +70,24 @@ github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kUL
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4=
github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9dPRWYAAbxhRCrKXPw=
go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 h1:0Qx7VGBacMm9ZENQ7TnNObTYI4ShC+lHI16seduaxZo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0/go.mod h1:Sje3i3MjSPKTSPvVWCaL8ugBzJwik3u4smCjUeuupqg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
@@ -90,6 +123,8 @@ google.golang.org/api v0.293.0 h1:p9XIWOf63U4OgYx120ZwVU8+vl4XTPmWfgVPnmOAS9w=
google.golang.org/api v0.293.0/go.mod h1:6n5tjEB1gzwniZTepZ0g5u+wM7Bof5GeULCx/zh8ZE0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94 h1:YJjbgu+dkp5kUJLfpMyCLfBIWZb/FcJyuLeo1gVBOuo=
google.golang.org/genproto v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:RRHjglSYABVCWpQ7USCpdfhcd9t4PkajvVwyynZizTc=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260807164820-c8921c73eeea h1:kVhQEPTpKQahD5+JSBTfBB19wcgQTTjAIn45MBqnyHk=
+124 -156
View File
@@ -1,9 +1,10 @@
// Package blob serves directory media from drive, held fully in memory with startup-generated thumbnails.
// Package blob serves directory media from cloud storage, held fully in memory with stored thumbnails.
package blob
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"image/jpeg"
@@ -22,49 +23,48 @@ import (
"golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"cloud.google.com/go/storage"
"google.golang.org/api/iterator"
)
const (
folderMime = "application/vnd.google-apps.folder"
Bucket = "heliosian-media"
refreshInterval = 5 * time.Minute
thumbWidth = 480
thumbSuffix = "-thumb"
thumbExt = ".jpg"
thumbMime = "image/jpeg"
fetchWorkers = 32
)
var folders = []string{"people", "families"}
type entry struct {
id string
mimeType string
data []byte
thumb []byte
name string
generation int64
mimeType string
data []byte
thumb []byte
}
type listed struct {
id string
mimeType string
type object struct {
name string
generation int64
mimeType string
}
type Store struct {
service *drive.Service
root string
bucket *storage.BucketHandle
mu sync.RWMutex
entries map[string]*entry
}
func New() (*Store, error) {
service, err := drive.NewService(context.Background(),
option.WithScopes(drive.DriveScope))
client, err := storage.NewClient(context.Background())
if err != nil {
return nil, err
return nil, fmt.Errorf("storage client: %w", err)
}
drives, err := service.Drives.List().Do()
if err != nil {
return nil, fmt.Errorf("list shared drives: %w", err)
}
if len(drives.Drives) != 1 {
return nil, fmt.Errorf("expected one shared drive visible to the service account, found %d", len(drives.Drives))
}
s := &Store{service: service, root: drives.Drives[0].Id, entries: map[string]*entry{}}
s := &Store{bucket: client.Bucket(Bucket), entries: map[string]*entry{}}
if err := s.refresh(); err != nil {
return nil, err
}
@@ -86,110 +86,77 @@ func (s *Store) refreshLoop() {
func (s *Store) refresh() error {
start := time.Now()
listing := map[string]listed{}
for _, folderName := range []string{"people", "families"} {
folderID, err := s.subfolder(folderName)
if err != nil {
return err
}
token := ""
ctx := context.Background()
primaries := map[string]object{}
thumbs := map[string]object{}
for _, folder := range folders {
it := s.bucket.Objects(ctx, &storage.Query{Prefix: folder + "/"})
for {
call := s.service.Files.List().
Q(fmt.Sprintf("'%s' in parents and trashed = false", folderID)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("nextPageToken, files(id, name, mimeType)").PageSize(1000)
if token != "" {
call = call.PageToken(token)
}
list, err := call.Do()
if err != nil {
return fmt.Errorf("list %s: %w", folderName, err)
}
for _, f := range list.Files {
if f.MimeType == folderMime {
continue
}
base := strings.TrimSuffix(f.Name, path.Ext(f.Name))
if strings.HasSuffix(base, "-thumb") {
continue
}
listing[folderName+"/"+base] = listed{id: f.Id, mimeType: f.MimeType}
}
if list.NextPageToken == "" {
attrs, err := it.Next()
if errors.Is(err, iterator.Done) {
break
}
token = list.NextPageToken
if err != nil {
return fmt.Errorf("list %s: %w", folder, err)
}
base := strings.TrimSuffix(path.Base(attrs.Name), path.Ext(attrs.Name))
o := object{name: attrs.Name, generation: attrs.Generation, mimeType: attrs.ContentType}
if strings.HasSuffix(base, thumbSuffix) {
thumbs[folder+"/"+strings.TrimSuffix(base, thumbSuffix)] = o
continue
}
key := folder + "/" + base
if existing, ok := primaries[key]; ok {
return fmt.Errorf("duplicate media for %s: %s and %s", key, existing.name, attrs.Name)
}
primaries[key] = o
}
}
s.mu.RLock()
missing := []string{}
for key, l := range listing {
if cached, ok := s.entries[key]; !ok || cached.id != l.id {
for key, o := range primaries {
if cached, ok := s.entries[key]; !ok || cached.generation != o.generation {
missing = append(missing, key)
}
}
s.mu.RUnlock()
fetched := map[string]*entry{}
var fetchedMu sync.Mutex
work := make(chan string)
errs := make(chan error, 1)
var mu sync.Mutex
var firstErr error
var wg sync.WaitGroup
for range 12 {
slots := make(chan struct{}, fetchWorkers)
for _, key := range missing {
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
for key := range work {
l := listing[key]
fetchStart := time.Now()
body, err := s.download(l.id)
if err == nil {
log.Printf("blob fetch: %s %d bytes in %s", key, len(body), time.Since(fetchStart).Round(time.Millisecond))
} else {
log.Printf("[ERROR] blob fetch: %s: %v", key, err)
}
if err == nil && strings.HasPrefix(l.mimeType, "image/") {
var thumb []byte
thumb, err = thumbnail(body)
if err == nil {
fetchedMu.Lock()
fetched[key] = &entry{id: l.id, mimeType: l.mimeType, data: body, thumb: thumb}
fetchedMu.Unlock()
continue
}
} else if err == nil {
fetchedMu.Lock()
fetched[key] = &entry{id: l.id, mimeType: l.mimeType, data: body}
fetchedMu.Unlock()
continue
}
select {
case errs <- fmt.Errorf("load %s: %w", key, err):
default:
defer func() { <-slots }()
e, err := s.load(ctx, primaries[key], thumbs[key])
mu.Lock()
defer mu.Unlock()
if err != nil {
if firstErr == nil {
firstErr = err
}
return
}
fetched[key] = e
}()
}
for _, key := range missing {
work <- key
}
close(work)
wg.Wait()
select {
case err := <-errs:
return err
default:
if firstErr != nil {
return firstErr
}
next := make(map[string]*entry, len(listing))
next := make(map[string]*entry, len(primaries))
var totalBytes int64
s.mu.Lock()
for key, l := range listing {
for key, o := range primaries {
if e, ok := fetched[key]; ok {
next[key] = e
} else if cached, ok := s.entries[key]; ok && cached.id == l.id {
} else if cached, ok := s.entries[key]; ok && cached.generation == o.generation {
next[key] = cached
}
}
@@ -203,33 +170,49 @@ func (s *Store) refresh() error {
return nil
}
func (s *Store) subfolder(name string) (string, error) {
return s.subfolderIn(s.root, name, false)
func (s *Store) load(ctx context.Context, primary, thumb object) (*entry, error) {
data, err := s.read(ctx, primary.name)
if err != nil {
return nil, err
}
e := &entry{name: primary.name, generation: primary.generation, mimeType: primary.mimeType, data: data}
if !strings.HasPrefix(primary.mimeType, "image/") {
return e, nil
}
if thumb.name == "" {
return nil, fmt.Errorf("no thumbnail stored for %s", primary.name)
}
e.thumb, err = s.read(ctx, thumb.name)
if err != nil {
return nil, err
}
return e, nil
}
func (s *Store) subfolderIn(parent, name string, create bool) (string, error) {
list, err := s.service.Files.List().
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, parent, folderMime)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("files(id)").Do()
func (s *Store) read(ctx context.Context, name string) ([]byte, error) {
r, err := s.bucket.Object(name).NewReader(ctx)
if err != nil {
return "", fmt.Errorf("find folder %s: %w", name, err)
return nil, fmt.Errorf("read %s: %w", name, err)
}
if len(list.Files) == 0 && create {
folder, err := s.service.Files.Create(&drive.File{
Name: name,
MimeType: folderMime,
Parents: []string{parent},
}).SupportsAllDrives(true).Fields("id").Do()
if err != nil {
return "", fmt.Errorf("create folder %s: %w", name, err)
}
return folder.Id, nil
defer r.Close()
content, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("read %s: %w", name, err)
}
if len(list.Files) != 1 {
return "", fmt.Errorf("expected one %s folder, found %d", name, len(list.Files))
return content, nil
}
func (s *Store) write(ctx context.Context, name, mimeType string, content []byte) error {
w := s.bucket.Object(name).NewWriter(ctx)
w.ContentType = mimeType
if _, err := w.Write(content); err != nil {
w.Close()
return fmt.Errorf("write %s: %w", name, err)
}
return list.Files[0].Id, nil
if err := w.Close(); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
func (s *Store) Refresh() error {
@@ -244,52 +227,37 @@ func (s *Store) Has(key string) bool {
}
func (s *Store) Upload(folder, base, ext, mimeType string, content []byte) (string, error) {
folderID, err := s.subfolder(folder)
if err != nil {
ctx := context.Background()
key := folder + "/" + base
name := key + "." + ext
s.mu.RLock()
existing, exists := s.entries[key]
s.mu.RUnlock()
if err := s.write(ctx, name, mimeType, content); err != nil {
return "", err
}
archived := ""
s.mu.RLock()
existing, exists := s.entries[folder+"/"+base]
s.mu.RUnlock()
if exists {
current, err := s.service.Files.Get(existing.id).SupportsAllDrives(true).Fields("name").Do()
if strings.HasPrefix(mimeType, "image/") {
thumb, err := Thumbnail(content)
if err != nil {
return "", fmt.Errorf("look up current %s/%s: %w", folder, base, err)
return "", fmt.Errorf("thumbnail %s: %w", name, err)
}
archiveID, err := s.subfolderIn(folderID, "archive", true)
if err != nil {
if err := s.write(ctx, key+thumbSuffix+thumbExt, thumbMime, thumb); err != nil {
return "", err
}
archived = strings.TrimSuffix(current.Name, path.Ext(current.Name)) +
"-" + time.Now().UTC().Format("20060102-150405") + path.Ext(current.Name)
_, err = s.service.Files.Update(existing.id, &drive.File{Name: archived}).
AddParents(archiveID).RemoveParents(folderID).SupportsAllDrives(true).Do()
if err != nil {
return "", fmt.Errorf("archive %s/%s: %w", folder, base, err)
}
}
_, err = s.service.Files.Create(&drive.File{
Name: base + "." + ext,
MimeType: mimeType,
Parents: []string{folderID},
}).SupportsAllDrives(true).Media(bytes.NewReader(content)).Do()
if err != nil {
return "", fmt.Errorf("upload %s/%s: %w", folder, base, err)
superseded := ""
if exists {
superseded = fmt.Sprint(existing.generation)
if existing.name != name {
if err := s.bucket.Object(existing.name).Delete(ctx); err != nil {
return "", fmt.Errorf("delete superseded %s: %w", existing.name, err)
}
}
}
if err := s.refresh(); err != nil {
return "", fmt.Errorf("refresh after upload: %w", err)
}
return archived, nil
}
func (s *Store) download(id string) ([]byte, error) {
resp, err := s.service.Files.Get(id).SupportsAllDrives(true).Download()
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
return superseded, nil
}
func (s *Store) serve(w http.ResponseWriter, r *http.Request) {
@@ -306,7 +274,7 @@ func (s *Store) serve(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Content-Type", thumbMime)
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(e.thumb))
return
}
@@ -314,7 +282,7 @@ func (s *Store) serve(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader(e.data))
}
func thumbnail(src []byte) ([]byte, error) {
func Thumbnail(src []byte) ([]byte, error) {
img, _, err := image.Decode(bytes.NewReader(src))
if err != nil {
return nil, err
+2 -2
View File
@@ -255,7 +255,7 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
base := local + "-" + kind
name := base + "." + ext
archived, err := u.store.Upload(folder, base, ext, mimeType, content)
superseded, err := u.store.Upload(folder, base, ext, mimeType, content)
if err != nil {
serverError(w, err)
return
@@ -264,7 +264,7 @@ func (u uploader) upload(w http.ResponseWriter, r *http.Request) {
serverError(w, fmt.Errorf("refresh model after upload: %w", err))
return
}
log.Printf("upload: %s set %s %s %s (archived %q)", me, target, key, name, archived)
log.Printf("upload: %s set %s %s %s (superseded generation %q)", me, target, key, name, superseded)
w.WriteHeader(http.StatusNoContent)
}
+180
View File
@@ -0,0 +1,180 @@
// Command migratemedia copies the drive media folders into the media bucket, generating thumbnails.
package main
import (
"context"
"fmt"
"io"
"log"
"path"
"strings"
"sync"
"time"
"cloud.google.com/go/storage"
"google.golang.org/api/drive/v3"
"google.golang.org/api/option"
"heliosian/internal/blob"
)
const (
folderMime = "application/vnd.google-apps.folder"
workers = 12
)
type source struct {
folder string
id string
name string
mimeType string
}
func main() {
ctx := context.Background()
drv, err := drive.NewService(ctx, option.WithScopes(drive.DriveReadonlyScope))
if err != nil {
log.Fatalf("[ERROR] drive client: %v", err)
}
client, err := storage.NewClient(ctx)
if err != nil {
log.Fatalf("[ERROR] storage client: %v", err)
}
bucket := client.Bucket(blob.Bucket)
drives, err := drv.Drives.List().Do()
if err != nil {
log.Fatalf("[ERROR] list shared drives: %v", err)
}
if len(drives.Drives) != 1 {
log.Fatalf("[ERROR] expected one shared drive, found %d", len(drives.Drives))
}
root := drives.Drives[0].Id
sources := []source{}
for _, folder := range []string{"people", "families"} {
folderID, err := subfolder(drv, root, folder)
if err != nil {
log.Fatalf("[ERROR] %v", err)
}
token := ""
for {
call := drv.Files.List().
Q(fmt.Sprintf("'%s' in parents and trashed = false", folderID)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("nextPageToken, files(id, name, mimeType)").PageSize(1000)
if token != "" {
call = call.PageToken(token)
}
list, err := call.Do()
if err != nil {
log.Fatalf("[ERROR] list %s: %v", folder, err)
}
for _, f := range list.Files {
if f.MimeType == folderMime {
continue
}
if strings.HasSuffix(strings.TrimSuffix(f.Name, path.Ext(f.Name)), "-thumb") {
continue
}
sources = append(sources, source{folder: folder, id: f.Id, name: f.Name, mimeType: f.MimeType})
}
if list.NextPageToken == "" {
break
}
token = list.NextPageToken
}
}
log.Printf("migrating %d files to gs://%s", len(sources), blob.Bucket)
start := time.Now()
var mu sync.Mutex
var copied, thumbed int
var bytesWritten int64
var failed []string
var wg sync.WaitGroup
slots := make(chan struct{}, workers)
for _, src := range sources {
wg.Add(1)
slots <- struct{}{}
go func() {
defer wg.Done()
defer func() { <-slots }()
n, thumb, err := migrate(ctx, drv, bucket, src)
mu.Lock()
defer mu.Unlock()
if err != nil {
log.Printf("[ERROR] %s/%s: %v", src.folder, src.name, err)
failed = append(failed, src.folder+"/"+src.name)
return
}
copied++
bytesWritten += n
if thumb {
thumbed++
}
}()
}
wg.Wait()
log.Printf("copied %d files (%d thumbnails, %.1f MB) in %s",
copied, thumbed, float64(bytesWritten)/1e6, time.Since(start).Round(time.Millisecond))
if len(failed) > 0 {
log.Fatalf("[ERROR] %d files failed: %s", len(failed), strings.Join(failed, " "))
}
}
func migrate(ctx context.Context, drv *drive.Service, bucket *storage.BucketHandle, src source) (int64, bool, error) {
resp, err := drv.Files.Get(src.id).SupportsAllDrives(true).Download()
if err != nil {
return 0, false, fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
content, err := io.ReadAll(resp.Body)
if err != nil {
return 0, false, fmt.Errorf("read: %w", err)
}
written := int64(len(content))
if err := write(ctx, bucket, src.folder+"/"+src.name, src.mimeType, content); err != nil {
return 0, false, err
}
if !strings.HasPrefix(src.mimeType, "image/") {
return written, false, nil
}
thumb, err := blob.Thumbnail(content)
if err != nil {
return 0, false, fmt.Errorf("thumbnail: %w", err)
}
base := strings.TrimSuffix(src.name, path.Ext(src.name))
if err := write(ctx, bucket, src.folder+"/"+base+"-thumb.jpg", "image/jpeg", thumb); err != nil {
return 0, false, err
}
return written + int64(len(thumb)), true, nil
}
func write(ctx context.Context, bucket *storage.BucketHandle, name, mimeType string, content []byte) error {
w := bucket.Object(name).NewWriter(ctx)
w.ContentType = mimeType
if _, err := w.Write(content); err != nil {
w.Close()
return fmt.Errorf("write %s: %w", name, err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("write %s: %w", name, err)
}
return nil
}
func subfolder(drv *drive.Service, parent, name string) (string, error) {
list, err := drv.Files.List().
Q(fmt.Sprintf("name = '%s' and '%s' in parents and mimeType = '%s' and trashed = false", name, parent, folderMime)).
SupportsAllDrives(true).IncludeItemsFromAllDrives(true).Corpora("allDrives").
Fields("files(id)").Do()
if err != nil {
return "", fmt.Errorf("find folder %s: %w", name, err)
}
if len(list.Files) != 1 {
return "", fmt.Errorf("expected one %s folder, found %d", name, len(list.Files))
}
return list.Files[0].Id, nil
}