CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# CLAUDE.md
Personal Media Hub — private Photos / Videos / Drive for a LAN. FastAPI + SQLAlchemy 2 + Alembic + PostgreSQL backend (`backend/app`), React 19 + TS + Vite + Tailwind v4 frontend (`frontend/src`), Caddy + Docker Compose deployment. Docs in `docs/` (ARCHITECTURE, DEPLOYMENT, OPERATIONS, USER_GUIDE, TEST_REPORT). UI text is Traditional Chinese.
## Commands
```bash
./scripts/test.sh # backend pytest in the api:test image, DB mediahub_test (conftest refuses other DBs)
docker compose --profile test run --rm --user $(id -u):$(id -g) -v $PWD/backend:/app -e HOME=/tmp tests pytest # same, with live source (no rebuild)
cd frontend && npm run build # tsc -b + vite build (type-check)
docker compose up -d --build --wait # prod stack (127.0.0.1:443 by default; HUB_BIND in .env). .env sets COMPOSE_FILE=compose.yaml:compose.nextcloud.yaml → also Nextcloud on :9443
docker compose exec -u www-data nextcloud php occ <cmd>
docker compose exec api mediahub <create-admin|create-invite|reset-password|check [--fix]>
./scripts/backup.sh / ./scripts/restore.sh <dir>
```
E2E (`scripts/e2e_api.py`, `frontend/e2e`, Playwright) create users/files: run them only against a separate stack, e.g. `docker compose -p mediahub-e2e --env-file <e2e.env> up -d` with `HUB_HTTPS_PORT=8443`. Fixtures: `./scripts/make-test-media.sh`. The e2e env file and test passwords live in `.devdata/` (`e2e.env` with `HUB_IMAGE_TAG=e2e`, `e2e-admin.pw`) — not in /tmp, which a reboot clears; scripts that call compose need `E2E_ENV_FILE=.devdata/e2e.env`. WebKit needs `libavif16` on the host; run it in `mcr.microsoft.com/playwright:v1.63.0-noble` with `--network host` instead.
Snap Docker cannot see host `/tmp` — mount paths under `$HOME`/the repo.
## Architecture notes
- **blob vs file**: `blobs` = one physical original (`storage/originals/ab/cd/<uuid>`), `files` = user-visible index entry. Copies/albums reference the same blob; `services.gc_blobs` deletes physical files only when unreferenced, *after* commit via `services.flush_deletions(db)` — call it after committing any purge.
- **Authorization** lives in `app/access.py` (`get_file/get_folder/get_album(..., needed=view|edit|owner)`); every route must go through it. Missing and forbidden both return 404. Admins get no content access.
- **Trash**: `deleted_at` + `trashed_directly`; a folder's subtree shares the same `deleted_at` so restore can match it.
- **Uploads**: raw-body `PUT /api/drive/upload?name=&folder_id=` streamed to `tmp/` then `os.replace`; name races retried on the partial unique index.
- **Worker** (`app/worker.py`): DB-queue (`media_jobs`, `FOR UPDATE SKIP LOCKED`), lanes `fast` and `transcode`, heartbeats, re-queue of interrupted jobs; plus a `storage` loop running `storagenet.storage_tick` every 10 s.
- **Storage network** (`app/storagenet.py`, `app/routers/nodes.py`, `app/routers/storage_admin.py`): blobs can live on several `storage_nodes` (node `…0001` = this laptop). Always read originals via `storagenet.readable_path(blob_id)` (may fetch from a remote node), never `storage.original_path` directly. Remote copies are AES-256-GCM ciphertext (`app/crypto.py`, keys wrapped by `MEDIAHUB_MASTER_KEY`). Node protocol spec: `docs/NODE_PROTOCOL.md`; reference client: `backend/nodesim/` (pytest drives it in-process via TestClient). Simulated nodes need `MEDIAHUB_ALLOW_SIMULATED_NODES=true`. Real computer node: `backend/nodeagent/` (subclasses `SimNode`, pins the CA from `/node-api/v1/ca.crt`, wipes itself on `node_revoked`), built into `/app/dist/mediahub-node.pyz` by the backend Dockerfile; real-process E2E `scripts/agent_e2e.py` (installs/removes a systemd user unit — needs `XDG_RUNTIME_DIR`/`DBUS_SESSION_BUS_ADDRESS` from a non-login shell). The api reads the Caddy root cert from the `ca_public` volume, never `caddy_data`.
- **S3 gateway** (`app/s3gw.py`, separate Starlette app `uvicorn app.s3gw:app --port 9000`, service `s3gw` in `compose.nextcloud.yaml`): lets an unmodified Nextcloud use the storage network as primary object storage. Each S3 object (`s3_objects`) references a blob like a `File` does — `services.blob_in_use` counts both, so GC/`mediahub check` must go through it. SigV4 only; the deployment key (`MEDIAHUB_S3_ACCESS_KEY/SECRET_KEY`, full access, compose-internal) plus one scoped key per integration client (see Nextcloud integration). Tests: `tests/test_s3_gateway.py` (boto3), E2E `scripts/nextcloud_poc.py`, report `docs/NEXTCLOUD_POC.md`. Production Nextcloud (DEPLOYMENT §5.1): own origin via Caddy `deploy/caddy/nextcloud.caddy` (imported from `/etc/caddy/sites/`), settings re-applied each start by `deploy/nextcloud/hooks/`; backup/restore/check-lan scripts handle it when the `nextcloud` service exists. Containers cannot reach the host LAN IP (ufw), so Nextcloud self-checks time out.
- **Android storage-node app** (`android/`, Gradle 9 / AGP 9 / Kotlin 2.4, SDK in `~/Android/Sdk`): `:core` is the plain-Kotlin protocol engine (OkHttp; tested on the JVM, `LiveServerIT` against the test stack via `MEDIAHUB_IT_SERVER`/`MEDIAHUB_IT_ADMIN_PASSWORD`); `:app` is the Compose shell (Keystore-encrypted credential, `dataSync` foreground service + WorkManager fallback). Real-app E2E: `scripts/android_e2e.py` (emulator AVD `mh-node` or USB phone, `adb reverse tcp:8443`). Release: `scripts/build-android.sh` → `android/dist/mediahub-node.apk` (served to admins; signing key in `~/.config/mediahub/`, never in the repo; no R8 on purpose). Anything that must survive the pairing screen going away belongs in `NodeRepository`, not a UI coroutine scope. Stop Gradle daemons after builds (`./gradlew --stop`) — the laptop has 15 GB RAM.
- **Resumable uploads**: tus 1.0 at `/api/uploads` (`app/routers/uploads.py`); the frontend uses tus-js-client for files ≥ 8 MB.
- **Auth**: server-side sessions (cookie `mh_session`, SHA-256 in DB); all non-GET `/api` calls need header `X-Requested-With: mediahub` (CSRF) — the frontend client adds it.
- **i18n**: zh-TW source strings are the message ids. Backend: `app/i18n.py` (`tr()`, `EN` dict, `LocaleMiddleware` from Accept-Language); raise `ApiError`/`bad_request(detail, code, **params)` with a stable `code` — clients branch on the code, never the text. Frontend: i18next, `t("中文原文")` via `useTranslation()` in components, English in `src/i18n/en.json` generated by `src/i18n/build_en.py`; check with `python3 scripts/i18n_check.py`. Never translate user data (file/folder names, incl. the auto-created 照片/影片 folders). Account language: `users.locale`. Android: `res/values` (zh-TW, default) + `values-en`, `AppLocale.kt`; core returns `PairingError` codes.
- **Nextcloud integration** (`docs/nextcloud-integration/`): Nextcloud app `integrations/nextcloud/android_storage_nodes` (PHP + plain JS, `npm run build` → `js/` + `l10n/`, `scripts/package-nextcloud-app.sh`) talks to `/api/integrations/v1` (`app/routers/integrations.py`, bearer `mhi_…` per `integration_clients` row) and uses the S3 gateway with a per-client key limited to its `bucket_prefix` (also as CopyObject source). A client only sees nodes with its `client_id`; node operations reuse `storage_admin` functions (`start_drain`, `revoke_node`, `delete_node_record`, `make_pairing`). Remote Nextcloud needs `compose.gateway.yaml` (opt-in S3 TLS port). Tests: `tests/test_integrations.py`, `scripts/nextcloud_app_e2e.py` (46 checks, plain NC 33/34/35 containers on the e2e network), `scripts/nextcloud_app_ui.mjs`, `PAIR_VIA_NEXTCLOUD=… scripts/android_e2e.py`.
- **Media Hub Android app = client + storage node** (1.3.0): `android/cloud` (plain-Kotlin `HubClient`/`NextcloudClient`, pinned TLS; JVM tests incl. `CloudLiveIT` via `CLOUD_IT_HUB`/`CLOUD_IT_NC…`), app side `cloud/` (accounts in DataStore + `AccountSecrets` Keystore key, `UploadWorker`, `BackupWorker`) and `ui/cloud/`; tabs in `ui/AppRoot.kt` (the node UI is the "Storage" tab). Phone sign-in: `POST /api/me/device-logins` (QR) → `POST /api/auth/device-login` (`device_logins`, migration 0006). UI E2E: `ANDROID_SERIAL=emulator-5554 scripts/android_cloud_e2e.py` (refuses non-emulators).
- **Compose image tags**: images are `mediahub-api:${HUB_IMAGE_TAG:-latest}`; test env files must set `HUB_IMAGE_TAG=e2e` so test builds never replace production's `:latest`. This host had both apt and snap Docker (apt disabled 2026-09-25, see DEPLOYMENT §6).
- **Admin sudo mode**: sensitive admin routes depend on `deps.require_confirmed_admin` (403 `confirmation_required` if the password wasn't re-entered within `admin_confirm_minutes`); the frontend client prompts and retries.
- Schema changes: edit `models.py`, write the next `alembic/versions/000N_*.py`, then on the e2e stack run `alembic upgrade head` + `alembic check` (must say no new operations) inside the api container. Do **not** run `compose.dev.yaml` in the default project: that recreates the production `db` container.
## Style
Backend: ruff-clean (`uvx ruff check app --select F,E9,B --ignore B008`), 120-col. Frontend: no semicolons, double quotes, trailing commas, TanStack Query for server state, design tokens in `src/styles/index.css` (`bg-surface`, `text-label-2`, …) — don't hardcode colors.