diff --git a/.env.example b/.env.example index 40b8d40..eac7e8c 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,8 @@ PREPROCESS_TIMEOUT=120 REMBG_MODEL=u2net REMBG_ALPHA=1 NICE_LEVEL=18 + +# --- Upload API (organizer Android app) ---------------------------------- +# Shared secret for POST /api/jobs. Leave empty to disable uploads. +# Generate with: python3 -c "import secrets; print(secrets.token_urlsafe(32))" +UPLOAD_TOKEN= diff --git a/.gitignore b/.gitignore index 5e4e33b..3ea97d7 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,8 @@ htmlcov/ *~ .idea/ .vscode/ + +# Android +android/local.properties +android/.gradle/ +android/**/build/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e4b5f87..3ada715 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -3,21 +3,24 @@ ## Overview ``` -Phone/Syncthing Docker host -─────────────── ─────────── -incoming/ ──watch──► worker ──► variants/ + intermediates/ - │ │ - └── rembg + gmic pipeline - │ -meta/{stem}.json ◄──────────────────────┘ +Organizer phone Docker host +────────────── ─────────── +Share / pick + → JPEG 2000px + → POST /api/jobs ──► incoming/ ──watch──► worker + token │ + └── rembg + gmic pipeline + │ +meta/{stem}.json ◄───────────────────────────────────┘ │ - ▼ - web (FastAPI) ──► browser UI + remix POST + ├── web (HTML) ──► guest browsers + remix + └── GET /api/jobs/{id} ──► app polls, local notification, + save variants + intermediates ``` Two containers share one image (`Dockerfile`) and one bind-mounted data -directory (`DATA_HOST_DIR` → `/data`). Syncthing runs on the host, not in -compose. +directory (`DATA_HOST_DIR` → `/data`). The organizer app is in `android/`. +Syncthing is an optional fallback writer for `incoming/`, not required. ## Python modules @@ -26,24 +29,42 @@ compose. | `app/worker.py` | Poll `incoming/`, copy to `variants/`, run `process_job()` | | `app/pipeline.py` | gmic/rembg orchestration, meta/manifest I/O, variant compose | | `app/remix.py` | User-selected remix variant (one-shot compose + manifest append) | -| `app/main.py` | FastAPI routes, Jinja templates, static files | -| `app/config.py` | Environment → constants (paths, timeouts, tuning) | +| `app/main.py` | FastAPI HTML routes, Jinja templates, static files | +| `app/api.py` | JSON API: health, list, detail, authenticated upload | +| `app/jobs.py` | Shared job payloads, labels, inbox filenames | +| `app/config.py` | Environment → constants (paths, timeouts, `UPLOAD_TOKEN`) | | `app/rembg_cli.py` | Subprocess entry for rembg (avoids heavy `rembg[cli]` extra) | | `app/io_utils.py` | Atomic JSON read/write | | `app/env_utils.py` | Shared bool env parsing | | `app/logging_config.py` | Entry-point logging setup | +| `app/images.py` | On-demand WebP srcset cache | ## Data model -One **job** = one sanitized stem (from incoming filename). Flat files: +One **job** = one sanitized stem (unique upload name). Flat files: - `variants/{stem}_original.jpg` — preprocessed copy - `variants/{stem}_rembg.png` — background removal - `variants/{stem}_vN.png` — final composed images - `intermediates/{stem}_vN_*.png` — step images (kept for debugging) -- `meta/{stem}.json` — status + manifest (variants list, filter metadata) +- `meta/{stem}.json` — status + stage + progress + manifest - `processed.json` — worker bookkeeping (incoming path → handled) +Status values: `queued` → `processing` (stages `preprocess` / `rembg` / +`variant`) → `done` | `error`. + +## API + +| Method | Path | Auth | Notes | +|--------|------|------|-------| +| GET | `/api/health` | no | `{ok, upload}` | +| GET | `/api/jobs` | no | list + `active` flag | +| GET | `/api/jobs/{id}` | no | full payload + file URLs | +| POST | `/api/jobs` | Bearer / `X-Upload-Token` | multipart `file`, 202 + job summary | +| GET | `/jobs/{id}/files/{name}` | no | original file or `?w=` WebP | + +Empty `UPLOAD_TOKEN` → POST returns 503. Wrong token → 401. + ## Pipeline origin The gmic filter/blend chain is ported from an external reference script @@ -57,7 +78,8 @@ Production uses `u2net` + alpha matting via `app/rembg_cli.py` ## Known limitations (intentional) -- **No web auth** — anyone with the link sees all jobs (event tool). +- **No web login** — anyone with the link sees all jobs (event tool). +- **Upload token** — organizer app only; not an account system. - **Sequential worker** — one photo at a time; burst uploads queue. - **Meta JSON** — worker and web share `meta/*.json` via locked read-modify-write (`fcntl.flock`); variants are merged by id so @@ -73,7 +95,8 @@ ruff check app/ tests/ pytest -q ``` -Full stack (gmic/rembg): `docker compose up --build` with `DATA_HOST_DIR=./data`. +Android: see `android/README.md`. Full stack (gmic/rembg): +`docker compose up --build` with `DATA_HOST_DIR=./data`. Python **3.11** matches the Docker image; host 3.14+ cannot install pinned `onnxruntime` — use Docker or a 3.11 venv for parity. diff --git a/NOTES.md b/NOTES.md index 6a42a06..ac70d85 100644 --- a/NOTES.md +++ b/NOTES.md @@ -16,3 +16,29 @@ - Old path `/home/frank/live.f12.rocks/data` no longer used by compose (migration/cleanup manual) - Host port 12121 can be closed after cutover + +## Android upload (2026-08-27) + +- Syncthing is fallback only. Product path: organizer Android app + (`android/`) → `POST /api/jobs` with `UPLOAD_TOKEN` → worker as before. +- App polls JSON, local notification, saves to `Pictures/livef12/{job_id}/`. +- After deploy: set `UPLOAD_TOKEN` in server `.env`, recreate `web`. +- Sideload debug APK; no Play Store / ntfy. +- Leftover branch `fix/concurrency-and-cleanup` deleted (already in main). + +## Deploy 2026-07-16 + +- Live on boka: `/home/frank/live.f12.rocks` +- Web: https://live.f12.rocks +- Password / secrets in server `.env` only (`chmod 600`) +- First rembg job downloads ~176 MB model into docker volume `rembg_cache` + +## Syncthing cutover (2026-07-18) + +- SFTPGo removed; drop path is Syncthing +- Data share: `/home/frank/sync.schwenk.online/data/livef12` + (`incoming/` + `variants/` + `intermediates/` + `meta/` + `processed.json`) +- Flat phone-friendly names: `{stem}_original.jpg`, `{stem}_v1.png`, … +- Old path `/home/frank/live.f12.rocks/data` no longer used by compose + (migration/cleanup manual) +- Host port 12121 can be closed after cutover diff --git a/README.md b/README.md index cba0370..9b3d1f8 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,31 @@ # live.f12.rocks -Event photo pep pipeline: guests drop a photo into a Syncthing folder, a -worker runs it through `rembg` (background removal) + `gmic` (random -filter/blend compositing, ported from `make_random.py`), and a small web -UI shows the results with a "remix" option to try different filters on -the same photo. Finished jobs sync back via the same share. +Event photo pep pipeline: the organizer shares a photo into the Android +app, a worker runs it through `rembg` (background removal) + `gmic` +(random filter/blend compositing, ported from `make_random.py`), and a +small web UI shows the results with a "remix" option. Finished jobs +land on the phone (gallery + local notification) and on the public page. -No web auth. This is an event tool, not a DAM. +No web login. Upload uses a shared token in the organizer app. This is +an event tool, not a DAM. ## Services (`compose.yml`) | Service | What | Port | |----------|--------------------------------------------------|--------------------------| | `worker` | Watches `incoming/`, runs the compose pipeline | none published | -| `web` | FastAPI + Jinja UI, browse jobs, remix | via Traefik only | +| `web` | FastAPI HTML + JSON API, remix | via Traefik only | Both share one host directory (`DATA_HOST_DIR`) mounted at `/data`. -Syncthing runs on the host — not in this compose. +The organizer app lives in `android/`. Syncthing can still write +`incoming/` as a fallback — it is not in this compose. -## Data layout (Syncthing share) +## Data layout Prod path: `/home/frank/sync.schwenk.online/data/livef12` ``` -incoming/ # phone drop — worker only ever reads/copies from here +incoming/ # HTTP upload (and optional Syncthing drop) variants/ {stem}_original.jpg # preprocessed private copy {stem}_rembg.png @@ -32,28 +34,38 @@ intermediates/ {stem}_v1_bg_filtered.png {stem}_v1_post_0.png # … meta/ - {stem}.json # status + manifest for the web UI -processed.json # worker bookkeeping: which incoming files were handled + {stem}.json # status + stage + manifest +processed.json # worker bookkeeping ``` -Phone tip: `.stignore` can drop `meta/` and `processed.json` if you only -want images on the device. - `assets/` (filter lists + trimmed `filters.json`) lives in the repo and is bind-mounted read-only into `worker`/`web` at `/data/assets`. +## API + +| Method | Path | Auth | +|--------|------|------| +| `GET /api/health` | `{ok, upload}` | no | +| `GET /api/jobs` | job list | no | +| `GET /api/jobs/{id}` | status, variants, intermediates | no | +| `POST /api/jobs` | multipart `file` | `Authorization: Bearer ` | + +Empty `UPLOAD_TOKEN` disables uploads (fail closed). File download stays +on `GET /jobs/{id}/files/{name}`. + ## Running it ```bash cp .env.example .env +# set UPLOAD_TOKEN (python3 -c "import secrets; print(secrets.token_urlsafe(32))") # local: set DATA_HOST_DIR=./data -# prod: leave unset (defaults to the Syncthing share path) docker compose up -d --build ``` -Drop a photo into `incoming/` (via Syncthing or locally). After a few -seconds (poll interval + processing time) it shows up on the web UI; flat -files land in `variants/` and `intermediates/` for the phone. +Share a photo into the Android app, or drop one into `incoming/` locally. +After a few seconds it shows up on the web UI. + +Android build: [`android/README.md`](android/README.md). ## Development @@ -71,13 +83,14 @@ pytest -q # unit tests (no gmic/rembg) CI (`.gitea/workflows/ci.yml`): ruff + pytest on push/PR. -Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`. +Architecture notes: `ARCHITECTURE.md`. ## Configuration (`.env`, see `.env.example`) | Var | Default | Notes | |-----|---------|-------| -| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | Syncthing share root; use `./data` locally | +| `DATA_HOST_DIR` | `/home/frank/sync.schwenk.online/data/livef12` | data root; use `./data` locally | +| `UPLOAD_TOKEN` | empty | required for `POST /api/jobs`; empty disables upload | | `OUTPUT_COUNT` | `3` | Variants generated per uploaded photo | | `BLEND_OPACITY` | `30%` | Default blend opacity for auto-generated variants | | `FILTER_TIMEOUT` | `120` | Seconds before a single gmic call is killed | @@ -90,17 +103,13 @@ Architecture notes: `ARCHITECTURE.md`. Cleanup log: `CLEANUP_PLAN.md`. - **Firewall:** Traefik must already route `live.f12.rocks` — this repo only adds the router labels; it assumes the external `traefik` docker - network exists. No SFTP port needed. + network exists. - **rembg model download:** first background-removal call downloads the - `u2net` ONNX model (~176 MB) from GitHub. This needs outbound internet - on first run and can take a minute or two depending on the link; the - model is cached in the `rembg_cache` named volume afterwards, so - restarts don't re-download it. + `u2net` ONNX model (~176 MB) from GitHub. Cached in `rembg_cache`. - **Incoming is append-only:** the worker only ever copies out of - `incoming/` and never deletes or moves anything there — plan disk space - accordingly, or clean up `incoming/` manually between events. + `incoming/` and never deletes or moves anything there. - **Sequential processing:** the worker handles one photo at a time - (`cpus: "1.0"`, no concurrency) — fine for an event pace, but a burst of - uploads will just queue up and get processed in order. + (`cpus: "1.0"`, no concurrency). - Deploy itself (`docker compose up -d` on the server) is on you — this - repo doesn't run it automatically. + repo doesn't run it automatically. Remember `UPLOAD_TOKEN` in the + server `.env` or the app cannot upload. diff --git a/SOUL.md b/SOUL.md index 8addb9b..fbbde7c 100644 --- a/SOUL.md +++ b/SOUL.md @@ -6,31 +6,33 @@ ## One-Liner -Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web. Upload a -photo at the event, get back weird/fun filtered variants seconds later. +Event photo pep pipeline: organizer Android app -> gmic/rembg -> web. +Upload a photo at the event, get back weird/fun filtered variants seconds +later. ## Vision -At an event (f12 meetup/party), someone drops a photo into a Syncthing -folder from a phone/camera rig. A worker rips the background off, runs it -through a random gmic filter/blend chain, and a handful of variants show -up on a shared web page almost immediately — no app install, no login, no -waiting for someone to "process the photos later". If a variant is fun, -remix it with different filters right there. Finished jobs sync back to -the phone via the same Syncthing share. +At an event (f12 meetup/party), the organizer shares a photo into the +Android app. The phone converts it to a 2000px JPEG, uploads it, a worker +rips the background off, runs it through a random gmic filter/blend chain, +and a handful of variants show up on a shared web page almost immediately — +guests need no app, no login, no "process the photos later". If a variant +is fun, remix it with different filters right there. Finished jobs land on +the organizer's phone (gallery + local notification) and stay on the web +for everyone with the link. ## Audience -Event organizers and attendees at f12 events, on their phones, often on -bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link -(and whoever's syncing photos in) is the whole audience. +Event organizers (upload via the Android app) and attendees on their +phones, often on bad venue wifi. Nobody signs up, nobody logs in. Guests +with the link see everything; only the organizer's app can upload. ## Tone & Wording - **Voice:** direct, casual, no explaining-the-obvious. It's a fun tool for an event, not enterprise software. - **Formality:** du. Short German labels in the UI (Ansehen, Remix, - Download, Varianten, Zwischenschritte). + Download, Varianten, Zwischenschritte, Warteschlange, Fertig). - **Error messages:** say what broke, don't blame the visitor — most errors here are gmic filter flakiness, not user error. - **Forbidden words/phrases:** no corporate buzzwords, no "Erlebnis", @@ -42,7 +44,7 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link |---------|------|-----| | Success | `Remix erzeugt.` | `Dein Kunstwerk ist fertig! 🎉` | | Error | `Foreground-Filter fehlgeschlagen: Timeout nach 120s` | `Etwas ist schiefgelaufen.` | -| Empty state | `Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` | +| Empty state | `Noch keine Jobs. Foto in der App teilen, dann kurz warten.` | `Hier ist noch nichts los... lade doch was hoch! 😊` | ## Design @@ -54,46 +56,51 @@ bad venue wifi. Nobody signs up, nobody logs in. Whoever has the link ## Non-Goals -- No web auth / accounts / login product — anyone with the link sees - everything, by design, for this event tool. -- No camera-to-phone client in this repo — upload path is Syncthing - (phone/folder setup is out of band). +- No web auth / accounts / login product — anyone with the link **sees** + everything, by design. Upload is a separate shared token in the + organizer app, not a user account. +- No Play Store / FCM / ntfy dependency. Notifications are local Android. - No beamer/projector product built into this repo. - Not a DAM (digital asset manager) — no albums, tagging, search, retention policies. `incoming/`, `variants/`, and `intermediates/` are the whole data model. - No auto-deletion of incoming uploads. Ever. The worker only reads/copies. -- No Syncthing container in this compose — Syncthing runs on the host. +- No Syncthing container in this compose. A host Syncthing share can + still write `incoming/` as a fallback; it is not the product path. ## Infrastructure (project-local) - Workdir on server: `/home/frank/live.f12.rocks` -- Data volume (Syncthing share): `${DATA_HOST_DIR}` — prod default +- Data volume: `${DATA_HOST_DIR}` — prod default `/home/frank/sync.schwenk.online/data/livef12`, local override `./data`. Bind-mounted into `worker` and `web` at `/data` (see `compose.yml`). -- Layout under the share: - - `incoming/` — phone drop +- Layout: + - `incoming/` — HTTP upload (and optional Syncthing drop) - `variants/` — `{stem}_original.jpg`, `{stem}_rembg.png`, `{stem}_vN.png` - `intermediates/` — `{stem}_vN_*.png` (incl. `_post_0` …) - - `meta/` — web bookkeeping JSON (optional `.stignore` on phone) + - `meta/` — status + manifest JSON - `processed.json` - Domain: `live.f12.rocks`, routed via the shared external `traefik` network, TLS via `myresolver` (see `INFRASTRUCTURE.md`). -- rembg model cache lives in the named Docker volume `rembg_cache`, not - under the Syncthing share — container state, not event data. +- rembg model cache lives in the named Docker volume `rembg_cache`. +- Organizer Android app lives in `android/` (sideload APK). +- `UPLOAD_TOKEN` in server `.env` — required for `POST /api/jobs`. ## Project-Specific Rules ```markdown -- Never delete or move files under incoming/ from worker code — Syncthing - is the writer there. (reason: uploads are the one copy of the original - that exists outside a phone's camera roll) +- Never delete or move files under incoming/ from worker code — the + upload API (and optional Syncthing) is the writer there. (reason: + uploads are the one copy of the original that exists outside a + phone's camera roll) - Keep every intermediate image under intermediates/ — don't clean them up automatically. (reason: useful for debugging bad filter picks, disk is cheap compared to re-running gmic) - gmic/rembg subprocess calls always run under `nice` (see NICE_LEVEL) — the worker box needs to stay responsive for other things during an event. (reason: explicit requirement, avoid CPU starvation) +- POST /api/jobs is fail-closed: empty UPLOAD_TOKEN disables uploads. + The public web UI stays read-only. ``` --- diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..afb9dac --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,9 @@ +.gradle/ +**/build/ +local.properties +*.apk +*.aab +*.iml +.idea/ +captures/ +.cxx/ diff --git a/android/README.md b/android/README.md new file mode 100644 index 0000000..766f968 --- /dev/null +++ b/android/README.md @@ -0,0 +1,27 @@ +# Android organizer app + +Share a photo into **live.f12** (or pick from the gallery). The app resizes +to JPEG, longest edge 2000px, uploads to `POST /api/jobs`, then polls until +the worker is done. Variants and intermediates land in +`Pictures/livef12/{job_id}/`. A local notification fires when the job +finishes — no ntfy, no Play Services. + +## Build + +JDK 17, Android SDK 35. On this machine: + +```bash +export JAVA_HOME=/usr/lib/jvm/java-17-openjdk +export ANDROID_HOME=$HOME/Android/Sdk +cd android +./gradlew assembleDebug +adb install -r app/build/outputs/apk/debug/app-debug.apk +``` + +First launch: Settings → paste the `UPLOAD_TOKEN` from the server `.env`. +Default server URL is `https://live.f12.rocks`. + +## Share target + +Android share sheet → live.f12. That's the replacement for dropping a +file into a Syncthing folder. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..1a72a11 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") + id("org.jetbrains.kotlin.plugin.serialization") +} + +android { + namespace = "rocks.f12.live" + compileSdk = 35 + + defaultConfig { + applicationId = "rocks.f12.live" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0.0" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } +} + +dependencies { + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.5") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("androidx.work:work-runtime-ktx:2.10.0") + implementation("androidx.exifinterface:exifinterface:1.3.7") + implementation("androidx.core:core-ktx:1.15.0") + + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + implementation("io.coil-kt:coil-compose:2.7.0") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..d077f12 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# Keep rules unused while minify is off; ready if we ever shrink release. +-dontwarn okhttp3.** +-dontwarn okio.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7c9afc --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/rocks/f12/live/LiveF12App.kt b/android/app/src/main/java/rocks/f12/live/LiveF12App.kt new file mode 100644 index 0000000..4d1b9ef --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/LiveF12App.kt @@ -0,0 +1,11 @@ +package rocks.f12.live + +import android.app.Application +import rocks.f12.live.notify.AppNotifications + +class LiveF12App : Application() { + override fun onCreate() { + super.onCreate() + AppNotifications.ensureChannels(this) + } +} diff --git a/android/app/src/main/java/rocks/f12/live/MainActivity.kt b/android/app/src/main/java/rocks/f12/live/MainActivity.kt new file mode 100644 index 0000000..3115bff --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/MainActivity.kt @@ -0,0 +1,79 @@ +package rocks.f12.live + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import rocks.f12.live.ui.LiveF12Root + +class MainActivity : ComponentActivity() { + private var shareUri by mutableStateOf(null) + private var openJobId by mutableStateOf(null) + + private val pickImage = + registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> + if (uri != null) shareUri = uri + } + + private val askNotifications = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + requestNotificationPermission() + consumeIntent(intent) + setContent { + LiveF12Root( + shareUri = shareUri, + openJobId = openJobId, + onPickPhoto = { pickImage.launch("image/*") }, + onShareConsumed = { shareUri = null }, + onOpenJobConsumed = { openJobId = null }, + ) + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + consumeIntent(intent) + } + + private fun consumeIntent(intent: Intent?) { + if (intent == null) return + intent.getStringExtra(EXTRA_JOB_ID)?.let { openJobId = it } + if (intent.action == Intent.ACTION_SEND) { + val uri = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_STREAM) + } + if (uri != null) shareUri = uri + } + } + + private fun requestNotificationPermission() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return + val granted = + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + if (!granted) askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + companion object { + const val EXTRA_JOB_ID = "job_id" + } +} diff --git a/android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt b/android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt new file mode 100644 index 0000000..e156417 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/data/LiveF12Api.kt @@ -0,0 +1,105 @@ +package rocks.f12.live.data + +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit + +class ApiException(val code: Int, override val message: String) : IOException(message) + +class LiveF12Api( + private val client: OkHttpClient = defaultClient(), +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun health(baseUrl: String): HealthResponse { + val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/health").get().build() + return parse(call(request), HealthResponse.serializer()) + } + + fun listJobs(baseUrl: String): JobListResponse { + val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/jobs").get().build() + return parse(call(request), JobListResponse.serializer()) + } + + fun job(baseUrl: String, jobId: String): JobDetail { + val request = + Request.Builder() + .url("${baseUrl.trimEnd('/')}/api/jobs/${encode(jobId)}") + .get() + .build() + return parse(call(request), JobDetail.serializer()) + } + + fun upload(baseUrl: String, token: String, jpeg: File, filename: String = jpeg.name): JobSummary { + val body = + MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart( + "file", + filename, + jpeg.asRequestBody("image/jpeg".toMediaType()), + ) + .build() + val request = + Request.Builder() + .url("${baseUrl.trimEnd('/')}/api/jobs") + .header("Authorization", "Bearer $token") + .post(body) + .build() + return parse(call(request), JobSummary.serializer()) + } + + fun download(baseUrl: String, relativeOrAbsolute: String): ByteArray { + val url = resolve(baseUrl, relativeOrAbsolute) + val request = Request.Builder().url(url).get().build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw ApiException(response.code, "Download fehlgeschlagen (${response.code})") + } + return response.body?.bytes() ?: throw ApiException(response.code, "Leere Antwort") + } + } + + private fun parse(raw: String, serializer: kotlinx.serialization.DeserializationStrategy): T = + json.decodeFromString(serializer, raw) + + private fun call(request: Request): String { + client.newCall(request).execute().use { response -> + val body = response.body?.string().orEmpty() + if (!response.isSuccessful) { + val detail = runCatching { json.decodeFromString(body).detail }.getOrNull() + throw ApiException(response.code, detail ?: "HTTP ${response.code}") + } + return body + } + } + + companion object { + fun defaultClient(): OkHttpClient = + OkHttpClient.Builder() + .connectTimeout(20, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build() + + fun resolve(baseUrl: String, path: String): String { + if (path.startsWith("http://") || path.startsWith("https://")) return path + return baseUrl.trimEnd('/') + if (path.startsWith("/")) path else "/$path" + } + + private fun encode(jobId: String): String = java.net.URLEncoder.encode(jobId, Charsets.UTF_8.name()) + } +} + +@kotlinx.serialization.Serializable +private data class ErrorBody(val detail: String = "") diff --git a/android/app/src/main/java/rocks/f12/live/data/Models.kt b/android/app/src/main/java/rocks/f12/live/data/Models.kt new file mode 100644 index 0000000..96120ce --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/data/Models.kt @@ -0,0 +1,80 @@ +package rocks.f12.live.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class JobListResponse( + val jobs: List = emptyList(), + val active: Boolean = false, +) + +@Serializable +data class JobSummary( + @SerialName("job_id") val jobId: String, + val status: String, + @SerialName("status_label") val statusLabel: String = status, + val stage: String? = null, + @SerialName("stage_label") val stageLabel: String? = null, + val progress: JobProgress? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", + @SerialName("variant_count") val variantCount: Int = 0, + val thumbnail: String? = null, + @SerialName("thumbnail_url") val thumbnailUrl: String? = null, + val error: String? = null, +) + +@Serializable +data class JobProgress( + val current: Int = 0, + val total: Int = 0, +) + +@Serializable +data class FileRef( + val file: String, + val url: String, +) + +@Serializable +data class JobDetail( + @SerialName("job_id") val jobId: String, + val status: String, + @SerialName("status_label") val statusLabel: String = status, + val stage: String? = null, + @SerialName("stage_label") val stageLabel: String? = null, + val progress: JobProgress? = null, + @SerialName("created_at") val createdAt: String = "", + @SerialName("updated_at") val updatedAt: String = "", + @SerialName("variant_count") val variantCount: Int = 0, + val thumbnail: String? = null, + @SerialName("thumbnail_url") val thumbnailUrl: String? = null, + val error: String? = null, + val original: FileRef? = null, + val rembg: FileRef? = null, + @SerialName("has_rembg") val hasRembg: Boolean = false, + val variants: List = emptyList(), + val intermediates: List = emptyList(), + @SerialName("variant_errors") val variantErrors: List = emptyList(), + @SerialName("source_file") val sourceFile: String? = null, +) { + val isActive: Boolean get() = status == "queued" || status == "processing" || status == "pending" + val isTerminal: Boolean get() = status == "done" || status == "error" +} + +@Serializable +data class VariantRef( + val id: String? = null, + val file: String, + val url: String? = null, + val source: String? = null, + @SerialName("background_filter") val backgroundFilter: String? = null, + @SerialName("foreground_filter") val foregroundFilter: String? = null, +) + +@Serializable +data class HealthResponse( + val ok: Boolean = false, + val upload: Boolean = false, +) diff --git a/android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt b/android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt new file mode 100644 index 0000000..c06a9ac --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/data/SettingsRepository.kt @@ -0,0 +1,38 @@ +package rocks.f12.live.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore by preferencesDataStore(name = "settings") + +data class AppSettings( + val baseUrl: String, + val token: String, +) + +class SettingsRepository(private val context: Context) { + val settings: Flow = + context.dataStore.data.map { prefs -> + AppSettings( + baseUrl = prefs[KEY_BASE_URL]?.ifBlank { DEFAULT_BASE_URL } ?: DEFAULT_BASE_URL, + token = prefs[KEY_TOKEN].orEmpty(), + ) + } + + suspend fun update(baseUrl: String, token: String) { + context.dataStore.edit { prefs -> + prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/') + prefs[KEY_TOKEN] = token.trim() + } + } + + companion object { + const val DEFAULT_BASE_URL = "https://live.f12.rocks" + private val KEY_BASE_URL = stringPreferencesKey("base_url") + private val KEY_TOKEN = stringPreferencesKey("upload_token") + } +} diff --git a/android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt b/android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt new file mode 100644 index 0000000..778cd82 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/image/ImagePrep.kt @@ -0,0 +1,95 @@ +package rocks.f12.live.image + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import java.io.File +import java.io.FileOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +object ImagePrep { + const val MAX_EDGE = 2000 + const val JPEG_QUALITY = 92 + + fun prepare(context: Context, uri: Uri): File { + val resolver = context.contentResolver + val bounds = + resolver.openInputStream(uri)?.use { input -> + BitmapFactory.Options().also { + it.inJustDecodeBounds = true + BitmapFactory.decodeStream(input, null, it) + } + } ?: throw IllegalArgumentException("Bild nicht lesbar") + + val longEdge = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + val sample = sampleSize(longEdge, MAX_EDGE) + + val bitmap = + resolver.openInputStream(uri)?.use { input -> + val opts = BitmapFactory.Options().apply { inSampleSize = sample } + BitmapFactory.decodeStream(input, null, opts) + } ?: throw IllegalArgumentException("Bild nicht dekodierbar") + + val oriented = applyExif(resolver.openInputStream(uri), bitmap) + val scaled = scaleToMaxEdge(oriented, MAX_EDGE) + if (scaled !== oriented && oriented !== bitmap) { + oriented.recycle() + } + if (oriented !== bitmap && scaled !== bitmap) { + bitmap.recycle() + } + + val out = File(context.cacheDir, "upload-${System.currentTimeMillis()}.jpg") + FileOutputStream(out).use { stream -> + if (!scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, stream)) { + throw IllegalStateException("JPEG-Encode fehlgeschlagen") + } + } + if (scaled !== bitmap) scaled.recycle() + return out + } + + private fun sampleSize(longEdge: Int, maxEdge: Int): Int { + var sample = 1 + var size = longEdge + while (size / 2 >= maxEdge) { + sample *= 2 + size /= 2 + } + return sample + } + + private fun scaleToMaxEdge(src: Bitmap, maxEdge: Int): Bitmap { + val longEdge = max(src.width, src.height) + if (longEdge <= maxEdge) return src + val scale = maxEdge.toFloat() / longEdge + val w = (src.width * scale).roundToInt().coerceAtLeast(1) + val h = (src.height * scale).roundToInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(src, w, h, true) + } + + private fun applyExif(stream: java.io.InputStream?, bitmap: Bitmap): Bitmap { + if (stream == null) return bitmap + val orientation = + stream.use { input -> + ExifInterface(input).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL, + ) + } + val matrix = Matrix() + when (orientation) { + ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) + ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) + ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) + else -> return bitmap + } + return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + } +} diff --git a/android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt b/android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt new file mode 100644 index 0000000..3d030b1 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/media/GalleryStore.kt @@ -0,0 +1,65 @@ +package rocks.f12.live.media + +import android.content.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import rocks.f12.live.data.FileRef +import rocks.f12.live.data.JobDetail +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.data.VariantRef + +object GalleryStore { + fun saveJob(context: Context, api: LiveF12Api, baseUrl: String, job: JobDetail): Int { + val refs = mutableListOf() + job.original?.let(refs::add) + job.rembg?.let(refs::add) + job.variants.forEach { variant -> + refs.add(FileRef(variant.file, variant.url ?: "/jobs/${job.jobId}/files/${variant.file}")) + } + job.intermediates.forEach(refs::add) + var saved = 0 + refs.distinctBy { it.file }.forEach { ref -> + val bytes = api.download(baseUrl, ref.url) + write(context, job.jobId, ref.file, bytes) + saved += 1 + } + return saved + } + + private fun write(context: Context, jobId: String, filename: String, bytes: ByteArray) { + val mime = + when { + filename.endsWith(".png", ignoreCase = true) -> "image/png" + filename.endsWith(".webp", ignoreCase = true) -> "image/webp" + else -> "image/jpeg" + } + val values = + ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, filename) + put(MediaStore.Images.Media.MIME_TYPE, mime) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put( + MediaStore.Images.Media.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/livef12/$jobId", + ) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + val resolver = context.contentResolver + val uri = + resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: throw IllegalStateException("MediaStore insert fehlgeschlagen") + resolver.openOutputStream(uri)?.use { it.write(bytes) } + ?: throw IllegalStateException("MediaStore write fehlgeschlagen") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + } + + fun variantUrl(jobId: String, variant: VariantRef): String = + variant.url ?: "/jobs/$jobId/files/${variant.file}" +} diff --git a/android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt b/android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt new file mode 100644 index 0000000..f14be4e --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/notify/AppNotifications.kt @@ -0,0 +1,77 @@ +package rocks.f12.live.notify + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import rocks.f12.live.MainActivity +import rocks.f12.live.R + +object AppNotifications { + const val CHANNEL_PROGRESS = "progress" + const val CHANNEL_RESULT = "result" + const val PROGRESS_ID = 42 + + fun ensureChannels(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_PROGRESS, + "Jobs laufen", + NotificationManager.IMPORTANCE_LOW, + ).apply { setShowBadge(false) }, + ) + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_RESULT, + "Job fertig", + NotificationManager.IMPORTANCE_DEFAULT, + ), + ) + } + + fun progress(context: Context, jobId: String, text: String): android.app.Notification { + return NotificationCompat.Builder(context, CHANNEL_PROGRESS) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle("Job läuft") + .setContentText(text.ifBlank { jobId }) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(openJob(context, jobId)) + .build() + } + + fun result(context: Context, jobId: String, title: String, text: String, success: Boolean) { + val notification = + NotificationCompat.Builder(context, CHANNEL_RESULT) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(text) + .setAutoCancel(true) + .setContentIntent(openJob(context, jobId)) + .build() + try { + NotificationManagerCompat.from(context).notify(jobId.hashCode(), notification) + } catch (_: SecurityException) { + /* POST_NOTIFICATIONS denied */ + } + NotificationManagerCompat.from(context).cancel(PROGRESS_ID) + } + + private fun openJob(context: Context, jobId: String): PendingIntent { + val intent = + Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(MainActivity.EXTRA_JOB_ID, jobId) + } + return PendingIntent.getActivity( + context, + jobId.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt b/android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt new file mode 100644 index 0000000..9d631eb --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/LiveF12Root.kt @@ -0,0 +1,103 @@ +package rocks.f12.live.ui + +import android.net.Uri +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import rocks.f12.live.ui.screens.HomeScreen +import rocks.f12.live.ui.screens.HomeViewModel +import rocks.f12.live.ui.screens.JobScreen +import rocks.f12.live.ui.screens.JobViewModel +import rocks.f12.live.ui.screens.SettingsScreen +import rocks.f12.live.ui.screens.SettingsViewModel +import rocks.f12.live.ui.screens.UploadScreen +import rocks.f12.live.ui.screens.UploadViewModel +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.LiveF12Theme +import java.net.URLEncoder +import java.nio.charset.StandardCharsets + +@Composable +fun LiveF12Root( + shareUri: Uri?, + openJobId: String?, + onPickPhoto: () -> Unit, + onShareConsumed: () -> Unit, + onOpenJobConsumed: () -> Unit, +) { + LiveF12Theme { + Surface(Modifier.fillMaxSize(), color = Bg) { + val nav = rememberNavController() + val homeVm: HomeViewModel = viewModel() + val settingsVm: SettingsViewModel = viewModel() + val jobVm: JobViewModel = viewModel() + val uploadVm: UploadViewModel = viewModel() + val homeState by homeVm.state.collectAsStateWithLifecycle() + + LaunchedEffect(shareUri) { + val uri = shareUri ?: return@LaunchedEffect + val encoded = URLEncoder.encode(uri.toString(), StandardCharsets.UTF_8.name()) + nav.navigate("upload?uri=$encoded") + onShareConsumed() + } + LaunchedEffect(openJobId) { + val id = openJobId ?: return@LaunchedEffect + nav.navigate("job/$id") + onOpenJobConsumed() + } + + NavHost(navController = nav, startDestination = "home") { + composable("home") { + HomeScreen( + viewModel = homeVm, + onOpenJob = { nav.navigate("job/$it") }, + onSettings = { nav.navigate("settings") }, + onPickPhoto = onPickPhoto, + ) + } + composable("settings") { + SettingsScreen(viewModel = settingsVm, onBack = { nav.popBackStack() }) + } + composable( + "job/{jobId}", + arguments = listOf(navArgument("jobId") { type = NavType.StringType }), + ) { entry -> + val jobId = entry.arguments?.getString("jobId").orEmpty() + JobScreen( + jobId = jobId, + viewModel = jobVm, + baseUrl = homeState.settings.baseUrl, + onBack = { nav.popBackStack() }, + ) + } + composable( + "upload?uri={uri}", + arguments = listOf(navArgument("uri") { type = NavType.StringType }), + ) { entry -> + val raw = entry.arguments?.getString("uri").orEmpty() + val uri = Uri.parse(java.net.URLDecoder.decode(raw, StandardCharsets.UTF_8.name())) + UploadScreen( + uri = uri, + viewModel = uploadVm, + onDone = { jobId -> + nav.navigate("job/$jobId") { + popUpTo("home") + } + }, + onErrorBack = { nav.popBackStack() }, + ) + } + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..bd94ef8 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/HomeScreen.kt @@ -0,0 +1,163 @@ +package rocks.f12.live.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddPhotoAlternate +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import rocks.f12.live.data.JobSummary +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.AccentOn +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.BgElevated +import rocks.f12.live.ui.theme.Err +import rocks.f12.live.ui.theme.Ok +import rocks.f12.live.ui.theme.Pending +import rocks.f12.live.ui.theme.Text +import rocks.f12.live.ui.theme.TextDim + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HomeScreen( + viewModel: HomeViewModel, + onOpenJob: (String) -> Unit, + onSettings: () -> Unit, + onPickPhoto: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + Scaffold( + containerColor = Bg, + topBar = { + TopAppBar( + title = { Text("live.f12.rocks") }, + actions = { + IconButton(onClick = onSettings) { + Icon(Icons.Outlined.Settings, contentDescription = "Einstellungen") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Bg, + titleContentColor = Text, + actionIconContentColor = Text, + ), + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onPickPhoto, containerColor = Accent, contentColor = AccentOn) { + Icon(Icons.Outlined.AddPhotoAlternate, contentDescription = "Foto wählen") + } + }, + ) { padding -> + when { + state.settings.token.isBlank() -> { + Column(Modifier.padding(padding).padding(16.dp)) { + Text("Kein Token. In den Einstellungen den Upload-Token setzen.", color = Pending) + } + } + state.error != null && state.jobs.isEmpty() -> { + Column(Modifier.padding(padding).padding(16.dp)) { + Text(state.error ?: "", color = Err) + Text("Ziehen zum Neu laden passiert beim nächsten Poll.", color = TextDim, modifier = Modifier.padding(top = 8.dp)) + } + } + state.jobs.isEmpty() && !state.loading -> { + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + Text("Noch keine Jobs. Foto teilen oder + tippen.", color = TextDim) + } + } + else -> { + LazyVerticalGrid( + columns = GridCells.Adaptive(160.dp), + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(padding).fillMaxSize(), + ) { + items(state.jobs, key = { it.jobId }) { job -> + JobCard( + job = job, + baseUrl = state.settings.baseUrl, + onClick = { onOpenJob(job.jobId) }, + ) + } + } + } + } + } +} + +@Composable +private fun JobCard(job: JobSummary, baseUrl: String, onClick: () -> Unit) { + val statusColor = + when (job.status) { + "done" -> Ok + "error" -> Err + else -> Pending + } + Column( + modifier = + Modifier + .clip(RoundedCornerShape(8.dp)) + .background(BgElevated) + .clickable(onClick = onClick), + ) { + val thumb = job.thumbnailUrl?.let { LiveF12Api.resolve(baseUrl, it) } + if (thumb != null) { + AsyncImage( + model = thumb, + contentDescription = job.jobId, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black), + ) + } else { + Box( + Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black), + contentAlignment = Alignment.Center, + ) { + Text(job.statusLabel, color = statusColor) + } + } + Column(Modifier.padding(10.dp)) { + Text(job.jobId, color = Text, maxLines = 1) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(job.statusLabel, color = statusColor) + Text( + job.stageLabel?.takeIf { job.status != "done" } ?: "${job.variantCount} Varianten", + color = TextDim, + ) + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt new file mode 100644 index 0000000..d744256 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/JobScreen.kt @@ -0,0 +1,146 @@ +package rocks.f12.live.ui.screens + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import rocks.f12.live.data.FileRef +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.BgElevated +import rocks.f12.live.ui.theme.Err +import rocks.f12.live.ui.theme.Ok +import rocks.f12.live.ui.theme.Pending +import rocks.f12.live.ui.theme.Text +import rocks.f12.live.ui.theme.TextDim + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JobScreen( + jobId: String, + viewModel: JobViewModel, + baseUrl: String, + onBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(jobId) { viewModel.start(jobId) } + val job = state.job + val statusColor = + when (job?.status) { + "done" -> Ok + "error" -> Err + else -> Pending + } + + Scaffold( + containerColor = Bg, + topBar = { + TopAppBar( + title = { Text(jobId, maxLines = 1) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Bg, + titleContentColor = Text, + navigationIconContentColor = Text, + ), + ) + }, + ) { padding -> + if (job == null) { + Text( + state.error ?: "Lädt…", + color = if (state.error != null) Err else TextDim, + modifier = Modifier.padding(padding).padding(16.dp), + ) + return@Scaffold + } + + val files = buildList { + job.original?.let { add("Original" to it) } + job.rembg?.let { add("Rembg" to it) } + job.variants.forEach { v -> + add((v.id ?: "Variante") to FileRef(v.file, v.url ?: "/jobs/${job.jobId}/files/${v.file}")) + } + job.intermediates.forEach { add(it.file to it) } + } + + LazyVerticalGrid( + columns = GridCells.Adaptive(140.dp), + modifier = Modifier.padding(padding).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + item(span = { GridItemSpan(maxLineSpan) }) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("${job.statusLabel} · ${job.stageLabel ?: ""}", color = statusColor) + if (job.isActive && job.progress != null && job.progress.total > 0) { + LinearProgressIndicator( + progress = { job.progress.current.toFloat() / job.progress.total }, + modifier = Modifier.fillMaxWidth(), + color = Accent, + ) + } + job.error?.let { Text(it, color = Err) } + Button( + onClick = viewModel::saveToGallery, + enabled = !state.saving && job.variants.isNotEmpty(), + ) { + Text( + when { + state.saving -> "Speichert…" + state.savedCount != null -> "${state.savedCount} Dateien gespeichert" + else -> "Aufs Handy speichern" + }, + ) + } + } + } + items(files, key = { it.second.file }) { (label, ref) -> + Column( + Modifier.clip(RoundedCornerShape(8.dp)).background(BgElevated), + ) { + AsyncImage( + model = LiveF12Api.resolve(baseUrl, ref.url), + contentDescription = label, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxWidth().aspectRatio(1f), + ) + Text(label, color = TextDim, modifier = Modifier.padding(8.dp), maxLines = 2) + } + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..43c7579 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/SettingsScreen.kt @@ -0,0 +1,96 @@ +package rocks.f12.live.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.Bg +import rocks.f12.live.ui.theme.Ok +import rocks.f12.live.ui.theme.Text +import rocks.f12.live.ui.theme.TextDim + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel, + onBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + Scaffold( + containerColor = Bg, + topBar = { + TopAppBar( + title = { Text("Einstellungen") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück") + } + }, + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Bg, + titleContentColor = Text, + navigationIconContentColor = Text, + ), + ) + }, + ) { padding -> + Column( + Modifier + .padding(padding) + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text("Server und Token nur auf diesem Handy. Gäste brauchen das nicht.", color = TextDim) + OutlinedTextField( + value = state.baseUrl, + onValueChange = viewModel::setBaseUrl, + label = { Text("Server-URL") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + ) + OutlinedTextField( + value = state.token, + onValueChange = viewModel::setToken, + label = { Text("Upload-Token") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + ) + Button( + onClick = viewModel::save, + modifier = Modifier.fillMaxWidth().padding(top = 20.dp), + ) { + Text("Speichern") + } + OutlinedButton( + onClick = viewModel::probe, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) { + Text("Server prüfen") + } + if (state.saved) { + Text("Gespeichert.", color = Ok, modifier = Modifier.padding(top = 12.dp)) + } + state.probe?.let { Text(it, color = Accent, modifier = Modifier.padding(top = 8.dp)) } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt new file mode 100644 index 0000000..153698d --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/UploadScreen.kt @@ -0,0 +1,60 @@ +package rocks.f12.live.ui.screens + +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import rocks.f12.live.ui.theme.Accent +import rocks.f12.live.ui.theme.Err +import rocks.f12.live.ui.theme.Text + +@Composable +fun UploadScreen( + uri: Uri, + viewModel: UploadViewModel, + onDone: (String) -> Unit, + onErrorBack: () -> Unit, +) { + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(uri) { viewModel.start(uri) } + LaunchedEffect(state.jobId) { + state.jobId?.let(onDone) + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (state.phase) { + "error" -> + Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) { + Text(state.error ?: "Fehler", color = Err) + Text( + "Zurück", + color = Accent, + modifier = Modifier.padding(top = 16.dp).clickable(onClick = onErrorBack), + ) + } + else -> { + CircularProgressIndicator(color = Accent) + Text( + when (state.phase) { + "prepare" -> "JPEG, längste Seite 2000px…" + "upload" -> "Lädt hoch…" + else -> "Job angelegt…" + }, + color = Text, + modifier = Modifier.padding(top = 96.dp), + ) + } + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt b/android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt new file mode 100644 index 0000000..5110585 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/screens/ViewModels.kt @@ -0,0 +1,256 @@ +package rocks.f12.live.ui.screens + +import android.app.Application +import android.net.Uri +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import rocks.f12.live.data.AppSettings +import rocks.f12.live.data.JobDetail +import rocks.f12.live.data.JobSummary +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.data.SettingsRepository +import rocks.f12.live.image.ImagePrep +import rocks.f12.live.media.GalleryStore +import rocks.f12.live.work.JobSyncWorker + +data class HomeState( + val settings: AppSettings = AppSettings(SettingsRepository.DEFAULT_BASE_URL, ""), + val jobs: List = emptyList(), + val loading: Boolean = true, + val error: String? = null, +) + +class HomeViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(HomeState()) + val state: StateFlow = _state.asStateFlow() + private var poll: Job? = null + + init { + viewModelScope.launch { + settingsRepo.settings.collect { settings -> + _state.value = _state.value.copy(settings = settings) + } + } + refresh() + startPoll() + } + + fun refresh() { + viewModelScope.launch { + _state.value = _state.value.copy(loading = true, error = null) + runCatching { + withContext(Dispatchers.IO) { api.listJobs(_state.value.settings.baseUrl) } + }.onSuccess { response -> + _state.value = _state.value.copy(jobs = response.jobs, loading = false, error = null) + }.onFailure { err -> + _state.value = + _state.value.copy( + loading = false, + error = err.message ?: "Jobs konnten nicht geladen werden", + ) + } + } + } + + private fun startPoll() { + poll?.cancel() + poll = + viewModelScope.launch { + while (isActive) { + delay(4000) + val base = _state.value.settings.baseUrl + runCatching { withContext(Dispatchers.IO) { api.listJobs(base) } } + .onSuccess { response -> + _state.value = _state.value.copy(jobs = response.jobs, error = null) + } + } + } + } +} + +data class SettingsState( + val baseUrl: String = SettingsRepository.DEFAULT_BASE_URL, + val token: String = "", + val saved: Boolean = false, + val probe: String? = null, +) + +class SettingsViewModel(application: Application) : AndroidViewModel(application) { + private val repo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(SettingsState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { + repo.settings.collect { settings -> + _state.value = + _state.value.copy( + baseUrl = settings.baseUrl, + token = settings.token, + ) + } + } + } + + fun setBaseUrl(value: String) { + _state.value = _state.value.copy(baseUrl = value, saved = false) + } + + fun setToken(value: String) { + _state.value = _state.value.copy(token = value, saved = false) + } + + fun save() { + viewModelScope.launch { + repo.update(_state.value.baseUrl, _state.value.token) + _state.value = _state.value.copy(saved = true, probe = null) + } + } + + fun probe() { + viewModelScope.launch { + val url = _state.value.baseUrl.trim().trimEnd('/') + val result = + runCatching { withContext(Dispatchers.IO) { api.health(url) } } + .fold( + onSuccess = { health -> + if (health.upload) "Server ok, Upload aktiv." else "Server erreichbar, Upload nicht konfiguriert." + }, + onFailure = { err -> "Nicht erreichbar: ${err.message}" }, + ) + _state.value = _state.value.copy(probe = result) + } + } +} + +data class JobUiState( + val job: JobDetail? = null, + val loading: Boolean = true, + val error: String? = null, + val saving: Boolean = false, + val savedCount: Int? = null, +) + +class JobViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(JobUiState()) + val state: StateFlow = _state.asStateFlow() + private var poll: Job? = null + private var jobId: String? = null + + fun start(id: String) { + if (jobId == id && poll?.isActive == true) return + jobId = id + poll?.cancel() + _state.value = JobUiState(loading = true) + loadOnce(id) + poll = + viewModelScope.launch { + while (isActive) { + delay(4000) + val current = _state.value.job + if (current?.isTerminal == true) break + loadOnce(id) + } + } + } + + private fun loadOnce(id: String) { + viewModelScope.launch { + val base = settingsRepo.settings.first().baseUrl + runCatching { withContext(Dispatchers.IO) { api.job(base, id) } } + .onSuccess { job -> + _state.value = _state.value.copy(job = job, loading = false, error = null) + } + .onFailure { err -> + _state.value = + _state.value.copy( + loading = false, + error = err.message ?: "Job nicht geladen", + ) + } + } + } + + fun saveToGallery() { + val job = _state.value.job ?: return + viewModelScope.launch { + _state.value = _state.value.copy(saving = true) + val base = settingsRepo.settings.first().baseUrl + runCatching { + withContext(Dispatchers.IO) { GalleryStore.saveJob(getApplication(), api, base, job) } + }.onSuccess { count -> + _state.value = _state.value.copy(saving = false, savedCount = count, error = null) + }.onFailure { err -> + _state.value = + _state.value.copy( + saving = false, + error = err.message ?: "Speichern fehlgeschlagen", + ) + } + } + } +} + +data class UploadUiState( + val phase: String = "prepare", + val error: String? = null, + val jobId: String? = null, +) + +class UploadViewModel(application: Application) : AndroidViewModel(application) { + private val settingsRepo = SettingsRepository(application) + private val api = LiveF12Api() + private val _state = MutableStateFlow(UploadUiState()) + val state: StateFlow = _state.asStateFlow() + + private var startedFor: Uri? = null + + fun start(uri: Uri) { + if (startedFor == uri && _state.value.phase != "error") return + startedFor = uri + viewModelScope.launch { + _state.value = UploadUiState(phase = "prepare") + val settings = settingsRepo.settings.first() + if (settings.token.isBlank()) { + _state.value = UploadUiState(phase = "error", error = "Kein Upload-Token. Erst Einstellungen.") + return@launch + } + val file = + runCatching { + withContext(Dispatchers.IO) { ImagePrep.prepare(getApplication(), uri) } + }.onFailure { err -> + _state.value = UploadUiState(phase = "error", error = err.message ?: "Konvertierung fehlgeschlagen") + }.getOrNull() ?: return@launch + + _state.value = UploadUiState(phase = "upload") + val uploaded = + runCatching { + withContext(Dispatchers.IO) { + api.upload(settings.baseUrl, settings.token, file, file.name) + } + }.onFailure { err -> + _state.value = UploadUiState(phase = "error", error = err.message ?: "Upload fehlgeschlagen") + }.getOrNull() + file.delete() + if (uploaded == null) return@launch + + JobSyncWorker.enqueue(getApplication(), uploaded.jobId, settings.baseUrl) + _state.value = UploadUiState(phase = "done", jobId = uploaded.jobId) + } + } +} diff --git a/android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt b/android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt new file mode 100644 index 0000000..5f0e6ef --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/ui/theme/Theme.kt @@ -0,0 +1,38 @@ +package rocks.f12.live.ui.theme + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val Bg = Color(0xFF14161A) +val BgElevated = Color(0xFF1D2025) +val Border = Color(0xFF2C3038) +val Text = Color(0xFFE7E9EC) +val TextDim = Color(0xFF9AA0AA) +val Accent = Color(0xFFFF5A1F) +val AccentOn = Color(0xFF14161A) +val Ok = Color(0xFF4CAF6A) +val Err = Color(0xFFE05A4A) +val Pending = Color(0xFFD9A441) + +private val Colors: ColorScheme = + darkColorScheme( + primary = Accent, + onPrimary = AccentOn, + background = Bg, + onBackground = Text, + surface = BgElevated, + onSurface = Text, + surfaceVariant = Border, + onSurfaceVariant = TextDim, + error = Err, + onError = Text, + outline = Border, + ) + +@Composable +fun LiveF12Theme(content: @Composable () -> Unit) { + MaterialTheme(colorScheme = Colors, content = content) +} diff --git a/android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt b/android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt new file mode 100644 index 0000000..a36f858 --- /dev/null +++ b/android/app/src/main/java/rocks/f12/live/work/JobSyncWorker.kt @@ -0,0 +1,113 @@ +package rocks.f12.live.work + +import android.content.Context +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.delay +import rocks.f12.live.data.LiveF12Api +import rocks.f12.live.media.GalleryStore +import rocks.f12.live.notify.AppNotifications + +class JobSyncWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params) { + private val api = LiveF12Api() + + override suspend fun doWork(): Result { + val jobId = inputData.getString(KEY_JOB_ID) ?: return Result.failure() + val baseUrl = inputData.getString(KEY_BASE_URL) ?: return Result.failure() + + setForeground(foreground("Wartet auf Worker…", jobId)) + + var lastLabel = "" + repeat(MAX_POLLS) { _ -> + val job = + try { + api.job(baseUrl, jobId) + } catch (_: Exception) { + delay(POLL_MS) + return@repeat + } + val label = job.stageLabel ?: job.statusLabel + if (label != lastLabel) { + setForeground(foreground(label, jobId)) + lastLabel = label + } + when { + job.status == "done" -> { + val count = GalleryStore.saveJob(applicationContext, api, baseUrl, job) + AppNotifications.result( + applicationContext, + jobId, + "Job fertig", + "$count Dateien in Pictures/livef12", + success = true, + ) + return Result.success() + } + job.status == "error" -> { + AppNotifications.result( + applicationContext, + jobId, + "Job fehlgeschlagen", + job.error ?: "Pipeline-Fehler", + success = false, + ) + return Result.failure() + } + else -> delay(POLL_MS) + } + } + AppNotifications.result( + applicationContext, + jobId, + "Job hängt", + "Timeout nach Poll. In der App nachschauen.", + success = false, + ) + return Result.retry() + } + + private fun foreground(text: String, jobId: String): ForegroundInfo { + val notification = AppNotifications.progress(applicationContext, jobId, text) + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo( + AppNotifications.PROGRESS_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } else { + ForegroundInfo(AppNotifications.PROGRESS_ID, notification) + } + } + + companion object { + private const val KEY_JOB_ID = "job_id" + private const val KEY_BASE_URL = "base_url" + private const val POLL_MS = 4000L + private const val MAX_POLLS = 450 // ~30 min + + fun enqueue(context: Context, jobId: String, baseUrl: String) { + val request = + OneTimeWorkRequestBuilder() + .setInputData(workDataOf(KEY_JOB_ID to jobId, KEY_BASE_URL to baseUrl)) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .addTag("job-$jobId") + .build() + WorkManager.getInstance(context).enqueueUniqueWork( + "job-$jobId", + ExistingWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..85c7686 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..5bd3d56 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..5bd3d56 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..901c5b7 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #14161A + #FF5A1F + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3ca26d3 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + live.f12 + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fbc1b95 --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..70dfb63 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,6 @@ +plugins { + id("com.android.application") version "8.7.3" apply false + id("org.jetbrains.kotlin.android") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false + id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2318707 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..2c35211 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d22acce --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +networkTimeout=60000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..d95bf61 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..3f62c68 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "livef12" +include(":app") diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..37335a9 --- /dev/null +++ b/app/api.py @@ -0,0 +1,143 @@ +"""JSON API for the organizer Android app (upload + job status).""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import uuid +from pathlib import Path + +from fastapi import APIRouter, File, Header, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse + +from . import config, jobs, pipeline + +logger = logging.getLogger("livef12.api") + +router = APIRouter(prefix="/api") + +_CHUNK = 1024 * 1024 +_JSON_HEADERS = {"Cache-Control": "no-store"} + + +def _provided_token( + authorization: str | None, + x_upload_token: str | None, +) -> str: + if x_upload_token and x_upload_token.strip(): + return x_upload_token.strip() + if authorization: + scheme, _, rest = authorization.partition(" ") + if scheme.lower() == "bearer" and rest.strip(): + return rest.strip() + return "" + + +def _token_ok(provided: str) -> bool: + expected = config.UPLOAD_TOKEN + if not expected or not provided: + return False + # Hash first so compare_digest never sees unequal lengths. + left = hashlib.sha256(provided.encode("utf-8")).digest() + right = hashlib.sha256(expected.encode("utf-8")).digest() + return hmac.compare_digest(left, right) + + +def require_upload_token( + authorization: str | None, + x_upload_token: str | None, +) -> None: + if not config.UPLOAD_TOKEN: + raise HTTPException(status_code=503, detail="Upload ist nicht konfiguriert") + if not _token_ok(_provided_token(authorization, x_upload_token)): + raise HTTPException(status_code=401, detail="Ungültiger Upload-Token") + + +@router.get("/health") +def health() -> JSONResponse: + return JSONResponse( + {"ok": True, "upload": bool(config.UPLOAD_TOKEN)}, + headers=_JSON_HEADERS, + ) + + +@router.get("/jobs") +def api_list_jobs() -> JSONResponse: + items = jobs.list_jobs() + return JSONResponse( + { + "jobs": items, + "active": any(j["status"] in jobs.ACTIVE_STATUSES for j in items), + }, + headers=_JSON_HEADERS, + ) + + +@router.get("/jobs/{job_id}") +def api_job_detail(job_id: str) -> JSONResponse: + return JSONResponse(jobs.job_detail_payload(job_id), headers=_JSON_HEADERS) + + +@router.post("/jobs", status_code=202) +async def api_upload_job( + request: Request, + file: UploadFile = File(...), + authorization: str | None = Header(default=None), + x_upload_token: str | None = Header(default=None), +) -> JSONResponse: + require_upload_token(authorization, x_upload_token) + + original_name = file.filename or "photo.jpg" + dest_name = jobs.make_inbox_filename(original_name) + suffix = Path(dest_name).suffix.lower() + if suffix not in config.SUPPORTED_EXTENSIONS: + raise HTTPException(status_code=400, detail="Dateityp nicht unterstützt") + + config.INBOX_DIR.mkdir(parents=True, exist_ok=True) + config.META_DIR.mkdir(parents=True, exist_ok=True) + tmp = config.INBOX_DIR / f".upload_{uuid.uuid4().hex}" + dest = config.INBOX_DIR / dest_name + written = 0 + try: + with tmp.open("wb") as out: + while True: + chunk = await file.read(_CHUNK) + if not chunk: + break + written += len(chunk) + if written > config.MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"Datei zu groß (max {config.MAX_UPLOAD_BYTES // (1024 * 1024)} MB)", + ) + out.write(chunk) + if written == 0: + raise HTTPException(status_code=400, detail="Leere Datei") + tmp.replace(dest) + except HTTPException: + tmp.unlink(missing_ok=True) + raise + except OSError as exc: + tmp.unlink(missing_ok=True) + logger.exception("upload write failed") + raise HTTPException(status_code=500, detail="Upload konnte nicht gespeichert werden") from exc + finally: + await file.close() + + stem = pipeline.sanitize_stem(dest.stem) + paths = pipeline.job_paths(stem, suffix) + pipeline.write_status( + paths, + "queued", + source_file=dest.name, + source_stem=stem, + stage="queued", + progress={"current": 0, "total": config.OUTPUT_COUNT}, + error=None, + uploaded_from=request.client.host if request.client else None, + ) + logger.info("queued upload %s -> job %s (%d bytes)", dest.name, stem, written) + payload = jobs.summarize_job(stem) + payload["status"] = "queued" + return JSONResponse(payload, status_code=202, headers=_JSON_HEADERS) diff --git a/app/config.py b/app/config.py index c816677..4c60a0c 100644 --- a/app/config.py +++ b/app/config.py @@ -107,3 +107,7 @@ STABLE_WAIT_SECONDS = _float_env("STABLE_WAIT_SECONDS", 2) # --- Web --------------------------------------------------------------- SITE_TITLE = os.environ.get("SITE_TITLE", "live.f12.rocks") +# Shared secret for POST /api/jobs. Empty = upload API disabled (fail closed). +UPLOAD_TOKEN = os.environ.get("UPLOAD_TOKEN", "") +# Hard cap after client-side 2000px JPEG; 25 MB is generous for a bad encode. +MAX_UPLOAD_BYTES = _int_env("MAX_UPLOAD_BYTES", 25 * 1024 * 1024) diff --git a/app/images.py b/app/images.py index ca9ba4f..022046b 100644 --- a/app/images.py +++ b/app/images.py @@ -76,9 +76,7 @@ def _write_resized(source: Path, dest: Path, width: int) -> None: img = img.resize((width, new_h), Image.Resampling.LANCZOS) dest.parent.mkdir(parents=True, exist_ok=True) - with tempfile.NamedTemporaryFile( - dir=dest.parent, suffix=".webp", delete=False - ) as tmp: + with tempfile.NamedTemporaryFile(dir=dest.parent, suffix=".webp", delete=False) as tmp: tmp_path = Path(tmp.name) try: img.save(tmp_path, format="WEBP", quality=78, method=4) diff --git a/app/jobs.py b/app/jobs.py new file mode 100644 index 0000000..71e27e8 --- /dev/null +++ b/app/jobs.py @@ -0,0 +1,180 @@ +"""Job listing, labels, and JSON payloads shared by the HTML UI and /api.""" + +from __future__ import annotations + +import re +import uuid +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from fastapi import HTTPException + +from . import config, images, pipeline + +_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") + +STATUS_LABELS: dict[str, str] = { + "queued": "Warteschlange", + "processing": "Läuft", + "done": "Fertig", + "error": "Fehler", + "unknown": "unbekannt", +} + +ACTIVE_STATUSES = frozenset({"queued", "processing", "pending"}) + + +class JobIdError(ValueError): + """Invalid or missing job id.""" + + +def validate_job_id(job_id: str) -> str: + raw = job_id or "" + if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw: + raise JobIdError("Ungültige Job-ID") + stem = pipeline.sanitize_stem(raw) + if not _JOB_ID_RE.match(stem): + raise JobIdError("Ungültige Job-ID") + return stem + + +def require_job_id(job_id: str) -> str: + try: + stem = validate_job_id(job_id) + except JobIdError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists(): + raise HTTPException(status_code=404, detail="Job nicht gefunden") + return stem + + +def status_label(status: str | None) -> str: + key = (status or "unknown").strip() or "unknown" + return STATUS_LABELS.get(key, key) + + +def stage_label(status: str | None, stage: str | None, progress: dict[str, Any] | None) -> str: + if status == "queued": + return "Wartet auf Worker" + if status == "done": + return "Fertig" + if status == "error": + return "Fehler" + if stage == "preprocess": + return "Vorverarbeitung" + if stage == "rembg": + return "Freistellen" + if stage == "variant": + current = (progress or {}).get("current") + total = (progress or {}).get("total") + if current and total: + return f"Variante {current}/{total}" + return "Varianten" + if status == "processing": + return "Läuft" + return status_label(status) + + +def file_url(job_id: str, filename: str | None) -> str | None: + if not filename: + return None + return images.file_url(job_id, filename) + + +def list_intermediates(stem: str) -> list[str]: + paths = pipeline.job_paths(stem) + if not paths.intermediates.exists(): + return [] + return sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()) + + +def _progress(data: dict[str, Any]) -> dict[str, int] | None: + raw = data.get("progress") + if not isinstance(raw, dict): + return None + try: + current = int(raw.get("current", 0)) + total = int(raw.get("total", 0)) + except (TypeError, ValueError): + return None + if total <= 0: + return None + return {"current": current, "total": total} + + +def _file_entry(job_id: str, filename: str | None) -> dict[str, str] | None: + if not filename: + return None + return {"file": filename, "url": images.file_url(job_id, filename)} + + +def summarize_job(job_id: str, data: dict[str, Any] | None = None) -> dict[str, Any]: + meta = data if data is not None else (pipeline.read_meta(job_id) or {}) + variants = meta.get("variants") or [] + status = meta.get("status", "unknown") + progress = _progress(meta) + stage = meta.get("stage") + thumb = variants[-1].get("file") if variants else None + return { + "job_id": job_id, + "status": status, + "status_label": status_label(status), + "stage": stage, + "stage_label": stage_label(status, stage if isinstance(stage, str) else None, progress), + "progress": progress, + "created_at": meta.get("created_at") or "", + "updated_at": meta.get("updated_at") or "", + "variant_count": len(variants), + "thumbnail": thumb, + "thumbnail_url": file_url(job_id, thumb), + "error": meta.get("error"), + } + + +def list_jobs() -> list[dict[str, Any]]: + jobs = [summarize_job(job_id) for job_id in pipeline.list_job_ids()] + jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True) + return jobs + + +def job_detail_payload(job_id: str) -> dict[str, Any]: + stem = require_job_id(job_id) + meta = pipeline.read_meta(stem) or {} + paths = pipeline.job_paths(stem) + summary = summarize_job(stem, meta) + variants = [] + for item in meta.get("variants") or []: + if not isinstance(item, dict) or not item.get("file"): + continue + entry = dict(item) + entry["url"] = images.file_url(stem, item["file"]) + variants.append(entry) + intermediates = [_file_entry(stem, name) for name in list_intermediates(stem)] + original_name = paths.original.name if paths.original.exists() else meta.get("original_file") + rembg_name = paths.rembg.name if paths.rembg.exists() else meta.get("rembg_file") + summary.update( + { + "original": _file_entry(stem, original_name if original_name else None), + "rembg": _file_entry(stem, rembg_name if rembg_name else None), + "has_rembg": paths.rembg.exists(), + "variants": variants, + "intermediates": [item for item in intermediates if item], + "variant_errors": meta.get("variant_errors") or [], + "source_file": meta.get("source_file"), + } + ) + return summary + + +def make_inbox_filename(original_name: str) -> str: + """Unique inbox name so two party.jpg uploads don't clobber each other.""" + stem = pipeline.sanitize_stem(original_name) + suffix = Path(original_name).suffix.lower() + if suffix not in config.SUPPORTED_EXTENSIONS: + suffix = ".jpg" + if suffix == ".jpeg": + suffix = ".jpg" + stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + short = uuid.uuid4().hex[:6] + return f"{stamp}_{short}_{stem}{suffix}" diff --git a/app/main.py b/app/main.py index f35e356..ad094d4 100644 --- a/app/main.py +++ b/app/main.py @@ -3,9 +3,7 @@ from __future__ import annotations import logging -import re from pathlib import Path -from typing import Any from urllib.parse import quote from fastapi import FastAPI, Form, HTTPException, Query, Request @@ -13,40 +11,35 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from . import config, images, pipeline, remix +from . import api, config, images, jobs, pipeline, remix from .logging_config import configure_logging configure_logging() logger = logging.getLogger("livef12.web") app = FastAPI(title=config.SITE_TITLE) +app.include_router(api.router) BASE_DIR = Path(__file__).resolve().parent templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) templates.env.globals["img_attrs"] = images.img_attrs templates.env.globals["PAIR_SIZES"] = images.PAIR_SIZES templates.env.globals["GRID_SIZES"] = images.GRID_SIZES +templates.env.globals["status_label"] = jobs.status_label +templates.env.globals["stage_label"] = jobs.stage_label +templates.env.globals["ACTIVE_STATUSES"] = jobs.ACTIVE_STATUSES app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static") -# Job id = sanitized source stem (may include dots). -_JOB_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") - def _validate_job_id(job_id: str) -> str: - raw = job_id or "" - if not raw.strip() or "/" in raw or "\\" in raw or ".." in raw: - raise HTTPException(status_code=400, detail="Ungueltige Job-ID") - stem = pipeline.sanitize_stem(raw) - if not _JOB_ID_RE.match(stem): - raise HTTPException(status_code=400, detail="Ungueltige Job-ID") - return stem + try: + return jobs.validate_job_id(job_id) + except jobs.JobIdError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc def _job_exists(job_id: str) -> str: - stem = _validate_job_id(job_id) - if pipeline.read_status(stem) is None and not pipeline.job_paths(stem).original.exists(): - raise HTTPException(status_code=404, detail="Job nicht gefunden") - return stem + return jobs.require_job_id(job_id) def _safe_job_file(job_id: str, filename: str) -> Path: @@ -54,9 +47,9 @@ def _safe_job_file(job_id: str, filename: str) -> Path: stem = _validate_job_id(job_id) name = Path(filename).name if name != filename or "/" in filename or "\\" in filename or name.startswith("."): - raise HTTPException(status_code=400, detail="Ungueltiger Pfad") + raise HTTPException(status_code=400, detail="Ungültiger Pfad") if not name.startswith(f"{stem}_"): - raise HTTPException(status_code=400, detail="Ungueltiger Pfad") + raise HTTPException(status_code=400, detail="Ungültiger Pfad") for directory in (config.VARIANTS_DIR, config.INTERMEDIATES_DIR): candidate = (directory / name).resolve() @@ -67,30 +60,17 @@ def _safe_job_file(job_id: str, filename: str) -> Path: raise HTTPException(status_code=404, detail="Datei nicht gefunden") -def list_jobs() -> list[dict[str, Any]]: - jobs = [] - for job_id in pipeline.list_job_ids(): - data = pipeline.read_meta(job_id) or {} - variants = data.get("variants") or [] - jobs.append( - { - "job_id": job_id, - "status": data.get("status", "unknown"), - "created_at": data.get("created_at") or "", - "variant_count": len(variants), - "thumbnail": variants[-1].get("file") if variants else None, - } - ) - jobs.sort(key=lambda j: j["created_at"] or j["job_id"], reverse=True) - return jobs - - @app.get("/", response_class=HTMLResponse) def index(request: Request) -> HTMLResponse: + job_list = jobs.list_jobs() return templates.TemplateResponse( request, "index.html", - {"jobs": list_jobs(), "site_title": config.SITE_TITLE}, + { + "jobs": job_list, + "site_title": config.SITE_TITLE, + "has_active": any(j["status"] in jobs.ACTIVE_STATUSES for j in job_list), + }, ) @@ -100,11 +80,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse: paths = pipeline.job_paths(stem) status = pipeline.read_status(stem) or {} manifest = pipeline.read_manifest(stem) or {"variants": []} - intermediates = ( - sorted(p.name for p in paths.intermediates.glob(f"{stem}_*.png") if p.is_file()) - if paths.intermediates.exists() - else [] - ) + intermediates = jobs.list_intermediates(stem) return templates.TemplateResponse( request, @@ -118,6 +94,7 @@ def job_detail(request: Request, job_id: str) -> HTMLResponse: "rembg_name": paths.rembg.name if paths.rembg.exists() else None, "has_rembg": paths.rembg.exists(), "intermediates": intermediates, + "live": (status.get("status") or "") in jobs.ACTIVE_STATUSES, }, ) @@ -134,7 +111,7 @@ def job_file( if w not in images.SRCSET_WIDTHS: raise HTTPException( status_code=400, - detail=f"Ungueltige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})", + detail=f"Ungültige Breite (erlaubt: {', '.join(map(str, images.SRCSET_WIDTHS))})", ) try: cached = images.get_or_create_resized(path, w) @@ -154,7 +131,10 @@ def remix_form( stem = _job_exists(job_id) paths = pipeline.job_paths(stem) if not paths.rembg.exists(): - raise HTTPException(status_code=409, detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht moeglich.") + raise HTTPException( + status_code=409, + detail="Job hat noch kein Rembg-Ergebnis, Remix noch nicht möglich.", + ) assets = pipeline.load_assets() options = remix.build_remix_options(assets) diff --git a/app/pipeline.py b/app/pipeline.py index 576706a..fb1d6a1 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -396,6 +396,8 @@ def read_status(job_id: str) -> dict[str, Any] | None: return None return { "status": data.get("status", "unknown"), + "stage": data.get("stage"), + "progress": data.get("progress"), "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), "error": data.get("error"), @@ -628,7 +630,15 @@ def process_job( paths.intermediates.mkdir(parents=True, exist_ok=True) paths.meta.parent.mkdir(parents=True, exist_ok=True) - write_status(paths, "processing", source_file=str(source_path.name), source_stem=stem) + write_status( + paths, + "processing", + source_file=str(source_path.name), + source_stem=stem, + stage="preprocess", + progress={"current": 0, "total": config.OUTPUT_COUNT}, + error=None, + ) try: paths = preprocess_original(paths) @@ -643,6 +653,12 @@ def process_job( } write_manifest(paths, manifest) + write_status( + paths, + "processing", + stage="rembg", + progress={"current": 0, "total": config.OUTPUT_COUNT}, + ) ok, err = run_rembg(paths.original, paths.rembg) if not ok: raise PipelineError(f"rembg fehlgeschlagen: {err}") @@ -654,6 +670,12 @@ def process_job( for i in range(1, config.OUTPUT_COUNT + 1): variant_id = f"v{i}" + write_status( + paths, + "processing", + stage="variant", + progress={"current": i, "total": config.OUTPUT_COUNT}, + ) try: entry = compose_variant( paths, @@ -700,10 +722,17 @@ def process_job( if not manifest["variants"]: raise PipelineError("Keine Variante erfolgreich erzeugt: " + "; ".join(variant_errors)) - write_status(paths, "done", variant_errors=variant_errors) + done_count = len(manifest["variants"]) + write_status( + paths, + "done", + stage="done", + progress={"current": done_count, "total": config.OUTPUT_COUNT}, + variant_errors=variant_errors, + ) except PipelineError as exc: logger.error("[%s] job failed: %s", stem, exc) - write_status(paths, "error", error=str(exc)) + write_status(paths, "error", stage="error", error=str(exc)) except Exception as exc: # noqa: BLE001 - keep the worker loop alive logger.exception("[%s] unexpected error", stem) - write_status(paths, "error", error=f"Unerwarteter Fehler: {exc}") + write_status(paths, "error", stage="error", error=f"Unerwarteter Fehler: {exc}") diff --git a/app/static/app.js b/app/static/app.js new file mode 100644 index 0000000..0c43978 --- /dev/null +++ b/app/static/app.js @@ -0,0 +1,160 @@ +/* live.f12.rocks — live poll + lightbox gallery. No framework. */ +(function () { + "use strict"; + + var POLL_MS = 4000; + + function $(id) { + return document.getElementById(id); + } + + /* --- live reload while jobs run --- */ + + function fingerprint(payload) { + if (!payload) return ""; + if (payload.job_id) { + return [ + payload.status, + payload.stage, + payload.updated_at, + payload.variant_count, + (payload.intermediates || []).length, + payload.error || "", + ].join("|"); + } + var jobs = payload.jobs || []; + return jobs + .map(function (j) { + return [j.job_id, j.status, j.updated_at, j.variant_count, j.stage].join(":"); + }) + .join(","); + } + + function startLivePoll() { + if (!document.body.hasAttribute("data-live")) return; + var jobId = document.body.getAttribute("data-job-id"); + var url = jobId ? "/api/jobs/" + encodeURIComponent(jobId) : "/api/jobs"; + var last = ""; + var first = true; + + function tick() { + fetch(url, { headers: { Accept: "application/json" } }) + .then(function (r) { + if (!r.ok) throw new Error("poll " + r.status); + return r.json(); + }) + .then(function (data) { + var next = fingerprint(data); + if (first) { + last = next; + first = false; + return; + } + if (next !== last) { + window.location.reload(); + } + }) + .catch(function () { + /* venue wifi: try again next tick */ + }); + } + + tick(); + setInterval(tick, POLL_MS); + } + + /* --- lightbox with prev/next --- */ + + function startLightbox() { + var box = $("lightbox"); + var img = $("lightbox-img"); + var caption = $("lightbox-caption"); + if (!box || !img) return; + + var items = []; + var index = 0; + + function collect() { + items = Array.prototype.slice + .call(document.querySelectorAll("img.lightboxable")) + .map(function (el) { + return { + src: el.getAttribute("data-full-src") || el.currentSrc || el.src, + alt: el.alt || "", + }; + }); + } + + function show(i) { + if (!items.length) return; + index = (i + items.length) % items.length; + img.src = items[index].src; + img.alt = items[index].alt; + if (caption) caption.textContent = items[index].alt; + box.hidden = false; + document.body.classList.add("lightbox-open"); + } + + function close() { + box.hidden = true; + img.removeAttribute("src"); + document.body.classList.remove("lightbox-open"); + } + + document.addEventListener("click", function (e) { + var target = e.target; + if (!(target instanceof HTMLImageElement)) return; + if (!target.classList.contains("lightboxable")) return; + e.preventDefault(); + e.stopPropagation(); + collect(); + var full = target.getAttribute("data-full-src") || target.currentSrc || target.src; + var found = items.findIndex(function (it) { + return it.src === full; + }); + show(found >= 0 ? found : 0); + }); + + box.addEventListener("click", function (e) { + if (e.target === box || (e.target && e.target.classList.contains("lightbox-close"))) { + close(); + return; + } + if (e.target && e.target.classList.contains("lightbox-next")) { + show(index + 1); + } + if (e.target && e.target.classList.contains("lightbox-prev")) { + show(index - 1); + } + }); + + document.addEventListener("keydown", function (e) { + if (box.hidden) return; + if (e.key === "Escape") close(); + if (e.key === "ArrowRight") show(index + 1); + if (e.key === "ArrowLeft") show(index - 1); + }); + + var startX = 0; + box.addEventListener( + "touchstart", + function (e) { + if (e.changedTouches && e.changedTouches[0]) startX = e.changedTouches[0].screenX; + }, + { passive: true } + ); + box.addEventListener( + "touchend", + function (e) { + if (!e.changedTouches || !e.changedTouches[0]) return; + var dx = e.changedTouches[0].screenX - startX; + if (Math.abs(dx) < 40) return; + show(index + (dx < 0 ? 1 : -1)); + }, + { passive: true } + ); + } + + startLivePoll(); + startLightbox(); +})(); diff --git a/app/static/favicon.svg b/app/static/favicon.svg new file mode 100644 index 0000000..cd60fbd --- /dev/null +++ b/app/static/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/static/style.css b/app/static/style.css index c67164c..4613846 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -52,6 +52,17 @@ a:focus-visible { .site-header { padding: 1rem var(--gap); border-bottom: 1px solid var(--border); + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.75rem; +} + +.header-note { + color: var(--text-dim); + font-size: 0.8rem; + letter-spacing: 0.04em; + text-transform: uppercase; } .brand { @@ -147,6 +158,33 @@ h2 { } } +.job-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin: 0.5rem 0 1rem; +} + +.job-heading h1 { + margin: 0; +} + +.progress { + height: 4px; + background: var(--border); + border-radius: 999px; + overflow: hidden; + margin: -0.5rem 0 1rem; +} + +.progress span { + display: block; + height: 100%; + background: var(--accent); + min-width: 0; +} + .job-card { background: var(--bg-elevated); border: 1px solid var(--border); @@ -154,6 +192,27 @@ h2 { overflow: hidden; } +.job-card.is-live { + border-color: var(--pending); + animation: live-pulse 1.8s ease-in-out infinite; +} + +@keyframes live-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(217, 164, 65, 0); + } + 50% { + box-shadow: 0 0 0 3px rgba(217, 164, 65, 0.35); + } +} + +@media (prefers-reduced-motion: reduce) { + .job-card.is-live { + animation: none; + } +} + .job-card a { color: var(--text); display: block; @@ -390,4 +449,39 @@ body.lightbox-open { padding: 0.25rem 0.5rem; min-height: 2.75rem; min-width: 2.75rem; + z-index: 1; +} + +.lightbox-nav { + position: absolute; + top: 50%; + transform: translateY(-50%); + border: 0; + background: transparent; + color: var(--text); + font-size: 2.4rem; + line-height: 1; + cursor: pointer; + padding: 0.5rem 0.75rem; + min-height: 2.75rem; + min-width: 2.75rem; +} + +.lightbox-prev { + left: 0.25rem; +} + +.lightbox-next { + right: 0.25rem; +} + +.lightbox-caption { + position: absolute; + bottom: 0.75rem; + left: 1rem; + right: 1rem; + margin: 0; + text-align: center; + color: var(--text-dim); + font-size: 0.85rem; } diff --git a/app/templates/base.html b/app/templates/base.html index b1864c3..046f6d9 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -3,61 +3,30 @@ + + {% block title %}{{ site_title }}{% endblock %} + + - +
{% block content %}{% endblock %}
- + diff --git a/app/templates/index.html b/app/templates/index.html index 5bd9d50..b4b2fbc 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -2,28 +2,34 @@ {% block title %}{{ site_title }}{% endblock %} {% block content %} {% if not jobs %} -

Noch keine Jobs. Bild in den Sync-Ordner legen, dann kurz warten.

+

Noch keine Jobs. Foto in der App teilen, dann kurz warten.

{% else %}
    {% for job in jobs %} -
  • +
  • {% if job.thumbnail %} {% set img = img_attrs(job.job_id, job.thumbnail) %} - Variante von Job {{ job.job_id }} {% else %} - {{ job.status }} + {{ job.status_label }} {% endif %} {{ job.job_id }} - {{ job.status }} · {{ job.variant_count }} Varianten + + {{ job.status_label }} + {% if job.stage_label and job.status in ACTIVE_STATUSES %} + · {{ job.stage_label }} + {% else %} + · {{ job.variant_count }} Varianten + {% endif %} +
  • diff --git a/app/templates/job.html b/app/templates/job.html index 4fa8aa7..7be0689 100644 --- a/app/templates/job.html +++ b/app/templates/job.html @@ -3,8 +3,20 @@ {% block content %} -

    Job {{ job_id }}

    -

    Status: {{ status.status or "unbekannt" }}

    +
    +

    Job {{ job_id }}

    +

    + {{ status_label(status.status) }} + {% if status.status in ACTIVE_STATUSES %} + · {{ stage_label(status.status, status.stage, status.progress) }} + {% endif %} +

    +
    + {% if status.status in ACTIVE_STATUSES and status.progress and status.progress.total %} + + {% endif %} {% if status.error %}

    Fehler: {{ status.error }}

    {% endif %} @@ -28,6 +40,8 @@ Download + {% else %} +

    Original kommt, sobald der Worker greift.

    {% endif %} {% if has_rembg and rembg_name %} {% set img = img_attrs(job_id, rembg_name, sizes=PAIR_SIZES) %} diff --git a/app/templates/remix.html b/app/templates/remix.html index 027127d..c774871 100644 --- a/app/templates/remix.html +++ b/app/templates/remix.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% block title %}Remix {{ job_id }} — {{ site_title }}{% endblock %} {% block content %} - +

    Remix · Job {{ job_id }}

    {% if from_variant %} diff --git a/compose.yml b/compose.yml index c84e3af..21cc56c 100644 --- a/compose.yml +++ b/compose.yml @@ -1,12 +1,12 @@ -# live.f12.rocks — event photo pipeline: Syncthing drop -> gmic/rembg -> web. +# live.f12.rocks — event photo pipeline: Android upload -> gmic/rembg -> web. # -# worker + web share one host directory (Syncthing share root): +# worker + web share one host directory: # ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12} -> /data # worker watches /data/incoming, writes /data/variants + /data/intermediates -# web reads meta/ + image dirs, writes remix variants +# web serves HTML + /api (POST /api/jobs needs UPLOAD_TOKEN) # -# Syncthing itself runs on the host (not in this compose). Copy .env.example -# to .env for local overrides (e.g. DATA_HOST_DIR=./data). Never commit .env. +# Copy .env.example to .env for local overrides (DATA_HOST_DIR=./data, +# UPLOAD_TOKEN=...). Never commit .env. x-app-env: &app-env DATA_DIR: /data @@ -53,6 +53,7 @@ services: environment: <<: *app-env SITE_TITLE: live.f12.rocks + UPLOAD_TOKEN: ${UPLOAD_TOKEN:-} volumes: - ${DATA_HOST_DIR:-/home/frank/sync.schwenk.online/data/livef12}:/data - ./assets:/data/assets:ro diff --git a/pyproject.toml b/pyproject.toml index 61cc886..c1c91f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "livef12rocks" version = "0.1.0" -description = "Event photo pep pipeline: Syncthing drop -> gmic/rembg -> web" +description = "Event photo pep pipeline: Android upload -> gmic/rembg -> web" readme = "README.md" requires-python = ">=3.11,<3.12" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..4ba574c --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,135 @@ +"""Tests for JSON API upload and job payloads.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from app import config, pipeline +from app.main import app +from fastapi.testclient import TestClient +from PIL import Image + + +@pytest.fixture +def client(data_dir: Path) -> TestClient: + return TestClient(app) + + +@pytest.fixture +def upload_token(monkeypatch: pytest.MonkeyPatch) -> str: + token = "test-upload-token" + monkeypatch.setattr(config, "UPLOAD_TOKEN", token) + return token + + +def _jpeg_bytes(size: tuple[int, int] = (80, 60)) -> bytes: + from io import BytesIO + + buf = BytesIO() + Image.new("RGB", size, color=(12, 34, 56)).save(buf, format="JPEG", quality=85) + return buf.getvalue() + + +def test_health_reports_upload_disabled(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "UPLOAD_TOKEN", "") + response = client.get("/api/health") + assert response.status_code == 200 + assert response.json() == {"ok": True, "upload": False} + + +def test_upload_disabled_without_token(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(config, "UPLOAD_TOKEN", "") + response = client.post( + "/api/jobs", + files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")}, + ) + assert response.status_code == 503 + + +def test_upload_rejects_bad_token(client: TestClient, upload_token: str) -> None: + response = client.post( + "/api/jobs", + files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")}, + headers={"Authorization": "Bearer nope"}, + ) + assert response.status_code == 401 + + +def test_upload_accepts_bearer_and_queues(client: TestClient, upload_token: str, data_dir: Path) -> None: + response = client.post( + "/api/jobs", + files={"file": ("party.jpg", _jpeg_bytes(), "image/jpeg")}, + headers={"Authorization": f"Bearer {upload_token}"}, + ) + assert response.status_code == 202 + body = response.json() + assert body["status"] == "queued" + job_id = body["job_id"] + assert job_id.endswith("_party") + inbox = list(config.INBOX_DIR.glob("*_party.jpg")) + assert len(inbox) == 1 + assert not inbox[0].name.startswith(".") + meta = pipeline.read_meta(job_id) + assert meta is not None + assert meta["status"] == "queued" + + +def test_upload_accepts_x_upload_token_header(client: TestClient, upload_token: str) -> None: + response = client.post( + "/api/jobs", + files={"file": ("cam.png", _jpeg_bytes(), "image/png")}, + headers={"X-Upload-Token": upload_token}, + ) + assert response.status_code == 202 + assert response.json()["status"] == "queued" + + +def test_upload_rejects_empty_file(client: TestClient, upload_token: str) -> None: + response = client.post( + "/api/jobs", + files={"file": ("empty.jpg", b"", "image/jpeg")}, + headers={"Authorization": f"Bearer {upload_token}"}, + ) + assert response.status_code == 400 + + +def test_api_job_detail_and_list(client: TestClient, data_dir: Path) -> None: + stem = "photo01" + paths = pipeline.job_paths(stem) + paths.variants.mkdir(parents=True, exist_ok=True) + png = paths.variants / f"{stem}_v1.png" + Image.new("RGB", (32, 32), color=(1, 2, 3)).save(png, format="PNG") + pipeline.write_status( + paths, + "processing", + stage="variant", + progress={"current": 2, "total": 3}, + created_at="2026-01-01T00:00:00", + ) + pipeline.write_manifest( + paths, + { + "variants": [{"id": "v1", "file": png.name}], + "created_at": "2026-01-01T00:00:00", + }, + ) + + listed = client.get("/api/jobs") + assert listed.status_code == 200 + payload = listed.json() + assert payload["active"] is True + assert payload["jobs"][0]["job_id"] == stem + assert payload["jobs"][0]["status_label"] == "Läuft" + assert payload["jobs"][0]["stage_label"] == "Variante 2/3" + + detail = client.get(f"/api/jobs/{stem}") + assert detail.status_code == 200 + body = detail.json() + assert body["has_rembg"] is False + assert body["variants"][0]["url"].endswith(f"/jobs/{stem}/files/{png.name}") + assert body["progress"] == {"current": 2, "total": 3} + + +def test_api_job_missing(client: TestClient) -> None: + assert client.get("/api/jobs/does-not-exist").status_code == 404 diff --git a/tests/test_jobs.py b/tests/test_jobs.py new file mode 100644 index 0000000..562758e --- /dev/null +++ b/tests/test_jobs.py @@ -0,0 +1,54 @@ +"""Tests for job helpers (labels, inbox names, payloads).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from app import jobs, pipeline + + +def test_status_label_known_and_passthrough() -> None: + assert jobs.status_label("done") == "Fertig" + assert jobs.status_label("queued") == "Warteschlange" + assert jobs.status_label("weird") == "weird" + + +def test_stage_label_variant_progress() -> None: + label = jobs.stage_label("processing", "variant", {"current": 1, "total": 3}) + assert label == "Variante 1/3" + assert jobs.stage_label("queued", None, None) == "Wartet auf Worker" + assert jobs.stage_label("processing", "rembg", None) == "Freistellen" + + +def test_make_inbox_filename_unique_and_safe() -> None: + a = jobs.make_inbox_filename("party.jpg") + b = jobs.make_inbox_filename("party.jpg") + assert a != b + assert a.endswith("_party.jpg") + assert "/" not in a + jpeg = jobs.make_inbox_filename("IMG_0001.JPEG") + assert jpeg.lower().endswith(".jpg") + fallback = jobs.make_inbox_filename("noext") + assert fallback.endswith("_noext.jpg") + + +def test_validate_job_id_rejects_paths() -> None: + with pytest.raises(jobs.JobIdError): + jobs.validate_job_id("../evil") + assert jobs.validate_job_id("foo@bar") == "foo_bar" + + +def test_summarize_job_includes_labels(data_dir: Path) -> None: + paths = pipeline.job_paths("event01") + pipeline.write_status( + paths, + "processing", + stage="rembg", + progress={"current": 0, "total": 3}, + created_at="2026-01-01T00:00:00", + ) + summary = jobs.summarize_job("event01") + assert summary["status_label"] == "Läuft" + assert summary["stage_label"] == "Freistellen" + assert summary["variant_count"] == 0 diff --git a/tests/test_main.py b/tests/test_main.py index c583173..31dab8d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -93,7 +93,10 @@ def test_job_file_srcset_resize(client: TestClient, data_dir: Path) -> None: assert index.status_code == 200 assert f"/jobs/{stem}/files/{png.name}?w=640" in index.text assert "srcset=" in index.text - assert "data-full-src=" in index.text + + detail = client.get(f"/jobs/{stem}") + assert detail.status_code == 200 + assert "data-full-src=" in detail.text def test_img_attrs_builds_srcset() -> None: