Move media from Drive to a Cloud Storage bucket with stored thumbnails
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user